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