Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 1d3328d1e9 fix(credentials): require full DPAPI provider GUID signature
Accept Windows safeStorage blobs only when they start with version
01 00 00 00 plus provider GUID df9d8cd0-1501-11d1-8c7a-00c04fc297eb,
so coincidental 01 00 00 00 prefixes are not treated as ciphertext.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 05:05:30 +00:00
Cursor Agentand陈大猫 97dbacffb3 fix(credentials): reject impossible v10/v11 ciphertext lengths
Accept only CBC-aligned sizes (19+16n) or GCM-sized blobs (>=31) so
coincidental enc:v1 plaintext of intermediate length is encrypted instead
of treated as an undecryptable placeholder.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:58:58 +00:00
Cursor Agentand陈大猫 b05e535740 fix(sync): treat preferred credential deletions as authoritative
When healing enc:v1 before merge, an empty/missing secret on a present
preferred entity is an intentional clear and must not be revived from
base. Only fall back to base when preferred is absent or also poisoned.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:53:17 +00:00
Cursor Agentand陈大猫 f42694964c test(credentials): use complete enc:v1 fixtures for stricter detector
Short placeholders like enc:v1:djEwAAAA no longer pass the platform
header + minimum-size checks. Update auth/SFTP/proxy/sync fixtures to
full v10-shaped blobs so npm test matches production validation.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:47:00 +00:00
Cursor Agentand陈大猫 19575264fe fix(credentials): detect Windows DPAPI by decoded header bytes
Real DPAPI blobs start with 01 00 00 00 d0 8c... and base64-encode as
AQAAANCM..., so the previous AQAAAA string prefix rejected them and
allowed double-wrapping after key rotation. Match decoded headers in
both the main-process bridge and renderer predicate.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:39:56 +00:00
Cursor Agentand陈大猫 835f316870 fix(sync): keep enc:v1 intact during convergent materialize
Stripping device-bound secrets inside materializeSyncPayloadFromConvergentState
broke envelope validation for poisoned-but-consistent v2 snapshots, so decrypt
could not hydrate the clouds #2702 needs to recover. Portable stripping stays
at apply/upload boundaries.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:33:44 +00:00
Cursor Agentand陈大猫 8734803475 fix(sync): heal local and remote secrets before smart-merge
Smart-merge could select a locally-changed entity whose only secret
delta was enc:v1 poison, then strip and upload empty secrets. Heal
both sides from the opposite payload/base before merge on all sync
paths.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:27:05 +00:00
Cursor Agentand陈大猫 111c77f877 fix(sync): sanitize payloads at every apply/commit boundary
Codex P1: vault apply stripped enc:v1 while commitRemoteInspection and
convergent materialization could keep the poison. Sanitize merged/remote
payloads before apply+base commit, strip on CRDT materialization, and
sanitize convergent apply inputs.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:20:39 +00:00
Cursor Agentand陈大猫 4c742486cb fix(credentials): address Codex P1 CBC min and startup heal
- Accept 19-byte v10/v11 CBC OSCrypt blobs (not only 31-byte GCM)
- Heal poisoned remote secrets before startup smart-merge
- Strip unresolved placeholders on merge round-trip upload

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:12:44 +00:00
Cursor Agentand陈大猫 e5f07ad24e fix(credentials): address Codex P1 review on #2702
- Decode enc:v1 payloads with atob in the renderer-safe domain helper
- Heal poisoned remote secrets from local/base before smart-merge so
  good credentials are not discarded as remote-only deletions
- Keep post-merge strip so leftover enc:v1 never uploads

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:07:53 +00:00
Cursor Agentand陈大猫 40212dc0db fix(credentials): address Codex P2 review on #2702
- Always re-encrypt vault init batches so plaintext siblings are not
  left unprotected when one record is a stale enc:v1 placeholder
- Sanitize device-bound secrets before convergent restore prepare so
  CRDT replica commit matches vault import
- Require a complete safeStorage blob (header + min 31 bytes) before
  treating enc:v1 as ciphertext; encrypt header-only coincidences

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:00:45 +00:00
Cursor Agentand陈大猫 4173b49b3a fix(sync): strip enc:v1 secrets from smart-merge uploads
Bugbot found that after local apply sanitized placeholders, legacy v1
smart-merge could still decrypt an unstripped remote, merge it, and
re-upload the poison. Strip device-bound credentials on remote decrypt
and again on the merged payload before upload.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:48:40 +00:00
Cursor Agentand陈大猫 ae8e8a6bd9 fix(credentials): stop double-encrypting undecryptable safeStorage blobs
When local decrypt failed (e.g. OSCrypt key churn after reboot), encrypt
used to wrap the leftover enc:v1 ciphertext again, permanently poisoning
the vault. Startup sync could also push those placeholders to cloud and
make download restore the same poison.

- Keep real enc:v1 blobs unchanged on encrypt (header check, no wrap)
- Strip device-bound placeholders when applying portable sync payloads
- Guard startup local-wins / merge round-trips before upload
- Skip vault init re-encrypt writes when secrets are still undecrypted

Fixes #2702

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:42:59 +00:00
32 changed files with 1218 additions and 188 deletions

No files matched your search

+3 -2
View File
@@ -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,
+45
View File
@@ -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",
);
});
+32 -12
View File
@@ -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);
+4
View File
@@ -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);
+93 -28
View File
@@ -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
View File
@@ -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 }),
);
+10 -6
View File
@@ -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,
}],
}),
+1 -1
View File
@@ -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/);
+1 -1
View File
@@ -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",
);
+4 -2
View File
@@ -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;
+158
View File
@@ -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
View File
@@ -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);
+22
View File
@@ -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");
+2 -2
View File
@@ -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" }),
],
});
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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,
}],
),
+3 -3
View File
@@ -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,
);
});
+1 -1
View File
@@ -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",
+150 -47
View File
@@ -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,
};
+136
View File
@@ -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),