mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 21:33:41 +00:00
refactor(crypto): remove pending share queue and credential sharing key
With server-unwrappable DEKs both sides of a share are always available, so the needsReEncryption queue, CREDENTIAL_SHARING_KEY and the system_* shadow columns on ssh_credentials are gone. A one-time boot cleanup re-creates legacy pending share copies where possible (dropping unresolvable ones with a warning) and drops the legacy columns.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "./crypto-migration/dek-migration.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { DatabaseSaveTrigger } from "./database-save-trigger.js";
|
||||
import { databaseLogger, authLogger } from "./logger.js";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import bcrypt from "bcryptjs";
|
||||
@@ -178,25 +177,6 @@ class AuthManager {
|
||||
}
|
||||
|
||||
await DataCrypto.migrateCurrentUserSensitiveFields(userId, userDataKey);
|
||||
|
||||
try {
|
||||
const { CredentialSystemEncryptionMigration } =
|
||||
await import("./credential-system-encryption-migration.js");
|
||||
const credMigration = new CredentialSystemEncryptionMigration();
|
||||
const credResult = await credMigration.migrateUserCredentials(userId);
|
||||
|
||||
if (credResult.migrated > 0) {
|
||||
await DatabaseSaveTrigger.forceSave(
|
||||
"login_credential_migration_explicit_save",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Credential migration failed during login", {
|
||||
operation: "login_credential_migration_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Lazy encryption migration failed", error, {
|
||||
operation: "lazy_encryption_migration_error",
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CredentialSystemEncryptionMigration } from "./credential-system-encryption-migration.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
|
||||
const credentialRepository = {
|
||||
listMissingSystemEncryptionByUserId: vi.fn(),
|
||||
updateSystemEncryptionForUser: vi.fn(),
|
||||
};
|
||||
|
||||
const sharedCredentialRepository = {
|
||||
markNeedsReEncryptionByOriginalCredentialId: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("../database/repositories/factory.js", () => ({
|
||||
createCurrentCredentialRepository: vi.fn(() => credentialRepository),
|
||||
createCurrentSharedCredentialRepository: vi.fn(
|
||||
() => sharedCredentialRepository,
|
||||
),
|
||||
}));
|
||||
|
||||
describe("CredentialSystemEncryptionMigration", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
credentialRepository.listMissingSystemEncryptionByUserId.mockReset();
|
||||
credentialRepository.updateSystemEncryptionForUser.mockReset();
|
||||
sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId.mockReset();
|
||||
});
|
||||
|
||||
it("migrates missing credential system-key copies through repositories", async () => {
|
||||
vi.spyOn(DataCrypto, "getUserDataKey").mockReturnValue(
|
||||
Buffer.from("user-key"),
|
||||
);
|
||||
vi.spyOn(
|
||||
SystemCrypto.getInstance(),
|
||||
"getCredentialSharingKey",
|
||||
).mockResolvedValue(Buffer.from("system-key"));
|
||||
vi.spyOn(FieldCrypto, "decryptField").mockImplementation(
|
||||
(_value, _key, _recordId, fieldName) => `plain-${fieldName}`,
|
||||
);
|
||||
vi.spyOn(FieldCrypto, "encryptField").mockImplementation(
|
||||
(value, _key, _recordId, fieldName) => `system-${fieldName}-${value}`,
|
||||
);
|
||||
credentialRepository.listMissingSystemEncryptionByUserId.mockResolvedValue([
|
||||
{
|
||||
id: 123,
|
||||
userId: "user-1",
|
||||
password: "encrypted-password",
|
||||
key: "encrypted-key",
|
||||
keyPassword: "encrypted-key-password",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
new CredentialSystemEncryptionMigration().migrateUserCredentials(
|
||||
"user-1",
|
||||
),
|
||||
).resolves.toEqual({ migrated: 1, failed: 0, skipped: 0 });
|
||||
|
||||
expect(
|
||||
credentialRepository.listMissingSystemEncryptionByUserId,
|
||||
).toHaveBeenCalledWith("user-1");
|
||||
expect(
|
||||
credentialRepository.updateSystemEncryptionForUser,
|
||||
).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
123,
|
||||
expect.objectContaining({
|
||||
systemPassword: "system-password-plain-password",
|
||||
systemKey: "system-key-plain-key",
|
||||
systemKeyPassword: "system-key_password-plain-keyPassword",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId,
|
||||
).toHaveBeenCalledWith(123);
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentSharedCredentialRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { SystemCrypto } from "./system-crypto.js";
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
export class CredentialSystemEncryptionMigration {
|
||||
async migrateUserCredentials(userId: string): Promise<{
|
||||
migrated: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
}> {
|
||||
try {
|
||||
const userDEK = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDEK) {
|
||||
throw new Error("User must be logged in to migrate credentials");
|
||||
}
|
||||
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const CSKEK = await systemCrypto.getCredentialSharingKey();
|
||||
const credentialRepository = createCurrentCredentialRepository();
|
||||
const sharedCredentialRepository =
|
||||
createCurrentSharedCredentialRepository();
|
||||
|
||||
const credentials =
|
||||
await credentialRepository.listMissingSystemEncryptionByUserId(userId);
|
||||
|
||||
let migrated = 0;
|
||||
let failed = 0;
|
||||
const skipped = 0;
|
||||
|
||||
for (const cred of credentials) {
|
||||
try {
|
||||
const plainPassword = cred.password
|
||||
? FieldCrypto.decryptField(
|
||||
cred.password,
|
||||
userDEK,
|
||||
cred.id.toString(),
|
||||
"password",
|
||||
)
|
||||
: null;
|
||||
|
||||
const plainKey = cred.key
|
||||
? FieldCrypto.decryptField(
|
||||
cred.key,
|
||||
userDEK,
|
||||
cred.id.toString(),
|
||||
"key",
|
||||
)
|
||||
: null;
|
||||
|
||||
const plainKeyPassword = cred.keyPassword
|
||||
? FieldCrypto.decryptField(
|
||||
cred.keyPassword,
|
||||
userDEK,
|
||||
cred.id.toString(),
|
||||
"keyPassword",
|
||||
)
|
||||
: null;
|
||||
|
||||
const systemPassword = plainPassword
|
||||
? FieldCrypto.encryptField(
|
||||
plainPassword,
|
||||
CSKEK,
|
||||
cred.id.toString(),
|
||||
"password",
|
||||
)
|
||||
: null;
|
||||
|
||||
const systemKey = plainKey
|
||||
? FieldCrypto.encryptField(
|
||||
plainKey,
|
||||
CSKEK,
|
||||
cred.id.toString(),
|
||||
"key",
|
||||
)
|
||||
: null;
|
||||
|
||||
const systemKeyPassword = plainKeyPassword
|
||||
? FieldCrypto.encryptField(
|
||||
plainKeyPassword,
|
||||
CSKEK,
|
||||
cred.id.toString(),
|
||||
"key_password",
|
||||
)
|
||||
: null;
|
||||
|
||||
await credentialRepository.updateSystemEncryptionForUser(
|
||||
userId,
|
||||
cred.id,
|
||||
{
|
||||
systemPassword,
|
||||
systemKey,
|
||||
systemKeyPassword,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
|
||||
await sharedCredentialRepository.markNeedsReEncryptionByOriginalCredentialId(
|
||||
cred.id,
|
||||
);
|
||||
|
||||
migrated++;
|
||||
} catch (error) {
|
||||
databaseLogger.warn(
|
||||
`Skipping credential migration for credential ${cred.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{
|
||||
operation: "credential_migration_skip",
|
||||
credentialId: cred.id,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { migrated, failed, skipped };
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Credential system encryption migration incomplete", {
|
||||
operation: "credential_migration_incomplete",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { DataCrypto } from "../data-crypto.js";
|
||||
import { DatabaseSaveTrigger } from "../database-save-trigger.js";
|
||||
import { getCurrentRepositorySqlite } from "../../database/repositories/factory.js";
|
||||
import { SharedCredentialManager } from "../shared-credential-manager.js";
|
||||
|
||||
interface SqliteLike {
|
||||
prepare(sql: string): {
|
||||
all(...params: unknown[]): unknown[];
|
||||
get(...params: unknown[]): unknown;
|
||||
run(...params: unknown[]): unknown;
|
||||
};
|
||||
exec(sql: string): unknown;
|
||||
}
|
||||
|
||||
function tableHasColumn(
|
||||
sqlite: SqliteLike,
|
||||
table: string,
|
||||
column: string,
|
||||
): boolean {
|
||||
const rows = sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
return rows.some((row) => row.name === column);
|
||||
}
|
||||
|
||||
function dropColumnIfExists(
|
||||
sqlite: SqliteLike,
|
||||
table: string,
|
||||
column: string,
|
||||
): boolean {
|
||||
if (!tableHasColumn(sqlite, table, column)) return false;
|
||||
sqlite.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
interface PendingShareRow {
|
||||
id: number;
|
||||
host_access_id: number;
|
||||
original_credential_id: number;
|
||||
target_user_id: string;
|
||||
}
|
||||
|
||||
// One-time cleanup of the pre-2.5 sharing machinery: pending share copies
|
||||
// (rows that were waiting for an offline user's DEK) are re-created now that
|
||||
// every migrated user's DEK is server-side, then the queue flag and the
|
||||
// CREDENTIAL_SHARING_KEY shadow columns are dropped.
|
||||
export async function runLegacySharedCredentialCleanup(): Promise<{
|
||||
resolved: number;
|
||||
dropped: number;
|
||||
columnsDropped: number;
|
||||
}> {
|
||||
const sqlite = getCurrentRepositorySqlite() as unknown as SqliteLike;
|
||||
const result = { resolved: 0, dropped: 0, columnsDropped: 0 };
|
||||
|
||||
if (tableHasColumn(sqlite, "shared_credentials", "needs_re_encryption")) {
|
||||
const pendingRows = sqlite
|
||||
.prepare(
|
||||
`SELECT id, host_access_id, original_credential_id, target_user_id
|
||||
FROM shared_credentials
|
||||
WHERE needs_re_encryption = 1 OR encrypted_username = ''`,
|
||||
)
|
||||
.all() as PendingShareRow[];
|
||||
|
||||
for (const row of pendingRows) {
|
||||
const owner = sqlite
|
||||
.prepare(
|
||||
`SELECT h.user_id AS ownerId
|
||||
FROM host_access ha JOIN ssh_data h ON h.id = ha.host_id
|
||||
WHERE ha.id = ?`,
|
||||
)
|
||||
.get(row.host_access_id) as { ownerId?: string } | undefined;
|
||||
|
||||
sqlite.prepare(`DELETE FROM shared_credentials WHERE id = ?`).run(row.id);
|
||||
|
||||
const ownerId = owner?.ownerId;
|
||||
if (
|
||||
ownerId &&
|
||||
DataCrypto.canUserAccessData(ownerId) &&
|
||||
DataCrypto.canUserAccessData(row.target_user_id)
|
||||
) {
|
||||
try {
|
||||
await SharedCredentialManager.getInstance().createSharedCredentialForUser(
|
||||
row.host_access_id,
|
||||
row.original_credential_id,
|
||||
row.target_user_id,
|
||||
ownerId,
|
||||
);
|
||||
result.resolved++;
|
||||
continue;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not re-create pending shared credential", {
|
||||
operation: "legacy_share_cleanup_recreate_failed",
|
||||
sharedCredentialId: row.id,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.dropped++;
|
||||
databaseLogger.warn(
|
||||
"Dropped unresolvable pending shared credential; the owner can re-share the host",
|
||||
{
|
||||
operation: "legacy_share_cleanup_dropped",
|
||||
hostAccessId: row.host_access_id,
|
||||
targetUserId: row.target_user_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [table, column] of [
|
||||
["shared_credentials", "needs_re_encryption"],
|
||||
["ssh_credentials", "system_password"],
|
||||
["ssh_credentials", "system_key"],
|
||||
["ssh_credentials", "system_key_password"],
|
||||
] as const) {
|
||||
if (dropColumnIfExists(sqlite, table, column)) {
|
||||
result.columnsDropped++;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.resolved || result.dropped || result.columnsDropped) {
|
||||
await DatabaseSaveTrigger.forceSave("legacy_share_cleanup");
|
||||
databaseLogger.info("Legacy shared-credential cleanup finished", {
|
||||
operation: "legacy_share_cleanup",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -471,48 +471,6 @@ class DataCrypto {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async encryptRecordWithSystemKey<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
record: T,
|
||||
systemKey: Buffer,
|
||||
): Promise<Partial<T>> {
|
||||
const systemEncrypted: Record<string, unknown> = {};
|
||||
const recordId = record.id || "temp-" + Date.now();
|
||||
|
||||
if (tableName !== "ssh_credentials") {
|
||||
return systemEncrypted as Partial<T>;
|
||||
}
|
||||
|
||||
if (record.password && typeof record.password === "string") {
|
||||
systemEncrypted.systemPassword = FieldCrypto.encryptField(
|
||||
record.password as string,
|
||||
systemKey,
|
||||
recordId as string,
|
||||
"password",
|
||||
);
|
||||
}
|
||||
|
||||
if (record.key && typeof record.key === "string") {
|
||||
systemEncrypted.systemKey = FieldCrypto.encryptField(
|
||||
record.key as string,
|
||||
systemKey,
|
||||
recordId as string,
|
||||
"key",
|
||||
);
|
||||
}
|
||||
|
||||
if (record.keyPassword && typeof record.keyPassword === "string") {
|
||||
systemEncrypted.systemKeyPassword = FieldCrypto.encryptField(
|
||||
record.keyPassword as string,
|
||||
systemKey,
|
||||
recordId as string,
|
||||
"key_password",
|
||||
);
|
||||
}
|
||||
|
||||
return systemEncrypted as Partial<T>;
|
||||
}
|
||||
}
|
||||
|
||||
export { DataCrypto };
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import crypto from "crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ownerDEK = crypto.randomBytes(32);
|
||||
const targetDEK = crypto.randomBytes(32);
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
sharedRows: [] as Array<Record<string, unknown>>,
|
||||
ownerCredential: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
vi.mock("../database/repositories/factory.js", () => ({
|
||||
createCurrentCredentialRepository: () => ({
|
||||
findByIdForUser: async (_userId: string, _id: number) =>
|
||||
state.ownerCredential,
|
||||
}),
|
||||
createCurrentSharedCredentialRepository: () => ({
|
||||
existsForHostAccessAndTargetUser: async () => state.sharedRows.length > 0,
|
||||
create: async (row: Record<string, unknown>) => {
|
||||
const created = { id: state.sharedRows.length + 1, ...row };
|
||||
state.sharedRows.push(created);
|
||||
return created;
|
||||
},
|
||||
listByOriginalCredentialId: async () => state.sharedRows,
|
||||
updateById: async (id: number, update: Record<string, unknown>) => {
|
||||
const row = state.sharedRows.find((r) => r.id === id);
|
||||
if (row) Object.assign(row, update);
|
||||
return row ?? null;
|
||||
},
|
||||
deleteByOriginalCredentialId: async () => 0,
|
||||
}),
|
||||
createCurrentRbacAccessRepository: () => ({
|
||||
findSharedCredentialForHostAndUser: async () => state.sharedRows[0] ?? null,
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
listRoleUserIds: async () => [],
|
||||
listUserRoleIds: async () => [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
validateUserAccess: (userId: string) => {
|
||||
if (userId === "owner") return ownerDEK;
|
||||
if (userId === "target") return targetDEK;
|
||||
throw new Error(`User ${userId} has no data encryption key`);
|
||||
},
|
||||
getUserDataKey: (userId: string) =>
|
||||
userId === "owner" ? ownerDEK : userId === "target" ? targetDEK : null,
|
||||
canUserAccessData: () => true,
|
||||
},
|
||||
}));
|
||||
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { SharedCredentialManager } from "./shared-credential-manager.js";
|
||||
|
||||
const manager = SharedCredentialManager.getInstance();
|
||||
|
||||
beforeEach(() => {
|
||||
state.sharedRows = [];
|
||||
state.ownerCredential = {
|
||||
id: 42,
|
||||
userId: "owner",
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: FieldCrypto.encryptField("hunter2", ownerDEK, "42", "password"),
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
};
|
||||
});
|
||||
|
||||
describe("SharedCredentialManager", () => {
|
||||
it("shares a credential and the target can decrypt it", async () => {
|
||||
await manager.createSharedCredentialForUser(7, 42, "target", "owner");
|
||||
|
||||
expect(state.sharedRows).toHaveLength(1);
|
||||
const row = state.sharedRows[0];
|
||||
expect(row.encryptedPassword).not.toBe("hunter2");
|
||||
expect(row.targetUserId).toBe("target");
|
||||
|
||||
const resolved = await manager.getSharedCredentialForUser(1, "target");
|
||||
expect(resolved).toMatchObject({
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "hunter2",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails the share when a participant has no key instead of queueing", async () => {
|
||||
await expect(
|
||||
manager.createSharedCredentialForUser(7, 42, "locked-user", "owner"),
|
||||
).rejects.toThrow(/no data encryption key/);
|
||||
expect(state.sharedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("re-encrypts existing share copies when the original changes", async () => {
|
||||
await manager.createSharedCredentialForUser(7, 42, "target", "owner");
|
||||
state.ownerCredential!.password = FieldCrypto.encryptField(
|
||||
"new-password",
|
||||
ownerDEK,
|
||||
"42",
|
||||
"password",
|
||||
);
|
||||
|
||||
await manager.updateSharedCredentialsForOriginal(42, "owner");
|
||||
|
||||
const resolved = await manager.getSharedCredentialForUser(1, "target");
|
||||
expect(resolved?.password).toBe("new-password");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ interface CredentialData {
|
||||
keyType?: string;
|
||||
}
|
||||
|
||||
// Shared credentials are per-target copies of the owner's credential,
|
||||
// re-encrypted under the target user's DEK at share time.
|
||||
class SharedCredentialManager {
|
||||
private static instance: SharedCredentialManager;
|
||||
|
||||
@@ -47,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 };
|
||||
|
||||
@@ -30,20 +30,6 @@ class SimpleDBOps {
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (tableName === "ssh_credentials") {
|
||||
const { SystemCrypto } = await import("./system-crypto.js");
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const systemKey = await systemCrypto.getCredentialSharingKey();
|
||||
|
||||
const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey(
|
||||
tableName,
|
||||
dataWithTempId,
|
||||
systemKey,
|
||||
);
|
||||
|
||||
Object.assign(encryptedData, systemEncrypted);
|
||||
}
|
||||
|
||||
if (!data.id) {
|
||||
delete encryptedData.id;
|
||||
}
|
||||
@@ -126,20 +112,6 @@ class SimpleDBOps {
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
if (tableName === "ssh_credentials") {
|
||||
const { SystemCrypto } = await import("./system-crypto.js");
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const systemKey = await systemCrypto.getCredentialSharingKey();
|
||||
|
||||
const systemEncrypted = await DataCrypto.encryptRecordWithSystemKey(
|
||||
tableName,
|
||||
data,
|
||||
systemKey,
|
||||
);
|
||||
|
||||
Object.assign(encryptedData, systemEncrypted);
|
||||
}
|
||||
|
||||
const result = await getDb()
|
||||
.update(table)
|
||||
.set(encryptedData)
|
||||
|
||||
@@ -9,7 +9,6 @@ class SystemCrypto {
|
||||
private databaseKey: Buffer | null = null;
|
||||
private encryptionKey: Buffer | null = null;
|
||||
private internalAuthToken: string | null = null;
|
||||
private credentialSharingKey: Buffer | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -196,52 +195,6 @@ class SystemCrypto {
|
||||
return this.internalAuthToken!;
|
||||
}
|
||||
|
||||
async initializeCredentialSharingKey(): Promise<void> {
|
||||
try {
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const envPath = path.join(dataDir, ".env");
|
||||
|
||||
const envKey = process.env.CREDENTIAL_SHARING_KEY;
|
||||
if (envKey && envKey.length >= 64) {
|
||||
this.credentialSharingKey = Buffer.from(envKey, "hex");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const envContent = await fs.readFile(envPath, "utf8");
|
||||
const csKeyMatch = envContent.match(/^CREDENTIAL_SHARING_KEY=(.+)$/m);
|
||||
if (csKeyMatch && csKeyMatch[1] && csKeyMatch[1].length >= 64) {
|
||||
this.credentialSharingKey = Buffer.from(csKeyMatch[1], "hex");
|
||||
process.env.CREDENTIAL_SHARING_KEY = csKeyMatch[1];
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// expected - env file may not exist
|
||||
}
|
||||
|
||||
await this.generateAndGuideCredentialSharingKey();
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to initialize credential sharing key",
|
||||
error,
|
||||
{
|
||||
operation: "cred_sharing_key_init_failed",
|
||||
dataDir: process.env.DATA_DIR || "./db/data",
|
||||
},
|
||||
);
|
||||
throw new Error("Credential sharing key initialization failed", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getCredentialSharingKey(): Promise<Buffer> {
|
||||
if (!this.credentialSharingKey) {
|
||||
await this.initializeCredentialSharingKey();
|
||||
}
|
||||
return this.credentialSharingKey!;
|
||||
}
|
||||
|
||||
private async generateAndGuideUser(): Promise<void> {
|
||||
const newSecret = crypto.randomBytes(32).toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
@@ -311,26 +264,6 @@ class SystemCrypto {
|
||||
);
|
||||
}
|
||||
|
||||
private async generateAndGuideCredentialSharingKey(): Promise<void> {
|
||||
const newKey = crypto.randomBytes(32);
|
||||
const newKeyHex = newKey.toString("hex");
|
||||
const instanceId = crypto.randomBytes(8).toString("hex");
|
||||
|
||||
this.credentialSharingKey = newKey;
|
||||
|
||||
await this.updateEnvFile("CREDENTIAL_SHARING_KEY", newKeyHex);
|
||||
|
||||
databaseLogger.success(
|
||||
"Credential sharing key auto-generated and saved to .env",
|
||||
{
|
||||
operation: "cred_sharing_key_auto_generated",
|
||||
instanceId,
|
||||
envVarName: "CREDENTIAL_SHARING_KEY",
|
||||
note: "Used for offline credential sharing - no restart required",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async validateJWTSecret(): Promise<boolean> {
|
||||
try {
|
||||
const secret = await this.getJWTSecret();
|
||||
|
||||
Reference in New Issue
Block a user