Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Experimental] - Prototype for Encrypted Settings #4208

Closed
wants to merge 11 commits into from
2 changes: 1 addition & 1 deletion .github/workflows/cypress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
APP_ENV: test
NEXT_PUBLIC_APP_ENV: test
REDIS_URL: localhost
DATABASE_URL: postgres://postgres:postgres@localhost:5432/formsDB
DATABASE_URL: postgres://postgres:postgres@localhost:5432/forms
steps:
- name: Checkout
uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1.2.0
Expand Down
16 changes: 7 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,20 +100,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [3.21.2](https://github.com/cds-snc/platform-forms-client/compare/v3.21.1...v3.21.2) (2024-09-18)


### Bug Fixes

* Remove Service Account from Zitadel when API key is deleted. ([#3908](https://github.com/cds-snc/platform-forms-client/issues/3908)) ([13e6671](https://github.com/cds-snc/platform-forms-client/commit/13e6671c058f2ad01b6892cfb778764d9613bd0b))

- Remove Service Account from Zitadel when API key is deleted. ([#3908](https://github.com/cds-snc/platform-forms-client/issues/3908)) ([13e6671](https://github.com/cds-snc/platform-forms-client/commit/13e6671c058f2ad01b6892cfb778764d9613bd0b))

### Miscellaneous Chores

* Add Canada Energy Regulator branding ([#4286](https://github.com/cds-snc/platform-forms-client/issues/4286)) ([701da69](https://github.com/cds-snc/platform-forms-client/commit/701da69e51416350058c76297a6897c64d318651))
* Add dynamic row dialog ([#4261](https://github.com/cds-snc/platform-forms-client/issues/4261)) ([bdb9821](https://github.com/cds-snc/platform-forms-client/commit/bdb9821af974002e028e46e1de8ed832c3e9c81e))
* Add Invitation model and migration ([#4269](https://github.com/cds-snc/platform-forms-client/issues/4269)) ([3c4bf72](https://github.com/cds-snc/platform-forms-client/commit/3c4bf729676e80c75bf8b45f3800af329b91ab48))
* call to check for overdue ids ([#4250](https://github.com/cds-snc/platform-forms-client/issues/4250)) ([8cb3609](https://github.com/cds-snc/platform-forms-client/commit/8cb360943926056264753aec6b464a9343c323da))
* Style repeating sets ([#4248](https://github.com/cds-snc/platform-forms-client/issues/4248)) ([6dddcd3](https://github.com/cds-snc/platform-forms-client/commit/6dddcd3061e42d2b7439325842a2eceb72c58edb))
* Translate dynamic row props ([#4266](https://github.com/cds-snc/platform-forms-client/issues/4266)) ([3070872](https://github.com/cds-snc/platform-forms-client/commit/307087250f2c3dbc05f6a735fcdca78f0280582a))
- Add Canada Energy Regulator branding ([#4286](https://github.com/cds-snc/platform-forms-client/issues/4286)) ([701da69](https://github.com/cds-snc/platform-forms-client/commit/701da69e51416350058c76297a6897c64d318651))
- Add dynamic row dialog ([#4261](https://github.com/cds-snc/platform-forms-client/issues/4261)) ([bdb9821](https://github.com/cds-snc/platform-forms-client/commit/bdb9821af974002e028e46e1de8ed832c3e9c81e))
- Add Invitation model and migration ([#4269](https://github.com/cds-snc/platform-forms-client/issues/4269)) ([3c4bf72](https://github.com/cds-snc/platform-forms-client/commit/3c4bf729676e80c75bf8b45f3800af329b91ab48))
- call to check for overdue ids ([#4250](https://github.com/cds-snc/platform-forms-client/issues/4250)) ([8cb3609](https://github.com/cds-snc/platform-forms-client/commit/8cb360943926056264753aec6b464a9343c323da))
- Style repeating sets ([#4248](https://github.com/cds-snc/platform-forms-client/issues/4248)) ([6dddcd3](https://github.com/cds-snc/platform-forms-client/commit/6dddcd3061e42d2b7439325842a2eceb72c58edb))
- Translate dynamic row props ([#4266](https://github.com/cds-snc/platform-forms-client/issues/4266)) ([3070872](https://github.com/cds-snc/platform-forms-client/commit/307087250f2c3dbc05f6a735fcdca78f0280582a))

## [3.21.1](https://github.com/cds-snc/platform-forms-client/compare/v3.21.0...v3.21.1) (2024-09-04)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
updateAppSetting,
} from "@lib/appSettings";
import { revalidatePath } from "next/cache";
import { logMessage } from "@lib/logger";
import { authCheckAndThrow } from "@lib/actions";
import { redirect } from "next/navigation";

Expand All @@ -19,8 +18,13 @@ function nullCheck(formData: FormData, key: string) {

export async function getSetting(internalId: string) {
const { ability } = await authCheckAndThrow();
logMessage.error("Getting setting with internalId: " + internalId);
return getFullAppSetting(ability, internalId);
const setting = await getFullAppSetting(ability, internalId);

if (setting?.encrypted) {
// Do not expose sensitive value to client
setting.value = null;
}
return setting;
}

export async function updateSetting(language: string, formData: FormData) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DeleteSettingsButton } from "../client/DeleteSettingsButton";
import { authCheckAndRedirect } from "@lib/actions";
import { getAllAppSettings } from "@lib/appSettings";
import { serverTranslation } from "@i18n";
import { LockIcon } from "@serverComponents/icons";

export const Settings = async () => {
const {
Expand All @@ -29,7 +30,10 @@ export const Settings = async () => {
className="border-2 hover:border-blue-hover rounded-md p-2 m-2 flex flex-row"
>
<div className="grow basis-2/3 m-auto">
<p>{language === "fr" ? setting.nameFr : setting.nameEn}</p>
<p>
{language === "fr" ? setting.nameFr : setting.nameEn}
{setting.encrypted && <LockIcon className="float-right mr-4" />}
</p>
<p className="italic">
{language === "fr" ? setting.descriptionFr : setting.descriptionEn}
</p>
Expand Down
79 changes: 78 additions & 1 deletion lib/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ import { logEvent } from "./auditLogs";
import { logMessage } from "@lib/logger";
import { UserAbility } from "./types";
import { settingCheck, settingPut, settingDelete } from "@lib/cache/settingCache";
import crypto from "node:crypto";
import { secretClient } from "./integration/awsServicesConnector";
import { GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import { EventEmitter } from "events";

export const settingChangeNotifier = new EventEmitter();

export class SettingNotConfiguredError extends Error {
constructor(internalIds: string[]) {
super(
`Setting${internalIds.length > 1 ? "s" : ""} with internalId${
internalIds.length > 1 ? "s" : ""
}: ${internalIds.join(" ")} ${internalIds.length > 1 ? "have" : "has"} not been configured`
);
this.name = "SettingNotConfiguredError";
}
}

const getEncryptionSecret = async () => {
const token_secret = await secretClient.send(
new GetSecretValueCommand({
SecretId: "token_secret",
})
);
return token_secret.SecretString;
};

export const getAllAppSettings = async (ability: UserAbility) => {
try {
Expand All @@ -17,6 +43,7 @@ export const getAllAppSettings = async (ability: UserAbility) => {
descriptionEn: true,
descriptionFr: true,
value: true,
encrypted: true,
},
})
.catch((e) => prismaErrors(e, []));
Expand All @@ -34,8 +61,8 @@ export const getAllAppSettings = async (ability: UserAbility) => {

export const getAppSetting = async (internalId: string) => {
const cachedSetting = await settingCheck(internalId);
logMessage.info(`Setting is not cached for ${internalId}`);
if (cachedSetting) return cachedSetting;

const uncachedSetting = await prisma.setting
.findUnique({
where: {
Expand All @@ -50,9 +77,17 @@ export const getAppSetting = async (internalId: string) => {
if (uncachedSetting?.value) {
settingPut(internalId, uncachedSetting.value);
}

return uncachedSetting?.value ?? null;
};

export const getEncryptedAppSetting = async (internalId: string) => {
const encryptedSetting = await getAppSetting(internalId);
if (!encryptedSetting) return null;

return decryptSetting(encryptedSetting);
};

export const getFullAppSetting = async (ability: UserAbility, internalId: string) => {
checkPrivileges(ability, [{ action: "view", subject: "Setting" }]);
// Note: the setting is not cached here because it's not expected to be called frequently
Expand All @@ -68,6 +103,7 @@ export const getFullAppSetting = async (ability: UserAbility, internalId: string
descriptionEn: true,
descriptionFr: true,
value: true,
encrypted: true,
},
})
.catch((e) => {
Expand Down Expand Up @@ -102,6 +138,21 @@ export const updateAppSetting = async (
try {
checkPrivileges(ability, [{ action: "update", subject: "Setting" }]);

// Is the setting protected by encryption?

const { encrypted } = await prisma.setting.findUniqueOrThrow({
where: {
internalId,
},
select: {
encrypted: true,
},
});

if (encrypted && settingData.value) {
settingData.value = await encryptSetting(settingData.value);
}

const updatedSetting = await prisma.setting.update({
where: {
internalId,
Expand All @@ -115,8 +166,10 @@ export const updateAppSetting = async (
`Updated setting with ${JSON.stringify(settingData)}`
);
if (settingData.value) {
// Add the setting to the Settings cache
settingPut(internalId, settingData.value);
}
settingChangeNotifier.emit(internalId);
return updatedSetting;
} catch (e) {
if (e instanceof AccessControlError) {
Expand Down Expand Up @@ -206,3 +259,27 @@ export const deleteAppSetting = async (ability: UserAbility, internalId: string)
}
}
};

const encryptSetting = async (value: string) => {
const secret = await getEncryptionSecret();
if (!secret) throw new Error("No Secret Available to Encrypt Settings");

const iv = crypto.randomBytes(16);
const key = crypto.createHash("sha256").update(secret).digest("base64").substring(0, 32);

const cipher = crypto.createCipheriv("aes-256-ctr", key, iv);
const result = Buffer.concat([iv, cipher.update(value), cipher.final()]);

return result.toString("base64");
};

export const decryptSetting = async (value: string) => {
const secret = await getEncryptionSecret();
if (!secret) throw new Error("No Secret Available to Decrypt Settings");

const encryptedValue = Buffer.from(value, "base64");
const key = crypto.createHash("sha256").update(secret).digest("base64").substring(0, 32);
const decipher = crypto.createDecipheriv("aes-256-ctr", key, encryptedValue.subarray(0, 16));
const decrypted = Buffer.concat([decipher.update(encryptedValue.subarray(16)), decipher.final()]);
return decrypted.toString();
};
1 change: 1 addition & 0 deletions lib/auth/cognito.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const initiateSignIn = async ({
}

logMessage.info("HealthCheck: cognito sign-in failure");
logMessage.debug(e);

throw e;
}
Expand Down
6 changes: 6 additions & 0 deletions lib/integration/awsServicesConnector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { LambdaClient } from "@aws-sdk/client-lambda";
import { SQSClient } from "@aws-sdk/client-sqs";
import { S3Client } from "@aws-sdk/client-s3";
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";

const globalConfig = {
region: process.env.AWS_REGION ?? "ca-central-1",
Expand Down Expand Up @@ -48,3 +49,8 @@ export const s3Client = new S3Client({
...localstackConfig,
...(process.env.LOCAL_AWS_ENDPOINT && { forcePathStyle: true }),
});

export const secretClient = new SecretsManagerClient({
...globalConfig,
...localstackConfig,
});
7 changes: 3 additions & 4 deletions lib/integration/prismaConnector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,9 @@ const prismaClientSingleton = () => {
});
};

declare global {
// eslint-disable-next-line no-var
var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>;
}
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;

export const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();

Expand Down
44 changes: 38 additions & 6 deletions lib/integration/zitadelConnector.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,50 @@
import { createManagementClient, ManagementServiceClient } from "@zitadel/node/api";
import { ServiceAccount } from "@zitadel/node/credentials";
import {
settingChangeNotifier,
getEncryptedAppSetting,
getAppSetting,
SettingNotConfiguredError,
} from "@lib/appSettings";
import { logMessage } from "@lib/logger";

import { AuthenticationOptions } from "@zitadel/node/dist/commonjs/credentials/service-account";
import type { CallOptions, ClientMiddleware, ClientMiddlewareCall } from "nice-grpc";
import { Metadata } from "nice-grpc-common";

let zitadelClient: ManagementServiceClient;
let initializtionPromise: Promise<void> | null = null;

const recreateZitadelClient = async () => {
logMessage.info("Recreating Zitadel client");
await createZitadelClient();
};

settingChangeNotifier.on("zitadelAdministrationKey", async () => {
await recreateZitadelClient();
});

settingChangeNotifier.on("zitadelProvider", async () => {
await recreateZitadelClient();
});

const getZitadelSettings = async () => {
if (!process.env.ZITADEL_PROVIDER) throw new Error("No value set for Zitadel Provider");
const getZitadelAdministrationKey = getEncryptedAppSetting("zitadelAdministrationKey");
const getZitadelProvider = getAppSetting("zitadelProvider");

const [zitadelAdministrationKey, zitadelProvider] = await Promise.all([
getZitadelAdministrationKey,
getZitadelProvider,
]);

if (!process.env.ZITADEL_ADMINISTRATION_KEY)
throw new Error("Zitadel Adminstration Key is not set");
if (!zitadelAdministrationKey || !zitadelProvider) {
logMessage.warn("Zitadel Settings are not properly configured");
throw new SettingNotConfiguredError(["zitadelAdministrationKey", "zitadelProvider"]);
}

return {
zitadelAdministrationKey: process.env.ZITADEL_ADMINISTRATION_KEY,
zitadelProvider: process.env.ZITADEL_PROVIDER,
zitadelAdministrationKey,
zitadelProvider,
};
};

Expand Down Expand Up @@ -58,6 +87,9 @@ export const getZitadelClient = async () => {
if (!initializtionPromise) {
initializtionPromise = createZitadelClient();
}
await initializtionPromise;
await initializtionPromise.catch((e) => {
initializtionPromise = null;
throw e;
});
return zitadelClient;
};
15 changes: 8 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,13 @@
},
"dependencies": {
"@auth/prisma-adapter": "^1.0.14",
"@aws-sdk/client-cognito-identity-provider": "3.515.0",
"@aws-sdk/client-dynamodb": "3.515.0",
"@aws-sdk/client-lambda": "3.515.0",
"@aws-sdk/client-s3": "3.515.0",
"@aws-sdk/client-sqs": "3.515.0",
"@aws-sdk/lib-dynamodb": "3.515.0",
"@aws-sdk/client-cognito-identity-provider": "3.637.0",
"@aws-sdk/client-dynamodb": "3.637.0",
"@aws-sdk/client-lambda": "3.637.0",
"@aws-sdk/client-s3": "3.637.0",
"@aws-sdk/client-secrets-manager": "3.637.0",
"@aws-sdk/client-sqs": "3.637.0",
"@aws-sdk/lib-dynamodb": "3.637.0",
"@casl/ability": "6.5.0",
"@cdssnc/gcds-tokens": "^1.20.0",
"@headlessui/react": "^1.7.18",
Expand Down Expand Up @@ -118,7 +119,7 @@
"zustand": "4.5.4"
},
"devDependencies": {
"@aws-sdk/types": "^3.515.0",
"@aws-sdk/types": "^3.609.0",
"@babel/core": "^7.23.6",
"@babel/preset-typescript": "^7.24.7",
"@jest/globals": "^29.7.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Setting" ADD COLUMN "encrypted" BOOLEAN NOT NULL DEFAULT false;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ model Setting {
descriptionEn String?
descriptionFr String?
value String?
encrypted Boolean @default(false)
}

model CognitoCustom2FA {
Expand Down
Loading
Loading