@@ -33,8 +33,9 @@ class DecryptionError < StandardError; end
3333 # @param message [String] the plaintext message
3434 # @param public_key [PublicKey] the recipient's public key
3535 # @param private_key [PrivateKey, nil] optional ephemeral key (random if omitted)
36- # @return [String] encrypted payload: BIE1 magic + ephemeral pubkey + ciphertext + HMAC
37- def encrypt ( message , public_key , private_key : nil )
36+ # @param no_key [Boolean] when +true+, omit the ephemeral public key from the payload
37+ # @return [String] encrypted payload: BIE1 magic + [ephemeral pubkey] + ciphertext + HMAC
38+ def encrypt ( message , public_key , private_key : nil , no_key : false )
3839 message = message . b if message . encoding != Encoding ::ASCII_8BIT
3940
4041 ephemeral = private_key || PrivateKey . generate
@@ -48,7 +49,11 @@ def encrypt(message, public_key, private_key: nil)
4849 cipher . iv = iv
4950 ciphertext = message . empty? ? cipher . final : cipher . update ( message ) + cipher . final
5051
51- payload = MAGIC + ephemeral_pub . compressed + ciphertext
52+ payload = if no_key
53+ MAGIC + ciphertext
54+ else
55+ MAGIC + ephemeral_pub . compressed + ciphertext
56+ end
5257 mac = Digest . hmac_sha256 ( key_m , payload )
5358
5459 payload + mac
@@ -58,29 +63,64 @@ def encrypt(message, public_key, private_key: nil)
5863 #
5964 # Verifies the HMAC before attempting decryption (encrypt-then-MAC).
6065 #
66+ # The ephemeral public key may be embedded in the payload (compressed or
67+ # uncompressed), or absent entirely (when the payload was encrypted with
68+ # +no_key: true+). When absent, +sender_public_key+ must be provided.
69+ #
70+ # If a key is found in the payload and +sender_public_key+ is also given,
71+ # the payload key takes precedence (matching TS SDK behaviour).
72+ #
6173 # @param data [String] the encrypted payload (BIE1 format)
6274 # @param private_key [PrivateKey] the recipient's private key
75+ # @param sender_public_key [PublicKey, nil] sender's public key (required when no key in payload)
6376 # @return [String] the decrypted plaintext
64- # @raise [ArgumentError] if the data is too short or has invalid magic bytes
77+ # @raise [ArgumentError] if the data is too short, has invalid magic, or has no key and none provided
6578 # @raise [DecryptionError] if HMAC verification or AES decryption fails
66- def decrypt ( data , private_key )
79+ def decrypt ( data , private_key , sender_public_key : nil )
6780 data = data . b if data . encoding != Encoding ::ASCII_8BIT
6881
69- raise ArgumentError , 'data too short' if data . bytesize < 85
82+ # Minimum: magic(4) + ciphertext(16) + HMAC(32) = 52 (no-key case)
83+ raise ArgumentError , 'data too short' if data . bytesize < 52
7084
7185 magic = data [ 0 , 4 ]
7286 raise ArgumentError , 'invalid magic: expected BIE1' unless magic == MAGIC
7387
74- ephemeral_pub_bytes = data [ 4 , 33 ]
75- mac = data [ -32 , 32 ]
76- ciphertext = data [ 37 ...-32 ]
88+ # Determine ephemeral key presence and format by inspecting byte at offset 4.
89+ # Ambiguity note: a no-key payload whose ciphertext starts with 0x02/0x03/0x04
90+ # could be misinterpreted as containing an embedded key. The HMAC check below
91+ # will catch this (wrong shared secret → HMAC mismatch), but the resulting
92+ # error message will be misleading. This is a TS SDK design inheritance —
93+ # the wire format has no explicit key-presence flag.
94+ # Guard: only attempt to read a key if sufficient bytes remain beyond HMAC.
95+ tag_length = 32
96+ offset = 4
97+ ephemeral_pub = nil
98+
99+ remaining_after_offset = data . bytesize - offset - tag_length
100+ if remaining_after_offset >= 33
101+ first_byte = data . getbyte ( offset )
102+ if [ 0x02 , 0x03 ] . include? ( first_byte )
103+ # Compressed key: 33 bytes
104+ ephemeral_pub = PublicKey . from_bytes ( data [ offset , 33 ] )
105+ offset += 33
106+ elsif first_byte == 0x04 && remaining_after_offset >= 65
107+ # Uncompressed key: 65 bytes
108+ ephemeral_pub = PublicKey . from_bytes ( data [ offset , 65 ] )
109+ offset += 65
110+ end
111+ end
112+
113+ # If no key found in payload, fall back to provided sender_public_key
114+ ephemeral_pub ||= sender_public_key
115+ raise ArgumentError , 'sender_public_key required when no key in payload' if ephemeral_pub . nil?
77116
78- ephemeral_pub = PublicKey . from_bytes ( ephemeral_pub_bytes )
117+ mac = data [ -tag_length , tag_length ]
118+ ciphertext = data [ offset ...-tag_length ]
79119
80120 iv , key_e , key_m = derive_keys ( private_key , ephemeral_pub )
81121
82122 # Verify HMAC before decryption (encrypt-then-MAC)
83- payload = data [ 0 ...-32 ]
123+ payload = data [ 0 ...-tag_length ]
84124 expected_mac = Digest . hmac_sha256 ( key_m , payload )
85125
86126 raise DecryptionError , 'HMAC verification failed' unless secure_compare ( mac , expected_mac )
0 commit comments