refactor(crypto): remove pending share queue and credential sharing key

With server-unwrappable DEKs both sides of a share are always available,
so the needsReEncryption queue, CREDENTIAL_SHARING_KEY and the system_*
shadow columns on ssh_credentials are gone. A one-time boot cleanup
re-creates legacy pending share copies where possible (dropping
unresolvable ones with a warning) and drops the legacy columns.
This commit is contained in:
LukeGus
2026-07-16 00:29:54 -05:00
parent ee7768dd86
commit 1032a31e25
22 changed files with 322 additions and 881 deletions
@@ -0,0 +1,111 @@
import crypto from "crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const ownerDEK = crypto.randomBytes(32);
const targetDEK = crypto.randomBytes(32);
const state = vi.hoisted(() => ({
sharedRows: [] as Array<Record<string, unknown>>,
ownerCredential: null as Record<string, unknown> | null,
}));
vi.mock("../database/repositories/factory.js", () => ({
createCurrentCredentialRepository: () => ({
findByIdForUser: async (_userId: string, _id: number) =>
state.ownerCredential,
}),
createCurrentSharedCredentialRepository: () => ({
existsForHostAccessAndTargetUser: async () => state.sharedRows.length > 0,
create: async (row: Record<string, unknown>) => {
const created = { id: state.sharedRows.length + 1, ...row };
state.sharedRows.push(created);
return created;
},
listByOriginalCredentialId: async () => state.sharedRows,
updateById: async (id: number, update: Record<string, unknown>) => {
const row = state.sharedRows.find((r) => r.id === id);
if (row) Object.assign(row, update);
return row ?? null;
},
deleteByOriginalCredentialId: async () => 0,
}),
createCurrentRbacAccessRepository: () => ({
findSharedCredentialForHostAndUser: async () => state.sharedRows[0] ?? null,
}),
createCurrentRoleRepository: () => ({
listRoleUserIds: async () => [],
listUserRoleIds: async () => [],
}),
}));
vi.mock("./data-crypto.js", () => ({
DataCrypto: {
validateUserAccess: (userId: string) => {
if (userId === "owner") return ownerDEK;
if (userId === "target") return targetDEK;
throw new Error(`User ${userId} has no data encryption key`);
},
getUserDataKey: (userId: string) =>
userId === "owner" ? ownerDEK : userId === "target" ? targetDEK : null,
canUserAccessData: () => true,
},
}));
import { FieldCrypto } from "./field-crypto.js";
import { SharedCredentialManager } from "./shared-credential-manager.js";
const manager = SharedCredentialManager.getInstance();
beforeEach(() => {
state.sharedRows = [];
state.ownerCredential = {
id: 42,
userId: "owner",
username: "root",
authType: "password",
password: FieldCrypto.encryptField("hunter2", ownerDEK, "42", "password"),
key: null,
keyPassword: null,
keyType: null,
};
});
describe("SharedCredentialManager", () => {
it("shares a credential and the target can decrypt it", async () => {
await manager.createSharedCredentialForUser(7, 42, "target", "owner");
expect(state.sharedRows).toHaveLength(1);
const row = state.sharedRows[0];
expect(row.encryptedPassword).not.toBe("hunter2");
expect(row.targetUserId).toBe("target");
const resolved = await manager.getSharedCredentialForUser(1, "target");
expect(resolved).toMatchObject({
username: "root",
authType: "password",
password: "hunter2",
});
});
it("fails the share when a participant has no key instead of queueing", async () => {
await expect(
manager.createSharedCredentialForUser(7, 42, "locked-user", "owner"),
).rejects.toThrow(/no data encryption key/);
expect(state.sharedRows).toHaveLength(0);
});
it("re-encrypts existing share copies when the original changes", async () => {
await manager.createSharedCredentialForUser(7, 42, "target", "owner");
state.ownerCredential!.password = FieldCrypto.encryptField(
"new-password",
ownerDEK,
"42",
"password",
);
await manager.updateSharedCredentialsForOriginal(42, "owner");
const resolved = await manager.getSharedCredentialForUser(1, "target");
expect(resolved?.password).toBe("new-password");
});
});