mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
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:
@@ -7,7 +7,6 @@ import {
|
||||
} from "./crypto-migration/dek-migration.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { DatabaseSaveTrigger } from "./database-save-trigger.js";
|
||||
import { databaseLogger, authLogger } from "./logger.js";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
@@ -178,25 +177,6 @@ class AuthManager {
|
||||
}
|
||||
|
||||
await DataCrypto.migrateCurrentUserSensitiveFields(userId, userDataKey);
|
||||
|
||||
try {
|
||||
const { CredentialSystemEncryptionMigration } =
|
||||
await import("./credential-system-encryption-migration.js");
|
||||
const credMigration = new CredentialSystemEncryptionMigration();
|
||||
const credResult = await credMigration.migrateUserCredentials(userId);
|
||||
|
||||
if (credResult.migrated > 0) {
|
||||
await DatabaseSaveTrigger.forceSave(
|
||||
"login_credential_migration_explicit_save",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Credential migration failed during login", {
|
||||
operation: "login_credential_migration_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Lazy encryption migration failed", error, {
|
||||
operation: "lazy_encryption_migration_error",
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CredentialSystemEncryptionMigration } from "./credential-system-encryption-migration.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
|
||||
const credentialRepository = {
|
||||
listMissingSystemEncryptionByUserId: vi.fn(),
|
||||
updateSystemEncryptionForUser: vi.fn(),
|
||||
};
|
||||
|
||||
const sharedCredentialRepository = {
|
||||
markNeedsReEncryptionByOriginalCredentialId: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("../database/repositories/factory.js", () => ({
|
||||
createCurrentCredentialRepository: vi.fn(() => credentialRepository),
|
||||
createCurrentSharedCredentialRepository: vi.fn(
|
||||
() => sharedCredentialRepository,
|
||||
),
|
||||
}));
|
||||
|
||||
describe("CredentialSystemEncryptionMigration", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
credentialRepository.listMissingSystemEncryptionByUserId.mockReset();
|
||||
credentialRepository.updateSystemEncryptionForUser.mockReset();
|
||||
sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId.mockReset();
|
||||
});
|
||||
|
||||
it("migrates missing credential system-key copies through repositories", async () => {
|
||||
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
vi.spyOn(
|
||||
SystemCrypto.getInstance(),
|
||||
"getCredentialSharingKey",
|
||||
).mockResolvedValue(Buffer.from("system-key"));
|
||||
vi.spyOn(FieldCrypto, "decryptField").mockImplementation(
|
||||
(_value, _key, _recordId, fieldName) => `plain-${fieldName}`,
|
||||
);
|
||||
vi.spyOn(FieldCrypto, "encryptField").mockImplementation(
|
||||
(value, _key, _recordId, fieldName) => `system-${fieldName}-${value}`,
|
||||
);
|
||||
credentialRepository.listMissingSystemEncryptionByUserId.mockResolvedValue([
|
||||
{
|
||||
id: 123,
|
||||
userId: "user-1",
|
||||
password: "encrypted-password",
|
||||
key: "encrypted-key",
|
||||
keyPassword: "encrypted-key-password",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
new CredentialSystemEncryptionMigration().migrateUserCredentials(
|
||||
"user-1",
|
||||
),
|
||||
).resolves.toEqual({ migrated: 1, failed: 0, skipped: 0 });
|
||||
|
||||
expect(
|
||||
credentialRepository.listMissingSystemEncryptionByUserId,
|
||||
).toHaveBeenCalledWith("user-1");
|
||||
expect(
|
||||
credentialRepository.updateSystemEncryptionForUser,
|
||||
).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
123,
|
||||
expect.objectContaining({
|
||||
systemPassword: "system-password-plain-password",
|
||||
systemKey: "system-key-plain-key",
|
||||
systemKeyPassword: "system-key_password-plain-keyPassword",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId,
|
||||
).toHaveBeenCalledWith(123);
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentSharedCredentialRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
export class CredentialSystemEncryptionMigration {
|
||||
async migrateUserCredentials(userId: string): Promise<{
|
||||
migrated: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
}> {
|
||||
try {
|
||||
const userDEK = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDEK) {
|
||||
throw new Error("User must be logged in to migrate credentials");
|
||||
}
|
||||
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const CSKEK = await systemCrypto.getCredentialSharingKey();
|
||||
const credentialRepository = createCurrentCredentialRepository();
|
||||
const sharedCredentialRepository =
|
||||
createCurrentSharedCredentialRepository();
|
||||
|
||||
const credentials =
|
||||
await credentialRepository.listMissingSystemEncryptionByUserId(userId);
|
||||
|
||||
let migrated = 0;
|
||||
let failed = 0;
|
||||
const skipped = 0;
|
||||
|
||||
for (const cred of credentials) {
|
||||
try {
|
||||
const plainPassword = cred.password
|
||||
? FieldCrypto.decryptField(
|
||||
cred.password,
|
||||
userDEK,
|
||||
cred.id.toString(),
|
||||
"password",
|
||||
)
|
||||
: null;
|
||||
|
||||
const plainKey = cred.key
|
||||
? FieldCrypto.decryptField(
|
||||
cred.key,
|
||||
userDEK,
|
||||
cred.id.toString(),
|
||||
"key",
|
||||
)
|
||||
: null;
|
||||
|
||||
const plainKeyPassword = cred.keyPassword
|
||||
? FieldCrypto.decryptField(
|
||||
cred.keyPassword,
|
||||
userDEK,
|
||||
cred.id.toString(),
|
||||
"keyPassword",
|
||||
)
|
||||
: null;
|
||||
|
||||
const systemPassword = plainPassword
|
||||
? FieldCrypto.encryptField(
|
||||
plainPassword,
|
||||
CSKEK,
|
||||
cred.id.toString(),
|
||||
"password",
|
||||
)
|
||||
: null;
|
||||
|
||||
const systemKey = plainKey
|
||||
? FieldCrypto.encryptField(
|
||||
plainKey,
|
||||
CSKEK,
|
||||
cred.id.toString(),
|
||||
"key",
|
||||
)
|
||||
: null;
|
||||
|
||||
const systemKeyPassword = plainKeyPassword
|
||||
? FieldCrypto.encryptField(
|
||||
plainKeyPassword,
|
||||
CSKEK,
|
||||
cred.id.toString(),
|
||||
"key_password",
|
||||
)
|
||||
: null;
|
||||
|
||||
await credentialRepository.updateSystemEncryptionForUser(
|
||||
userId,
|
||||
cred.id,
|
||||
{
|
||||
systemPassword,
|
||||
systemKey,
|
||||
systemKeyPassword,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
await sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId(
|
||||
cred.id,
|
||||
);
|
||||
|
||||
migrated++;
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
`Skipping credential migration for credential ${cred.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{
|
||||
operation: "credential_migration_skip",
|
||||
credentialId: cred.id,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { migrated, failed, skipped };
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Credential system encryption migration incomplete", {
|
||||
operation: "credential_migration_incomplete",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { DataCrypto } from "../data-crypto.js";
|
||||
import { DatabaseSaveTrigger } from "../database-save-trigger.js";
|
||||
import { getCurrentRepositorySqlite } from "../../database/repositories/factory.js";
|
||||
import { SharedCredentialManager } from "../shared-credential-manager.js";
|
||||
|
||||
interface SqliteLike {
|
||||
prepare(sql: string): {
|
||||
all(...params: unknown[]): unknown[];
|
||||
get(...params: unknown[]): unknown;
|
||||
run(...params: unknown[]): unknown;
|
||||
};
|
||||
exec(sql: string): unknown;
|
||||
}
|
||||
|
||||
function tableHasColumn(
|
||||
sqlite: SqliteLike,
|
||||
table: string,
|
||||
column: string,
|
||||
): boolean {
|
||||
const rows = sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
return rows.some((row) => row.name === column);
|
||||
}
|
||||
|
||||
function dropColumnIfExists(
|
||||
sqlite: SqliteLike,
|
||||
table: string,
|
||||
column: string,
|
||||
): boolean {
|
||||
if (!tableHasColumn(sqlite, table, column)) return false;
|
||||
sqlite.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
interface PendingShareRow {
|
||||
id: number;
|
||||
host_access_id: number;
|
||||
original_credential_id: number;
|
||||
target_user_id: string;
|
||||
}
|
||||
|
||||
// One-time cleanup of the pre-2.5 sharing machinery: pending share copies
|
||||
// (rows that were waiting for an offline user's DEK) are re-created now that
|
||||
// every migrated user's DEK is server-side, then the queue flag and the
|
||||
// CREDENTIAL_SHARING_KEY shadow columns are dropped.
|
||||
export async function runLegacySharedCredentialCleanup(): Promise<{
|
||||
resolved: number;
|
||||
dropped: number;
|
||||
columnsDropped: number;
|
||||
}> {
|
||||
const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike;
|
||||
const result = { resolved: 0, dropped: 0, columnsDropped: 0 };
|
||||
|
||||
if (tableHasColumn(sqlite, "shared_credentials", "needs_re_encryption")) {
|
||||
const pendingRows = sqlite
|
||||
.prepare(
|
||||
`SELECT id, host_access_id, original_credential_id, target_user_id
|
||||
FROM shared_credentials
|
||||
WHERE needs_re_encryption = 1 OR encrypted_username = ''`,
|
||||
)
|
||||
.all() as PendingShareRow[];
|
||||
|
||||
for (const row of pendingRows) {
|
||||
const owner = sqlite
|
||||
.prepare(
|
||||
`SELECT h.user_id AS ownerId
|
||||
FROM host_access ha JOIN ssh_data h ON h.id = ha.host_id
|
||||
WHERE ha.id = ?`,
|
||||
)
|
||||
.get(row.host_access_id) as { ownerId?: string } | undefined;
|
||||
|
||||
sqlite.prepare(`DELETE FROM shared_credentials WHERE id = ?`).run(row.id);
|
||||
|
||||
const ownerId = owner?.ownerId;
|
||||
if (
|
||||
ownerId &&
|
||||
DataCrypto.canUserAccessData(ownerId) &&
|
||||
DataCrypto.canUserAccessData(row.target_user_id)
|
||||
) {
|
||||
try {
|
||||
await SharedCredentialManager.getInstance().createSharedCredentialForUser(
|
||||
row.host_access_id,
|
||||
row.original_credential_id,
|
||||
row.target_user_id,
|
||||
ownerId,
|
||||
);
|
||||
result.resolved++;
|
||||
continue;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not re-create pending shared credential", {
|
||||
operation: "legacy_share_cleanup_recreate_failed",
|
||||
sharedCredentialId: row.id,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.dropped++;
|
||||
databaseLogger.warn(
|
||||
"Dropped unresolvable pending shared credential; the owner can re-share the host",
|
||||
{
|
||||
operation: "legacy_share_cleanup_dropped",
|
||||
hostAccessId: row.host_access_id,
|
||||
targetUserId: row.target_user_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [table, column] of [
|
||||
["shared_credentials", "needs_re_encryption"],
|
||||
["ssh_credentials", "system_password"],
|
||||
["ssh_credentials", "system_key"],
|
||||
["ssh_credentials", "system_key_password"],
|
||||
] as const) {
|
||||
if (dropColumnIfExists(sqlite, table, column)) {
|
||||
result.columnsDropped++;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.resolved || result.dropped || result.columnsDropped) {
|
||||
await DatabaseSaveTrigger.forceSave("legacy_share_cleanup");
|
||||
databaseLogger.info("Legacy shared-credential cleanup finished", {
|
||||
operation: "legacy_share_cleanup",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -471,48 +471,6 @@ class DataCrypto {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async encryptRecordWithSystemKey<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
record: T,
|
||||
systemKey: Buffer,
|
||||
): Promise<Partial<T>> {
|
||||
const systemEncrypted: Record<string, unknown> = {};
|
||||
const recordId = record.id || "temp-" + Date.now();
|
||||
|
||||
if (tableName !== "ssh_credentials") {
|
||||
return systemEncrypted as Partial<T>;
|
||||
}
|
||||
|
||||
if (record.password && typeof record.password === "string") {
|
||||
systemEncrypted.systemPassword = FieldCrypto.encryptField(
|
||||
record.password as string,
|
||||
systemKey,
|
||||
recordId as string,
|
||||
"password",
|
||||
);
|
||||
}
|
||||
|
||||
if (record.key && typeof record.key === "string") {
|
||||
systemEncrypted.systemKey = FieldCrypto.encryptField(
|
||||
record.key as string,
|
||||
systemKey,
|
||||
recordId as string,
|
||||
"key",
|
||||
);
|
||||
}
|
||||
|
||||
if (record.keyPassword && typeof record.keyPassword === "string") {
|
||||
systemEncrypted.systemKeyPassword = FieldCrypto.encryptField(
|
||||
record.keyPassword as string,
|
||||
systemKey,
|
||||
recordId as string,
|
||||
"key_password",
|
||||
);
|
||||
}
|
||||
|
||||
return systemEncrypted as Partial<T>;
|
||||
}
|
||||
}
|
||||
|
||||
export { DataCrypto };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ interface CredentialData {
|
||||
keyType?: string;
|
||||
}
|
||||
|
||||
// Shared credentials are per-target copies of the owner's credential,
|
||||
// re-encrypted under the target user's DEK at share time.
|
||||
class SharedCredentialManager {
|
||||
private static instance: SharedCredentialManager;
|
||||
|
||||
@@ -47,68 +49,28 @@ class SharedCredentialManager {
|
||||
|
||||
if (existing) return;
|
||||
|
||||
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
|
||||
const ownerDEK = DataCrypto.validateUserAccess(ownerId);
|
||||
const targetDEK = DataCrypto.validateUserAccess(targetUserId);
|
||||
|
||||
if (ownerDEK) {
|
||||
const targetDEK = DataCrypto.getUserDataKey(targetUserId);
|
||||
if (!targetDEK) {
|
||||
await this.createPendingSharedCredential(
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
targetUserId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const credentialData = await this.getDecryptedCredential(
|
||||
originalCredentialId,
|
||||
ownerId,
|
||||
ownerDEK,
|
||||
);
|
||||
|
||||
const credentialData = await this.getDecryptedCredential(
|
||||
originalCredentialId,
|
||||
ownerId,
|
||||
ownerDEK,
|
||||
);
|
||||
const encryptedForTarget = this.encryptCredentialForUser(
|
||||
credentialData,
|
||||
targetUserId,
|
||||
targetDEK,
|
||||
hostAccessId,
|
||||
);
|
||||
|
||||
const encryptedForTarget = this.encryptCredentialForUser(
|
||||
credentialData,
|
||||
targetUserId,
|
||||
targetDEK,
|
||||
hostAccessId,
|
||||
);
|
||||
|
||||
await sharedCredentialRepository.create({
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
targetUserId,
|
||||
...encryptedForTarget,
|
||||
needsReEncryption: false,
|
||||
});
|
||||
} else {
|
||||
const targetDEK = DataCrypto.getUserDataKey(targetUserId);
|
||||
if (!targetDEK) {
|
||||
await this.createPendingSharedCredential(
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
targetUserId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialData =
|
||||
await this.getDecryptedCredentialViaSystemKey(originalCredentialId);
|
||||
|
||||
const encryptedForTarget = this.encryptCredentialForUser(
|
||||
credentialData,
|
||||
targetUserId,
|
||||
targetDEK,
|
||||
hostAccessId,
|
||||
);
|
||||
|
||||
await sharedCredentialRepository.create({
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
targetUserId,
|
||||
...encryptedForTarget,
|
||||
needsReEncryption: false,
|
||||
});
|
||||
}
|
||||
await sharedCredentialRepository.create({
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
targetUserId,
|
||||
...encryptedForTarget,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create shared credential", error, {
|
||||
operation: "create_shared_credential",
|
||||
@@ -243,10 +205,7 @@ class SharedCredentialManager {
|
||||
userId: string,
|
||||
): Promise<CredentialData | null> {
|
||||
try {
|
||||
const userDEK = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDEK) {
|
||||
throw new Error(`User ${userId} data not unlocked`);
|
||||
}
|
||||
const userDEK = DataCrypto.validateUserAccess(userId);
|
||||
|
||||
const cred =
|
||||
await createCurrentRbacAccessRepository().findSharedCredentialForHostAndUser(
|
||||
@@ -258,27 +217,6 @@ class SharedCredentialManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cred.needsReEncryption) {
|
||||
await this.reEncryptSharedCredential(cred.id, userId);
|
||||
|
||||
const refreshed =
|
||||
await createCurrentSharedCredentialRepository().findById(cred.id);
|
||||
|
||||
if (!refreshed || refreshed.needsReEncryption) {
|
||||
databaseLogger.warn(
|
||||
"Shared credential needs re-encryption but cannot be accessed yet",
|
||||
{
|
||||
operation: "get_shared_credential_pending",
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.decryptSharedCredential(refreshed, userDEK);
|
||||
}
|
||||
|
||||
return this.decryptSharedCredential(cred, userDEK);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get shared credential", error, {
|
||||
@@ -302,45 +240,19 @@ class SharedCredentialManager {
|
||||
credentialId,
|
||||
);
|
||||
|
||||
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
|
||||
let credentialData: CredentialData;
|
||||
if (sharedCreds.length === 0) return;
|
||||
|
||||
if (ownerDEK) {
|
||||
credentialData = await this.getDecryptedCredential(
|
||||
credentialId,
|
||||
ownerId,
|
||||
ownerDEK,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
credentialData =
|
||||
await this.getDecryptedCredentialViaSystemKey(credentialId);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
"Cannot update shared credentials: owner offline and credential not migrated",
|
||||
{
|
||||
operation: "update_shared_credentials_failed",
|
||||
credentialId,
|
||||
ownerId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
await sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId(
|
||||
credentialId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ownerDEK = DataCrypto.validateUserAccess(ownerId);
|
||||
const credentialData = await this.getDecryptedCredential(
|
||||
credentialId,
|
||||
ownerId,
|
||||
ownerDEK,
|
||||
);
|
||||
|
||||
for (const sharedCred of sharedCreds) {
|
||||
const targetDEK = DataCrypto.getUserDataKey(sharedCred.targetUserId);
|
||||
|
||||
if (!targetDEK) {
|
||||
await sharedCredentialRepository.updateById(sharedCred.id, {
|
||||
needsReEncryption: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const targetDEK = DataCrypto.validateUserAccess(
|
||||
sharedCred.targetUserId,
|
||||
);
|
||||
|
||||
const encryptedForTarget = this.encryptCredentialForUser(
|
||||
credentialData,
|
||||
@@ -351,7 +263,6 @@ class SharedCredentialManager {
|
||||
|
||||
await sharedCredentialRepository.updateById(sharedCred.id, {
|
||||
...encryptedForTarget,
|
||||
needsReEncryption: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -378,29 +289,6 @@ class SharedCredentialManager {
|
||||
}
|
||||
}
|
||||
|
||||
async reEncryptPendingCredentialsForUser(userId: string): Promise<void> {
|
||||
try {
|
||||
const userDEK = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDEK) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingCreds =
|
||||
await createCurrentSharedCredentialRepository().listPendingByTargetUserId(
|
||||
userId,
|
||||
);
|
||||
|
||||
for (const cred of pendingCreds) {
|
||||
await this.reEncryptSharedCredential(cred.id, userId);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to re-encrypt pending credentials", error, {
|
||||
operation: "reencrypt_pending_credentials",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getDecryptedCredential(
|
||||
credentialId: number,
|
||||
ownerId: string,
|
||||
@@ -436,53 +324,6 @@ class SharedCredentialManager {
|
||||
};
|
||||
}
|
||||
|
||||
private async getDecryptedCredentialViaSystemKey(
|
||||
credentialId: number,
|
||||
): Promise<CredentialData> {
|
||||
const cred =
|
||||
await createCurrentCredentialRepository().findById(credentialId);
|
||||
|
||||
if (!cred) {
|
||||
throw new Error(`Credential ${credentialId} not found`);
|
||||
}
|
||||
|
||||
if (!cred.systemPassword && !cred.systemKey && !cred.systemKeyPassword) {
|
||||
throw new Error(
|
||||
"Credential not yet migrated for offline sharing. " +
|
||||
"Please ask credential owner to log in to enable sharing.",
|
||||
);
|
||||
}
|
||||
|
||||
const { SystemCrypto } = await import("./system-crypto.js");
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const CSKEK = await systemCrypto.getCredentialSharingKey();
|
||||
|
||||
return {
|
||||
username: cred.username,
|
||||
authType: cred.authType,
|
||||
password: cred.systemPassword
|
||||
? this.decryptField(
|
||||
cred.systemPassword,
|
||||
CSKEK,
|
||||
credentialId,
|
||||
"password",
|
||||
)
|
||||
: undefined,
|
||||
key: cred.systemKey
|
||||
? this.decryptField(cred.systemKey, CSKEK, credentialId, "key")
|
||||
: undefined,
|
||||
keyPassword: cred.systemKeyPassword
|
||||
? this.decryptField(
|
||||
cred.systemKeyPassword,
|
||||
CSKEK,
|
||||
credentialId,
|
||||
"key_password",
|
||||
)
|
||||
: undefined,
|
||||
keyType: cred.keyType,
|
||||
};
|
||||
}
|
||||
|
||||
private encryptCredentialForUser(
|
||||
credentialData: CredentialData,
|
||||
targetUserId: string,
|
||||
@@ -598,114 +439,6 @@ class SharedCredentialManager {
|
||||
return encryptedValue;
|
||||
}
|
||||
}
|
||||
|
||||
private async createPendingSharedCredential(
|
||||
hostAccessId: number,
|
||||
originalCredentialId: number,
|
||||
targetUserId: string,
|
||||
): Promise<void> {
|
||||
await createCurrentSharedCredentialRepository().create({
|
||||
hostAccessId,
|
||||
originalCredentialId,
|
||||
targetUserId,
|
||||
encryptedUsername: "",
|
||||
encryptedAuthType: "",
|
||||
needsReEncryption: true,
|
||||
});
|
||||
|
||||
databaseLogger.info("Created pending shared credential", {
|
||||
operation: "create_pending_shared_credential",
|
||||
hostAccessId,
|
||||
targetUserId,
|
||||
});
|
||||
}
|
||||
|
||||
private async reEncryptSharedCredential(
|
||||
sharedCredId: number,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cred =
|
||||
await createCurrentSharedCredentialRepository().findById(sharedCredId);
|
||||
|
||||
if (!cred) {
|
||||
databaseLogger.warn("Re-encrypt: shared credential not found", {
|
||||
operation: "reencrypt_not_found",
|
||||
sharedCredId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerId =
|
||||
await createCurrentRbacAccessRepository().findHostAccessOwnerId(
|
||||
cred.hostAccessId,
|
||||
);
|
||||
|
||||
if (!ownerId) {
|
||||
databaseLogger.warn("Re-encrypt: host access not found", {
|
||||
operation: "reencrypt_access_not_found",
|
||||
sharedCredId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const userDEK = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDEK) {
|
||||
databaseLogger.warn("Re-encrypt: user DEK not available", {
|
||||
operation: "reencrypt_user_offline",
|
||||
sharedCredId,
|
||||
userId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
|
||||
let credentialData: CredentialData;
|
||||
|
||||
if (ownerDEK) {
|
||||
credentialData = await this.getDecryptedCredential(
|
||||
cred.originalCredentialId,
|
||||
ownerId,
|
||||
ownerDEK,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
credentialData = await this.getDecryptedCredentialViaSystemKey(
|
||||
cred.originalCredentialId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
"Re-encrypt: system key decryption failed, credential may not be migrated yet",
|
||||
{
|
||||
operation: "reencrypt_system_key_failed",
|
||||
sharedCredId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const encryptedForTarget = this.encryptCredentialForUser(
|
||||
credentialData,
|
||||
userId,
|
||||
userDEK,
|
||||
cred.hostAccessId,
|
||||
);
|
||||
|
||||
await createCurrentSharedCredentialRepository().updateById(sharedCredId, {
|
||||
...encryptedForTarget,
|
||||
needsReEncryption: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to re-encrypt shared credential", error, {
|
||||
operation: "reencrypt_shared_credential",
|
||||
sharedCredId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { SharedCredentialManager };
|
||||
|
||||
@@ -30,20 +30,6 @@ class SimpleDBOps {
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (tableName === "ssh_credentials") {
|
||||
const { SystemCrypto } = await import("./system-crypto.js");
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const systemKey = await systemCrypto.getCredentialSharingKey();
|
||||
|
||||
const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey(
|
||||
tableName,
|
||||
dataWithTempId,
|
||||
systemKey,
|
||||
);
|
||||
|
||||
Object.assign(encryptedData, systemEncrypted);
|
||||
}
|
||||
|
||||
if (!data.id) {
|
||||
delete encryptedData.id;
|
||||
}
|
||||
@@ -126,20 +112,6 @@ class SimpleDBOps {
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (tableName === "ssh_credentials") {
|
||||
const { SystemCrypto } = await import("./system-crypto.js");
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const systemKey = await systemCrypto.getCredentialSharingKey();
|
||||
|
||||
const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey(
|
||||
tableName,
|
||||
data,
|
||||
systemKey,
|
||||
);
|
||||
|
||||
Object.assign(encryptedData, systemEncrypted);
|
||||
}
|
||||
|
||||
const result = await getDb()
|
||||
.update(table)
|
||||
.set(encryptedData)
|
||||
|
||||
@@ -9,7 +9,6 @@ class SystemCrypto {
|
||||
private databaseKey: Buffer | null = null;
|
||||
private encryptionKey: Buffer | null = null;
|
||||
private internalAuthToken: string | null = null;
|
||||
private credentialSharingKey: Buffer | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -196,52 +195,6 @@ class SystemCrypto {
|
||||
return this.internalAuthToken!;
|
||||
}
|
||||
|
||||
async initializeCredentialSharingKey(): Promise<void> {
|
||||
try {
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
const envKey = process.env.CREDENTIAL_SHARING_KEY;
|
||||
if (envKey && envKey.length >= 64) {
|
||||
this.credentialSharingKey = Buffer.from(envKey, "hex");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const envContent = await fs.readFile(envPath, "utf8");
|
||||
const csKeyMatch = envContent.match(/^CREDENTIAL_SHARING_KEY=(.+)$/m);
|
||||
if (csKeyMatch && csKeyMatch[1] && csKeyMatch[1].length >= 64) {
|
||||
this.credentialSharingKey = Buffer.from(csKeyMatch[1], "hex");
|
||||
process.env.CREDENTIAL_SHARING_KEY = csKeyMatch[1];
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// expected - env file may not exist
|
||||
}
|
||||
|
||||
await this.generateAndGuideCredentialSharingKey();
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to initialize credential sharing key",
|
||||
error,
|
||||
{
|
||||
operation: "cred_sharing_key_init_failed",
|
||||
dataDir: process.env.DATA_DIR || "./db/data",
|
||||
},
|
||||
);
|
||||
throw new Error("Credential sharing key initialization failed", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getCredentialSharingKey(): Promise<Buffer> {
|
||||
if (!this.credentialSharingKey) {
|
||||
await this.initializeCredentialSharingKey();
|
||||
}
|
||||
return this.credentialSharingKey!;
|
||||
}
|
||||
|
||||
private async generateAndGuideUser(): Promise<void> {
|
||||
const newSecret = crypto.randomBytes(32).toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
@@ -311,26 +264,6 @@ class SystemCrypto {
|
||||
);
|
||||
}
|
||||
|
||||
private async generateAndGuideCredentialSharingKey(): Promise<void> {
|
||||
const newKey = crypto.randomBytes(32);
|
||||
const newKeyHex = newKey.toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
this.credentialSharingKey = newKey;
|
||||
|
||||
await this.updateEnvFile("CREDENTIAL_SHARING_KEY", newKeyHex);
|
||||
|
||||
databaseLogger.success(
|
||||
"Credential sharing key auto-generated and saved to .env",
|
||||
{
|
||||
operation: "cred_sharing_key_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "CREDENTIAL_SHARING_KEY",
|
||||
note: "Used for offline credential sharing - no restart required",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async validateJWTSecret(): Promise<boolean> {
|
||||
try {
|
||||
const secret = await this.getJWTSecret();
|
||||
|
||||
Reference in New Issue
Block a user