diff --git a/.cspell.jsonc b/.cspell.jsonc index 1164590..02dbe95 100644 --- a/.cspell.jsonc +++ b/.cspell.jsonc @@ -8,12 +8,13 @@ "aes", "aes-256-gcm", "axios", + "chacha", "Codacy", "Codecov", "consts", "ecies", - "eciespy", "eciesjs", + "eciespy", "eth", "futoin-hkdf", "helloworld", @@ -22,7 +23,8 @@ "Npm", "Prv", "querystring", - "secp256k1" + "secp256k1", + "xchacha20" ], // flagWords - list of words to be always considered incorrect // This is useful for offensive words and common spelling errors. diff --git a/CHANGELOG.md b/CHANGELOG.md index 87902af..92a9e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ # Changelog -## 0.4.1 +## 0.4.1 ~ 0.4.2 +- Add XChacha20 as an optional encryption backend - Add configuration for more compatibility - Bump dependencies diff --git a/README.md b/README.md index ef7f9d0..203bcc7 100644 --- a/README.md +++ b/README.md @@ -94,10 +94,13 @@ readonly compressed: Buffer; Ephemeral key format in the payload and shared key in the key derivation can be configured as compressed or uncompressed format. ```ts +export type SymmetricAlgorithm = "aes-256-gcm" | "xchacha20"; +export type NonceLength = 12 | 16; // bytes. Only for aes-256-gcm + class Config { isEphemeralKeyCompressed: boolean = false; isHkdfKeyCompressed: boolean = false; - symmetricAlgorithm: Algorithm = "aes-256-gcm"; // currently we only support aes-256-gcm + symmetricAlgorithm: SymmetricAlgorithm = "aes-256-gcm"; symmetricNonceLength: NonceLength = 16; } @@ -108,10 +111,12 @@ For example, if you set `isEphemeralKeyCompressed = true`, the payload would be If you set `isHkdfKeyCompressed = true`, the hkdf key would be derived from `ephemeral public key (compressed) + shared public key (compressed)` instead of `ephemeral public key (uncompressed) + shared public key (uncompressed)`. -If you set `symmetricNonceLength = 12`, then the nonce of aes-256-gcm would be 12 bytes. +If you set `symmetricAlgorithm = "xchacha20"`, plaintext data will encrypted with XChacha20-Poly1305. + +If you set `symmetricNonceLength = 12`, then the nonce of aes-256-gcm would be 12 bytes. XChacha20-Poly1305's nonce is always 24 bytes. For compatibility, make sure different applications share the same configuration. ## Changelog -See [CHANGELOG.md](./CHANGELOG.md) +See [CHANGELOG.md](./CHANGELOG.md). diff --git a/package.json b/package.json index 21bf22f..958f55c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,11 @@ "email": "to.be.impressive@gmail.com", "url": "https://github.com/kigawas" }, + "repository": { + "type": "git", + "url": "https://github.com/ecies/js.git" + }, + "version": "0.4.2", "engines": { "node": ">=16.0.0" }, @@ -28,12 +33,8 @@ "build": "npx tsc", "test": "jest" }, - "repository": { - "type": "git", - "url": "https://github.com/ecies/js.git" - }, - "version": "0.4.1", "dependencies": { + "@noble/ciphers": "^0.1.4", "@noble/curves": "^1.1.0" }, "devDependencies": { diff --git a/src/config.ts b/src/config.ts index 447217b..5111929 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ import { COMPRESSED_PUBLIC_KEY_SIZE, UNCOMPRESSED_PUBLIC_KEY_SIZE } from "./consts"; -export type SymmetricAlgorithm = "aes-256-gcm"; -export type NonceLength = 12 | 16 | 24; // bytes +export type SymmetricAlgorithm = "aes-256-gcm" | "xchacha20"; +export type NonceLength = 12 | 16; // bytes. Only for aes-256-gcm class Config { isEphemeralKeyCompressed: boolean = false; diff --git a/src/consts.ts b/src/consts.ts index 8c81b98..6ac342c 100644 --- a/src/consts.ts +++ b/src/consts.ts @@ -3,4 +3,5 @@ export const UNCOMPRESSED_PUBLIC_KEY_SIZE = 65; export const ETH_PUBLIC_KEY_SIZE = 64; export const SECRET_KEY_LENGTH = 32; export const ONE = BigInt(1); +export const XCHACHA20_NONCE_LENGTH = 24; export const AEAD_TAG_LENGTH = 16; diff --git a/src/utils/symmetric.ts b/src/utils/symmetric.ts index be94908..3385ab6 100644 --- a/src/utils/symmetric.ts +++ b/src/utils/symmetric.ts @@ -1,23 +1,16 @@ +import { xchacha20_poly1305 as xchacha20 } from "@noble/ciphers/chacha"; +import { Cipher } from "@noble/ciphers/utils"; +import { randomBytes } from "@noble/ciphers/webcrypto/utils"; import { hkdf } from "@noble/hashes/hkdf"; import { sha256 } from "@noble/hashes/sha256"; -import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; +import { createCipheriv, createDecipheriv } from "crypto"; -import { - NonceLength, - SymmetricAlgorithm, - symmetricAlgorithm, - symmetricNonceLength, -} from "../config"; -import { AEAD_TAG_LENGTH } from "../consts"; +import { NonceLength, symmetricAlgorithm, symmetricNonceLength } from "../config"; +import { AEAD_TAG_LENGTH, XCHACHA20_NONCE_LENGTH } from "../consts"; -function _aesEncrypt( - key: Buffer, - plainText: Buffer, - algorithm: SymmetricAlgorithm, - nonceLength: NonceLength -): Buffer { +function _aesEncrypt(key: Buffer, plainText: Buffer, nonceLength: NonceLength): Buffer { const nonce = randomBytes(nonceLength); - const cipher = createCipheriv(algorithm, key, nonce); + const cipher = createCipheriv("aes-256-gcm", key, nonce); const encrypted = Buffer.concat([cipher.update(plainText), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([nonce, tag, encrypted]); @@ -26,24 +19,74 @@ function _aesEncrypt( function _aesDecrypt( key: Buffer, cipherText: Buffer, - algorithm: SymmetricAlgorithm, nonceLength: NonceLength ): Buffer { + const nonceTagLength = nonceLength + AEAD_TAG_LENGTH; const nonce = cipherText.subarray(0, nonceLength); - const tag = cipherText.subarray(nonceLength, nonceLength + AEAD_TAG_LENGTH); - const ciphered = cipherText.subarray(nonceLength + AEAD_TAG_LENGTH); - const decipher = createDecipheriv(algorithm, key, nonce); + const tag = cipherText.subarray(nonceLength, nonceTagLength); + const ciphered = cipherText.subarray(nonceTagLength); + const decipher = createDecipheriv("aes-256-gcm", key, nonce); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(ciphered), decipher.final()]); } +function _encrypt( + func: (key: Uint8Array, nonce: Uint8Array, AAD?: Uint8Array) => Cipher, + key: Buffer, + plainText: Buffer, + nonceLength: number +) { + const nonce = randomBytes(nonceLength); + const cipher = func(key, nonce); + const ciphered = cipher.encrypt(plainText); + const encrypted = ciphered.subarray(0, ciphered.length - AEAD_TAG_LENGTH); + const tag = ciphered.subarray(-AEAD_TAG_LENGTH); + return Buffer.concat([nonce, tag, encrypted]); +} + +function _decrypt( + func: (key: Uint8Array, nonce: Uint8Array, AAD?: Uint8Array) => Cipher, + key: Buffer, + cipherText: Buffer, + nonceLength: number +) { + const nonceTagLength = nonceLength + AEAD_TAG_LENGTH; + const nonce = cipherText.subarray(0, nonceLength); + const tag = cipherText.subarray(nonceLength, nonceTagLength); + const ciphered = cipherText.subarray(nonceTagLength); + + const decipher = func(key, nonce); + const res = new Uint8Array(AEAD_TAG_LENGTH + ciphered.length); + res.set(ciphered); + res.set(tag, ciphered.length); + return Buffer.from(decipher.decrypt(res)); +} + export function aesEncrypt(key: Buffer, plainText: Buffer): Buffer { - return _aesEncrypt(key, plainText, symmetricAlgorithm(), symmetricNonceLength()); + // TODO: Rename to symEncrypt + const algorithm = symmetricAlgorithm(); + if (algorithm === "aes-256-gcm") { + return _aesEncrypt(key, plainText, symmetricNonceLength()); + } else if (algorithm === "xchacha20") { + return _encrypt(xchacha20, key, plainText, XCHACHA20_NONCE_LENGTH); + } else { + throw new Error("Not implemented"); + } } export function aesDecrypt(key: Buffer, cipherText: Buffer): Buffer { - return _aesDecrypt(key, cipherText, symmetricAlgorithm(), symmetricNonceLength()); + // TODO: Rename to symDecrypt + const algorithm = symmetricAlgorithm(); + if (algorithm === "aes-256-gcm") { + return _aesDecrypt(key, cipherText, symmetricNonceLength()); + } else if (algorithm === "xchacha20") { + return _decrypt(xchacha20, key, cipherText, XCHACHA20_NONCE_LENGTH); + } else { + throw new Error("Not implemented"); + } } + export function deriveKey(master: Buffer) { + // 32 bytes shared secret for aes and chacha20 return Buffer.from(hkdf(sha256, master, undefined, undefined, 32)); } diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 63805c1..41c30e8 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -20,7 +20,7 @@ describe("test encrypt and decrypt", () => { pub: prv.publicKey.toHex(), }) ); - const encryptedKnown = Buffer.from(decodeHex(res.data)); + const encryptedKnown = decodeHex(res.data); const decrypted = decrypt(prv.toHex(), encryptedKnown); expect(decrypted.toString()).toEqual(TEXT); diff --git a/tests/keys.test.ts b/tests/keys.test.ts index 77c9712..49bede7 100644 --- a/tests/keys.test.ts +++ b/tests/keys.test.ts @@ -7,32 +7,35 @@ const PUB_HEX = "0x98afe4f150642cd05cc9d2fa36458ce0a58567daeaf5fde7333ba9b403011140" + "a4e28911fcf83ab1f457a30b4959efc4b9306f514a4c3711a16a80e3b47eb58b"; -function checkHkdf(k1: PrivateKey, k2: PrivateKey, knownHex: string) { - const derived1 = k1.encapsulate(k2.publicKey); - const derived2 = k1.publicKey.decapsulate(k2); - const knownDerived = Buffer.from(decodeHex(knownHex)); - expect(derived1.equals(knownDerived)).toBe(true); - expect(derived2.equals(knownDerived)).toBe(true); -} - const two = Buffer.from(new Uint8Array(32)); two[31] = 2; const three = Buffer.from(new Uint8Array(32)); three[31] = 3; describe("test keys", () => { + function checkHkdf(k1: PrivateKey, k2: PrivateKey, knownHex: string) { + const derived1 = k1.encapsulate(k2.publicKey); + const derived2 = k1.publicKey.decapsulate(k2); + + const knownDerived = decodeHex(knownHex); + expect(derived1).toEqual(knownDerived); + expect(derived2).toEqual(knownDerived); + } + it("tests invalid", () => { // 0 < private key < group order int + const ERROR = "Invalid private key"; const groupOrderInt = "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"; - expect(() => PrivateKey.fromHex(groupOrderInt)).toThrow(Error); + expect(() => PrivateKey.fromHex(groupOrderInt)).toThrow(ERROR); const groupOrderIntAdd1 = "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364142"; - expect(() => PrivateKey.fromHex(groupOrderIntAdd1)).toThrow(Error); + expect(() => PrivateKey.fromHex(groupOrderIntAdd1)).toThrow(ERROR); - expect(() => PrivateKey.fromHex("0")).toThrow(Error); + expect(() => PrivateKey.fromHex("0")).toThrow(ERROR); + expect(() => new PrivateKey(decodeHex("0"))).toThrow(ERROR); }); it("tests equal", () => { @@ -49,7 +52,7 @@ describe("test keys", () => { it("tests eth key compatibility", () => { const ethPrv = PrivateKey.fromHex(PRV_HEX); const ethPub = PublicKey.fromHex(PUB_HEX); - expect(ethPub.equals(ethPrv.publicKey)).toBe(true); + expect(ethPub).toEqual(ethPrv.publicKey); expect(ethPub.compressed.toString("hex")).toEqual( "0398afe4f150642cd05cc9d2fa36458ce0a58567daeaf5fde7333ba9b403011140" ); @@ -62,7 +65,7 @@ describe("test keys", () => { const k1 = new PrivateKey(two); const k2 = new PrivateKey(three); - expect(k1.multiply(k2.publicKey).equals(k2.multiply(k1.publicKey))).toBe(true); + expect(k1.multiply(k2.publicKey)).toEqual(k2.multiply(k1.publicKey)); checkHkdf( k1, diff --git a/tests/utils.test.ts b/tests/utils.test.ts index e779d9b..fa78e88 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -5,6 +5,7 @@ import { aesDecrypt, aesEncrypt, decodeHex, + deriveKey, getValidSecret, isValidPrivateKey, remove0x, @@ -34,19 +35,32 @@ describe("test elliptic utils", () => { }); describe("test symmetric utils", () => { - it("tests aes with random key", () => { + function testRandomKey() { const key = randomBytes(32); const data = Buffer.from(TEXT); expect(data).toEqual(aesDecrypt(key, aesEncrypt(key, data))); + } + + it("tests hkdf with know key", () => { + const knownKey = decodeHex( + "0x8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d" + ); + expect(knownKey).toEqual( + deriveKey(decodeHex("0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")) + ); + }); + + it("tests aes with random key", () => { + testRandomKey(); }); it("tests aes decrypt with known key", () => { - const key = Buffer.from( - decodeHex("0000000000000000000000000000000000000000000000000000000000000000") + const key = decodeHex( + "0000000000000000000000000000000000000000000000000000000000000000" ); - const nonce = Buffer.from(decodeHex("0xf3e1ba810d2c8900b11312b7c725565f")); - const tag = Buffer.from(decodeHex("0Xec3b71e17c11dbe31484da9450edcf6c")); - const encrypted = Buffer.from(decodeHex("02d2ffed93b856f148b9")); + const nonce = decodeHex("0xf3e1ba810d2c8900b11312b7c725565f"); + const tag = decodeHex("0Xec3b71e17c11dbe31484da9450edcf6c"); + const encrypted = decodeHex("02d2ffed93b856f148b9"); const data = Buffer.concat([nonce, tag, encrypted]); const decrypted = aesDecrypt(key, data); @@ -56,10 +70,45 @@ describe("test symmetric utils", () => { it("tests aes nonce length config", () => { ECIES_CONFIG.symmetricNonceLength = 12; - const key = randomBytes(32); - const data = Buffer.from(TEXT); - expect(data.equals(aesDecrypt(key, aesEncrypt(key, data)))).toBe(true); + testRandomKey(); ECIES_CONFIG.symmetricNonceLength = 16; }); + + it("tests xchacha20 decrypt with known key", () => { + ECIES_CONFIG.symmetricAlgorithm = "xchacha20"; + + const key = decodeHex( + "27bd6ec46292a3b421cdaf8a3f0ca759cbc67bcbe7c5855aa0d1e0700fd0e828" + ); + const nonce = decodeHex("0xfbd5dd10431af533c403d6f4fa629931e5f31872d2f7e7b6"); + const tag = decodeHex("0X5b5ccc27324af03b7ca92dd067ad6eb5"); + const encrypted = decodeHex("aa0664f3c00a09d098bf"); + const data = Buffer.concat([nonce, tag, encrypted]); + + const decrypted = aesDecrypt(key, data); + expect(decrypted.toString()).toBe("helloworld"); + + ECIES_CONFIG.symmetricAlgorithm = "aes-256-gcm"; + }); + + it("tests xchacha20 with random key", () => { + ECIES_CONFIG.symmetricAlgorithm = "xchacha20"; + + testRandomKey(); + + ECIES_CONFIG.symmetricAlgorithm = "aes-256-gcm"; + }); + + it("tests not implemented", () => { + ECIES_CONFIG.symmetricAlgorithm = "" as any; + + expect(testRandomKey).toThrow("Not implemented"); + + expect(() => aesDecrypt(randomBytes(32), decodeHex("01010e0e"))).toThrow( + "Not implemented" + ); + + ECIES_CONFIG.symmetricAlgorithm = "aes-256-gcm"; + }); }); diff --git a/yarn.lock b/yarn.lock index 4fa6e9a..d754109 100644 --- a/yarn.lock +++ b/yarn.lock @@ -561,6 +561,11 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@noble/ciphers@^0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.1.4.tgz#96327dca147829ed9eee0d96cfdf7c57915765f0" + integrity sha512-d3ZR8vGSpy3v/nllS+bD/OMN5UZqusWiQqkyj7AwzTnhXFH72pF5oB4Ach6DQ50g5kXxC28LdaYBEpsyv9KOUQ== + "@noble/curves@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d" @@ -906,9 +911,9 @@ camelcase@^6.2.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-lite@^1.0.30001503: - version "1.0.30001516" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001516.tgz#621b1be7d85a8843ee7d210fd9d87b52e3daab3a" - integrity sha512-Wmec9pCBY8CWbmI4HsjBeQLqDTqV91nFVR83DnZpYyRnPI1wePDsTg0bGLPC5VU/3OIZV1fmxEea1b+tFKe86g== + version "1.0.30001517" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz#90fabae294215c3495807eb24fc809e11dc2f0a8" + integrity sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA== chalk@^2.0.0: version "2.4.2" @@ -1059,9 +1064,9 @@ diff@^4.0.1: integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== electron-to-chromium@^1.4.431: - version "1.4.461" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.461.tgz#6b14af66042732bf883ab63a4d82cac8f35eb252" - integrity sha512-1JkvV2sgEGTDXjdsaQCeSwYYuhLRphRpc+g6EHTFELJXEiznLt3/0pZ9JuAOQ5p2rI3YxKTbivtvajirIfhrEQ== + version "1.4.464" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.464.tgz#2f94bad78dff34e527aacbfc5d0b1a33cf046507" + integrity sha512-guZ84yoou4+ILNdj0XEbmGs6DEWj6zpVOWYpY09GU66yEb0DSYvP/biBPzHn0GuW/3RC/pnaYNUWlQE1fJYtgA== emittery@^0.13.1: version "0.13.1"