Search code examples
node.jsencryption-asymmetricnode-crypto

node crypto.publicEncrypt returns different value each time it is used


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");
}

Solution

  • 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.