diff --git a/apps/api/src/pkg/cache/index.ts b/apps/api/src/pkg/cache/index.ts index cc000ba56..515c06ee7 100644 --- a/apps/api/src/pkg/cache/index.ts +++ b/apps/api/src/pkg/cache/index.ts @@ -71,6 +71,7 @@ export function initCache(c: Context, metrics: Metrics): C(c.executionCtx, defaultOpts), }); } diff --git a/apps/api/src/pkg/cache/namespaces.ts b/apps/api/src/pkg/cache/namespaces.ts index dd750aa25..b5fe0713b 100644 --- a/apps/api/src/pkg/cache/namespaces.ts +++ b/apps/api/src/pkg/cache/namespaces.ts @@ -64,6 +64,7 @@ export type CacheNamespaces = { total: number; }; identityByExternalId: Identity | null; + encryptedMeta: string | null; }; export type CacheNamespace = keyof CacheNamespaces; diff --git a/apps/api/src/routes/v1_keys_createKey.ts b/apps/api/src/routes/v1_keys_createKey.ts index 006feb7ca..5acaee614 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 encrypted 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,56 @@ 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; + await db.primary.insert(schema.secrets).values({ + id: newId("secret"), + workspaceId: authorizedWorkspaceId, + encrypted: encryptedMeta.encrypted, + // Using newId here because the id returned by the vault i.e. encryptionKeyId is a data agnostic id. + // So its going to give errors if we the encrypted Metadata is not unique. + encryptionKeyId: newId("enryptedMeta"), + }); + } + } + const start = secret.slice(0, (req.prefix?.length ?? 0) + 5); const kId = newId("key"); const hash = await sha256(secret.toString()); @@ -347,6 +408,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.test.ts b/apps/api/src/routes/v1_keys_verifyKey.test.ts index d97e9d195..74982206a 100644 --- a/apps/api/src/routes/v1_keys_verifyKey.test.ts +++ b/apps/api/src/routes/v1_keys_verifyKey.test.ts @@ -132,6 +132,7 @@ describe("with temporary key", () => { expires: now, environment: "prod", meta: JSON.stringify({ hello: "world" }), + encryptedMeta: JSON.stringify({ encrypted: "data" }), }; await h.db.primary.insert(schema.keys).values(key); @@ -163,6 +164,7 @@ describe("with temporary key", () => { expect(res.body.valid).toBe(false); expect(res.body.code).toBe("EXPIRED"); expect(res.body.meta).toMatchObject({ hello: "world" }); + expect(res.body.encryptedMeta).toMatchObject({ encrypted: "data" }); expect(res.body.expires).toBe(key.expires.getTime()); expect(res.body.environment).toBe(key.environment); expect(res.body.name).toBe(key.name); @@ -189,6 +191,9 @@ describe("with metadata", () => { meta: JSON.stringify({ disabledReason: "cause I can", }), + encryptedMeta: JSON.stringify({ + disabledReason: "cause I can", + }), enabled: false, }); @@ -205,6 +210,7 @@ describe("with metadata", () => { expect(res.status, `expected 200, received: ${JSON.stringify(res, null, 2)}`).toBe(200); expect(res.body.valid).toBe(false); expect(res.body.meta).toMatchObject({ disabledReason: "cause I can" }); + expect(res.body.encryptedMeta).toMatchObject({ disabledReason: "cause I can" }); }, { timeout: 20000 }, ); @@ -266,6 +272,9 @@ describe("with identity", () => { meta: { hello: "world", }, + encryptedMeta: { + encrypted: "data", + }, }; await h.db.primary.insert(schema.identities).values(identity); diff --git a/apps/api/src/routes/v1_keys_verifyKey.ts b/apps/api/src/routes/v1_keys_verifyKey.ts index 5cd232b3d..a438555f6 100644 --- a/apps/api/src/routes/v1_keys_verifyKey.ts +++ b/apps/api/src/routes/v1_keys_verifyKey.ts @@ -1,9 +1,11 @@ +import { rootKeyAuth } from "@/pkg/auth/root_key"; import { UnkeyApiError, openApiErrorResponses } from "@/pkg/errors"; import type { App } from "@/pkg/hono/app"; import { DisabledWorkspaceError, MissingRatelimitError } from "@/pkg/keys/service"; +import { retry } from "@/pkg/util/retry"; import { createRoute, z } from "@hono/zod-openapi"; import { SchemaError } from "@unkey/error"; -import { permissionQuerySchema } from "@unkey/rbac"; +import { buildUnkeyQuery, permissionQuerySchema } from "@unkey/rbac"; const route = createRoute({ tags: ["keys"], @@ -176,6 +178,17 @@ 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: + "Encrypted metadata that might contain secrets that 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, cache } = c.get("services"); const { val, err } = await keyService.verifyKey(c, { key: req.key, apiId: req.apiId, @@ -327,12 +339,45 @@ export const registerV1KeysVerifyKey = (app: App) => }); } + let metaSecret: string | undefined | null; + if (val.key.encryptedMeta) { + try { + await rootKeyAuth( + c, + buildUnkeyQuery(({ or }) => + or("*", "api.*.decrypt_meta", `api.${val.api.id}.decrypt_meta`), + ), + ); + const { val: vaultRes } = await cache.encryptedMeta.swr( + val.key.encryptedMeta!, + async () => { + 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, + }), + ); + return decryptRes.plaintext; + }, + ); + metaSecret = vaultRes; + } catch { + metaSecret = undefined; + } + } + const responseBody = { keyId: val.key?.id, valid: val.valid, name: val.key?.name ?? undefined, ownerId: val.key?.ownerId ?? undefined, meta: val.key?.meta ? JSON.parse(val.key?.meta) : undefined, + encryptedMeta: metaSecret ? JSON.parse(metaSecret) : undefined, expires: val.key?.expires?.getTime(), remaining: val.remaining ?? undefined, ratelimit: val.ratelimit ?? undefined, diff --git a/apps/api/src/routes/v1_migrations_createKey.ts b/apps/api/src/routes/v1_migrations_createKey.ts index bd8a201f8..8af989d7f 100644 --- a/apps/api/src/routes/v1_migrations_createKey.ts +++ b/apps/api/src/routes/v1_migrations_createKey.ts @@ -85,6 +85,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 encrypted 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() @@ -402,6 +413,7 @@ export const registerV1MigrationsCreateKeys = (app: App) => ownerId: key.ownerId ?? null, identityId: null, meta: key.meta ? JSON.stringify(key.meta) : null, + encryptedMeta: key.encryptedMeta ? JSON.stringify(key.encryptedMeta) : null, workspaceId: authorizedWorkspaceId, forWorkspaceId: null, expires: key.expires ? new Date(key.expires) : null, 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..fe710d71e 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,100 @@ 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. +

+
+ ( + + +