mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
remove the unwired field encryption boundary (#1136)
FieldEncryptionBoundary declared a full sensitive/plaintext policy for six
tables and was referenced only by its own test. Nothing in production used it.
Its policy is byte-for-byte the same as FieldCrypto.ENCRYPTED_FIELDS, which is
the copy that actually runs, so nothing is lost by deleting it. Keeping a second
list is the real risk: someone adds a field to this one, sees it classified as
sensitive, and ships something that was never encrypted.
The one apparent improvement it had — requiring an explicit recordId instead of
DataCrypto's temp-${Date.now()} fallback — turns out to guard against nothing.
decryptField derives its context from the recordId stored inside the ciphertext,
not from the argument, so a temporary id at encryption time still decrypts.
This commit is contained in:
@@ -1,158 +0,0 @@
|
|||||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
|
||||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
|
||||||
|
|
||||||
const FIELD_ENCRYPTION_POLICY = {
|
|
||||||
users: {
|
|
||||||
sensitive: new Set([
|
|
||||||
"passwordHash",
|
|
||||||
"clientSecret",
|
|
||||||
"totpSecret",
|
|
||||||
"totpBackupCodes",
|
|
||||||
"oidcIdentifier",
|
|
||||||
]),
|
|
||||||
plaintext: new Set(["id", "username", "isAdmin", "isOidc"]),
|
|
||||||
},
|
|
||||||
ssh_data: {
|
|
||||||
sensitive: new Set([
|
|
||||||
"password",
|
|
||||||
"key",
|
|
||||||
"keyPassword",
|
|
||||||
"sudoPassword",
|
|
||||||
"autostartPassword",
|
|
||||||
"autostartKey",
|
|
||||||
"autostartKeyPassword",
|
|
||||||
"socks5Password",
|
|
||||||
"rdpPassword",
|
|
||||||
"vncPassword",
|
|
||||||
"telnetPassword",
|
|
||||||
]),
|
|
||||||
plaintext: new Set([
|
|
||||||
"id",
|
|
||||||
"userId",
|
|
||||||
"connectionType",
|
|
||||||
"name",
|
|
||||||
"ip",
|
|
||||||
"port",
|
|
||||||
"username",
|
|
||||||
"folder",
|
|
||||||
"tags",
|
|
||||||
"authType",
|
|
||||||
"credentialId",
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
ssh_credentials: {
|
|
||||||
sensitive: new Set([
|
|
||||||
"password",
|
|
||||||
"key",
|
|
||||||
"privateKey",
|
|
||||||
"publicKey",
|
|
||||||
"keyPassword",
|
|
||||||
]),
|
|
||||||
plaintext: new Set([
|
|
||||||
"id",
|
|
||||||
"userId",
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"folder",
|
|
||||||
"tags",
|
|
||||||
"authType",
|
|
||||||
"username",
|
|
||||||
"keyType",
|
|
||||||
"detectedKeyType",
|
|
||||||
"usageCount",
|
|
||||||
"lastUsed",
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
opkssh_tokens: {
|
|
||||||
sensitive: new Set(["sshCert", "privateKey"]),
|
|
||||||
plaintext: new Set(["id", "userId", "hostId", "createdAt", "expiresAt"]),
|
|
||||||
},
|
|
||||||
termix_identity_ca: {
|
|
||||||
sensitive: new Set(["privateKey"]),
|
|
||||||
plaintext: new Set(["id", "publicKey", "createdAt", "updatedAt"]),
|
|
||||||
},
|
|
||||||
vault_tokens: {
|
|
||||||
sensitive: new Set(["sshCert", "privateKey"]),
|
|
||||||
plaintext: new Set(["id", "userId", "profileId", "expiresAt"]),
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type PolicyTable = keyof typeof FIELD_ENCRYPTION_POLICY;
|
|
||||||
export type FieldClassification = "sensitive" | "plaintext" | "unknown";
|
|
||||||
|
|
||||||
export class FieldEncryptionBoundary {
|
|
||||||
static classifyField(
|
|
||||||
tableName: string,
|
|
||||||
fieldName: string,
|
|
||||||
): FieldClassification {
|
|
||||||
const policy = this.getPolicy(tableName);
|
|
||||||
if (!policy) return "unknown";
|
|
||||||
if (policy.sensitive.has(fieldName)) return "sensitive";
|
|
||||||
if (policy.plaintext.has(fieldName)) return "plaintext";
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
static getSensitiveFields(tableName: string): string[] {
|
|
||||||
const policy = this.getPolicy(tableName);
|
|
||||||
return policy ? [...policy.sensitive].sort() : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
static encryptRecord<T extends Record<string, unknown>>(
|
|
||||||
tableName: string,
|
|
||||||
record: T,
|
|
||||||
userDataKey: Buffer,
|
|
||||||
recordId = record.id,
|
|
||||||
): T {
|
|
||||||
const id = this.requireRecordId(recordId);
|
|
||||||
const encryptedRecord: Record<string, unknown> = { ...record };
|
|
||||||
|
|
||||||
for (const fieldName of this.getSensitiveFields(tableName)) {
|
|
||||||
const value = encryptedRecord[fieldName];
|
|
||||||
if (typeof value === "string" && value) {
|
|
||||||
encryptedRecord[fieldName] = FieldCrypto.encryptField(
|
|
||||||
value,
|
|
||||||
userDataKey,
|
|
||||||
id,
|
|
||||||
fieldName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return encryptedRecord as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
static decryptRecord<T extends Record<string, unknown>>(
|
|
||||||
tableName: string,
|
|
||||||
record: T,
|
|
||||||
userDataKey: Buffer,
|
|
||||||
recordId = record.id,
|
|
||||||
): T {
|
|
||||||
const id = this.requireRecordId(recordId);
|
|
||||||
const decryptedRecord: Record<string, unknown> = { ...record };
|
|
||||||
|
|
||||||
for (const fieldName of this.getSensitiveFields(tableName)) {
|
|
||||||
const value = decryptedRecord[fieldName];
|
|
||||||
if (typeof value === "string" && value) {
|
|
||||||
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
|
|
||||||
value,
|
|
||||||
userDataKey,
|
|
||||||
id,
|
|
||||||
fieldName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return decryptedRecord as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getPolicy(tableName: string) {
|
|
||||||
return FIELD_ENCRYPTION_POLICY[tableName as PolicyTable];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static requireRecordId(recordId: unknown): string {
|
|
||||||
if (recordId === null || recordId === undefined || recordId === "") {
|
|
||||||
throw new Error("Field encryption requires a stable record id.");
|
|
||||||
}
|
|
||||||
return String(recordId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { FieldEncryptionBoundary } from "../../../database/repositories/field-encryption-boundary.js";
|
|
||||||
|
|
||||||
describe("FieldEncryptionBoundary", () => {
|
|
||||||
const userDataKey = crypto.randomBytes(32);
|
|
||||||
|
|
||||||
it("encrypts sensitive host fields while leaving queryable metadata plaintext", () => {
|
|
||||||
const host = {
|
|
||||||
id: 42,
|
|
||||||
userId: "user-1",
|
|
||||||
name: "prod-db",
|
|
||||||
ip: "10.0.0.5",
|
|
||||||
username: "root",
|
|
||||||
password: "secret",
|
|
||||||
rdpPassword: "rdp-secret",
|
|
||||||
};
|
|
||||||
|
|
||||||
const encrypted = FieldEncryptionBoundary.encryptRecord(
|
|
||||||
"ssh_data",
|
|
||||||
host,
|
|
||||||
userDataKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(encrypted.password).not.toBe("secret");
|
|
||||||
expect(encrypted.rdpPassword).not.toBe("rdp-secret");
|
|
||||||
expect(encrypted.ip).toBe("10.0.0.5");
|
|
||||||
expect(encrypted.name).toBe("prod-db");
|
|
||||||
|
|
||||||
const decrypted = FieldEncryptionBoundary.decryptRecord(
|
|
||||||
"ssh_data",
|
|
||||||
encrypted,
|
|
||||||
userDataKey,
|
|
||||||
);
|
|
||||||
expect(decrypted).toMatchObject(host);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("encrypts credential secret fields and keeps metadata plaintext", () => {
|
|
||||||
const credential = {
|
|
||||||
id: 7,
|
|
||||||
userId: "user-1",
|
|
||||||
name: "primary credential",
|
|
||||||
authType: "key",
|
|
||||||
key: "private-key-material",
|
|
||||||
keyPassword: "key-password",
|
|
||||||
};
|
|
||||||
|
|
||||||
const encrypted = FieldEncryptionBoundary.encryptRecord(
|
|
||||||
"ssh_credentials",
|
|
||||||
credential,
|
|
||||||
userDataKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(encrypted.key).not.toBe("private-key-material");
|
|
||||||
expect(encrypted.keyPassword).not.toBe("key-password");
|
|
||||||
expect(encrypted.name).toBe("primary credential");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
FieldEncryptionBoundary.decryptRecord(
|
|
||||||
"ssh_credentials",
|
|
||||||
encrypted,
|
|
||||||
userDataKey,
|
|
||||||
),
|
|
||||||
).toMatchObject(credential);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps empty and non-string sensitive values unchanged", () => {
|
|
||||||
const encrypted = FieldEncryptionBoundary.encryptRecord(
|
|
||||||
"ssh_data",
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
password: "",
|
|
||||||
key: null,
|
|
||||||
},
|
|
||||||
userDataKey,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(encrypted.password).toBe("");
|
|
||||||
expect(encrypted.key).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requires a stable record id instead of inventing a temporary encryption context", () => {
|
|
||||||
expect(() =>
|
|
||||||
FieldEncryptionBoundary.encryptRecord(
|
|
||||||
"ssh_data",
|
|
||||||
{ password: "secret" },
|
|
||||||
userDataKey,
|
|
||||||
),
|
|
||||||
).toThrow(/stable record id/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("classifies sensitive, plaintext, and unknown fields", () => {
|
|
||||||
expect(FieldEncryptionBoundary.classifyField("ssh_data", "password")).toBe(
|
|
||||||
"sensitive",
|
|
||||||
);
|
|
||||||
expect(FieldEncryptionBoundary.classifyField("ssh_data", "ip")).toBe(
|
|
||||||
"plaintext",
|
|
||||||
);
|
|
||||||
expect(FieldEncryptionBoundary.classifyField("ssh_data", "newField")).toBe(
|
|
||||||
"unknown",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user