Skip to content

Commit 36ec16b

Browse files
authored
feat: encrypt values in SecretStore using an encryption key (#240)
* feat: encrypt values in SecretStore using an encryption key and `aes-256-gcm` * Stopped using apiCors in the connectionId API endpoint
1 parent 92233f2 commit 36ec16b

File tree

9 files changed

+205
-31
lines changed

9 files changed

+205
-31
lines changed

.env.example

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# YOU MIGHT LIKE TO MODIFY THESE VARIABLES
22
SESSION_SECRET=abcdef1234
33
MAGIC_LINK_SECRET=abcdef1234
4+
ENCRYPTION_KEY=ae13021afef0819c3a307ad487071c06 # Must be a random 16 byte hex string. You can generate an encryption key by running `openssl rand -hex 16` in your terminal
45
LOGIN_ORIGIN=http://localhost:3030
56
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public
67
REMIX_APP_PORT=3030

CONTRIBUTING.md

+14-19
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,15 @@ branch are tagged into a release monthly.
4141
```
4242
cp .env.example .env && cp packages/database/.env.example packages/database/.env
4343
```
44-
5. Open the root `.env` file and fill in the required values Magic Link:
44+
5. Open the root `.env` file and generate a new value for `ENCRYPTION_KEY`:
4545

46-
Both of these secrets should be random strings, which you can easily generate (and copy into your pasteboard) with the following command:
46+
`ENCRYPTION_KEY` is used to two-way encrypt OAuth access tokens and so you'll probably want to actually generate a unique value, and it must be a random 16 byte hex string. You can generate one with the following command:
4747

4848
```sh
49-
openssl rand -hex 16 | pbcopy
49+
openssl rand -hex 16
5050
```
5151

52-
<p>Then set them here:</p>
53-
54-
```
55-
SESSION_SECRET=<string>
56-
MAGIC_LINK_SECRET=<string>
57-
```
52+
Feel free to update `SESSION_SECRET` and `MAGIC_LINK_SECRET` as well using the same method.
5853

5954
6. Start Docker. This starts the required services like Postgres. If this is your first time using Docker, consider going through this [guide](DOCKER_INSTALLATION.md)
6055
```
@@ -75,6 +70,7 @@ branch are tagged into a release monthly.
7570
10. Run the app. See the section below.
7671

7772
## Running
73+
7874
1. You can run the app with:
7975

8076
```
@@ -85,14 +81,12 @@ branch are tagged into a release monthly.
8581

8682
2. Once the app is running click the magic link button and enter your email.
8783
3. Check your terminal, the magic link email should have printed out as following:
88-
``
89-
webapp:dev: Log in to Trigger.dev
84+
`webapp:dev: Log in to Trigger.dev
9085
webapp:dev:
9186
webapp:dev: Click here to log in with this magic link
9287
webapp:dev: [http://localhost:3030/magic?token=U2FsdGVkX18OvB0JxgaswTLCSbaRz%2FY82TN0EZWhSzFyZYwgG%2BIzKVTkeiaOtWfotPw7F8RwFzCHh53aBpMEu%2B%2B%2FItb%2FcJYh89MSjc3Pz92bevoEjqxSQ%2Ff%2BZbks09JOpqlBbYC3FzGWC8vuSVFBlxqLXxteSDLthZSUaC%2BS2LaA%2BJgp%2BLO7hgjAaC2lXbCHrM7MTgTdXOFt7i0Dvvuwz6%2BWY25RnfomZOPqDsyH0xz8Q2rzPTz0Xu53WSXrZ1hd]
9388
webapp:dev:
94-
webapp:dev: If you didn't try to log in, you can safely ignore this email.
95-
``
89+
webapp:dev: If you didn't try to log in, you can safely ignore this email.`
9690
4. Paste the magic link shown in your terminal into your browser to login.
9791

9892
## Adding and running migrations
@@ -120,10 +114,11 @@ webapp:dev: If you didn't try to log in, you can safely ignore this email.
120114
6. If you're using VSCode you may need to restart the Typescript server in the webapp to get updated type inference. Open a TypeScript file, then open the Command Palette (View > Command Palette) and run `TypeScript: Restart TS server`.
121115

122116
## Testing CLI changes
117+
123118
To test CLI changes, follow the steps below:
124119

125120
1. Build the CLI and watch for changes
126-
121+
127122
```
128123
cd packages/cli
129124
pnpm run dev
@@ -148,7 +143,7 @@ To test CLI changes, follow the steps below:
148143
```
149144

150145
5. Open a new terminal window, navigate into the example, and initialize the CLI:
151-
146+
152147
```
153148
cd examples/your-newly-created-nextjs-project
154149
pnpm i
@@ -158,19 +153,21 @@ To test CLI changes, follow the steps below:
158153
6. When prompted, select `self-hosted` and enter `localhost:3030` for your local version of the webapp. When asked for an API key, use the key you copied earlier.
159154

160155
7. Run the CLI
156+
161157
```
162158
pnpm exec trigger-cli dev
163159
```
164160

165161
8. After running the CLI, start your newly created Next.js project. You should now be able to see the changes.
166162

167163
9. Please remember to delete the temporary project you created after you've tested the changes, and before you raise a PR.
164+
168165
## Add sample jobs
169166

170167
The [examples/jobs-starter](./examples/jobs-starter/) project defines simple jobs you can get started with.
171168

172169
1. `cd` into `examples/jobs-starter`
173-
2. Create a `.env.local` file with the following content,
170+
2. Create a `.env.local` file with the following content,
174171
replacing `[TRIGGER_DEV_API_KEY]` with an actual key:
175172

176173
```
@@ -224,9 +221,7 @@ Most of the time the changes you'll make are likely to be categorized as patch r
224221
### EADDRINUSE: address already in use :::3030
225222

226223
When receiving the following error message:
227-
``
228-
webapp:dev: Error: listen EADDRINUSE: address already in use :::3030
229-
``
224+
`webapp:dev: Error: listen EADDRINUSE: address already in use :::3030`
230225

231226
The process running on port `3030` should be destroyed.
232227

apps/webapp/app/env.server.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const EnvironmentSchema = z.object({
1010
DATABASE_URL: z.string(),
1111
SESSION_SECRET: z.string(),
1212
MAGIC_LINK_SECRET: z.string(),
13+
ENCRYPTION_KEY: z.string(),
1314
REMIX_APP_PORT: z.string().optional(),
1415
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
1516
APP_ORIGIN: z.string().default("http://localhost:3030"),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { LoaderArgs, json } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { prisma } from "~/db.server";
4+
import { resolveApiConnection } from "~/models/runConnection.server";
5+
import { authenticateApiRequest } from "~/services/apiAuth.server";
6+
import { apiCors } from "~/utils/apiCors";
7+
8+
const ParamsSchema = z.object({
9+
integrationSlug: z.string(),
10+
connectionId: z.string(),
11+
});
12+
13+
export async function loader({ request, params }: LoaderArgs) {
14+
const authenticationResult = await authenticateApiRequest(request);
15+
if (!authenticationResult) {
16+
return json({ error: "Invalid or Missing API key" }, { status: 401 });
17+
}
18+
19+
if (authenticationResult.type !== "PRIVATE") {
20+
return json(
21+
{ error: "Only private API keys can access this endpoint" },
22+
{ status: 403 }
23+
);
24+
}
25+
26+
const authenticatedEnv = authenticationResult.environment;
27+
28+
const parsedParams = ParamsSchema.safeParse(params);
29+
30+
if (!parsedParams.success) {
31+
return apiCors(
32+
request,
33+
json({ error: parsedParams.error.message }, { status: 400 })
34+
);
35+
}
36+
37+
const connection = await prisma.integrationConnection.findFirst({
38+
where: {
39+
id: parsedParams.data.connectionId,
40+
integration: {
41+
slug: parsedParams.data.integrationSlug,
42+
organization: authenticatedEnv.organization,
43+
},
44+
},
45+
include: {
46+
integration: {
47+
include: {
48+
authMethod: true,
49+
},
50+
},
51+
dataReference: true,
52+
},
53+
});
54+
55+
if (!connection) {
56+
return apiCors(
57+
request,
58+
json({ error: "Connection not found" }, { status: 404 })
59+
);
60+
}
61+
62+
const auth = await resolveApiConnection(connection);
63+
64+
return json({
65+
id: connection.id,
66+
type: connection.connectionType,
67+
externalAccountId: connection.externalAccountId,
68+
expiresAt: connection.expiresAt,
69+
auth,
70+
integration: {
71+
id: connection.integration.id,
72+
slug: connection.integration.slug,
73+
title: connection.integration.title,
74+
description: connection.integration.description,
75+
authSource: connection.integration.authSource,
76+
authMethod: connection.integration.authMethod
77+
? {
78+
id: connection.integration.authMethod.id,
79+
key: connection.integration.authMethod.key,
80+
name: connection.integration.authMethod.name,
81+
description: connection.integration.authMethod.description,
82+
type: connection.integration.authMethod.type,
83+
}
84+
: null,
85+
},
86+
createdAt: connection.createdAt,
87+
updatedAt: connection.updatedAt,
88+
});
89+
}

apps/webapp/app/services/externalApis/integrationAuthRepository.server.ts

-2
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@ export type ConnectionWithSecretReference = IntegrationConnection & {
4444
dataReference: SecretReference;
4545
};
4646

47-
const randomGenerator = customAlphabet("1234567890abcdef", 3);
48-
4947
/** How many seconds before expiry we should refresh the token */
5048
const tokenRefreshThreshold = 5 * 60;
5149

apps/webapp/app/services/externalApis/oauth2.server.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ function convertToken({
273273
const scopesPtr = jsonpointer.compile(scopePointer);
274274
const scopesValue = scopesPtr.get(token.token);
275275
if (typeof scopesValue === "string") {
276-
actualScopes = (scopesValue as string).split(scopeSeparator);
276+
actualScopes = scopesValue.split(scopeSeparator);
277277
}
278278

279279
const refreshTokenPtr = jsonpointer.compile(refreshTokenPointer);

apps/webapp/app/services/secrets/secretStore.server.ts

+94-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { PrismaClientOrTransaction, prisma } from "~/db.server";
22
import { z } from "zod";
3+
import { env } from "~/env.server";
4+
import nodeCrypto from "node:crypto";
5+
import { safeJsonParse } from "~/utils/json";
36

47
export const SecretStoreOptionsSchema = z.enum(["DATABASE", "AWS_PARAM_STORE"]);
58
export type SecretStoreOptions = z.infer<typeof SecretStoreOptionsSchema>;
@@ -41,11 +44,20 @@ export class SecretStore {
4144
}
4245
}
4346

44-
/** This stores secrets in the Postgres Database, in plain text. NOT recommended outside of localhost. */
47+
const EncryptedSecretValueSchema = z.object({
48+
nonce: z.string(),
49+
ciphertext: z.string(),
50+
tag: z.string(),
51+
});
52+
53+
/** This stores secrets in the Postgres Database, encrypted using aes-256-gcm */
4554
class PrismaSecretStore implements SecretStoreProvider {
4655
#prismaClient: PrismaClientOrTransaction;
4756

48-
constructor(private options?: ProviderInitializationOptions["DATABASE"]) {
57+
constructor(
58+
private readonly encryptionKey: string,
59+
private options?: ProviderInitializationOptions["DATABASE"]
60+
) {
4961
this.#prismaClient = options?.prismaClient ?? prisma;
5062
}
5163

@@ -60,23 +72,94 @@ class PrismaSecretStore implements SecretStoreProvider {
6072
return undefined;
6173
}
6274

63-
return schema.parse(secret.value);
75+
if (secret.version === "1") {
76+
return schema.parse(secret.value);
77+
}
78+
79+
const encryptedData = EncryptedSecretValueSchema.safeParse(secret.value);
80+
81+
if (!encryptedData.success) {
82+
throw new Error(
83+
`Unable to parse encrypted secret ${key}: ${encryptedData.error.message}`
84+
);
85+
}
86+
87+
const decrypted = await this.#decrypt(
88+
encryptedData.data.nonce,
89+
encryptedData.data.ciphertext,
90+
encryptedData.data.tag
91+
);
92+
93+
const parsedDecrypted = safeJsonParse(decrypted);
94+
95+
if (!parsedDecrypted) {
96+
return;
97+
}
98+
99+
return schema.parse(parsedDecrypted);
64100
}
65101

66102
async setSecret<T extends object>(key: string, value: T): Promise<void> {
103+
const encrypted = await this.#encrypt(JSON.stringify(value));
104+
67105
await this.#prismaClient.secretStore.upsert({
68106
create: {
69107
key,
70-
value,
108+
value: encrypted,
109+
version: "2",
71110
},
72111
update: {
73-
value,
112+
value: encrypted,
113+
version: "2",
74114
},
75115
where: {
76116
key,
77117
},
78118
});
79119
}
120+
121+
async #decrypt(
122+
nonce: string,
123+
ciphertext: string,
124+
tag: string
125+
): Promise<string> {
126+
const decipher = nodeCrypto.createDecipheriv(
127+
"aes-256-gcm",
128+
this.encryptionKey,
129+
Buffer.from(nonce, "hex")
130+
);
131+
132+
decipher.setAuthTag(Buffer.from(tag, "hex"));
133+
134+
let decrypted = decipher.update(ciphertext, "hex", "utf8");
135+
decrypted += decipher.final("utf8");
136+
137+
return decrypted;
138+
}
139+
140+
async #encrypt(value: string): Promise<{
141+
nonce: string;
142+
ciphertext: string;
143+
tag: string;
144+
}> {
145+
const nonce = nodeCrypto.randomBytes(12);
146+
const cipher = nodeCrypto.createCipheriv(
147+
"aes-256-gcm",
148+
this.encryptionKey,
149+
nonce
150+
);
151+
152+
let encrypted = cipher.update(value, "utf8", "hex");
153+
encrypted += cipher.final("hex");
154+
155+
const tag = cipher.getAuthTag().toString("hex");
156+
157+
return {
158+
nonce: nonce.toString("hex"),
159+
ciphertext: encrypted,
160+
tag,
161+
};
162+
}
80163
}
81164

82165
export function getSecretStore<
@@ -85,7 +168,12 @@ export function getSecretStore<
85168
>(provider: K, options?: TOptions): SecretStore {
86169
switch (provider) {
87170
case "DATABASE": {
88-
return new SecretStore(new PrismaSecretStore(options as any));
171+
return new SecretStore(
172+
new PrismaSecretStore(
173+
env.ENCRYPTION_KEY,
174+
options as ProviderInitializationOptions["DATABASE"]
175+
)
176+
);
89177
}
90178
default: {
91179
throw new Error(`Unsupported secret store option ${provider}`);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "SecretStore" ADD COLUMN "version" TEXT NOT NULL DEFAULT '1';

packages/database/prisma/schema.prisma

+3-3
Original file line numberDiff line numberDiff line change
@@ -850,10 +850,10 @@ enum SecretStoreProvider {
850850
AWS_PARAM_STORE
851851
}
852852

853-
/// Used when the provider = "database". Not recommended outside of local development.
854853
model SecretStore {
855-
key String @unique
856-
value Json
854+
key String @unique
855+
value Json
856+
version String @default("1")
857857
858858
createdAt DateTime @default(now())
859859
updatedAt DateTime @updatedAt

0 commit comments

Comments
 (0)