Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/components/hosts/HostsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -589,8 +589,7 @@ export default function HostsPage() {

const handleSubmit = async (data: ConnectionFormData, secrets: HostFormSecrets) => {
try {
const saved = await saveHostFromForm(editing, data, secrets, selectedVaultIds[0] ?? "personal");
if (!editing && saved) setEditingId(saved.id);
await saveHostFromForm(editing, data, secrets, selectedVaultIds[0] ?? "personal", (created) => setEditingId(created.id));
} catch (err) {
reportSaveError(err);
}
Expand Down
9 changes: 5 additions & 4 deletions src/components/keychain/KeychainPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,8 +461,9 @@ export default function KeychainPage() {

const handleKeySubmit = async (data: SshKeyFormData, privateKey: string | null, publicKey: string | null, passphrase: string | null) => {
try {
const key = await saveKeyFromForm(editingKey, data, privateKey, publicKey, passphrase, selectedVaultIds[0] ?? "personal");
if (!editingKey) setEditingKeyId(key.id);
await saveKeyFromForm(
editingKey, data, privateKey, publicKey, passphrase, selectedVaultIds[0] ?? "personal", (created) => setEditingKeyId(created.id),
);
} catch (err) {
reportSaveError(err);
}
Expand All @@ -474,10 +475,10 @@ export default function KeychainPage() {
inlineKeyMaterial?: { label?: string; privateKey: string; publicKey: string },
) => {
try {
const identity = await saveIdentityFromForm(
await saveIdentityFromForm(
editingIdentity, data, password, inlineKeyMaterial, inlineKeyIdRef, selectedVaultIds[0] ?? "personal",
(created) => setEditingIdentityId(created.id),
);
if (!editingIdentity) setEditingIdentityId(identity.id);
} catch (err) {
reportSaveError(err);
}
Expand Down
5 changes: 1 addition & 4 deletions src/components/mobile/screens/MobileHostEditScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,7 @@ export default function MobileHostEditScreen({ hostId }: { hostId?: string }) {
ref={formRef}
hideChrome
initial={editing ?? undefined}
onSubmit={async (data, secrets) => {
const saved = await saveHostFromForm(editing, data, secrets, createVaultId);
if (!editing && saved) setEditingId(saved.id);
}}
onSubmit={(data, secrets) => saveHostFromForm(editing, data, secrets, createVaultId, (created) => setEditingId(created.id))}
onClose={pop}
vaults={[]}
/>
Expand Down
10 changes: 4 additions & 6 deletions src/components/mobile/screens/MobileIdentityEditScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,10 @@ export default function MobileIdentityEditScreen({ identityId }: { identityId?:
hideChrome
initial={editing ?? undefined}
flushRef={flushRef}
onSubmit={async (data, password, inlineKeyMaterial) => {
const saved = await saveIdentityFromForm(
editing, data, password, inlineKeyMaterial, inlineKeyIdRef, createVaultId,
);
if (!editing) setEditingId(saved.id);
}}
onSubmit={(data, password, inlineKeyMaterial) =>
saveIdentityFromForm(
editing, data, password, inlineKeyMaterial, inlineKeyIdRef, createVaultId, (created) => setEditingId(created.id),
)}
onClose={pop}
/>
</div>
Expand Down
6 changes: 2 additions & 4 deletions src/components/mobile/screens/MobileKeyEditScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,8 @@ export default function MobileKeyEditScreen({ keyId, mode }: { keyId?: string; m
initial={editing ?? undefined}
initialMode={mode}
flushRef={flushRef}
onSubmit={async (data, privateKey, publicKey, passphrase) => {
const saved = await saveKeyFromForm(editing, data, privateKey, publicKey, passphrase, createVaultId);
if (!editing) setEditingId(saved.id);
}}
onSubmit={(data, privateKey, publicKey, passphrase) =>
saveKeyFromForm(editing, data, privateKey, publicKey, passphrase, createVaultId, (created) => setEditingId(created.id))}
onClose={pop}
/>
</div>
Expand Down
6 changes: 3 additions & 3 deletions src/services/editorMoveSecrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,17 @@ const editors = [
{
kind: "host", key: "password:c1",
save: (vault_id: string, value: string, from = "personal") =>
saveHostFromForm({ id: "c1", vault_id: from } as Connection, { tags: [], vault_id }, { ...none, password: value }, "personal"),
saveHostFromForm({ id: "c1", vault_id: from } as Connection, { tags: [], vault_id }, { ...none, password: value }, "personal", () => {}),
},
{
kind: "key", key: "key:k1:private",
save: (vault_id: string, value: string, from = "personal") =>
saveKeyFromForm({ id: "k1", vault_id: from } as SshKey, { tags: [], vault_id }, value, null, null, "personal"),
saveKeyFromForm({ id: "k1", vault_id: from } as SshKey, { tags: [], vault_id }, value, null, null, "personal", () => {}),
},
{
kind: "identity", key: "identity:i1:password",
save: (vault_id: string, value: string, from = "personal") =>
saveIdentityFromForm({ id: "i1", vault_id: from } as Identity, { vault_id } as never, value, undefined, { current: null }, "personal"),
saveIdentityFromForm({ id: "i1", vault_id: from } as Identity, { vault_id } as never, value, undefined, { current: null }, "personal", () => {}),
},
];

Expand Down
30 changes: 24 additions & 6 deletions src/services/hostForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe("saveHostFromForm", () => {
storeSecret.mockImplementation(async (k: string) => { calls.push(`store ${k}`); });
const personal = { id: "c1", vault_id: "personal" } as never;

await saveHostFromForm(personal, { tags: [], vault_id: "v-other" }, { ...none, password: "new" }, "personal");
await saveHostFromForm(personal, { tags: [], vault_id: "v-other" }, { ...none, password: "new" }, "personal", () => {});

expect(moveWithSecrets).toHaveBeenCalledWith("connection", personal, "v-other", expect.any(Function), [
["password:c1", "new"], ["key:c1", null], ["passphrase:c1", null], ["proxy_password:c1", null], ["knock_sequence:c1", null],
Expand All @@ -49,31 +49,49 @@ describe("saveHostFromForm", () => {
});

it("stores the proxy password locally", async () => {
await saveHostFromForm(editing, { tags: [] }, { ...none, proxy_password: "pp" }, "personal");
await saveHostFromForm(editing, { tags: [] }, { ...none, proxy_password: "pp" }, "personal", () => {});
expect(storeSecret).toHaveBeenCalledWith("proxy_password:c1", "pp");
});

it("stores the knock sequence locally", async () => {
await saveHostFromForm(editing, { tags: [] }, { ...none, knock_sequence: "666/tcp" }, "personal");
await saveHostFromForm(editing, { tags: [] }, { ...none, knock_sequence: "666/tcp" }, "personal", () => {});
expect(storeSecret).toHaveBeenCalledWith("knock_sequence:c1", "666/tcp");
});

it("clears the proxy password when emptied", async () => {
await saveHostFromForm(editing, { tags: [] }, { ...none, proxy_password: "" }, "personal");
await saveHostFromForm(editing, { tags: [] }, { ...none, proxy_password: "" }, "personal", () => {});
expect(deleteSecret).toHaveBeenCalledWith("proxy_password:c1");
});

it("an edit that clears a password rejects when deleteSecret rejects", async () => {
deleteSecret.mockRejectedValueOnce(new Error("withdraw failed"));
await expect(
saveHostFromForm(editing, { tags: [] }, { ...none, password: "" }, "personal"),
saveHostFromForm(editing, { tags: [] }, { ...none, password: "" }, "personal", () => {}),
).rejects.toThrow("withdraw failed");
});

it("an upload failure while saving a host form reaches the caller", async () => {
storeSecret.mockRejectedValue(new TeamSecretUploadError("password:c1", new Error("403")));
await expect(
saveHostFromForm(editing, { tags: [] }, { ...none, password: "pw" }, "v1"),
saveHostFromForm(editing, { tags: [] }, { ...none, password: "pw" }, "v1", () => {}),
).rejects.toBeInstanceOf(TeamSecretUploadError);
});

it("a new host whose secret save fails is announced first, so the next save edits it instead of creating another", async () => {
saveConnection.mockResolvedValue({ id: "n1" });
storeSecret.mockRejectedValue(new TeamSecretUploadError("password:n1", new Error("403")));
const created = vi.fn();

await expect(
saveHostFromForm(null, { tags: [] }, { ...none, password: "pw" }, "team-1", created),
).rejects.toBeInstanceOf(TeamSecretUploadError);

expect(created).toHaveBeenCalledWith({ id: "n1" });
});

it("an edit announces nothing", async () => {
const created = vi.fn();
await saveHostFromForm(editing, { tags: [] }, none, "personal", created);
expect(created).not.toHaveBeenCalled();
});
});
16 changes: 9 additions & 7 deletions src/services/hostForm.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Connection, ConnectionFormData } from "@/types";
import { useConnectionStore } from "@/stores/connectionStore";
import { CONNECTION_SECRET_FIELDS, CONNECTION_SECRET_KEYS, type ConnectionSecretField } from "@/services/teamVaultSecretKeys";
import { moveWithSecrets, storeNewSecrets, type SecretEdit } from "@/services/vaultObjectSecrets";
import { moveWithSecrets, createWithSecrets, type SecretEdit } from "@/services/vaultObjectSecrets";

export type HostFormSecrets = Record<ConnectionSecretField, string | null>;

Expand All @@ -17,13 +17,15 @@ export async function saveHostFromForm(
data: ConnectionFormData,
secrets: HostFormSecrets,
fallbackVaultId: string,
): Promise<Connection | null> {
onCreated: (created: Connection) => void,
): Promise<void> {
const { updateConnection, saveConnection } = useConnectionStore.getState();
if (editing) {
await moveWithSecrets("connection", editing, data.vault_id, () => updateConnection(editing.id, data), secretEdits(editing.id, secrets));
return editing;
return moveWithSecrets("connection", editing, data.vault_id, () => updateConnection(editing.id, data), secretEdits(editing.id, secrets));
}
const conn = await saveConnection({ ...data, vault_id: data.vault_id ?? fallbackVaultId });
if (conn) await storeNewSecrets(secretEdits(conn.id, secrets));
return conn ?? null;
await createWithSecrets(
() => saveConnection({ ...data, vault_id: data.vault_id ?? fallbackVaultId }),
(id) => secretEdits(id, secrets),
onCreated,
);
}
43 changes: 37 additions & 6 deletions src/services/keychainForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ beforeEach(() => {

describe("editing hands the edited fields to the move", () => {
test("a key passes its halves, written after a same-location update", async () => {
await saveKeyFromForm(existing, { tags: [], vault_id: "v-other" }, null, null, "pass", "personal");
await saveKeyFromForm(existing, { tags: [], vault_id: "v-other" }, null, null, "pass", "personal", () => {});
expect(h.moveWithSecrets).toHaveBeenCalledWith("key", existing, "v-other", expect.any(Function), [
["key:k1:private", null], ["key:k1:public", null], ["key:k1:passphrase", "pass"],
]);
Expand All @@ -57,7 +57,7 @@ describe("editing hands the edited fields to the move", () => {

test("an identity passes its password, written after a same-location update", async () => {
const identity = { id: "i1", vault_id: "v-other" } as Identity;
await saveIdentityFromForm(identity, { vault_id: "personal" } as never, "pw", undefined, { current: null }, "personal");
await saveIdentityFromForm(identity, { vault_id: "personal" } as never, "pw", undefined, { current: null }, "personal", () => {});
expect(h.moveWithSecrets).toHaveBeenCalledWith("identity", identity, "personal", expect.any(Function), [
["identity:i1:password", "pw"],
]);
Expand All @@ -68,7 +68,7 @@ describe("editing hands the edited fields to the move", () => {

describe("saveKeyFromForm", () => {
test("creates the key, then stores each half under its own secret name", async () => {
await saveKeyFromForm(null, { tags: [] }, "PRIV", "PUB", "pass", "personal");
await saveKeyFromForm(null, { tags: [] }, "PRIV", "PUB", "pass", "personal", () => {});
expect(saveKey).toHaveBeenCalledWith({ tags: [], vault_id: "personal" });
expect(storeSecret.mock.calls).toEqual([
["key:new:private", "PRIV"],
Expand All @@ -79,14 +79,14 @@ describe("saveKeyFromForm", () => {
});

test("a null half is left untouched, an emptied one is deleted on edit", async () => {
await saveKeyFromForm(existing, { tags: [] }, null, "", "pass", "personal");
await saveKeyFromForm(existing, { tags: [] }, null, "", "pass", "personal", () => {});
expect(updateKey).toHaveBeenCalledWith("k1", { tags: [] });
expect(storeSecret.mock.calls).toEqual([["key:k1:passphrase", "pass"]]);
expect(deleteSecret.mock.calls).toEqual([["key:k1:public"]]);
});

test("an empty half on create writes nothing rather than deleting", async () => {
await saveKeyFromForm(null, { tags: [] }, "PRIV", "", "", "personal");
await saveKeyFromForm(null, { tags: [] }, "PRIV", "", "", "personal", () => {});
expect(storeSecret.mock.calls).toEqual([["key:new:private", "PRIV"]]);
expect(deleteSecret).not.toHaveBeenCalled();
});
Expand All @@ -95,11 +95,42 @@ describe("saveKeyFromForm", () => {
describe("saveIdentityFromForm", () => {
test("an inline key is filed in the identity's vault, not the personal one", async () => {
h.saveIdentity.mockResolvedValue({ id: "i-new" });
await saveIdentityFromForm(null, { username: "root", tags: [] }, null, { privateKey: "PRIV", publicKey: "" }, { current: null }, "team-1");
await saveIdentityFromForm(null, { username: "root", tags: [] }, null, { privateKey: "PRIV", publicKey: "" }, { current: null }, "team-1", () => {});
expect(saveKey).toHaveBeenCalledWith(expect.objectContaining({ vault_id: "team-1" }));
});
});

describe("a new object whose secret save fails", () => {
const refused = new TeamSecretUploadError("k", new Error("403"));

test("a key is announced first, so the next save edits it instead of creating another", async () => {
h.storeSecret.mockRejectedValue(refused);
const created = vi.fn();

await expect(saveKeyFromForm(null, { tags: [] }, "PRIV", null, null, "team-1", created)).rejects.toBe(refused);

expect(created).toHaveBeenCalledWith({ id: "new", vault_id: "personal" });
});

test("an identity is announced first too", async () => {
h.saveIdentity.mockResolvedValue({ id: "i-new" });
h.storeSecret.mockRejectedValue(refused);
const created = vi.fn();

await expect(
saveIdentityFromForm(null, { username: "root", tags: [] }, "pw", undefined, { current: null }, "team-1", created),
).rejects.toBe(refused);

expect(created).toHaveBeenCalledWith({ id: "i-new" });
});

test("an edit announces nothing", async () => {
const created = vi.fn();
await saveKeyFromForm(existing, { tags: [] }, null, null, null, "personal", created);
expect(created).not.toHaveBeenCalled();
});
});

describe("unlinkIdentityFromHost", () => {
const identity = { id: "i1", username: "root", key_id: "k1" } as Identity;
const host = { id: "c1", name: "web", host: "h", port: 22, tags: [] } as never;
Expand Down
28 changes: 13 additions & 15 deletions src/services/keychainForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useIdentityStore } from "@/stores/identityStore";
import { connectionToFormData } from "@/stores/connectionStore";
import { storeSecret, getSecret } from "@/services/vault";
import { reportUploadFailure } from "@/services/secretRouting";
import { moveWithSecrets, storeNewSecrets, type SecretEdit } from "@/services/vaultObjectSecrets";
import { moveWithSecrets, createWithSecrets, type SecretEdit } from "@/services/vaultObjectSecrets";
import type { AuthType, Connection, ConnectionFormData, Identity, IdentityFormData, SshKey, SshKeyFormData } from "@/types";

type InlineKeyMaterial = { label?: string; privateKey: string; publicKey: string };
Expand All @@ -15,20 +15,16 @@ export async function saveKeyFromForm(
publicKey: string | null,
passphrase: string | null,
fallbackVaultId: string,
): Promise<SshKey> {
onCreated: (created: SshKey) => void,
): Promise<void> {
const { saveKey, updateKey } = useKeyStore.getState();
const edits = (id: string): SecretEdit[] => [
[`key:${id}:private`, privateKey],
[`key:${id}:public`, publicKey],
[`key:${id}:passphrase`, passphrase],
];
if (editing) {
await moveWithSecrets("key", editing, data.vault_id, () => updateKey(editing.id, data), edits(editing.id));
return editing;
}
const key = await saveKey({ ...data, vault_id: data.vault_id ?? fallbackVaultId });
await storeNewSecrets(edits(key.id));
return key;
if (editing) return moveWithSecrets("key", editing, data.vault_id, () => updateKey(editing.id, data), edits(editing.id));
await createWithSecrets(() => saveKey({ ...data, vault_id: data.vault_id ?? fallbackVaultId }), edits, onCreated);
}

/** `inlineKeyId` carries the key a previous autosave pass created, so repeated
Expand All @@ -40,7 +36,8 @@ export async function saveIdentityFromForm(
inlineKeyMaterial: InlineKeyMaterial | undefined,
inlineKeyId: { current: string | null },
fallbackVaultId: string,
): Promise<Identity> {
onCreated: (created: Identity) => void,
): Promise<void> {
const { saveIdentity, updateIdentity } = useIdentityStore.getState();
let resolvedData = data;

Expand All @@ -57,12 +54,13 @@ export async function saveIdentityFromForm(

const edits = (id: string): SecretEdit[] => [[`identity:${id}:password`, password]];
if (editing) {
await moveWithSecrets("identity", editing, resolvedData.vault_id, () => updateIdentity(editing.id, resolvedData), edits(editing.id));
return editing;
return moveWithSecrets("identity", editing, resolvedData.vault_id, () => updateIdentity(editing.id, resolvedData), edits(editing.id));
}
const identity = await saveIdentity({ ...resolvedData, vault_id: resolvedData.vault_id ?? fallbackVaultId });
await storeNewSecrets(edits(identity.id));
return identity;
await createWithSecrets(
() => saveIdentity({ ...resolvedData, vault_id: resolvedData.vault_id ?? fallbackVaultId }),
edits,
onCreated,
);
}

export async function unlinkIdentityFromHost(
Expand Down
12 changes: 11 additions & 1 deletion src/services/vaultObjectSecrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,17 @@ async function applyEdits(
}
}

export const storeNewSecrets = (edits: readonly SecretEdit[]) => applyEdits(edits, storeSecret, async () => {});
export async function createWithSecrets<T extends { id: string }>(
create: () => Promise<T | null | undefined>,
edits: (id: string) => readonly SecretEdit[],
onCreated: (created: T) => void,
): Promise<void> {
const created = await create();
if (!created) return;
// Announced before its secrets: a caller that never learns the id creates the object again on its next save.
onCreated(created);
await applyEdits(edits(created.id), storeSecret, async () => {});
}

export async function moveWithSecrets(
kind: SecretObjectKind,
Expand Down
Loading