-
Notifications
You must be signed in to change notification settings - Fork 505
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
feat: encrypt metadata feature #2059
base: main
Are you sure you want to change the base?
Changes from 4 commits
07b1fc8
82db90b
20acdfd
07ad642
7a4f0f7
c453788
5f1c4da
7451f9b
f65066c
342bfe2
cb44ea9
875e826
0e9c23d
616beb4
1770d0c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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", | ||
perkinsjr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you're ignoring the returned encryption key id, that needs to be stored as well, otherwise we can't reroll the encryption keys |
||
} | ||
} | ||
|
||
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, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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,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 +298,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,13 +337,40 @@ export const registerV1KeysVerifyKey = (app: App) => | |
code: val.code, | ||
}); | ||
} | ||
let metaSecret = ""; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is way too expensive to do uncached. Making a network request to the agent in the critical path is too slow. |
||
if (val.key.encryptedMeta) { | ||
try { | ||
await rootKeyAuth( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what rootKey are you using here? |
||
c, | ||
buildUnkeyQuery(({ or }) => | ||
or("*", "api.*.decrypt_meta", `api.${val.api.id}.decrypt_meta`), | ||
), | ||
); | ||
const encryptedMeta = | ||
typeof val.key.encryptedMeta === "string" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what else could it be? |
||
? 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 { | ||
metaSecret = "{}"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think |
||
} | ||
} | ||
|
||
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: JSON.parse(metaSecret), | ||
expires: val.key?.expires?.getTime(), | ||
remaining: val.remaining ?? undefined, | ||
ratelimit: val.ratelimit ?? undefined, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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<Props> = ({ apiId, keyAuthId }) => { | |
expireEnabled: false, | ||
limitEnabled: false, | ||
metaEnabled: false, | ||
encryptMetaEnabled: false, | ||
ratelimitEnabled: false, | ||
}, | ||
}); | ||
|
@@ -189,6 +207,9 @@ export const CreateKey: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ apiId, keyAuthId }) => { | |
) : null} | ||
</CardContent> | ||
</Card> | ||
<Card> | ||
<CardContent className="justify-between w-full p-4 item-center"> | ||
<div className="flex items-center justify-between w-full"> | ||
<span>Encrypted Metadata</span> | ||
|
||
<FormField | ||
control={form.control} | ||
name="encryptMetaEnabled" | ||
render={({ field }) => ( | ||
<FormItem> | ||
<FormLabel className="sr-only">Metadata</FormLabel> | ||
<FormControl> | ||
<Switch | ||
onCheckedChange={(e) => { | ||
field.onChange(e); | ||
if (field.value === false) { | ||
resetLimited(); | ||
} | ||
}} | ||
/> | ||
</FormControl> | ||
</FormItem> | ||
)} | ||
/> | ||
</div> | ||
|
||
{form.watch("encryptMetaEnabled") ? ( | ||
<> | ||
<p className="text-xs text-content-subtle"> | ||
Encrypt sensitive data before associating it with this key to store it | ||
securely. Whenever you verify this key with the decrypt metadata | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As mentioned above, verifying keys does not require a root key (yet), therefore you can't do permission checks |
||
permissions, the encrypted metadata will be returned to you securely, | ||
ensuring confidentiality. Enter custom encrypted metadata as a JSON | ||
object. | ||
</p> | ||
|
||
<div className="flex flex-col gap-4 mt-4"> | ||
<FormField | ||
control={form.control} | ||
name="encryptMeta" | ||
render={({ field }) => ( | ||
<FormItem> | ||
<FormControl> | ||
<Textarea | ||
disabled={!form.watch("encryptMetaEnabled")} | ||
className="m-4 mx-auto border rounded-md shadow-sm" | ||
rows={7} | ||
placeholder={`{"STRIPE_API_KEY" : "sk_test123"}`} | ||
{...field} | ||
value={ | ||
form.getValues("encryptMetaEnabled") | ||
? field.value | ||
: undefined | ||
} | ||
/> | ||
</FormControl> | ||
<FormDescription> | ||
Enter custom metadata as a JSON object. | ||
</FormDescription> | ||
<FormMessage /> | ||
<Button | ||
variant="secondary" | ||
type="button" | ||
onClick={(_e) => { | ||
try { | ||
if (field.value) { | ||
const parsed = JSON.parse(field.value); | ||
field.onChange(JSON.stringify(parsed, null, 2)); | ||
form.clearErrors("encryptMeta"); | ||
} | ||
} catch (_e) { | ||
form.setError("encryptMeta", { | ||
type: "manual", | ||
message: "Invalid JSON", | ||
}); | ||
} | ||
}} | ||
value={field.value} | ||
> | ||
Format Json | ||
</Button> | ||
</FormItem> | ||
)} | ||
/> | ||
</div> | ||
{form.formState.errors.ratelimit && ( | ||
<p className="text-xs text-center text-content-alert"> | ||
{form.formState.errors.ratelimit.message} | ||
</p> | ||
)} | ||
</> | ||
) : null} | ||
</CardContent> | ||
</Card> | ||
<div className="w-full"> | ||
<Button | ||
className="w-full" | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs to explain that it's encrypted too, as this string is used in sdks and documentation.
I know the field is named
encryptedMeta
but it doesn't hurt to be obvious.