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