Skip to content

Commit

Permalink
Merge 98b6ce2 into edd4283
Browse files Browse the repository at this point in the history
  • Loading branch information
martijn-dev committed Jun 25, 2024
2 parents edd4283 + 98b6ce2 commit 0520dd7
Show file tree
Hide file tree
Showing 8 changed files with 146 additions and 21 deletions.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,30 @@ rainbow table attacks. There are multiple ways to do so, listed by order of prec

The salt should be of the same encoding as the associated data to hash.

### Sanitize hash

> _Support: introduced in version 1.6.0_
You can sanitize a hash before creation and querying. This might be useful in case you would like to find a User with the name of `François ` with a query input of `francois`.

There are several sanitize options:

```
/// @encryption:hash(email)?sanitize=lowercase <- lowercase hash
/// @encryption:hash(email)?sanitize=uppercase <- uppercase hash
/// @encryption:hash(email)?sanitize=trim <- trim start and end of hash
/// @encryption:hash(email)?sanitize=spaces <- remove spaces in hash
/// @encryption:hash(email)?sanitize=diacritics <- remove diacritics like ç or é in hash
```

You can also combine the sanitize options:

```
/// @encryption:hash(email)?sanitize=lowercase&sanitize=trim&sanitize=trim&sanitize=diacritics
```

> Be aware: Using the sanitize hash feature in combination with `unique` could cause conflicts. Example: Users with the name `François` and `francois` result in the same hash which could result in a database conflict.
## Migrations

Adding encryption to an existing field is a transparent operation: Prisma will
Expand Down
2 changes: 1 addition & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ model User {
id Int @id @default(autoincrement())
email String @unique
name String? @unique /// @encrypted
nameHash String? @unique /// @encryption:hash(name)
nameHash String? @unique /// @encryption:hash(name)?sanitize=lowercase&sanitize=diacritics&sanitize=trim
posts Post[]
pinnedPost Post? @relation(fields: [pinnedPostId], references: [id], name: "pinnedPost")
pinnedPostId Int?
Expand Down
6 changes: 4 additions & 2 deletions src/dmmf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
parseEncryptedAnnotation,
parseHashAnnotation
} from './dmmf'
import { HashFieldSanitizeOptions } from './types'

describe('dmmf', () => {
describe('parseEncryptedAnnotation', () => {
Expand Down Expand Up @@ -120,7 +121,7 @@ describe('dmmf', () => {
id Int @id @default(autoincrement())
email String @unique
name String? /// @encrypted
nameHash String? /// @encryption:hash(name)
nameHash String? /// @encryption:hash(name)?sanitize=lowercase
posts Post[]
pinnedPost Post? @relation(fields: [pinnedPostId], references: [id], name: "pinnedPost")
pinnedPostId Int?
Expand Down Expand Up @@ -162,7 +163,8 @@ describe('dmmf', () => {
targetField: 'nameHash',
algorithm: 'sha256',
inputEncoding: 'utf8',
outputEncoding: 'hex'
outputEncoding: 'hex',
sanitize: [HashFieldSanitizeOptions.lowercase]
}
}
},
Expand Down
30 changes: 25 additions & 5 deletions src/dmmf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type { Encoding } from '@47ng/codec'
import { errors, warnings } from './errors'
import {
DMMFDocument,
dmmfDocumentParser,
FieldConfiguration,
HashFieldConfiguration
HashFieldConfiguration,
HashFieldSanitizeOptions,
dmmfDocumentParser
} from './types'

export interface ConnectionDescriptor {
Expand Down Expand Up @@ -40,8 +41,8 @@ export function analyseDMMF(input: DMMFDocument): DMMFModels {
field =>
field.isUnique && supportedCursorTypes.includes(String(field.type))
)
const cursorField = model.fields.find(
field => field.documentation?.includes('@encryption:cursor')
const cursorField = model.fields.find(field =>
field.documentation?.includes('@encryption:cursor')
)
if (cursorField) {
// Make sure custom cursor field is valid
Expand Down Expand Up @@ -208,16 +209,35 @@ export function parseHashAnnotation(
? process.env[saltEnv]
: process.env.PRISMA_FIELD_ENCRYPTION_HASH_SALT)

const sanitize =
(query.getAll('sanitize') as HashFieldSanitizeOptions[]) ?? []
console.log(sanitize)
if (
!isValidSanitizeOptions(sanitize) &&
process.env.NODE_ENV === 'development' &&
model &&
field
) {
console.warn(warnings.unsupportedSanitize(model, field, sanitize, 'output'))
}

return {
sourceField: match.groups.fieldName,
targetField: field ?? match.groups.fieldName + 'Hash',
algorithm: query.get('algorithm') ?? 'sha256',
salt,
inputEncoding,
outputEncoding
outputEncoding,
sanitize
}
}

function isValidEncoding(encoding: string): encoding is Encoding {
return ['hex', 'base64', 'utf8'].includes(encoding)
}

function isValidSanitizeOptions(
options: string[]
): options is HashFieldSanitizeOptions[] {
return options.every(option => option in HashFieldSanitizeOptions)
}
14 changes: 13 additions & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { namespace } from './debugger'
import type { DMMFField, DMMFModel } from './types'
import {
HashFieldSanitizeOptions,
type DMMFField,
type DMMFModel
} from './types'

const error = `[${namespace}] Error`
const warning = `[${namespace}] Warning`
Expand Down Expand Up @@ -105,5 +109,13 @@ export const warnings = {
io: string
) => `${warning}: unsupported ${io} encoding \`${encoding}\` for hash field ${model}.${field}
-> Valid values are utf8, base64, hex
`,
unsupportedSanitize: (
model: string,
field: string,
sanitize: string,
io: string
) => `${warning}: unsupported ${io} sanitize \`${sanitize}\` for hash field ${model}.${field}
-> Valid values are ${Object.values(HashFieldSanitizeOptions)}
`
}
29 changes: 27 additions & 2 deletions src/hash.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
import { decoders, encoders } from '@47ng/codec'
import crypto from 'node:crypto'
import { HashFieldConfiguration } from 'types'
import { HashFieldConfiguration, HashFieldSanitizeOptions } from './types'

export function hashString(
input: string,
config: Omit<HashFieldConfiguration, 'sourceField'>
) {
const decode = decoders[config.inputEncoding]
const encode = encoders[config.outputEncoding]
const data = decode(input)
const sanitized = sanitizeHashString(input, config.sanitize)

const data = decode(sanitized)
const hash = crypto.createHash(config.algorithm)
hash.update(data)
if (config.salt) {
hash.update(decode(config.salt))
}
return encode(hash.digest())
}

export function sanitizeHashString(
input: string,
options: HashFieldSanitizeOptions[] = []
) {
let output = input
if (options.includes(HashFieldSanitizeOptions.lowercase)) {
output = output.toLowerCase()
}
if (options.includes(HashFieldSanitizeOptions.uppercase)) {
output = output.toUpperCase()
}
if (options.includes(HashFieldSanitizeOptions.trim)) {
output = output.trim()
}
if (options.includes(HashFieldSanitizeOptions.spaces)) {
output = output.replace(/\s/g, '')
}
if (options.includes(HashFieldSanitizeOptions.diacritics)) {
output = output.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
}
return output
}
53 changes: 43 additions & 10 deletions src/tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,20 +404,53 @@ describe.each(clients)('integration ($type)', ({ client }) => {
}
})

test("query entries with non-empty name", async () => {
test('query entries with non-empty name', async () => {
const fakeName = 'f@keU$er'
await client.user.create({
data: {
name: '',
email: 'test_mail@example.com'
data: {
name: '',
email: 'test_mail@example.com'
}
});
const users = await client.user.findMany();
})
const users = await client.user.findMany()
// assume active user with nonempty name
const activeUserCount = await client.user.count({ where: { name: { not: '' } } })
const activeUserCount = await client.user.count({
where: { name: { not: '' } }
})
// use fakeName to pretend unique name
const existingUsers = await client.user.findMany({ where: { name: { not: fakeName } } })
expect(activeUserCount).toBe(users.length - 1);
expect(existingUsers).toEqual(users);
const existingUsers = await client.user.findMany({
where: { name: { not: fakeName } }
})
expect(activeUserCount).toBe(users.length - 1)
expect(existingUsers).toEqual(users)
})

const sanitizeTestEmail = 'sanitize@example.com'

test('create user with sanitizable name', async () => {
const received = await client.user.create({
data: {
email: sanitizeTestEmail,
name: ' François'
}
})
const dbValue = await sqlite.get({
table: 'User',
where: { email: sanitizeTestEmail }
})
expect(received.name).toEqual(' François') // clear text in returned value
expect(dbValue.name).toMatch(cloakedStringRegex) // encrypted in database
})

test('query user by encrypted and hashed name field with a sanitized input (with equals)', async () => {
const received = await client.user.findFirst({
where: {
name: {
equals: 'Francois' //check for lowercase, trim and diacritics
}
}
})
expect(received!.name).toEqual(' François') // clear text in returned value
expect(received!.email).toEqual(sanitizeTestEmail)
})
})
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ export type HashFieldConfiguration = {
salt?: string
inputEncoding: Encoding
outputEncoding: Encoding
sanitize?: HashFieldSanitizeOptions[]
}

export enum HashFieldSanitizeOptions {
lowercase = 'lowercase',
uppercase = 'uppercase',
trim = 'trim',
spaces = 'spaces',
diacritics = 'diacritics'
}

export interface FieldConfiguration {
Expand Down

0 comments on commit 0520dd7

Please sign in to comment.