From fc9c26e8dd07dee062d43c22e6a39da87467e13a Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Fri, 30 Jan 2026 09:15:13 -0300 Subject: [PATCH] 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 --- .../api/destination-calendars/[id]/_patch.ts | 1 + .../services/apple-calendar.service.ts | 1 + .../ee/calendars/services/ics-feed.service.ts | 1 + .../credentials/credentials.repository.ts | 1 + apps/web/app/api/cron/credentials/route.ts | 28 ++-- .../apps/InstallAppButtonChild.test.tsx | 2 + packages/app-store/applecalendar/api/add.ts | 1 + packages/app-store/caldavcalendar/api/add.ts | 1 + .../dailyvideo/lib/VideoApiAdapter.ts | 1 + .../app-store/delegationCredential.test.ts | 2 + packages/app-store/delegationCredential.ts | 1 + .../app-store/exchange2013calendar/api/add.ts | 1 + .../app-store/exchange2016calendar/api/add.ts | 1 + .../exchangecalendar/api/_postAdd.ts | 2 +- .../app-store/googlecalendar/api/callback.ts | 7 +- .../lib/__tests__/CalendarService.test.ts | 1 + .../app-store/hubspot/lib/CrmService.test.ts | 1 + .../app-store/ics-feedcalendar/api/add.ts | 1 + .../lib/VideoApiAdapter.test.ts | 1 + .../salesforce/lib/CrmService.test.ts | 1 + .../__tests__/CrmService.integration.test.ts | 1 + packages/app-store/utils.test.ts | 1 + packages/app-store/utils.ts | 1 + .../features/auth/lib/next-auth-options.ts | 7 +- .../features/bookings/lib/EventManager.ts | 2 + .../calendars/lib/getCalendarsEvents.ts | 33 +++- .../features/conferencing/lib/videoClient.ts | 10 ++ .../repositories/CredentialRepository.ts | 11 +- .../services/CredentialDataService.ts | 43 +++++ packages/lib/crypto/keyring.ts | 149 ++++++++++++++++++ .../migration.sql | 2 + packages/prisma/schema.prisma | 19 +-- packages/prisma/selects/credential.ts | 2 + .../availability/calendarOverlay.handler.ts | 1 + .../getHostsWithLocationOptions.handler.ts | 1 + packages/types/Credential.d.ts | 3 +- 36 files changed, 308 insertions(+), 34 deletions(-) create mode 100644 packages/features/credentials/services/CredentialDataService.ts create mode 100644 packages/lib/crypto/keyring.ts create mode 100644 packages/prisma/migrations/20260122165837_add_encrypted_key_to_credential/migration.sql diff --git a/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts index ff2c74718a..23533864aa 100644 --- a/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts +++ b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts @@ -83,6 +83,7 @@ type UserCredentialType = { teamId: number | null; key: Prisma.JsonValue; invalid: boolean | null; + encryptedKey: string | null; }; export async function patchHandler(req: NextApiRequest) { diff --git a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts index 75ddc49ec0..86599d97aa 100644 --- a/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts +++ b/apps/api/v2/src/ee/calendars/services/apple-calendar.service.ts @@ -120,6 +120,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp { appId: APPLE_CALENDAR_ID, invalid: false, delegationCredentialId: null, + encryptedKey: null, }; const dav = BuildCalendarService({ diff --git a/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts b/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts index 28c7af178d..b1609ddf55 100644 --- a/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts +++ b/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts @@ -40,6 +40,7 @@ export class IcsFeedService implements ICSFeedCalendarApp { appId: ICS_CALENDAR, invalid: false, delegationCredentialId: null, + encryptedKey: null, }; try { diff --git a/apps/api/v2/src/modules/credentials/credentials.repository.ts b/apps/api/v2/src/modules/credentials/credentials.repository.ts index a1017e1ee5..88b16a3044 100644 --- a/apps/api/v2/src/modules/credentials/credentials.repository.ts +++ b/apps/api/v2/src/modules/credentials/credentials.repository.ts @@ -88,6 +88,7 @@ export class CredentialsRepository { id: true, type: true, key: true, + encryptedKey: true, userId: true, teamId: true, appId: true, diff --git a/apps/web/app/api/cron/credentials/route.ts b/apps/web/app/api/cron/credentials/route.ts index 1ce4dc1ef8..0ca4de111e 100644 --- a/apps/web/app/api/cron/credentials/route.ts +++ b/apps/web/app/api/cron/credentials/route.ts @@ -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; diff --git a/apps/web/components/apps/InstallAppButtonChild.test.tsx b/apps/web/components/apps/InstallAppButtonChild.test.tsx index f0b27650c3..8f8ed3d2f2 100644 --- a/apps/web/components/apps/InstallAppButtonChild.test.tsx +++ b/apps/web/components/apps/InstallAppButtonChild.test.tsx @@ -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 = {}): 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, diff --git a/packages/app-store/applecalendar/api/add.ts b/packages/app-store/applecalendar/api/add.ts index 68ce88885c..d41704802c 100644 --- a/packages/app-store/applecalendar/api/add.ts +++ b/packages/app-store/applecalendar/api/add.ts @@ -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({ diff --git a/packages/app-store/caldavcalendar/api/add.ts b/packages/app-store/caldavcalendar/api/add.ts index c7ea3cb491..0548f0efcf 100644 --- a/packages/app-store/caldavcalendar/api/add.ts +++ b/packages/app-store/caldavcalendar/api/add.ts @@ -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({ diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index 5b617adfc8..d2027804b5 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -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, diff --git a/packages/app-store/delegationCredential.test.ts b/packages/app-store/delegationCredential.test.ts index 7e9f5b6068..226adedf71 100644 --- a/packages/app-store/delegationCredential.test.ts +++ b/packages/app-store/delegationCredential.test.ts @@ -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, }); diff --git a/packages/app-store/delegationCredential.ts b/packages/app-store/delegationCredential.ts index bc80352c75..c342ec8eae 100644 --- a/packages/app-store/delegationCredential.ts +++ b/packages/app-store/delegationCredential.ts @@ -92,6 +92,7 @@ const _buildCommonUserCredential = ({ key: { access_token: "NOOP_UNUSED_DELEGATION_TOKEN", }, + encryptedKey: null, invalid: false, teamId: null, team: null, diff --git a/packages/app-store/exchange2013calendar/api/add.ts b/packages/app-store/exchange2013calendar/api/add.ts index 53315619b2..d73b73f400 100644 --- a/packages/app-store/exchange2013calendar/api/add.ts +++ b/packages/app-store/exchange2013calendar/api/add.ts @@ -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({ diff --git a/packages/app-store/exchange2016calendar/api/add.ts b/packages/app-store/exchange2016calendar/api/add.ts index 8d02c02e2b..6f41efafb0 100644 --- a/packages/app-store/exchange2016calendar/api/add.ts +++ b/packages/app-store/exchange2016calendar/api/add.ts @@ -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({ diff --git a/packages/app-store/exchangecalendar/api/_postAdd.ts b/packages/app-store/exchangecalendar/api/_postAdd.ts index 830f8d3bd4..d776e406d7 100644 --- a/packages/app-store/exchangecalendar/api/_postAdd.ts +++ b/packages/app-store/exchangecalendar/api/_postAdd.ts @@ -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) { diff --git a/packages/app-store/googlecalendar/api/callback.ts b/packages/app-store/googlecalendar/api/callback.ts index 0e355c9c6e..f990bbee49 100644 --- a/packages/app-store/googlecalendar/api/callback.ts +++ b/packages/app-store/googlecalendar/api/callback.ts @@ -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" }) diff --git a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts index d95d435a01..1d5ba21be6 100644 --- a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts @@ -52,6 +52,7 @@ const mockCredential: CredentialForCalendarServiceWithEmail = { delegatedTo: null, invalid: false, teamId: null, + encryptedKey: null, }; describe("getAvailability", () => { diff --git a/packages/app-store/hubspot/lib/CrmService.test.ts b/packages/app-store/hubspot/lib/CrmService.test.ts index bbe2bf2857..d5be7ecde6 100644 --- a/packages/app-store/hubspot/lib/CrmService.test.ts +++ b/packages/app-store/hubspot/lib/CrmService.test.ts @@ -191,6 +191,7 @@ describe("HubspotCalendarService", () => { email: "test-user@example.com", }, delegationCredentialId: null, + encryptedKey: null, }; service = BuildCrmService(mockCredential, {}) as CRM & { getAppOptions: () => AppOptions }; diff --git a/packages/app-store/ics-feedcalendar/api/add.ts b/packages/app-store/ics-feedcalendar/api/add.ts index b62e49bb8d..43a512af83 100644 --- a/packages/app-store/ics-feedcalendar/api/add.ts +++ b/packages/app-store/ics-feedcalendar/api/add.ts @@ -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(); diff --git a/packages/app-store/office365video/lib/VideoApiAdapter.test.ts b/packages/app-store/office365video/lib/VideoApiAdapter.test.ts index 865e2e54f5..e84b3e51ed 100644 --- a/packages/app-store/office365video/lib/VideoApiAdapter.test.ts +++ b/packages/app-store/office365video/lib/VideoApiAdapter.test.ts @@ -56,6 +56,7 @@ const testCredential = { teamId: 1, delegatedTo: null, delegationCredentialId: null, + encryptedKey: null, }; describe("createMeeting", () => { diff --git a/packages/app-store/salesforce/lib/CrmService.test.ts b/packages/app-store/salesforce/lib/CrmService.test.ts index c3c1a8caa2..fe352416f9 100644 --- a/packages/app-store/salesforce/lib/CrmService.test.ts +++ b/packages/app-store/salesforce/lib/CrmService.test.ts @@ -120,6 +120,7 @@ describe("SalesforceCRMService", () => { email: "test-user@example.com", }, delegationCredentialId: null, + encryptedKey: null, }; service = createSalesforceCrmServiceWithSalesforceType(mockCredential, {}, true); diff --git a/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts b/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts index d9dc5ed662..3e8125b91a 100644 --- a/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts +++ b/packages/app-store/salesforce/lib/__tests__/CrmService.integration.test.ts @@ -86,6 +86,7 @@ const createMockCredential = (options?: { tokenLifetime?: number; issuedAt?: str appId: "test_app_id", invalid: false, delegationCredentialId: null, + encryptedKey: null, }; }; diff --git a/packages/app-store/utils.test.ts b/packages/app-store/utils.test.ts index e2dd98931d..792899cb8f 100644 --- a/packages/app-store/utils.test.ts +++ b/packages/app-store/utils.test.ts @@ -21,6 +21,7 @@ describe("sanitizeAppForViewer", () => { delegatedToId: null, delegationCredentialId: null, team: null, + encryptedKey: null, }; const mockApp: App & { diff --git a/packages/app-store/utils.ts b/packages/app-store/utils.ts index 78a9b0a4f5..641bab3615 100644 --- a/packages/app-store/utils.ts +++ b/packages/app-store/utils.ts @@ -64,6 +64,7 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials? teamId: null, appId: appMeta.slug, invalid: false, + encryptedKey: null, delegatedTo: null, delegatedToId: null, delegationCredentialId: null, diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index d14543982b..731a3ca04a 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -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); diff --git a/packages/features/bookings/lib/EventManager.ts b/packages/features/bookings/lib/EventManager.ts index eebd9807b2..1633883406 100644 --- a/packages/features/bookings/lib/EventManager.ts +++ b/packages/features/bookings/lib/EventManager.ts @@ -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, diff --git a/packages/features/calendars/lib/getCalendarsEvents.ts b/packages/features/calendars/lib/getCalendarsEvents.ts index 5277a74d28..1853479627 100644 --- a/packages/features/calendars/lib/getCalendarsEvents.ts +++ b/packages/features/calendars/lib/getCalendarsEvents.ts @@ -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; }) ); diff --git a/packages/features/conferencing/lib/videoClient.ts b/packages/features/conferencing/lib/videoClient.ts index d2c6f9ade2..db9bff4a41 100644 --- a/packages/features/conferencing/lib/videoClient.ts +++ b/packages/features/conferencing/lib/videoClient.ts @@ -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, }, diff --git a/packages/features/credentials/repositories/CredentialRepository.ts b/packages/features/credentials/repositories/CredentialRepository.ts index 20b445f538..2ce797c373 100644 --- a/packages/features/credentials/repositories/CredentialRepository.ts +++ b/packages/features/credentials/repositories/CredentialRepository.ts @@ -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 }) }, }); } diff --git a/packages/features/credentials/services/CredentialDataService.ts b/packages/features/credentials/services/CredentialDataService.ts new file mode 100644 index 0000000000..e3d6745403 --- /dev/null +++ b/packages/features/credentials/services/CredentialDataService.ts @@ -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 | 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) }), + }; +} diff --git a/packages/lib/crypto/keyring.ts b/packages/lib/crypto/keyring.ts new file mode 100644 index 0000000000..b3f1064acb --- /dev/null +++ b/packages/lib/crypto/keyring.ts @@ -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 | Array; + +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)[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 }; +}; diff --git a/packages/prisma/migrations/20260122165837_add_encrypted_key_to_credential/migration.sql b/packages/prisma/migrations/20260122165837_add_encrypted_key_to_credential/migration.sql new file mode 100644 index 0000000000..ad0eed45f5 --- /dev/null +++ b/packages/prisma/migrations/20260122165837_add_encrypted_key_to_credential/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."Credential" ADD COLUMN "encryptedKey" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 3c270c75ce..62c5d6c583 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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? diff --git a/packages/prisma/selects/credential.ts b/packages/prisma/selects/credential.ts index 0490fde7d3..3cd0bb4f30 100644 --- a/packages/prisma/selects/credential.ts +++ b/packages/prisma/selects/credential.ts @@ -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: { diff --git a/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts b/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts index 92946131c8..2bf8a95e98 100644 --- a/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/calendarOverlay.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts index e861270626..64578cd095 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/getHostsWithLocationOptions.handler.ts @@ -127,6 +127,7 @@ export const getHostsWithLocationOptionsHandler = async ({ credentials: host.user.credentials.map((cred) => ({ ...cred, key: {} as Prisma.JsonValue, + encryptedKey: null, })), })); diff --git a/packages/types/Credential.d.ts b/packages/types/Credential.d.ts index a6c764fc9c..46cf0b1de3 100644 --- a/packages/types/Credential.d.ts +++ b/packages/types/Credential.d.ts @@ -55,7 +55,8 @@ export type Office365CredentialPayload = CredentialPayload & { } | null; }; -export type CredentialFrontendPayload = Omit & { +export type CredentialFrontendPayload = Omit & { /** We should type error if keys are leaked to the frontend */ key?: never; + encryptedKey?: never; };