Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@
"npm": ">=10.x"
},
"dependencies": {
"@noble/secp256k1": "^2.2.3",
"@noble/ciphers": "^1.3.0",
"@noble/hashes": "^1.8.0",
"elliptic": "^6.6.1"
}
}
139 changes: 139 additions & 0 deletions src/nobleEncryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { cbc } from "@noble/ciphers/aes";
import { hmac } from "@noble/hashes/hmac";
import { sha256, sha512 } from "@noble/hashes/sha2";
import { getPublicKey, getSharedSecret, utils } from "@noble/secp256k1";

import { Ecies } from ".";

export interface NobleEcies {
iv: Uint8Array;
ephemPublicKey: Uint8Array;
ciphertext: Uint8Array;
mac: Uint8Array;
}
// Compare two buffers in constant time to prevent timing attacks.
function equalConstTime(b1: Uint8Array, b2: Uint8Array): boolean {
if (b1.length !== b2.length) {
return false;
}
let res = 0;
for (let i = 0; i < b1.length; i++) {
res |= b1[i] ^ b2[i]; // jshint ignore:line

Check warning on line 21 in src/nobleEncryption.ts

View workflow job for this annotation

GitHub Actions / run tests (22.x, ubuntu-latest)

Generic Object Injection Sink

Check warning on line 21 in src/nobleEncryption.ts

View workflow job for this annotation

GitHub Actions / run tests (22.x, ubuntu-latest)

Generic Object Injection Sink

Check warning on line 21 in src/nobleEncryption.ts

View workflow job for this annotation

GitHub Actions / run tests (22.x, ubuntu-latest)

Generic Object Injection Sink

Check warning on line 21 in src/nobleEncryption.ts

View workflow job for this annotation

GitHub Actions / run tests (22.x, ubuntu-latest)

Generic Object Injection Sink
}

return res === 0;
}

export const NobleEciesToEcies = (nobleEcies: NobleEcies): Ecies => {
return {
iv: Buffer.from(nobleEcies.iv),
ciphertext: Buffer.from(nobleEcies.ciphertext),
mac: Buffer.from(nobleEcies.mac),
ephemPublicKey: Buffer.from(nobleEcies.ephemPublicKey),
};
};

export const EciesToNobleEcies = (ecies: Ecies): NobleEcies => {
return {
iv: new Uint8Array(ecies.iv),
ephemPublicKey: new Uint8Array(ecies.ephemPublicKey),
ciphertext: new Uint8Array(ecies.ciphertext),
mac: new Uint8Array(ecies.mac),
};
};

export const hmacSha256Sign = (key: Uint8Array, msg: Uint8Array) => {
const mac = hmac(sha256, key, msg);
return mac;
};

export function hmacSha256Verify(key: Uint8Array, msg: Uint8Array, sig: Uint8Array): boolean {
const expectedSig = hmacSha256Sign(key, msg);
return equalConstTime(expectedSig, sig);
}

export const nobleEncrypt = async function (
publicKeyTo: Uint8Array,
msg: Uint8Array,
opts?: { iv?: Uint8Array; ephemPrivateKey?: Uint8Array }
): Promise<NobleEcies> {
const ephemPrivateKey = opts?.ephemPrivateKey || utils.randomPrivateKey();
const ephemPublicKey = getPublicKey(ephemPrivateKey, false);

const sharedSecret = getSharedSecret(ephemPrivateKey, publicKeyTo);

// need to remove first byte
const sharedSecretSliced = sharedSecret.slice(1);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noble uses wrong shared secret format causing incompatibility

High Severity

The getSharedSecret call returns the full uncompressed point (65 bytes: 0x04 | X | Y), and slice(1) yields 64 bytes (X | Y). However, the existing eccrypto implementation uses only the X coordinate (~32 bytes) from derive(). Hashing 64 bytes vs 32 bytes produces completely different encryption keys and MACs, making the noble implementation incompatible with the existing one. The test expecting convertedEcies to equal encrypted would fail.

Additional Locations (1)

Fix in Cursor Fix in Web


const hash = sha512(sharedSecretSliced);
const key = hash.slice(0, 32);
const macKey = hash.slice(32);

const iv = opts?.iv || utils.randomPrivateKey().slice(0, 16);
const cipher = cbc(key, iv);

const cipherText = cipher.encrypt(msg);

const dataToMac = new Uint8Array(iv.length + ephemPublicKey.length + cipherText.length);
dataToMac.set(iv, 0);
dataToMac.set(ephemPublicKey, iv.length);
dataToMac.set(cipherText, iv.length + ephemPublicKey.length);
const mac = hmacSha256Sign(Buffer.from(macKey), dataToMac);

return {
iv,
ephemPublicKey,
ciphertext: cipherText,
mac,
};
};

export const nobleDecrypt = async function (privateKey: Uint8Array, opts: NobleEcies, padding?: boolean): Promise<Uint8Array> {
const { iv, ephemPublicKey, ciphertext, mac } = opts;
const sharedSecret = getSharedSecret(privateKey, ephemPublicKey);
// need to remove first byte
let sharedSecretSliced = sharedSecret.slice(1);

if (!padding) {
while (sharedSecretSliced.at(0) === 0) {
sharedSecretSliced = sharedSecretSliced.slice(1);
}
}

const hash = sha512(sharedSecretSliced);
const key = hash.slice(0, 32);
const macKey = hash.slice(32);

const dataToMac = new Uint8Array(iv.length + ephemPublicKey.length + ciphertext.length);
dataToMac.set(iv, 0);
dataToMac.set(ephemPublicKey, iv.length);
dataToMac.set(ciphertext, iv.length + ephemPublicKey.length);
const macGood = hmacSha256Verify(macKey, dataToMac, mac);

if (!macGood && !padding) {
return nobleDecrypt(privateKey, opts, true);
} else if (!macGood && padding === true) {
throw new Error("bad MAC after trying padded");
}

const cipher = cbc(key, iv);
const decrypted = cipher.decrypt(ciphertext);

return decrypted;
};

export const encrypt = async function (
publicKeyTo: Buffer,
msg: Buffer,
opts?: { iv?: Buffer; ephemPrivateKey?: Buffer; padding?: boolean }
): Promise<Ecies> {
if (opts?.padding !== undefined) throw new Error("padding opts is not supported");
const nobleEcies = await nobleEncrypt(publicKeyTo, msg, opts);
return NobleEciesToEcies(nobleEcies);
};

export const decrypt = async function (privateKey: Buffer, opts: Ecies, padding?: boolean): Promise<Buffer> {
const nobleEcies = EciesToNobleEcies(opts);
const decrypted = await nobleDecrypt(privateKey, nobleEcies, padding);
return Buffer.from(decrypted);
};
41 changes: 40 additions & 1 deletion test/encrypt_decrypt.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/* eslint-disable import/no-extraneous-dependencies */
import { bytesToUtf8 } from "@noble/ciphers/utils";
import { getPublicKey } from "@noble/secp256k1";
import { beforeEach, describe, expect, it } from "vitest";

import * as eccrypto from "../src/index";
import { decrypt, encrypt, nobleDecrypt, NobleEciesToEcies, nobleEncrypt } from "../src/nobleEncryption";

describe("Functions: encrypt & decrypt", () => {
let ephemPublicKey: Buffer;
Expand All @@ -14,8 +17,9 @@ describe("Functions: encrypt & decrypt", () => {
let publicKey: Buffer;
let publicKeyCompressed: Buffer;

let ephemPrivateKey: Buffer;
beforeEach(() => {
const ephemPrivateKey = Buffer.alloc(32).fill(4);
ephemPrivateKey = Buffer.alloc(32).fill(4);
ephemPublicKey = eccrypto.getPublic(ephemPrivateKey);
iv = Buffer.alloc(16).fill(5);
ciphertext = Buffer.from("bbf3f0e7486b552b0e2ba9c4ca8c4579", "hex");
Expand Down Expand Up @@ -181,4 +185,39 @@ describe("Functions: encrypt & decrypt", () => {
expect(message.toString()).toBe("generated private key");
});
});

describe("ECIES:encrypt -> decrypt withnoble/ciphers", () => {
it("should encrypt and decrypt", async () => {
const message = "random message which is very very long and should be encrypted";
const nobleEcies = await nobleEncrypt(getPublicKey(privateKey), Buffer.from(message), {
iv: iv,
ephemPrivateKey: ephemPrivateKey,
});

const convertedEcies = NobleEciesToEcies(nobleEcies);

const encrypted = await eccrypto.encrypt(publicKey, Buffer.from(message), {
iv: iv,
ephemPrivateKey: ephemPrivateKey,
});

const wrappedEncrypted = await encrypt(publicKey, Buffer.from(message), {
iv: iv,
ephemPrivateKey: ephemPrivateKey,
});

expect(convertedEcies).toEqual(encrypted);

expect(wrappedEncrypted).toEqual(encrypted);

const decrypted = await nobleDecrypt(privateKey, nobleEcies);
expect(bytesToUtf8(decrypted)).toBe(message);

const decrypted1 = await eccrypto.decrypt(privateKey, convertedEcies);
expect(bytesToUtf8(decrypted1)).toBe(message);

const wrappedDecrypted = await decrypt(privateKey, encrypted);
expect(bytesToUtf8(wrappedDecrypted)).toBe(message);
});
});
});
Loading