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 +4 -4
- data/CHANGELOG.md +20 -0
- data/Gemfile.lock +3 -78
- data/README.md +22 -4
- data/lib/siwe/adapter.rb +7 -22
- data/lib/siwe/config.rb +8 -20
- data/lib/siwe/crypto.rb +132 -0
- data/lib/siwe/eip6492.rb +0 -3
- data/lib/siwe/message.rb +40 -7
- data/lib/siwe/parser.rb +29 -20
- data/lib/siwe/rpc.rb +6 -4
- data/lib/siwe/smart_wallet.rb +5 -9
- data/lib/siwe/util.rb +128 -10
- data/lib/siwe/version.rb +1 -1
- data/lib/siwe.rb +1 -0
- metadata +9 -15
- data/siwe-rb.gemspec +0 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b6bc3df2351b75f3628baf83e1a49b32967538ce242b76407b0d978f7777fa2
|
|
4
|
+
data.tar.gz: ddfd9da52f3d0dd77504c3df22ec547091b7038356623891ac465d008298d68b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
5
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
161
|
+
MIT.
|
data/lib/siwe/adapter.rb
CHANGED
|
@@ -1,33 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
7
|
-
#
|
|
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
|
|
11
|
+
attr_reader :rpc_url, :rpc, :adapter
|
|
12
12
|
|
|
13
|
-
def initialize(rpc_url: nil, rpc: nil, adapter: 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
|
|
26
|
+
attr_accessor :rpc_url, :rpc, :adapter
|
|
33
27
|
|
|
34
|
-
def initialize(rpc_url: nil, rpc: nil, adapter: 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
|
data/lib/siwe/crypto.rb
ADDED
|
@@ -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
|
|
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
|
-
|
|
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::
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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
|
-
|
|
24
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
data/lib/siwe/smart_wallet.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
26
|
+
digest = eip191_hash(message)
|
|
27
27
|
sig_hex = strip_hex_prefix(signature)
|
|
28
28
|
|
|
29
|
-
args =
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
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.
|
|
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:
|
|
13
|
+
name: keccak
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
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.
|
|
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.
|
|
76
|
+
rubygems_version: 4.0.16
|
|
83
77
|
specification_version: 4
|
|
84
|
-
summary: Sign
|
|
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
|