From 1b2756fc440f1d2bd1e1eb235b8089a33e197059 Mon Sep 17 00:00:00 2001 From: Weiliang Li Date: Fri, 17 Oct 2025 06:39:07 +0900 Subject: [PATCH] Init basic structure --- .cspell.jsonc | 17 +++++ .github/workflows/cd.yml | 29 ++++++++ .github/workflows/ci.yml | 48 ++++++++++++++ .gitignore | 58 ++++++++++++++++ CHANGELOG.md | 5 ++ Gemfile | 12 ++++ Gemfile.lock | 131 ++++++++++++++++++++++++++++++++++++ README.md | 102 ++++++++++++++++++++++++++++ Rakefile | 10 +++ eciesrb.gemspec | 14 ++++ examples/config.rb | 7 ++ examples/quickstart.rb | 14 ++++ lib/ecies.rb | 44 +++++++++++++ lib/ecies/config.rb | 20 ++++++ lib/ecies/elliptic.rb | 49 ++++++++++++++ lib/ecies/hash.rb | 15 +++++ lib/ecies/hex.rb | 22 +++++++ lib/ecies/symmetric.rb | 75 +++++++++++++++++++++ tests/test_ecies.rb | 99 ++++++++++++++++++++++++++++ tests/test_elliptic.rb | 35 ++++++++++ tests/test_hash.rb | 18 +++++ tests/test_symmetric.rb | 139 +++++++++++++++++++++++++++++++++++++++ 22 files changed, 963 insertions(+) create mode 100644 .cspell.jsonc create mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 README.md create mode 100644 Rakefile create mode 100644 eciesrb.gemspec create mode 100644 examples/config.rb create mode 100644 examples/quickstart.rb create mode 100644 lib/ecies.rb create mode 100644 lib/ecies/config.rb create mode 100644 lib/ecies/elliptic.rb create mode 100644 lib/ecies/hash.rb create mode 100644 lib/ecies/hex.rb create mode 100644 lib/ecies/symmetric.rb create mode 100644 tests/test_ecies.rb create mode 100644 tests/test_elliptic.rb create mode 100644 tests/test_hash.rb create mode 100644 tests/test_symmetric.rb diff --git a/.cspell.jsonc b/.cspell.jsonc new file mode 100644 index 0000000..060387a --- /dev/null +++ b/.cspell.jsonc @@ -0,0 +1,17 @@ +{ + "words": [ + "decapsulation", + "ecies", + "eciesrb", + "HKDF", + "libsecp256k1", + "privkey", + "rubygems", + "secp256k1" + ], + "ignorePaths": [ + ".gitignore", + "LICENSE", + "Gemfile.lock" + ] +} diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..c6ed7ae --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,29 @@ +name: CD + +on: + release: + types: [published] + +jobs: + publish: + name: Push gem to RubyGems.org + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + + - name: Install dependencies + run: bundle install + + - name: Build gem + run: gem build --output=release.gem + + - name: Publish gem + run: gem push release.gem + env: + GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..96efe7c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + ruby-version: ['3.2', '3.3', '3.4'] + + steps: + - uses: actions/checkout@v5 + with: + submodules: true + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - name: Install dependencies on macOS + if: runner.os == 'macOS' + run: brew install automake libtool secp256k1 openssl + + - name: Install dependencies on Ubuntu + if: runner.os == 'Linux' + run: sudo apt install -y libsecp256k1-dev gcc + + - run: bundle install + + - name: Run tests + run: C_INCLUDE_PATH=/opt/homebrew/opt/secp256k1/include bundle exec rake test + + - name: Run examples + run: | + export C_INCLUDE_PATH=/opt/homebrew/opt/secp256k1/include + bundle exec ruby examples/config.rb + bundle exec ruby examples/quickstart.rb + + - name: Build gem + run: gem build --output=release.gem diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ad226e --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +*.gem +*.rbc +/.config +/coverage/ +/InstalledFiles +/pkg/ +/spec/reports/ +/spec/examples.txt +/test/tmp/ +/test/version_tmp/ +/tmp/ + +# Used by dotenv library to load environment variables. +# .env + +# Ignore Byebug command history file. +.byebug_history + +## Specific to RubyMotion: +.dat* +.repl_history +build/ +*.bridgesupport +build-iPhoneOS/ +build-iPhoneSimulator/ + +## Specific to RubyMotion (use of CocoaPods): +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# vendor/Pods/ + +## Documentation cache and generated files: +/.yardoc/ +/_yardoc/ +/doc/ +/rdoc/ + +## Environment normalization: +/.bundle/ +/vendor/bundle +/lib/bundler/man/ + +# for a library or gem, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# Gemfile.lock +# .ruby-version +# .ruby-gemset + +# unless supporting rvm < 1.11.0 or doing something fancy, ignore this: +.rvmrc + +# Used by RuboCop. Remote config files pulled in from inherit_from directive. +# .rubocop-https?--* + +release/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e9e7508 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.0.1 + +- First alpha release diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..a8c707f --- /dev/null +++ b/Gemfile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gemspec + +gem "standard", group: :development +gem "sorbet", group: :development +gem "sorbet-runtime" +gem "tapioca", require: false, group: [:development, :test] +gem "rake", group: :development +gem "minitest", group: :test diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..e61c2ff --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,131 @@ +PATH + remote: . + specs: + eciesrb (0.0.1) + libsecp256k1 (~> 0.6.1) + openssl (~> 3.3) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + benchmark (0.5.0) + erubi (1.13.1) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) + json (2.15.1) + language_server-protocol (3.17.0.5) + libsecp256k1 (0.6.1) + ffi (~> 1.17) + lint_roller (1.1.0) + logger (1.7.0) + minitest (5.26.0) + netrc (0.11.0) + openssl (3.3.1) + parallel (1.27.0) + parser (3.3.9.0) + ast (~> 2.4.1) + racc + prism (1.6.0) + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.0) + rbi (0.3.7) + prism (~> 1.0) + rbs (>= 3.4.4) + rbs (3.9.5) + logger + regexp_parser (2.11.3) + rexml (3.4.4) + rubocop (1.80.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.46.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.47.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.25.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + ruby-progressbar (1.13.0) + sorbet (0.6.12651) + sorbet-static (= 0.6.12651) + sorbet-runtime (0.6.12651) + sorbet-static (0.6.12651-aarch64-linux) + sorbet-static (0.6.12651-universal-darwin) + sorbet-static (0.6.12651-x86_64-linux) + sorbet-static-and-runtime (0.6.12651) + sorbet (= 0.6.12651) + sorbet-runtime (= 0.6.12651) + spoom (1.6.3) + erubi (>= 1.10.0) + prism (>= 0.28.0) + rbi (>= 0.3.3) + rexml (>= 3.2.6) + sorbet-static-and-runtime (>= 0.5.10187) + thor (>= 0.19.2) + standard (1.51.1) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.80.2) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.8.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.25.0) + tapioca (0.16.11) + benchmark + bundler (>= 2.2.25) + netrc (>= 0.11.0) + parallel (>= 1.21.0) + rbi (~> 0.2) + sorbet-static-and-runtime (>= 0.5.11087) + spoom (>= 1.2.0) + thor (>= 1.2.0) + yard-sorbet + thor (1.4.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + yard (0.9.37) + yard-sorbet (0.9.0) + sorbet-runtime + yard + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm64-darwin + universal-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + eciesrb! + minitest + rake + sorbet + sorbet-runtime + standard + tapioca + +BUNDLED WITH + 2.7.2 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e05cca --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# eciesrb + +[![Gem Version](https://badge.fury.io/rb/eciesrb.svg)](https://badge.fury.io/rb/eciesrb) +[![License](https://img.shields.io/github/license/ecies/rb.svg)](https://github.com/ecies/rb) +[![CI](https://img.shields.io/github/actions/workflow/status/ecies/rb/ci.yml)](https://github.com/ecies/rb/actions) + +Elliptic Curve Integrated Encryption Scheme for secp256k1 in Ruby. + +This is a Ruby port of [eciespy](https://github.com/ecies/py). + +## Prerequisite + +Make sure you have secp256k1 and openssl installed, if not: + +```bash +brew install secp256k1 openssl +``` + +Then set environment variables: + +```bash +export C_INCLUDE_PATH=$(brew --prefix secp256k1)/include +``` + +## Install + +```bash +gem install eciesrb +``` + +## Quick Start + +```ruby +# examples/quickstart.rb +require "ecies" + +# Generate a secret key +sk = Ecies.generate_key +raw_sk = Ecies.decode_hex(sk.send(:serialize)) +raw_pk = sk.pubkey.serialize(compressed: false) + +# Encrypt data with the receiver's public key +plaintext = "Hello, World!" +encrypted = Ecies.encrypt(raw_pk, plaintext) + +# Decrypt data with the receiver's secret key +decrypted = Ecies.decrypt(raw_sk, encrypted) +puts decrypted # => "Hello, World!" +``` + +## Sponsors + +dotenvx + +## Configuration + +You can customize the encryption behavior using a `Config` object: + +```ruby +# examples/config.rb +require "ecies" + +Ecies::DEFAULT_CONFIG.is_ephemeral_key_compressed = true # Use compressed ephemeral public key +Ecies::DEFAULT_CONFIG.is_hkdf_key_compressed = true # Use compressed key for HKDF +Ecies::DEFAULT_CONFIG.symmetric_nonce_length = 16 # Nonce length for AES-GCM (default: 16) +``` + +### Configuration Parameters + +- `is_ephemeral_key_compressed` (Boolean): Whether to use compressed format for the ephemeral public key. Default: `false` +- `is_hkdf_key_compressed` (Boolean): Whether to use compressed format for HKDF key derivation. Default: `false` +- `symmetric_nonce_length` (Integer): The nonce length for AES-GCM encryption. Options: `12`, `16`. Default: `16` + +## API Reference + +### `encrypt(receiver_pk, data, config = DEFAULT_CONFIG)` + +Encrypts data using the receiver's public key. + +**Parameters:** + +- `receiver_pk` (String): The receiver's public key (raw bytes, serialized) +- `data` (String): The plaintext data to encrypt (raw bytes) +- `config` (Ecies::Config): Optional configuration object + +**Returns:** (String) The encrypted data (ephemeral public key + encrypted data) + +### `decrypt(receiver_sk, data, config = DEFAULT_CONFIG)` + +Decrypts data using the receiver's secret key. + +**Parameters:** + +- `receiver_sk` (String): The receiver's secret key (raw bytes, serialized) +- `data` (String): The encrypted data (ephemeral public key + encrypted data) +- `config` (Ecies::Config): Optional configuration object + +**Returns:** (String) The decrypted plaintext data (raw bytes) + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..b9d65ff --- /dev/null +++ b/Rakefile @@ -0,0 +1,10 @@ +require "bundler/gem_tasks" +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "tests" + t.libs << "lib" + t.test_files = FileList["tests/**/test_*.rb"] +end + +task default: :test diff --git a/eciesrb.gemspec b/eciesrb.gemspec new file mode 100644 index 0000000..5761e90 --- /dev/null +++ b/eciesrb.gemspec @@ -0,0 +1,14 @@ +Gem::Specification.new do |s| + s.name = "eciesrb" + s.version = "0.0.1" + s.summary = "Elliptic Curve Integrated Encryption Scheme for secp256k1 in Ruby" + s.description = "Elliptic Curve Integrated Encryption Scheme for secp256k1 in Ruby, based on libsecp256k1 and OpenSSL." + s.authors = ["Weiliang Li"] + s.email = "to.be.impressive@gmail.com" + s.files = Dir["lib/**/**.rb"] + ["eciesrb.gemspec", "README.md", "LICENSE", "CHANGELOG.md"] + s.homepage = "https://github.com/ecies/rb" + s.license = "MIT" + s.add_dependency "libsecp256k1", "~> 0.6.1" + s.add_dependency "openssl", "~> 3.3" + s.required_ruby_version = ">= 3.2" +end diff --git a/examples/config.rb b/examples/config.rb new file mode 100644 index 0000000..42a7288 --- /dev/null +++ b/examples/config.rb @@ -0,0 +1,7 @@ +require "ecies" + +Ecies::DEFAULT_CONFIG.is_ephemeral_key_compressed = true # Use compressed ephemeral public key +Ecies::DEFAULT_CONFIG.is_hkdf_key_compressed = true # Use compressed key for HKDF +Ecies::DEFAULT_CONFIG.symmetric_nonce_length = 16 # Nonce length for AES-GCM (default: 16) + +require_relative "quickstart" diff --git a/examples/quickstart.rb b/examples/quickstart.rb new file mode 100644 index 0000000..97590d5 --- /dev/null +++ b/examples/quickstart.rb @@ -0,0 +1,14 @@ +require "ecies" + +# Generate a secret key +sk = Ecies.generate_key +raw_sk = Ecies.decode_hex(sk.send(:serialize)) +raw_pk = sk.pubkey.serialize(compressed: false) + +# Encrypt data with the receiver's public key +plaintext = "Hello, World🌍!" +encrypted = Ecies.encrypt(raw_pk, plaintext) + +# Decrypt data with the receiver's secret key +decrypted = Ecies.decrypt(raw_sk, encrypted) +puts decrypted # => "Hello, World🌍!" diff --git a/lib/ecies.rb b/lib/ecies.rb new file mode 100644 index 0000000..836018b --- /dev/null +++ b/lib/ecies.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "ecies/config" +require "ecies/hex" +require "ecies/hash" +require "ecies/elliptic" +require "ecies/symmetric" + +module Ecies + COMPRESSED_PUBLIC_KEY_SIZE = 33 + UNCOMPRESSED_PUBLIC_KEY_SIZE = 65 + + # Encrypt with receiver's public key + # + # @param [String] receiver_pk The receiver's public key (serialized, raw bytes). + # @param [String] data The plaintext data to encrypt (raw bytes). + # @param [Ecies::Config] config The configuration object (optional). + # @return [String] The encrypted data (ephemeral public key + encrypted data). + def encrypt(receiver_pk, data, config = DEFAULT_CONFIG) + ephemeral_sk = generate_key + raw_ephemeral_sk = decode_hex(ephemeral_sk.send(:serialize)) + ephemeral_pk = ephemeral_sk.pubkey.serialize(compressed: config.is_ephemeral_key_compressed) + sym_key = encapsulate(raw_ephemeral_sk, receiver_pk, config.is_hkdf_key_compressed) + encrypted = sym_encrypt(:"aes-256-gcm", sym_key, data, config.symmetric_nonce_length) + ephemeral_pk + encrypted + end + + # Decrypt with receiver's secret key + # + # @param [String] receiver_sk The receiver's secret key (serialized, raw bytes). + # @param [String] data The encrypted data (ephemeral public key + encrypted data). + # @param [Ecies::Config] config The configuration object (optional). + # @return [String] The decrypted plaintext data (raw bytes). + def decrypt(receiver_sk, data, config = DEFAULT_CONFIG) + pk_size = config.is_ephemeral_key_compressed ? + COMPRESSED_PUBLIC_KEY_SIZE : UNCOMPRESSED_PUBLIC_KEY_SIZE + ephemeral_pk = data[0, pk_size] + encrypted = data[pk_size..] + sym_key = decapsulate(ephemeral_pk, receiver_sk, config.is_hkdf_key_compressed) + sym_decrypt(:"aes-256-gcm", sym_key, encrypted, config.symmetric_nonce_length) + end + + module_function :encrypt, :decrypt +end diff --git a/lib/ecies/config.rb b/lib/ecies/config.rb new file mode 100644 index 0000000..8f936f4 --- /dev/null +++ b/lib/ecies/config.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ecies + # Configuration class for ECIES settings. + class Config + attr_accessor :is_ephemeral_key_compressed, :is_hkdf_key_compressed, :symmetric_nonce_length + + def initialize( + is_ephemeral_key_compressed: false, + is_hkdf_key_compressed: false, + symmetric_nonce_length: 16 + ) + @is_ephemeral_key_compressed = is_ephemeral_key_compressed + @is_hkdf_key_compressed = is_hkdf_key_compressed + @symmetric_nonce_length = symmetric_nonce_length + end + end + + DEFAULT_CONFIG = Config.new +end diff --git a/lib/ecies/elliptic.rb b/lib/ecies/elliptic.rb new file mode 100644 index 0000000..bec4524 --- /dev/null +++ b/lib/ecies/elliptic.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "secp256k1" +require_relative "symmetric" +require_relative "hash" + +module Ecies + # Generates a new random Secp256k1 private key. + # @return [Secp256k1::PrivateKey] The generated private key. + def generate_key + loop do + sk = Secp256k1::PrivateKey.new + return sk + rescue ArgumentError + end + end + + # Performs elliptic key encapsulation. + # + # @param private_key [String] Private key bytes + # @param peer_public_key [String] Peer's public key bytes + # @param is_compressed [Boolean] Whether to use compressed format (default: false) + # + # @return [String] HKDF-SHA256 derived 32-byte key + def encapsulate(private_key, peer_public_key, is_compressed = false) + sk = Secp256k1::PrivateKey.new(privkey: private_key, raw: true) + peer_pk = Secp256k1::PublicKey.new(pubkey: peer_public_key, raw: true) + shared_point = peer_pk.tweak_mul(private_key) + + master = sk.pubkey.serialize(compressed: is_compressed) + + shared_point.serialize(compressed: is_compressed) + derive_key(master) + end + + # Performs elliptic key decapsulation. + # @param public_key [String] Public key bytes + # @param peer_private_key [String] Peer's private key bytes + # @param is_compressed [Boolean] Whether to use compressed format (default: false) + # @return [String] HKDF-SHA256 derived 32-byte key + def decapsulate(public_key, peer_private_key, is_compressed = false) + pk = Secp256k1::PublicKey.new(pubkey: public_key, raw: true) + shared_point = pk.tweak_mul(peer_private_key) + master = pk.serialize(compressed: is_compressed) + + shared_point.serialize(compressed: is_compressed) + derive_key(master) + end + + module_function :generate_key, :encapsulate, :decapsulate +end diff --git a/lib/ecies/hash.rb b/lib/ecies/hash.rb new file mode 100644 index 0000000..c45e4ac --- /dev/null +++ b/lib/ecies/hash.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "openssl" + +module Ecies + # Derives a 32-byte key from the given master key using HKDF with SHA256. + # + # @param master [String] The input master key (binary string). + # @return [String] The derived 32-byte key (binary string). + def derive_key(master) + OpenSSL::KDF.hkdf(master, salt: "", info: "", length: 32, hash: "SHA256") + end + + module_function :derive_key +end diff --git a/lib/ecies/hex.rb b/lib/ecies/hex.rb new file mode 100644 index 0000000..eaf11d9 --- /dev/null +++ b/lib/ecies/hex.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Ecies + # Decodes a hex string (with optional "0x" prefix) into raw bytes. + # @param [String] str The hex string to decode. + # return [String] The decoded raw bytes. + def decode_hex(str) + if str.start_with?("0x", "0X") + str = str[2..] + end + [str].pack("H*") + end + + # Encodes raw bytes into a hex string without "0x" prefix. + # @param [String] bytes The raw bytes to encode. + # @return [String] The encoded hex string without "0x" prefix. + def encode_hex(bytes) + bytes.unpack1("H*") + end + + module_function :decode_hex, :encode_hex +end diff --git a/lib/ecies/symmetric.rb b/lib/ecies/symmetric.rb new file mode 100644 index 0000000..82ff7c0 --- /dev/null +++ b/lib/ecies/symmetric.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "openssl" + +module Ecies + AEAD_TAG_LENGTH = 16 + + # Encrypts plain text using symmetric AES-256-GCM encryption. + # + # @param algorithm [Symbol] The encryption algorithm to use (must be :aes-256-gcm) + # @param key [String] The encryption key (must be 32 bytes for AES-256) + # @param plain_text [String] The data to encrypt + # @param nonce_length [Integer] The length of the nonce/IV to generate + # @param aad [String] Additional authenticated data (optional, defaults to empty string) + # + # @return [String] The encrypted data formatted as: nonce + auth_tag + cipher_text + # + # @raise [ArgumentError] If the algorithm is not :aes-256-gcm + # + # @example + # key = OpenSSL::Random.random_bytes(32) + # encrypted = Ecies.sym_encrypt(:"aes-256-gcm", key, "Hello World", 12) + def sym_encrypt(algorithm, key, plain_text, nonce_length, aad = "") + if algorithm != :"aes-256-gcm" + raise ArgumentError, "Unsupported algorithm: #{algorithm}" + end + + nonce = OpenSSL::Random.random_bytes(nonce_length) + cipher = OpenSSL::Cipher.new(algorithm.to_s).encrypt + cipher.key = key + cipher.iv_len = nonce_length + cipher.iv = nonce + cipher.auth_data = aad + + cipher_text = cipher.update(plain_text) + cipher.final + tag = cipher.auth_tag + nonce + tag + cipher_text + end + + # Decrypts cipher text that was encrypted using symmetric AES-256-GCM encryption. + # + # @param algorithm [Symbol] The encryption algorithm to use (must be :aes-256-gcm) + # @param key [String] The decryption key (must match the encryption key) + # @param cipher_text [String] The encrypted data (formatted as: nonce + auth_tag + cipher_text) + # @param nonce_length [Integer] The length of the nonce/IV used during encryption + # @param aad [String] Additional authenticated data (must match the value used during encryption) + # + # @return [String] The decrypted plain text + # + # @raise [ArgumentError] If the algorithm is not :aes-256-gcm + # @raise [OpenSSL::Cipher::CipherError] If authentication fails or decryption fails + # + # @example + # key = OpenSSL::Random.random_bytes(32) + # encrypted = Ecies.sym_encrypt(:"aes-256-gcm", key, "Hello World", 12) + # decrypted = Ecies.sym_decrypt(:"aes-256-gcm", key, encrypted, 12) + def sym_decrypt(algorithm, key, cipher_text, nonce_length, aad = "") + if algorithm != :"aes-256-gcm" + raise ArgumentError, "Unsupported algorithm: #{algorithm}" + end + + nonce = cipher_text[0, nonce_length] + tag = cipher_text[nonce_length, AEAD_TAG_LENGTH] + encrypted = cipher_text[nonce_length + AEAD_TAG_LENGTH..] + decipher = OpenSSL::Cipher.new(algorithm.to_s).decrypt + decipher.key = key + decipher.iv_len = nonce_length + decipher.iv = nonce + decipher.auth_tag = tag + decipher.auth_data = aad + decipher.update(encrypted) + decipher.final + end + + module_function :sym_encrypt, :sym_decrypt +end diff --git a/tests/test_ecies.rb b/tests/test_ecies.rb new file mode 100644 index 0000000..c282d02 --- /dev/null +++ b/tests/test_ecies.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "minitest/autorun" + +require "ecies" +require "ecies/elliptic" +require "ecies/hex" + +class TestEcies < Minitest::Test + include Ecies + + def setup + @data = "Hello, World!" + @python_backend = "https://demo.ecies.org/" + end + + def _call_api(data) + require "net/http" + require "uri" + + uri = URI.parse(@python_backend) + request = Net::HTTP::Post.new(uri) + request["Content-Type"] = "application/x-www-form-urlencoded" + request.body = data.map { |k, v| "#{k}=#{v}" }.join("&") + + Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.request(request) + end + end + + private :_call_api + + def test_encrypt_decrypt + sk = generate_key + raw_sk = decode_hex(sk.send(:serialize)) + raw_pk = sk.pubkey.serialize(compressed: false) + encrypted = encrypt(raw_pk, @data) + decrypted = decrypt(raw_sk, encrypted) + assert_equal @data, decrypted + end + + def test_encrypt_decrypt_with_config + config = Config.new( + is_ephemeral_key_compressed: true, + is_hkdf_key_compressed: true, + symmetric_nonce_length: 12 + ) + sk = generate_key + raw_sk = decode_hex(sk.send(:serialize)) + raw_pk = sk.pubkey.serialize(compressed: false) + encrypted = encrypt(raw_pk, @data, config) + decrypted = decrypt(raw_sk, encrypted, config) + assert_equal @data, decrypted + end + + def test_encrypt_decrypt_with_known_values + raw_sk = decode_hex("5b5b1a0ff51e4350badd6f58d9e6fa6f57fbdbde6079d12901770dda3b803081") + raw_pk = decode_hex("048e41409f2e109f2d704f0afd15d1ab53935fd443729913a7e8536b4cef8cf5773d4db7bbd99e9ed64595e24a251c9836f35d4c9842132443c17f6d501b3410d2") + encrypted = encrypt(raw_pk, @data) + decrypted = decrypt(raw_sk, encrypted) + assert_equal @data, decrypted + end + + def test_encrypt_with_python_backend + sk = generate_key + raw_sk = decode_hex(sk.send(:serialize)) + raw_pk = sk.pubkey.serialize(compressed: false) + + # Encrypt with Python backend + response = _call_api( + { + "pub" => encode_hex(raw_pk), + "data" => @data + } + ) + encrypted = decode_hex(response.body) + # Decrypt with our implementation + decrypted = decrypt(raw_sk, encrypted) + assert_equal @data, decrypted + end + + def test_decrypt_with_python_backend + sk = generate_key + raw_sk = decode_hex(sk.send(:serialize)) + raw_pk = sk.pubkey.serialize(compressed: false) + + # Encrypt with our implementation + encrypted = encrypt(raw_pk, @data) + # Decrypt with Python backend + response = _call_api( + { + "prv" => encode_hex(raw_sk), + "data" => encode_hex(encrypted) + } + ) + decrypted = response.body + assert_equal @data, decrypted + end +end diff --git a/tests/test_elliptic.rb b/tests/test_elliptic.rb new file mode 100644 index 0000000..24c665c --- /dev/null +++ b/tests/test_elliptic.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "minitest/autorun" + +require "ecies/elliptic" +require "ecies/hex" + +class TestElliptic < Minitest::Test + include Ecies + + def setup + @raw_sk = "\x00" * 31 + "\x02" # Private key 2 + @raw_peer_sk = "\x00" * 31 + "\x03" # Private key 3 + end + + def test_encapsulate_decapsulate_with_known_values + sk = Secp256k1::PrivateKey.new(privkey: @raw_sk, raw: true) + raw_pk = sk.pubkey.serialize(compressed: true) + peer_sk = Secp256k1::PrivateKey.new(privkey: @raw_peer_sk, raw: true) + raw_peer_pk = peer_sk.pubkey.serialize(compressed: true) + + # compressed: false + encapsulated = encapsulate(@raw_sk, raw_peer_pk) + assert_equal encapsulated, decapsulate(raw_pk, @raw_peer_sk) + + expected = decode_hex("6f982d63e8590c9d9b5b4c1959ff80315d772edd8f60287c9361d548d5200f82") + assert_equal expected, encapsulated + + # compressed: true + encapsulated = encapsulate(@raw_sk, raw_peer_pk, true) + assert_equal encapsulated, decapsulate(raw_pk, @raw_peer_sk, true) + expected = decode_hex("b192b226edb3f02da11ef9c6ce4afe1c7e40be304e05ae3b988f4834b1cb6c69") + assert_equal expected, encapsulated + end +end diff --git a/tests/test_hash.rb b/tests/test_hash.rb new file mode 100644 index 0000000..2ea6562 --- /dev/null +++ b/tests/test_hash.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "minitest/unit" +require "openssl" + +require "ecies/hash" +require "ecies/hex" + +class TestHash < Minitest::Test + include Ecies + + def test_known + derived = derive_key(decode_hex("0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")) + expected = decode_hex("0x8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d") + assert_equal expected, derived + end +end diff --git a/tests/test_symmetric.rb b/tests/test_symmetric.rb new file mode 100644 index 0000000..b4c617b --- /dev/null +++ b/tests/test_symmetric.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "openssl" + +require "ecies/symmetric" + +class TestSymmetric < Minitest::Test + include Ecies + + def setup + @algorithm = :"aes-256-gcm" + @key = OpenSSL::Random.random_bytes(32) + @nonce_length = 16 + @plain_text = "Hello, World!" + end + + def test_sym_decrypt_with_known_values + plain_text = decode_hex("00000000000000000000000000000000000000000000000000000000000000000000000000000000") + cipher_text = decode_hex("28e1c5232f4ee8161dbe4c036309e0b3254e9212bef0a93431ce5e5604c8f6a73c18a3183018b770") + key = decode_hex("00112233445566778899aabbccddeeff102132435465768798a9bacbdcedfe0f") + nonce = decode_hex("5c2ea9b695fcf6e264b96074d6bfa572") + tag = decode_hex("d5808a1bd11a01129bf3c6919aff2339") + decrypted = sym_decrypt(@algorithm, key, nonce + tag + cipher_text, @nonce_length) + assert_equal plain_text, decrypted + end + + def test_sym_encrypt_returns_correct_format + cipher_text = sym_encrypt(@algorithm, @key, @plain_text, @nonce_length) + + # Should return nonce + tag + ciphertext + # nonce: 16 bytes, tag: 16 bytes, ciphertext: at least as long as plaintext + assert cipher_text.length >= @nonce_length + 16 + @plain_text.length + end + + def test_sym_encrypt_decrypt_round_trip + cipher_text = sym_encrypt(@algorithm, @key, @plain_text, @nonce_length) + decrypted = sym_decrypt(@algorithm, @key, cipher_text, @nonce_length) + + assert_equal @plain_text, decrypted + end + + def test_sym_encrypt_with_aad + aad = "additional authenticated data" + cipher_text = sym_encrypt(@algorithm, @key, @plain_text, @nonce_length, aad) + decrypted = sym_decrypt(@algorithm, @key, cipher_text, @nonce_length, aad) + + assert_equal @plain_text, decrypted + end + + def test_sym_decrypt_fails_with_wrong_aad + aad = "additional authenticated data" + wrong_aad = "wrong aad" + cipher_text = sym_encrypt(@algorithm, @key, @plain_text, @nonce_length, aad) + + assert_raises(OpenSSL::Cipher::CipherError) do + sym_decrypt(@algorithm, @key, cipher_text, @nonce_length, wrong_aad) + end + end + + def test_sym_decrypt_fails_with_wrong_key + cipher_text = sym_encrypt(@algorithm, @key, @plain_text, @nonce_length) + wrong_key = OpenSSL::Random.random_bytes(32) + + assert_raises(OpenSSL::Cipher::CipherError) do + sym_decrypt(@algorithm, wrong_key, cipher_text, @nonce_length) + end + end + + def test_sym_decrypt_fails_with_tampered_ciphertext + cipher_text = sym_encrypt(@algorithm, @key, @plain_text, @nonce_length) + # Tamper with the last byte + tampered = cipher_text.dup + tampered[-1] = (tampered[-1].ord ^ 1).chr + + assert_raises(OpenSSL::Cipher::CipherError) do + sym_decrypt(@algorithm, @key, tampered, @nonce_length) + end + end + + def test_sym_encrypt_uses_random_nonce + cipher_text1 = sym_encrypt(@algorithm, @key, @plain_text, @nonce_length) + cipher_text2 = sym_encrypt(@algorithm, @key, @plain_text, @nonce_length) + + # Nonces should be different + nonce1 = cipher_text1[0, @nonce_length] + nonce2 = cipher_text2[0, @nonce_length] + refute_equal nonce1, nonce2 + end + + def test_sym_encrypt_with_empty_plaintext + empty_text = "" + cipher_text = sym_encrypt(@algorithm, @key, empty_text, @nonce_length) + decrypted = sym_decrypt(@algorithm, @key, cipher_text, @nonce_length) + + assert_equal empty_text, decrypted + end + + def test_sym_encrypt_with_binary_data + binary_data = "\x00\x01\x02\xFF\xFE\xFD" + cipher_text = sym_encrypt(@algorithm, @key, binary_data, @nonce_length) + decrypted = sym_decrypt(@algorithm, @key, cipher_text, @nonce_length) + + assert_equal binary_data.bytes, decrypted.bytes + end + + def test_sym_encrypt_with_large_plaintext + large_text = "A" * 10000 + cipher_text = sym_encrypt(@algorithm, @key, large_text, @nonce_length) + decrypted = sym_decrypt(@algorithm, @key, cipher_text, @nonce_length) + + assert_equal large_text, decrypted + end + + def test_sym_encrypt_raises_on_unsupported_algorithm + error = assert_raises(ArgumentError) do + sym_encrypt(:"aes-128-cbc", @key, @plain_text, @nonce_length) + end + assert_match(/Unsupported algorithm/, error.message) + end + + def test_sym_decrypt_raises_on_unsupported_algorithm + cipher_text = sym_encrypt(@algorithm, @key, @plain_text, @nonce_length) + + error = assert_raises(ArgumentError) do + sym_decrypt(:"aes-128-cbc", @key, cipher_text, @nonce_length) + end + assert_match(/Unsupported algorithm/, error.message) + end + + def test_sym_encrypt_with_different_nonce_lengths + [12, 16].each do |nonce_len| + cipher_text = sym_encrypt(@algorithm, @key, @plain_text, nonce_len) + decrypted = sym_decrypt(@algorithm, @key, cipher_text, nonce_len) + + assert_equal @plain_text, decrypted + end + end +end