3

I'm trying to implement basic asymmetric encryption; one service has a public key and encrypts a value with that public key and then another service receives the encrypted message, decodes it using the private key, and does something with the decrypted data.

The problem that I'm having is that every time I use the in-built crypto.publicEncrypt method, I get a different encrypted value returned. As far as I can tell, I'm using the same inputs, so as I understand it I should be seeing the same output. Perhaps I have misunderstood this?

Here is my encryption utility;

import { createPublicKey, createPrivateKey, privateDecrypt, publicEncrypt, constants } from "crypto";

const privateKeyPem = process.env.ENCRYPTION_PRIVATE_KEY;
const privateKeyPemFixed = privateKeyPem.replace(/\\n/g, "\n");
const privateKey = createPrivateKey(privateKeyPemFixed);
const publicKey = createPublicKey(privateKey);

// const private1 = privateKey.export({
//   type: 'pkcs1',
//   format: 'pem',
// }).toString("base64");

// const public1 = publicKey.export({
//   type: 'pkcs1',
//   format: 'pem',
// }).toString("base64");

export const encrypt = (text: string): string => {
  const buffer = Buffer.from(text);

  const encrypted1 = publicEncrypt(   {
      key: publicKey,
      oaepHash: 'sha256',
      padding: constants.RSA_PKCS1_OAEP_PADDING,
  }, buffer);

  const encrypted2 = publicEncrypt({
    key: publicKey,
    oaepHash: 'sha256',
    padding: constants.RSA_PKCS1_OAEP_PADDING,
  }, buffer);

  console.log(encrypted1.toString("base64"));
  console.log(encrypted2.toString("base64"));

  return encrypted1.toString("base64");
}

export const decrypt = (cipher: string): string => {
  const buffer = Buffer.from(cipher);
  const decrypted = privateDecrypt(privateKey, buffer);
  return decrypted.toString("utf8");
}

I have a jest test which looks like this;

import { encrypt } from "./encryption";

describe("encryption", () => {

  const helloWorld = "Hello world";
  const encryptedHelloWorld = "IIisobkVsZxKiR0e5nwyIHjsww/ebrKXI0hzDbdTdC8KMU2rc57IRX9krhVThVma2no7gZcMvbfwJsRjHz1s7NoBiT+BitgYlI/LE1jMpFd5Bmghy2S93F/wGFRWA4DMAqdw32I9s8CRKVvellxkh3ZlJ5NyzxWG8kVfc11CrEMD+1sqo2e9cFCcTdx5jEVYpCgITy7X2vDxUwOPQ7bK8K56kU5ivQhUfyoHjd9VclRUxfBaSzOwLJQqK6RJPbNwuUfILcCaR72GTf4zWMhQqIvs/zHhSu+S9QQYPVvmZ1SzqqJaCM9mM6Cvl8Gn2brwcMB003f0CFb8WFimOgM6lQ==";

  it("should encrypt text", () => {
    const received = encrypt(helloWorld);
    expect(received).toEqual(encryptedHelloWorld);
  });
});

However it constantly fails as the result always seems to be different.

I ran the encryption process twice in the encrypt function, to demonstrate the problem; the two values which it logs out are completely different and I don't understand why.

  console.log
    aDWDWcE+Zs92/rp2DLJN8UTgwHPTg6TDqFPIrC3ODVIfZgo5uaQV0NTSESPPPAGHhHeKiWB8JFnVewJaEN7iz9StzRepaL3+DFpD/CvhA8L7o8CQ5CTeScqL9HedVkM7O4MziMHkTJy0Li7EjP/6xdp8Caw+m6EsqvQ9Yd3qN4OTwrsMWmItLIaAHmkB/4UPhMqVnddVnwBUVb7toJ5rvGc/uktZkZPuHdzJRI0XSW//ltHHFCi3zneoJ92v/myYZOtWTyBDTmrgUtzC5fHbsSVdnD9IyWTRf72fz1Hjf2z8xFdFsdugo/+0qzOwE77K4BkgukeIDwhAxmdIr5yo4w==

      at encrypt (utils/encryption.ts:33:11)

  console.log
    LROC3KIjXJVoQVawJYZUYqT7rhXC8enb6O9ipY9VnOFMilFM00NHGiF3FHJQLWqac5zWFFZg2ofygANqT7Y5rQRtePcUEM5bLEUHvMaDdOAEXSdOK4PTbiCqZCAIPd79VVsW9gk2+vhKHbsq78AXhycCgUiOVjv25ooluDvqj3CQ+sTR+5cbatYO5kpXWwpu/BmPlRZYwsLUldpCuUPAYbkItKmQmiq/FWw1+z9Vx8mMKYhPtLuSTxnRrJ2Hn1eQm2EkuEeWQAEp+TJYaBsi93NalqmcWDo5swNe5HFPUH4hV7xtMtTZv82Wu9uNJ+ADUTD1B2mKDzKr0M0yNEYcGA==

      at encrypt (utils/encryption.ts:34:11)

At first I wondered if there was a problem with my multiline private key in my .env, but I can export my private and public keys (see commented out code) and when I log them out, they look as I would expect, which I think means the keyObjects are being created successfully. If the keys were not created successfully, maybe it would create new keys each time and that would cause this failure? But as far as I can tell, they are created successfully.

I also read this answer which suggested that there might be a problem with the OpenSSL implementation on MacOS - I'm on MacOs Big Sur, Node 14.16.0 (LTS). So, I brew install openssl and then linked it, and now I can see that I am using OpenSSL rather than LibreSSL by checking like so;

➜  website git:(master) ✗ openssl version
OpenSSL 1.1.1j  16 Feb 2021

However that doesn't seem to have made a difference.

So, what can I do to make the encrypt function reliably return the same output, given the same input?

EDIT

I've updated my encryption util to the following and accepted that the result of the encryption will be different because it is encrypted with a unique session key as well as the public key, however all the output values decrypt correctly with the private key.

import { createPublicKey, createPrivateKey, privateDecrypt, publicEncrypt } from "crypto";

const privateKeyPem = process.env.ENCRYPTION_PRIVATE_KEY;
const privateKeyPemFixed = privateKeyPem.replace(/\\n/g, "\n");
const privateKey = createPrivateKey(privateKeyPemFixed);
const publicKey = createPublicKey(privateKey);

export const encrypt = (text: string): string => {
  const buffer = Buffer.from(text, "utf8");
  const encrypted = publicEncrypt(publicKey, buffer);
  return encrypted.toString("base64");
}

export const decrypt = (cipher: string): string => {
  const buffer = Buffer.from(cipher, "base64");
  const decrypted = privateDecrypt(privateKey, buffer);
  return decrypted.toString("utf8");
}
sauntimo
  • 1,531
  • 1
  • 17
  • 28
  • For your encryption you are using "RSA_PKCS1_OAEP_PADDING" that has an important security **feature** - it adds some random data so in the end each ciphertext looks different with the same key and input. There are other padding modes like PKCS#1.5 but some libraries mark it as "deprecated" because it generates determistic signatures (look the same for each run) that is **unsecure** in a lot environments. – Michael Fehr Mar 01 '21 at 14:18
  • @MichaelFehr thanks for this, this is useful for my understanding – sauntimo Mar 01 '21 at 14:25

1 Answers1

5

It turns out that my assumptions about crypto.PublicEncrypt were incorrect. To quote from this answer

Pure function criterion 1: Calling the function with the same values must always yield the same return value

This is impossible when doing asymmetric encryption because a random session key is generated for each operation. The session key is encrypted with the public key, and then the session key is used to encrypt the payload. The returned value is usually just an encoded version of two values: (1) the pubkey-encrypted session key, and (2) the session key -encrypted payload.

Both of these values are going to be different each time you call the function because the session key is going to be different each time.

However, despite the return values not comparing as equal, I would argue that they are semantically equal -- that is, if you decrypt each value with the matching private key, the decrypted values will compare as equal.

So I updated my test to;

import { decrypt, encrypt } from "./encryption";

describe("encryption", () => {

  it("should encrypt and decrypt text", () => {
    const encrypted = encrypt("Hello World");
    const decrypted = decrypt(encrypted);
    expect(decrypted).toEqual("Hello World");
  });
});

And it's now working.

sauntimo
  • 1,531
  • 1
  • 17
  • 28