Module: BSV::Primitives::ECIES

Defined in:
lib/bsv/primitives/ecies.rb

Overview

Elliptic Curve Integrated Encryption Scheme (ECIES) using the Electrum/BIE1 protocol.

Provides authenticated encryption using an ephemeral ECDH shared secret. The sender generates a random key pair, derives a shared secret with the recipient’s public key, then encrypts with AES-128-CBC and authenticates with HMAC-SHA-256 (encrypt-then-MAC).

Examples:

Encrypt and decrypt a message

alice = BSV::Primitives::PrivateKey.generate
bob   = BSV::Primitives::PrivateKey.generate

ciphertext = BSV::Primitives::ECIES.encrypt('hello', bob.public_key)
plaintext  = BSV::Primitives::ECIES.decrypt(ciphertext, bob)

Defined Under Namespace

Classes: DecryptionError

Constant Summary collapse

MAGIC =

BIE1 magic bytes identifying the Electrum ECIES format.

'BIE1'.b.freeze

Class Method Summary collapse

Class Method Details

.bitcore_decrypt(data, private_key) ⇒ String

Decrypt a message encrypted with the Bitcore ECIES variant.

Parameters:

  • data (String)

    the encrypted payload (Bitcore ECIES format)

  • private_key (PrivateKey)

    the recipient’s private key

Returns:

  • (String)

    the decrypted plaintext

Raises:

  • (ArgumentError)

    if the data is too short

  • (DecryptionError)

    if HMAC verification or AES decryption fails



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/bsv/primitives/ecies.rb', line 178

def bitcore_decrypt(data, private_key)
  data = data.b if data.encoding != Encoding::ASCII_8BIT

  # Minimum: ephemeral_pub(33) + IV(16) + AES block(16) + HMAC(32) = 97
  raise ArgumentError, 'data too short' if data.bytesize < 97

  ephemeral_pub = PublicKey.from_bytes(data[0, 33])
  mac = data[-32, 32]
  c = data[33...-32] # IV + ciphertext

  key_e, key_m = derive_bitcore_keys(private_key, ephemeral_pub)

  expected_mac = Digest.hmac_sha256(key_m, c)
  raise DecryptionError, 'HMAC verification failed' unless secure_compare(mac, expected_mac)

  iv = c[0, 16]
  ciphertext = c[16..]

  begin
    cipher = OpenSSL::Cipher.new('aes-256-cbc')
    cipher.decrypt
    cipher.key = key_e
    cipher.iv = iv
    cipher.update(ciphertext) + cipher.final
  rescue OpenSSL::Cipher::CipherError => e
    raise DecryptionError, "decryption failed: #{e.message}"
  end
end

.bitcore_encrypt(message, public_key, private_key: nil) ⇒ String

Encrypt a message using the Bitcore ECIES variant.

Differs from the Electrum variant: no magic prefix, AES-256-CBC (not AES-128), random IV prepended to ciphertext, and HMAC covers the ciphertext (not the ephemeral pubkey).

Wire format: ephemeral_pub(33) IV(16) + ciphertext + HMAC(32)+

Parameters:

  • message (String)

    the plaintext message

  • public_key (PublicKey)

    the recipient’s public key

  • private_key (PrivateKey, nil) (defaults to: nil)

    optional ephemeral key (random if omitted)

Returns:

  • (String)

    encrypted payload



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/bsv/primitives/ecies.rb', line 151

def bitcore_encrypt(message, public_key, private_key: nil)
  message = message.b if message.encoding != Encoding::ASCII_8BIT

  ephemeral = private_key || PrivateKey.generate
  key_e, key_m = derive_bitcore_keys(ephemeral, public_key)

  iv = SecureRandom.random_bytes(16)

  cipher = OpenSSL::Cipher.new('aes-256-cbc')
  cipher.encrypt
  cipher.key = key_e
  cipher.iv = iv
  ciphertext = message.empty? ? cipher.final : cipher.update(message) + cipher.final

  c = iv + ciphertext
  mac = Digest.hmac_sha256(key_m, c)

  ephemeral.public_key.compressed + c + mac
end

.decrypt(data, private_key, sender_public_key: nil) ⇒ String

Decrypt an ECIES-encrypted message with a private key.

Verifies the HMAC before attempting decryption (encrypt-then-MAC).

The ephemeral public key may be embedded in the payload (compressed or uncompressed), or absent entirely (when the payload was encrypted with no_key: true). When absent, sender_public_key must be provided.

If a key is found in the payload and sender_public_key is also given, the payload key takes precedence (matching TS SDK behaviour).

Parameters:

  • data (String)

    the encrypted payload (BIE1 format)

  • private_key (PrivateKey)

    the recipient’s private key

  • sender_public_key (PublicKey, nil) (defaults to: nil)

    sender’s public key (required when no key in payload)

Returns:

  • (String)

    the decrypted plaintext

Raises:

  • (ArgumentError)

    if the data is too short, has invalid magic, or has no key and none provided

  • (DecryptionError)

    if HMAC verification or AES decryption fails



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/bsv/primitives/ecies.rb', line 79

def decrypt(data, private_key, sender_public_key: nil)
  data = data.b if data.encoding != Encoding::ASCII_8BIT

  # Minimum: magic(4) + ciphertext(16) + HMAC(32) = 52 (no-key case)
  raise ArgumentError, 'data too short' if data.bytesize < 52

  magic = data[0, 4]
  raise ArgumentError, 'invalid magic: expected BIE1' unless magic == MAGIC

  # Determine ephemeral key presence and format by inspecting byte at offset 4.
  # Ambiguity note: a no-key payload whose ciphertext starts with 0x02/0x03/0x04
  # could be misinterpreted as containing an embedded key. The HMAC check below
  # will catch this (wrong shared secret → HMAC mismatch), but the resulting
  # error message will be misleading. This is a TS SDK design inheritance —
  # the wire format has no explicit key-presence flag.
  # Guard: only attempt to read a key if sufficient bytes remain beyond HMAC.
  tag_length = 32
  offset = 4
  ephemeral_pub = nil

  remaining_after_offset = data.bytesize - offset - tag_length
  if remaining_after_offset >= 33
    first_byte = data.getbyte(offset)
    if [0x02, 0x03].include?(first_byte)
      # Compressed key: 33 bytes
      ephemeral_pub = PublicKey.from_bytes(data[offset, 33])
      offset += 33
    elsif first_byte == 0x04 && remaining_after_offset >= 65
      # Uncompressed key: 65 bytes
      ephemeral_pub = PublicKey.from_bytes(data[offset, 65])
      offset += 65
    end
  end

  # If no key found in payload, fall back to provided sender_public_key
  ephemeral_pub ||= sender_public_key
  raise ArgumentError, 'sender_public_key required when no key in payload' if ephemeral_pub.nil?

  mac = data[-tag_length, tag_length]
  ciphertext = data[offset...-tag_length]

  iv, key_e, key_m = derive_keys(private_key, ephemeral_pub)

  # Verify HMAC before decryption (encrypt-then-MAC)
  payload = data[0...-tag_length]
  expected_mac = Digest.hmac_sha256(key_m, payload)

  raise DecryptionError, 'HMAC verification failed' unless secure_compare(mac, expected_mac)

  begin
    cipher = OpenSSL::Cipher.new('aes-128-cbc')
    cipher.decrypt
    cipher.key = key_e
    cipher.iv = iv
    cipher.update(ciphertext) + cipher.final
  rescue OpenSSL::Cipher::CipherError => e
    raise DecryptionError, "decryption failed: #{e.message}"
  end
end

.encrypt(message, public_key, private_key: nil, no_key: false) ⇒ String

Encrypt a message for a recipient’s public key.

Parameters:

  • message (String)

    the plaintext message

  • public_key (PublicKey)

    the recipient’s public key

  • private_key (PrivateKey, nil) (defaults to: nil)

    optional ephemeral key (random if omitted)

  • no_key (Boolean) (defaults to: false)

    when true, omit the ephemeral public key from the payload

Returns:

  • (String)

    encrypted payload: BIE1 magic + [ephemeral pubkey] + ciphertext + HMAC



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/bsv/primitives/ecies.rb', line 38

def encrypt(message, public_key, private_key: nil, no_key: false)
  message = message.b if message.encoding != Encoding::ASCII_8BIT

  ephemeral = private_key || PrivateKey.generate
  ephemeral_pub = ephemeral.public_key

  iv, key_e, key_m = derive_keys(ephemeral, public_key)

  cipher = OpenSSL::Cipher.new('aes-128-cbc')
  cipher.encrypt
  cipher.key = key_e
  cipher.iv = iv
  ciphertext = message.empty? ? cipher.final : cipher.update(message) + cipher.final

  payload = if no_key
              MAGIC + ciphertext
            else
              MAGIC + ephemeral_pub.compressed + ciphertext
            end
  mac = Digest.hmac_sha256(key_m, payload)

  payload + mac
end