From 07b1fc8e5772345bbfd924d389b9006ff068540a Mon Sep 17 00:00:00 2001 From: Harsh Shrikant Bhat <90265455+harshsbhat@users.noreply.github.com> Date: Mon, 2 Sep 2024 12:52:22 +0530 Subject: [PATCH 1/4] Encrypted meta for API --- apps/api/src/routes/v1_keys_createKey.ts | 54 + apps/api/src/routes/v1_keys_verifyKey.ts | 50 +- .../[apiId]/keys/[keyAuthId]/new/client.tsx | 112 ++ .../[keyId]/permissions/permissions.ts | 16 + apps/dashboard/lib/trpc/routers/key/create.ts | 9 +- internal/db/src/schema/keys.ts | 1 + packages/api/src/openapi.d.ts | 1105 ++++++++--------- packages/rbac/src/permissions.ts | 2 + 8 files changed, 776 insertions(+), 573 deletions(-) diff --git a/apps/api/src/routes/v1_keys_createKey.ts b/apps/api/src/routes/v1_keys_createKey.ts index e1164e5cb..d8edd649b 100644 --- a/apps/api/src/routes/v1_keys_createKey.ts +++ b/apps/api/src/routes/v1_keys_createKey.ts @@ -74,6 +74,17 @@ When validating a key, we will return this back to you, so you can clearly ident trialEnds: "2023-06-16T17:16:37.161Z", }, }), + encryptedMeta: z + .record(z.unknown()) + .optional() + .openapi({ + description: + "This is a place for dynamic meta data, anything that feels useful for you should go here", + example: { + billingTier: "PRO", + trialEnds: "2023-06-16T17:16:37.161Z", + }, + }), roles: z .array(z.string().min(1).max(512)) .optional() @@ -336,6 +347,48 @@ export const registerV1KeysCreateKey = (app: App) => byteLength: req.byteLength ?? 16, prefix: req.prefix, }).toString(); + let metaSecret = req.encryptedMeta ? JSON.stringify(req.encryptedMeta) : null; + + if (metaSecret) { + const perm = rbac.evaluatePermissions( + buildUnkeyQuery(({ or }) => or("*", "api.*.encrypt_meta", `api.${api.id}.encrypt_meta`)), + auth.permissions, + ); + + if (perm.err) { + throw new UnkeyApiError({ + code: "INTERNAL_SERVER_ERROR", + message: `unable to evaluate permissions: ${perm.err.message}`, + }); + } + + if (!perm.val.valid) { + throw new UnkeyApiError({ + code: "INSUFFICIENT_PERMISSIONS", + message: `insufficient permissions to encrypt metadata: ${perm.val.message}`, + }); + } + + if (metaSecret) { + const encryptedMeta = await retry( + 3, + async () => { + return await vault.encrypt(c, { + keyring: authorizedWorkspaceId, + data: metaSecret as string, + }); + }, + (attempt, err) => + logger.warn("vault.encrypt failed", { + attempt, + err: err.message, + }), + ); + + metaSecret = encryptedMeta.encrypted; + } + } + const start = secret.slice(0, (req.prefix?.length ?? 0) + 5); const kId = newId("key"); const hash = await sha256(secret.toString()); @@ -347,6 +400,7 @@ export const registerV1KeysCreateKey = (app: App) => start, ownerId: externalId, meta: req.meta ? JSON.stringify(req.meta) : null, + encryptedMeta: metaSecret ? metaSecret : null, workspaceId: authorizedWorkspaceId, forWorkspaceId: null, expires: req.expires ? new Date(req.expires) : null, diff --git a/apps/api/src/routes/v1_keys_verifyKey.ts b/apps/api/src/routes/v1_keys_verifyKey.ts index 5cd232b3d..61b48b243 100644 --- a/apps/api/src/routes/v1_keys_verifyKey.ts +++ b/apps/api/src/routes/v1_keys_verifyKey.ts @@ -1,9 +1,11 @@ import { UnkeyApiError, openApiErrorResponses } from "@/pkg/errors"; import type { App } from "@/pkg/hono/app"; import { DisabledWorkspaceError, MissingRatelimitError } from "@/pkg/keys/service"; +import { rootKeyAuth } from "@/pkg/auth/root_key"; +import { retry } from "@/pkg/util/retry"; import { createRoute, z } from "@hono/zod-openapi"; import { SchemaError } from "@unkey/error"; -import { permissionQuerySchema } from "@unkey/rbac"; +import { permissionQuerySchema, buildUnkeyQuery } from "@unkey/rbac"; const route = createRoute({ tags: ["keys"], @@ -21,7 +23,8 @@ const route = createRoute({ .string() .optional() // .min(1) TODO enable after we stopped sending traffic from the agent - .openapi({ + .openapi( + { description: `The id of the api where the key belongs to. This is optional for now but will be required soon. The key will be verified against the api's configuration. If the key does not belong to the api, the verification will fail.`, example: "api_1234", @@ -176,6 +179,16 @@ A key could be invalid for a number of reasons, for example if it has expired, h stripeCustomerId: "cus_1234", }, }), + encryptedMeta: z + .record(z.unknown()) + .optional() + .openapi({ + description: "Any additional metadata you want to store with the key", + example: { + roles: ["admin", "user"], + stripeCustomerId: "cus_1234", + }, + }), expires: z.number().int().optional().openapi({ description: "The unix timestamp in milliseconds when the key will expire. If this field is null or undefined, the key is not expiring.", @@ -286,8 +299,7 @@ export type V1KeysVerifyKeyResponse = z.infer< export const registerV1KeysVerifyKey = (app: App) => app.openapi(route, async (c) => { const req = c.req.valid("json"); - const { keyService, analytics } = c.get("services"); - + const { keyService, analytics, vault } = c.get("services"); const { val, err } = await keyService.verifyKey(c, { key: req.key, apiId: req.apiId, @@ -326,6 +338,35 @@ export const registerV1KeysVerifyKey = (app: App) => code: val.code, }); } + let metaSecret: string = ''; + + console.log("Encrypted Meta:", val.key.encryptedMeta); + + if (val.key.encryptedMeta) { + try { + await rootKeyAuth( + c, + buildUnkeyQuery(({ or }) => or("*", "api.*.decrypt_meta", `api.${val.api.id}.decrypt_meta`)) + ); + + // Ensure that `val.key.encryptedMeta` is a string + const encryptedMeta = typeof val.key.encryptedMeta === 'string' ? val.key.encryptedMeta : JSON.stringify(val.key.encryptedMeta); + + const decryptRes = await retry(3, () => + vault.decrypt(c, { + keyring: val.key.workspaceId, + encrypted: encryptedMeta, + }) + ); + + metaSecret = decryptRes.plaintext; + + } catch (error) { + metaSecret = '{}'; + } + } + + const responseBody = { keyId: val.key?.id, @@ -333,6 +374,7 @@ export const registerV1KeysVerifyKey = (app: App) => name: val.key?.name ?? undefined, ownerId: val.key?.ownerId ?? undefined, meta: val.key?.meta ? JSON.parse(val.key?.meta) : undefined, + encryptedMeta: JSON.parse(metaSecret), expires: val.key?.expires?.getTime(), remaining: val.remaining ?? undefined, ratelimit: val.ratelimit ?? undefined, diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx index 6e4af59ef..ec668fb2f 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx @@ -78,6 +78,23 @@ const formSchema = z.object({ }, ) .optional(), + encryptMetaEnabled: z.boolean().default(false), + encryptMeta: z + .string() + .refine( + (s) => { + try { + JSON.parse(s); + return true; + } catch { + return false; + } + }, + { + message: "Must be valid json", + }, + ) + .optional(), limitEnabled: z.boolean().default(false), limit: z .object({ @@ -163,6 +180,7 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId }) => { expireEnabled: false, limitEnabled: false, metaEnabled: false, + encryptMetaEnabled: false, ratelimitEnabled: false, }, }); @@ -189,6 +207,9 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId }) => { if (!values.metaEnabled) { delete values.meta; } + if (!values.encryptMetaEnabled) { + delete values.encryptMeta; + } if (!values.limitEnabled) { delete values.limit; } @@ -200,6 +221,7 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId }) => { keyAuthId, ...values, meta: values.meta ? JSON.parse(values.meta) : undefined, + encryptedMeta: values.encryptMeta ? JSON.parse(values.encryptMeta) : undefined, expires: values.expires?.getTime() ?? undefined, ownerId: values.ownerId ?? undefined, remaining: values.limit?.remaining ?? undefined, @@ -292,6 +314,7 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId }) => { form.setValue("expireEnabled", false); form.setValue("ratelimitEnabled", false); form.setValue("metaEnabled", false); + form.setValue("encryptMetaEnabled", false); form.setValue("limitEnabled", false); router.refresh(); }} @@ -806,7 +829,96 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId }) => { ) : null} + + +
+ Encrypted Metadata + + ( + + Metadata + + { + field.onChange(e); + if (field.value === false) { + resetLimited(); + } + }} + /> + + + )} + /> +
+ {form.watch("encryptMetaEnabled") ? ( + <> +

+ Encrypt sensitive data before associating it with this key to store it securely. + Whenever you verify this key with the decrypt metadata permissions, the encrypted metadata will be returned to you securely, + ensuring confidentiality. Enter custom encrypted metadata as a JSON object. +

+ +
+ ( + + +