diff --git a/.github/workflows/on_pull_request.yml b/.github/workflows/on_pull_request.yml new file mode 100644 index 0000000..5b7b26e --- /dev/null +++ b/.github/workflows/on_pull_request.yml @@ -0,0 +1,23 @@ +--- +name: PR commit + +on: + pull_request: + workflow_dispatch: + +jobs: + integration-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: actions/setup-node@v4 + with: + node-version: 23 + - uses: pnpm/action-setup@v4 + with: + version: 10.2.0 + + - name: Integration tests + shell: bash + run: ./tests/integration-test.sh diff --git a/.gitignore b/.gitignore index 52b9d6a..6b97a21 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ + +**/node_modules +**/dist diff --git a/Cargo.lock b/Cargo.lock index 75a6d2f..cdde3fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,56 @@ dependencies = [ "subtle", ] +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.98" @@ -80,6 +130,52 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "const-oid" version = "0.9.6" @@ -163,17 +259,26 @@ dependencies = [ "spki", ] +[[package]] +name = "ecies-encryption-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "ecies-encryption-lib", +] + [[package]] name = "ecies-encryption-lib" version = "0.1.0" dependencies = [ "aes-gcm", - "anyhow", "hex", "hkdf", "k256", "rand_core", "sha2", + "thiserror", ] [[package]] @@ -248,6 +353,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" @@ -281,6 +392,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "k256" version = "0.13.4" @@ -307,6 +424,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -335,6 +458,24 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -399,18 +540,61 @@ dependencies = [ "der", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typenum" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + [[package]] name = "universal-hash" version = "0.5.1" @@ -421,6 +605,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" @@ -433,6 +623,79 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "zeroize" version = "1.8.1" diff --git a/Cargo.toml b/Cargo.toml index f8d6c7f..dfcb134 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,7 @@ -[package] -name = "ecies-encryption-lib" -version = "0.1.0" -edition = "2024" +[workspace] +resolver = "2" -[dependencies] -k256 = { version = "0.13", features = ["ecdsa"] } -hkdf = "0.12" -aes-gcm = { version = "0.10", features = ["std"] } -rand_core = "0.6.4" -sha2 = "0.10" -hex = "0.4" -anyhow = "1.0" +members = ["rust/*"] + +[workspace.dependencies] +ecies-encryption-lib = { path = "rust/lib" } diff --git a/README.md b/README.md index daa87e5..0bc205c 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,57 @@ This repo contains ECIES encryption using `secp256k1` + `AES-GCM` - AES-256-GCM for authenticated encryption - HKDF for key derivation +## 📂 Project Structure + +- `rust/` + - `lib/` — Full keygen + ECIES encrypt/decrypt in Rust + - `cli/` — `clap`-based CLI +- `ts/` + - `lib/` — TypeScript ECIES using `@noble/secp256k1` + WebCrypto + - `cli/` — `commander`-based CLI + --- +## Integration test +We include an integration test for JS <--> Rust compatibility. +``` +./ecies-integration-test.sh +``` ## 🚀 Usage -See https://github.com/Cardinal-Cryptography/ecies-encryption-poc +```bash +git clone https://github.com/Cardinal-Cryptography/ecies-encryption-lib.git +cd ecies-encryption-lib +``` + +Run the rust example +```bash +cargo build +./target/debug/ecies-encryption-cli example +``` + +Or run subcommands like: + +```bash +./target/debug/ecies-encryption-cli generate-keypair +./target/debug/ecies-encryption-cli encrypt --pubkey --message "hello" +./target/debug/ecies-encryption-cli decrypt --privkey --ciphertext +``` + +Run the TypeScript example + +```bash +pnpm install +pnpm build +``` +Then +```bash +pnpm tsx ./ts/cli/index.ts example +pnpm tsx ./ts/cli/index.ts generate-keypair +pnpm tsx ./ts/cli/index.ts encrypt --pubkey --message "hello" +pnpm tsx ./ts/cli/index.ts decrypt --privkey --ciphertext +``` ## WARNING -Using encrypt or decrypt directly does not hide plaintext length which might be a problem in some cases. One needs do further work in order to fix that (add padding carefully). +Using encrypt or decrypt directly does not hide plaintext length which might be a problem in some cases. Use `encrypt-padded` and `decrypt-padded` to change the original plaintext length (add padding carefully). diff --git a/package.json b/package.json new file mode 100644 index 0000000..c8aca9a --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "@cardinal-cryptography/ecies-encryption-lib", + "author": "CardinalCryptography", + "version": "0.1.0", + "description": "ECIES encryption library (proxy to ts/lib)", + "main": "ts/lib/dist/index.js", + "types": "ts/lib/dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Cardinal-Cryptography/ecies-encryption-lib.git", + "directory": "ts/lib" + }, + "files": [ + "ts/lib" + ], + "exports": { + ".": { + "import": "./ts/lib/dist/index.js", + "require": "./ts/lib/dist/index.js", + "types": "./ts/lib/dist/index.d.ts" + } + }, + "scripts": { + "build": "pnpm --filter ./ts/lib... build", + "prepare": "pnpm run build" + }, + "devDependencies": { + "@types/node": "^24.0.4", + "typescript": "^5.8.3", + "tsx": "^4.20.3" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..92e72ff --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,382 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^24.0.4 + version: 24.1.0 + tsx: + specifier: ^4.20.3 + version: 4.20.3 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + + ts/cli: + dependencies: + '@cardinal-cryptography/ecies-encryption-lib': + specifier: workspace:* + version: link:../lib + commander: + specifier: ^14.0.0 + version: 14.0.0 + devDependencies: + '@types/node': + specifier: ^24.0.4 + version: 24.1.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + + ts/lib: + dependencies: + '@noble/secp256k1': + specifier: ^1.7.2 + version: 1.7.2 + devDependencies: + '@types/node': + specifier: ^24.0.4 + version: 24.1.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + +packages: + + '@esbuild/aix-ppc64@0.25.8': + resolution: {integrity: sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.8': + resolution: {integrity: sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.8': + resolution: {integrity: sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.8': + resolution: {integrity: sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.8': + resolution: {integrity: sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.8': + resolution: {integrity: sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.8': + resolution: {integrity: sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.8': + resolution: {integrity: sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.8': + resolution: {integrity: sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.8': + resolution: {integrity: sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.8': + resolution: {integrity: sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.8': + resolution: {integrity: sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.8': + resolution: {integrity: sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.8': + resolution: {integrity: sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.8': + resolution: {integrity: sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.8': + resolution: {integrity: sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.8': + resolution: {integrity: sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.8': + resolution: {integrity: sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.8': + resolution: {integrity: sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.8': + resolution: {integrity: sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.8': + resolution: {integrity: sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.8': + resolution: {integrity: sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.8': + resolution: {integrity: sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.8': + resolution: {integrity: sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.8': + resolution: {integrity: sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.8': + resolution: {integrity: sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@noble/secp256k1@1.7.2': + resolution: {integrity: sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==} + + '@types/node@24.1.0': + resolution: {integrity: sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==} + + commander@14.0.0: + resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} + engines: {node: '>=20'} + + esbuild@0.25.8: + resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.10.1: + resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + tsx@4.20.3: + resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.8.0: + resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + +snapshots: + + '@esbuild/aix-ppc64@0.25.8': + optional: true + + '@esbuild/android-arm64@0.25.8': + optional: true + + '@esbuild/android-arm@0.25.8': + optional: true + + '@esbuild/android-x64@0.25.8': + optional: true + + '@esbuild/darwin-arm64@0.25.8': + optional: true + + '@esbuild/darwin-x64@0.25.8': + optional: true + + '@esbuild/freebsd-arm64@0.25.8': + optional: true + + '@esbuild/freebsd-x64@0.25.8': + optional: true + + '@esbuild/linux-arm64@0.25.8': + optional: true + + '@esbuild/linux-arm@0.25.8': + optional: true + + '@esbuild/linux-ia32@0.25.8': + optional: true + + '@esbuild/linux-loong64@0.25.8': + optional: true + + '@esbuild/linux-mips64el@0.25.8': + optional: true + + '@esbuild/linux-ppc64@0.25.8': + optional: true + + '@esbuild/linux-riscv64@0.25.8': + optional: true + + '@esbuild/linux-s390x@0.25.8': + optional: true + + '@esbuild/linux-x64@0.25.8': + optional: true + + '@esbuild/netbsd-arm64@0.25.8': + optional: true + + '@esbuild/netbsd-x64@0.25.8': + optional: true + + '@esbuild/openbsd-arm64@0.25.8': + optional: true + + '@esbuild/openbsd-x64@0.25.8': + optional: true + + '@esbuild/openharmony-arm64@0.25.8': + optional: true + + '@esbuild/sunos-x64@0.25.8': + optional: true + + '@esbuild/win32-arm64@0.25.8': + optional: true + + '@esbuild/win32-ia32@0.25.8': + optional: true + + '@esbuild/win32-x64@0.25.8': + optional: true + + '@noble/secp256k1@1.7.2': {} + + '@types/node@24.1.0': + dependencies: + undici-types: 7.8.0 + + commander@14.0.0: {} + + esbuild@0.25.8: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.8 + '@esbuild/android-arm': 0.25.8 + '@esbuild/android-arm64': 0.25.8 + '@esbuild/android-x64': 0.25.8 + '@esbuild/darwin-arm64': 0.25.8 + '@esbuild/darwin-x64': 0.25.8 + '@esbuild/freebsd-arm64': 0.25.8 + '@esbuild/freebsd-x64': 0.25.8 + '@esbuild/linux-arm': 0.25.8 + '@esbuild/linux-arm64': 0.25.8 + '@esbuild/linux-ia32': 0.25.8 + '@esbuild/linux-loong64': 0.25.8 + '@esbuild/linux-mips64el': 0.25.8 + '@esbuild/linux-ppc64': 0.25.8 + '@esbuild/linux-riscv64': 0.25.8 + '@esbuild/linux-s390x': 0.25.8 + '@esbuild/linux-x64': 0.25.8 + '@esbuild/netbsd-arm64': 0.25.8 + '@esbuild/netbsd-x64': 0.25.8 + '@esbuild/openbsd-arm64': 0.25.8 + '@esbuild/openbsd-x64': 0.25.8 + '@esbuild/openharmony-arm64': 0.25.8 + '@esbuild/sunos-x64': 0.25.8 + '@esbuild/win32-arm64': 0.25.8 + '@esbuild/win32-ia32': 0.25.8 + '@esbuild/win32-x64': 0.25.8 + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.10.1: + dependencies: + resolve-pkg-maps: 1.0.0 + + resolve-pkg-maps@1.0.0: {} + + tsx@4.20.3: + dependencies: + esbuild: 0.25.8 + get-tsconfig: 4.10.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.8.3: {} + + undici-types@7.8.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..38de221 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "ts/lib" + - "ts/cli" diff --git a/rust/cli/Cargo.toml b/rust/cli/Cargo.toml new file mode 100644 index 0000000..96768c6 --- /dev/null +++ b/rust/cli/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ecies-encryption-cli" +version = "0.1.0" +edition = "2024" +description = "Demo usage of ecies-encryption-lib" + +[dependencies] +anyhow = "1.0" +clap = { version = "4", features = ["derive"] } + +ecies-encryption-lib = { workspace = true } diff --git a/rust/cli/src/main.rs b/rust/cli/src/main.rs new file mode 100644 index 0000000..393f9eb --- /dev/null +++ b/rust/cli/src/main.rs @@ -0,0 +1,160 @@ +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use ecies_encryption_lib::{ + PrivKey, PubKey, decrypt, decrypt_padded_unchecked, encrypt, encrypt_padded, generate_keypair, + utils::{from_hex, to_hex}, +}; + +fn example() -> Result<()> { + let (sk, pk) = generate_keypair(); + let sk_hex = to_hex(&sk.to_bytes()); + let pk_hex = to_hex(&pk.to_bytes()); + + println!("Private key: {} (len: {})", sk_hex, sk_hex.len() / 2); + println!("Public key: {} (len: {})", pk_hex, pk_hex.len() / 2); + + let message = "hello from Rust"; + let ciphertext_bytes = encrypt(message.as_bytes(), &pk)?; + println!( + "Ciphertext hex: {} (len: {})", + to_hex(&ciphertext_bytes), + ciphertext_bytes.len() + ); + println!( + "Diff to plaintext: {}", + ciphertext_bytes.len() - message.len() + ); + + let recovered = decrypt(&ciphertext_bytes, &sk)?; + println!("Decrypted: {}", String::from_utf8(recovered)?); + Ok(()) +} + +/// ECIES CLI: Encrypt, decrypt, and generate keys using secp256k1 +#[derive(Parser)] +#[command(name = "ecies")] +#[command(about = "ECIES encryption tool", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Generate a new secp256k1 keypair + GenerateKeypair, + + /// Encrypt a plaintext message with a public key + Encrypt { + /// Public key (hex) + #[arg(short, long)] + pubkey: String, + + /// Message to encrypt (or file path if --file is passed) + #[arg(short, long)] + message: String, + }, + + /// Decrypt a ciphertext with a private key + Decrypt { + /// Private key (hex) + #[arg(short, long)] + privkey: String, + + /// Ciphertext in hex (or file path if --file is passed) + #[arg(short, long)] + ciphertext: String, + }, + /// Encrypt a plaintext message with a public key and padding + EncryptPadded { + /// Public key (hex) + #[arg(short, long)] + pubkey: String, + + /// Message to encrypt (or file path if --file is passed) + #[arg(short, long)] + message: String, + + #[arg(long)] + /// Padded length of the message. + padded_length: usize, + }, + + /// Decrypt a padded ciphertext with a private key + DecryptPadded { + /// Private key (hex) + #[arg(short, long)] + privkey: String, + + /// Ciphertext in hex (or file path if --file is passed) + #[arg(short, long)] + ciphertext: String, + }, + Example, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::GenerateKeypair => { + let (sk, pk) = generate_keypair(); + println!("Private key: {}", to_hex(&sk.to_bytes())); + println!("Public key: {}", to_hex(&pk.to_bytes())); + } + Commands::Encrypt { pubkey, message } => { + let pubkey_bytes = from_hex(&pubkey)?; + let pubkey = PubKey::from_bytes(&pubkey_bytes).context("Failed to parse public key")?; + + let message_bytes = message.as_bytes().to_vec(); + + let ciphertext = encrypt(&message_bytes, &pubkey)?; + println!("{}", to_hex(&ciphertext)); + } + Commands::Decrypt { + privkey, + ciphertext, + } => { + let privkey_bytes = from_hex(&privkey).context("Invalid private key hex")?; + let privkey = + PrivKey::from_bytes(&privkey_bytes).context("Failed to parse private key")?; + + let ciphertext_bytes = from_hex(&ciphertext).context("Invalid ciphertext hex")?; + + let decrypted = decrypt(&ciphertext_bytes, &privkey).context("Decryption failed")?; + println!("{}", String::from_utf8(decrypted)?); + } + Commands::Example => { + example()?; + } + Commands::EncryptPadded { + pubkey, + message, + padded_length, + } => { + let pubkey_bytes = from_hex(&pubkey)?; + let pubkey = PubKey::from_bytes(&pubkey_bytes).context("Failed to parse public key")?; + + let message_bytes = message.as_bytes().to_vec(); + + let ciphertext = encrypt_padded(&message_bytes, &pubkey, padded_length)?; + println!("{}", to_hex(&ciphertext)); + } + Commands::DecryptPadded { + privkey, + ciphertext, + } => { + let privkey_bytes = from_hex(&privkey).context("Invalid private key hex")?; + let privkey = + PrivKey::from_bytes(&privkey_bytes).context("Failed to parse private key")?; + + let ciphertext_bytes = from_hex(&ciphertext).context("Invalid ciphertext hex")?; + + let decrypted = decrypt_padded_unchecked(&ciphertext_bytes, &privkey) + .context("Decryption failed")?; + println!("{}", String::from_utf8(decrypted)?); + } + } + + Ok(()) +} diff --git a/rust/lib/Cargo.toml b/rust/lib/Cargo.toml new file mode 100644 index 0000000..b7d63b7 --- /dev/null +++ b/rust/lib/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ecies-encryption-lib" +version = "0.1.0" +edition = "2024" + +[dependencies] +k256 = { version = "0.13", features = ["ecdsa"] } +hkdf = "0.12" +aes-gcm = { version = "0.10", features = ["std"] } +rand_core = "0.6.4" +sha2 = "0.10" +hex = "0.4" +thiserror = "2.0.12" diff --git a/rust/lib/src/error.rs b/rust/lib/src/error.rs new file mode 100644 index 0000000..ac022ec --- /dev/null +++ b/rust/lib/src/error.rs @@ -0,0 +1,41 @@ +use hex::FromHexError; +use sha2::digest::crypto_common; +use thiserror::Error; + +/// Result type with the `ecies-encryption-lib` crate's [`Error`] type. +pub type Result = core::result::Result; + +/// ECIES encryption lib errors +#[derive(Error, Debug)] +pub enum Error { + #[error("Eliptic Curve Error: {0}")] + ElipticCurve(#[from] k256::elliptic_curve::Error), + #[error("Invalid Length Error : {0}")] + CryptoInvalidLength(String), + #[error("AES Error : {0}")] + AES(#[from] aes_gcm::Error), + #[error("Failed to decode: {0}")] + Decoding(String), + #[error("Invalid message length (found {found:?} bytes, expected at most {expected:?} bytes)")] + InvalidMessageLength { found: usize, expected: usize }, + #[error("Invalid padded length (found {found:?} bytes, expected at least {expected:?} bytes)")] + InvalidPaddedLength { found: usize, expected: usize }, +} + +impl From for Error { + fn from(error: FromHexError) -> Self { + Error::Decoding(error.to_string()) + } +} + +impl From for Error { + fn from(error: hkdf::InvalidLength) -> Self { + Error::CryptoInvalidLength(error.to_string()) + } +} + +impl From for Error { + fn from(error: crypto_common::InvalidLength) -> Self { + Error::CryptoInvalidLength(error.to_string()) + } +} diff --git a/rust/lib/src/lib.rs b/rust/lib/src/lib.rs new file mode 100644 index 0000000..9f8a108 --- /dev/null +++ b/rust/lib/src/lib.rs @@ -0,0 +1,169 @@ +use aes_gcm::{ + Aes256Gcm, Nonce, + aead::{Aead, KeyInit}, +}; +use hkdf::Hkdf; +use k256::{PublicKey, Scalar, SecretKey, elliptic_curve::sec1::ToEncodedPoint}; +use rand_core::{OsRng, RngCore}; +use sha2::Sha256; + +pub mod error; +pub mod utils; + +use error::{Error, Result}; + +#[derive(Debug, Clone)] +pub struct PubKey { + key: PublicKey, +} + +impl PubKey { + pub fn from_bytes(bytes: &[u8]) -> Result { + let key = PublicKey::from_sec1_bytes(bytes)?; + Ok(PubKey { key }) + } + + pub fn to_bytes(&self) -> Vec { + self.key.to_encoded_point(true).as_bytes().to_vec() + } +} + +#[derive(Clone)] +pub struct PrivKey { + key: SecretKey, +} + +impl PrivKey { + pub fn from_bytes(bytes: &[u8]) -> Result { + let sk = SecretKey::from_slice(bytes)?; + Ok(PrivKey { key: sk }) + } + + pub fn to_bytes(&self) -> Vec { + self.key.to_bytes().to_vec() + } +} + +pub fn generate_keypair() -> (PrivKey, PubKey) { + let sk = SecretKey::random(&mut OsRng); + let pk = sk.public_key(); + let priv_key = PrivKey { key: sk }; + let pub_key = PubKey { key: pk }; + (priv_key, pub_key) +} + +fn derive_shared_secret(sk: &SecretKey, pk: &PublicKey) -> Vec { + let pk_point = *pk.as_affine(); + let sk_scalar: Scalar = sk.as_scalar_primitive().into(); + let shared_point = pk_point * sk_scalar; + let encoded = shared_point.to_encoded_point(true); + encoded.to_bytes().to_vec() +} + +fn hkdf_expand(shared_secret: &[u8]) -> Result<[u8; 32]> { + let hk = Hkdf::::new(None, shared_secret); + let mut okm = [0u8; 32]; + hk.expand(b"ecies-secp256k1-v1", &mut okm)?; + Ok(okm) +} + +pub fn encrypt(message: &[u8], recipient_pub_key: &PubKey) -> Result> { + let recipient_pk = &recipient_pub_key.key; + let eph_sk = SecretKey::random(&mut OsRng); + let eph_pk = eph_sk.public_key(); + + let shared_secret = derive_shared_secret(&eph_sk, recipient_pk); + let aes_key = hkdf_expand(&shared_secret)?; + let cipher = Aes256Gcm::new_from_slice(&aes_key)?; + + let mut iv = [0u8; 12]; + OsRng.fill_bytes(&mut iv); + let nonce = Nonce::from_slice(&iv); + + let ciphertext = cipher.encrypt(nonce, message)?; + + let mut output = vec![]; + output.extend(eph_pk.to_encoded_point(true).as_bytes()); + output.extend(&iv); + output.extend(&ciphertext); + Ok(output) +} + +pub fn decrypt(ciphertext_bytes: &[u8], recipient_priv_key: &PrivKey) -> Result> { + let data = ciphertext_bytes; + let eph_pk = PublicKey::from_sec1_bytes(&data[..33])?; + let iv = &data[33..45]; + let ciphertext = &data[45..]; + let recipient_sk = recipient_priv_key.key.clone(); + + let shared_secret = derive_shared_secret(&recipient_sk, &eph_pk); + let aes_key = hkdf_expand(&shared_secret)?; + let cipher = Aes256Gcm::new_from_slice(&aes_key)?; + + let nonce = Nonce::from_slice(iv); + let decrypted_bytes = cipher.decrypt(nonce, ciphertext)?; + Ok(decrypted_bytes) +} + +pub fn encrypt_padded( + message: &[u8], + recipient_pub_key: &PubKey, + padded_length: usize, +) -> Result> { + if padded_length < message.len() + 4 { + return Err(Error::InvalidPaddedLength { + found: padded_length, + expected: message.len() + 4, + }); + } + // prepend with the message length info in little endian (4 bytes) + let mut padded_message = (message.len() as u32).to_le_bytes().to_vec(); + padded_message.extend(message); + padded_message.resize(padded_length, 0u8); + encrypt(&padded_message, recipient_pub_key) +} + +pub fn decrypt_padded_unchecked( + ciphertext_bytes: &[u8], + recipient_priv_key: &PrivKey, +) -> Result> { + let padded_message = decrypt(ciphertext_bytes, recipient_priv_key)?; + _decode_padded(&padded_message) +} + +pub fn decrypt_padded( + ciphertext_bytes: &[u8], + recipient_priv_key: &PrivKey, + padded_length: usize, +) -> Result> { + let padded_message = decrypt(ciphertext_bytes, recipient_priv_key)?; + if padded_message.len() != padded_length { + return Err(Error::InvalidPaddedLength { + found: padded_message.len(), + expected: padded_length, + }); + } + _decode_padded(&padded_message) +} + +fn _decode_padded(padded_message: &[u8]) -> Result> { + // decode the original message length + let message_length = u32::from_le_bytes( + padded_message + .get(..4) + .ok_or(Error::InvalidPaddedLength { + found: padded_message.len(), + expected: 4, + })? + .try_into() + .map_err(|_| Error::Decoding("Message length".to_string()))?, + ) as usize; + // extract the original message + padded_message + .get(4..(message_length + 4)) + .ok_or(Error::InvalidMessageLength { + found: message_length, + expected: padded_message.len() - 4, + }) + .map(|m| m.to_vec()) +} diff --git a/rust/lib/src/utils.rs b/rust/lib/src/utils.rs new file mode 100644 index 0000000..3a23d7b --- /dev/null +++ b/rust/lib/src/utils.rs @@ -0,0 +1,9 @@ +use crate::error::{Error, Result}; + +pub fn to_hex(data: &[u8]) -> String { + hex::encode(data) +} + +pub fn from_hex(hex_str: &str) -> Result> { + hex::decode(hex_str).map_err(|e| Error::Decoding(e.to_string())) +} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 092f463..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,104 +0,0 @@ -use aes_gcm::{ - aead::{Aead, KeyInit}, - Aes256Gcm, Nonce, -}; -use anyhow::Result; -use hkdf::Hkdf; -use k256::{elliptic_curve::sec1::ToEncodedPoint, PublicKey, Scalar, SecretKey}; -use rand_core::{OsRng, RngCore}; -use sha2::Sha256; - -pub mod utils; - -#[derive(Debug, Clone)] -pub struct PubKey { - key: PublicKey, -} - -impl PubKey { - pub fn from_bytes(bytes: &[u8]) -> Result { - let key = PublicKey::from_sec1_bytes(&bytes)?; - Ok(PubKey { key }) - } - - pub fn to_bytes(&self) -> Vec { - self.key.to_encoded_point(true).as_bytes().to_vec() - } -} - -#[derive(Clone)] -pub struct PrivKey { - key: SecretKey, -} - -impl PrivKey { - pub fn from_bytes(bytes: &[u8]) -> Result { - let sk = SecretKey::from_slice(bytes)?; - Ok(PrivKey { key: sk }) - } - - pub fn to_bytes(&self) -> Vec { - self.key.to_bytes().to_vec() - } -} - -pub fn generate_keypair() -> (PrivKey, PubKey) { - let sk = SecretKey::random(&mut OsRng); - let pk = sk.public_key(); - let priv_key = PrivKey { key: sk }; - let pub_key = PubKey { key: pk }; - (priv_key, pub_key) -} - -fn derive_shared_secret(sk: &SecretKey, pk: &PublicKey) -> Vec { - let pk_point = *pk.as_affine(); - let sk_scalar: Scalar = sk.as_scalar_primitive().into(); - let shared_point = pk_point * sk_scalar; - let encoded = shared_point.to_encoded_point(true); - encoded.to_bytes().to_vec() -} - -fn hkdf_expand(shared_secret: &[u8]) -> [u8; 32] { - let hk = Hkdf::::new(None, shared_secret); - let mut okm = [0u8; 32]; - hk.expand(b"ecies-secp256k1-v1", &mut okm).unwrap(); - okm -} - -pub fn encrypt(message: &[u8], recipient_pub_key: &PubKey) -> Vec { - let recipient_pk = &recipient_pub_key.key; - let eph_sk = SecretKey::random(&mut OsRng); - let eph_pk = eph_sk.public_key(); - - let shared_secret = derive_shared_secret(&eph_sk, &recipient_pk); - let aes_key = hkdf_expand(&shared_secret); - let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); - - let mut iv = [0u8; 12]; - OsRng.fill_bytes(&mut iv); - let nonce = Nonce::from_slice(&iv); - - let ciphertext = cipher.encrypt(nonce, message).unwrap(); - - let mut output = vec![]; - output.extend(eph_pk.to_encoded_point(true).as_bytes()); - output.extend(&iv); - output.extend(&ciphertext); - output -} - -pub fn decrypt(ciphertext_bytes: &[u8], recipient_priv_key: &PrivKey) -> Result> { - let data = ciphertext_bytes; - let eph_pk = PublicKey::from_sec1_bytes(&data[..33]).unwrap(); - let iv = &data[33..45]; - let ciphertext = &data[45..]; - let recipient_sk = recipient_priv_key.key.clone(); - - let shared_secret = derive_shared_secret(&recipient_sk, &eph_pk); - let aes_key = hkdf_expand(&shared_secret); - let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); - - let nonce = Nonce::from_slice(iv); - let decrypted_bytes = cipher.decrypt(nonce, ciphertext); - decrypted_bytes.map_err(|_| anyhow::anyhow!("Decryption failed")) -} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 01ba2fb..0000000 --- a/src/utils.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub fn to_hex(data: &[u8]) -> String { - hex::encode(data) -} - -pub fn from_hex(hex_str: &str) -> anyhow::Result> { - hex::decode(hex_str).map_err(|e| anyhow::anyhow!("Failed to decode hex: {}", e)) -} diff --git a/tests/integration-test.sh b/tests/integration-test.sh new file mode 100755 index 0000000..ae82019 --- /dev/null +++ b/tests/integration-test.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +export NODE_NO_WARNINGS=1 + +RUST="./target/debug/ecies-encryption-cli" # Path to Rust binary +JS="pnpm tsx ./ts/cli/index.ts" # Path to JS CLI +MSG="hello from integration test" # Test message +PADDED_LENGTH=128 # Padded message length + +pnpm install +pnpm build + +cargo build + +echo "=== Scenario 1: JS encrypt -> Rust decrypt ===" + +# Generate keypair in JS +eval "$($JS generate-keypair | tee /dev/stderr | awk ' + /Private key:/ { print "JS_SK=" $3 } + /Public key:/ { print "JS_PK=" $3 } +')" + +# Encrypt in JS +JS_CIPHERTEXT=$($JS encrypt --pubkey "$JS_PK" --message "$MSG") + +# Decrypt in Rust +RUST_OUTPUT=$($RUST decrypt --privkey "$JS_SK" --ciphertext "$JS_CIPHERTEXT") +echo "Rust output: $RUST_OUTPUT" + +if [[ "$RUST_OUTPUT" == "$MSG" ]]; then + echo "✅ JS → Rust decryption success" +else + echo "❌ JS → Rust decryption failed" + echo "Expected: $MSG" + echo "Got: $RUST_OUTPUT" + exit 1 +fi + + +echo "=== Scenario 2: Rust encrypt -> JS decrypt ===" + +# Generate keypair in Rust +eval "$($RUST generate-keypair | tee /dev/stderr | awk ' + /Private key:/ { print "RUST_SK=" $3 } + /Public key:/ { print "RUST_PK=" $3 } +')" + +# Encrypt in Rust +RUST_CIPHERTEXT=$($RUST encrypt --pubkey "$RUST_PK" --message "$MSG" | tail -n1) + +echo "Rust ciphertext: $RUST_CIPHERTEXT" + +# Decrypt in JS +JS_OUTPUT=$($JS decrypt --privkey "$RUST_SK" --ciphertext "$RUST_CIPHERTEXT") + +if [[ "$JS_OUTPUT" == "$MSG" ]]; then + echo "✅ Rust → JS decryption success" +else + echo "❌ Rust → JS decryption failed" + echo "Expected: $MSG" + echo "Got: $JS_OUTPUT" + exit 1 +fi + + +echo "=== Scenario 3: JS encrypt padded -> Rust decrypt padded ===" + +# Generate keypair in JS +eval "$($JS generate-keypair | tee /dev/stderr | awk ' + /Private key:/ { print "JS_SK=" $3 } + /Public key:/ { print "JS_PK=" $3 } +')" + +# Encrypt in JS +JS_CIPHERTEXT=$($JS encrypt-padded --pubkey "$JS_PK" --message "$MSG" --padded-length $PADDED_LENGTH) + +# Decrypt in Rust +RUST_OUTPUT=$($RUST decrypt-padded --privkey "$JS_SK" --ciphertext "$JS_CIPHERTEXT") +echo "Rust output: $RUST_OUTPUT" + +if [[ "$RUST_OUTPUT" == "$MSG" ]]; then + echo "✅ JS → Rust padded decryption success" +else + echo "❌ JS → Rust padded decryption failed" + echo "Expected: $MSG" + echo "Got: $RUST_OUTPUT" + exit 1 +fi + + +echo "=== Scenario 4: Rust encrypt padded -> JS decrypt padded ===" + +# Generate keypair in Rust +eval "$($RUST generate-keypair | tee /dev/stderr | awk ' + /Private key:/ { print "RUST_SK=" $3 } + /Public key:/ { print "RUST_PK=" $3 } +')" + +# Encrypt in Rust +RUST_CIPHERTEXT=$($RUST encrypt-padded --pubkey "$RUST_PK" --message "$MSG" --padded-length $PADDED_LENGTH | tail -n1) + +echo "Rust ciphertext: $RUST_CIPHERTEXT" + +# Decrypt in JS +JS_OUTPUT=$($JS decrypt-padded --privkey "$RUST_SK" --ciphertext "$RUST_CIPHERTEXT") + +if [[ "$JS_OUTPUT" == "$MSG" ]]; then + echo "✅ Rust → JS padded decryption success" +else + echo "❌ Rust → JS padded decryption failed" + echo "Expected: $MSG" + echo "Got: $JS_OUTPUT" + exit 1 +fi + +echo "🎉 All integration tests passed!" diff --git a/ts/cli/index.ts b/ts/cli/index.ts new file mode 100644 index 0000000..2afaf75 --- /dev/null +++ b/ts/cli/index.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env ts-node + +import { Command } from "commander"; +import { + generateKeypair, + toHex, + getCrypto, + encrypt, + decrypt, + encryptPadded, + decryptPaddedUnchecked +} from "@cardinal-cryptography/ecies-encryption-lib"; + +const program = new Command(); +program.name("ecies").description("ECIES encryption tool").version("1.0.0"); + +program + .command("generate-keypair") + .description("Generate a new secp256k1 keypair") + .action(() => { + const { sk, pk } = generateKeypair(); + console.log("Private key:", toHex(sk)); + console.log("Public key: ", toHex(pk)); + }); + +program + .command("encrypt") + .description("Encrypt a plaintext message with a public key") + .requiredOption("-p, --pubkey ", "Recipient public key (hex)") + .requiredOption("-m, --message ", "Plaintext message to encrypt") + .action(async (opts: { message: string; pubkey: string }) => { + const cryptoAPI = await getCrypto(); + const hex = await encrypt(opts.message, opts.pubkey, cryptoAPI); + console.log(hex); + }); + +program + .command("decrypt") + .description("Decrypt a ciphertext with a private key") + .requiredOption("-k, --privkey ", "Private key (hex)") + .requiredOption("-c, --ciphertext ", "Ciphertext (hex)") + .action(async (opts: { privkey: string; ciphertext: string }) => { + const cryptoAPI = await getCrypto(); + const result = await decrypt(opts.ciphertext, opts.privkey, cryptoAPI); + console.log(result); + }); + + program + .command("encrypt-padded") + .description("Encrypt a plaintext message with a public key") + .requiredOption("-p, --pubkey ", "Recipient public key (hex)") + .requiredOption("-m, --message ", "Plaintext message to encrypt") + .requiredOption("--padded-length ", "Padded length of the message") + .action(async (opts: { message: string; pubkey: string; paddedLength: number }) => { + const cryptoAPI = await getCrypto(); + const hex = await encryptPadded(opts.message, opts.pubkey, cryptoAPI, opts.paddedLength); + console.log(hex); + }); + +program + .command("decrypt-padded") + .description("Decrypt a ciphertext with a private key") + .requiredOption("-k, --privkey ", "Private key (hex)") + .requiredOption("-c, --ciphertext ", "Ciphertext (hex)") + .action(async (opts: { privkey: string; ciphertext: string }) => { + const cryptoAPI = await getCrypto(); + const result = await decryptPaddedUnchecked(opts.ciphertext, opts.privkey, cryptoAPI); + console.log(result); + }); + +program + .command("example") + .description("Run the ECIES example") + .action(async () => { + const cryptoAPI = await getCrypto(); + const { sk, pk } = generateKeypair(); + const skHex = toHex(sk); + const pkHex = toHex(pk); + + console.log("Private key:", skHex); + console.log("Public key: ", pkHex); + + const message = "hello from TypeScript"; + const ciphertext = await encrypt(message, pkHex, cryptoAPI); + console.log("Ciphertext:", ciphertext); + + const decrypted = await decrypt(ciphertext, skHex, cryptoAPI); + console.log("Decrypted:", decrypted); + }); + +program.parseAsync(); diff --git a/ts/cli/package.json b/ts/cli/package.json new file mode 100644 index 0000000..828df18 --- /dev/null +++ b/ts/cli/package.json @@ -0,0 +1,19 @@ +{ + "name": "ecies-encryption-cli", + "version": "1.0.0", + "description": "", + "type": "module", + "main": "index.ts", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@cardinal-cryptography/ecies-encryption-lib": "workspace:*", + "commander": "^14.0.0" + }, + "devDependencies": { + "@types/node": "^24.0.4", + "typescript": "^5.8.3" + } +} diff --git a/ts/cli/pnpm-lock.yaml b/ts/cli/pnpm-lock.yaml new file mode 100644 index 0000000..4c0a4a5 --- /dev/null +++ b/ts/cli/pnpm-lock.yaml @@ -0,0 +1,23 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + commander: + specifier: ^14.0.0 + version: 14.0.0 + +packages: + + commander@14.0.0: + resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} + engines: {node: '>=20'} + +snapshots: + + commander@14.0.0: {} diff --git a/ts/cli/tsconfig.json b/ts/cli/tsconfig.json new file mode 100644 index 0000000..9077022 --- /dev/null +++ b/ts/cli/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2022", + "moduleResolution": "node", + "outDir": "dist", + "esModuleInterop": true, + "moduleDetection": "force", + "strict": true, + "skipLibCheck": true + }, + "include": ["index.ts"] +} diff --git a/ts/lib/package.json b/ts/lib/package.json new file mode 100644 index 0000000..0691d9b --- /dev/null +++ b/ts/lib/package.json @@ -0,0 +1,18 @@ +{ + "name": "@cardinal-cryptography/ecies-encryption-lib", + "author": "CardinalCryptography", + "version": "1.0.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@noble/secp256k1": "^1.7.2" + }, + "devDependencies": { + "@types/node": "^24.0.4", + "typescript": "^5.8.3" + } +} diff --git a/ts/lib/pnpm-lock.yaml b/ts/lib/pnpm-lock.yaml new file mode 100644 index 0000000..b074b8a --- /dev/null +++ b/ts/lib/pnpm-lock.yaml @@ -0,0 +1,48 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@noble/secp256k1': + specifier: ^1.7.2 + version: 1.7.2 + devDependencies: + '@types/node': + specifier: ^24.0.4 + version: 24.1.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + +packages: + + '@noble/secp256k1@1.7.2': + resolution: {integrity: sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==} + + '@types/node@24.1.0': + resolution: {integrity: sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.8.0: + resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + +snapshots: + + '@noble/secp256k1@1.7.2': {} + + '@types/node@24.1.0': + dependencies: + undici-types: 7.8.0 + + typescript@5.8.3: {} + + undici-types@7.8.0: {} diff --git a/ts/lib/src/index.ts b/ts/lib/src/index.ts new file mode 100644 index 0000000..63ffa6b --- /dev/null +++ b/ts/lib/src/index.ts @@ -0,0 +1,205 @@ +import * as secp from "@noble/secp256k1"; +import { TextEncoder, TextDecoder } from "util"; + +export function toHex(buf: Uint8Array): string { + return Buffer.from(buf).toString("hex"); +} + +export function fromHex(hex: string): Uint8Array { + return new Uint8Array(Buffer.from(hex, "hex")); +} + +export async function getCrypto(): Promise { + return typeof globalThis.crypto !== "undefined" + ? globalThis.crypto + : ((await import("node:crypto")).webcrypto as Crypto); +} + +export type Keypair = { sk: Uint8Array; pk: Uint8Array }; + +export function generateKeypair(): Keypair { + const sk = secp.utils.randomPrivateKey(); + const pk = secp.getPublicKey(sk, true); + return { sk, pk }; +} + +async function hkdf( + sharedSecret: Uint8Array, + cryptoAPI: Crypto +): Promise { + const keyMaterial = await cryptoAPI.subtle.importKey( + "raw", + sharedSecret as BufferSource, + "HKDF", + false, + ["deriveKey"] + ); + return await cryptoAPI.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array([]), + info: new TextEncoder().encode("ecies-secp256k1-v1") as BufferSource, + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} + +async function _encrypt( + message: Uint8Array, + recipientPubHex: string, + cryptoAPI: Crypto +): Promise { + const recipientPub = secp.Point.fromHex(recipientPubHex); + const ephSk = secp.utils.randomPrivateKey(); + const ephPk = secp.getPublicKey(ephSk, true); + + const ephSkBigInt = BigInt("0x" + toHex(ephSk)); + const shared = recipientPub.multiply(ephSkBigInt).toRawBytes(true); + const aesKey = await hkdf(shared, cryptoAPI); + + const iv = cryptoAPI.getRandomValues(new Uint8Array(12)); + + const ciphertextBuffer = await cryptoAPI.subtle.encrypt( + { name: "AES-GCM", iv }, + aesKey, + message as BufferSource + ); + const ciphertext = new Uint8Array(ciphertextBuffer); + + const out = new Uint8Array(ephPk.length + iv.length + ciphertext.length); + out.set(ephPk); + out.set(iv, ephPk.length); + out.set(ciphertext, ephPk.length + iv.length); + + return out; +} + +async function _decrypt( + ciphertextBytes: Uint8Array, + recipientSkHex: string, + cryptoAPI: Crypto +): Promise { + const ephPk = secp.Point.fromHex(ciphertextBytes.slice(0, 33)); + const iv = ciphertextBytes.slice(33, 45); + const ciphertext = ciphertextBytes.slice(45); + + const skBytes = fromHex(recipientSkHex); + const skBigInt = BigInt("0x" + toHex(skBytes)); + const shared_point = ephPk.multiply(skBigInt); + let shared = shared_point.toRawBytes(true); + const aesKey = await hkdf(shared, cryptoAPI); + + const plaintextBuffer = await cryptoAPI.subtle.decrypt( + { name: "AES-GCM", iv }, + aesKey, + ciphertext as BufferSource + ); + return new Uint8Array(plaintextBuffer); +} + +export async function encrypt( + message: string, + recipientPubHex: string, + cryptoAPI: Crypto +): Promise { + const encoded = new TextEncoder().encode(message); + const out = await _encrypt(encoded, recipientPubHex, cryptoAPI); + return toHex(out); +} + +export async function decrypt( + ciphertextHex: string, + recipientSkHex: string, + cryptoAPI: Crypto +): Promise { + const decrypted = await _decrypt( + fromHex(ciphertextHex), + recipientSkHex, + cryptoAPI + ); + return new TextDecoder().decode(decrypted); +} + +export async function encryptPadded( + message: string, + recipientPubHex: string, + cryptoAPI: Crypto, + paddedLength: number +): Promise { + if (paddedLength < message.length + 4) { + throw new Error( + `Invalid padded length ${paddedLength} bytes, expected at least ${ + message.length + 4 + } bytes)` + ); + } + let encoded = new Uint8Array(paddedLength); + + // prepend with the message length info in little endian (4 bytes) + const buffer = new ArrayBuffer(4); + const view = new DataView(buffer); + view.setUint32(0, message.length, true); + encoded.set(new Uint8Array(buffer), 0); + + encoded.set(new TextEncoder().encode(message), 4); + const encrypted = await _encrypt(encoded, recipientPubHex, cryptoAPI); + return toHex(encrypted); +} + +export async function decryptPadded( + ciphertextHex: string, + recipientSkHex: string, + cryptoAPI: Crypto, + paddedLength: number +): Promise { + const decrypted = await _decrypt( + fromHex(ciphertextHex), + recipientSkHex, + cryptoAPI + ); + if (decrypted.length != paddedLength) { + throw new Error( + `Invalid padded length ${decrypted.length} bytes, expected ${paddedLength} bytes)` + ); + } + return decodePadded(decrypted); +} + +export async function decryptPaddedUnchecked( + ciphertextHex: string, + recipientSkHex: string, + cryptoAPI: Crypto +): Promise { + const decrypted = await _decrypt( + fromHex(ciphertextHex), + recipientSkHex, + cryptoAPI + ); + return await decodePadded(decrypted); +} + +async function decodePadded(paddedMessage: Uint8Array): Promise { + if (paddedMessage.length < 4) { + throw new Error( + `Invalid padded length ${ + paddedMessage.length + } bytes, expected at least ${4} bytes)` + ); + } + const view = new DataView(paddedMessage.buffer); + const messageLength = view.getUint32(0, true); + + if (messageLength > paddedMessage.length - 4) { + throw new Error( + `Invalid message length ${messageLength} bytes, expected at most ${ + paddedMessage.length - 4 + } bytes)` + ); + } + + return new TextDecoder().decode(paddedMessage.subarray(4, messageLength + 4)); +} diff --git a/ts/lib/tsconfig.json b/ts/lib/tsconfig.json new file mode 100644 index 0000000..461a9cc --- /dev/null +++ b/ts/lib/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2022", + "lib": ["ES2020", "DOM"], + "moduleResolution": "node", + "outDir": "dist", + "esModuleInterop": true, + "moduleDetection": "force", + "strict": true, + "skipLibCheck": true, + "allowJs": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": [ + "node_modules", + "**/*.spec.ts" + ] +}