feat: add encryptedKey column to Credential table for calendar integrations (#27154)
Adds encrypted credential storage using a new keyring system: - New `encryptedKey` column with AES-256-GCM encryption - Decryption in getCalendarsEvents with fallback to legacy `key` - buildCredentialCreateData service for credential creation - Phase 1: Google Calendar only, other integrations follow
This commit is contained in:
@@ -83,6 +83,7 @@ type UserCredentialType = {
|
||||
teamId: number | null;
|
||||
key: Prisma.JsonValue;
|
||||
invalid: boolean | null;
|
||||
encryptedKey: string | null;
|
||||
};
|
||||
|
||||
export async function patchHandler(req: NextApiRequest) {
|
||||
|
||||
@@ -120,6 +120,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
appId: APPLE_CALENDAR_ID,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
encryptedKey: null,
|
||||
};
|
||||
|
||||
const dav = BuildCalendarService({
|
||||
|
||||
@@ -40,6 +40,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
appId: ICS_CALENDAR,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
encryptedKey: null,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -88,6 +88,7 @@ export class CredentialsRepository {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
encryptedKey: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
appId: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { buildCredentialCreateData } from "@calcom/features/credentials/services/CredentialDataService";
|
||||
import { DelegationCredentialRepository } from "@calcom/features/delegation-credentials/repositories/DelegationCredentialRepository";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
@@ -70,19 +71,20 @@ export async function handleCreateCredentials() {
|
||||
`Creating credentials for ${toProcessMembers.length} members out of ${delegatedMembers.length} delegated members in organization ${organization.id}`
|
||||
);
|
||||
|
||||
const credentialCreationPromises = toProcessMembers.map(async (member) => {
|
||||
try {
|
||||
await CredentialRepository.create({
|
||||
type: "google_calendar",
|
||||
key: {
|
||||
// The in-memory credential that we create for Delegation Credential has access_token as "NOOP_UNUSED_DELEGATION_TOKEN. This is to mark it is in DB, in case we need to identify that.
|
||||
access_token: "NOOP_UNUSED_DELEGATION_TOKEN_DB",
|
||||
},
|
||||
userId: member.userId,
|
||||
appId: "google-calendar",
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
});
|
||||
log.info(`Created credential for member ${member.userId}`);
|
||||
const credentialCreationPromises = toProcessMembers.map(async (member) => {
|
||||
try {
|
||||
const credentialData = buildCredentialCreateData({
|
||||
type: "google_calendar",
|
||||
key: {
|
||||
// The in-memory credential that we create for Delegation Credential has access_token as "NOOP_UNUSED_DELEGATION_TOKEN. This is to mark it is in DB, in case we need to identify that.
|
||||
access_token: "NOOP_UNUSED_DELEGATION_TOKEN_DB",
|
||||
},
|
||||
userId: member.userId,
|
||||
appId: "google-calendar",
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
});
|
||||
await CredentialRepository.create(credentialData);
|
||||
log.info(`Created credential for member ${member.userId}`);
|
||||
} catch (error) {
|
||||
log.error(`Error creating credential for member ${member.userId}:`, safeStringify(error));
|
||||
throw error;
|
||||
|
||||
@@ -10,6 +10,7 @@ type MockCredential = {
|
||||
userId: number | null;
|
||||
user: { email: string; name: string | null } | null;
|
||||
key: { access_token: string };
|
||||
encryptedKey: string | null;
|
||||
invalid: boolean | null;
|
||||
teamId: number | null;
|
||||
team: { name: string } | null;
|
||||
@@ -28,6 +29,7 @@ const createMockCredential = (overrides: Partial<MockCredential> = {}): MockCred
|
||||
team: null,
|
||||
user: { email: "test@example.com", name: "Test User" },
|
||||
key: { access_token: "mock_token_123" },
|
||||
encryptedKey: null,
|
||||
delegationCredentialId: "delegation-123",
|
||||
appId: "google-calendar",
|
||||
invalid: false,
|
||||
|
||||
@@ -61,6 +61,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
...data,
|
||||
user: { email: user.email },
|
||||
delegationCredentialId: null,
|
||||
encryptedKey: null,
|
||||
});
|
||||
await dav?.listCalendars();
|
||||
await prisma.credential.upsert({
|
||||
|
||||
@@ -39,6 +39,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
id: 0,
|
||||
...data,
|
||||
user: { email: user.email },
|
||||
encryptedKey: null,
|
||||
});
|
||||
await dav?.listCalendars();
|
||||
await prisma.credential.create({
|
||||
|
||||
@@ -91,6 +91,7 @@ export const FAKE_DAILY_CREDENTIAL: CredentialForCalendarService & { invalid: bo
|
||||
appId: "daily-video",
|
||||
invalid: false,
|
||||
teamId: null,
|
||||
encryptedKey: null,
|
||||
delegatedToId: null,
|
||||
delegatedTo: null,
|
||||
delegationCredentialId: null,
|
||||
|
||||
@@ -126,6 +126,7 @@ const buildDelegationCredential = (overrides = {}) => ({
|
||||
delegatedTo: {
|
||||
serviceAccountKey: mockServiceAccountKey,
|
||||
},
|
||||
encryptedKey: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -158,6 +159,7 @@ const buildRegularCredential = (overrides = {}): CredentialForCalendarService =>
|
||||
delegatedTo: null,
|
||||
// Regular credentials have it set to null always
|
||||
delegationCredentialId: null,
|
||||
encryptedKey: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ const _buildCommonUserCredential = ({
|
||||
key: {
|
||||
access_token: "NOOP_UNUSED_DELEGATION_TOKEN",
|
||||
},
|
||||
encryptedKey: null,
|
||||
invalid: false,
|
||||
teamId: null,
|
||||
team: null,
|
||||
|
||||
@@ -46,6 +46,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
id: 0,
|
||||
...data,
|
||||
user: { email: user.email },
|
||||
encryptedKey: null,
|
||||
});
|
||||
await dav?.listCalendars();
|
||||
await prisma.credential.create({
|
||||
|
||||
@@ -46,6 +46,7 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
id: 0,
|
||||
user: { email: user.email },
|
||||
...data,
|
||||
encryptedKey: null,
|
||||
});
|
||||
await dav?.listCalendars();
|
||||
await prisma.credential.create({
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
};
|
||||
|
||||
try {
|
||||
const service = BuildCalendarService({ id: 0, user: { email: session.user.email || "" }, ...data });
|
||||
const service = BuildCalendarService({ id: 0, user: { email: session.user.email || "" }, ...data, encryptedKey: null });
|
||||
await service?.listCalendars();
|
||||
await prisma.credential.create({ data });
|
||||
} catch (reason) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createGoogleCalendarServiceWithGoogleType } from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { buildCredentialCreateData } from "@calcom/features/credentials/services/CredentialDataService";
|
||||
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
||||
import {
|
||||
GOOGLE_CALENDAR_SCOPES,
|
||||
@@ -71,12 +72,13 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
oAuth2Client.setCredentials(key);
|
||||
|
||||
const gcalCredential = await CredentialRepository.create({
|
||||
const gcalCredentialData = buildCredentialCreateData({
|
||||
userId: req.session.user.id,
|
||||
key,
|
||||
appId: "google-calendar",
|
||||
type: "google_calendar",
|
||||
});
|
||||
const gcalCredential = await CredentialRepository.create(gcalCredentialData);
|
||||
|
||||
const gCalService = createGoogleCalendarServiceWithGoogleType({
|
||||
...gcalCredential,
|
||||
@@ -169,12 +171,13 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
// Create a new google meet credential
|
||||
await CredentialRepository.create({
|
||||
const googleMeetCredentialData = buildCredentialCreateData({
|
||||
userId: req.session.user.id,
|
||||
type: "google_video",
|
||||
key: {},
|
||||
appId: "google-meet",
|
||||
});
|
||||
await CredentialRepository.create(googleMeetCredentialData);
|
||||
res.redirect(
|
||||
getSafeRedirectUrl(`${WEBAPP_URL}/apps/installed/conferencing?hl=google-meet`) ??
|
||||
getInstalledAppPath({ variant: "conferencing", slug: "google-meet" })
|
||||
|
||||
@@ -52,6 +52,7 @@ const mockCredential: CredentialForCalendarServiceWithEmail = {
|
||||
delegatedTo: null,
|
||||
invalid: false,
|
||||
teamId: null,
|
||||
encryptedKey: null,
|
||||
};
|
||||
|
||||
describe("getAvailability", () => {
|
||||
|
||||
@@ -191,6 +191,7 @@ describe("HubspotCalendarService", () => {
|
||||
email: "test-user@example.com",
|
||||
},
|
||||
delegationCredentialId: null,
|
||||
encryptedKey: null,
|
||||
};
|
||||
|
||||
service = BuildCrmService(mockCredential, {}) as CRM & { getAppOptions: () => AppOptions };
|
||||
|
||||
@@ -37,6 +37,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
id: 0,
|
||||
...data,
|
||||
user: { email: user.email },
|
||||
encryptedKey: null,
|
||||
});
|
||||
const listedCals = await dav.listCalendars();
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ const testCredential = {
|
||||
teamId: 1,
|
||||
delegatedTo: null,
|
||||
delegationCredentialId: null,
|
||||
encryptedKey: null,
|
||||
};
|
||||
|
||||
describe("createMeeting", () => {
|
||||
|
||||
@@ -120,6 +120,7 @@ describe("SalesforceCRMService", () => {
|
||||
email: "test-user@example.com",
|
||||
},
|
||||
delegationCredentialId: null,
|
||||
encryptedKey: null,
|
||||
};
|
||||
|
||||
service = createSalesforceCrmServiceWithSalesforceType(mockCredential, {}, true);
|
||||
|
||||
@@ -86,6 +86,7 @@ const createMockCredential = (options?: { tokenLifetime?: number; issuedAt?: str
|
||||
appId: "test_app_id",
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
encryptedKey: null,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("sanitizeAppForViewer", () => {
|
||||
delegatedToId: null,
|
||||
delegationCredentialId: null,
|
||||
team: null,
|
||||
encryptedKey: null,
|
||||
};
|
||||
|
||||
const mockApp: App & {
|
||||
|
||||
@@ -64,6 +64,7 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials?
|
||||
teamId: null,
|
||||
appId: appMeta.slug,
|
||||
invalid: false,
|
||||
encryptedKey: null,
|
||||
delegatedTo: null,
|
||||
delegatedToId: null,
|
||||
delegationCredentialId: null,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { buildCredentialCreateData } from "@calcom/features/credentials/services/CredentialDataService";
|
||||
import type { TrackingData } from "@calcom/lib/tracking";
|
||||
import { DeploymentRepository } from "@calcom/features/ee/deployment/repositories/DeploymentRepository";
|
||||
import createUsersAndConnectToOrg from "@calcom/features/ee/dsync/lib/users/createUsersAndConnectToOrg";
|
||||
@@ -730,12 +731,13 @@ export const getOptions = ({
|
||||
token_type: account.token_type,
|
||||
expires_at: account.expires_at,
|
||||
};
|
||||
const gcalCredential = await CredentialRepository.create({
|
||||
const gcalCredentialData = buildCredentialCreateData({
|
||||
userId: Number(user.id),
|
||||
key: credentialkey,
|
||||
appId: "google-calendar",
|
||||
type: "google_calendar",
|
||||
});
|
||||
const gcalCredential = await CredentialRepository.create(gcalCredentialData);
|
||||
const gCalService = createGoogleCalendarServiceWithGoogleType({
|
||||
...gcalCredential,
|
||||
user: null,
|
||||
@@ -748,12 +750,13 @@ export const getOptions = ({
|
||||
type: "google_video",
|
||||
}))
|
||||
) {
|
||||
await CredentialRepository.create({
|
||||
const googleMeetCredentialData = buildCredentialCreateData({
|
||||
type: "google_video",
|
||||
key: {},
|
||||
userId: Number(user.id),
|
||||
appId: "google-meet",
|
||||
});
|
||||
await CredentialRepository.create(googleMeetCredentialData);
|
||||
}
|
||||
|
||||
const oAuth2Client = new OAuth2Client(GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET);
|
||||
|
||||
@@ -939,6 +939,7 @@ export default class EventManager {
|
||||
invalid: credentialFromDB.invalid,
|
||||
appId: credentialFromDB.appId,
|
||||
user: credentialFromDB.user,
|
||||
encryptedKey: credentialFromDB.encryptedKey,
|
||||
delegatedToId: credentialFromDB.delegatedToId,
|
||||
delegatedTo: credentialFromDB.delegatedTo,
|
||||
delegationCredentialId: credentialFromDB.delegationCredentialId,
|
||||
@@ -1155,6 +1156,7 @@ export default class EventManager {
|
||||
invalid: credentialFromDB.invalid,
|
||||
appId: credentialFromDB.appId,
|
||||
user: credentialFromDB.user,
|
||||
encryptedKey: credentialFromDB.encryptedKey,
|
||||
delegatedToId: credentialFromDB.delegatedToId,
|
||||
delegatedTo: credentialFromDB.delegatedTo,
|
||||
delegationCredentialId: credentialFromDB.delegationCredentialId,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import process from "node:process";
|
||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { decryptSecret } from "@calcom/lib/crypto/keyring";
|
||||
import { isDelegationCredential } from "@calcom/lib/delegationCredential";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getPiiFreeSelectedCalendar, getPiiFreeCredential } from "@calcom/lib/piiFreeData";
|
||||
import { getPiiFreeCredential, getPiiFreeSelectedCalendar } from "@calcom/lib/piiFreeData";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { performance } from "@calcom/lib/server/perfObserver";
|
||||
import type { CalendarFetchMode, EventBusyDate, SelectedCalendar } from "@calcom/types/Calendar";
|
||||
import type { CredentialForCalendarService } from "@calcom/types/Credential";
|
||||
|
||||
import { normalizeTimezone } from "./timezone-conversion";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] });
|
||||
@@ -101,7 +102,33 @@ const getCalendarsEvents = async (
|
||||
|
||||
const calendarAndCredentialPairs = await Promise.all(
|
||||
calendarCredentials.map(async (credential) => {
|
||||
const calendar = await getCalendar(credential, mode);
|
||||
let key: typeof credential.key;
|
||||
try {
|
||||
if (credential.encryptedKey) {
|
||||
key = JSON.parse(
|
||||
decryptSecret({
|
||||
envelope: JSON.parse(credential.encryptedKey),
|
||||
aad: { type: credential.type },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
key = credential.key;
|
||||
}
|
||||
} catch {
|
||||
log.warn("Failed to decrypt credential key, falling back to legacy key", {
|
||||
credentialId: credential.id,
|
||||
});
|
||||
key = credential.key;
|
||||
}
|
||||
|
||||
const calendar = await getCalendar(
|
||||
{
|
||||
...credential,
|
||||
// use encrypted secret to get unencrypted calendar creds
|
||||
key,
|
||||
},
|
||||
mode
|
||||
);
|
||||
return [calendar, credential] as const;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -180,6 +180,7 @@ const createMeetingWithCalVideo = async (calEvent: CalendarEvent) => {
|
||||
user: { email: "" },
|
||||
teamId: null,
|
||||
key: dailyAppKeys,
|
||||
encryptedKey: null,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
},
|
||||
@@ -203,6 +204,7 @@ export const createInstantMeetingWithCalVideo = async (endTime: string) => {
|
||||
user: { email: "" },
|
||||
teamId: null,
|
||||
key: dailyAppKeys,
|
||||
encryptedKey: null,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
},
|
||||
@@ -229,6 +231,7 @@ const getRecordingsOfCalVideoByRoomName = async (
|
||||
user: { email: "" },
|
||||
teamId: null,
|
||||
key: dailyAppKeys,
|
||||
encryptedKey: null,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
},
|
||||
@@ -255,6 +258,7 @@ const getDownloadLinkOfCalVideoByRecordingId = async (
|
||||
user: { email: "" },
|
||||
teamId: null,
|
||||
key: dailyAppKeys,
|
||||
encryptedKey: null,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
},
|
||||
@@ -279,6 +283,7 @@ const getAllTranscriptsAccessLinkFromRoomName = async (roomName: string) => {
|
||||
user: { email: "" },
|
||||
teamId: null,
|
||||
key: dailyAppKeys,
|
||||
encryptedKey: null,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
},
|
||||
@@ -303,6 +308,7 @@ const getAllTranscriptsAccessLinkFromMeetingId = async (meetingId: string) => {
|
||||
user: { email: "" },
|
||||
teamId: null,
|
||||
key: dailyAppKeys,
|
||||
encryptedKey: null,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
},
|
||||
@@ -327,6 +333,7 @@ const submitBatchProcessorTranscriptionJob = async (recordingId: string) => {
|
||||
user: { email: "" },
|
||||
teamId: null,
|
||||
key: dailyAppKeys,
|
||||
encryptedKey: null,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
},
|
||||
@@ -363,6 +370,7 @@ const getTranscriptsAccessLinkFromRecordingId = async (recordingId: string) => {
|
||||
user: { email: "" },
|
||||
teamId: null,
|
||||
key: dailyAppKeys,
|
||||
encryptedKey: null,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
},
|
||||
@@ -388,6 +396,7 @@ const checkIfRoomNameMatchesInRecording = async (roomName: string, recordingId:
|
||||
user: { email: "" },
|
||||
teamId: null,
|
||||
key: dailyAppKeys,
|
||||
encryptedKey: null,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
},
|
||||
@@ -413,6 +422,7 @@ const getCalVideoMeetingSessionsByRoomName = async (roomName: string) => {
|
||||
user: { email: "" },
|
||||
teamId: null,
|
||||
key: dailyAppKeys,
|
||||
encryptedKey: null,
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ type CredentialCreateInput = {
|
||||
userId: number;
|
||||
appId: string;
|
||||
delegationCredentialId?: string | null;
|
||||
encryptedKey?: string | null;
|
||||
};
|
||||
|
||||
type CredentialUpdateInput = {
|
||||
@@ -45,7 +46,9 @@ export class CredentialRepository {
|
||||
}
|
||||
|
||||
static async create(data: CredentialCreateInput) {
|
||||
const credential = await prisma.credential.create({ data: { ...data } });
|
||||
const credential = await prisma.credential.create({
|
||||
data,
|
||||
});
|
||||
return buildNonDelegationCredential(credential);
|
||||
}
|
||||
static async findByAppIdAndUserId({
|
||||
@@ -81,7 +84,7 @@ export class CredentialRepository {
|
||||
static async findFirstByIdWithKeyAndUser({ id }: { id: number }) {
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: { id },
|
||||
select: { ...safeCredentialSelect, key: true },
|
||||
select: { ...safeCredentialSelect, key: true, encryptedKey: true },
|
||||
});
|
||||
return buildNonDelegationCredential(credential);
|
||||
}
|
||||
@@ -276,15 +279,17 @@ export class CredentialRepository {
|
||||
type,
|
||||
key,
|
||||
appId,
|
||||
encryptedKey,
|
||||
}: {
|
||||
userId: number;
|
||||
delegationCredentialId: string;
|
||||
type: string;
|
||||
key: Prisma.InputJsonValue;
|
||||
appId: string;
|
||||
encryptedKey?: string | null;
|
||||
}) {
|
||||
return prisma.credential.create({
|
||||
data: { userId, delegationCredentialId, type, key, appId },
|
||||
data: { userId, delegationCredentialId, type, key, appId, ...(encryptedKey && { encryptedKey }) },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { encryptSecret } from "@calcom/lib/crypto/keyring";
|
||||
|
||||
export type CredentialCreateData = {
|
||||
type: string;
|
||||
key: object;
|
||||
userId: number;
|
||||
appId: string;
|
||||
delegationCredentialId?: string | null;
|
||||
encryptedKey?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the data object for creating a credential, including the encrypted key.
|
||||
* This service handles the encryption logic so the repository stays focused on data access.
|
||||
*
|
||||
* @param data The credential data without encryptedKey
|
||||
* @returns The credential data with encryptedKey populated if encryption key is available
|
||||
*/
|
||||
export function buildCredentialCreateData(data: {
|
||||
type: string;
|
||||
key: object;
|
||||
userId: number;
|
||||
appId: string;
|
||||
delegationCredentialId?: string | null;
|
||||
}): CredentialCreateData {
|
||||
const aad = {
|
||||
type: data.type,
|
||||
};
|
||||
|
||||
let encryptedKey: ReturnType<typeof encryptSecret> | null = null;
|
||||
try {
|
||||
encryptedKey = encryptSecret({ ring: "CREDENTIALS", plaintext: JSON.stringify(data.key), aad });
|
||||
} catch {
|
||||
// Encryption keyring not configured - this is expected in tests and some environments
|
||||
// The encryptedKey will be null, which is fine for backwards compatibility
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
key: data.key,
|
||||
...(encryptedKey && { encryptedKey: JSON.stringify(encryptedKey) }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import process from "node:process";
|
||||
|
||||
type KeyringName = "CREDENTIALS";
|
||||
|
||||
type SecretEnvelopeV1 = {
|
||||
v: 1;
|
||||
alg: "AES-256-GCM";
|
||||
ring: KeyringName;
|
||||
kid: string;
|
||||
nonce: string;
|
||||
ct: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
type AAD = Record<string, string | number | boolean | null> | Array<string | number | boolean | null>;
|
||||
|
||||
function stableJson(value: unknown): string {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value !== "object") return JSON.stringify(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((v) => stableJson(v)).join(",")}]`;
|
||||
}
|
||||
|
||||
const keys = Object.keys(value).sort();
|
||||
const entries = keys.map(
|
||||
(k) => `${JSON.stringify(k)}:${stableJson((value as Record<string, unknown>)[k])}`
|
||||
);
|
||||
return `{${entries.join(",")}}`;
|
||||
}
|
||||
|
||||
function aadToBuffer(aad: AAD): Buffer {
|
||||
const stable = stableJson(aad);
|
||||
return Buffer.from(stable, "utf8");
|
||||
}
|
||||
|
||||
function b64url(buf: Buffer): string {
|
||||
return buf.toString("base64url");
|
||||
}
|
||||
|
||||
function unb64url(s: string): Buffer {
|
||||
return Buffer.from(s, "base64url");
|
||||
}
|
||||
|
||||
const envKeyringPrefix = (ring: KeyringName): string => {
|
||||
// enforce all caps ring names to match your convention (optional)
|
||||
// if you want to allow any casing, remove this check.
|
||||
if (ring !== ring.toUpperCase()) {
|
||||
throw new Error(`Keyring name must be ALL CAPS. Got: ${ring}`);
|
||||
}
|
||||
return `CALCOM_KEYRING_${ring}_`;
|
||||
};
|
||||
|
||||
const getCurrentKid = (ring: KeyringName): string => {
|
||||
const prefix = envKeyringPrefix(ring);
|
||||
const current = process.env[`${prefix}CURRENT`];
|
||||
if (!current) throw new Error(`Missing env var ${prefix}CURRENT`);
|
||||
return current;
|
||||
};
|
||||
|
||||
export function getKeyMaterial(ring: KeyringName, kid: string): Buffer {
|
||||
const prefix = envKeyringPrefix(ring);
|
||||
const raw = process.env[`${prefix}${kid.toUpperCase()}`]; // e.g. CALCOM_KEYRING_CREDENTIALS_K1
|
||||
if (!raw) throw new Error(`Unknown kid for ring=${ring}: missing env var ${prefix}${kid.toUpperCase()}`);
|
||||
|
||||
const key = Buffer.from(raw, "base64url");
|
||||
if (key.length !== 32) {
|
||||
throw new Error(`Invalid key length for ring=${ring} kid=${kid}. Expected 32 bytes, got ${key.length}`);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export const encryptSecret = ({
|
||||
ring,
|
||||
plaintext,
|
||||
aad,
|
||||
}: {
|
||||
ring: KeyringName;
|
||||
plaintext: string;
|
||||
aad: AAD;
|
||||
}): SecretEnvelopeV1 => {
|
||||
const kid = getCurrentKid(ring);
|
||||
const key = getKeyMaterial(ring, kid);
|
||||
|
||||
const nonce = randomBytes(12); // 96-bit nonce for GCM
|
||||
const aadBuf = aadToBuffer(aad);
|
||||
|
||||
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
||||
cipher.setAAD(aadBuf);
|
||||
|
||||
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
alg: "AES-256-GCM",
|
||||
ring,
|
||||
kid,
|
||||
nonce: b64url(nonce),
|
||||
ct: b64url(ct),
|
||||
tag: b64url(tag),
|
||||
};
|
||||
};
|
||||
|
||||
export const decryptSecret = ({ envelope, aad }: { envelope: SecretEnvelopeV1; aad: AAD }): string => {
|
||||
if (envelope.v !== 1) {
|
||||
throw new Error(`Unsupported envelope version: ${envelope.v}`);
|
||||
}
|
||||
if (envelope.alg !== "AES-256-GCM") {
|
||||
throw new Error(`Unsupported envelope algorithm: ${envelope.alg}`);
|
||||
}
|
||||
|
||||
const key = getKeyMaterial(envelope.ring, envelope.kid);
|
||||
const nonce = unb64url(envelope.nonce);
|
||||
const ct = unb64url(envelope.ct);
|
||||
const tag = unb64url(envelope.tag);
|
||||
const aadBuf = aadToBuffer(aad);
|
||||
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
||||
decipher.setAAD(aadBuf);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
|
||||
return pt.toString("utf8");
|
||||
};
|
||||
|
||||
export const decryptAndMaybeReencrypt = ({
|
||||
aad,
|
||||
envelope,
|
||||
}: {
|
||||
envelope: SecretEnvelopeV1;
|
||||
aad: AAD;
|
||||
}): { plaintext: string; updatedEnvelope: SecretEnvelopeV1 | null } => {
|
||||
const plaintext = decryptSecret({ envelope, aad });
|
||||
|
||||
const currentKid = getCurrentKid(envelope.ring);
|
||||
if (envelope.kid === currentKid) {
|
||||
return { plaintext, updatedEnvelope: null };
|
||||
}
|
||||
|
||||
const updatedEnvelope = encryptSecret({
|
||||
ring: envelope.ring,
|
||||
plaintext,
|
||||
aad,
|
||||
});
|
||||
|
||||
return { plaintext, updatedEnvelope };
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."Credential" ADD COLUMN "encryptedKey" TEXT;
|
||||
@@ -300,17 +300,18 @@ model EventType {
|
||||
}
|
||||
|
||||
model Credential {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
// @@type is deprecated
|
||||
type String
|
||||
key Json
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int?
|
||||
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
teamId Int?
|
||||
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
|
||||
type String
|
||||
key Json
|
||||
encryptedKey String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int?
|
||||
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
teamId Int?
|
||||
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
|
||||
// How to make it a required column?
|
||||
appId String?
|
||||
appId String?
|
||||
|
||||
// paid apps
|
||||
subscriptionId String?
|
||||
|
||||
@@ -12,6 +12,7 @@ export const credentialForCalendarServiceSelect = {
|
||||
},
|
||||
teamId: true,
|
||||
key: true,
|
||||
encryptedKey: true,
|
||||
invalid: true,
|
||||
delegationCredentialId: true,
|
||||
} satisfies Prisma.CredentialSelect;
|
||||
@@ -21,6 +22,7 @@ export const safeCredentialSelect = {
|
||||
type: true,
|
||||
/** Omitting to avoid frontend leaks */
|
||||
// key: true,
|
||||
// encryptedKey: true,
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
|
||||
@@ -41,6 +41,7 @@ export const calendarOverlayHandler = async ({ ctx, input }: ListOptions) => {
|
||||
id: true,
|
||||
type: true,
|
||||
key: true,
|
||||
encryptedKey: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
appId: true,
|
||||
|
||||
@@ -127,6 +127,7 @@ export const getHostsWithLocationOptionsHandler = async ({
|
||||
credentials: host.user.credentials.map((cred) => ({
|
||||
...cred,
|
||||
key: {} as Prisma.JsonValue,
|
||||
encryptedKey: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
Vendored
+2
-1
@@ -55,7 +55,8 @@ export type Office365CredentialPayload = CredentialPayload & {
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type CredentialFrontendPayload = Omit<CredentialPayload, "key"> & {
|
||||
export type CredentialFrontendPayload = Omit<CredentialPayload, "key" | "encryptedKey"> & {
|
||||
/** We should type error if keys are leaked to the frontend */
|
||||
key?: never;
|
||||
encryptedKey?: never;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user