import { eq } from "drizzle-orm"; import { termixIdentityCa } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { insertedId, rowsAffected, supportsReturning, } from "./mutation-result.js"; import { updateReturning } from "./returning.js"; export type TermixIdentityCaRecord = typeof termixIdentityCa.$inferSelect; export type NewTermixIdentityCaRecord = typeof termixIdentityCa.$inferInsert; export type TermixIdentityCaUpdate = Partial< Pick< NewTermixIdentityCaRecord, "publicKey" | "privateKey" | "validityDays" | "updatedAt" > >; export class TermixIdentityCaRepository { constructor( private readonly context: DatabaseContext, private readonly onWrite?: () => void | Promise, ) {} async findPublicByIdentityId( identityId: number, ): Promise | null> { const rows = await this.context.drizzle .select({ publicKey: termixIdentityCa.publicKey, validityDays: termixIdentityCa.validityDays, }) .from(termixIdentityCa) .where(eq(termixIdentityCa.identityId, identityId)) .limit(1); return rows[0] ?? null; } async findDecryptedByIdentityId( userId: string, identityId: number, ): Promise { const rows = await this.context.drizzle .select() .from(termixIdentityCa) .where(eq(termixIdentityCa.identityId, identityId)) .limit(1); return this.decryptOne(rows[0] ?? null, userId); } async createEncryptedForUser( userId: string, ca: NewTermixIdentityCaRecord, ): Promise { const userDataKey = DataCrypto.validateUserAccess(userId); const result = await this.insertThenEncrypt(userId, ca, userDataKey); await this.afterWrite(); return DataCrypto.decryptRecord( "termix_identity_ca", result, userId, userDataKey, ); } /** * Writes a CA in two steps, because the ciphertext depends on the id. * * The private key is encrypted with the row's own id as context, which does * not exist until the row does. So: insert with an empty key, encrypt, update. * The empty key must never be observable, hence the transaction. * * Two branches because better-sqlite3 rejects an async transaction callback — * see the same note in UserRepository. */ private async insertThenEncrypt( userId: string, ca: NewTermixIdentityCaRecord, userDataKey: Buffer, ): Promise { const draft = { ...ca, privateKey: "" }; const seal = (id: number) => DataCrypto.encryptRecord( "termix_identity_ca", { id, privateKey: ca.privateKey }, userId, userDataKey, ).privateKey; if (this.context.dialect === "sqlite") { /* eslint-disable no-restricted-syntax -- sqlite-only branch: the dialect is checked directly above, and better-sqlite3 needs the synchronous .all() form, which has no async equivalent. */ return this.context.drizzle.transaction((tx) => { const row = tx .insert(termixIdentityCa) .values(draft) .returning() .all()[0]; return tx .update(termixIdentityCa) .set({ privateKey: seal(row.id) }) .where(eq(termixIdentityCa.id, row.id)) .returning() .all()[0]; }); /* eslint-enable no-restricted-syntax */ } return this.context.drizzle.transaction(async (tx) => { let id: number | null; if (supportsReturning(this.context.dialect)) { // eslint-disable-next-line no-restricted-syntax -- guarded by the check above const rows = await tx .insert(termixIdentityCa) .values(draft) .returning(); id = rows[0]?.id ?? null; } else { id = insertedId(await tx.insert(termixIdentityCa).values(draft)); } if (id === null) { throw new Error("Insert into termix_identity_ca returned no id."); } await tx .update(termixIdentityCa) .set({ privateKey: seal(id) }) .where(eq(termixIdentityCa.id, id)); const [row] = await tx .select() .from(termixIdentityCa) .where(eq(termixIdentityCa.id, id)); return row; }); } async updateEncryptedForIdentity( userId: string, identityId: number, update: TermixIdentityCaUpdate, ): Promise { const existing = await this.findDecryptedByIdentityId(userId, identityId); if (!existing) return null; const userDataKey = DataCrypto.validateUserAccess(userId); const encryptedPrivateKey = update.privateKey ? DataCrypto.encryptRecord( "termix_identity_ca", { id: existing.id, privateKey: update.privateKey }, userId, userDataKey, ).privateKey : undefined; const rows = await updateReturning( this.context, termixIdentityCa, { ...update, ...(encryptedPrivateKey ? { privateKey: encryptedPrivateKey } : {}), }, eq(termixIdentityCa.identityId, identityId), ); await this.afterWrite(); return this.decryptOne(rows[0] ?? null, userId); } async deleteByIdentityId(identityId: number): Promise { const result = await this.context.drizzle .delete(termixIdentityCa) .where(eq(termixIdentityCa.identityId, identityId)); if (rowsAffected(result) > 0) { await this.afterWrite(); } return rowsAffected(result) > 0; } async deleteByUserId(userId: string): Promise { const result = await this.context.drizzle .delete(termixIdentityCa) .where(eq(termixIdentityCa.userId, userId)); if (rowsAffected(result) > 0) { await this.afterWrite(); } return rowsAffected(result); } private decryptOne>( record: T | null, userId: string, ): T | null { if (!record) return null; const userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey) return null; return DataCrypto.decryptRecord( "termix_identity_ca", record, userId, userDataKey, ); } private async afterWrite(): Promise { await this.onWrite?.(); } }