47ng/prisma-field-encryption

Fields that are selected through include are returned as cyphertext

Opened this issue · 6 comments

When doing a query which includes encrypted fields, rather than selecting them directly, the field will return encrypted.
Example of my prisma schema:

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
    provider = "prisma-client-js"
}

generator fieldEncryptionMigrations {
    provider = "prisma-field-encryption"
    output   = "./migrations"

    // Optionally opt-in to concurrent model migration.
    // Since this can cause timeouts and performance issues,
    // it's off by default, and models are updated sequentially.
    concurrently = true
}

datasource db {
    provider  = "mongodb"
    url       = env("DATABASE_URL")
    directUrl = env("DIRECT_DATABASE_URL")
}

enum Role {
    USER
    ADMIN
}

model User {
    id                   String         @id @default(cuid()) @map("_id")
    accountId            String         @unique
    email                String /// @encrypted?mode=strict
    role                 Role           @default(USER)
    subscriptions        Subscription[] @relation("subscriptions")
    createdSubscriptions Subscription[] @relation("createdSubscriptions")
}

model Subscription {
    id         String   @id @default(cuid()) @map("_id")
    name       String
    paymentUrl String
    expiresOn  DateTime
    customerId String
    customer   User     @relation("subscriptions", fields: [customerId], references: [id])
    adminId    String
    admin      User     @relation("createdSubscriptions", fields: [adminId], references: [id])
}

Could you paste the debug logs here (redacting any relevant values) please?

Actually I don't think the include matters. I'm also getting it for regular selects.

  prisma-field-encryption:setup Keys: {
  prisma-field-encryption:setup   encryptionKey: {
  prisma-field-encryption:setup     raw: Uint8Array(32) [
xX
  prisma-field-encryption:setup     ],
  prisma-field-encryption:setup     fingerprint: '83fa61c5'
  prisma-field-encryption:setup   },
  prisma-field-encryption:setup   keychain: { '83fa61c5': { key: [Object], createdAt: 1699968990545 } }
  prisma-field-encryption:setup } +0ms
  prisma-field-encryption:setup Models: {
  prisma-field-encryption:setup   User: {
  prisma-field-encryption:setup     cursor: 'id',
  prisma-field-encryption:setup     fields: { email: [Object] },
  prisma-field-encryption:setup     connections: { subscriptions: [Object], createdSubscriptions: [Object] }
  prisma-field-encryption:setup   },
  prisma-field-encryption:setup   Subscription: {
  prisma-field-encryption:setup     cursor: 'id',
  prisma-field-encryption:setup     fields: {},
  prisma-field-encryption:setup     connections: { customer: [Object], admin: [Object] }
  prisma-field-encryption:setup   }
  prisma-field-encryption:setup } +20ms
 ✓ Compiled in 333ms (332 modules)
prisma:query db.User.aggregate([ { $match: { $expr: { $and: [ { $eq: [ "$accountId", { $literal: "30712252-966d-4b17-a2ca-650f0011c106", }, ], }, { $ne: [ "$accountId", "$$REMOVE", ], }, ], }, }, }, { $limit: 1, }, { $project: { _id: 1, accountId: 1, email: 1, role: 1, }, }, ])
prisma:query db.User.aggregate([ { $match: { $expr: { $and: [ { $eq: [ "$accountId", { $literal: "30712252-966d-4b17-a2ca-650f0011c106", }, ], }, { $ne: [ "$accountId", "$$REMOVE", ], }, ], }, }, }, { $limit: 1, }, { $project: { _id: 1, accountId: 1, email: 1, role: 1, }, }, ])
AUTH USER {
  id: 'clojv3dbz0000o60gfmquqw6u',
  accountId: '30712252-966d-4b17-a2ca-650f0011c106',
  email: 'v1.aesgcm256.83fa61c5.b_AMLTv_7VbNSS9n.cFmJw2yujwmdYTEa3yePa5Hdby2wD0BVp97b6q_IBY9VOvI=',
  role: 'ADMIN'
}
 ○ Compiling /favicon.ico/route ...
 ✓ Compiled /api/user/[userId]/subscription/route in 1523ms (864 modules)
prisma:warn In production, we recommend using `prisma generate --no-engine` (See: `prisma generate --help`)
2023-11-14T13:36:33.669Z prisma-field-encryption:setup Keys: {
  encryptionKey: {
    raw: Uint8Array(32) [
      214, 112,  94, 194, 189,  90,  64, 162,
       98, 147, 152, 213,  33, 131,  83, 253,
      245,  19,  20, 191,  36,   3, 152,   3,
       38, 190, 186,  61, 241, 154,  57, 201
    ],
    fingerprint: '83fa61c5'
  },
  keychain: { '83fa61c5': { key: [Object], createdAt: 1699968993665 } }
}
2023-11-14T13:36:33.699Z prisma-field-encryption:setup Models: {
  User: {
    cursor: 'id',
    fields: { email: [Object] },
    connections: { subscriptions: [Object], createdSubscriptions: [Object] }
  },
  Subscription: {
    cursor: 'id',
    fields: {},
    connections: { customer: [Object], admin: [Object] }
  }
}
prisma:query db.Subscription.aggregate([ { $match: { $expr: { $and: [ { $eq: [ "$customerId", { $literal: "clojv3dbz0000o60gfmquqw6u", }, ], }, { $ne: [ "$customerId", "$$REMOVE", ], }, ], }, }, }, { $sort: { expiresOn: -1, }, }, { $project: { _id: 1, name: 1, paymentUrl: 1, expiresOn: 1, customerId: 1, adminId: 1, }, }, ])

Ah I see, you have to do special things with Next.js to prevent hot-reloading to stack up the middlewares, see #61 (comment).

Let me know if that fixes it for you.

I instantiate my prisma client like so, which is roughly the same as in that comment I think and it doesn't work:

import { PrismaClient } from "@prisma/client/edge";
import { withAccelerate } from "@prisma/extension-accelerate";
import { fieldEncryptionExtension } from 'prisma-field-encryption'

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

export const prisma =
    globalForPrisma.prisma ||
    new PrismaClient({
        log:
            process.env.NODE_ENV === "development"
                ? ["query", "error", "warn"]
                : ["error"],
    })
        .$extends(withAccelerate())
        .$extends(fieldEncryptionExtension());

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

export default prisma;

I also have the same problem, with a similar setup (I tried accelerate too, but removed it for now).

When I am on prisma ~4.3 and prisma-field-encryption@~1.4.5 the cypher gets decoded correctly. I tried prisma ~5.1 and then ~5.5 on prisma-field-encryption@^1.5.0 and the cypher does not get decoded. I use the same encryption keys.

Here is my debug output:

Debug output
 ✓ Compiled /api/graphql in 5.9s (5591 modules)
2023-11-22T21:21:01.453Z prisma-field-encryption:setup Keys: {
  encryptionKey: {
    raw: Uint8Array(32) [/* redacted */],
    fingerprint: '/* redacted */'
  },
  keychain: { /* redacted */: { key: [Object], createdAt: 1700688061450 } }
}
2023-11-22T21:21:01.463Z prisma-field-encryption:setup Models: {
  Job: {
    cursor: 'id',
    fields: {},
    connections: {
      address: [Object],
      positions: [Object],
      requestedPositions: [Object],
      workShiftEvents: [Object],
      workShiftViolations: [Object]
    }
  },
  JobClockInCode: { cursor: 'id', fields: {}, connections: {} },
  Address: {
    cursor: 'id',
    fields: {},
    connections: { job: [Object], contact: [Object] }
  },
  JobPosition: {
    cursor: 'id',
    fields: {},
    connections: { job: [Object], user: [Object] }
  },
  JobPositionRequest: {
    cursor: 'id',
    fields: {},
    connections: { job: [Object], requestedBy: [Object] }
  },
  WorkShift: {
    cursor: 'id',
    fields: {},
    connections: { user: [Object], events: [Object], violations: [Object] }
  },
  WorkShiftEvent: {
    cursor: 'id',
    fields: {},
    connections: {
      workShift: [Object],
      job: [Object],
      successor: [Object],
      predecessor: [Object]
    }
  },
  WorkShiftViolation: {
    cursor: 'id',
    fields: {},
    connections: { workShift: [Object], job: [Object] }
  },
  User: {
    cursor: 'id',
    fields: { pin: [Object] },
    connections: {
      profile: [Object],
      jobPositions: [Object],
      jobPositionRequests: [Object],
      workShifts: [Object],
      notifications: [Object],
      devicePushTokens: [Object],
      employeeNotes: [Object],
      authoredNotes: [Object]
    }
  },
  Profile: {
    cursor: 'id',
    fields: {},
    connections: { users: [Object], contacts: [Object] }
  },
  EmployeeNote: {
    cursor: 'id',
    fields: {},
    connections: { employee: [Object], author: [Object] }
  },
  Contact: {
    cursor: 'id',
    fields: {},
    connections: { address: [Object], profile: [Object] }
  },
  Notification: { cursor: 'id', fields: {}, connections: { user: [Object] } },
  DevicePushToken: { cursor: 'id', fields: {}, connections: { user: [Object] } }
}

And my globally configured prisma on NextJs 14:

prisma.ts
import { Prisma, PrismaClient } from './client';
import { fieldEncryptionExtension } from 'prisma-field-encryption';
import { loadEnvironment } from '@ss/environment-loader';
import { isProd } from '@ss/environment';

loadEnvironment();

declare global {
  // eslint-disable-next-line no-var
  var prisma: PrismaClient | undefined;
}

export const prisma =
  global.prisma ||
  new PrismaClient({
    datasources: {
      db: {
        url: process.env.DATABASE_URL,
      },
    },
    log: isProd() ? ['query', 'info', 'warn', 'error'] : [],
  });

if (!global.prisma) {
  prisma.$extends(fieldEncryptionExtension({ dmmf: Prisma.dmmf }));
}

if (!isProd()) {
  global.prisma = prisma;
}

On my case I am not doing this within an include although might be why I did a root resolver in this sample code

/*
 * This is a workaround, as I am not sure how type-graphql-prisma handles queries. Nested resolvers that
 * need the pin found themselves with an encrypted value instead of an unencrypted one. This works around that,
 * although not performant.
 */
@FieldResolver(() => String)
async pin(@Root() user: User, @Ctx() { prisma }: Context): Promise<string> {
  const foundUser = await prisma.user.findUnique({ where: { id: user.id } });

  return foundUser?.pin || user.pin;
}

I also double checked that the encrypted pin is not double encrypted, it is the same as found within the database

Another interesting thing here is that I tried using the deprecated $use and it works

// prisma.$extends(fieldEncryptionExtension({ dmmf: Prisma.dmmf }));
prisma.$use(fieldEncryptionMiddleware({ dmmf: Prisma.dmmf }))

I noticed that the difference between the extension and the middleware is that the extension uses Prisma.defineExtension. Since my setup uses a custom output directory, I tried removing that from the compiled extension.js file in favor of just returning an object like documented here, but that did not work either.

Maybe that is a separate issue though, not too sure.