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
-8
View File
@@ -939,10 +939,6 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_credentials", "cert_public_key", "TEXT"); 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 { try {
const tableInfo = sqlite.prepare("PRAGMA table_info(ssh_credentials)").all() as Array<{ const tableInfo = sqlite.prepare("PRAGMA table_info(ssh_credentials)").all() as Array<{
cid: number; cid: number;
@@ -980,9 +976,6 @@ const migrateSchema = () => {
private_key TEXT, private_key TEXT,
public_key TEXT, public_key TEXT,
detected_key_type 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 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
); );
@@ -1609,7 +1602,6 @@ const migrateSchema = () => {
encrypted_key_type TEXT, encrypted_key_type TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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 (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (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 FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
-7
View File
@@ -344,9 +344,6 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
certPublicKey: text("cert_public_key", { length: 8192 }), 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), usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"), lastUsed: text("last_used"),
@@ -569,10 +566,6 @@ export const sharedCredentials = sqliteTable("shared_credentials", {
updatedAt: text("updated_at") updatedAt: text("updated_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
needsReEncryption: integer("needs_re_encryption", { mode: "boolean" })
.notNull()
.default(false),
}); });
export const roles = sqliteTable("roles", { export const roles = sqliteTable("roles", {
@@ -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 { sshCredentials, sshCredentialUsage } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js"; import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js"; import { DataCrypto } from "../../utils/data-crypto.js";
import { SystemCrypto } from "../../utils/system-crypto.js";
export type CredentialRecord = typeof sshCredentials.$inferSelect; export type CredentialRecord = typeof sshCredentials.$inferSelect;
export type NewCredentialRecord = typeof sshCredentials.$inferInsert; export type NewCredentialRecord = typeof sshCredentials.$inferInsert;
export type CredentialUpdate = Partial< export type CredentialUpdate = Partial<
Omit<NewCredentialRecord, "id" | "userId"> Omit<NewCredentialRecord, "id" | "userId">
>; >;
export type CredentialSystemEncryptionUpdate = Pick<
CredentialUpdate,
"systemPassword" | "systemKey" | "systemKeyPassword" | "updatedAt"
>;
export class CredentialRepository { export class CredentialRepository {
constructor( constructor(
@@ -36,7 +31,7 @@ export class CredentialRepository {
const userDataKey = DataCrypto.validateUserAccess(userId); const userDataKey = DataCrypto.validateUserAccess(userId);
const tempId = credential.id ?? Date.now(); const tempId = credential.id ?? Date.now();
const dataWithTempId = { ...credential, id: tempId }; const dataWithTempId = { ...credential, id: tempId };
const encryptedCredential = await this.encryptCredentialRecordForWrite( const encryptedCredential = this.encryptCredentialRecordForWrite(
dataWithTempId, dataWithTempId,
userId, userId,
userDataKey, userDataKey,
@@ -96,24 +91,6 @@ export class CredentialRepository {
.orderBy(desc(sshCredentials.updatedAt)); .orderBy(desc(sshCredentials.updatedAt));
} }
async listMissingSystemEncryptionByUserId(
userId: string,
): Promise<CredentialRecord[]> {
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( async existsForImportIdentity(
userId: string, userId: string,
name: string, name: string,
@@ -205,7 +182,7 @@ export class CredentialRepository {
update: CredentialUpdate, update: CredentialUpdate,
): Promise<CredentialRecord | null> { ): Promise<CredentialRecord | null> {
const userDataKey = DataCrypto.validateUserAccess(userId); const userDataKey = DataCrypto.validateUserAccess(userId);
const encryptedUpdate = await this.encryptCredentialRecordForWrite( const encryptedUpdate = this.encryptCredentialRecordForWrite(
update, update,
userId, userId,
userDataKey, userDataKey,
@@ -226,29 +203,6 @@ export class CredentialRepository {
return this.decryptOne(rows[0] ?? null, userId); return this.decryptOne(rows[0] ?? null, userId);
} }
async updateSystemEncryptionForUser(
userId: string,
credentialId: number,
update: CredentialSystemEncryptionUpdate,
): Promise<CredentialRecord | null> {
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<boolean> { async deleteForUser(userId: string, credentialId: number): Promise<boolean> {
const rows = await this.context.drizzle const rows = await this.context.drizzle
.delete(sshCredentials) .delete(sshCredentials)
@@ -334,24 +288,17 @@ export class CredentialRepository {
); );
} }
private async encryptCredentialRecordForWrite< private encryptCredentialRecordForWrite<T extends Record<string, unknown>>(
T extends Record<string, unknown>, record: T,
>(record: T, userId: string, userDataKey: Buffer): Promise<T> { userId: string,
const encryptedRecord = DataCrypto.encryptRecord( userDataKey: Buffer,
): T {
return DataCrypto.encryptRecord(
"ssh_credentials", "ssh_credentials",
record, record,
userId, userId,
userDataKey, userDataKey,
); );
const systemKey =
await SystemCrypto.getInstance().getCredentialSharingKey();
const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey(
"ssh_credentials",
record,
systemKey,
);
return { ...encryptedRecord, ...systemEncrypted };
} }
private async afterWrite(): Promise<void> { private async afterWrite(): Promise<void> {
@@ -35,15 +35,14 @@ describe("FieldEncryptionBoundary", () => {
expect(decrypted).toMatchObject(host); 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 = { const credential = {
id: 7, id: 7,
userId: "user-1", userId: "user-1",
name: "system credential", name: "primary credential",
authType: "key", authType: "key",
systemPassword: "system-password", key: "private-key-material",
systemKey: "system-key", keyPassword: "key-password",
systemKeyPassword: "system-key-password",
}; };
const encrypted = FieldEncryptionBoundary.encryptRecord( const encrypted = FieldEncryptionBoundary.encryptRecord(
@@ -52,10 +51,9 @@ describe("FieldEncryptionBoundary", () => {
userDataKey, userDataKey,
); );
expect(encrypted.systemPassword).not.toBe("system-password"); expect(encrypted.key).not.toBe("private-key-material");
expect(encrypted.systemKey).not.toBe("system-key"); expect(encrypted.keyPassword).not.toBe("key-password");
expect(encrypted.systemKeyPassword).not.toBe("system-key-password"); expect(encrypted.name).toBe("primary credential");
expect(encrypted.name).toBe("system credential");
expect( expect(
FieldEncryptionBoundary.decryptRecord( FieldEncryptionBoundary.decryptRecord(
@@ -47,9 +47,6 @@ const FIELD_ENCRYPTION_POLICY = {
"privateKey", "privateKey",
"publicKey", "publicKey",
"keyPassword", "keyPassword",
"systemPassword",
"systemKey",
"systemKeyPassword",
]), ]),
plaintext: new Set([ plaintext: new Set([
"id", "id",
@@ -3,7 +3,6 @@ import { TestSqliteDatabase } from "./test-support.js";
import { CredentialRepository } from "./credential-repository.js"; import { CredentialRepository } from "./credential-repository.js";
import { HostRepository } from "./host-repository.js"; import { HostRepository } from "./host-repository.js";
import { DataCrypto } from "../../utils/data-crypto.js"; import { DataCrypto } from "../../utils/data-crypto.js";
import { SystemCrypto } from "../../utils/system-crypto.js";
describe("HostRepository and CredentialRepository", () => { describe("HostRepository and CredentialRepository", () => {
let adapter: TestSqliteDatabase | null = null; let adapter: TestSqliteDatabase | null = null;
@@ -54,9 +53,6 @@ describe("HostRepository and CredentialRepository", () => {
key_type TEXT, key_type TEXT,
detected_key_type TEXT, detected_key_type TEXT,
cert_public_key TEXT, cert_public_key TEXT,
system_password TEXT,
system_key TEXT,
system_key_password TEXT,
usage_count INTEGER NOT NULL DEFAULT 0, usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT, last_used TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 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(); const repo = await createRepositories();
vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue( vi.spyOn(DataCrypto, "validateUserAccess").mockReturnValue(
Buffer.from("user-key"), Buffer.from("user-key"),
@@ -321,16 +317,9 @@ describe("HostRepository and CredentialRepository", () => {
password: "user-encrypted-password", password: "user-encrypted-password",
}) as typeof record, }) as typeof record,
); );
vi.spyOn(DataCrypto, "encryptRecordWithSystemKey").mockResolvedValue({
systemPassword: "system-encrypted-password",
});
vi.spyOn(DataCrypto, "decryptRecord").mockImplementation( vi.spyOn(DataCrypto, "decryptRecord").mockImplementation(
(_tableName, record) => record, (_tableName, record) => record,
); );
vi.spyOn(
SystemCrypto.getInstance(),
"getCredentialSharingKey",
).mockResolvedValue(Buffer.from("system-key"));
const created = await repo.credentials.createEncryptedForUser("user-1", { const created = await repo.credentials.createEncryptedForUser("user-1", {
userId: "user-1", userId: "user-1",
@@ -341,79 +330,28 @@ describe("HostRepository and CredentialRepository", () => {
}); });
const raw = repo.sqlite const raw = repo.sqlite
.prepare( .prepare("SELECT password FROM ssh_credentials WHERE id = ?")
"SELECT password, system_password FROM ssh_credentials WHERE id = ?", .get(created.id) as { password: string };
)
.get(created.id) as { password: string; system_password: string };
expect(raw.password).toBe("user-encrypted-password"); expect(raw.password).toBe("user-encrypted-password");
expect(raw.system_password).toBe("system-encrypted-password");
await repo.credentials.updateEncryptedForUser("user-1", created.id, { await repo.credentials.updateEncryptedForUser("user-1", created.id, {
password: "updated-secret", password: "updated-secret",
}); });
const updatedRaw = repo.sqlite const updatedRaw = repo.sqlite
.prepare( .prepare("SELECT password FROM ssh_credentials WHERE id = ?")
"SELECT password, system_password FROM ssh_credentials WHERE id = ?", .get(created.id) as { password: string };
)
.get(created.id) as { password: string; system_password: string };
expect(updatedRaw.password).toBe("user-encrypted-password"); expect(updatedRaw.password).toBe("user-encrypted-password");
expect(updatedRaw.system_password).toBe("system-encrypted-password"); expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
expect(DataCrypto.encryptRecordWithSystemKey).toHaveBeenCalledWith(
"ssh_credentials", "ssh_credentials",
expect.objectContaining({ password: "updated-secret" }), 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 () => { it("checks credential import identity", async () => {
const repo = await createRepositories(); const repo = await createRepositories();
@@ -145,9 +145,6 @@ describe("HostResolutionRepository", () => {
key_type TEXT, key_type TEXT,
detected_key_type TEXT, detected_key_type TEXT,
cert_public_key TEXT, cert_public_key TEXT,
system_password TEXT,
system_key TEXT,
system_key_password TEXT,
usage_count INTEGER NOT NULL DEFAULT 0, usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT, last_used TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -64,8 +64,7 @@ describe("RbacAccessRepository", () => {
encrypted_key_password TEXT, encrypted_key_password TEXT,
encrypted_key_type TEXT, encrypted_key_type TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
needs_re_encryption INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE ssh_data ( 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'); (5, 44, 'user-1', NULL, 'admin', 'view', '2026-06-25T00:00:00.000Z', '2026-06-24T00:00:00.000Z');
INSERT INTO shared_credentials ( 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) INSERT INTO snippets (id, user_id, name, content)
VALUES (99, 'owner-1', 'deploy', 'echo deploy'); VALUES (99, 'owner-1', 'deploy', 'echo deploy');
@@ -35,8 +35,7 @@ describe("SharedCredentialRepository", () => {
encrypted_key_password TEXT, encrypted_key_password TEXT,
encrypted_key_type TEXT, encrypted_key_type TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
needs_re_encryption INTEGER NOT NULL DEFAULT 0
); );
INSERT INTO shared_credentials ( INSERT INTO shared_credentials (
@@ -46,13 +45,12 @@ describe("SharedCredentialRepository", () => {
target_user_id, target_user_id,
encrypted_username, encrypted_username,
encrypted_auth_type, encrypted_auth_type,
encrypted_password, encrypted_password
needs_re_encryption
) )
VALUES VALUES
(1, 10, 100, 'user-1', 'u1', 'password', 'p1', 0), (1, 10, 100, 'user-1', 'u1', 'password', 'p1'),
(2, 10, 100, 'user-2', 'u2', 'password', 'p2', 1), (2, 10, 100, 'user-2', 'u2', 'password', 'p2'),
(3, 11, 101, 'user-2', 'u3', 'key', NULL, 1); (3, 11, 101, 'user-2', 'u3', 'key', NULL);
`); `);
return { return {
@@ -81,7 +79,6 @@ describe("SharedCredentialRepository", () => {
encryptedUsername: "u4", encryptedUsername: "u4",
encryptedAuthType: "password", encryptedAuthType: "password",
encryptedPassword: "p4", encryptedPassword: "p4",
needsReEncryption: false,
}); });
expect(created).toMatchObject({ expect(created).toMatchObject({
@@ -106,42 +103,31 @@ describe("SharedCredentialRepository", () => {
await expect( await expect(
repository.listByOriginalCredentialId(100), repository.listByOriginalCredentialId(100),
).resolves.toHaveLength(2); ).resolves.toHaveLength(2);
await expect(
repository.listPendingByTargetUserId("user-2"),
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 2 }),
expect.objectContaining({ id: 3 }),
]),
);
await expect( await expect(
repository.updateById(2, { repository.updateById(2, {
encryptedPassword: "updated", encryptedPassword: "updated",
needsReEncryption: false,
}), }),
).resolves.toMatchObject({ ).resolves.toMatchObject({
encryptedPassword: "updated", encryptedPassword: "updated",
needsReEncryption: false,
}); });
expect(writes).toBe(1); expect(writes).toBe(1);
}); });
it("marks and deletes shared credentials", async () => { it("deletes shared credentials", async () => {
let writes = 0; let writes = 0;
const { repository, sqlite } = await createRepository(() => { const { repository, sqlite } = await createRepository(() => {
writes += 1; writes += 1;
}); });
await expect( await expect(repository.deleteById(1)).resolves.toBe(true);
repository.markNeedsReEncryptionByOriginalCredentialId(100), await expect(repository.deleteById(999)).resolves.toBe(false);
).resolves.toBe(2); await expect(repository.deleteByTargetUserId("user-2")).resolves.toBe(2);
await expect(repository.deleteByTargetUserId("user-1")).resolves.toBe(1); await expect(repository.deleteByOriginalCredentialId(100)).resolves.toBe(0);
await expect(repository.deleteByOriginalCredentialId(101)).resolves.toBe(1);
expect( expect(
sqlite.prepare("SELECT id FROM shared_credentials ORDER BY id").all(), sqlite.prepare("SELECT id FROM shared_credentials ORDER BY id").all(),
).toEqual([{ id: 2 }]); ).toEqual([]);
expect(writes).toBe(3); expect(writes).toBe(2);
}); });
}); });
@@ -63,20 +63,6 @@ export class SharedCredentialRepository {
.where(eq(sharedCredentials.originalCredentialId, credentialId)); .where(eq(sharedCredentials.originalCredentialId, credentialId));
} }
async listPendingByTargetUserId(
userId: string,
): Promise<SharedCredentialRecord[]> {
return this.context.drizzle
.select()
.from(sharedCredentials)
.where(
and(
eq(sharedCredentials.targetUserId, userId),
eq(sharedCredentials.needsReEncryption, true),
),
);
}
async updateById( async updateById(
id: number, id: number,
update: SharedCredentialUpdate, update: SharedCredentialUpdate,
@@ -94,20 +80,17 @@ export class SharedCredentialRepository {
return rows[0] ?? null; return rows[0] ?? null;
} }
async markNeedsReEncryptionByOriginalCredentialId( async deleteById(id: number): Promise<boolean> {
credentialId: number,
): Promise<number> {
const rows = await this.context.drizzle const rows = await this.context.drizzle
.update(sharedCredentials) .delete(sharedCredentials)
.set({ needsReEncryption: true }) .where(eq(sharedCredentials.id, id))
.where(eq(sharedCredentials.originalCredentialId, credentialId))
.returning({ id: sharedCredentials.id }); .returning({ id: sharedCredentials.id });
if (rows.length > 0) { if (rows.length > 0) {
await this.afterWrite(); await this.afterWrite();
} }
return rows.length; return rows.length > 0;
} }
async deleteByOriginalCredentialId(credentialId: number): Promise<number> { async deleteByOriginalCredentialId(credentialId: number): Promise<number> {
@@ -133,9 +133,6 @@ describe("UserDataExportRepository", () => {
key_type TEXT, key_type TEXT,
detected_key_type TEXT, detected_key_type TEXT,
cert_public_key TEXT, cert_public_key TEXT,
system_password TEXT,
system_key TEXT,
system_key_password TEXT,
usage_count INTEGER NOT NULL DEFAULT 0, usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT, last_used TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
-1
View File
@@ -61,7 +61,6 @@ async function syncSharedCredentialsForUserRoles(
await import("../../utils/shared-credential-manager.js"); await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.createSharedCredentialsForUserRoles(userId); await sharedCredManager.createSharedCredentialsForUserRoles(userId);
await sharedCredManager.reEncryptPendingCredentialsForUser(userId);
} catch (error) { } catch (error) {
authLogger.warn("Failed to sync role shared credentials", { authLogger.warn("Failed to sync role shared credentials", {
operation, operation,
+4
View File
@@ -124,6 +124,10 @@ import {
await import("./utils/crypto-migration/dek-migration.js"); await import("./utils/crypto-migration/dek-migration.js");
await runBootDekMigration({ cleanupLegacy: true }); await runBootDekMigration({ cleanupLegacy: true });
const { runLegacySharedCredentialCleanup } =
await import("./utils/crypto-migration/legacy-share-cleanup.js");
await runLegacySharedCredentialCleanup();
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
await authManager.initialize(); await authManager.initialize();
DataCrypto.initialize(); DataCrypto.initialize();
-20
View File
@@ -7,7 +7,6 @@ import {
} from "./crypto-migration/dek-migration.js"; } from "./crypto-migration/dek-migration.js";
import { SystemCrypto } from "./system-crypto.js"; import { SystemCrypto } from "./system-crypto.js";
import { DataCrypto } from "./data-crypto.js"; import { DataCrypto } from "./data-crypto.js";
import { DatabaseSaveTrigger } from "./database-save-trigger.js";
import { databaseLogger, authLogger } from "./logger.js"; import { databaseLogger, authLogger } from "./logger.js";
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
@@ -178,25 +177,6 @@ class AuthManager {
} }
await DataCrypto.migrateCurrentUserSensitiveFields(userId, userDataKey); 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) { } catch (error) {
databaseLogger.error("Lazy encryption migration failed", error, { databaseLogger.error("Lazy encryption migration failed", error, {
operation: "lazy_encryption_migration_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;
}
-42
View File
@@ -471,48 +471,6 @@ class DataCrypto {
return false; 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 }; 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");
});
});
+32 -299
View File
@@ -18,6 +18,8 @@ interface CredentialData {
keyType?: string; 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 { class SharedCredentialManager {
private static instance: SharedCredentialManager; private static instance: SharedCredentialManager;
@@ -47,68 +49,28 @@ class SharedCredentialManager {
if (existing) return; if (existing) return;
const ownerDEK = DataCrypto.getUserDataKey(ownerId); const ownerDEK = DataCrypto.validateUserAccess(ownerId);
const targetDEK = DataCrypto.validateUserAccess(targetUserId);
if (ownerDEK) { const credentialData = await this.getDecryptedCredential(
const targetDEK = DataCrypto.getUserDataKey(targetUserId); originalCredentialId,
if (!targetDEK) { ownerId,
await this.createPendingSharedCredential( ownerDEK,
hostAccessId, );
originalCredentialId,
targetUserId,
);
return;
}
const credentialData = await this.getDecryptedCredential( const encryptedForTarget = this.encryptCredentialForUser(
originalCredentialId, credentialData,
ownerId, targetUserId,
ownerDEK, targetDEK,
); hostAccessId,
);
const encryptedForTarget = this.encryptCredentialForUser( await sharedCredentialRepository.create({
credentialData, hostAccessId,
targetUserId, originalCredentialId,
targetDEK, targetUserId,
hostAccessId, ...encryptedForTarget,
); });
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,
});
}
} catch (error) { } catch (error) {
databaseLogger.error("Failed to create shared credential", error, { databaseLogger.error("Failed to create shared credential", error, {
operation: "create_shared_credential", operation: "create_shared_credential",
@@ -243,10 +205,7 @@ class SharedCredentialManager {
userId: string, userId: string,
): Promise<CredentialData | null> { ): Promise<CredentialData | null> {
try { try {
const userDEK = DataCrypto.getUserDataKey(userId); const userDEK = DataCrypto.validateUserAccess(userId);
if (!userDEK) {
throw new Error(`User ${userId} data not unlocked`);
}
const cred = const cred =
await createCurrentRbacAccessRepository().findSharedCredentialForHostAndUser( await createCurrentRbacAccessRepository().findSharedCredentialForHostAndUser(
@@ -258,27 +217,6 @@ class SharedCredentialManager {
return null; 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); return this.decryptSharedCredential(cred, userDEK);
} catch (error) { } catch (error) {
databaseLogger.error("Failed to get shared credential", error, { databaseLogger.error("Failed to get shared credential", error, {
@@ -302,45 +240,19 @@ class SharedCredentialManager {
credentialId, credentialId,
); );
const ownerDEK = DataCrypto.getUserDataKey(ownerId); if (sharedCreds.length === 0) return;
let credentialData: CredentialData;
if (ownerDEK) { const ownerDEK = DataCrypto.validateUserAccess(ownerId);
credentialData = await this.getDecryptedCredential( const credentialData = await this.getDecryptedCredential(
credentialId, credentialId,
ownerId, ownerId,
ownerDEK, 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;
}
}
for (const sharedCred of sharedCreds) { for (const sharedCred of sharedCreds) {
const targetDEK = DataCrypto.getUserDataKey(sharedCred.targetUserId); const targetDEK = DataCrypto.validateUserAccess(
sharedCred.targetUserId,
if (!targetDEK) { );
await sharedCredentialRepository.updateById(sharedCred.id, {
needsReEncryption: true,
});
continue;
}
const encryptedForTarget = this.encryptCredentialForUser( const encryptedForTarget = this.encryptCredentialForUser(
credentialData, credentialData,
@@ -351,7 +263,6 @@ class SharedCredentialManager {
await sharedCredentialRepository.updateById(sharedCred.id, { await sharedCredentialRepository.updateById(sharedCred.id, {
...encryptedForTarget, ...encryptedForTarget,
needsReEncryption: false,
updatedAt: new Date().toISOString(), 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( private async getDecryptedCredential(
credentialId: number, credentialId: number,
ownerId: string, 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( private encryptCredentialForUser(
credentialData: CredentialData, credentialData: CredentialData,
targetUserId: string, targetUserId: string,
@@ -598,114 +439,6 @@ class SharedCredentialManager {
return encryptedValue; 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 }; export { SharedCredentialManager };
-28
View File
@@ -30,20 +30,6 @@ class SimpleDBOps {
userDataKey, 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) { if (!data.id) {
delete encryptedData.id; delete encryptedData.id;
} }
@@ -126,20 +112,6 @@ class SimpleDBOps {
userDataKey, 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() const result = await getDb()
.update(table) .update(table)
.set(encryptedData) .set(encryptedData)
-67
View File
@@ -9,7 +9,6 @@ class SystemCrypto {
private databaseKey: Buffer | null = null; private databaseKey: Buffer | null = null;
private encryptionKey: Buffer | null = null; private encryptionKey: Buffer | null = null;
private internalAuthToken: string | null = null; private internalAuthToken: string | null = null;
private credentialSharingKey: Buffer | null = null;
private constructor() {} private constructor() {}
@@ -196,52 +195,6 @@ class SystemCrypto {
return this.internalAuthToken!; 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> { private async generateAndGuideUser(): Promise<void> {
const newSecret = crypto.randomBytes(32).toString("hex"); const newSecret = crypto.randomBytes(32).toString("hex");
const instanceId = crypto.randomBytes(8).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> { async validateJWTSecret(): Promise<boolean> {
try { try {
const secret = await this.getJWTSecret(); const secret = await this.getJWTSecret();