Files
Netcatty/domain/credentials.ts
2026-05-06 15:20:23 +08:00

111 lines
3.7 KiB
TypeScript

import type { SyncPayload } from "./sync";
const CREDENTIAL_ENCRYPTION_PREFIX = "enc:v1:";
/**
* Base64 pattern: only allows A-Z, a-z, 0-9, +, / and trailing = padding.
*/
const BASE64_RE = /^[A-Za-z0-9+/]+=*$/;
/**
* 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
*
* 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:".
*
* 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)
] as const;
export const isEncryptedCredentialPlaceholder = (
value: string | undefined | null,
): value is string => {
if (typeof value !== "string" || !value.startsWith(CREDENTIAL_ENCRYPTION_PREFIX)) {
return false;
}
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));
};
/**
* Strip enc:v1: placeholders from a single credential value.
* Used at the terminal connection boundary to avoid sending encrypted
* placeholders as actual passwords to SSH/Telnet servers.
*/
export const sanitizeCredentialValue = (
value: string | undefined,
): string | undefined => {
if (isEncryptedCredentialPlaceholder(value)) return undefined;
return value;
};
/**
* Scan a sync payload for any fields that still carry device-bound
* enc:v1: ciphertext. Returns the dotted paths of offending fields.
* Used as a pre-upload guard to prevent pushing un-decryptable data.
*/
export const findSyncPayloadEncryptedCredentialPaths = (
payload: SyncPayload,
): string[] => {
const issues: string[] = [];
payload.hosts.forEach((host, index) => {
if (isEncryptedCredentialPlaceholder(host.password)) {
issues.push(`hosts[${index}].password`);
}
if (isEncryptedCredentialPlaceholder(host.telnetPassword)) {
issues.push(`hosts[${index}].telnetPassword`);
}
if (isEncryptedCredentialPlaceholder(host.proxyConfig?.password)) {
issues.push(`hosts[${index}].proxyConfig.password`);
}
});
payload.keys.forEach((key, index) => {
if (isEncryptedCredentialPlaceholder(key.privateKey)) {
issues.push(`keys[${index}].privateKey`);
}
if (isEncryptedCredentialPlaceholder(key.passphrase)) {
issues.push(`keys[${index}].passphrase`);
}
});
payload.identities?.forEach((identity, index) => {
if (isEncryptedCredentialPlaceholder(identity.password)) {
issues.push(`identities[${index}].password`);
}
});
payload.proxyProfiles?.forEach((profile, index) => {
if (isEncryptedCredentialPlaceholder(profile.config.password)) {
issues.push(`proxyProfiles[${index}].config.password`);
}
});
payload.groupConfigs?.forEach((config, index) => {
if (isEncryptedCredentialPlaceholder(config.password)) {
issues.push(`groupConfigs[${index}].password`);
}
if (isEncryptedCredentialPlaceholder(config.telnetPassword)) {
issues.push(`groupConfigs[${index}].telnetPassword`);
}
if (isEncryptedCredentialPlaceholder(config.proxyConfig?.password)) {
issues.push(`groupConfigs[${index}].proxyConfig.password`);
}
});
return issues;
};