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", "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
-7
View File
@@ -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", {
@@ -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<NewCredentialRecord, "id" | "userId">
>;
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<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(
userId: string,
name: string,
@@ -205,7 +182,7 @@ export class CredentialRepository {
update: CredentialUpdate,
): Promise<CredentialRecord | null> {
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<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> {
const rows = await this.context.drizzle
.delete(sshCredentials)
@@ -334,24 +288,17 @@ export class CredentialRepository {
);
}
private async encryptCredentialRecordForWrite<
T extends Record<string, unknown>,
>(record: T, userId: string, userDataKey: Buffer): Promise<T> {
const encryptedRecord = DataCrypto.encryptRecord(
private encryptCredentialRecordForWrite<T extends Record<string, unknown>>(
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<void> {
@@ -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(
@@ -47,9 +47,6 @@ const FIELD_ENCRYPTION_POLICY = {
"privateKey",
"publicKey",
"keyPassword",
"systemPassword",
"systemKey",
"systemKeyPassword",
]),
plaintext: new Set([
"id",
@@ -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();
@@ -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,
@@ -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');
@@ -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);
});
});
@@ -63,20 +63,6 @@ export class SharedCredentialRepository {
.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(
id: number,
update: SharedCredentialUpdate,
@@ -94,20 +80,17 @@ export class SharedCredentialRepository {
return rows[0] ?? null;
}
async markNeedsReEncryptionByOriginalCredentialId(
credentialId: number,
): Promise<number> {
async deleteById(id: number): Promise<boolean> {
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<number> {
@@ -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,
-1
View File
@@ -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,
+4
View File
@@ -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();
-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");
});
});
+11 -278
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,18 +49,8 @@ class SharedCredentialManager {
if (existing) return;
const ownerDEK = DataCrypto.getUserDataKey(ownerId);
if (ownerDEK) {
const targetDEK = DataCrypto.getUserDataKey(targetUserId);
if (!targetDEK) {
await this.createPendingSharedCredential(
hostAccessId,
originalCredentialId,
targetUserId,
);
return;
}
const ownerDEK = DataCrypto.validateUserAccess(ownerId);
const targetDEK = DataCrypto.validateUserAccess(targetUserId);
const credentialData = await this.getDecryptedCredential(
originalCredentialId,
@@ -78,37 +70,7 @@ class SharedCredentialManager {
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) {
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(
const ownerDEK = DataCrypto.validateUserAccess(ownerId);
const 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;
}
}
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();