mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d3328d1e9 | ||
|
|
97dbacffb3 | ||
|
|
b05e535740 | ||
|
|
f42694964c | ||
|
|
19575264fe | ||
|
|
835f316870 | ||
|
|
8734803475 | ||
|
|
111c77f877 | ||
|
|
4c742486cb | ||
|
|
e5f07ad24e | ||
|
|
40212dc0db | ||
|
|
4173b49b3a | ||
|
|
ae8e8a6bd9 |
No files matched your search
@@ -34,7 +34,7 @@ import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFrom
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from './domain/jmsDeepLink';
|
||||
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from './domain/ephemeralHosts';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
|
||||
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from './domain/credentials';
|
||||
import {
|
||||
mergeTerminalHostUpdate,
|
||||
TERMINAL_THEME_AUTO,
|
||||
@@ -664,7 +664,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: async () => {
|
||||
await applySyncPayload(payload, {
|
||||
const portable = stripSyncPayloadEncryptedCredentials(payload);
|
||||
await applySyncPayload(portable, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
|
||||
@@ -189,7 +189,7 @@ test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", a
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
[keyPath]: "enc:v1:djEwYWJj",
|
||||
[keyPath]: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
"/Users/alice/.ssh/id_rsa": "still-valid",
|
||||
}),
|
||||
);
|
||||
@@ -206,7 +206,7 @@ test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", a
|
||||
test("export read reports unavailable encrypted passphrases without deleting them", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
const encrypted = "enc:v1:djEwYWJj";
|
||||
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ [keyPath]: encrypted }),
|
||||
@@ -225,7 +225,7 @@ test("export read reports unavailable encrypted passphrases without deleting the
|
||||
test("export read retries when the passphrase changes during decryption", async (t) => {
|
||||
installLocalStorage(t);
|
||||
const keyPath = "/Users/alice/.ssh/id_ed25519";
|
||||
const encrypted = "enc:v1:djEwYWJj";
|
||||
const encrypted = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
|
||||
let releaseDecrypt: (() => void) | undefined;
|
||||
const decryptGate = new Promise<void>((resolve) => {
|
||||
releaseDecrypt = resolve;
|
||||
@@ -286,7 +286,7 @@ test("remembered passphrase read marks encrypted Keychain placeholders unreadabl
|
||||
installLocalStorage(t);
|
||||
const key = {
|
||||
...referenceKey(),
|
||||
passphrase: "enc:v1:djEwYWJj",
|
||||
passphrase: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
savePassphrase: true,
|
||||
};
|
||||
|
||||
@@ -311,7 +311,7 @@ test("passphrase verification reads every alias and preserves unreadable state",
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "readable-secret",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJj",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -455,7 +455,7 @@ test("loadDefaultKeyPassphrase cleanup preserves a passphrase saved concurrently
|
||||
});
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({ "/Users/alice/.ssh/id_old": "enc:v1:djEwYWJj" }),
|
||||
JSON.stringify({ "/Users/alice/.ssh/id_old": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==" }),
|
||||
);
|
||||
|
||||
const pendingLoad = loadDefaultKeyPassphrase("/Users/alice/.ssh/id_old");
|
||||
@@ -549,7 +549,7 @@ test("loadDefaultKeyPassphrase prefers an exact saved path over a stale alias",
|
||||
globalThis.localStorage.setItem(
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "enc:v1:djEwYWJj",
|
||||
"~/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
"/Users/alice/.ssh/id_ed25519": "valid-exact-passphrase",
|
||||
}),
|
||||
);
|
||||
@@ -574,7 +574,7 @@ test("loadDefaultKeyPassphrase falls back to a valid alias and removes an invali
|
||||
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
|
||||
JSON.stringify({
|
||||
"~/.ssh/id_ed25519": "valid-alias-passphrase",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJj",
|
||||
"/Users/alice/.ssh/id_ed25519": "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -913,7 +913,7 @@ test("shouldUpdateReferenceKeyPassphrase replaces missing or undecryptable passp
|
||||
assert.equal(
|
||||
shouldUpdateReferenceKeyPassphrase({
|
||||
...referenceKey(),
|
||||
passphrase: "enc:v1:djEwAAAA",
|
||||
passphrase: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -232,7 +232,7 @@ test("buildSftpHostCredentials rejects proxy identities with blank usernames eve
|
||||
label: "Proxy login",
|
||||
username: " ",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -285,7 +285,7 @@ test("buildSftpHostCredentials rejects incomplete proxy identities on jump hosts
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -368,7 +368,7 @@ test("buildSftpHostCredentials rejects undecryptable proxy identity passwords",
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -434,7 +434,7 @@ test("buildSftpHostCredentials rejects undecryptable jump host proxy identity pa
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -539,7 +539,7 @@ test("buildSftpHostCredentials rejects undecryptable saved password credentials"
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
@@ -553,7 +553,7 @@ test("buildSftpHostCredentials keeps automatic target discovery available with a
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({
|
||||
authMethod: "auto",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
}),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
@@ -569,7 +569,7 @@ test("buildSftpHostCredentials keeps automatic jump discovery available with an
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
authMethod: "auto",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
@@ -604,7 +604,7 @@ test("buildSftpHostCredentials rejects undecryptable saved key material without
|
||||
id: "key-1",
|
||||
label: "Imported key",
|
||||
type: "ED25519",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
@@ -626,7 +626,7 @@ test("buildSftpHostCredentials does not use stale local key paths when a selecte
|
||||
id: "key-1",
|
||||
label: "Imported key",
|
||||
type: "ED25519",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
@@ -653,7 +653,7 @@ test("buildSftpHostCredentials uses the system agent when a synced key cannot be
|
||||
label: "Synced key",
|
||||
type: "ED25519",
|
||||
publicKey: "ssh-ed25519 AAAASELECTED",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
|
||||
@@ -191,3 +191,48 @@ test("auto-sync skips only the exact remote-applied data hash", () => {
|
||||
"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",
|
||||
);
|
||||
});
|
||||
@@ -14,6 +14,8 @@ import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncMana
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
healPoisonedSecretsForMerge,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
@@ -435,13 +437,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
for (const result of resultList) {
|
||||
if (result.mergedPayload && !result.mergedPayloadApplied) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(portableMerged));
|
||||
if (result.remoteFile) {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
|
||||
recordDownload: true,
|
||||
});
|
||||
}
|
||||
skipNextSyncHashRef.current = allProvidersSynced ? getSyncPayloadDataHash(result.mergedPayload) : null;
|
||||
skipNextSyncHashRef.current = allProvidersSynced ? getSyncPayloadDataHash(portableMerged) : null;
|
||||
if (!allProvidersSynced) {
|
||||
console.warn('[AutoSync] Remote payload applied locally, but not every provider synced; leaving next auto-sync enabled for retry.');
|
||||
}
|
||||
@@ -707,7 +710,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
inspectedRemoteChange = true;
|
||||
|
||||
const remoteFile = inspection.remoteFile;
|
||||
const remotePayload = inspection.payload;
|
||||
const remoteRaw = inspection.payload;
|
||||
// Strip device-bound enc:v1 for download/apply paths so poisoned cloud
|
||||
// snapshots restore usable host shells. Smart-merge heals from local/base
|
||||
// separately below so good secrets are not treated as remote deletions.
|
||||
const remotePayload = stripSyncPayloadEncryptedCredentials(remoteRaw);
|
||||
const localPayload = await buildPayloadRef.current();
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
@@ -778,6 +785,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
if (conflictAction === 'upload-local') {
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(localPayload);
|
||||
if (encryptedCredentialPaths.length > 0) {
|
||||
console.warn(
|
||||
'[AutoSync] Startup local-wins blocked: encrypted credential placeholders found at:',
|
||||
encryptedCredentialPaths.join(', '),
|
||||
);
|
||||
throw new Error(tRef.current('sync.credentialsUnavailable'));
|
||||
}
|
||||
const pushResults = await manager.syncAllProviders(localPayload);
|
||||
const results = Array.from(pushResults.values());
|
||||
commitPluginSidecarsAfterSuccessfulSync(localPayload, results);
|
||||
@@ -797,15 +812,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error('Startup local-wins sync failed for one or more providers');
|
||||
}
|
||||
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
// Prefer usable secrets from the opposite side / base over enc:v1
|
||||
// placeholders before merge. Otherwise smart-merge can pick a "changed"
|
||||
// local entity whose only secret change is ciphertext, then strip +
|
||||
// upload and wipe usable cloud passwords. Same for remote-side poison.
|
||||
const localHealed = healPoisonedSecretsForMerge(localPayload, remoteRaw, base);
|
||||
const remoteHealed = healPoisonedSecretsForMerge(remoteRaw, localPayload, base);
|
||||
const mergeResult = mergeSyncPayloads(base, localHealed, remoteHealed);
|
||||
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
// throws, the next startup will re-run the merge with fresh data.
|
||||
await Promise.resolve(onApplyPayloadRef.current(mergeResult.payload));
|
||||
// Base is the last-agreed remote snapshot; `commitRemoteInspection`
|
||||
// stores remotePayload as the base so the next diff is computed
|
||||
// against what the cloud actually has, not against the merged
|
||||
// local-only state.
|
||||
const portableMerge = stripSyncPayloadEncryptedCredentials(mergeResult.payload);
|
||||
await Promise.resolve(onApplyPayloadRef.current(portableMerge));
|
||||
// Base is the last-agreed remote snapshot; store the portable remote
|
||||
// view so future diffs never treat device-bound enc:v1 as cloud truth.
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
startupConsistent = true;
|
||||
markCurrentDataSynced = false;
|
||||
@@ -826,9 +846,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// that only approximated the correct ordering.
|
||||
if (mergeResult.payload) {
|
||||
try {
|
||||
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
|
||||
const roundTripResults = await manager.syncAllProviders(portableMerge);
|
||||
const roundTripResultList = Array.from(roundTripResults.values());
|
||||
commitPluginSidecarsAfterSuccessfulSync(mergeResult.payload, roundTripResultList);
|
||||
commitPluginSidecarsAfterSuccessfulSync(portableMerge, roundTripResultList);
|
||||
const wasShrinkBlocked = roundTripResultList.some((r) => r.shrinkBlocked === true);
|
||||
const roundTripFullySynced = roundTripResultList.length > 0
|
||||
&& roundTripResultList.every((result) => result.success);
|
||||
|
||||
@@ -989,6 +989,10 @@ export const useVaultState = () => {
|
||||
),
|
||||
);
|
||||
setHosts(sanitized);
|
||||
// Always re-encrypt the batch. Stale enc:v1 placeholders are left
|
||||
// unchanged by encryptCredentialValue (no double-wrap); plaintext
|
||||
// siblings still need encryption when safeStorage was previously
|
||||
// unavailable for some records.
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
|
||||
@@ -212,13 +212,13 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:test",
|
||||
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
defaultModel: "gpt-test",
|
||||
enabled: true,
|
||||
}];
|
||||
const webSearch = {
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:web",
|
||||
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
maxResults: 7,
|
||||
};
|
||||
@@ -239,8 +239,11 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
|
||||
// Device-bound enc:v1 apiKeys are stripped from portable sync settings.
|
||||
const { apiKey: _providerKey, ...providerWithoutKey } = providers[0]!;
|
||||
const { apiKey: _webKey, ...webSearchWithoutKey } = webSearch;
|
||||
assert.deepEqual(payload.settings?.ai, {
|
||||
providers,
|
||||
providers: [providerWithoutKey],
|
||||
activeProviderId: "openai-main",
|
||||
activeModelId: "gpt-test",
|
||||
globalPermissionMode: "auto",
|
||||
@@ -251,7 +254,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
|
||||
maxIterations: 10,
|
||||
agentModelMap: { codex: "gpt-test" },
|
||||
agentProviderMap: { catty: "openai-main" },
|
||||
webSearchConfig: webSearch,
|
||||
webSearchConfig: webSearchWithoutKey,
|
||||
showTerminalSelectionAction: false,
|
||||
});
|
||||
});
|
||||
@@ -340,12 +343,12 @@ test("buildSyncPayload omits device-bound encrypted AI API keys", () => {
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwAAAA",
|
||||
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
}]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwAAAA",
|
||||
apiKey: "enc:v1:djEwQUFBQQAAAAAAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
@@ -360,8 +363,8 @@ test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sy
|
||||
value: {
|
||||
netcatty: {
|
||||
credentialsDecrypt: async (value: string) => {
|
||||
if (value === "enc:v1:djEwPROVIDER") return "sk-provider";
|
||||
if (value === "enc:v1:djEwWEB") return "sk-web";
|
||||
if (value === "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==") return "sk-provider";
|
||||
if (value === "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==") return "sk-web";
|
||||
return value;
|
||||
},
|
||||
},
|
||||
@@ -373,12 +376,12 @@ test("buildCloudSyncPayload includes decrypted AI API keys for portable cloud sy
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwPROVIDER",
|
||||
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
}]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
@@ -402,7 +405,7 @@ test("buildCloudSyncPayload fails instead of deleting API keys when decrypt fail
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwPROVIDER",
|
||||
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
}]));
|
||||
|
||||
@@ -417,12 +420,12 @@ test("applySyncPayload restores AI configuration settings", async () => {
|
||||
id: "anthropic-main",
|
||||
providerId: "anthropic",
|
||||
name: "Anthropic",
|
||||
apiKey: "enc:v1:test",
|
||||
apiKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
}];
|
||||
const webSearch = {
|
||||
providerId: "exa",
|
||||
apiKey: "enc:v1:web",
|
||||
apiKey: "enc:v1:djEwd2ViAAAAAAAAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
@@ -473,7 +476,12 @@ test("applySyncPayload encrypts synced plaintext AI API keys before saving local
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
credentialsEncrypt: async (value: string) => `enc:v1:djEwLOCAL_${value}`,
|
||||
credentialsEncrypt: async (value: string) => {
|
||||
const body = Buffer.alloc(19, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
Buffer.from(`LOCAL_${value}`.slice(0, 16), "utf8").copy(body, 3);
|
||||
return `enc:v1:${body.toString("base64")}`;
|
||||
},
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
},
|
||||
@@ -501,8 +509,20 @@ test("applySyncPayload encrypts synced plaintext AI API keys before saving local
|
||||
|
||||
const provider = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
|
||||
const webSearch = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
||||
assert.equal(provider.apiKey, "enc:v1:djEwLOCAL_sk-provider");
|
||||
assert.equal(webSearch.apiKey, "enc:v1:djEwLOCAL_sk-web");
|
||||
const expectedProviderKey = (() => {
|
||||
const body = Buffer.alloc(19, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
Buffer.from("LOCAL_sk-provider".slice(0, 16), "utf8").copy(body, 3);
|
||||
return `enc:v1:${body.toString("base64")}`;
|
||||
})();
|
||||
const expectedWebKey = (() => {
|
||||
const body = Buffer.alloc(19, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
Buffer.from("LOCAL_sk-web".slice(0, 16), "utf8").copy(body, 3);
|
||||
return `enc:v1:${body.toString("base64")}`;
|
||||
})();
|
||||
assert.equal(provider.apiKey, expectedProviderKey);
|
||||
assert.equal(webSearch.apiKey, expectedWebKey);
|
||||
});
|
||||
|
||||
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
|
||||
@@ -658,14 +678,14 @@ test("applySyncPayload preserves local AI provider apiKeys when synced payload o
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwLOCAL",
|
||||
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "anthropic-main",
|
||||
providerId: "anthropic",
|
||||
name: "Anthropic",
|
||||
apiKey: "enc:v1:djEwANTHROPIC",
|
||||
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
@@ -696,14 +716,14 @@ test("applySyncPayload preserves local AI provider apiKeys when synced payload o
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI (renamed)",
|
||||
apiKey: "enc:v1:djEwLOCAL",
|
||||
apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "anthropic-main",
|
||||
providerId: "anthropic",
|
||||
name: "Anthropic",
|
||||
apiKey: "enc:v1:djEwANTHROPIC",
|
||||
apiKey: "enc:v1:djEwQU5USFJPUElDAAAAAAAAAA==",
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
@@ -711,7 +731,7 @@ test("applySyncPayload preserves local AI provider apiKeys when synced payload o
|
||||
|
||||
test("applySyncPayload prefers explicit synced apiKey over local apiKey", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwLOCAL", enabled: true },
|
||||
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwTE9DQUwAAAAAAAAAAAAAAA==", enabled: true },
|
||||
]));
|
||||
|
||||
const payload: SyncPayload = {
|
||||
@@ -739,7 +759,7 @@ test("applySyncPayload prefers explicit synced apiKey over local apiKey", async
|
||||
test("applySyncPayload preserves local web-search apiKey when synced config omits it", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
maxResults: 7,
|
||||
}));
|
||||
@@ -763,7 +783,7 @@ test("applySyncPayload preserves local web-search apiKey when synced config omit
|
||||
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
|
||||
assert.deepEqual(stored, {
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
enabled: false,
|
||||
maxResults: 12,
|
||||
});
|
||||
@@ -772,7 +792,7 @@ test("applySyncPayload preserves local web-search apiKey when synced config omit
|
||||
test("applySyncPayload drops local web-search apiKey when synced config switches provider", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
@@ -1046,19 +1066,19 @@ test("buildLocalVaultPayload preserves local AI API keys for protective backups"
|
||||
id: "openai-main",
|
||||
providerId: "openai",
|
||||
name: "OpenAI",
|
||||
apiKey: "enc:v1:djEwPROVIDER",
|
||||
apiKey: "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
}]));
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
|
||||
providerId: "tavily",
|
||||
apiKey: "enc:v1:djEwWEB",
|
||||
apiKey: "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==",
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
const payload = buildLocalVaultPayload(vault([]));
|
||||
|
||||
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwPROVIDER");
|
||||
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwWEB");
|
||||
assert.equal(payload.settings?.ai?.providers?.[0]?.apiKey, "enc:v1:djEwUFJPVklERVIAAAAAAAAAAA==");
|
||||
assert.equal(payload.settings?.ai?.webSearchConfig?.apiKey, "enc:v1:djEwV0VCAAAAAAAAAAAAAAAAAA==");
|
||||
});
|
||||
|
||||
test("applySyncPayload ignores legacy cloud known hosts", async () => {
|
||||
@@ -1497,6 +1517,51 @@ test("prepareLocalVaultPayloadApply does not import until the prepared callback
|
||||
assert.deepEqual(calls, ["prepare", "import", "commit"]);
|
||||
});
|
||||
|
||||
test("prepareLocalVaultPayloadApply sanitizes enc:v1 before convergent prepare and apply", async () => {
|
||||
const completeBlob = Buffer.alloc(31, 0);
|
||||
Buffer.from("v10", "utf8").copy(completeBlob, 0);
|
||||
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
|
||||
const preparedPayloads: SyncPayload[] = [];
|
||||
const imported: Array<Record<string, unknown>> = [];
|
||||
|
||||
const applyPreparedPayload = await prepareLocalVaultPayloadApply({
|
||||
hosts: [{
|
||||
id: "h1",
|
||||
label: "prod",
|
||||
hostname: "prod.example",
|
||||
username: "root",
|
||||
password: ENC,
|
||||
port: 22,
|
||||
os: "linux",
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
}],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
}, {
|
||||
importVaultData: (json) => {
|
||||
imported.push(JSON.parse(json) as Record<string, unknown>);
|
||||
},
|
||||
}, {
|
||||
prepareConvergentRestore: async (payload) => {
|
||||
preparedPayloads.push(payload);
|
||||
return async () => undefined;
|
||||
},
|
||||
});
|
||||
|
||||
await applyPreparedPayload();
|
||||
|
||||
assert.equal(preparedPayloads.length, 1);
|
||||
assert.equal(preparedPayloads[0]?.hosts[0]?.password, undefined);
|
||||
assert.equal(imported.length, 1);
|
||||
const hosts = imported[0]?.hosts as Array<{ password?: string }> | undefined;
|
||||
assert.equal(hosts?.[0]?.password, undefined);
|
||||
});
|
||||
|
||||
test("applyLocalVaultPayload leaves local data untouched when convergent preparation fails", async () => {
|
||||
let imported = false;
|
||||
const payload: SyncPayload = {
|
||||
|
||||
+37
-27
@@ -32,7 +32,10 @@ import {
|
||||
parseCustomKeyBindingsStorageRecord,
|
||||
serializeCustomKeyBindingsStorageRecord,
|
||||
} from '../domain/customKeyBindings';
|
||||
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
|
||||
import {
|
||||
isEncryptedCredentialPlaceholder,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
} from '../domain/credentials';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { decryptField, encryptField } from '../infrastructure/persistence/secureFieldAdapter';
|
||||
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
@@ -1079,31 +1082,35 @@ function applyPayload(
|
||||
applyPluginSidecars?: PluginSyncSidecarApplier;
|
||||
},
|
||||
): Promise<void> {
|
||||
const legacyLineTimestampsEnabled = payload.settings?.terminalSettings?.showLineTimestamps === true;
|
||||
// Portable payloads must never keep device-bound enc:v1 blobs. Strip them
|
||||
// so a previously poisoned cloud/backup snapshot can still restore host
|
||||
// shells and let the user re-enter secrets (#2702).
|
||||
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
|
||||
const legacyLineTimestampsEnabled = sanitizedPayload.settings?.terminalSettings?.showLineTimestamps === true;
|
||||
// Build the vault import object. Cloud sync intentionally ignores
|
||||
// local-only trust records even if legacy cloud snapshots still carry them.
|
||||
const vaultImport: Record<string, unknown> = {
|
||||
hosts: migrateHostsFromLegacyLineTimestamps(payload.hosts, legacyLineTimestampsEnabled),
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
proxyProfiles: payload.proxyProfiles,
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
hosts: migrateHostsFromLegacyLineTimestamps(sanitizedPayload.hosts, legacyLineTimestampsEnabled),
|
||||
keys: sanitizedPayload.keys,
|
||||
identities: sanitizedPayload.identities,
|
||||
proxyProfiles: sanitizedPayload.proxyProfiles,
|
||||
snippets: sanitizedPayload.snippets,
|
||||
customGroups: sanitizedPayload.customGroups,
|
||||
};
|
||||
if (payload.snippetPackages !== undefined) {
|
||||
vaultImport.snippetPackages = payload.snippetPackages;
|
||||
if (sanitizedPayload.snippetPackages !== undefined) {
|
||||
vaultImport.snippetPackages = sanitizedPayload.snippetPackages;
|
||||
}
|
||||
if (payload.notes !== undefined) {
|
||||
vaultImport.notes = payload.notes;
|
||||
if (sanitizedPayload.notes !== undefined) {
|
||||
vaultImport.notes = sanitizedPayload.notes;
|
||||
}
|
||||
if (payload.noteGroups !== undefined) {
|
||||
vaultImport.noteGroups = payload.noteGroups;
|
||||
if (sanitizedPayload.noteGroups !== undefined) {
|
||||
vaultImport.noteGroups = sanitizedPayload.noteGroups;
|
||||
}
|
||||
if (options.includeLocalOnlyData && payload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = payload.knownHosts;
|
||||
if (options.includeLocalOnlyData && sanitizedPayload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = sanitizedPayload.knownHosts;
|
||||
}
|
||||
if (Array.isArray(payload.groupConfigs)) {
|
||||
vaultImport.groupConfigs = payload.groupConfigs;
|
||||
if (Array.isArray(sanitizedPayload.groupConfigs)) {
|
||||
vaultImport.groupConfigs = sanitizedPayload.groupConfigs;
|
||||
}
|
||||
|
||||
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
|
||||
@@ -1111,23 +1118,23 @@ function applyPayload(
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
// rules, clear local state".
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
if (sanitizedPayload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(sanitizedPayload.portForwardingRules);
|
||||
}
|
||||
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
await applySyncableSettings(payload.settings);
|
||||
if (sanitizedPayload.settings) {
|
||||
await applySyncableSettings(sanitizedPayload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
if (sanitizedPayload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
|
||||
// Plugin encrypted sidecars: only apply when the field is present so
|
||||
// legacy payloads without pluginSidecars do not wipe installed settings.
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
|
||||
if (Object.prototype.hasOwnProperty.call(sanitizedPayload, 'pluginSidecars')) {
|
||||
const applySidecars = options.applyPluginSidecars ?? defaultApplyPluginSyncSidecars;
|
||||
await applySidecars(payload.pluginSidecars ?? { version: 1, entries: [] });
|
||||
await applySidecars(sanitizedPayload.pluginSidecars ?? { version: 1, entries: [] });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1154,11 +1161,14 @@ export async function prepareLocalVaultPayloadApply(
|
||||
) => Promise<() => Promise<void>>;
|
||||
} = {},
|
||||
): Promise<() => Promise<void>> {
|
||||
// Sanitize once so vault import and convergent replica commit share the
|
||||
// same portable secrets view (no device-bound enc:v1 leftovers).
|
||||
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
|
||||
const prepareConvergentRestore = dependencies.prepareConvergentRestore
|
||||
?? prepareRestoredPayloadConvergentWrites;
|
||||
const commitConvergentRestore = await prepareConvergentRestore(payload);
|
||||
const commitConvergentRestore = await prepareConvergentRestore(sanitizedPayload);
|
||||
return async () => {
|
||||
await applyPayload(payload, importers, { includeLocalOnlyData: true });
|
||||
await applyPayload(sanitizedPayload, importers, { includeLocalOnlyData: true });
|
||||
await commitConvergentRestore();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ test("CSV credentials exclude an explicitly unsaved Keychain passphrase for a di
|
||||
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:djEwYWJj" })],
|
||||
[referenceKey({ savePassphrase: true, passphrase: "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==" })],
|
||||
async () => ({ values: [], unreadable: false, present: false }),
|
||||
);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
} from '../domain/credentials';
|
||||
import {
|
||||
isProviderReadyForSync,
|
||||
@@ -675,9 +676,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// requested provider fails, another provider can verify a newer
|
||||
// joined replica that must be applied before reporting the error.
|
||||
if (result.mergedPayload && !result.mergedPayloadApplied && onApplyPayload) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(portableMerged));
|
||||
if (result.remoteFile) {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
|
||||
recordDownload: true,
|
||||
});
|
||||
}
|
||||
@@ -703,13 +705,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// USE_REMOTE applies cloud data over local — same data-loss
|
||||
// shape as a local backup restore, so gate auto-sync in
|
||||
// every other window the same way.
|
||||
const portableRemote = stripSyncPayloadEncryptedCredentials(remoteResult.payload);
|
||||
await withRestoreBarrier(async () => {
|
||||
await Promise.resolve(onApplyPayload(remoteResult.payload));
|
||||
await Promise.resolve(onApplyPayload(portableRemote));
|
||||
});
|
||||
await sync.commitRemoteInspection(
|
||||
remoteResult.provider,
|
||||
remoteResult.remoteFile,
|
||||
remoteResult.payload,
|
||||
portableRemote,
|
||||
{ recordDownload: true },
|
||||
);
|
||||
toast.success(t('cloudSync.resolve.downloaded'));
|
||||
@@ -746,9 +749,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// reflects what's now on cloud (in case remote changed during the merge).
|
||||
for (const result of (results as Map<CloudProvider, SyncResult>).values()) {
|
||||
if (result.mergedPayload && !result.mergedPayloadApplied) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(portableMerged));
|
||||
if (result.remoteFile) {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
|
||||
recordDownload: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@ test("validateProxyConfigForSave rejects unreadable proxy identity passwords", (
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
@@ -459,7 +459,7 @@ test("prepareProxyConfigForSave returns identity errors before save handlers bui
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
}),
|
||||
|
||||
@@ -178,7 +178,7 @@ test("ProxyPanel warns when a saved proxy profile has an unreadable identity pas
|
||||
},
|
||||
}],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
identities: [{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
|
||||
identities: [{ ...proxyIdentity, password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }],
|
||||
});
|
||||
|
||||
assert.match(markup, /Proxy identity password cannot be read/);
|
||||
|
||||
@@ -193,7 +193,7 @@ test("prepareProxyProfileForSave rejects unreadable proxy identity passwords", (
|
||||
identityId: proxyIdentity.id,
|
||||
},
|
||||
},
|
||||
[{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
|
||||
[{ ...proxyIdentity, password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }],
|
||||
).error,
|
||||
"unreadableIdentity",
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { type Dispatch, type MutableRefObject, type SetStateAction } from
|
||||
import { AlertTriangle, Cloud, Database, Download, History, Key, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import type { CloudProvider, ConflictResolution, SyncPayload, SyncResult, WebDAVAuthType } from '../../domain/sync';
|
||||
import type { ShrinkFinding } from '../../domain/syncGuards';
|
||||
import { stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
|
||||
import type { useCloudSync } from '../../application/state/useCloudSync';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -901,9 +902,10 @@ export const CloudSyncDialogs: React.FC<CloudSyncDialogsProps> = ({
|
||||
// re-deleting the remote additions we just merged in.
|
||||
for (const result of results.values()) {
|
||||
if (result.mergedPayload && !result.mergedPayloadApplied) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
const portableMerged = stripSyncPayloadEncryptedCredentials(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(portableMerged));
|
||||
if (result.remoteFile) {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, result.mergedPayload, {
|
||||
await sync.commitRemoteInspection(result.provider, result.remoteFile, portableMerged, {
|
||||
recordDownload: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "./createTerminalSessionStarters";
|
||||
|
||||
const noop = () => undefined;
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";
|
||||
|
||||
const armSudoPrompt = (
|
||||
autofill: { armForCommand: (command: string) => void } | null,
|
||||
@@ -618,7 +618,7 @@ test("startMosh asks for credential re-entry when saved key material cannot be d
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
label: "Deploy key",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
}],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
@@ -699,7 +699,7 @@ test("startMosh does not use stale local key paths when selected key material is
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
label: "Deploy key",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
}],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./createTerminalSessionStarters";
|
||||
|
||||
const noop = () => undefined;
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";
|
||||
|
||||
test("startTelnet rejects missing saved proxy profiles", async () => {
|
||||
let started = false;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { pasteTextIntoTerminal } from "./terminalUserPaste";
|
||||
import { shouldSuppressHostStartupCommandOnReconnect } from "../restoredSessionGate";
|
||||
|
||||
const noop = () => undefined;
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==";
|
||||
|
||||
const armSudoPrompt = (
|
||||
autofill: { armForCommand: (command: string) => void } | null,
|
||||
@@ -665,7 +665,7 @@ test("startSSH uses the system agent when a synced vault key cannot be decrypted
|
||||
label: "Synced key",
|
||||
type: "ED25519",
|
||||
publicKey: "ssh-ed25519 AAAASELECTED",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
|
||||
@@ -132,6 +132,29 @@ test('envelope creation and hydration reject a materialized snapshot that disagr
|
||||
);
|
||||
});
|
||||
|
||||
test('poisoned enc:v1 secrets still round-trip through convergent envelope validation', () => {
|
||||
// Materialize must preserve device-bound ciphertext that already lives in the
|
||||
// CRDT + v1 snapshot pair. Stripping here would make decrypt/hydrate reject
|
||||
// the exact poisoned v2 clouds #2702 needs to recover from.
|
||||
const completeBlob = Buffer.alloc(19, 0);
|
||||
Buffer.from('v10', 'utf8').copy(completeBlob, 0);
|
||||
const ENC = `enc:v1:${completeBlob.toString('base64')}`;
|
||||
const poisoned = payload();
|
||||
poisoned.hosts = [{ ...poisoned.hosts[0]!, password: ENC }];
|
||||
poisoned.keys = [{ ...poisoned.keys[0]!, privateKey: ENC }];
|
||||
|
||||
const state = createConvergentSyncStateFromPayload(poisoned, 'device-a', NOW);
|
||||
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
|
||||
assert.equal(materialized.hosts[0]?.password, ENC);
|
||||
assert.equal(materialized.keys[0]?.privateKey, ENC);
|
||||
|
||||
const envelope = createConvergentSyncEnvelope(state, materialized);
|
||||
assert.equal(
|
||||
serializeConvergentSyncState(hydrateConvergentSyncEnvelope(envelope, materialized)),
|
||||
serializeConvergentSyncState(state),
|
||||
);
|
||||
});
|
||||
|
||||
test('envelope maps preserve prototype-like entity, field, setting, and string identifiers', () => {
|
||||
const specialObject = JSON.parse('{"id":"__proto__","constructor":"safe"}') as {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
healPoisonedSecretsForMerge,
|
||||
isEncryptedCredentialPlaceholder,
|
||||
stripSyncPayloadEncryptedCredentials,
|
||||
} from "./credentials.ts";
|
||||
import type { SyncPayload } from "./sync.ts";
|
||||
|
||||
const completeBlob = Buffer.alloc(19, 0);
|
||||
Buffer.from("v10", "utf8").copy(completeBlob, 0);
|
||||
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
|
||||
|
||||
function samplePayload(overrides: Partial<SyncPayload> = {}): SyncPayload {
|
||||
return {
|
||||
hosts: [
|
||||
{
|
||||
id: "h1",
|
||||
label: "prod",
|
||||
hostname: "prod.example",
|
||||
username: "root",
|
||||
password: ENC,
|
||||
port: 22,
|
||||
os: "linux",
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
},
|
||||
],
|
||||
keys: [
|
||||
{
|
||||
id: "k1",
|
||||
label: "key",
|
||||
type: "ED25519",
|
||||
privateKey: ENC,
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
},
|
||||
],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("isEncryptedCredentialPlaceholder detects complete v10 device-bound ciphertext", () => {
|
||||
assert.equal(isEncryptedCredentialPlaceholder(ENC), true);
|
||||
});
|
||||
|
||||
test("isEncryptedCredentialPlaceholder rejects intermediate v10 lengths that are neither CBC nor GCM", () => {
|
||||
const body = Buffer.alloc(24, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
assert.equal(isEncryptedCredentialPlaceholder(`enc:v1:${body.toString("base64")}`), false);
|
||||
});
|
||||
|
||||
test("isEncryptedCredentialPlaceholder detects real Windows DPAPI base64 prefixes", () => {
|
||||
const body = Buffer.from([
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0xd0, 0x8c, 0x9d, 0xdf, 0x01, 0x15, 0xd1, 0x11,
|
||||
0x8c, 0x7a, 0x00, 0xc0, 0x4f, 0xc2, 0x97, 0xeb,
|
||||
0xaa,
|
||||
]);
|
||||
const encoded = body.toString("base64");
|
||||
assert.equal(encoded.startsWith("AQAAANCMnd8"), true);
|
||||
assert.equal(isEncryptedCredentialPlaceholder(`enc:v1:${encoded}`), true);
|
||||
});
|
||||
|
||||
test("isEncryptedCredentialPlaceholder rejects header-only enc:v1 payloads", () => {
|
||||
assert.equal(isEncryptedCredentialPlaceholder("enc:v1:djEw"), false);
|
||||
});
|
||||
|
||||
test("findSyncPayloadEncryptedCredentialPaths reports host and key secrets", () => {
|
||||
const paths = findSyncPayloadEncryptedCredentialPaths(samplePayload());
|
||||
assert.deepEqual(paths, ["hosts[0].password", "keys[0].privateKey"]);
|
||||
});
|
||||
|
||||
test("stripSyncPayloadEncryptedCredentials clears device-bound placeholders for recovery", () => {
|
||||
const stripped = stripSyncPayloadEncryptedCredentials(samplePayload());
|
||||
assert.equal(stripped.hosts[0]?.password, undefined);
|
||||
assert.equal(stripped.keys[0]?.privateKey, "");
|
||||
assert.equal(findSyncPayloadEncryptedCredentialPaths(stripped).length, 0);
|
||||
});
|
||||
|
||||
test("healPoisonedSecretsForMerge keeps usable preferred passwords over poisoned enc:v1", () => {
|
||||
const poisoned = samplePayload();
|
||||
const preferred = samplePayload({
|
||||
hosts: [{
|
||||
...samplePayload().hosts[0]!,
|
||||
password: "preferred-secret",
|
||||
}],
|
||||
keys: [{
|
||||
...samplePayload().keys[0]!,
|
||||
privateKey: "PREFERRED_PRIVATE_KEY",
|
||||
}],
|
||||
});
|
||||
const fallback = samplePayload({
|
||||
hosts: [{
|
||||
...samplePayload().hosts[0]!,
|
||||
password: "base-secret",
|
||||
}],
|
||||
});
|
||||
const healed = healPoisonedSecretsForMerge(poisoned, preferred, fallback);
|
||||
assert.equal(healed.hosts[0]?.password, "preferred-secret");
|
||||
assert.equal(healed.keys[0]?.privateKey, "PREFERRED_PRIVATE_KEY");
|
||||
});
|
||||
|
||||
test("healPoisonedSecretsForMerge heals local poison from remote then base", () => {
|
||||
const local = samplePayload();
|
||||
const remote = samplePayload({
|
||||
hosts: [{
|
||||
...samplePayload().hosts[0]!,
|
||||
password: "remote-secret",
|
||||
}],
|
||||
keys: [{
|
||||
...samplePayload().keys[0]!,
|
||||
privateKey: ENC,
|
||||
}],
|
||||
});
|
||||
const base = samplePayload({
|
||||
keys: [{
|
||||
...samplePayload().keys[0]!,
|
||||
privateKey: "BASE_PRIVATE_KEY",
|
||||
}],
|
||||
});
|
||||
const healed = healPoisonedSecretsForMerge(local, remote, base);
|
||||
assert.equal(healed.hosts[0]?.password, "remote-secret");
|
||||
assert.equal(healed.keys[0]?.privateKey, "BASE_PRIVATE_KEY");
|
||||
});
|
||||
|
||||
test("healPoisonedSecretsForMerge preserves explicit preferred credential deletions", () => {
|
||||
const poisoned = samplePayload({
|
||||
hosts: [{
|
||||
...samplePayload().hosts[0]!,
|
||||
label: "renamed-on-poisoned-device",
|
||||
password: ENC,
|
||||
}],
|
||||
});
|
||||
const preferred = samplePayload({
|
||||
hosts: [{
|
||||
...samplePayload().hosts[0]!,
|
||||
label: "renamed-on-poisoned-device",
|
||||
password: undefined,
|
||||
}],
|
||||
});
|
||||
const fallback = samplePayload({
|
||||
hosts: [{
|
||||
...samplePayload().hosts[0]!,
|
||||
password: "base-secret",
|
||||
}],
|
||||
});
|
||||
const healed = healPoisonedSecretsForMerge(poisoned, preferred, fallback);
|
||||
assert.equal(healed.hosts[0]?.password, undefined);
|
||||
assert.equal(healed.hosts[0]?.label, "renamed-on-poisoned-device");
|
||||
});
|
||||
+337
-9
@@ -12,21 +12,78 @@ const BASE64_RE = /^[A-Za-z0-9+/]+=*$/;
|
||||
* - macOS/Linux: plaintext bytes start with "v10" or "v11"
|
||||
* - Windows (legacy DPAPI blob): leading bytes are 0x01 0x00 0x00 0x00
|
||||
*
|
||||
* We validate the base64 payload starts with one of these header signatures
|
||||
* instead of relying only on prefix+length heuristics. This greatly reduces
|
||||
* false positives for plaintext credentials that happen to start with "enc:v1:".
|
||||
* Detect headers on *decoded* bytes. A four-byte DPAPI version alone is not
|
||||
* enough — real blobs continue with provider GUID
|
||||
* {df9d8cd0-1501-11d1-8c7a-00c04fc297eb} (base64 `AQAAANCMnd8...`).
|
||||
*
|
||||
* We require a known header AND a complete-enough decoded blob. v10/v11 CBC
|
||||
* blobs are at least header(3) + one AES block(16) = 19 bytes. Header-only
|
||||
* base64 such as `enc:v1:djEw` must not be treated as ciphertext.
|
||||
*
|
||||
* Keep in sync with electron/bridges/credentialBridge.cjs.
|
||||
*
|
||||
* References:
|
||||
* - components/os_crypt/sync/os_crypt_mac.mm (kObfuscationPrefixV10 = "v10")
|
||||
* - components/os_crypt/sync/os_crypt_linux.cc (kObfuscationPrefixV10/V11)
|
||||
* - components/os_crypt/sync/os_crypt_win.cc (DPAPI legacy path)
|
||||
*/
|
||||
const SAFE_STORAGE_BASE64_HEADER_PREFIXES = [
|
||||
"djEw", // "v10"
|
||||
"djEx", // "v11"
|
||||
"AQAAAA", // 0x01 0x00 0x00 0x00 (DPAPI blob header)
|
||||
const V10_HEADER = [0x76, 0x31, 0x30] as const; // "v10"
|
||||
const V11_HEADER = [0x76, 0x31, 0x31] as const; // "v11"
|
||||
// Version (4) + provider GUID {df9d8cd0-1501-11d1-8c7a-00c04fc297eb} (16).
|
||||
const DPAPI_BLOB_PREFIX = [
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0xd0, 0x8c, 0x9d, 0xdf, 0x01, 0x15, 0xd1, 0x11,
|
||||
0x8c, 0x7a, 0x00, 0xc0, 0x4f, 0xc2, 0x97, 0xeb,
|
||||
] as const;
|
||||
|
||||
/** Minimum decoded sizes for complete Chromium OSCrypt blobs. */
|
||||
const MIN_V10_V11_CIPHERTEXT_BYTES = 19; // CBC: header(3) + one AES block(16)
|
||||
const MIN_V10_V11_GCM_CIPHERTEXT_BYTES = 31; // header(3) + nonce(12) + tag(16)
|
||||
const MIN_DPAPI_CIPHERTEXT_BYTES = DPAPI_BLOB_PREFIX.length + 1;
|
||||
|
||||
/**
|
||||
* Renderer-safe base64 decode. Avoids Node `Buffer` which is unavailable
|
||||
* in Electron windows with `nodeIntegration: false`.
|
||||
*/
|
||||
const decodeBase64Bytes = (payload: string): Uint8Array | null => {
|
||||
try {
|
||||
if (typeof atob === "function") {
|
||||
const binary = atob(payload);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
out[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
// Node / test environments without atob.
|
||||
if (typeof Buffer !== "undefined") {
|
||||
try {
|
||||
return new Uint8Array(Buffer.from(payload, "base64"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const startsWithBytes = (
|
||||
decoded: Uint8Array,
|
||||
prefix: readonly number[],
|
||||
): boolean => {
|
||||
if (decoded.byteLength < prefix.length) return false;
|
||||
return prefix.every((byte, index) => decoded[index] === byte);
|
||||
};
|
||||
|
||||
/** CBC is header(3) + 16-byte blocks; GCM is at least header+nonce+tag (31). */
|
||||
const isValidV10V11CiphertextLength = (byteLength: number): boolean => {
|
||||
if (byteLength >= MIN_V10_V11_GCM_CIPHERTEXT_BYTES) return true;
|
||||
return byteLength >= MIN_V10_V11_CIPHERTEXT_BYTES
|
||||
&& (byteLength - 3) % 16 === 0;
|
||||
};
|
||||
|
||||
export const isEncryptedCredentialPlaceholder = (
|
||||
value: string | undefined | null,
|
||||
): value is string => {
|
||||
@@ -35,8 +92,15 @@ export const isEncryptedCredentialPlaceholder = (
|
||||
}
|
||||
const payload = value.slice(CREDENTIAL_ENCRYPTION_PREFIX.length);
|
||||
if (!payload || !BASE64_RE.test(payload)) return false;
|
||||
|
||||
return SAFE_STORAGE_BASE64_HEADER_PREFIXES.some((prefix) => payload.startsWith(prefix));
|
||||
const decoded = decodeBase64Bytes(payload);
|
||||
if (!decoded) return false;
|
||||
if (startsWithBytes(decoded, V10_HEADER) || startsWithBytes(decoded, V11_HEADER)) {
|
||||
return isValidV10V11CiphertextLength(decoded.byteLength);
|
||||
}
|
||||
if (startsWithBytes(decoded, DPAPI_BLOB_PREFIX)) {
|
||||
return decoded.byteLength >= MIN_DPAPI_CIPHERTEXT_BYTES;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -108,3 +172,267 @@ export const findSyncPayloadEncryptedCredentialPaths = (
|
||||
|
||||
return issues;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear device-bound enc:v1 placeholders from a portable sync payload.
|
||||
*
|
||||
* Cloud / backup payloads must carry plaintext secrets (protected by the
|
||||
* master key envelope). If a previous bug uploaded undecryptable local
|
||||
* ciphertext, stripping placeholders lets download restore a usable vault
|
||||
* shell so the user can re-enter credentials instead of looping forever.
|
||||
*/
|
||||
export const stripSyncPayloadEncryptedCredentials = (
|
||||
payload: SyncPayload,
|
||||
): SyncPayload => {
|
||||
const hosts = payload.hosts.map((host) => {
|
||||
const next = { ...host };
|
||||
if (isEncryptedCredentialPlaceholder(next.password)) delete next.password;
|
||||
if (isEncryptedCredentialPlaceholder(next.telnetPassword)) delete next.telnetPassword;
|
||||
if (next.proxyConfig && isEncryptedCredentialPlaceholder(next.proxyConfig.password)) {
|
||||
const { password: _removed, ...proxyRest } = next.proxyConfig;
|
||||
next.proxyConfig = proxyRest;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const keys = payload.keys.map((key) => {
|
||||
const next = { ...key };
|
||||
if (isEncryptedCredentialPlaceholder(next.privateKey)) next.privateKey = "";
|
||||
if (isEncryptedCredentialPlaceholder(next.passphrase)) delete next.passphrase;
|
||||
return next;
|
||||
});
|
||||
|
||||
const identities = payload.identities?.map((identity) => {
|
||||
if (!isEncryptedCredentialPlaceholder(identity.password)) return identity;
|
||||
const next = { ...identity };
|
||||
delete next.password;
|
||||
return next;
|
||||
});
|
||||
|
||||
const proxyProfiles = payload.proxyProfiles?.map((profile) => {
|
||||
if (!isEncryptedCredentialPlaceholder(profile.config.password)) return profile;
|
||||
const { password: _removed, ...configRest } = profile.config;
|
||||
return { ...profile, config: configRest };
|
||||
});
|
||||
|
||||
const groupConfigs = payload.groupConfigs?.map((config) => {
|
||||
const next = { ...config };
|
||||
if (isEncryptedCredentialPlaceholder(next.password)) delete next.password;
|
||||
if (isEncryptedCredentialPlaceholder(next.telnetPassword)) delete next.telnetPassword;
|
||||
if (next.proxyConfig && isEncryptedCredentialPlaceholder(next.proxyConfig.password)) {
|
||||
const { password: _removed, ...proxyRest } = next.proxyConfig;
|
||||
next.proxyConfig = proxyRest;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
return {
|
||||
...payload,
|
||||
hosts,
|
||||
keys,
|
||||
identities: identities ?? payload.identities,
|
||||
proxyProfiles: proxyProfiles ?? payload.proxyProfiles,
|
||||
groupConfigs: groupConfigs ?? payload.groupConfigs,
|
||||
};
|
||||
};
|
||||
|
||||
const usableCredential = (value: string | undefined): string | undefined => {
|
||||
if (typeof value !== "string" || value.length === 0) return undefined;
|
||||
if (isEncryptedCredentialPlaceholder(value)) return undefined;
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a poisoned secret against preferred then fallback.
|
||||
*
|
||||
* If the preferred *entity* exists, an empty/missing preferred secret is an
|
||||
* explicit deletion and must win over fallback/base. Only when preferred is
|
||||
* also poisoned (or the preferred entity is absent) may we revive from base.
|
||||
*/
|
||||
const healPoisonedCredential = (
|
||||
preferredEntity: unknown,
|
||||
preferredValue: string | undefined,
|
||||
fallbackValue: string | undefined,
|
||||
): string | undefined => {
|
||||
if (preferredEntity) {
|
||||
if (isEncryptedCredentialPlaceholder(preferredValue)) {
|
||||
return usableCredential(fallbackValue);
|
||||
}
|
||||
return usableCredential(preferredValue);
|
||||
}
|
||||
return usableCredential(fallbackValue);
|
||||
};
|
||||
|
||||
/**
|
||||
* Before three-way merge, replace device-bound enc:v1 secrets on `poisoned`
|
||||
* with usable values from `preferred` then `fallback`. Used for both remote
|
||||
* and local sides so a poisoned field cannot win the entity-level merge and
|
||||
* delete a still-usable secret from the other side / base.
|
||||
*
|
||||
* Explicit preferred-side deletions (empty/absent secrets on a present entity)
|
||||
* are authoritative and are not revived from base.
|
||||
*/
|
||||
export const healPoisonedSecretsForMerge = (
|
||||
poisoned: SyncPayload,
|
||||
preferred: SyncPayload,
|
||||
fallback: SyncPayload | null | undefined,
|
||||
): SyncPayload => {
|
||||
const preferredHosts = new Map(preferred.hosts.map((host) => [host.id, host]));
|
||||
const fallbackHosts = new Map((fallback?.hosts ?? []).map((host) => [host.id, host]));
|
||||
const hosts = poisoned.hosts.map((host) => {
|
||||
const preferredHost = preferredHosts.get(host.id);
|
||||
const fallbackHost = fallbackHosts.get(host.id);
|
||||
const next = { ...host };
|
||||
if (isEncryptedCredentialPlaceholder(next.password)) {
|
||||
const healed = healPoisonedCredential(
|
||||
preferredHost,
|
||||
preferredHost?.password,
|
||||
fallbackHost?.password,
|
||||
);
|
||||
if (healed !== undefined) next.password = healed;
|
||||
else delete next.password;
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(next.telnetPassword)) {
|
||||
const healed = healPoisonedCredential(
|
||||
preferredHost,
|
||||
preferredHost?.telnetPassword,
|
||||
fallbackHost?.telnetPassword,
|
||||
);
|
||||
if (healed !== undefined) next.telnetPassword = healed;
|
||||
else delete next.telnetPassword;
|
||||
}
|
||||
if (next.proxyConfig && isEncryptedCredentialPlaceholder(next.proxyConfig.password)) {
|
||||
const healed = healPoisonedCredential(
|
||||
preferredHost,
|
||||
preferredHost?.proxyConfig?.password,
|
||||
fallbackHost?.proxyConfig?.password,
|
||||
);
|
||||
if (healed !== undefined) {
|
||||
next.proxyConfig = { ...next.proxyConfig, password: healed };
|
||||
} else {
|
||||
const { password: _removed, ...proxyRest } = next.proxyConfig;
|
||||
next.proxyConfig = proxyRest;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const preferredKeys = new Map(preferred.keys.map((key) => [key.id, key]));
|
||||
const fallbackKeys = new Map((fallback?.keys ?? []).map((key) => [key.id, key]));
|
||||
const keys = poisoned.keys.map((key) => {
|
||||
const preferredKey = preferredKeys.get(key.id);
|
||||
const fallbackKey = fallbackKeys.get(key.id);
|
||||
const next = { ...key };
|
||||
if (isEncryptedCredentialPlaceholder(next.privateKey)) {
|
||||
next.privateKey = healPoisonedCredential(
|
||||
preferredKey,
|
||||
preferredKey?.privateKey,
|
||||
fallbackKey?.privateKey,
|
||||
) ?? "";
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(next.passphrase)) {
|
||||
const healed = healPoisonedCredential(
|
||||
preferredKey,
|
||||
preferredKey?.passphrase,
|
||||
fallbackKey?.passphrase,
|
||||
);
|
||||
if (healed !== undefined) next.passphrase = healed;
|
||||
else delete next.passphrase;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const preferredIdentities = new Map((preferred.identities ?? []).map((identity) => [identity.id, identity]));
|
||||
const fallbackIdentities = new Map((fallback?.identities ?? []).map((identity) => [identity.id, identity]));
|
||||
const identities = poisoned.identities?.map((identity) => {
|
||||
if (!isEncryptedCredentialPlaceholder(identity.password)) return identity;
|
||||
const preferredIdentity = preferredIdentities.get(identity.id);
|
||||
const healed = healPoisonedCredential(
|
||||
preferredIdentity,
|
||||
preferredIdentity?.password,
|
||||
fallbackIdentities.get(identity.id)?.password,
|
||||
);
|
||||
if (healed !== undefined) return { ...identity, password: healed };
|
||||
const next = { ...identity };
|
||||
delete next.password;
|
||||
return next;
|
||||
});
|
||||
|
||||
const preferredProfiles = new Map((preferred.proxyProfiles ?? []).map((profile) => [profile.id, profile]));
|
||||
const fallbackProfiles = new Map((fallback?.proxyProfiles ?? []).map((profile) => [profile.id, profile]));
|
||||
const proxyProfiles = poisoned.proxyProfiles?.map((profile) => {
|
||||
if (!isEncryptedCredentialPlaceholder(profile.config.password)) return profile;
|
||||
const preferredProfile = preferredProfiles.get(profile.id);
|
||||
const healed = healPoisonedCredential(
|
||||
preferredProfile,
|
||||
preferredProfile?.config.password,
|
||||
fallbackProfiles.get(profile.id)?.config.password,
|
||||
);
|
||||
if (healed !== undefined) {
|
||||
return { ...profile, config: { ...profile.config, password: healed } };
|
||||
}
|
||||
const { password: _removed, ...configRest } = profile.config;
|
||||
return { ...profile, config: configRest };
|
||||
});
|
||||
|
||||
const preferredGroupConfigs = new Map((preferred.groupConfigs ?? []).map((config) => [config.path, config]));
|
||||
const fallbackGroupConfigs = new Map((fallback?.groupConfigs ?? []).map((config) => [config.path, config]));
|
||||
const groupConfigs = poisoned.groupConfigs?.map((config) => {
|
||||
const preferredConfig = preferredGroupConfigs.get(config.path);
|
||||
const fallbackConfig = fallbackGroupConfigs.get(config.path);
|
||||
const next = { ...config };
|
||||
let changed = false;
|
||||
if (isEncryptedCredentialPlaceholder(next.password)) {
|
||||
const healed = healPoisonedCredential(
|
||||
preferredConfig,
|
||||
preferredConfig?.password,
|
||||
fallbackConfig?.password,
|
||||
);
|
||||
if (healed !== undefined) next.password = healed;
|
||||
else delete next.password;
|
||||
changed = true;
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(next.telnetPassword)) {
|
||||
const healed = healPoisonedCredential(
|
||||
preferredConfig,
|
||||
preferredConfig?.telnetPassword,
|
||||
fallbackConfig?.telnetPassword,
|
||||
);
|
||||
if (healed !== undefined) next.telnetPassword = healed;
|
||||
else delete next.telnetPassword;
|
||||
changed = true;
|
||||
}
|
||||
if (next.proxyConfig && isEncryptedCredentialPlaceholder(next.proxyConfig.password)) {
|
||||
const healed = healPoisonedCredential(
|
||||
preferredConfig,
|
||||
preferredConfig?.proxyConfig?.password,
|
||||
fallbackConfig?.proxyConfig?.password,
|
||||
);
|
||||
if (healed !== undefined) {
|
||||
next.proxyConfig = { ...next.proxyConfig, password: healed };
|
||||
} else {
|
||||
const { password: _removed, ...proxyRest } = next.proxyConfig;
|
||||
next.proxyConfig = proxyRest;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
return changed ? next : config;
|
||||
});
|
||||
|
||||
return {
|
||||
...poisoned,
|
||||
hosts,
|
||||
keys,
|
||||
identities: identities ?? poisoned.identities,
|
||||
proxyProfiles: proxyProfiles ?? poisoned.proxyProfiles,
|
||||
groupConfigs: groupConfigs ?? poisoned.groupConfigs,
|
||||
};
|
||||
};
|
||||
|
||||
/** @deprecated Prefer healPoisonedSecretsForMerge(remote, local, base). */
|
||||
export const healPoisonedRemoteSecretsForMerge = (
|
||||
remote: SyncPayload,
|
||||
local: SyncPayload,
|
||||
base: SyncPayload | null | undefined,
|
||||
): SyncPayload => healPoisonedSecretsForMerge(remote, local, base);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Shared complete enc:v1 fixtures for tests.
|
||||
*
|
||||
* Production detectors require a full platform-shaped safeStorage blob
|
||||
* (v10/v11 CBC >= 19 bytes, or DPAPI >= 20). Short strings like
|
||||
* `enc:v1:djEwAAAA` are no longer treated as ciphertext.
|
||||
*/
|
||||
|
||||
export const MIN_V10_TEST_CIPHERTEXT_BYTES = 19;
|
||||
|
||||
export function makeEncryptedCredentialPlaceholder(seed = "fixture"): string {
|
||||
const body = Buffer.alloc(MIN_V10_TEST_CIPHERTEXT_BYTES, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
Buffer.from(String(seed).slice(0, 16), "utf8").copy(body, 3);
|
||||
return `enc:v1:${body.toString("base64")}`;
|
||||
}
|
||||
|
||||
/** Stable complete placeholder used by most auth/SFTP/proxy tests. */
|
||||
export const ENCRYPTED_CREDENTIAL_PLACEHOLDER = makeEncryptedCredentialPlaceholder("test");
|
||||
|
||||
/** Alternate complete placeholder (historically `enc:v1:djEwYWJj`). */
|
||||
export const ENCRYPTED_CREDENTIAL_PLACEHOLDER_ABC = makeEncryptedCredentialPlaceholder("abc");
|
||||
@@ -69,11 +69,11 @@ test("listPasswordPromptFillCandidates includes password identities", () => {
|
||||
|
||||
test("listPasswordPromptFillCandidates skips key-only identities and placeholders", () => {
|
||||
const candidates = listPasswordPromptFillCandidates({
|
||||
host: baseHost({ password: "enc:v1:djEwAAAA" }),
|
||||
host: baseHost({ password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }),
|
||||
keys: [],
|
||||
identities: [
|
||||
identity({ id: "key", authMethod: "key", password: undefined }),
|
||||
identity({ id: "bad", password: "enc:v1:djEwAAAA" }),
|
||||
identity({ id: "bad", password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }),
|
||||
identity({ id: "ok", password: "ok-secret" }),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ test('plugin credentials are selectable only after secure catalog publication',
|
||||
assert.equal(isPluginCredentialCatalogEntryAvailable(credentialId, 'secret', published), true);
|
||||
assert.equal(isPluginCredentialCatalogEntryAvailable(credentialId, 'secret', new Set()), false);
|
||||
assert.equal(
|
||||
isPluginCredentialCatalogEntryAvailable(credentialId, 'enc:v1:djEwAAAA', published),
|
||||
isPluginCredentialCatalogEntryAvailable(credentialId, 'enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==', published),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
|
||||
@@ -352,7 +352,7 @@ test("findIncompleteProxyIdentityId treats blank usernames as incomplete even wi
|
||||
label: "Proxy login",
|
||||
username: " ",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
),
|
||||
|
||||
@@ -37,7 +37,7 @@ test("resolveBridgeKeyAuth ignores undecryptable passphrase placeholders", () =>
|
||||
resolveBridgeKeyAuth({
|
||||
key: {
|
||||
...referenceKey,
|
||||
passphrase: "enc:v1:djEwAAAA",
|
||||
passphrase: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
},
|
||||
}).passphrase,
|
||||
undefined,
|
||||
@@ -51,7 +51,7 @@ test("resolveBridgeKeyAuth ignores undecryptable private key placeholders", () =
|
||||
...referenceKey,
|
||||
source: "imported",
|
||||
filePath: undefined,
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
},
|
||||
}).privateKey,
|
||||
undefined,
|
||||
@@ -767,7 +767,7 @@ test("resolveHostAutofillPassword returns undefined when no password is availabl
|
||||
|
||||
test("resolveHostAutofillPassword ignores undecryptable password placeholders", () => {
|
||||
assert.equal(
|
||||
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: "enc:v1:djEwAAAA" }, keys: [] }),
|
||||
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }, keys: [] }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -438,7 +438,7 @@ test("CSV import ignores a passphrase without a key path", () => {
|
||||
});
|
||||
|
||||
test("CSV import rejects encrypted passphrase placeholders", () => {
|
||||
const placeholder = "enc:v1:djEwYWJj";
|
||||
const placeholder = "enc:v1:djEwYWJjAAAAAAAAAAAAAAAAAA==";
|
||||
for (const value of [placeholder, encodeCsvPassphrase(placeholder)]) {
|
||||
const result = importVaultHostsFromText(
|
||||
"csv",
|
||||
|
||||
@@ -15,8 +15,147 @@
|
||||
|
||||
const ENC_PREFIX = "enc:v1:";
|
||||
|
||||
/**
|
||||
* Chromium/Electron safeStorage ciphertext carries known platform headers:
|
||||
* - macOS/Linux: plaintext bytes start with "v10" or "v11"
|
||||
* - Windows (legacy DPAPI blob): leading bytes are 0x01 0x00 0x00 0x00
|
||||
*
|
||||
* Detect headers on *decoded* bytes. A four-byte DPAPI version alone is not
|
||||
* enough — real blobs continue with provider GUID
|
||||
* {df9d8cd0-1501-11d1-8c7a-00c04fc297eb} (base64 `AQAAANCMnd8...`).
|
||||
*
|
||||
* Keep in sync with domain/credentials.ts.
|
||||
*
|
||||
* v10/v11 CBC blobs are at least header(3) + one AES block(16) = 19 bytes.
|
||||
* AES-GCM blobs are larger (nonce+tag), but CBC is the lower bound we must
|
||||
* accept so short credentials are not mistaken for plaintext coincidences.
|
||||
*/
|
||||
const V10_HEADER = Buffer.from("v10", "utf8");
|
||||
const V11_HEADER = Buffer.from("v11", "utf8");
|
||||
// Version (4) + provider GUID {df9d8cd0-1501-11d1-8c7a-00c04fc297eb} (16).
|
||||
const DPAPI_BLOB_PREFIX = Buffer.from([
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0xd0, 0x8c, 0x9d, 0xdf, 0x01, 0x15, 0xd1, 0x11,
|
||||
0x8c, 0x7a, 0x00, 0xc0, 0x4f, 0xc2, 0x97, 0xeb,
|
||||
]);
|
||||
|
||||
const MIN_V10_V11_CIPHERTEXT_BYTES = 19;
|
||||
const MIN_V10_V11_GCM_CIPHERTEXT_BYTES = 31; // header(3) + nonce(12) + tag(16)
|
||||
// Header alone is 20 bytes; require at least one trailing payload byte.
|
||||
const MIN_DPAPI_CIPHERTEXT_BYTES = DPAPI_BLOB_PREFIX.byteLength + 1;
|
||||
|
||||
const BASE64_RE = /^[A-Za-z0-9+/]+=*$/;
|
||||
|
||||
let safeStorage = null;
|
||||
|
||||
function startsWithBytes(buffer, prefix) {
|
||||
if (buffer.byteLength < prefix.byteLength) return false;
|
||||
return buffer.subarray(0, prefix.byteLength).equals(prefix);
|
||||
}
|
||||
|
||||
/** CBC is header(3) + 16-byte blocks; GCM is at least header+nonce+tag (31). */
|
||||
function isValidV10V11CiphertextLength(byteLength) {
|
||||
if (byteLength >= MIN_V10_V11_GCM_CIPHERTEXT_BYTES) return true;
|
||||
return byteLength >= MIN_V10_V11_CIPHERTEXT_BYTES
|
||||
&& (byteLength - 3) % 16 === 0;
|
||||
}
|
||||
|
||||
function looksLikeEncryptedCredential(value) {
|
||||
if (typeof value !== "string" || !value.startsWith(ENC_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
const payload = value.slice(ENC_PREFIX.length);
|
||||
if (!payload || !BASE64_RE.test(payload)) return false;
|
||||
// Reject header-only / truncated base64 that is not a full platform blob.
|
||||
let decoded;
|
||||
try {
|
||||
decoded = Buffer.from(payload, "base64");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (startsWithBytes(decoded, V10_HEADER) || startsWithBytes(decoded, V11_HEADER)) {
|
||||
return isValidV10V11CiphertextLength(decoded.byteLength);
|
||||
}
|
||||
if (startsWithBytes(decoded, DPAPI_BLOB_PREFIX)) {
|
||||
return decoded.byteLength >= MIN_DPAPI_CIPHERTEXT_BYTES;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a credential field. Never wraps an existing complete device-bound
|
||||
* enc:v1 blob again — even when trial decrypt fails (e.g. OSCrypt key
|
||||
* rotated). Ambiguous `enc:v1:` prefixes that are not full ciphertext are
|
||||
* treated as coincidental plaintext and encrypted normally.
|
||||
*
|
||||
* @param {string} plaintext
|
||||
* @param {{ isEncryptionAvailable?: () => boolean, encryptString?: (v: string) => Buffer, decryptString?: (b: Buffer) => string } | null} storage
|
||||
*/
|
||||
function encryptCredentialValue(plaintext, storage = safeStorage) {
|
||||
if (typeof plaintext !== "string" || plaintext.length === 0) {
|
||||
return plaintext ?? "";
|
||||
}
|
||||
if (!storage?.isEncryptionAvailable?.()) {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
// Complete device-bound ciphertext: return as-is. Do not trial-decrypt then
|
||||
// fall through to encryptString — that double-wraps when decrypt fails.
|
||||
if (looksLikeEncryptedCredential(plaintext)) {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
// Ambiguous enc:v1: prefix (header-only / short coincidence): try decrypt.
|
||||
// Success means a real blob we failed to classify — keep it. Failure means
|
||||
// plaintext that happens to look prefixed — encrypt it.
|
||||
if (plaintext.startsWith(ENC_PREFIX) && typeof storage.decryptString === "function") {
|
||||
try {
|
||||
const base64 = plaintext.slice(ENC_PREFIX.length);
|
||||
const buf = Buffer.from(base64, "base64");
|
||||
storage.decryptString(buf);
|
||||
return plaintext;
|
||||
} catch {
|
||||
// coincidental plaintext — fall through
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const encrypted = storage.encryptString(plaintext);
|
||||
return ENC_PREFIX + encrypted.toString("base64");
|
||||
} catch (err) {
|
||||
console.warn("[Credentials] encrypt failed, returning plaintext:", err?.message || err);
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a credential field. On failure returns the ciphertext unchanged so
|
||||
* callers can detect enc:v1 placeholders via looksLikeEncryptedCredential /
|
||||
* isEncryptedCredentialPlaceholder.
|
||||
*
|
||||
* @param {string} value
|
||||
* @param {{ isEncryptionAvailable?: () => boolean, decryptString?: (b: Buffer) => string } | null} storage
|
||||
*/
|
||||
function decryptCredentialValue(value, storage = safeStorage) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return value ?? "";
|
||||
}
|
||||
if (!value.startsWith(ENC_PREFIX)) {
|
||||
return value;
|
||||
}
|
||||
if (!storage?.isEncryptionAvailable?.()) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const base64 = value.slice(ENC_PREFIX.length);
|
||||
const buf = Buffer.from(base64, "base64");
|
||||
return storage.decryptString(buf);
|
||||
} catch (err) {
|
||||
console.warn("[Credentials] decrypt failed:", err?.message || err);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for credential encryption/decryption
|
||||
* @param {Electron.IpcMain} ipcMain
|
||||
@@ -30,56 +169,20 @@ function registerHandlers(ipcMain, electronModule) {
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:credentials:encrypt", (_event, plaintext) => {
|
||||
if (typeof plaintext !== "string" || plaintext.length === 0) {
|
||||
return plaintext ?? "";
|
||||
}
|
||||
if (!safeStorage?.isEncryptionAvailable?.()) {
|
||||
return plaintext;
|
||||
}
|
||||
// If value looks like it might already be encrypted, verify by attempting
|
||||
// to decode and decrypt. If it succeeds the value is genuinely encrypted
|
||||
// and we return it as-is; if it fails, the prefix was a coincidence and
|
||||
// we proceed to encrypt the raw plaintext.
|
||||
if (plaintext.startsWith(ENC_PREFIX)) {
|
||||
try {
|
||||
const base64 = plaintext.slice(ENC_PREFIX.length);
|
||||
const buf = Buffer.from(base64, "base64");
|
||||
safeStorage.decryptString(buf); // throws on invalid ciphertext
|
||||
return plaintext; // verified — already encrypted
|
||||
} catch {
|
||||
// Not valid ciphertext — fall through to encrypt
|
||||
}
|
||||
}
|
||||
try {
|
||||
const encrypted = safeStorage.encryptString(plaintext);
|
||||
return ENC_PREFIX + encrypted.toString("base64");
|
||||
} catch (err) {
|
||||
console.warn("[Credentials] encrypt failed, returning plaintext:", err?.message || err);
|
||||
return plaintext;
|
||||
}
|
||||
return encryptCredentialValue(plaintext, safeStorage);
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:credentials:decrypt", (_event, value) => {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return value ?? "";
|
||||
}
|
||||
// Not encrypted — pass through (supports migration from plaintext)
|
||||
if (!value.startsWith(ENC_PREFIX)) {
|
||||
return value;
|
||||
}
|
||||
if (!safeStorage?.isEncryptionAvailable?.()) {
|
||||
// Cannot decrypt without safeStorage; return raw value
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const base64 = value.slice(ENC_PREFIX.length);
|
||||
const buf = Buffer.from(base64, "base64");
|
||||
return safeStorage.decryptString(buf);
|
||||
} catch (err) {
|
||||
console.warn("[Credentials] decrypt failed:", err?.message || err);
|
||||
return value;
|
||||
}
|
||||
return decryptCredentialValue(value, safeStorage);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerHandlers };
|
||||
module.exports = {
|
||||
ENC_PREFIX,
|
||||
MIN_V10_V11_CIPHERTEXT_BYTES,
|
||||
MIN_DPAPI_CIPHERTEXT_BYTES,
|
||||
looksLikeEncryptedCredential,
|
||||
encryptCredentialValue,
|
||||
decryptCredentialValue,
|
||||
registerHandlers,
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
ENC_PREFIX,
|
||||
MIN_V10_V11_CIPHERTEXT_BYTES,
|
||||
MIN_DPAPI_CIPHERTEXT_BYTES,
|
||||
encryptCredentialValue,
|
||||
decryptCredentialValue,
|
||||
looksLikeEncryptedCredential,
|
||||
} = require("./credentialBridge.cjs");
|
||||
|
||||
function fakeSafeStorage({ decryptThrows = false } = {}) {
|
||||
const blobs = new Map();
|
||||
let nextId = 1;
|
||||
return {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString(plaintext) {
|
||||
const id = `cipher-${nextId++}`;
|
||||
// Pad to a complete CBC-sized blob (header + one AES block).
|
||||
const body = Buffer.alloc(MIN_V10_V11_CIPHERTEXT_BYTES, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
Buffer.from(id, "utf8").copy(body, 3);
|
||||
blobs.set(body.toString("base64"), plaintext);
|
||||
return body;
|
||||
},
|
||||
decryptString(buffer) {
|
||||
if (decryptThrows) throw new Error("decrypt failed");
|
||||
const key = Buffer.from(buffer).toString("base64");
|
||||
if (!blobs.has(key)) throw new Error("unknown cipher");
|
||||
return blobs.get(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completeCiphertextPlaceholder(seed = "stale-key-material") {
|
||||
const body = Buffer.alloc(MIN_V10_V11_CIPHERTEXT_BYTES, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
Buffer.from(seed, "utf8").copy(body, 3);
|
||||
return `${ENC_PREFIX}${body.toString("base64")}`;
|
||||
}
|
||||
|
||||
test("looksLikeEncryptedCredential accepts complete CBC-sized v10 payloads", () => {
|
||||
assert.equal(looksLikeEncryptedCredential(completeCiphertextPlaceholder()), true);
|
||||
});
|
||||
|
||||
test("looksLikeEncryptedCredential rejects impossible intermediate v10 lengths", () => {
|
||||
// 20–30 bytes starting with v10 cannot be CBC (19+16n) or GCM (>=31).
|
||||
const body = Buffer.alloc(24, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
assert.equal(looksLikeEncryptedCredential(`${ENC_PREFIX}${body.toString("base64")}`), false);
|
||||
});
|
||||
|
||||
test("looksLikeEncryptedCredential accepts GCM-sized v10 payloads", () => {
|
||||
const body = Buffer.alloc(31, 0);
|
||||
Buffer.from("v10", "utf8").copy(body, 0);
|
||||
assert.equal(looksLikeEncryptedCredential(`${ENC_PREFIX}${body.toString("base64")}`), true);
|
||||
});
|
||||
|
||||
test("looksLikeEncryptedCredential accepts real Windows DPAPI base64 prefixes", () => {
|
||||
// Version + provider GUID {df9d8cd0-1501-11d1-8c7a-00c04fc297eb} + payload.
|
||||
const body = Buffer.from([
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0xd0, 0x8c, 0x9d, 0xdf, 0x01, 0x15, 0xd1, 0x11,
|
||||
0x8c, 0x7a, 0x00, 0xc0, 0x4f, 0xc2, 0x97, 0xeb,
|
||||
0xaa,
|
||||
]);
|
||||
const encoded = body.toString("base64");
|
||||
assert.equal(encoded.startsWith("AQAAANCMnd8"), true);
|
||||
assert.equal(looksLikeEncryptedCredential(`${ENC_PREFIX}${encoded}`), true);
|
||||
});
|
||||
|
||||
test("looksLikeEncryptedCredential rejects version-only DPAPI-looking blobs", () => {
|
||||
const body = Buffer.alloc(MIN_DPAPI_CIPHERTEXT_BYTES, 0);
|
||||
body[0] = 0x01;
|
||||
body[4] = 0xd0;
|
||||
body[5] = 0x8c;
|
||||
assert.equal(looksLikeEncryptedCredential(`${ENC_PREFIX}${body.toString("base64")}`), false);
|
||||
});
|
||||
|
||||
test("looksLikeEncryptedCredential rejects header-only enc:v1 payloads", () => {
|
||||
assert.equal(looksLikeEncryptedCredential(`${ENC_PREFIX}djEw`), false);
|
||||
assert.equal(looksLikeEncryptedCredential(`${ENC_PREFIX}not-real-ciphertext`), false);
|
||||
assert.equal(looksLikeEncryptedCredential("password"), false);
|
||||
});
|
||||
|
||||
test("encrypt leaves undecryptable DPAPI enc:v1 ciphertext unchanged", () => {
|
||||
const storage = fakeSafeStorage({ decryptThrows: true });
|
||||
const body = Buffer.from([
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
0xd0, 0x8c, 0x9d, 0xdf, 0x01, 0x15, 0xd1, 0x11,
|
||||
0x8c, 0x7a, 0x00, 0xc0, 0x4f, 0xc2, 0x97, 0xeb,
|
||||
0xaa,
|
||||
]);
|
||||
const stale = `${ENC_PREFIX}${body.toString("base64")}`;
|
||||
assert.equal(encryptCredentialValue(stale, storage), stale);
|
||||
});
|
||||
|
||||
test("encrypt leaves undecryptable complete enc:v1 ciphertext unchanged instead of wrapping again", () => {
|
||||
const storage = fakeSafeStorage({ decryptThrows: true });
|
||||
const stale = completeCiphertextPlaceholder("stale-key-material");
|
||||
const result = encryptCredentialValue(stale, storage);
|
||||
assert.equal(result, stale);
|
||||
});
|
||||
|
||||
test("encrypt encrypts header-only enc:v1 coincidence instead of leaving it plaintext", () => {
|
||||
const storage = fakeSafeStorage();
|
||||
const coincidence = `${ENC_PREFIX}djEw`;
|
||||
const result = encryptCredentialValue(coincidence, storage);
|
||||
assert.notEqual(result, coincidence);
|
||||
assert.ok(result.startsWith(ENC_PREFIX));
|
||||
assert.equal(decryptCredentialValue(result, storage), coincidence);
|
||||
});
|
||||
|
||||
test("encrypt still encrypts coincidental plaintext that starts with enc:v1:", () => {
|
||||
const storage = fakeSafeStorage();
|
||||
const coincidence = `${ENC_PREFIX}totally-plain-password`;
|
||||
const result = encryptCredentialValue(coincidence, storage);
|
||||
assert.notEqual(result, coincidence);
|
||||
assert.ok(result.startsWith(ENC_PREFIX));
|
||||
assert.equal(decryptCredentialValue(result, storage), coincidence);
|
||||
});
|
||||
|
||||
test("encrypt round-trips plaintext and does not double-encrypt", () => {
|
||||
const storage = fakeSafeStorage();
|
||||
const once = encryptCredentialValue("secret", storage);
|
||||
const twice = encryptCredentialValue(once, storage);
|
||||
assert.equal(twice, once);
|
||||
assert.equal(decryptCredentialValue(once, storage), "secret");
|
||||
});
|
||||
|
||||
test("decrypt returns ciphertext unchanged when safeStorage cannot decrypt", () => {
|
||||
const storage = fakeSafeStorage({ decryptThrows: true });
|
||||
const stale = completeCiphertextPlaceholder("stale");
|
||||
assert.equal(decryptCredentialValue(stale, storage), stale);
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
import packageJson from '../../../package.json';
|
||||
import { EncryptionService } from '../EncryptionService';
|
||||
import { mergeSyncPayloads } from '../../../domain/syncMerge';
|
||||
import { stripSyncPayloadEncryptedCredentials, healPoisonedSecretsForMerge } from '../../../domain/credentials';
|
||||
import { summarizeSyncChanges, withSyncReliabilityMeta } from '../../../domain/syncReliability';
|
||||
import { detectSuspiciousShrink, type ShrinkFinding } from '../../../domain/syncGuards';
|
||||
import { resolveCloudSyncConflictAction } from '../../../domain/syncStrategy';
|
||||
@@ -467,11 +468,18 @@ export async function syncToProviderImpl(this: any,
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const base = await this.loadSyncBase(provider);
|
||||
baseForConflict = base;
|
||||
const mergeResult = mergeSyncPayloads(base, payload, remotePayload);
|
||||
const mergedPayload = withSyncReliabilityMeta(mergeResult.payload, base, {
|
||||
deviceId: this.state.deviceId,
|
||||
now: Date.now(),
|
||||
});
|
||||
const localHealed = healPoisonedSecretsForMerge(payload, remotePayload, base);
|
||||
remotePayload = healPoisonedSecretsForMerge(remotePayload, payload, base);
|
||||
remotePayloadForConflict = remotePayload;
|
||||
const mergeResult = mergeSyncPayloads(base, localHealed, remotePayload);
|
||||
const mergedPayload = withSyncReliabilityMeta(
|
||||
stripSyncPayloadEncryptedCredentials(mergeResult.payload),
|
||||
base,
|
||||
{
|
||||
deviceId: this.state.deviceId,
|
||||
now: Date.now(),
|
||||
},
|
||||
);
|
||||
assertConvergentSyncWriteCompatible(checkResult.remoteFile.meta, mergedPayload);
|
||||
|
||||
console.info('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
|
||||
|
||||
@@ -165,7 +165,7 @@ test("syncAllProviders uses the newest cloud payload without merging other remot
|
||||
const results = await syncAllProvidersImpl.call(manager, localPayload);
|
||||
|
||||
assert.equal(results.get("github")?.action, "download");
|
||||
assert.equal(results.get("github")?.mergedPayload, githubPayload);
|
||||
assert.deepEqual(results.get("github")?.mergedPayload, githubPayload);
|
||||
assert.equal(results.get("github")?.remoteFile, githubRemote);
|
||||
assert.equal(uploaded.length, 1);
|
||||
assert.equal(uploaded[0].provider, "google");
|
||||
@@ -816,3 +816,93 @@ test("syncToProvider preserves a merged payload discovered by a non-target provi
|
||||
assert.equal(selected.mergedPayload, mergedPayload);
|
||||
assert.equal(selected.remoteFile, undefined);
|
||||
});
|
||||
|
||||
test("syncAllProviders smart-merge strips device-bound enc:v1 secrets before upload", async () => {
|
||||
const originalDecryptPayload = EncryptionService.decryptPayload;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const completeBlob = Buffer.alloc(31, 0);
|
||||
Buffer.from("v10", "utf8").copy(completeBlob, 0);
|
||||
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
|
||||
const checkedRemote = remoteFile("github", 5, 500);
|
||||
const localPayload = {
|
||||
...payload("shared"),
|
||||
hosts: [{
|
||||
...payload("shared").hosts[0]!,
|
||||
password: "kept-secret",
|
||||
}],
|
||||
};
|
||||
const remotePoisoned: SyncPayload = {
|
||||
...payload("shared"),
|
||||
hosts: [
|
||||
{
|
||||
...payload("shared").hosts[0]!,
|
||||
label: "remote-label",
|
||||
password: ENC,
|
||||
},
|
||||
],
|
||||
};
|
||||
const uploaded: SyncPayload[] = [];
|
||||
|
||||
EncryptionService.decryptPayload = async () => remotePoisoned;
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
|
||||
...remoteFile("github", 6, 600),
|
||||
payload: JSON.stringify(outgoing),
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
google: { enabled: false, connected: false, status: "disconnected" },
|
||||
onedrive: { enabled: false, connected: false, status: "disconnected" },
|
||||
webdav: { enabled: false, connected: false, status: "disconnected" },
|
||||
s3: { enabled: false, connected: false, status: "disconnected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: true, remoteFile: checkedRemote }),
|
||||
loadSyncBase: async () => ({
|
||||
...payload("shared"),
|
||||
hosts: [{
|
||||
...payload("shared").hosts[0]!,
|
||||
password: "kept-secret",
|
||||
}],
|
||||
}),
|
||||
uploadToProvider: async (
|
||||
_provider: CloudProvider,
|
||||
_adapter: unknown,
|
||||
_file: SyncedFile,
|
||||
outgoing: SyncPayload,
|
||||
) => {
|
||||
uploaded.push(outgoing);
|
||||
return { success: true, provider: "github" as const, action: "upload" as const, version: 6 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
const results = await syncAllProvidersImpl.call(manager, localPayload);
|
||||
assert.equal(results.get("github")?.success, true);
|
||||
assert.equal(uploaded.length, 1);
|
||||
const sharedHost = uploaded[0]?.hosts.find((host) => host.id === "shared");
|
||||
assert.ok(sharedHost);
|
||||
// Local/base usable secret must survive; remote non-secret edits can still apply.
|
||||
assert.equal(sharedHost?.password, "kept-secret");
|
||||
assert.equal(sharedHost?.label, "remote-label");
|
||||
} finally {
|
||||
EncryptionService.decryptPayload = originalDecryptPayload;
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import packageJson from '../../../package.json';
|
||||
import { EncryptionService } from '../EncryptionService';
|
||||
import { mergeSyncPayloads } from '../../../domain/syncMerge';
|
||||
import { stripSyncPayloadEncryptedCredentials, healPoisonedSecretsForMerge } from '../../../domain/credentials';
|
||||
import {
|
||||
SYNC_SNAPSHOT_LIMIT,
|
||||
summarizeSyncChanges,
|
||||
@@ -51,7 +52,9 @@ async function downloadRemoteForSyncAllImpl(this: any,
|
||||
syncSecurityGeneration?: number,
|
||||
): Promise<SyncResult> {
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const payload = await EncryptionService.decryptPayload(remoteFile, this.masterPassword);
|
||||
const payload = stripSyncPayloadEncryptedCredentials(
|
||||
await EncryptionService.decryptPayload(remoteFile, this.masterPassword),
|
||||
);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
this.updateProviderStatus(provider, 'connected');
|
||||
|
||||
@@ -301,15 +304,23 @@ export async function syncAllProvidersImpl(this: any,
|
||||
let merged = payload;
|
||||
for (const c of conflicts) {
|
||||
const providerBase = await this.loadSyncBase(c.provider as CloudProvider);
|
||||
const remotePayload = await EncryptionService.decryptPayload(
|
||||
const remoteRaw = await EncryptionService.decryptPayload(
|
||||
c.check!.remoteFile!,
|
||||
this.masterPassword,
|
||||
);
|
||||
const localHealed = healPoisonedSecretsForMerge(merged, remoteRaw, providerBase);
|
||||
const remotePayload = healPoisonedSecretsForMerge(
|
||||
remoteRaw,
|
||||
merged,
|
||||
providerBase,
|
||||
);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const result = mergeSyncPayloads(providerBase, merged, remotePayload);
|
||||
const result = mergeSyncPayloads(providerBase, localHealed, remotePayload);
|
||||
merged = result.payload;
|
||||
}
|
||||
const mergeResult = { payload: merged };
|
||||
const mergeResult = {
|
||||
payload: stripSyncPayloadEncryptedCredentials(merged),
|
||||
};
|
||||
|
||||
console.info('[CloudSyncManager] syncAll: three-way merge completed');
|
||||
|
||||
|
||||
@@ -1220,7 +1220,7 @@ test("startPortForward uses the system agent when a synced key cannot be decrypt
|
||||
label: "Synced key",
|
||||
type: "ED25519",
|
||||
publicKey: "ssh-ed25519 AAAASELECTED",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
privateKey: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
@@ -1249,7 +1249,7 @@ test("startPortForward keeps automatic target discovery available with an unread
|
||||
const bridge = installBridgeStub();
|
||||
const result = await startPortForward(
|
||||
rule({ id: "rule-auto-unreadable" }),
|
||||
host({ authMethod: "auto", password: "enc:v1:djEwAAAA" }),
|
||||
host({ authMethod: "auto", password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==" }),
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
@@ -1267,7 +1267,7 @@ test("startPortForward keeps automatic jump discovery available with an unreadab
|
||||
id: "jump-1",
|
||||
label: "Jump",
|
||||
authMethod: "auto",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
});
|
||||
const result = await startPortForward(
|
||||
rule({ id: "rule-auto-jump-unreadable" }),
|
||||
@@ -1415,7 +1415,7 @@ test("startPortForward rejects proxy identities with blank usernames even when p
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
(status, error) => statuses.push(error ? `${status}:${error}` : status),
|
||||
@@ -1487,7 +1487,7 @@ test("startPortForward rejects target proxy identity passwords that cannot be de
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
(status, error) => statuses.push(error ? `${status}:${error}` : status),
|
||||
@@ -1576,7 +1576,7 @@ test("startPortForward rejects incomplete jump host proxy identities before star
|
||||
label: "Proxy login",
|
||||
username: "",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
(status, error) => statuses.push(error ? `${status}:${error}` : status),
|
||||
@@ -1655,7 +1655,7 @@ test("startPortForward rejects jump host proxy identity passwords that cannot be
|
||||
label: "Proxy login",
|
||||
username: "proxy-user",
|
||||
authMethod: "password",
|
||||
password: "enc:v1:djEwAAAA",
|
||||
password: "enc:v1:djEwdGVzdAAAAAAAAAAAAAAAAA==",
|
||||
created: 1,
|
||||
}],
|
||||
(status, error) => statuses.push(error ? `${status}:${error}` : status),
|
||||
|
||||
Reference in new issue
Block a user