From 1032a31e2519a370a9ddef6a100f036b84888d87 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 16 Jul 2026 00:29:54 -0500 Subject: [PATCH] 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. --- src/backend/database/db/index.ts | 8 - src/backend/database/db/schema.ts | 7 - .../repositories/credential-repository.ts | 71 +--- .../field-encryption-boundary.test.ts | 16 +- .../repositories/field-encryption-boundary.ts | 3 - .../host-credential-repositories.test.ts | 78 +---- .../host-resolution-repository.test.ts | 3 - .../rbac-access-repository.test.ts | 7 +- .../shared-credential-repository.test.ts | 38 +- .../shared-credential-repository.ts | 25 +- .../user-data-export-repository.test.ts | 3 - src/backend/database/routes/users.ts | 1 - src/backend/starter.ts | 4 + src/backend/utils/auth-manager.ts | 20 -- ...ential-system-encryption-migration.test.ts | 79 ----- .../credential-system-encryption-migration.ts | 129 ------- .../crypto-migration/legacy-share-cleanup.ts | 132 +++++++ src/backend/utils/data-crypto.ts | 42 --- .../utils/shared-credential-manager.test.ts | 111 ++++++ .../utils/shared-credential-manager.ts | 331 ++---------------- src/backend/utils/simple-db-ops.ts | 28 -- src/backend/utils/system-crypto.ts | 67 ---- 22 files changed, 322 insertions(+), 881 deletions(-) delete mode 100644 src/backend/utils/credential-system-encryption-migration.test.ts delete mode 100644 src/backend/utils/credential-system-encryption-migration.ts create mode 100644 src/backend/utils/crypto-migration/legacy-share-cleanup.ts create mode 100644 src/backend/utils/shared-credential-manager.test.ts diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index e427907f..296a6f12 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -939,10 +939,6 @@ const migrateSchema = () => { addColumnIfNotExists("ssh_credentials", "cert_public_key", "TEXT"); - addColumnIfNotExists("ssh_credentials", "system_password", "TEXT"); - addColumnIfNotExists("ssh_credentials", "system_key", "TEXT"); - addColumnIfNotExists("ssh_credentials", "system_key_password", "TEXT"); - try { const tableInfo = sqlite.prepare("PRAGMA table_info(ssh_credentials)").all() as Array<{ cid: number; @@ -980,9 +976,6 @@ const migrateSchema = () => { private_key TEXT, public_key TEXT, detected_key_type TEXT, - system_password TEXT, - system_key TEXT, - system_key_password TEXT, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); @@ -1609,7 +1602,6 @@ const migrateSchema = () => { encrypted_key_type TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - needs_re_encryption INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE, FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE, FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index cc03c6aa..6d758a60 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -344,9 +344,6 @@ export const sshCredentials = sqliteTable("ssh_credentials", { certPublicKey: text("cert_public_key", { length: 8192 }), - systemPassword: text("system_password"), - systemKey: text("system_key", { length: 16384 }), - systemKeyPassword: text("system_key_password"), usageCount: integer("usage_count").notNull().default(0), lastUsed: text("last_used"), @@ -569,10 +566,6 @@ export const sharedCredentials = sqliteTable("shared_credentials", { updatedAt: text("updated_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), - - needsReEncryption: integer("needs_re_encryption", { mode: "boolean" }) - .notNull() - .default(false), }); export const roles = sqliteTable("roles", { diff --git a/src/backend/database/repositories/credential-repository.ts b/src/backend/database/repositories/credential-repository.ts index eacff71c..82d0af36 100644 --- a/src/backend/database/repositories/credential-repository.ts +++ b/src/backend/database/repositories/credential-repository.ts @@ -1,18 +1,13 @@ -import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; +import { and, desc, eq, sql } from "drizzle-orm"; import { sshCredentials, sshCredentialUsage } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; -import { SystemCrypto } from "../../utils/system-crypto.js"; export type CredentialRecord = typeof sshCredentials.$inferSelect; export type NewCredentialRecord = typeof sshCredentials.$inferInsert; export type CredentialUpdate = Partial< Omit >; -export type CredentialSystemEncryptionUpdate = Pick< - CredentialUpdate, - "systemPassword" | "systemKey" | "systemKeyPassword" | "updatedAt" ->; export class CredentialRepository { constructor( @@ -36,7 +31,7 @@ export class CredentialRepository { const userDataKey = DataCrypto.validateUserAccess(userId); const tempId = credential.id ?? Date.now(); const dataWithTempId = { ...credential, id: tempId }; - const encryptedCredential = await this.encryptCredentialRecordForWrite( + const encryptedCredential = this.encryptCredentialRecordForWrite( dataWithTempId, userId, userDataKey, @@ -96,24 +91,6 @@ export class CredentialRepository { .orderBy(desc(sshCredentials.updatedAt)); } - async listMissingSystemEncryptionByUserId( - userId: string, - ): Promise { - return this.context.drizzle - .select() - .from(sshCredentials) - .where( - and( - eq(sshCredentials.userId, userId), - or( - isNull(sshCredentials.systemPassword), - isNull(sshCredentials.systemKey), - isNull(sshCredentials.systemKeyPassword), - ), - ), - ); - } - async existsForImportIdentity( userId: string, name: string, @@ -205,7 +182,7 @@ export class CredentialRepository { update: CredentialUpdate, ): Promise { const userDataKey = DataCrypto.validateUserAccess(userId); - const encryptedUpdate = await this.encryptCredentialRecordForWrite( + const encryptedUpdate = this.encryptCredentialRecordForWrite( update, userId, userDataKey, @@ -226,29 +203,6 @@ export class CredentialRepository { return this.decryptOne(rows[0] ?? null, userId); } - async updateSystemEncryptionForUser( - userId: string, - credentialId: number, - update: CredentialSystemEncryptionUpdate, - ): Promise { - const rows = await this.context.drizzle - .update(sshCredentials) - .set(update) - .where( - and( - eq(sshCredentials.id, credentialId), - eq(sshCredentials.userId, userId), - ), - ) - .returning(); - - if (rows.length > 0) { - await this.afterWrite(); - } - - return rows[0] ?? null; - } - async deleteForUser(userId: string, credentialId: number): Promise { const rows = await this.context.drizzle .delete(sshCredentials) @@ -334,24 +288,17 @@ export class CredentialRepository { ); } - private async encryptCredentialRecordForWrite< - T extends Record, - >(record: T, userId: string, userDataKey: Buffer): Promise { - const encryptedRecord = DataCrypto.encryptRecord( + private encryptCredentialRecordForWrite>( + record: T, + userId: string, + userDataKey: Buffer, + ): T { + return DataCrypto.encryptRecord( "ssh_credentials", record, userId, userDataKey, ); - const systemKey = - await SystemCrypto.getInstance().getCredentialSharingKey(); - const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey( - "ssh_credentials", - record, - systemKey, - ); - - return { ...encryptedRecord, ...systemEncrypted }; } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/field-encryption-boundary.test.ts b/src/backend/database/repositories/field-encryption-boundary.test.ts index 81acdd00..7d458732 100644 --- a/src/backend/database/repositories/field-encryption-boundary.test.ts +++ b/src/backend/database/repositories/field-encryption-boundary.test.ts @@ -35,15 +35,14 @@ describe("FieldEncryptionBoundary", () => { expect(decrypted).toMatchObject(host); }); - it("encrypts credential system secret fields that the legacy map did not cover", () => { + it("encrypts credential secret fields and keeps metadata plaintext", () => { const credential = { id: 7, userId: "user-1", - name: "system credential", + name: "primary credential", authType: "key", - systemPassword: "system-password", - systemKey: "system-key", - systemKeyPassword: "system-key-password", + key: "private-key-material", + keyPassword: "key-password", }; const encrypted = FieldEncryptionBoundary.encryptRecord( @@ -52,10 +51,9 @@ describe("FieldEncryptionBoundary", () => { userDataKey, ); - expect(encrypted.systemPassword).not.toBe("system-password"); - expect(encrypted.systemKey).not.toBe("system-key"); - expect(encrypted.systemKeyPassword).not.toBe("system-key-password"); - expect(encrypted.name).toBe("system credential"); + expect(encrypted.key).not.toBe("private-key-material"); + expect(encrypted.keyPassword).not.toBe("key-password"); + expect(encrypted.name).toBe("primary credential"); expect( FieldEncryptionBoundary.decryptRecord( diff --git a/src/backend/database/repositories/field-encryption-boundary.ts b/src/backend/database/repositories/field-encryption-boundary.ts index 1c4027bc..04cdf02d 100644 --- a/src/backend/database/repositories/field-encryption-boundary.ts +++ b/src/backend/database/repositories/field-encryption-boundary.ts @@ -47,9 +47,6 @@ const FIELD_ENCRYPTION_POLICY = { "privateKey", "publicKey", "keyPassword", - "systemPassword", - "systemKey", - "systemKeyPassword", ]), plaintext: new Set([ "id", diff --git a/src/backend/database/repositories/host-credential-repositories.test.ts b/src/backend/database/repositories/host-credential-repositories.test.ts index 3a8112c9..84be0e98 100644 --- a/src/backend/database/repositories/host-credential-repositories.test.ts +++ b/src/backend/database/repositories/host-credential-repositories.test.ts @@ -3,7 +3,6 @@ import { TestSqliteDatabase } from "./test-support.js"; import { CredentialRepository } from "./credential-repository.js"; import { HostRepository } from "./host-repository.js"; import { DataCrypto } from "../../utils/data-crypto.js"; -import { SystemCrypto } from "../../utils/system-crypto.js"; describe("HostRepository and CredentialRepository", () => { let adapter: TestSqliteDatabase | null = null; @@ -54,9 +53,6 @@ describe("HostRepository and CredentialRepository", () => { key_type TEXT, detected_key_type TEXT, cert_public_key TEXT, - system_password TEXT, - system_key TEXT, - system_key_password TEXT, usage_count INTEGER NOT NULL DEFAULT 0, last_used TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -306,7 +302,7 @@ describe("HostRepository and CredentialRepository", () => { ); }); - it("encrypts credential writes with user and system keys", async () => { + it("encrypts credential writes with the user key", async () => { const repo = await createRepositories(); vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue( Buffer.from("user-key"), @@ -321,16 +317,9 @@ describe("HostRepository and CredentialRepository", () => { password: "user-encrypted-password", }) as typeof record, ); - vi.spyOn(DataCrypto, "encryptRecordWithSystemKey").mockResolvedValue({ - systemPassword: "system-encrypted-password", - }); vi.spyOn(DataCrypto, "decryptRecord").mockImplementation( (_tableName, record) => record, ); - vi.spyOn( - SystemCrypto.getInstance(), - "getCredentialSharingKey", - ).mockResolvedValue(Buffer.from("system-key")); const created = await repo.credentials.createEncryptedForUser("user-1", { userId: "user-1", @@ -341,79 +330,28 @@ describe("HostRepository and CredentialRepository", () => { }); const raw = repo.sqlite - .prepare( - "SELECT password, system_password FROM ssh_credentials WHERE id = ?", - ) - .get(created.id) as { password: string; system_password: string }; + .prepare("SELECT password FROM ssh_credentials WHERE id = ?") + .get(created.id) as { password: string }; expect(raw.password).toBe("user-encrypted-password"); - expect(raw.system_password).toBe("system-encrypted-password"); await repo.credentials.updateEncryptedForUser("user-1", created.id, { password: "updated-secret", }); const updatedRaw = repo.sqlite - .prepare( - "SELECT password, system_password FROM ssh_credentials WHERE id = ?", - ) - .get(created.id) as { password: string; system_password: string }; + .prepare("SELECT password FROM ssh_credentials WHERE id = ?") + .get(created.id) as { password: string }; expect(updatedRaw.password).toBe("user-encrypted-password"); - expect(updatedRaw.system_password).toBe("system-encrypted-password"); - expect(DataCrypto.encryptRecordWithSystemKey).toHaveBeenCalledWith( + expect(DataCrypto.encryptRecord).toHaveBeenCalledWith( "ssh_credentials", expect.objectContaining({ password: "updated-secret" }), - Buffer.from("system-key"), + "user-1", + Buffer.from("user-key"), ); }); - it("finds and updates credential system encryption migration rows", async () => { - const repo = await createRepositories(); - - const complete = await repo.credentials.create({ - userId: "user-1", - name: "complete", - authType: "password", - password: "encrypted-password", - systemPassword: "system-password", - systemKey: "system-key", - systemKeyPassword: "system-key-password", - }); - const missing = await repo.credentials.create({ - userId: "user-1", - name: "missing", - authType: "key", - key: "encrypted-key", - systemPassword: null, - systemKey: null, - systemKeyPassword: null, - }); - await repo.credentials.create({ - userId: "user-2", - name: "other", - authType: "password", - systemPassword: null, - }); - - await expect( - repo.credentials.listMissingSystemEncryptionByUserId("user-1"), - ).resolves.toMatchObject([{ id: missing.id }]); - - await repo.credentials.updateSystemEncryptionForUser("user-1", missing.id, { - systemPassword: "new-system-password", - systemKey: "new-system-key", - systemKeyPassword: "new-system-key-password", - }); - - await expect( - repo.credentials.listMissingSystemEncryptionByUserId("user-1"), - ).resolves.toEqual([]); - await expect( - repo.credentials.findByIdForUser("user-1", complete.id), - ).resolves.toMatchObject({ systemPassword: "system-password" }); - }); - it("checks credential import identity", async () => { const repo = await createRepositories(); diff --git a/src/backend/database/repositories/host-resolution-repository.test.ts b/src/backend/database/repositories/host-resolution-repository.test.ts index 777be65d..c1228f77 100644 --- a/src/backend/database/repositories/host-resolution-repository.test.ts +++ b/src/backend/database/repositories/host-resolution-repository.test.ts @@ -145,9 +145,6 @@ describe("HostResolutionRepository", () => { key_type TEXT, detected_key_type TEXT, cert_public_key TEXT, - system_password TEXT, - system_key TEXT, - system_key_password TEXT, usage_count INTEGER NOT NULL DEFAULT 0, last_used TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/src/backend/database/repositories/rbac-access-repository.test.ts b/src/backend/database/repositories/rbac-access-repository.test.ts index e85597ee..f289fb67 100644 --- a/src/backend/database/repositories/rbac-access-repository.test.ts +++ b/src/backend/database/repositories/rbac-access-repository.test.ts @@ -64,8 +64,7 @@ describe("RbacAccessRepository", () => { encrypted_key_password TEXT, encrypted_key_type TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - needs_re_encryption INTEGER NOT NULL DEFAULT 0 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE ssh_data ( @@ -130,9 +129,9 @@ describe("RbacAccessRepository", () => { (5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z'); INSERT INTO shared_credentials ( - id, host_access_id, original_credential_id, target_user_id, encrypted_username, encrypted_auth_type, needs_re_encryption + id, host_access_id, original_credential_id, target_user_id, encrypted_username, encrypted_auth_type ) - VALUES (8, 2, 123, 'user-1', 'enc-user', 'enc-auth', 0); + VALUES (8, 2, 123, 'user-1', 'enc-user', 'enc-auth'); INSERT INTO snippets (id, user_id, name, content) VALUES (99, 'owner-1', 'deploy', 'echo deploy'); diff --git a/src/backend/database/repositories/shared-credential-repository.test.ts b/src/backend/database/repositories/shared-credential-repository.test.ts index d604727a..e9046008 100644 --- a/src/backend/database/repositories/shared-credential-repository.test.ts +++ b/src/backend/database/repositories/shared-credential-repository.test.ts @@ -35,8 +35,7 @@ describe("SharedCredentialRepository", () => { encrypted_key_password TEXT, encrypted_key_type TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - needs_re_encryption INTEGER NOT NULL DEFAULT 0 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); INSERT INTO shared_credentials ( @@ -46,13 +45,12 @@ describe("SharedCredentialRepository", () => { target_user_id, encrypted_username, encrypted_auth_type, - encrypted_password, - needs_re_encryption + encrypted_password ) VALUES - (1, 10, 100, 'user-1', 'u1', 'password', 'p1', 0), - (2, 10, 100, 'user-2', 'u2', 'password', 'p2', 1), - (3, 11, 101, 'user-2', 'u3', 'key', NULL, 1); + (1, 10, 100, 'user-1', 'u1', 'password', 'p1'), + (2, 10, 100, 'user-2', 'u2', 'password', 'p2'), + (3, 11, 101, 'user-2', 'u3', 'key', NULL); `); return { @@ -81,7 +79,6 @@ describe("SharedCredentialRepository", () => { encryptedUsername: "u4", encryptedAuthType: "password", encryptedPassword: "p4", - needsReEncryption: false, }); expect(created).toMatchObject({ @@ -106,42 +103,31 @@ describe("SharedCredentialRepository", () => { await expect( repository.listByOriginalCredentialId(100), ).resolves.toHaveLength(2); - await expect( - repository.listPendingByTargetUserId("user-2"), - ).resolves.toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: 2 }), - expect.objectContaining({ id: 3 }), - ]), - ); await expect( repository.updateById(2, { encryptedPassword: "updated", - needsReEncryption: false, }), ).resolves.toMatchObject({ encryptedPassword: "updated", - needsReEncryption: false, }); expect(writes).toBe(1); }); - it("marks and deletes shared credentials", async () => { + it("deletes shared credentials", async () => { let writes = 0; const { repository, sqlite } = await createRepository(() => { writes += 1; }); - await expect( - repository.markNeedsReEncryptionByOriginalCredentialId(100), - ).resolves.toBe(2); - await expect(repository.deleteByTargetUserId("user-1")).resolves.toBe(1); - await expect(repository.deleteByOriginalCredentialId(101)).resolves.toBe(1); + await expect(repository.deleteById(1)).resolves.toBe(true); + await expect(repository.deleteById(999)).resolves.toBe(false); + await expect(repository.deleteByTargetUserId("user-2")).resolves.toBe(2); + await expect(repository.deleteByOriginalCredentialId(100)).resolves.toBe(0); expect( sqlite.prepare("SELECT id FROM shared_credentials ORDER BY id").all(), - ).toEqual([{ id: 2 }]); - expect(writes).toBe(3); + ).toEqual([]); + expect(writes).toBe(2); }); }); diff --git a/src/backend/database/repositories/shared-credential-repository.ts b/src/backend/database/repositories/shared-credential-repository.ts index f5fe3397..462b72f7 100644 --- a/src/backend/database/repositories/shared-credential-repository.ts +++ b/src/backend/database/repositories/shared-credential-repository.ts @@ -63,20 +63,6 @@ export class SharedCredentialRepository { .where(eq(sharedCredentials.originalCredentialId, credentialId)); } - async listPendingByTargetUserId( - userId: string, - ): Promise { - return this.context.drizzle - .select() - .from(sharedCredentials) - .where( - and( - eq(sharedCredentials.targetUserId, userId), - eq(sharedCredentials.needsReEncryption, true), - ), - ); - } - async updateById( id: number, update: SharedCredentialUpdate, @@ -94,20 +80,17 @@ export class SharedCredentialRepository { return rows[0] ?? null; } - async markNeedsReEncryptionByOriginalCredentialId( - credentialId: number, - ): Promise { + async deleteById(id: number): Promise { const rows = await this.context.drizzle - .update(sharedCredentials) - .set({ needsReEncryption: true }) - .where(eq(sharedCredentials.originalCredentialId, credentialId)) + .delete(sharedCredentials) + .where(eq(sharedCredentials.id, id)) .returning({ id: sharedCredentials.id }); if (rows.length > 0) { await this.afterWrite(); } - return rows.length; + return rows.length > 0; } async deleteByOriginalCredentialId(credentialId: number): Promise { diff --git a/src/backend/database/repositories/user-data-export-repository.test.ts b/src/backend/database/repositories/user-data-export-repository.test.ts index 7ec817a4..8ea5505c 100644 --- a/src/backend/database/repositories/user-data-export-repository.test.ts +++ b/src/backend/database/repositories/user-data-export-repository.test.ts @@ -133,9 +133,6 @@ describe("UserDataExportRepository", () => { key_type TEXT, detected_key_type TEXT, cert_public_key TEXT, - system_password TEXT, - system_key TEXT, - system_key_password TEXT, usage_count INTEGER NOT NULL DEFAULT 0, last_used TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 7b553751..08c99ef5 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -61,7 +61,6 @@ async function syncSharedCredentialsForUserRoles( await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); await sharedCredManager.createSharedCredentialsForUserRoles(userId); - await sharedCredManager.reEncryptPendingCredentialsForUser(userId); } catch (error) { authLogger.warn("Failed to sync role shared credentials", { operation, diff --git a/src/backend/starter.ts b/src/backend/starter.ts index f7bf088d..6b36b846 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -124,6 +124,10 @@ import { await import("./utils/crypto-migration/dek-migration.js"); await runBootDekMigration({ cleanupLegacy: true }); + const { runLegacySharedCredentialCleanup } = + await import("./utils/crypto-migration/legacy-share-cleanup.js"); + await runLegacySharedCredentialCleanup(); + const authManager = AuthManager.getInstance(); await authManager.initialize(); DataCrypto.initialize(); diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 52a1a8f6..fd6f0563 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -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", diff --git a/src/backend/utils/credential-system-encryption-migration.test.ts b/src/backend/utils/credential-system-encryption-migration.test.ts deleted file mode 100644 index 7ae51971..00000000 --- a/src/backend/utils/credential-system-encryption-migration.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/backend/utils/credential-system-encryption-migration.ts b/src/backend/utils/credential-system-encryption-migration.ts deleted file mode 100644 index 63803120..00000000 --- a/src/backend/utils/credential-system-encryption-migration.ts +++ /dev/null @@ -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; - } - } -} diff --git a/src/backend/utils/crypto-migration/legacy-share-cleanup.ts b/src/backend/utils/crypto-migration/legacy-share-cleanup.ts new file mode 100644 index 00000000..a285d683 --- /dev/null +++ b/src/backend/utils/crypto-migration/legacy-share-cleanup.ts @@ -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; +} diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts index 0e6c4593..13d0017c 100644 --- a/src/backend/utils/data-crypto.ts +++ b/src/backend/utils/data-crypto.ts @@ -471,48 +471,6 @@ class DataCrypto { return false; } } - - static async encryptRecordWithSystemKey>( - tableName: string, - record: T, - systemKey: Buffer, - ): Promise> { - const systemEncrypted: Record = {}; - const recordId = record.id || "temp-" + Date.now(); - - if (tableName !== "ssh_credentials") { - return systemEncrypted as Partial; - } - - 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; - } } export { DataCrypto }; diff --git a/src/backend/utils/shared-credential-manager.test.ts b/src/backend/utils/shared-credential-manager.test.ts new file mode 100644 index 00000000..6c214da2 --- /dev/null +++ b/src/backend/utils/shared-credential-manager.test.ts @@ -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>, + ownerCredential: null as Record | 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) => { + const created = { id: state.sharedRows.length + 1, ...row }; + state.sharedRows.push(created); + return created; + }, + listByOriginalCredentialId: async () => state.sharedRows, + updateById: async (id: number, update: Record) => { + 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"); + }); +}); diff --git a/src/backend/utils/shared-credential-manager.ts b/src/backend/utils/shared-credential-manager.ts index 23b3e094..cb3a9fc4 100644 --- a/src/backend/utils/shared-credential-manager.ts +++ b/src/backend/utils/shared-credential-manager.ts @@ -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 { 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 { - 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 { - 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 { - 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 { - 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 }; diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts index ddf8df75..1ec75a49 100644 --- a/src/backend/utils/simple-db-ops.ts +++ b/src/backend/utils/simple-db-ops.ts @@ -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) diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index 915b8248..ff3b6c3f 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -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 { - 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 { - if (!this.credentialSharingKey) { - await this.initializeCredentialSharingKey(); - } - return this.credentialSharingKey!; - } - private async generateAndGuideUser(): Promise { const newSecret = crypto.randomBytes(32).toString("hex"); const instanceId = crypto.randomBytes(8).toString("hex"); @@ -311,26 +264,6 @@ class SystemCrypto { ); } - private async generateAndGuideCredentialSharingKey(): Promise { - 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 { try { const secret = await this.getJWTSecret();