Strapi Custom Fields (encryptable field)
Tags: strapi, encryption
Published: 2022-12-21 12:28:54


We are going to create a custom field in order to encrypt the value when saved, and decrypt when fetched.

## Preface

> DO NOT USE ENCRYPTION TO STORE USER PASSWORDS, FOR THIS YOU USE HASHING.

> ONLY STORE PII DATA WHEN NEEDED AND ONLY THE BARE MINIMUM. CONSULT THE RULES AROUND PII DATA THAT APPLY IN THE REGIONS YOU OPERATE IN.

**Requirements**:
- A [Strapi](https://strapi.io/) installation (version 4.4+)
- Understanding of Typescript (Should be fairly easy for plain javascript as well, we are not using types heavily).

**Generating the plugin boilerplate**
These steps can also be found in the [official documentation](https://docs.strapi.io/developer-docs/latest/development/plugins-development.html#create-a-plugin).
1. Navigate to the root of a Strapi project.
2. Run `yarn strapi generate` or `npm run strapi generate` in a terminal window to start the interactive CLI.
3. Choose "plugin" from the list, press Enter, and give the plugin a name in kebab-case (e.g. encryptable-field)
4. Choose either JavaScript or TypeScript for the plugin language.
5. Enable the plugin by adding it to the plugins configurations file: 

        
        // config/plugins.ts
        export default {
          'encryptable-field': {
            enabled: true,
            resolve: './src/plugins/encryptable-field'
          },
        }
        
6. (TypeScript-specific) Run `npm install` or `yarn` in the newly-created plugin directory.
8. (TypeScript-specific) Run `yarn build` or `npm run build` in the plugin directory. This step transpiles the TypeScript files and outputs the JavaScript files to a dist directory that is unique to the plugin.
9. Run `yarn build` or `npm run build` at the project root.
10. Run `yarn develop` or `npm run develop` at the project root.

**Note**: if your changes are not visible after a code change, run step 7, 8 and 9 again.

Now, let's build out our plugin.

## Server

### Plugin registration
```typescript
// plugins/encryptable-field/server/register.ts
import { Strapi } from '@strapi/strapi';
import pluginId from '../admin/src/pluginId';

export default ({ strapi }: { strapi: Strapi }) => {
  strapi.customFields.register({
    name: pluginId,
    plugin: pluginId,
    type: 'text',
  });
};

```

### Encryption service
The encryption service holds the logic for encryption and decryption as well as a function to retrieve relevant fields to encrypt/decrypt.

```typescript
// plugins/encryptable-field/server/services/service.ts
import { Strapi } from '@strapi/strapi';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';

const AES_METHOD = 'aes-256-cbc';
const IV_LENGTH = 16;
const KEY = process.env.ENCRYPTION_KEY || ''; // hex key 32 bytes
const PLUGIN_DSN = 'plugin::encryptable-field.encryptable-field';

export default ({ strapi }: { strapi: Strapi }) => ({

  // Get fields that are of our custom field type.
  getFields(fields: object): string[] {
    const attributes = [];
    for (const attribute in fields) {
      if (fields[attribute]['customField'] ===  PLUGIN_DSN) {
        attributes.push(attribute);
      }
    }

    return attributes
  },

  encrypt(value: string): string {
    const iv = randomBytes(IV_LENGTH);
    const cipher = createCipheriv(AES_METHOD, Buffer.from(KEY), iv);

    let encrypted = cipher.update(value);
    encrypted = Buffer.concat([encrypted, cipher.final()]);

    return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
  },

  decrypt(value: string): string {
    const textParts = value.split(':');
    const firstPart = textParts.shift();

    if (!firstPart) throw Error('Malformed payload');

    const iv = Buffer.from(firstPart, 'hex');
    const encryptedText = Buffer.from(textParts.join(':'), 'hex');
    const decipher = createDecipheriv(AES_METHOD, Buffer.from(KEY), iv);

    let decrypted = decipher.update(encryptedText);
    decrypted = Buffer.concat([decrypted, decipher.final()]);

    return decrypted.toString();
  }
});
```

## Admin

![Custom Field with options](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6wo85w5b1sm7w4qo11b3.png)
We will create a new input field with the following options:
- field hint
- required
- regex validation

### Configuration

```typescript
// plugins/encryptable-field/admin/src/index.tsx
import React from 'react';
import { prefixPluginTranslations } from '@strapi/helper-plugin';
import pluginId from './pluginId';
import getTrad from './utils/getTrad';

export default {
  register(app) {
    app.customFields.register({
      name: pluginId,
      pluginId: pluginId,
      type: 'string',
      intlLabel: {
        id: getTrad(`${pluginId}.label`),
        defaultMessage: 'Encryptable'
      },
      intlDescription: {
        id: getTrad(`${pluginId}.description`),
        defaultMessage: 'Adds Encryptable fields',
      },
      components: {
        Input: async () => import('./components/EncryptableFieldInput'),
      },
      options: {
        base: [
          {
            intlLabel: {
              id: getTrad(`${pluginId}.options.advanced.regex.hint`),
              defaultMessage: 'Input hint',
            },
            name: 'options.hint',
            type: 'text',
            defaultValue: null,
            description: {
              id: getTrad(`${pluginId}.options.advanced.regex.hint.description`),
              defaultMessage: 'The text of the regular expression hint',
            },
          },
        ],
        advanced: [
          {
            intlLabel: {
              id: getTrad(`${pluginId}.options.advanced.regex`),
              defaultMessage: 'RegExp pattern',
            },
            name: 'regex',
            type: 'text',
            defaultValue: null,
            description: {
              id: getTrad(`${pluginId}.options.advanced.regex.description`),
              defaultMessage: 'The text of the regular expression',
            },
          },
          {
            sectionTitle: {
              id: 'global.settings',
              defaultMessage: 'Settings',
            },
            items: [
              {
                name: 'required',
                type: 'checkbox',
                intlLabel: {
                  id: getTrad(`${pluginId}.options.advanced.requiredField`),
                  defaultMessage: 'Required field',
                },
                description: {
                  id: getTrad(`${pluginId}.options.advanced.requiredField.description`),
                  defaultMessage: 'You won\'t be able to create an entry if this field is empty',
                },
              },
            ],
          },
        ]
      }
    })
  },

  bootstrap(app) {
  },

  async registerTrads(app) {
    const {locales} = app;

    const importedTrads = await Promise.all(
      locales.map(locale => {
        return import(`./translations/${locale}.json`)
          .then(({default: data}) => {
            return {
              data: prefixPluginTranslations(data, pluginId),
              locale,
            };
          })
          .catch(() => {
            return {
              data: {},
              locale,
            };
          });
      })
    );

    return Promise.resolve(importedTrads);
  },
};
```

## The input field

```typescript
// plugins/encryptable-field/admin/src/EncryptableFieldInput/index.tsx
import {
  Box,
  Field,
  Stack,
  FieldLabel,
  Flex,
  FieldInput,
  FieldHint,
  FieldError,
} from '@strapi/design-system';
import PropTypes from 'prop-types';
import React, { useRef } from 'react';
import { useIntl } from 'react-intl';
import getTrad from '../../utils/getTrad';

const encryptableFieldInput = ({
  description,
  placeholder,
  disabled,
  error,
  intlLabel,
  labelAction,
  name,
  onChange,
  required,
  value,
  attribute,
}): JSX.Element => {
  const { formatMessage } = useIntl();
  const reference = useRef(null);

  return (
    <Box>
      <Field
        id={name}
        name={name}
        hint={attribute.options?.hint ?? description ?? ''}
        error={error}
        required={required}
      >
        <Stack spacing={1}>
          <Flex>
            <FieldLabel action={labelAction} required={required}>
              {formatMessage(intlLabel)}
            </FieldLabel>
          </Flex>
          <FieldInput
            ref={reference}
            id="encryptable-field-value"
            disabled={disabled}
            required={required}
            name={name}
            aria-label={formatMessage({
              id: getTrad('input.aria-label'),
              defaultMessage: 'Encryptable input',
            })}
            value={value}
            placeholder={placeholder}
            onChange={onChange}
            hint={description}
          />
          <FieldHint />
          <FieldError />
        </Stack>
      </Field>
    </Box>
  );
};

encryptableFieldInput.defaultProps = {
  description: null,
  disabled: false,
  error: null,
  labelAction: null,
  required: false,
  value: '',
};

encryptableFieldInput.propTypes = {
  intlLabel: PropTypes.object.isRequired,
  onChange: PropTypes.func.isRequired,
  attribute: PropTypes.object.isRequired,
  name: PropTypes.string.isRequired,
  description: PropTypes.object,
  disabled: PropTypes.bool,
  error: PropTypes.string,
  labelAction: PropTypes.object,
  required: PropTypes.bool,
  value: PropTypes.string,
};

export default encryptableFieldInput;
```

## Hooking it up to the lifecycle events

In order to actually get it to work we need to hook it up to the database lifecycle events.

```typescript
// plugins/encryptable-field/server/bootstrap.ts
import { Strapi } from '@strapi/strapi';
import { ENCRYPTABLE_FIELD } from './index';
import { Subscriber } from '@strapi/database/lib/lifecycles/subscribers';

export default ({ strapi }: { strapi: Strapi }) => {
  const encryptionService = strapi.plugin(ENCRYPTABLE_FIELD).service('service');

  strapi.db.lifecycles.subscribe((<Subscriber>{
    beforeCreate(event): void {
      const attributes = encryptionService.getFields(event.model.attributes);
      attributes.forEach(
        (attr) => (event.params.data[attr] = encryptionService.encrypt(event.params.data[attr])),
      );
    },

    beforeUpdate(event): void {
      const attributes = encryptionService.getFields(event.model.attributes);
      attributes.forEach(
        (attr) => (event.params.data[attr] = encryptionService.encrypt(event.params.data[attr])),
      );
    },

    afterFindOne(event): void {
      const attributes = encryptionService.getFields(event.model.attributes);
      attributes.forEach(
        (attr) => (event['result'][attr] = encryptionService.decrypt(event['result'][attr])),
      );
    },

    afterFindMany(event): void {
      const attributes = encryptionService.getFields(event.model.attributes);
      event['result'].forEach((result, i) =>
        attributes.forEach(
          (attr) =>
            (event['result'][i][attr] = encryptionService.decrypt(event['result'][i][attr])),
        ),
      );
    },
  }) as Subscriber);
};
```
---

Aaand~ that's it, you now have a custom field that allows you to store values encrypted and they will be decrypted when queried.

To verify if it works, comment out the `afterFindOne` and `afterFindMany` hooks. You should now see the encrypted values.

![Custom Field](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fwjdafbic8yn4uoia6ux.png)

[Source Code](https://github.com/Edwin-Luijten/strapi-encryptable-field)