From 17a0106fccfe2a5bbc818ccf69f0c27507c280e3 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 26 Mar 2024 19:26:09 +0100 Subject: [PATCH] Hash API keys with SHA-256 and never show them again --- api/resolvers/user.js | 13 +++- api/typeDefs/user.js | 2 +- pages/api/graphql.js | 7 +- pages/settings/index.js | 64 +++++++++++-------- .../migration.sql | 16 +++++ prisma/schema.prisma | 2 +- 6 files changed, 71 insertions(+), 33 deletions(-) create mode 100644 prisma/migrations/20240326181608_api_key_sha256/migration.sql diff --git a/api/resolvers/user.js b/api/resolvers/user.js index efbfe642..0e5e04ad 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -47,7 +47,7 @@ async function authMethods (user, args, { models, me }) { twitter: oauth.indexOf('twitter') >= 0, github: oauth.indexOf('github') >= 0, nostr: !!user.nostrAuthPubkey, - apiKey: user.apiKeyEnabled ? user.apiKey : null + apiKey: user.apiKeyEnabled ? !!user.apiKeyHash : null } } @@ -541,7 +541,14 @@ export default { throw new GraphQLError('you are not allowed to generate api keys', { extensions: { code: 'FORBIDDEN' } }) } - const [{ apiKey }] = await models.$queryRaw`UPDATE users SET "apiKey" = encode(gen_random_bytes(32), 'base64')::CHAR(32) WHERE id = ${me.id} RETURNING "apiKey"` + // I trust postgres CSPRNG more than the one from JS + const [{ apiKey, apiKeyHash }] = await models.$queryRaw` + SELECT "apiKey", encode(digest("apiKey", 'sha256'), 'hex') AS "apiKeyHash" + FROM ( + SELECT encode(gen_random_bytes(32), 'base64')::CHAR(32) as "apiKey" + ) rng` + await models.user.update({ where: { id: me.id }, data: { apiKeyHash } }) + return apiKey }, deleteApiKey: async (parent, { id }, { models, me }) => { @@ -549,7 +556,7 @@ export default { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } - return await models.user.update({ where: { id: me.id }, data: { apiKey: null } }) + return await models.user.update({ where: { id: me.id }, data: { apiKeyHash: null } }) }, unlinkAuth: async (parent, { authType }, { models, me }) => { if (!me) { diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 2300fdc3..cd2e31bd 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -104,7 +104,7 @@ export default gql` github: Boolean! twitter: Boolean! email: String - apiKey: String + apiKey: Boolean! } type UserPrivates { diff --git a/pages/api/graphql.js b/pages/api/graphql.js index 14cfba3b..e19723ea 100644 --- a/pages/api/graphql.js +++ b/pages/api/graphql.js @@ -56,8 +56,11 @@ export default startServerAndCreateNextHandler(apolloServer, { const apiKey = req.headers['x-api-key'] let session if (apiKey) { - const sessionFieldSelect = { name: true, id: true, email: true } - const user = await models.user.findUnique({ where: { apiKey }, select: { ...sessionFieldSelect, apiKeyEnabled: true } }) + const [user] = await models.$queryRaw` + SELECT id, name, email, "apiKeyEnabled" + FROM users + WHERE "apiKeyHash" = encode(digest(${apiKey}, 'sha256'), 'hex') + LIMIT 1` if (user?.apiKeyEnabled) { const { apiKeyEnabled, ...sessionFields } = user session = { user: { ...sessionFields, apiKey: true } } diff --git a/pages/settings/index.js b/pages/settings/index.js index 7de221e3..32a9b149 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -768,6 +768,7 @@ export function EmailLinkForm ({ callbackUrl }) { } export function ApiKey ({ enabled, apiKey }) { + const showModal = useShowModal() const me = useMe() const [generateApiKey] = useMutation( gql` @@ -785,7 +786,7 @@ export function ApiKey ({ enabled, apiKey }) { privates: { ...existing.privates, apiKey: generateApiKey, - authMethods: { ...existing.privates.authMethods, apiKey: generateApiKey } + authMethods: { ...existing.privates.authMethods, apiKey: true } } } } @@ -811,7 +812,7 @@ export function ApiKey ({ enabled, apiKey }) { ...existing, privates: { ...existing.privates, - authMethods: { ...existing.privates.authMethods, apiKey: null } + authMethods: { ...existing.privates.authMethods, apiKey: false } } } } @@ -820,6 +821,7 @@ export function ApiKey ({ enabled, apiKey }) { } } ) + const toaster = useToast() const subject = '[API Key Request] ' const body = @@ -850,36 +852,34 @@ I estimate that I will call the GraphQL API this many times (rough estimate is f <>
api key
- {apiKey && - <> - - } request access to API keys in ~meta : <>} trigger={['hover', 'focus']} >
- {apiKey - ? { - await deleteApiKey({ variables: { id: me.id } }) - }} - /> - : ( - - )} + + {apiKey && + { + await deleteApiKey({ variables: { id: me.id } }) + }} + />}
@@ -912,6 +912,18 @@ I estimate that I will call the GraphQL API this many times (rough estimate is f ) } +function ApiKeyModal ({ apiKey }) { + return ( + <> +

+ Make sure to copy your API key now.
+ This is the only time we will show it to you. +

+ use the X-API-Key header to include this key in your requests} /> + + ) +} + const ZapUndosField = () => { const [checkboxField] = useField({ name: 'zapUndosEnabled' }) return ( diff --git a/prisma/migrations/20240326181608_api_key_sha256/migration.sql b/prisma/migrations/20240326181608_api_key_sha256/migration.sql new file mode 100644 index 00000000..a37efe7a --- /dev/null +++ b/prisma/migrations/20240326181608_api_key_sha256/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - You are about to drop the column `apiKey` on the `users` table. All the data in the column will be lost. + - A unique constraint covering the columns `[apiKeyHash]` on the table `users` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "users.apikey_unique"; + +-- AlterTable +ALTER TABLE "users" DROP COLUMN "apiKey", +ADD COLUMN "apiKeyHash" CHAR(64); + +-- CreateIndex +CREATE UNIQUE INDEX "users.apikeyhash_unique" ON "users"("apiKeyHash"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca222290..74655e78 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,7 +26,7 @@ model User { checkedNotesAt DateTime? foundNotesAt DateTime? pubkey String? @unique(map: "users.pubkey_unique") - apiKey String? @unique(map: "users.apikey_unique") @db.Char(32) + apiKeyHash String? @unique(map: "users.apikeyhash_unique") @db.Char(64) apiKeyEnabled Boolean @default(false) tipDefault Int @default(100) bioId Int?