siwe-rb 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f43daa18ea4cad6fad589c66ec33ab0e42fe605371705b9f6b6b9c42a00d619
4
- data.tar.gz: 251ff4c5139a8632090db0db4480020bfe954a595d855d13e9a45ede412feaf4
3
+ metadata.gz: 4b6bc3df2351b75f3628baf83e1a49b32967538ce242b76407b0d978f7777fa2
4
+ data.tar.gz: ddfd9da52f3d0dd77504c3df22ec547091b7038356623891ac465d008298d68b
5
5
  SHA512:
6
- metadata.gz: 82a18c3a0584046085715bd316d8eda59628b6b92696f2b219adb8e3115546cdcbf2deb3c72996a36550a8fa0966a25a15acfeacbc1416386c153b9a8d771c5a
7
- data.tar.gz: 88b136c8941313f6d11cf9ea6bb03c538d2e00f09ea4b40fa5806810eb2efbb381f14d738cb2266c36151c9104cab1e0af37f23c7c44e17441e984afc90163dd
6
+ metadata.gz: b318a4225a6ea4c31c142720ba981a0fd6d465f69a0e150aece84898a5144299c0b8e5f4b89db75c230fe156a9a76f0b21f8ed926da8884458a05aab078e2f77
7
+ data.tar.gz: d5980f23b3f96765fcdf617998cb5d0554aa326fef6c0a2bf9401943a53478bbec452b1f552d0f87d57c9c70f5cf6b99e5bdb6e2b7b36411919ab1d1b9045555
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] — 2026-09-11
4
+
5
+ - Replace the `eth` runtime dependency with `keccak` and Ruby OpenSSL for EOA signer recovery, EIP-55 checksums, EIP-191 hashing, and smart-wallet ABI encoding. This removes `rbsecp256k1` and its conflicting build-tool dependencies from installations such as Discourse.
6
+ - Default to `Siwe::Adapter::OpenSSL`; remove `Siwe::Adapter::EthGem`. Custom adapters should raise `Siwe::Crypto::SignatureError` for recovery failures. Message verification APIs and smart-wallet chain binding are unchanged.
7
+ - Signing with `eth` now requires an explicit application dependency. Package smoke tests and the regular test suite no longer depend on it.
8
+ - Preserve EOA verification of raw binary signatures and `0X`-prefixed hex. Malformed contract-signature hex now returns `:invalid_signature` without calling RPC; odd-length hex is rejected instead of implicitly left-padded.
9
+ - Propagate OpenSSL configuration errors instead of reporting them as invalid signatures.
10
+
11
+ ## [0.2.0] — 2026-05-26
12
+
13
+ Spec-conformance pass. Several behaviour changes; the only one that breaks API contract is the strict chain-id requirement on the smart-wallet path (3).
14
+
15
+ 1. **RFC 3986 enforcement for `domain`, `URI`, and resource URIs.** The previous regexes accepted malformed authorities (`example.com:abc`), IP-literal hosts with invalid IPv4 octets (`uri://[::0.0.0.256]/p`), and URI characters outside RFC 3986 (`https://example.com/path?q=one|two`). The shared test-vector suite's `invalid_uris` / `invalid_resources` grammar cases were silently passing because the spec wrapper called `vec["msg"]` on raw-string entries; both the parser and the spec are fixed.
16
+ 2. **Expiration boundary now matches sibling implementations.** A message is rejected at the exact `Expiration Time` (not one instant after), aligning with TypeScript / Python / Rust.
17
+ 3. **Smart-wallet chain binding is enforced, not best-effort.** ERC-4361 requires ERC-1271 verification to happen on the chain matching the message's `Chain ID`. Previously, an RPC that lacked `chain_id` or whose `eth_chainId` probe failed would silently skip the check. Now `:invalid_signature_chain_id` is raised in either case. **Breaking** for callers using a custom RPC client without a `chain_id` method, or relying on `chain_id` returning `nil` on probe failure.
18
+ 4. **`request-id` field tightened to `*pchar`.** The parser previously accepted `/` and `?` (ABNF query/fragment characters) which are not pchar.
19
+ 5. **Empty / nil signatures now raise `:invalid_params`** instead of `:invalid_signature` — same `Siwe::Error` class, more specific type.
20
+ 6. **Narrower `rescue` in EOA recovery.** Adapter misconfiguration no longer disappears behind `:invalid_signature`; only `eth`-gem signature/chain errors and arg/type errors are swallowed.
21
+ 7. **Removed unused public surface.** `Config#verification_fallback`, `Eip6492::EIP1271_MAGIC_VALUE`, and `Adapter::EthGem#{hash_message,get_address}` are gone. None were called anywhere.
22
+
3
23
  ## [0.1.2] — 2026-05-04
4
24
 
5
25
  - Add top-level convenience methods `Siwe.parse(str)` (alias for `Siwe::Message.parse`) and `Siwe.eip6492_signature?(hex)` (alias for `Siwe::Eip6492.signature?`), mirroring the TS package's root-level exports.
data/Gemfile.lock CHANGED
@@ -1,8 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- siwe-rb (0.1.2)
5
- eth (>= 0.5.11, < 1.0)
4
+ siwe-rb (0.3.0)
5
+ keccak (~> 1.3)
6
6
 
7
7
  GEM
8
8
  remote: https://rubygems.org/
@@ -10,70 +10,26 @@ GEM
10
10
  addressable (2.9.0)
11
11
  public_suffix (>= 2.0.2, < 8.0)
12
12
  ast (2.4.3)
13
- base64 (0.3.0)
14
13
  bigdecimal (3.3.1)
15
- bls12-381 (0.3.1)
16
- bigdecimal
17
- h2c (>= 0.2.1, < 0.3)
18
14
  crack (1.0.1)
19
15
  bigdecimal
20
16
  rexml
21
17
  diff-lcs (1.6.2)
22
18
  docile (1.4.1)
23
- ecdsa (1.2.0)
24
- eth (0.5.17)
25
- base64 (~> 0.1)
26
- bigdecimal (~> 3.1)
27
- bls12-381 (~> 0.3)
28
- forwardable (~> 1.3)
29
- httpx (~> 1.6)
30
- keccak (~> 1.3)
31
- konstructor (~> 1.0)
32
- openssl (~> 3.3)
33
- rbsecp256k1 (~> 6.0)
34
- scrypt (~> 3.0)
35
- ffi (1.17.4)
36
- ffi (1.17.4-aarch64-linux-gnu)
37
- ffi (1.17.4-aarch64-linux-musl)
38
- ffi (1.17.4-arm-linux-gnu)
39
- ffi (1.17.4-arm-linux-musl)
40
- ffi (1.17.4-arm64-darwin)
41
- ffi (1.17.4-x86-linux-gnu)
42
- ffi (1.17.4-x86-linux-musl)
43
- ffi (1.17.4-x86_64-darwin)
44
- ffi (1.17.4-x86_64-linux-gnu)
45
- ffi (1.17.4-x86_64-linux-musl)
46
- ffi-compiler (1.4.2)
47
- ffi (>= 1.15.5)
48
- rake
49
- forwardable (1.4.0)
50
- h2c (0.2.1)
51
- ecdsa (~> 1.2.0)
52
19
  hashdiff (1.2.1)
53
- http-2 (1.1.3)
54
- httpx (1.7.6)
55
- http-2 (>= 1.1.3)
56
20
  json (2.19.5)
57
21
  keccak (1.3.3)
58
- konstructor (1.0.2)
59
22
  language_server-protocol (3.17.0.5)
60
23
  lint_roller (1.1.0)
61
- mini_portile2 (2.8.9)
62
- openssl (3.3.2)
63
24
  parallel (2.1.0)
64
25
  parser (3.3.11.1)
65
26
  ast (~> 2.4.1)
66
27
  racc
67
- pkg-config (1.6.5)
68
28
  prism (1.9.0)
69
29
  public_suffix (7.0.5)
70
30
  racc (1.8.1)
71
31
  rainbow (3.1.1)
72
32
  rake (13.4.2)
73
- rbsecp256k1 (6.0.0)
74
- mini_portile2 (~> 2.8)
75
- pkg-config (~> 1.5)
76
- rubyzip (~> 2.3)
77
33
  regexp_parser (2.12.0)
78
34
  rexml (3.4.4)
79
35
  rspec (3.13.2)
@@ -104,10 +60,6 @@ GEM
104
60
  parser (>= 3.3.7.2)
105
61
  prism (~> 1.7)
106
62
  ruby-progressbar (1.13.0)
107
- rubyzip (2.4.1)
108
- scrypt (3.1.0)
109
- ffi-compiler (>= 1.0, < 2.0)
110
- rake (~> 13)
111
63
  simplecov (0.22.0)
112
64
  docile (~> 1.1)
113
65
  simplecov-html (~> 0.11)
@@ -146,47 +98,22 @@ DEPENDENCIES
146
98
  CHECKSUMS
147
99
  addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
148
100
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
149
- base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
150
101
  bigdecimal (3.3.1) sha256=eaa01e228be54c4f9f53bf3cc34fe3d5e845c31963e7fcc5bedb05a4e7d52218
151
- bls12-381 (0.3.1) sha256=d1525cd319c53f14178d54f355dfe1b4c572fbf5625965c1afccdc425b9896dc
152
102
  crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
153
103
  diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
154
104
  docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
155
- ecdsa (1.2.0) sha256=b8f7c9541b6c587cbe37e705a1b76be6dc1e85336aa23ac3cf2f07b038bcec55
156
- eth (0.5.17) sha256=5be9caa85b0643613010ae33c92e746a02ce376a35245e6eb5f491e80531f137
157
- ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf
158
- ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
159
- ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
160
- ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564
161
- ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95
162
- ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b
163
- ffi (1.17.4-x86-linux-gnu) sha256=38e150df5f4ca555e25beca4090823ae09657bceded154e3c52f8631c1ed72cf
164
- ffi (1.17.4-x86-linux-musl) sha256=fbeec0fc7c795bcf86f623bb18d31ea1820f7bd580e1703a3d3740d527437809
165
- ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
166
- ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
167
- ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
168
- ffi-compiler (1.4.2) sha256=a9d69d5d9ced6f64776ddafa535867ad90e5740ad37bed7afb4de6e450da126e
169
- forwardable (1.4.0) sha256=f1cd40cc9812937980e1c76f1aa053660990a7c9b6a98fc37d945468afcce838
170
- h2c (0.2.1) sha256=a53b7dfeb95660c97b615235ea1b29ec8e1856edc571845685682d94b6e8e227
171
105
  hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
172
- http-2 (1.1.3) sha256=1b2f379d35a11dbae94f8a1a52c053d8c161eb4a0c98b5d1605ff1b2bf171c9c
173
- httpx (1.7.6) sha256=82d825abc9876a132adc3492c56a0c528478ac238dd6f74d3422ab0036c6b5c8
174
106
  json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59
175
107
  keccak (1.3.3) sha256=970dcb1e78b8c3129ba2baff8a5baa5cebf391136db1f4018e8ccc9130794557
176
- konstructor (1.0.2) sha256=fd6ac9eb1dadc1e520e06042aa2ef0122d6a070e06cde86db5a54c0c7bfe2e31
177
108
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
178
109
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
179
- mini_portile2 (2.8.9) sha256=0cd7c7f824e010c072e33f68bc02d85a00aeb6fce05bb4819c03dfd3c140c289
180
- openssl (3.3.2) sha256=7f4e01215dc9c4be1fca71d692406be3e6340b39c1f71a47fea9c497decd0f6c
181
110
  parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
182
111
  parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
183
- pkg-config (1.6.5) sha256=33f9f81c5322983d22b439b8b672f27777b406fea23bfec74ff14bbeb42ec733
184
112
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
185
113
  public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
186
114
  racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
187
115
  rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
188
116
  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
189
- rbsecp256k1 (6.0.0) sha256=d38f563bfc6bcf3ce20c336a4798cc6990427f291e74c6817360363a72f11669
190
117
  regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
191
118
  rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
192
119
  rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
@@ -197,12 +124,10 @@ CHECKSUMS
197
124
  rubocop (1.86.1) sha256=44415f3f01d01a21e01132248d2fd0867572475b566ca188a0a42133a08d4531
198
125
  rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
199
126
  ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
200
- rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
201
- scrypt (3.1.0) sha256=67fde35bc7e3b7fe906c5be9c242acf95fe4a886c89572f405f6b11df30aa6af
202
127
  simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
203
128
  simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
204
129
  simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
205
- siwe-rb (0.1.2)
130
+ siwe-rb (0.3.0)
206
131
  unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
207
132
  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
208
133
  webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # siwe-rb
2
2
 
3
- Sign-In with Ethereum (EIP-4361) for Ruby — message construction, parsing, and signature verification, with built-in support for ERC-1271 and EIP-6492 smart contract wallets.
3
+ Sign in with Ethereum (EIP-4361) for Ruby — message construction, parsing, and signature verification, with built-in support for ERC-1271 and EIP-6492 smart contract wallets.
4
4
 
5
5
  `siwe-rb` is the Ruby companion to the [TypeScript](https://github.com/signinwithethereum/siwe), [Python](https://github.com/signinwithethereum/siwe-py), and [Rust](https://github.com/signinwithethereum/siwe-rs) implementations under the [@signinwithethereum](https://github.com/signinwithethereum) organisation. It runs against the same shared test-vector suite.
6
6
 
@@ -8,19 +8,27 @@ Sign-In with Ethereum (EIP-4361) for Ruby — message construction, parsing, and
8
8
 
9
9
  ```ruby
10
10
  # Gemfile
11
- gem "siwe-rb", "~> 0.1"
11
+ gem "siwe-rb", "~> 0.3.0"
12
12
  ```
13
13
 
14
14
  ```ruby
15
15
  require "siwe"
16
16
  ```
17
17
 
18
- Requires Ruby ≥ 3.3.
18
+ Requires Ruby ≥ 3.3 with OpenSSL secp256k1 support. The only external runtime
19
+ dependency is `keccak`; verification does not require `eth` or `rbsecp256k1`.
20
+
21
+ For hosts that install gems with `--ignore-dependencies` (including Discourse
22
+ plugins), install and activate `keccak` before `siwe-rb`.
19
23
 
20
24
  ## Usage
21
25
 
22
26
  ### Construct and sign a message
23
27
 
28
+ Signing is usually performed by the user’s wallet. This Ruby signing example
29
+ uses the optional `eth` gem, which you must add to your own Gemfile to run it.
30
+ `siwe-rb` itself only verifies signatures.
31
+
24
32
  ```ruby
25
33
  require "siwe"
26
34
  require "eth"
@@ -91,6 +99,16 @@ config = Siwe::Config.new(rpc: custom_rpc)
91
99
  message.verify!(signature: sig, domain: domain, nonce: nonce, config: config)
92
100
  ```
93
101
 
102
+ ### Crypto adapters
103
+
104
+ The default adapter is `Siwe::Adapter::OpenSSL`. Custom adapters still implement
105
+ `verify_message(message, signature)` and return the recovered EIP-55 address.
106
+ Raise `Siwe::Crypto::SignatureError` for a bad signature so verification can try
107
+ the smart-wallet fallback; unexpected exceptions propagate to the caller.
108
+
109
+ Version 0.3 removes `Siwe::Adapter::EthGem`. Applications that sign with `eth`
110
+ must declare it themselves; ordinary verification callers need no API changes.
111
+
94
112
  ### Error handling
95
113
 
96
114
  All failures raise (or, for `verify`, surface as `response.error`) a single `Siwe::Error` carrying a `type` symbol from `Siwe::ErrorType`:
@@ -140,4 +158,4 @@ SIWE_RPC_URL=https://ethereum-rpc.publicnode.com bundle exec rspec --tag live_rp
140
158
 
141
159
  ## License
142
160
 
143
- MIT or Apache-2.0, at your option.
161
+ MIT.
data/lib/siwe/adapter.rb CHANGED
@@ -1,33 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "eth"
4
-
5
- require_relative "error"
6
- require_relative "error_type"
3
+ require_relative "crypto"
7
4
 
8
5
  module Siwe
9
6
  module Adapter
10
- # Default crypto adapter using the eth gem.
11
- # Implements the SiweConfig interface from the TS reference implementation:
12
- # verify_message recover signer address from EIP-191 signed message
13
- # hash_message → EIP-191 personal_sign hash
14
- # get_address → normalize to EIP-55 checksum address
15
- class EthGem
7
+ # Recover the signer using Ruby's OpenSSL bindings and Keccak.
8
+ # A custom adapter implements #verify_message(message, signature), returns
9
+ # an EIP-55 address, and raises Crypto::SignatureError for invalid signatures.
10
+ class OpenSSL
16
11
  def verify_message(message, signature)
17
- public_key = Eth::Signature.personal_recover(message, signature)
18
- Eth::Util.public_key_to_address(public_key).to_s
19
- end
20
-
21
- def hash_message(message)
22
- prefixed = Eth::Signature.prefix_message(message)
23
- Eth::Util.keccak256(prefixed)
24
- end
25
-
26
- def get_address(addr)
27
- Eth::Address.new(addr).to_s
12
+ Crypto.recover_address(message, signature)
28
13
  end
29
14
  end
30
15
 
31
- DEFAULT = EthGem.new
16
+ DEFAULT = OpenSSL.new
32
17
  end
33
18
  end
data/lib/siwe/config.rb CHANGED
@@ -3,48 +3,36 @@
3
3
  require_relative "adapter"
4
4
 
5
5
  module Siwe
6
- # Verification config — pluggable adapter (crypto), RPC URL or RPC client (smart wallet),
7
- # and optional verification fallback.
6
+ # Verification config — pluggable adapter (crypto) plus RPC URL or RPC client
7
+ # for the smart-wallet (ERC-1271 / ERC-6492) verification path.
8
8
  #
9
9
  # Set globally via Siwe.configure { |c| ... } or per-call as `verify(config: ...)`.
10
10
  class Config
11
- attr_reader :rpc_url, :rpc, :adapter, :verification_fallback
11
+ attr_reader :rpc_url, :rpc, :adapter
12
12
 
13
- def initialize(rpc_url: nil, rpc: nil, adapter: nil, verification_fallback: nil)
13
+ def initialize(rpc_url: nil, rpc: nil, adapter: nil)
14
14
  @rpc_url = rpc_url
15
15
  @rpc = rpc
16
16
  @adapter = adapter || Siwe::Adapter::DEFAULT
17
- @verification_fallback = verification_fallback
18
17
  end
19
18
 
20
19
  def to_h
21
- {
22
- rpc_url: @rpc_url,
23
- rpc: @rpc,
24
- adapter: @adapter,
25
- verification_fallback: @verification_fallback
26
- }
20
+ { rpc_url: @rpc_url, rpc: @rpc, adapter: @adapter }
27
21
  end
28
22
 
29
23
  # Mutable struct used inside Siwe.configure { |c| c.rpc_url = ... }.
30
24
  # Caller mutates fields, then build returns a frozen Config.
31
25
  class Builder
32
- attr_accessor :rpc_url, :rpc, :adapter, :verification_fallback
26
+ attr_accessor :rpc_url, :rpc, :adapter
33
27
 
34
- def initialize(rpc_url: nil, rpc: nil, adapter: nil, verification_fallback: nil)
28
+ def initialize(rpc_url: nil, rpc: nil, adapter: nil)
35
29
  @rpc_url = rpc_url
36
30
  @rpc = rpc
37
31
  @adapter = adapter
38
- @verification_fallback = verification_fallback
39
32
  end
40
33
 
41
34
  def build
42
- Config.new(
43
- rpc_url: @rpc_url,
44
- rpc: @rpc,
45
- adapter: @adapter,
46
- verification_fallback: @verification_fallback
47
- )
35
+ Config.new(rpc_url: @rpc_url, rpc: @rpc, adapter: @adapter)
48
36
  end
49
37
  end
50
38
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/keccak"
4
+ require "openssl"
5
+
6
+ module Siwe
7
+ # Ethereum cryptographic and encoding helpers. Signature recovery handles
8
+ # only public inputs and uses Ruby's OpenSSL bindings for secp256k1 operations.
9
+ module Crypto
10
+ module_function
11
+
12
+ class SignatureError < StandardError; end
13
+
14
+ HEX_ADDRESS = /\A0x[0-9a-fA-F]{40}\z/
15
+ HEX_SIGNATURE = /\A(?:0[xX])?([0-9a-fA-F]{64})([0-9a-fA-F]{64})([0-9a-fA-F]{2})\z/
16
+ SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
17
+
18
+ def keccak256(data)
19
+ Digest::Keccak.new(256).digest(data)
20
+ end
21
+
22
+ def eip191_hash(message)
23
+ keccak256("\x19Ethereum Signed Message:\n#{message.bytesize}".b + message.b)
24
+ end
25
+
26
+ def recover_address(message, signature)
27
+ public_key = recover_public_key(eip191_hash(message), signature)
28
+ checksum_address("0x#{bin_to_hex(keccak256(public_key.byteslice(1, 64)).byteslice(-20, 20))}")
29
+ end
30
+
31
+ def recover_public_key(digest, signature)
32
+ raise SignatureError, "digest must be exactly 32 bytes" unless digest&.bytesize == 32
33
+
34
+ r, s, parity = parse_signature(signature)
35
+ group = OpenSSL::PKey::EC::Group.new("secp256k1")
36
+ recovery_point = recovery_point(group, r, parity)
37
+ order = group.order
38
+ # ECDSA recovery: Q = r^-1 * (sR - zG).
39
+ digest_integer = digest.unpack1("H*").to_i(16)
40
+ signed_recovery_point = recovery_point.mul(OpenSSL::BN.new(s.to_s))
41
+ inverse_message_point = group.generator.mul(OpenSSL::BN.new((-digest_integer % SECP256K1_ORDER).to_s))
42
+ inverse_r = OpenSSL::BN.new(r.to_s).mod_inverse(order)
43
+ public_key = signed_recovery_point.add(inverse_message_point).mul(inverse_r)
44
+ raise SignatureError, "could not recover a valid secp256k1 public key" if public_key.infinity?
45
+
46
+ public_key.to_octet_string(:uncompressed)
47
+ rescue OpenSSL::PKey::EC::Point::Error => e
48
+ raise SignatureError, e.message
49
+ end
50
+
51
+ def parse_signature(signature)
52
+ # eth historically accepted raw 65-byte signatures as well as hex.
53
+ signature = bin_to_hex(signature) if signature.is_a?(String) && signature.bytesize == 65
54
+ match = HEX_SIGNATURE.match(signature.to_s.b)
55
+ raise SignatureError, "signature must be exactly 65 bytes" unless match
56
+
57
+ r, s, recovery = match.captures.map { |hex| hex.to_i(16) }
58
+ unless r.positive? && r < SECP256K1_ORDER && s.positive? && s < SECP256K1_ORDER
59
+ raise SignatureError, "signature values are outside the secp256k1 scalar field"
60
+ end
61
+
62
+ [r, s, recovery_id(recovery)]
63
+ end
64
+ private_class_method :parse_signature
65
+
66
+ def recovery_point(group, scalar, recovery_id)
67
+ # personal_sign has one parity bit; its x coordinate is r. OpenSSL
68
+ # decompresses and validates the point (secp256k1 has cofactor 1).
69
+ encoded = [2 + recovery_id].pack("C") + [scalar.to_s(16).rjust(64, "0")].pack("H*")
70
+ point = OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new(encoded, 2))
71
+ raise SignatureError, "signature recovery point is not on secp256k1" unless point.on_curve?
72
+
73
+ point
74
+ end
75
+ private_class_method :recovery_point
76
+
77
+ def checksum_address(address)
78
+ return nil unless HEX_ADDRESS.match?(address.to_s)
79
+
80
+ hex = remove_hex_prefix(address).downcase
81
+ hash = bin_to_hex(keccak256(hex))
82
+ checksummed = hex.chars.each_with_index.map do |character, index|
83
+ character.match?(/[a-f]/) && hash[index].to_i(16) >= 8 ? character.upcase : character
84
+ end.join
85
+ "0x#{checksummed}"
86
+ end
87
+
88
+ def abi_encode_validator_args(address, digest, signature)
89
+ raise ArgumentError, "invalid Ethereum address" unless HEX_ADDRESS.match?(address.to_s)
90
+ raise ArgumentError, "digest must be 32 bytes" unless digest&.bytesize == 32
91
+
92
+ signature_bytes = hex_to_bin(signature)
93
+ address_word = remove_hex_prefix(address).downcase.rjust(64, "0")
94
+ digest_word = bin_to_hex(digest)
95
+ offset_word = 96.to_s(16).rjust(64, "0")
96
+ length_word = signature_bytes.bytesize.to_s(16).rjust(64, "0")
97
+ padded_signature = bin_to_hex(signature_bytes).ljust(((signature_bytes.bytesize + 31) / 32) * 64, "0")
98
+
99
+ "#{address_word}#{digest_word}#{offset_word}#{length_word}#{padded_signature}"
100
+ end
101
+
102
+ def hex_to_bin(hex)
103
+ normalized = remove_hex_prefix(hex.to_s)
104
+ unless normalized.length.even? && normalized.match?(/\A[0-9a-fA-F]*\z/)
105
+ raise ArgumentError, "invalid hexadecimal value"
106
+ end
107
+
108
+ [normalized].pack("H*")
109
+ end
110
+
111
+ def bin_to_hex(binary)
112
+ binary.unpack1("H*")
113
+ end
114
+
115
+ def remove_hex_prefix(value)
116
+ value.start_with?("0x", "0X") ? value[2..] : value
117
+ end
118
+
119
+ def recovery_id(value)
120
+ case value
121
+ when 0, 1 then value
122
+ when 27, 28 then value - 27
123
+ # Preserve eth.rb's historical default-chain behavior for EIP-155-style
124
+ # v values, even though personal_sign normally emits 0/1 or 27/28.
125
+ when 37, 38 then value - 37
126
+ else
127
+ raise SignatureError, "invalid signature recovery byte #{value}"
128
+ end
129
+ end
130
+ private_class_method :recovery_id
131
+ end
132
+ end
data/lib/siwe/eip6492.rb CHANGED
@@ -10,9 +10,6 @@ module Siwe
10
10
  # 32-byte magic suffix appended to EIP-6492 wrapped signatures.
11
11
  MAGIC_SUFFIX = "6492649264926492649264926492649264926492649264926492649264926492"
12
12
 
13
- # ERC-1271 magic value returned by `isValidSignature(bytes32,bytes)`.
14
- EIP1271_MAGIC_VALUE = "1626ba7e"
15
-
16
13
  # Off-chain universal signature validator. Deployed via eth_call (no actual
17
14
  # deployment) to verify EOA, ERC-1271, and EIP-6492 signatures in one call.
18
15
  #
data/lib/siwe/message.rb CHANGED
@@ -10,7 +10,7 @@ require_relative "response"
10
10
  require_relative "util"
11
11
 
12
12
  module Siwe
13
- # An EIP-4361 Sign-In with Ethereum message.
13
+ # An EIP-4361 Sign in with Ethereum message.
14
14
  # Construct via `Siwe::Message.new(**fields)` or parse via `Siwe::Message.parse(string)`.
15
15
  # Render via `#prepare_message` (alias `#to_eip4361`, `#to_s`).
16
16
  class Message
@@ -156,7 +156,9 @@ module Siwe
156
156
 
157
157
  def check_temporal!(time_str)
158
158
  check_at = time_str ? Time.iso8601(time_str) : Time.now.utc
159
- if @expiration_time && Time.iso8601(@expiration_time) < check_at
159
+ # `expiration-time` is the instant the message becomes invalid (ERC-4361). Reject at
160
+ # the boundary too — matches TS/Python/Rust which all use check_time >= expiration.
161
+ if @expiration_time && Time.iso8601(@expiration_time) <= check_at
160
162
  raise Error.new(ErrorType::EXPIRED_MESSAGE, expected: @expiration_time, received: check_at.iso8601)
161
163
  end
162
164
  return unless @not_before && Time.iso8601(@not_before) > check_at
@@ -167,22 +169,40 @@ module Siwe
167
169
  end
168
170
 
169
171
  def check_signature!(signature, cfg)
170
- if signature.nil? || signature.empty?
171
- raise Error.new(ErrorType::INVALID_SIGNATURE, expected: "non-empty signature", received: signature.inspect)
172
+ if signature.nil? || signature.to_s.empty?
173
+ raise Error.new(ErrorType::INVALID_PARAMS, expected: "non-empty signature", received: signature.inspect)
172
174
  end
173
175
 
174
176
  recovered = recover_eoa(signature, cfg)
175
177
  return if recovered && recovered.downcase == @address.downcase
176
178
 
179
+ # EOA recovery also accepts binary signatures. Contract signatures use
180
+ # hex of arbitrary byte length, including zero. Reject malformed hex
181
+ # here so the ABI encoder cannot leak an ArgumentError from #verify.
182
+ unless signature.is_a?(String) && signature.b.match?(/\A(?:0[xX])?(?:[0-9a-fA-F]{2})*\z/)
183
+ raise Error.new(ErrorType::INVALID_SIGNATURE, expected: "hex-encoded signature", received: signature.inspect)
184
+ end
185
+
177
186
  # EOA recovery failed or address mismatch — try smart-wallet path if configured.
178
187
  return if smart_wallet_valid?(signature, cfg)
179
188
 
180
189
  raise Error.new(ErrorType::INVALID_SIGNATURE, expected: @address, received: recovered.to_s)
181
190
  end
182
191
 
192
+ # Errors specific to signature recovery failures. Other
193
+ # exceptions (e.g. an adapter raising on misconfiguration, network errors)
194
+ # are intentionally not rescued — they signal programmer error or transport
195
+ # failure, not "this signature is bad".
196
+ EOA_RECOVERY_ERRORS = [
197
+ Crypto::SignatureError,
198
+ ArgumentError,
199
+ TypeError
200
+ ].freeze
201
+ private_constant :EOA_RECOVERY_ERRORS
202
+
183
203
  def recover_eoa(signature, cfg)
184
204
  cfg.adapter.verify_message(prepare_message, signature)
185
- rescue StandardError
205
+ rescue *EOA_RECOVERY_ERRORS
186
206
  nil
187
207
  end
188
208
 
@@ -201,11 +221,24 @@ module Siwe
201
221
  nil
202
222
  end
203
223
 
224
+ # ERC-4361 requires ERC-1271 verification to happen on the chain matching the
225
+ # message's `Chain ID`. We refuse to fall back to the smart-wallet path unless
226
+ # the RPC reports a chain id and it matches. A custom RPC client that does not
227
+ # expose chain_id must add it; a silent skip here would let an attacker validate
228
+ # against any chain a misconfigured RPC happens to be pointed at.
204
229
  def check_chain_id_match!(rpc)
205
- return unless rpc.respond_to?(:chain_id)
230
+ unless rpc.respond_to?(:chain_id)
231
+ raise Error.new(ErrorType::INVALID_SIGNATURE_CHAIN_ID,
232
+ expected: @chain_id.to_s,
233
+ received: "rpc client does not expose chain_id")
234
+ end
206
235
 
207
236
  rpc_chain = rpc.chain_id
208
- return if rpc_chain.nil? || rpc_chain == @chain_id
237
+ if rpc_chain.nil?
238
+ raise Error.new(ErrorType::INVALID_SIGNATURE_CHAIN_ID,
239
+ expected: @chain_id.to_s, received: "nil")
240
+ end
241
+ return if rpc_chain == @chain_id
209
242
 
210
243
  raise Error.new(ErrorType::INVALID_SIGNATURE_CHAIN_ID,
211
244
  expected: @chain_id.to_s, received: rpc_chain.to_s)
data/lib/siwe/parser.rb CHANGED
@@ -10,18 +10,17 @@ module Siwe
10
10
  # Returns a hash of typed fields plus an array of non-fatal warnings.
11
11
  class Parser
12
12
  HEADER_SUFFIX = " wants you to sign in with your Ethereum account:"
13
- SCHEME_REGEX = /\A([A-Za-z][A-Za-z0-9+\-.]*)\z/
14
- # RFC 3986 authority (without scheme://) — userinfo, host (reg-name/IPv4/IP-literal), port.
15
- DOMAIN_REGEX = /\A[A-Za-z0-9\-._~%!$&'()*+,;=:@\[\]]+\z/
13
+ SCHEME_REGEX = Util::SCHEME_REGEX
16
14
  ADDRESS_REGEX = /\A0x[0-9a-fA-F]{40}\z/
17
15
  NONCE_REGEX = /\A[A-Za-z0-9]{8,}\z/
18
16
  CHAIN_REGEX = /\A[0-9]+\z/
19
- # Statement chars per ABNF: %d32-33 %d35-36 %d38-59 %d61 %d63-64 %d91 %d93 %d95 %d126.
20
- # Note: explicitly excludes %d34 (") and %d37 (%). Built via Regexp.new to avoid
21
- # accidental string interpolation of `#$&` inside a regex literal.
17
+ # ERC-4361 statement = reserved / unreserved / " " per RFC 3986. The regex unions
18
+ # ALPHA + DIGIT with the punctuation in those sets; excludes LF, ", %, and other
19
+ # characters outside RFC 3986. Built via Regexp.new to avoid accidental string
20
+ # interpolation of `#$&` inside a regex literal.
22
21
  STATEMENT_REGEX = Regexp.new('\A[a-zA-Z0-9 !\x23\x24\x26-\x3B\x3D\x3F\x40\x5B\x5D\x5F\x7E]*\z')
23
- REQUEST_ID_REGEX = %r{\A[A-Za-z0-9\-._~%!$&'()*+,;=:@/?]*\z}
24
- URI_REGEX = /\A[A-Za-z][A-Za-z0-9+\-.]*:[^\s]*\z/
22
+ # request-id = *pchar per ABNF — no "/" or "?".
23
+ REQUEST_ID_REGEX = Util::PCHAR_REGEX
25
24
 
26
25
  def self.parse(str)
27
26
  new(str).parse
@@ -46,7 +45,7 @@ module Siwe
46
45
 
47
46
  statement = parse_statement_block(lines[2...uri_idx])
48
47
 
49
- uri = parse_field(lines[uri_idx], "URI: ", :invalid_uri, URI_REGEX)
48
+ uri = parse_uri_field(lines[uri_idx], "URI: ")
50
49
  version = parse_field(lines[uri_idx + 1], "Version: ", :invalid_message_version, /\A1\z/)
51
50
  chain_str = parse_field(lines[uri_idx + 2], "Chain ID: ", :unable_to_parse, CHAIN_REGEX)
52
51
  nonce = parse_field(lines[uri_idx + 3], "Nonce: ", :invalid_nonce, NONCE_REGEX)
@@ -101,7 +100,7 @@ module Siwe
101
100
 
102
101
  private
103
102
 
104
- def parse_header(line) # rubocop:disable Metrics/AbcSize
103
+ def parse_header(line)
105
104
  raise unable_to_parse("missing header") if line.nil? || line.empty?
106
105
  raise unable_to_parse("malformed header") unless line.end_with?(HEADER_SUFFIX)
107
106
 
@@ -112,22 +111,22 @@ module Siwe
112
111
  scheme, _, domain = prefix.partition("://")
113
112
  raise unable_to_parse("invalid scheme: #{scheme.inspect}") unless SCHEME_REGEX.match?(scheme)
114
113
  raise unable_to_parse("empty domain") if domain.empty?
115
- unless DOMAIN_REGEX.match?(domain)
116
- raise Error.new(ErrorType::INVALID_DOMAIN, expected: "RFC 3986 authority",
117
- received: domain)
118
- end
119
114
 
115
+ validate_domain!(domain)
120
116
  [scheme, domain]
121
117
  else
122
- unless DOMAIN_REGEX.match?(prefix)
123
- raise Error.new(ErrorType::INVALID_DOMAIN, expected: "RFC 3986 authority",
124
- received: prefix)
125
- end
126
-
118
+ validate_domain!(prefix)
127
119
  [nil, prefix]
128
120
  end
129
121
  end
130
122
 
123
+ def validate_domain!(domain)
124
+ return if Util.valid_authority?(domain) && !domain.empty?
125
+
126
+ raise Error.new(ErrorType::INVALID_DOMAIN, expected: "RFC 3986 authority",
127
+ received: domain)
128
+ end
129
+
131
130
  def parse_address(line)
132
131
  raise unable_to_parse("missing address line") if line.nil?
133
132
  unless ADDRESS_REGEX.match?(line)
@@ -188,12 +187,22 @@ module Siwe
188
187
  value
189
188
  end
190
189
 
190
+ def parse_uri_field(line, prefix)
191
+ raise unable_to_parse("missing #{prefix.strip}") if line.nil?
192
+ raise unable_to_parse("expected #{prefix.strip} line") unless line.start_with?(prefix)
193
+
194
+ value = line[prefix.length..]
195
+ raise Error.new(ErrorType::INVALID_URI, expected: "RFC 3986 URI", received: value) unless Util.valid_uri?(value)
196
+
197
+ value
198
+ end
199
+
191
200
  def parse_resources(lines, start_idx)
192
201
  idx = start_idx
193
202
  out = []
194
203
  while idx < lines.length && lines[idx].start_with?("- ")
195
204
  uri = lines[idx][2..]
196
- unless URI_REGEX.match?(uri)
205
+ unless Util.valid_uri?(uri)
197
206
  raise Error.new(ErrorType::INVALID_URI, expected: "RFC 3986 resource URI", received: uri)
198
207
  end
199
208
 
data/lib/siwe/rpc.rb CHANGED
@@ -25,6 +25,11 @@ module Siwe
25
25
  @timeout = timeout
26
26
  end
27
27
 
28
+ # Returns the chain id (Integer). Lazy-fetched via eth_chainId on first call and
29
+ # cached for the lifetime of this client. Raises Siwe::Error :rpc_error on probe
30
+ # failure rather than returning nil — callers in the smart-wallet path rely on
31
+ # this to enforce ERC-4361's chain binding (a silent nil could otherwise validate
32
+ # a signature against the wrong chain).
28
33
  def chain_id
29
34
  @chain_id ||= fetch_chain_id
30
35
  end
@@ -41,10 +46,7 @@ module Siwe
41
46
  private
42
47
 
43
48
  def fetch_chain_id
44
- hex = rpc_request("eth_chainId", [])
45
- hex.to_i(16)
46
- rescue Error
47
- nil
49
+ rpc_request("eth_chainId", []).to_i(16)
48
50
  end
49
51
 
50
52
  def rpc_request(method, params)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "eth"
3
+ require_relative "crypto"
4
4
 
5
5
  require_relative "eip6492"
6
6
  require_relative "error"
@@ -23,14 +23,11 @@ module Siwe
23
23
  # Returns true (valid) or false (validator returned non-1).
24
24
  # Re-raises Siwe::Error from the rpc client on transport errors.
25
25
  def verify(rpc:, address:, message:, signature:) # rubocop:disable Naming/PredicateMethod
26
- hash_hex = bin_to_hex(eip191_hash(message))
26
+ digest = eip191_hash(message)
27
27
  sig_hex = strip_hex_prefix(signature)
28
28
 
29
- args = Eth::Abi.encode(
30
- %w[address bytes32 bytes],
31
- [address, Eth::Util.hex_to_bin(hash_hex), Eth::Util.hex_to_bin(sig_hex)]
32
- )
33
- data = "0x#{Eip6492::VALIDATOR_BYTECODE}#{Eth::Util.bin_to_hex(args)}"
29
+ args = Crypto.abi_encode_validator_args(address, digest, sig_hex)
30
+ data = "0x#{Eip6492::VALIDATOR_BYTECODE}#{args}"
34
31
 
35
32
  result = rpc.eth_call(to: nil, data: data)
36
33
  result_indicates_valid?(result)
@@ -38,8 +35,7 @@ module Siwe
38
35
 
39
36
  # Hash a message per EIP-191 (`personal_sign`). Returns binary keccak256.
40
37
  def eip191_hash(message)
41
- prefixed = Eth::Signature.prefix_message(message)
42
- Eth::Util.keccak256(prefixed)
38
+ Crypto.eip191_hash(message)
43
39
  end
44
40
 
45
41
  # Validator returns 0x01 (possibly zero-padded to 32 bytes) for valid,
data/lib/siwe/util.rb CHANGED
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
3
4
  require "securerandom"
4
5
  require "time"
5
6
  require "date"
6
- require "eth"
7
+ require_relative "crypto"
7
8
 
8
9
  module Siwe
9
10
  module Util
@@ -23,6 +24,20 @@ module Siwe
23
24
  \z
24
25
  /x
25
26
 
27
+ # Character classes per RFC 3986. Anything outside these is rejected.
28
+ # unreserved + sub-delims + pct-encoded marker, used in reg-name.
29
+ HOST_CHAR_REGEX = /\A(?:[A-Za-z0-9\-._~!$&'()*+,;=]|%[0-9A-Fa-f]{2})*\z/
30
+ USERINFO_CHAR_REGEX = /\A(?:[A-Za-z0-9\-._~!$&'()*+,;=:]|%[0-9A-Fa-f]{2})*\z/
31
+ # pchar = unreserved / pct-encoded / sub-delims / ":" / "@". Used for request-id
32
+ # (ABNF "request-id = *pchar") and segments within a path.
33
+ PCHAR_REGEX = /\A(?:[A-Za-z0-9\-._~!$&'()*+,;=:@]|%[0-9A-Fa-f]{2})*\z/
34
+ # path = *( pchar / "/" ). No "?" or "#".
35
+ PATH_REGEX = %r{\A(?:[A-Za-z0-9\-._~!$&'()*+,;=:@/]|%[0-9A-Fa-f]{2})*\z}
36
+ # query / fragment = *( pchar / "/" / "?" ).
37
+ QUERY_FRAGMENT_REGEX = %r{\A(?:[A-Za-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9A-Fa-f]{2})*\z}
38
+ PORT_REGEX = /\A\d*\z/
39
+ SCHEME_REGEX = /\A[A-Za-z][A-Za-z0-9+\-.]*\z/
40
+
26
41
  def generate_nonce
27
42
  SecureRandom.alphanumeric(NONCE_LENGTH)
28
43
  end
@@ -43,12 +58,10 @@ module Siwe
43
58
  end
44
59
 
45
60
  def valid_address?(addr)
46
- return false if addr.nil? || addr.empty?
47
-
48
- Eth::Address.new(addr)
49
- true
50
- rescue StandardError
51
- false
61
+ case address_case(addr)
62
+ when :lower, :upper, :checksum then true
63
+ else false
64
+ end
52
65
  end
53
66
 
54
67
  def address_case(addr)
@@ -63,9 +76,114 @@ module Siwe
63
76
  end
64
77
 
65
78
  def checksum_address(addr)
66
- Eth::Address.new(addr).to_s
67
- rescue StandardError
68
- nil
79
+ Crypto.checksum_address(addr)
80
+ end
81
+
82
+ # Validate an RFC 3986 authority: [userinfo "@"] host [":" port].
83
+ # Used for both the SIWE `domain` field and any URI's authority component.
84
+ # Empty authority is valid (empty reg-name).
85
+ def valid_authority?(str)
86
+ return false if str.nil?
87
+ return true if str.empty?
88
+
89
+ userinfo, host_port = str.include?("@") ? str.split("@", 2) : [nil, str]
90
+ return false if userinfo && !USERINFO_CHAR_REGEX.match?(userinfo)
91
+
92
+ host, port = split_host_port(host_port)
93
+ return false if host.nil?
94
+ return false if port && !PORT_REGEX.match?(port)
95
+
96
+ valid_host?(host)
97
+ end
98
+
99
+ # Validate an absolute RFC 3986 URI: scheme ":" hier-part [ "?" query ] [ "#" fragment ].
100
+ def valid_uri?(str)
101
+ return false if str.nil? || str.empty?
102
+
103
+ scheme, rest = str.split(":", 2)
104
+ return false if rest.nil?
105
+ return false unless SCHEME_REGEX.match?(scheme)
106
+
107
+ m = rest.match(/\A(?<hier>[^?#]*)(?:\?(?<query>[^#]*))?(?:\#(?<frag>.*))?\z/)
108
+ return false if m.nil?
109
+ return false unless valid_hier_part?(m[:hier])
110
+ return false if m[:query] && !QUERY_FRAGMENT_REGEX.match?(m[:query])
111
+ return false if m[:frag] && !QUERY_FRAGMENT_REGEX.match?(m[:frag])
112
+
113
+ true
114
+ end
115
+
116
+ # host = IP-literal / IPv4address / reg-name. Distinguishes by leading "[".
117
+ def valid_host?(host)
118
+ if host.start_with?("[") && host.end_with?("]")
119
+ valid_ip_literal?(host[1..-2])
120
+ else
121
+ HOST_CHAR_REGEX.match?(host)
122
+ end
123
+ end
124
+
125
+ # IP-literal = IPv6address / IPvFuture (latter is rare and unused in SIWE — reject).
126
+ def valid_ip_literal?(content)
127
+ return false if content.nil? || content.empty?
128
+ return false if content.start_with?("v", "V") # IPvFuture not supported
129
+
130
+ ipv6 = content
131
+ if content.include?(".")
132
+ last_colon = content.rindex(":")
133
+ return false if last_colon.nil?
134
+
135
+ ipv4 = content[(last_colon + 1)..]
136
+ return false unless valid_dotted_ipv4?(ipv4)
137
+
138
+ # IPAddr rejects leading zeros in IPv4 octets ("zero-filled ambiguous"), but the
139
+ # SIWE test vectors treat them as valid. Rewrite the IPv4 tail as two h16 groups.
140
+ octets = ipv4.split(".").map(&:to_i)
141
+ g1 = format("%x", (octets[0] << 8) | octets[1])
142
+ g2 = format("%x", (octets[2] << 8) | octets[3])
143
+ ipv6 = "#{content[0..last_colon]}#{g1}:#{g2}"
144
+ end
145
+
146
+ IPAddr.new(ipv6).ipv6?
147
+ rescue IPAddr::InvalidAddressError
148
+ false
149
+ end
150
+
151
+ def valid_dotted_ipv4?(addr)
152
+ octets = addr.split(".", -1)
153
+ return false unless octets.length == 4
154
+
155
+ octets.all? { |o| o.match?(/\A\d{1,3}\z/) && o.to_i <= 255 }
156
+ end
157
+
158
+ def split_host_port(str)
159
+ if str.start_with?("[")
160
+ close = str.index("]")
161
+ return [nil, nil] if close.nil?
162
+
163
+ host = str[0..close]
164
+ rest = str[(close + 1)..]
165
+ if rest.empty?
166
+ [host, nil]
167
+ elsif rest.start_with?(":")
168
+ [host, rest[1..]]
169
+ else
170
+ [nil, nil]
171
+ end
172
+ elsif (colon = str.rindex(":"))
173
+ [str[0...colon], str[(colon + 1)..]]
174
+ else
175
+ [str, nil]
176
+ end
177
+ end
178
+
179
+ def valid_hier_part?(hier)
180
+ if hier.start_with?("//")
181
+ rest = hier[2..]
182
+ authority_end = rest.index("/") || rest.length
183
+ valid_authority?(rest[0...authority_end]) && PATH_REGEX.match?(rest[authority_end..])
184
+ else
185
+ PATH_REGEX.match?(hier)
186
+ end
69
187
  end
70
188
  end
71
189
  end
data/lib/siwe/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Siwe
4
- VERSION = "0.1.2"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/siwe.rb CHANGED
@@ -10,6 +10,7 @@ module Siwe
10
10
  autoload :Parser, "siwe/parser"
11
11
  autoload :Message, "siwe/message"
12
12
  autoload :Config, "siwe/config"
13
+ autoload :Crypto, "siwe/crypto"
13
14
  autoload :Adapter, "siwe/adapter"
14
15
  autoload :Rpc, "siwe/rpc"
15
16
  autoload :SmartWallet, "siwe/smart_wallet"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: siwe-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - EthID.org
@@ -10,25 +10,19 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: eth
13
+ name: keccak
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - ">="
16
+ - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.5.11
19
- - - "<"
20
- - !ruby/object:Gem::Version
21
- version: '1.0'
18
+ version: '1.3'
22
19
  type: :runtime
23
20
  prerelease: false
24
21
  version_requirements: !ruby/object:Gem::Requirement
25
22
  requirements:
26
- - - ">="
27
- - !ruby/object:Gem::Version
28
- version: 0.5.11
29
- - - "<"
23
+ - - "~>"
30
24
  - !ruby/object:Gem::Version
31
- version: '1.0'
25
+ version: '1.3'
32
26
  description: EIP-4361 message construction, parsing, and signature verification, with
33
27
  built-in support for ERC-1271 and EIP-6492 smart contract wallets.
34
28
  email:
@@ -46,6 +40,7 @@ files:
46
40
  - lib/siwe.rb
47
41
  - lib/siwe/adapter.rb
48
42
  - lib/siwe/config.rb
43
+ - lib/siwe/crypto.rb
49
44
  - lib/siwe/eip6492.rb
50
45
  - lib/siwe/error.rb
51
46
  - lib/siwe/error_type.rb
@@ -56,7 +51,6 @@ files:
56
51
  - lib/siwe/smart_wallet.rb
57
52
  - lib/siwe/util.rb
58
53
  - lib/siwe/version.rb
59
- - siwe-rb.gemspec
60
54
  homepage: https://github.com/signinwithethereum/siwe-rb
61
55
  licenses:
62
56
  - MIT
@@ -79,7 +73,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
73
  - !ruby/object:Gem::Version
80
74
  version: '0'
81
75
  requirements: []
82
- rubygems_version: 4.0.3
76
+ rubygems_version: 4.0.16
83
77
  specification_version: 4
84
- summary: Sign-In with Ethereum (EIP-4361) for Ruby
78
+ summary: Sign in with Ethereum (EIP-4361) for Ruby
85
79
  test_files: []
data/siwe-rb.gemspec DELETED
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/siwe/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "siwe-rb"
7
- spec.version = Siwe::VERSION
8
- spec.authors = ["EthID.org"]
9
- spec.email = ["jalil@ethfollow.xyz"]
10
-
11
- spec.summary = "Sign-In with Ethereum (EIP-4361) for Ruby"
12
- spec.description = "EIP-4361 message construction, parsing, and signature verification, " \
13
- "with built-in support for ERC-1271 and EIP-6492 smart contract wallets."
14
- spec.homepage = "https://github.com/signinwithethereum/siwe-rb"
15
- spec.license = "MIT"
16
- spec.required_ruby_version = ">= 3.3"
17
-
18
- spec.metadata["homepage_uri"] = spec.homepage
19
- spec.metadata["source_code_uri"] = spec.homepage
20
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
- spec.metadata["rubygems_mfa_required"] = "true"
22
-
23
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
24
- `git ls-files -z`.split("\x0").reject do |f|
25
- f == __FILE__ ||
26
- f.start_with?("spec/", "test/", "features/", "bin/", "gems/", "script/") ||
27
- f.match?(/\A\.(?:git|github|rubocop|ruby-version|rspec)/)
28
- end
29
- end
30
- spec.require_paths = ["lib"]
31
-
32
- spec.add_dependency "eth", ">= 0.5.11", "< 1.0"
33
- end