Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
529 lines
19 KiB
TypeScript
529 lines
19 KiB
TypeScript
import type { SSHKey } from "../domain/models";
|
|
import { isEncryptedCredentialPlaceholder } from "../domain/credentials";
|
|
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../infrastructure/config/storageKeys";
|
|
import { localStorageAdapter } from "../infrastructure/persistence/localStorageAdapter";
|
|
import { encryptField, decryptField } from "../infrastructure/persistence/secureFieldAdapter";
|
|
import { netcattyBridge } from "../infrastructure/services/netcattyBridge";
|
|
|
|
function defaultKeyPassphrasePathKey(keyPath: string): string {
|
|
const isWindowsPath = /^[A-Za-z]:[\\/]/u.test(keyPath) || /^[\\/]{2}/u.test(keyPath);
|
|
if (!isWindowsPath) return keyPath;
|
|
const normalized = keyPath.replace(/\\/g, "/");
|
|
return normalized.toLowerCase();
|
|
}
|
|
|
|
function matchingPathKeys(keyPaths: string[]): Set<string> {
|
|
return new Set(keyPaths.map(defaultKeyPassphrasePathKey));
|
|
}
|
|
|
|
export async function resolveDefaultKeyPassphraseAliases(keyPath: string): Promise<string[]> {
|
|
const aliases = new Set([keyPath]);
|
|
const isWindowsPath = /^[A-Za-z]:[\\/]/u.test(keyPath) || /^\\\\/u.test(keyPath);
|
|
const normalizedKeyPath = isWindowsPath ? keyPath.replace(/\\/g, "/") : keyPath;
|
|
aliases.add(normalizedKeyPath);
|
|
try {
|
|
const homeDir = await netcattyBridge.get()?.getHomeDir?.();
|
|
if (!homeDir) return [...aliases];
|
|
|
|
const normalizedHome = homeDir.replace(/\\/g, "/").replace(/\/$/u, "");
|
|
const comparableHome = defaultKeyPassphrasePathKey(normalizedHome);
|
|
const comparableKeyPath = defaultKeyPassphrasePathKey(normalizedKeyPath);
|
|
if (comparableKeyPath.startsWith(`${comparableHome}/`)) {
|
|
aliases.add(`~/${normalizedKeyPath.slice(normalizedHome.length + 1)}`);
|
|
} else if (normalizedKeyPath.startsWith("~/")) {
|
|
const suffix = normalizedKeyPath.slice(2);
|
|
aliases.add(`${normalizedHome}/${suffix}`);
|
|
const nativeHome = homeDir.replace(/[\\/]+$/u, "");
|
|
const nativeSeparator = homeDir.includes("\\") ? "\\" : "/";
|
|
aliases.add(`${nativeHome}${nativeSeparator}${suffix.replace(/\//g, nativeSeparator)}`);
|
|
}
|
|
} catch {
|
|
// The renderer bridge may be unavailable in tests or web fallback mode.
|
|
}
|
|
return [...aliases];
|
|
}
|
|
|
|
let passphraseMutationQueue: Promise<void> = Promise.resolve();
|
|
|
|
function runPassphraseMutation<T>(mutation: () => Promise<T>): Promise<T> {
|
|
const result = passphraseMutationQueue.then(mutation, mutation);
|
|
passphraseMutationQueue = result.then(() => undefined, () => undefined);
|
|
return result;
|
|
}
|
|
|
|
function writeDefaultKeyPassphraseUnlocked(
|
|
keyPath: string,
|
|
encrypted: string,
|
|
aliases: string[],
|
|
): void {
|
|
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? {};
|
|
const aliasKeys = matchingPathKeys(aliases);
|
|
for (const storedPath of Object.keys(store)) {
|
|
if (storedPath !== keyPath && aliasKeys.has(defaultKeyPassphrasePathKey(storedPath))) {
|
|
delete store[storedPath];
|
|
}
|
|
}
|
|
store[keyPath] = encrypted;
|
|
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
|
|
}
|
|
|
|
async function saveDefaultKeyPassphraseUnlocked(keyPath: string, passphrase: string): Promise<void> {
|
|
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
|
const encrypted = await encryptField(passphrase) ?? passphrase;
|
|
writeDefaultKeyPassphraseUnlocked(keyPath, encrypted, aliases);
|
|
}
|
|
|
|
export async function saveDefaultKeyPassphrase(keyPath: string, passphrase: string): Promise<void> {
|
|
return runPassphraseMutation(() => saveDefaultKeyPassphraseUnlocked(keyPath, passphrase));
|
|
}
|
|
|
|
function matchingStoreEntriesChanged(
|
|
previous: Record<string, string>,
|
|
latest: Record<string, string> | null,
|
|
aliasKeys: Set<string>,
|
|
): boolean {
|
|
const paths = new Set([
|
|
...Object.keys(previous),
|
|
...Object.keys(latest ?? {}),
|
|
]);
|
|
for (const path of paths) {
|
|
if (
|
|
aliasKeys.has(defaultKeyPassphrasePathKey(path))
|
|
&& previous[path] !== latest?.[path]
|
|
) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function loadDefaultKeyPassphraseOnce(keyPath: string): Promise<{
|
|
retry: boolean;
|
|
value: string | null;
|
|
}> {
|
|
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
|
if (!store) return { retry: false, value: null };
|
|
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
|
const aliasKeys = matchingPathKeys(aliases);
|
|
const storedPaths = Object.keys(store).filter((path) => (
|
|
aliasKeys.has(defaultKeyPassphrasePathKey(path))
|
|
));
|
|
const exactIndex = storedPaths.indexOf(keyPath);
|
|
if (exactIndex > 0) {
|
|
storedPaths.unshift(storedPaths.splice(exactIndex, 1)[0]);
|
|
}
|
|
|
|
const invalidEntries = new Map<string, string>();
|
|
for (const storedPath of storedPaths) {
|
|
const decrypted = await decryptField(store[storedPath]);
|
|
if (decrypted && !isEncryptedCredentialPlaceholder(decrypted)) {
|
|
const latestStore = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
|
if (matchingStoreEntriesChanged(store, latestStore, aliasKeys)) {
|
|
return { retry: true, value: null };
|
|
}
|
|
if (latestStore) {
|
|
let changed = false;
|
|
for (const duplicatePath of storedPaths) {
|
|
if (duplicatePath !== storedPath && duplicatePath in latestStore) {
|
|
delete latestStore[duplicatePath];
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, latestStore);
|
|
}
|
|
}
|
|
return { retry: false, value: decrypted };
|
|
}
|
|
invalidEntries.set(storedPath, store[storedPath]);
|
|
}
|
|
if (invalidEntries.size > 0) {
|
|
const latestStore = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
|
if (matchingStoreEntriesChanged(store, latestStore, aliasKeys)) {
|
|
return { retry: true, value: null };
|
|
}
|
|
if (latestStore) {
|
|
let changed = false;
|
|
for (const [storedPath, invalidValue] of invalidEntries) {
|
|
if (latestStore[storedPath] === invalidValue) {
|
|
delete latestStore[storedPath];
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, latestStore);
|
|
}
|
|
}
|
|
}
|
|
return { retry: false, value: null };
|
|
}
|
|
|
|
export async function loadDefaultKeyPassphrase(keyPath: string): Promise<string | null> {
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
const result = await loadDefaultKeyPassphraseOnce(keyPath);
|
|
if (!result.retry) return result.value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export type DefaultKeyPassphraseExportRead =
|
|
| { status: "missing" }
|
|
| { status: "readable"; value: string }
|
|
| { status: "unreadable" };
|
|
|
|
export interface DefaultKeyPassphraseVerificationRead {
|
|
values: string[];
|
|
unreadable: boolean;
|
|
present: boolean;
|
|
}
|
|
|
|
async function readDefaultKeyPassphrasesForVerificationOnce(
|
|
keyPath: string,
|
|
): Promise<{ retry: boolean; result: DefaultKeyPassphraseVerificationRead }> {
|
|
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
|
const aliasKeys = matchingPathKeys(aliases);
|
|
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
|
if (!store) {
|
|
return { retry: false, result: { values: [], unreadable: false, present: false } };
|
|
}
|
|
|
|
const storedPaths = Object.keys(store).filter((path) => (
|
|
aliasKeys.has(defaultKeyPassphrasePathKey(path))
|
|
));
|
|
const exactIndex = storedPaths.indexOf(keyPath);
|
|
if (exactIndex > 0) {
|
|
storedPaths.unshift(storedPaths.splice(exactIndex, 1)[0]);
|
|
}
|
|
if (storedPaths.length === 0) {
|
|
return { retry: false, result: { values: [], unreadable: false, present: false } };
|
|
}
|
|
|
|
const values = new Set<string>();
|
|
let unreadable = false;
|
|
for (const storedPath of storedPaths) {
|
|
try {
|
|
const decrypted = await decryptField(store[storedPath]);
|
|
if (decrypted && !isEncryptedCredentialPlaceholder(decrypted)) {
|
|
values.add(decrypted);
|
|
} else {
|
|
unreadable = true;
|
|
}
|
|
} catch {
|
|
// Export must not mutate saved credentials when secure storage is unavailable.
|
|
unreadable = true;
|
|
}
|
|
}
|
|
const latestStore = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
|
if (matchingStoreEntriesChanged(store, latestStore, aliasKeys)) {
|
|
return {
|
|
retry: true,
|
|
result: { values: [], unreadable: true, present: true },
|
|
};
|
|
}
|
|
return {
|
|
retry: false,
|
|
result: { values: [...values], unreadable, present: true },
|
|
};
|
|
}
|
|
|
|
export async function readDefaultKeyPassphrasesForVerification(
|
|
keyPath: string,
|
|
): Promise<DefaultKeyPassphraseVerificationRead> {
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
const read = await readDefaultKeyPassphrasesForVerificationOnce(keyPath);
|
|
if (!read.retry) return read.result;
|
|
}
|
|
return { values: [], unreadable: true, present: true };
|
|
}
|
|
|
|
export async function readDefaultKeyPassphraseForExport(
|
|
keyPath: string,
|
|
): Promise<DefaultKeyPassphraseExportRead> {
|
|
const read = await readDefaultKeyPassphrasesForVerification(keyPath);
|
|
if (read.values[0]) return { status: "readable", value: read.values[0] };
|
|
return read.unreadable ? { status: "unreadable" } : { status: "missing" };
|
|
}
|
|
|
|
export async function readRememberedKeyPassphrases(
|
|
keyPath: string,
|
|
keys: SSHKey[],
|
|
): Promise<{ values: string[]; unreadable: boolean }> {
|
|
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
|
const aliasKeys = matchingPathKeys(aliases);
|
|
const values = new Set<string>();
|
|
let unreadable = false;
|
|
|
|
const sideStore = await readDefaultKeyPassphrasesForVerification(keyPath);
|
|
for (const value of sideStore.values) values.add(value);
|
|
if (sideStore.unreadable) unreadable = true;
|
|
|
|
for (const key of keys) {
|
|
if (
|
|
key.source !== "reference"
|
|
|| !key.filePath
|
|
|| !aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
|
|| !key.passphrase
|
|
) continue;
|
|
if (isEncryptedCredentialPlaceholder(key.passphrase)) {
|
|
unreadable = true;
|
|
} else {
|
|
values.add(key.passphrase);
|
|
}
|
|
}
|
|
|
|
return { values: [...values], unreadable };
|
|
}
|
|
|
|
export async function readExportableRememberedKeyPassphrases(
|
|
keyPath: string,
|
|
keys: SSHKey[],
|
|
): Promise<{ values: string[]; unreadable: boolean }> {
|
|
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
|
const aliasKeys = matchingPathKeys(aliases);
|
|
const values = new Set<string>();
|
|
let unreadable = false;
|
|
|
|
const hasExplicitOptOut = keys.some((key) => (
|
|
key.source === "reference"
|
|
&& key.savePassphrase === false
|
|
&& key.filePath
|
|
&& aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
|
));
|
|
if (hasExplicitOptOut) return { values: [], unreadable: false };
|
|
|
|
const sideStore = await readDefaultKeyPassphrasesForVerification(keyPath);
|
|
for (const value of sideStore.values) values.add(value);
|
|
if (sideStore.unreadable) unreadable = true;
|
|
|
|
for (const key of keys) {
|
|
if (
|
|
key.source !== "reference"
|
|
|| key.savePassphrase === false
|
|
|| !key.filePath
|
|
|| !aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
|
|| !key.passphrase
|
|
) continue;
|
|
if (isEncryptedCredentialPlaceholder(key.passphrase)) {
|
|
unreadable = true;
|
|
} else {
|
|
values.add(key.passphrase);
|
|
}
|
|
}
|
|
|
|
return { values: [...values], unreadable };
|
|
}
|
|
|
|
function removeDefaultKeyPassphrasesUnlocked(keyPaths: string[]): void {
|
|
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
|
|
if (!store) return;
|
|
const pathKeys = matchingPathKeys(keyPaths);
|
|
let changed = false;
|
|
for (const storedPath of Object.keys(store)) {
|
|
if (pathKeys.has(defaultKeyPassphrasePathKey(storedPath))) {
|
|
delete store[storedPath];
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
|
|
}
|
|
}
|
|
|
|
export async function removeDefaultKeyPassphrases(keyPaths: string[]): Promise<void> {
|
|
return runPassphraseMutation(async () => {
|
|
removeDefaultKeyPassphrasesUnlocked(keyPaths);
|
|
});
|
|
}
|
|
|
|
export async function removeDefaultKeyPassphraseAliases(keyPaths: string[]): Promise<string[]> {
|
|
return runPassphraseMutation(async () => {
|
|
const aliases = Array.from(new Set((await Promise.all(
|
|
keyPaths.map(resolveDefaultKeyPassphraseAliases),
|
|
)).flat()));
|
|
removeDefaultKeyPassphrasesUnlocked(aliases);
|
|
return aliases;
|
|
});
|
|
}
|
|
|
|
export async function clearRememberedKeyPassphrases(args: {
|
|
keyPaths: string[];
|
|
keyIds?: string[];
|
|
getKeys: () => SSHKey[];
|
|
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
|
setCurrentKeys?: (keys: SSHKey[]) => void;
|
|
}): Promise<void> {
|
|
return runPassphraseMutation(async () => {
|
|
const aliases = Array.from(new Set((await Promise.all(
|
|
args.keyPaths.map(resolveDefaultKeyPassphraseAliases),
|
|
)).flat()));
|
|
removeDefaultKeyPassphrasesUnlocked(aliases);
|
|
const currentKeys = args.getKeys();
|
|
const withoutReferencePassphrases = clearReferenceKeyPassphrases(currentKeys, aliases);
|
|
const updatedKeys = clearKeyPassphrasesByIds(withoutReferencePassphrases, args.keyIds);
|
|
if (updatedKeys === currentKeys) return;
|
|
args.setCurrentKeys?.(updatedKeys);
|
|
await args.updateKeys(updatedKeys);
|
|
});
|
|
}
|
|
|
|
export async function deleteVaultKey(args: {
|
|
keyId: string;
|
|
getKeys: () => SSHKey[];
|
|
updateKeys: (keys: SSHKey[]) => void;
|
|
}): Promise<void> {
|
|
const keys = args.getKeys();
|
|
const key = keys.find((candidate) => candidate.id === args.keyId);
|
|
if (!key) return;
|
|
|
|
args.updateKeys(keys.filter((candidate) => candidate.id !== args.keyId));
|
|
if (key.source !== "reference" || !key.filePath) return;
|
|
|
|
await runPassphraseMutation(async () => {
|
|
const deletedAliases = await resolveDefaultKeyPassphraseAliases(key.filePath!);
|
|
const deletedAliasKeys = matchingPathKeys(deletedAliases);
|
|
const currentReferencePathKeys = matchingPathKeys(args.getKeys()
|
|
.filter((candidate) => candidate.source === "reference" && candidate.filePath)
|
|
.map((candidate) => candidate.filePath!));
|
|
const pathStillReferenced = [...currentReferencePathKeys]
|
|
.some((path) => deletedAliasKeys.has(path));
|
|
if (!pathStillReferenced) {
|
|
removeDefaultKeyPassphrasesUnlocked(deletedAliases);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function clearReferenceKeyPassphrases(keys: SSHKey[], keyPaths: string[]): SSHKey[] {
|
|
const pathKeys = matchingPathKeys(keyPaths);
|
|
let changed = false;
|
|
const updated = keys.map((key) => {
|
|
if (
|
|
key.source === "reference"
|
|
&& key.filePath
|
|
&& pathKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
|
&& key.passphrase
|
|
) {
|
|
changed = true;
|
|
return { ...key, passphrase: undefined, savePassphrase: false };
|
|
}
|
|
return key;
|
|
});
|
|
return changed ? updated : keys;
|
|
}
|
|
|
|
export function clearKeyPassphrasesByIds(keys: SSHKey[], keyIds: string[] = []): SSHKey[] {
|
|
if (keyIds.length === 0) return keys;
|
|
const ids = new Set(keyIds);
|
|
let changed = false;
|
|
const updated = keys.map((key) => {
|
|
if (ids.has(key.id) && key.passphrase) {
|
|
changed = true;
|
|
return { ...key, passphrase: undefined, savePassphrase: false };
|
|
}
|
|
return key;
|
|
});
|
|
return changed ? updated : keys;
|
|
}
|
|
|
|
export function shouldUpdateReferenceKeyPassphrase(key?: SSHKey | null): boolean {
|
|
return Boolean(
|
|
key &&
|
|
(!key.passphrase || isEncryptedCredentialPlaceholder(key.passphrase)),
|
|
);
|
|
}
|
|
|
|
export async function rememberKeyPassphrase(args: {
|
|
keyPath: string;
|
|
passphrase: string;
|
|
keys: SSHKey[];
|
|
getKeys?: () => SSHKey[];
|
|
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
|
setCurrentKeys?: (keys: SSHKey[]) => void;
|
|
}): Promise<void> {
|
|
return runPassphraseMutation(() => rememberKeyPassphraseUnlocked(args));
|
|
}
|
|
|
|
async function rememberKeyPassphraseUnlocked(args: {
|
|
keyPath: string;
|
|
passphrase: string;
|
|
keys: SSHKey[];
|
|
getKeys?: () => SSHKey[];
|
|
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
|
setCurrentKeys?: (keys: SSHKey[]) => void;
|
|
}): Promise<void> {
|
|
const { keyPath, passphrase, keys, getKeys, updateKeys, setCurrentKeys } = args;
|
|
const aliases = await resolveDefaultKeyPassphraseAliases(keyPath);
|
|
const aliasKeys = matchingPathKeys(aliases);
|
|
const encrypted = await encryptField(passphrase) ?? passphrase;
|
|
writeDefaultKeyPassphraseUnlocked(keyPath, encrypted, aliases);
|
|
|
|
let changed = false;
|
|
const updated = (getKeys?.() ?? keys).map((key) => {
|
|
if (
|
|
key.source !== "reference"
|
|
|| !key.filePath
|
|
|| !aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
|
) return key;
|
|
changed = true;
|
|
return { ...key, passphrase, savePassphrase: true };
|
|
});
|
|
if (!changed) return;
|
|
setCurrentKeys?.(updated);
|
|
await updateKeys(updated);
|
|
}
|
|
|
|
export type RememberImportedKeyPassphraseResult = "saved" | "conflict" | "unreadable";
|
|
|
|
function referenceKeyPassphraseFingerprint(keys: SSHKey[], aliasKeys: Set<string>): string {
|
|
return JSON.stringify(keys
|
|
.filter((key) => (
|
|
key.source === "reference"
|
|
&& key.filePath
|
|
&& aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
|
))
|
|
.map((key) => ({
|
|
id: key.id,
|
|
filePath: key.filePath,
|
|
passphrase: key.passphrase,
|
|
savePassphrase: key.savePassphrase,
|
|
}))
|
|
.sort((left, right) => left.id.localeCompare(right.id)));
|
|
}
|
|
|
|
export async function rememberImportedKeyPassphrase(args: {
|
|
keyPath: string;
|
|
passphrase: string;
|
|
keys: SSHKey[];
|
|
getKeys?: () => SSHKey[];
|
|
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
|
|
setCurrentKeys?: (keys: SSHKey[]) => void;
|
|
}): Promise<RememberImportedKeyPassphraseResult> {
|
|
return runPassphraseMutation(async () => {
|
|
const aliases = await resolveDefaultKeyPassphraseAliases(args.keyPath);
|
|
const aliasKeys = matchingPathKeys(aliases);
|
|
const currentKeys = args.getKeys?.() ?? args.keys;
|
|
const initialKeyFingerprint = referenceKeyPassphraseFingerprint(currentKeys, aliasKeys);
|
|
const existing = await readRememberedKeyPassphrases(args.keyPath, currentKeys);
|
|
if (existing.unreadable) return "unreadable";
|
|
if (existing.values.some((value) => value !== args.passphrase)) return "conflict";
|
|
const encrypted = await encryptField(args.passphrase) ?? args.passphrase;
|
|
const latestKeys = args.getKeys?.() ?? args.keys;
|
|
if (referenceKeyPassphraseFingerprint(latestKeys, aliasKeys) !== initialKeyFingerprint) {
|
|
return "conflict";
|
|
}
|
|
writeDefaultKeyPassphraseUnlocked(args.keyPath, encrypted, aliases);
|
|
let changed = false;
|
|
const updated = latestKeys.map((key) => {
|
|
if (
|
|
key.source !== "reference"
|
|
|| !key.filePath
|
|
|| !aliasKeys.has(defaultKeyPassphrasePathKey(key.filePath))
|
|
) return key;
|
|
changed = true;
|
|
return { ...key, passphrase: args.passphrase, savePassphrase: true };
|
|
});
|
|
if (changed) {
|
|
args.setCurrentKeys?.(updated);
|
|
await args.updateKeys(updated);
|
|
}
|
|
return "saved";
|
|
});
|
|
}
|