saro-dat 4.6.0 → 4.6.1
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/.idea/saro-dat.iml +3 -1
- data/PUBLISH.md +6 -0
- data/lib/saro/dat/crypto.rb +1 -15
- data/lib/saro/dat/dat.rb +0 -14
- data/lib/saro/dat/dat_certificate.rb +0 -4
- data/lib/saro/dat/dat_cms_manager.rb +1 -33
- data/lib/saro/dat/dat_manager.rb +0 -26
- data/lib/saro/dat/error.rb +0 -41
- data/lib/saro/dat/signature.rb +1 -21
- data/lib/saro/dat/util.rb +0 -10
- data/lib/saro-dat.rb +0 -1
- data/saro-dat.gemspec +2 -4
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b6d5127dce2e1d7ca632f94e2fa8d6b6a1434a37ab285bbf43dfc589282fccbb
|
|
4
|
+
data.tar.gz: e26c938570bfe16eae48746935a541e6ed552653b302cf7d15c10b7304dbc0af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb31b09e921a1222111ae2a5ce992ef12e76ac9c776039ebb7f408deaf6255a026f7f25ee820bddc055bbbb9ad84c3e24b493a9e489d6082ceba16fdd9037115
|
|
7
|
+
data.tar.gz: 0041f03e782e284867e46d744bf0d9360c6ff0f24bda8af74ae39dc396e0700bdd3b6fd85a22a618e9f9a3590f8632c7ba81c8f20d2848a365bea1105565acbb
|
data/.idea/saro-dat.iml
CHANGED
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
<orderEntry type="library" scope="PROVIDED" name="base64 (v0.3.0, rbenv: 4.0.5) [gem]" level="application" />
|
|
15
15
|
<orderEntry type="library" scope="PROVIDED" name="benchmark (v0.5.0, rbenv: 4.0.5) [gem]" level="application" />
|
|
16
16
|
<orderEntry type="library" scope="PROVIDED" name="bundler (v4.0.12, rbenv: 4.0.5) [gem]" level="application" />
|
|
17
|
+
<orderEntry type="library" scope="PROVIDED" name="drb (v2.2.3, rbenv: 4.0.5) [gem]" level="application" />
|
|
17
18
|
<orderEntry type="library" scope="PROVIDED" name="logger (v1.7.0, rbenv: 4.0.5) [gem]" level="application" />
|
|
18
|
-
<orderEntry type="library" scope="PROVIDED" name="minitest (
|
|
19
|
+
<orderEntry type="library" scope="PROVIDED" name="minitest (v6.0.6, rbenv: 4.0.5) [gem]" level="application" />
|
|
19
20
|
<orderEntry type="library" scope="PROVIDED" name="openssl (v4.0.2, rbenv: 4.0.5) [gem]" level="application" />
|
|
20
21
|
<orderEntry type="library" scope="PROVIDED" name="parallel (v2.1.0, rbenv: 4.0.5) [gem]" level="application" />
|
|
22
|
+
<orderEntry type="library" scope="PROVIDED" name="prism (v1.9.0, rbenv: 4.0.5) [gem]" level="application" />
|
|
21
23
|
</component>
|
|
22
24
|
</module>
|
data/PUBLISH.md
CHANGED
data/lib/saro/dat/crypto.rb
CHANGED
|
@@ -32,8 +32,6 @@ module Saro
|
|
|
32
32
|
|
|
33
33
|
def initialize(algorithm, key_bytes, config = nil)
|
|
34
34
|
@config = config || Saro::Dat.get_crypto_config(algorithm)
|
|
35
|
-
# OpenSSL accepts any valid AES length, so a 16-byte key would silently
|
|
36
|
-
# become AES-128 under an IV-AES256-GCM label without this check.
|
|
37
35
|
if key_bytes.bytesize != @config[:length]
|
|
38
36
|
raise Saro::Dat::Error.new(
|
|
39
37
|
Saro::Dat::ErrorCode::KEY_INVALID,
|
|
@@ -82,24 +80,16 @@ module Saro
|
|
|
82
80
|
nonce + ciphertext + tag
|
|
83
81
|
end
|
|
84
82
|
|
|
85
|
-
# Decrypts base64url-encoded ciphertext.
|
|
86
83
|
def decrypt_base64(base64_str)
|
|
87
84
|
decrypt(Saro::Dat::Util.decode_base64_url(base64_str))
|
|
88
85
|
end
|
|
89
86
|
|
|
90
|
-
# Decrypts raw (already decoded) ciphertext: nonce(12) + ciphertext + tag(16).
|
|
91
|
-
# Use #decrypt_base64 for encoded input. The branch used to be picked from
|
|
92
|
-
# the string's encoding tag, so raw ciphertext that merely carried a UTF-8
|
|
93
|
-
# tag was silently mangled by the base64 decoder.
|
|
94
87
|
def decrypt(data)
|
|
95
88
|
return "".b if data.nil? || data.empty?
|
|
96
89
|
|
|
97
|
-
# Slice by bytes, never by characters: String#[] is character-indexed on
|
|
98
|
-
# a non-binary string, so raw ciphertext carrying a UTF-8 tag would be
|
|
99
|
-
# split at the wrong offsets.
|
|
100
90
|
data = data.b if data.is_a?(String) && data.encoding != Encoding::BINARY
|
|
101
91
|
|
|
102
|
-
if data.bytesize <= 12 + 16
|
|
92
|
+
if data.bytesize <= 12 + 16
|
|
103
93
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CRYPTO_DATA_INVALID, "ciphertext is shorter than iv(12) + tag(16)")
|
|
104
94
|
end
|
|
105
95
|
|
|
@@ -114,10 +104,6 @@ module Saro
|
|
|
114
104
|
cipher.iv = nonce
|
|
115
105
|
cipher.auth_tag = tag
|
|
116
106
|
|
|
117
|
-
# cipher.final 에서 나는 실패는 GCM 인증 태그 불일치다 — 변조된 secure 이거나
|
|
118
|
-
# 잘못된 인증서 키다. 예전에는 OpenSSL::Cipher::CipherError 가 그대로 공개
|
|
119
|
-
# API 밖으로 새어 나갔다. 서명 검증을 건너뛰는 경로에서는 이것이 유일한
|
|
120
|
-
# 무결성 검사이므로 백엔드 오류(CRYPTO_BACKEND)와 반드시 구분한다.
|
|
121
107
|
begin
|
|
122
108
|
res = cipher.update(ciphertext) + cipher.final
|
|
123
109
|
rescue OpenSSL::Cipher::CipherError => e
|
data/lib/saro/dat/dat.rb
CHANGED
|
@@ -8,11 +8,6 @@ module Saro
|
|
|
8
8
|
class Dat
|
|
9
9
|
attr_reader :dat, :expire, :cid, :plain, :secure, :signature, :format
|
|
10
10
|
|
|
11
|
-
# 파싱이 실패한 이유. 성공이면 nil 이다.
|
|
12
|
-
#
|
|
13
|
-
# 예전에는 빈 `rescue StandardError` 가 모든 실패를 삼키고 @format=false
|
|
14
|
-
# 하나만 남겼다. 어느 필드가 왜 틀렸는지가 전부 사라져 호출부는
|
|
15
|
-
# "Invalid DAT: Format" 밖에 볼 수 없었다.
|
|
16
11
|
attr_reader :error
|
|
17
12
|
|
|
18
13
|
def initialize(dat_str)
|
|
@@ -30,18 +25,12 @@ module Saro
|
|
|
30
25
|
return
|
|
31
26
|
end
|
|
32
27
|
|
|
33
|
-
# 1) 먼저 구조를 확정한다. 파트가 5개가 아니면 그건 만료된 토큰이 아니라
|
|
34
|
-
# 애초에 토큰이 아니다.
|
|
35
|
-
# split 의 limit 을 -1 로 준다: 기본값은 뒤쪽 빈 필드를 버려서
|
|
36
|
-
# "a.b.c.d.e." (6필드) 가 5파트로 보였고, 빈 서명("a.b.c.d.")은
|
|
37
|
-
# 4파트로 보여 서명 오류를 구조 오류로 오인하게 만들었다.
|
|
38
28
|
parts = @dat.split('.', -1)
|
|
39
29
|
if parts.length != 5
|
|
40
30
|
@error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::TOKEN_MALFORMED, "expected exactly 5 dot-separated fields")
|
|
41
31
|
return
|
|
42
32
|
end
|
|
43
33
|
|
|
44
|
-
# 2) 구조가 맞은 뒤에야 값을 본다. 필드마다 어디서 틀렸는지 코드를 붙인다.
|
|
45
34
|
begin
|
|
46
35
|
@expire = Saro::Dat::Util.parse_u64(parts[0])
|
|
47
36
|
rescue Saro::Dat::Error => e
|
|
@@ -70,8 +59,6 @@ module Saro
|
|
|
70
59
|
return
|
|
71
60
|
end
|
|
72
61
|
|
|
73
|
-
# 빈 서명은 구조 오류가 아니라 서명 오류다 (error.pre2.md: DAT_SIG_MALFORMED
|
|
74
|
-
# 가 "빈 서명"을 포함한다). 위조(SIG_MISMATCH)와도 구분된다.
|
|
75
62
|
if parts[4].empty?
|
|
76
63
|
@error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_MALFORMED, "signature field is empty")
|
|
77
64
|
return
|
|
@@ -92,7 +79,6 @@ module Saro
|
|
|
92
79
|
@format = true
|
|
93
80
|
end
|
|
94
81
|
|
|
95
|
-
# 파싱에 실패했으면 그 코드로 던진다.
|
|
96
82
|
def raise_if_invalid!
|
|
97
83
|
raise @error if @error
|
|
98
84
|
nil
|
|
@@ -15,8 +15,6 @@ module Saro
|
|
|
15
15
|
@dat_issuance_start_seconds = u64!("dat_issuance_start_seconds", dat_issuance_start_seconds)
|
|
16
16
|
duration = u64!("dat_issuance_duration_seconds", dat_issuance_duration_seconds)
|
|
17
17
|
@dat_ttl_seconds = u64!("dat_ttl_seconds", dat_ttl_seconds)
|
|
18
|
-
# Ruby 정수는 자동으로 bignum 이 되므로 그냥 더하면 u64 를 넘겨도 조용히
|
|
19
|
-
# 통과한다. 기준 구현(rust)의 checked_add 와 같은 경계를 여기서 강제한다.
|
|
20
18
|
@dat_issuance_end_seconds = u64!(
|
|
21
19
|
"dat_issuance_start_seconds + dat_issuance_duration_seconds",
|
|
22
20
|
@dat_issuance_start_seconds + duration
|
|
@@ -56,7 +54,6 @@ module Saro
|
|
|
56
54
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CERT_MALFORMED, "expected exactly 8 dot-separated fields")
|
|
57
55
|
end
|
|
58
56
|
|
|
59
|
-
# 필드 파싱 실패는 인증서가 깨진 것이지 호출자의 인자 문제가 아니다.
|
|
60
57
|
cid = _field("cid") { Saro::Dat::Util.parse_u64_hex(parts[0]) }
|
|
61
58
|
dat_issuance_start_seconds = _field("issuance_start_seconds") { Saro::Dat::Util.parse_u64(parts[1]) }
|
|
62
59
|
dat_issuance_duration_seconds = _field("issuance_duration_seconds") { Saro::Dat::Util.parse_u64(parts[2]) }
|
|
@@ -107,7 +104,6 @@ module Saro
|
|
|
107
104
|
end
|
|
108
105
|
private_class_method :_field
|
|
109
106
|
|
|
110
|
-
# For Ruby conventions
|
|
111
107
|
alias_method :issuable?, :issuable
|
|
112
108
|
alias_method :expired?, :expired
|
|
113
109
|
alias_method :signable?, :signable
|
|
@@ -13,9 +13,6 @@ module Saro
|
|
|
13
13
|
class DatCmsManager
|
|
14
14
|
DAT_CMS_API_VERSION = "v1"
|
|
15
15
|
|
|
16
|
-
# `stop` waits at most this long for an in-flight HTTP sync to finish.
|
|
17
|
-
# The thread is never killed: past the grace period it is left to complete
|
|
18
|
-
# its request and exit on its own at the top of the loop.
|
|
19
16
|
STOP_JOIN_TIMEOUT_SECONDS = 1.0
|
|
20
17
|
|
|
21
18
|
def initialize(uri:, token:, interval_seconds: 60, verify_only: false, dat_manager: nil)
|
|
@@ -25,18 +22,12 @@ module Saro
|
|
|
25
22
|
@verify_only = verify_only
|
|
26
23
|
@manager = dat_manager || DatManager.new
|
|
27
24
|
@version = 0
|
|
28
|
-
# Two separate locks: @lock guards an in-flight sync (taken non-blocking,
|
|
29
|
-
# held across the HTTP request), @lifecycle guards the stop flag and the
|
|
30
|
-
# sleep condition. Sharing one lock made `stop` block for the whole
|
|
31
|
-
# request timeout.
|
|
32
25
|
@lock = Mutex.new
|
|
33
26
|
@lifecycle = Mutex.new
|
|
34
27
|
@stop_cond = ConditionVariable.new
|
|
35
28
|
@stopped = false
|
|
36
29
|
@logger = Logger.new($stdout)
|
|
37
30
|
@logger.level = Logger::DEBUG
|
|
38
|
-
# 최초 sync 실패는 여전히 생성을 막지 않는다(list.md F-3). 다만 이제 로그로만
|
|
39
|
-
# 사라지지 않고 #last_error 로 조회할 수 있다.
|
|
40
31
|
@last_error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_NOT_SYNCED)
|
|
41
32
|
|
|
42
33
|
sync
|
|
@@ -51,8 +42,6 @@ module Saro
|
|
|
51
42
|
@lifecycle.synchronize do
|
|
52
43
|
return if @stopped
|
|
53
44
|
@stopped = true
|
|
54
|
-
# Wakes the scheduler out of its sleep immediately instead of killing
|
|
55
|
-
# it in the middle of a request.
|
|
56
45
|
@stop_cond.broadcast
|
|
57
46
|
thread = @thread
|
|
58
47
|
end
|
|
@@ -64,8 +53,6 @@ module Saro
|
|
|
64
53
|
@lifecycle.synchronize { @stopped }
|
|
65
54
|
end
|
|
66
55
|
|
|
67
|
-
# 마지막 동기화 실패. 한 번도 성공하지 못했으면 DAT_CMS_NOT_SYNCED, 정상이면 nil.
|
|
68
|
-
# 재시도 여부는 err.retry 로 판정한다.
|
|
69
56
|
attr_reader :last_error
|
|
70
57
|
|
|
71
58
|
def version
|
|
@@ -77,7 +64,6 @@ module Saro
|
|
|
77
64
|
@last_error = nil
|
|
78
65
|
err
|
|
79
66
|
rescue Saro::Dat::Error => e
|
|
80
|
-
# 상태 신호는 실패로 기록하지 않는다 — 이전 동기화가 도는 중일 뿐이다.
|
|
81
67
|
unless e.retry == :state
|
|
82
68
|
@last_error = e
|
|
83
69
|
@logger.error("[CRITICAL] DAT CMS SYNC #{@uri}: #{e.code} #{e.detail}")
|
|
@@ -89,10 +75,7 @@ module Saro
|
|
|
89
75
|
nil
|
|
90
76
|
end
|
|
91
77
|
|
|
92
|
-
# 실패를 코드로 던진다. #sync 는 이것을 잡아 #last_error 에 담기만 한다 —
|
|
93
|
-
# 기존 호출부가 갑자기 예외를 받지 않도록.
|
|
94
78
|
private def sync_or_raise
|
|
95
|
-
# non-blocking lock
|
|
96
79
|
unless @lock.try_lock
|
|
97
80
|
@logger.debug("cms sync skipped, previous sync still running: #{@uri}")
|
|
98
81
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_SYNC_IN_PROGRESS)
|
|
@@ -103,7 +86,6 @@ module Saro
|
|
|
103
86
|
request = Net::HTTP::Get.new(url)
|
|
104
87
|
request["Authorization"] = @token
|
|
105
88
|
|
|
106
|
-
# 연결 거부·DNS 실패·TLS 실패·타임아웃이 전부 여기로 온다. 전부 일시적이다.
|
|
107
89
|
response =
|
|
108
90
|
begin
|
|
109
91
|
Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == 'https', open_timeout: 10, read_timeout: 10) do |http|
|
|
@@ -113,8 +95,6 @@ module Saro
|
|
|
113
95
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_UNREACHABLE, "cannot reach #{@uri}", cause: e)
|
|
114
96
|
end
|
|
115
97
|
|
|
116
|
-
# HTTP 상태를 갈라 낸다. 예전에는 전부 하나의 로그라 401(영구)에도
|
|
117
|
-
# 60초마다 영원히 재시도했다.
|
|
118
98
|
status = response.code.to_i
|
|
119
99
|
raise self.class.http_status_error(status) unless status.between?(200, 299)
|
|
120
100
|
|
|
@@ -141,26 +121,17 @@ module Saro
|
|
|
141
121
|
end
|
|
142
122
|
new_version = new_version_str.to_i
|
|
143
123
|
|
|
144
|
-
# 서버가 우리보다 과거 버전을 돌려주면 전체 재동기화 지시다. 오류가 아니라
|
|
145
|
-
# 상태 신호이며, 아래 imports 가 clear: true 라 그 자체로 처리된다.
|
|
146
124
|
if new_version < @version
|
|
147
125
|
@logger.warn("#{Saro::Dat::ErrorCode::CMS_VERSION_RESET}: #{@version} -> #{new_version}")
|
|
148
126
|
end
|
|
149
127
|
|
|
150
|
-
# 인증서 적용 실패의 원인(CERT_*/KEY_*)을 버리지 않고 체이닝한다.
|
|
151
128
|
renew_count =
|
|
152
129
|
begin
|
|
153
|
-
|
|
154
|
-
# response is the authoritative full set for that version, so
|
|
155
|
-
# revoked certificates disappear instead of lingering until they
|
|
156
|
-
# expire on their own.
|
|
157
|
-
@manager.imports(new_certificates, clear: true)
|
|
130
|
+
@manager.imports(new_certificates, clear: false)
|
|
158
131
|
rescue Saro::Dat::Error => e
|
|
159
132
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_IMPORT_FAILED, "cannot apply received certificates", cause: e)
|
|
160
133
|
end
|
|
161
134
|
|
|
162
|
-
# Only a successful import advances the version, matching rust:
|
|
163
|
-
# a rejected payload is re-requested rather than skipped.
|
|
164
135
|
@version = new_version
|
|
165
136
|
@logger.debug("Renewed #{renew_count} certificates for version #{new_version}: #{url}")
|
|
166
137
|
nil
|
|
@@ -206,8 +177,6 @@ module Saro
|
|
|
206
177
|
end
|
|
207
178
|
end
|
|
208
179
|
|
|
209
|
-
# Interruptible sleep: returns false as soon as `stop` signals, so the
|
|
210
|
-
# scheduler exits promptly without Thread#kill.
|
|
211
180
|
def wait_interval
|
|
212
181
|
@lifecycle.synchronize do
|
|
213
182
|
return false if @stopped
|
|
@@ -219,7 +188,6 @@ module Saro
|
|
|
219
188
|
def run_sync_task
|
|
220
189
|
sync
|
|
221
190
|
rescue StandardError => e
|
|
222
|
-
# sync 는 이미 스스로 삼키므로 여기 오는 것은 예상 밖의 실패다.
|
|
223
191
|
@logger.error("Error in sync task: #{e.message}")
|
|
224
192
|
end
|
|
225
193
|
end
|
data/lib/saro/dat/dat_manager.rb
CHANGED
|
@@ -10,9 +10,6 @@ require_relative 'util'
|
|
|
10
10
|
module Saro
|
|
11
11
|
module Dat
|
|
12
12
|
class DatManager
|
|
13
|
-
# Immutable snapshot of the manager state.
|
|
14
|
-
# Readers access it lock-free via a single ivar read (atomic reference swap);
|
|
15
|
-
# writers rebuild a new frozen snapshot under @write_lock.
|
|
16
13
|
State = Struct.new(:issuer, :certificates, :by_cid)
|
|
17
14
|
|
|
18
15
|
EMPTY_STATE = State.new(nil, [].freeze, {}.freeze).freeze
|
|
@@ -24,13 +21,8 @@ module Saro
|
|
|
24
21
|
end
|
|
25
22
|
|
|
26
23
|
def import_certificates(input_certs, clear: false)
|
|
27
|
-
# rust returns early on an empty input without touching the state, so an
|
|
28
|
-
# empty CMS response can never wipe the certificates held by a manager
|
|
29
|
-
# that is importing with clear: true.
|
|
30
24
|
return 0 if input_certs.nil? || input_certs.empty?
|
|
31
25
|
|
|
32
|
-
# Duplicate detection runs before any mutation (rust checks the whole
|
|
33
|
-
# input up front), so a bad payload cannot leave a half-applied state.
|
|
34
26
|
seen_cids = Set.new
|
|
35
27
|
input_certs.each do |cert|
|
|
36
28
|
if seen_cids.include?(cert.cid)
|
|
@@ -53,12 +45,9 @@ module Saro
|
|
|
53
45
|
renew_count += 1
|
|
54
46
|
end
|
|
55
47
|
|
|
56
|
-
# Expired certificates are dropped from the *merged* list, not just
|
|
57
|
-
# from the incoming one, so a renewal sweeps out what has aged out.
|
|
58
48
|
certificates.reject!(&:expired)
|
|
59
49
|
certificates.sort_by!(&:dat_issuance_end_seconds)
|
|
60
50
|
|
|
61
|
-
# Find latest issuable certificate as issuer
|
|
62
51
|
issuer = certificates.reverse_each.find(&:issuable)
|
|
63
52
|
|
|
64
53
|
by_cid = {}
|
|
@@ -87,9 +76,6 @@ module Saro
|
|
|
87
76
|
state = @state
|
|
88
77
|
issuer = state.issuer
|
|
89
78
|
unless issuer
|
|
90
|
-
# 예전에는 이 다섯 가지가 "Invalid DAT: Signing Key Does Not Exist"
|
|
91
|
-
# 문자열 하나였다. 대응이 전부 다르다 — 발급창 전이면 기다리면 되고,
|
|
92
|
-
# verify-only 뿐이면 배포 설정 실수이며, 0건이면 CMS 접속 문제다.
|
|
93
79
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::MANAGER_NO_CERTIFICATE) if state.certificates.empty?
|
|
94
80
|
raise Saro::Dat::Error.new(
|
|
95
81
|
Saro::Dat::ErrorCode::MANAGER_NO_ISSUABLE_CERTIFICATE,
|
|
@@ -102,11 +88,8 @@ module Saro
|
|
|
102
88
|
|
|
103
89
|
def parse(dat_input)
|
|
104
90
|
dat = Saro::Dat::Dat.from_value(dat_input)
|
|
105
|
-
# 파싱 실패의 코드를 그대로 올린다.
|
|
106
91
|
dat.raise_if_invalid!
|
|
107
92
|
|
|
108
|
-
# 만료를 cid 조회보다 먼저 본다. 기준 구현(rust)이 토큰을 읽는 시점에
|
|
109
|
-
# 만료를 판정하므로, 모르는 cid 의 만료 토큰도 만료로 보고된다.
|
|
110
93
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::TOKEN_EXPIRED) if dat.expired?
|
|
111
94
|
|
|
112
95
|
certificate = @state.by_cid[dat.cid]
|
|
@@ -117,7 +100,6 @@ module Saro
|
|
|
117
100
|
self.class._parse(certificate, dat)
|
|
118
101
|
end
|
|
119
102
|
|
|
120
|
-
# 발급 가능한 인증서가 없을 때 왜 없는지 가려낸다.
|
|
121
103
|
private def no_issuable_cause(certificates)
|
|
122
104
|
now = Time.now.to_i
|
|
123
105
|
signable_seen = false
|
|
@@ -138,7 +120,6 @@ module Saro
|
|
|
138
120
|
if !signable_seen
|
|
139
121
|
Saro::Dat::ErrorCode::CERT_VERIFY_ONLY
|
|
140
122
|
elsif not_yet
|
|
141
|
-
# 기다리면 풀리는 유일한 사유다. 하나라도 있으면 이것을 앞세운다.
|
|
142
123
|
Saro::Dat::ErrorCode::CERT_NOT_YET_ISSUABLE
|
|
143
124
|
elsif ended
|
|
144
125
|
Saro::Dat::ErrorCode::CERT_ISSUANCE_ENDED
|
|
@@ -148,10 +129,6 @@ module Saro
|
|
|
148
129
|
Saro::Dat::Error.new(code)
|
|
149
130
|
end
|
|
150
131
|
|
|
151
|
-
# NOTE: 아래 `private` 는 `def self.` 메서드에 적용되지 않는다. _issue/_parse 는
|
|
152
|
-
# 예전부터 실제로는 public 이었고 테스트·벤치가 그렇게 쓰고 있다. 오해를 없애려
|
|
153
|
-
# 위치를 옮기고, 정말 감춰야 하는 것만 private_class_method 로 막는다.
|
|
154
|
-
|
|
155
132
|
def self._issue(cert, plain, secure)
|
|
156
133
|
now = Time.now.to_i
|
|
157
134
|
expire = now + cert.dat_ttl_seconds
|
|
@@ -170,12 +147,9 @@ module Saro
|
|
|
170
147
|
|
|
171
148
|
def self._parse(cert, dat_input)
|
|
172
149
|
dat = Saro::Dat::Dat.from_value(dat_input)
|
|
173
|
-
# 같은 조건이 여기서는 RuntimeError, DatManager#parse 에서는 ArgumentError
|
|
174
|
-
# 였다. 이제 양쪽 모두 같은 코드를 던진다.
|
|
175
150
|
dat.raise_if_invalid!
|
|
176
151
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::TOKEN_EXPIRED) if dat.expired?
|
|
177
152
|
|
|
178
|
-
# verify 는 불일치일 때만 false 를 준다. 연산 실패는 SIG_BACKEND 로 올라온다.
|
|
179
153
|
unless cert.signature_key.verify(dat.body_string, dat.signature)
|
|
180
154
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_MISMATCH)
|
|
181
155
|
end
|
data/lib/saro/dat/error.rb
CHANGED
|
@@ -2,21 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Saro
|
|
4
4
|
module Dat
|
|
5
|
-
# DAT 통합 오류 코드 (error.pre2.md).
|
|
6
|
-
#
|
|
7
|
-
# 코드 문자열은 모든 공식 클라이언트와 CMS 서버가 공유하는 공개 계약이다. 메시지는 자유롭게 바꿔도
|
|
8
|
-
# 되지만 코드는 바꾸지 않는다.
|
|
9
|
-
#
|
|
10
|
-
# - 분류는 원인이다. "어느 함수에서 났는가"가 아니라 "무엇이 잘못됐는가"다.
|
|
11
|
-
# - *_UNKNOWN 은 각 영역의 폴백 전용이다. "알 수 없는 X" 라는 뜻으로 쓰지 않는다.
|
|
12
|
-
# - 하위 원인은 버리지 않고 #cause 로 보존한다.
|
|
13
5
|
module ErrorCode
|
|
14
|
-
# TOKEN : DAT 토큰 문자열
|
|
15
6
|
TOKEN_MALFORMED = "DAT_TOKEN_MALFORMED"
|
|
16
7
|
TOKEN_EXPIRED = "DAT_TOKEN_EXPIRED"
|
|
17
8
|
TOKEN_UNKNOWN = "DAT_TOKEN_UNKNOWN"
|
|
18
9
|
|
|
19
|
-
# CERT : 인증서
|
|
20
10
|
CERT_MALFORMED = "DAT_CERT_MALFORMED"
|
|
21
11
|
CERT_EXPIRED = "DAT_CERT_EXPIRED"
|
|
22
12
|
CERT_NOT_YET_ISSUABLE = "DAT_CERT_NOT_YET_ISSUABLE"
|
|
@@ -27,31 +17,26 @@ module Saro
|
|
|
27
17
|
CERT_DUPLICATE_CID = "DAT_CERT_DUPLICATE_CID"
|
|
28
18
|
CERT_UNKNOWN = "DAT_CERT_UNKNOWN"
|
|
29
19
|
|
|
30
|
-
# SIG : 서명
|
|
31
20
|
SIG_MISMATCH = "DAT_SIG_MISMATCH"
|
|
32
21
|
SIG_MALFORMED = "DAT_SIG_MALFORMED"
|
|
33
22
|
SIG_KEY_MISSING = "DAT_SIG_KEY_MISSING"
|
|
34
23
|
SIG_BACKEND = "DAT_SIG_BACKEND"
|
|
35
24
|
SIG_UNKNOWN = "DAT_SIG_UNKNOWN"
|
|
36
25
|
|
|
37
|
-
# CRYPTO : secure 페이로드
|
|
38
26
|
CRYPTO_TAG_MISMATCH = "DAT_CRYPTO_TAG_MISMATCH"
|
|
39
27
|
CRYPTO_DATA_INVALID = "DAT_CRYPTO_DATA_INVALID"
|
|
40
28
|
CRYPTO_BACKEND = "DAT_CRYPTO_BACKEND"
|
|
41
29
|
CRYPTO_UNKNOWN = "DAT_CRYPTO_UNKNOWN"
|
|
42
30
|
|
|
43
|
-
# KEY : 키 재료
|
|
44
31
|
KEY_INVALID = "DAT_KEY_INVALID"
|
|
45
32
|
KEY_VERIFY_ONLY_UNSUPPORTED = "DAT_KEY_VERIFY_ONLY_UNSUPPORTED"
|
|
46
33
|
KEY_UNKNOWN = "DAT_KEY_UNKNOWN"
|
|
47
34
|
|
|
48
|
-
# MANAGER : 매니저 보유 상태
|
|
49
35
|
MANAGER_NO_CERTIFICATE = "DAT_MANAGER_NO_CERTIFICATE"
|
|
50
36
|
MANAGER_NO_ISSUABLE_CERTIFICATE = "DAT_MANAGER_NO_ISSUABLE_CERTIFICATE"
|
|
51
37
|
MANAGER_DISPOSED = "DAT_MANAGER_DISPOSED"
|
|
52
38
|
MANAGER_UNKNOWN = "DAT_MANAGER_UNKNOWN"
|
|
53
39
|
|
|
54
|
-
# CMS : 서버 응답·전송
|
|
55
40
|
CMS_UNREACHABLE = "DAT_CMS_UNREACHABLE"
|
|
56
41
|
CMS_UNAUTHORIZED = "DAT_CMS_UNAUTHORIZED"
|
|
57
42
|
CMS_FORBIDDEN = "DAT_CMS_FORBIDDEN"
|
|
@@ -66,18 +51,14 @@ module Saro
|
|
|
66
51
|
CMS_NOT_SUPPORTED = "DAT_CMS_NOT_SUPPORTED"
|
|
67
52
|
CMS_UNKNOWN = "DAT_CMS_UNKNOWN"
|
|
68
53
|
|
|
69
|
-
# CONFIG : 호출자가 넘긴 값
|
|
70
54
|
CONFIG_ALG_UNSUPPORTED = "DAT_CONFIG_ALG_UNSUPPORTED"
|
|
71
55
|
CONFIG_URI_INVALID = "DAT_CONFIG_URI_INVALID"
|
|
72
56
|
CONFIG_ARGUMENT_INVALID = "DAT_CONFIG_ARGUMENT_INVALID"
|
|
73
57
|
CONFIG_UNKNOWN = "DAT_CONFIG_UNKNOWN"
|
|
74
58
|
|
|
75
|
-
# INTERNAL : 실행 환경
|
|
76
59
|
INTERNAL_UNAVAILABLE = "DAT_INTERNAL_UNAVAILABLE"
|
|
77
60
|
INTERNAL_UNKNOWN = "DAT_INTERNAL_UNKNOWN"
|
|
78
61
|
|
|
79
|
-
# 재시도 분류. 애매하면 :permanent 다 — 영구 오류에 대한 무한 재시도가
|
|
80
|
-
# 이 체계 이전의 실제 결함이었다.
|
|
81
62
|
TRANSIENT = [
|
|
82
63
|
CERT_NOT_YET_ISSUABLE, CERT_NOT_SYNCED, MANAGER_NO_CERTIFICATE,
|
|
83
64
|
CMS_UNREACHABLE, CMS_SERVER_ERROR, CMS_NOT_SYNCED
|
|
@@ -85,20 +66,11 @@ module Saro
|
|
|
85
66
|
|
|
86
67
|
STATE = [CMS_VERSION_RESET, CMS_SYNC_IN_PROGRESS].freeze
|
|
87
68
|
|
|
88
|
-
# 위조·변조 시도의 직접 증거.
|
|
89
69
|
SECURITY = [SIG_MISMATCH, CRYPTO_TAG_MISMATCH].freeze
|
|
90
70
|
end
|
|
91
71
|
|
|
92
|
-
# DAT 의 단일 오류 타입.
|
|
93
|
-
#
|
|
94
|
-
# 예전에는 정의만 되어 있고 아무도 쓰지 않았다. 대신 ArgumentError 와
|
|
95
|
-
# RuntimeError 가 같은 조건에 뒤섞여 던져져서(예: "Invalid DAT: Format" 이
|
|
96
|
-
# DatManager#parse 에서는 ArgumentError, DatManager._parse 에서는
|
|
97
|
-
# RuntimeError) 호출부가 어느 쪽을 잡아야 할지 알 수 없었다.
|
|
98
72
|
class Error < StandardError
|
|
99
|
-
# 공개 계약인 오류 코드. 모든 공식 클라이언트에서 동일하다.
|
|
100
73
|
attr_reader :code
|
|
101
|
-
# 사람이 읽는 설명. 자유롭게 바꿔도 된다.
|
|
102
74
|
attr_reader :detail
|
|
103
75
|
|
|
104
76
|
def initialize(code, detail = nil, cause: nil)
|
|
@@ -108,21 +80,12 @@ module Saro
|
|
|
108
80
|
super(detail ? "#{code}: #{detail}" : code)
|
|
109
81
|
end
|
|
110
82
|
|
|
111
|
-
# Ruby 는 rescue 안에서 raise 하면 #cause 를 자동으로 채운다. 그 동작은
|
|
112
|
-
# 그대로 두고, 명시적으로 넘긴 하위 원인이 있으면 그쪽을 우선한다.
|
|
113
|
-
# DAT_MANAGER_NO_ISSUABLE_CERTIFICATE 의 사유 코드가 이 경로로 실린다.
|
|
114
83
|
def cause
|
|
115
84
|
@explicit_cause || super
|
|
116
85
|
end
|
|
117
86
|
|
|
118
|
-
# :transient - 같은 입력으로 재시도하면 해소될 수 있다. 백오프 후 재시도.
|
|
119
|
-
# :permanent - 설정·입력·배포를 고쳐야 한다. 재시도하지 않는다.
|
|
120
|
-
# :state - 오류가 아닌 상태 신호. 흐름 제어에만 쓴다.
|
|
121
|
-
#
|
|
122
|
-
# 중간값을 두지 않는다 — 호출부가 분기할 수 없기 때문이다.
|
|
123
87
|
def retry
|
|
124
88
|
if @code == ErrorCode::MANAGER_NO_ISSUABLE_CERTIFICATE
|
|
125
|
-
# 발급창 시작 전이면 기다리면 풀린다. 나머지 사유는 안 풀린다.
|
|
126
89
|
c = cause
|
|
127
90
|
return :transient if c.is_a?(Error) && c.code == ErrorCode::CERT_NOT_YET_ISSUABLE
|
|
128
91
|
return :permanent
|
|
@@ -132,7 +95,6 @@ module Saro
|
|
|
132
95
|
:permanent
|
|
133
96
|
end
|
|
134
97
|
|
|
135
|
-
# 위조·변조 시도의 직접 증거. 다른 실패와 같은 경로로 로깅하지 않는다.
|
|
136
98
|
def security_event?
|
|
137
99
|
ErrorCode::SECURITY.include?(@code)
|
|
138
100
|
end
|
|
@@ -142,13 +104,10 @@ module Saro
|
|
|
142
104
|
end
|
|
143
105
|
|
|
144
106
|
class << self
|
|
145
|
-
# 어떤 예외에서든 DAT 오류 코드를 꺼낸다. DAT 오류가 아니면 nil 이다.
|
|
146
107
|
def code_of(e)
|
|
147
108
|
e.is_a?(Error) ? e.code : nil
|
|
148
109
|
end
|
|
149
110
|
|
|
150
|
-
# DAT 오류가 아닌 것을 감싼다. 이미 Error 면 그대로 둔다.
|
|
151
|
-
# 원본은 cause 로 반드시 보존한다.
|
|
152
111
|
def wrap(code, detail, e)
|
|
153
112
|
return e if e.is_a?(Error)
|
|
154
113
|
new(code, detail, cause: e)
|
data/lib/saro/dat/signature.rb
CHANGED
|
@@ -48,8 +48,6 @@ module Saro
|
|
|
48
48
|
if priv_bn
|
|
49
49
|
group = OpenSSL::PKey::EC::Group.new(curve_name)
|
|
50
50
|
|
|
51
|
-
# d must be in [1, n-1]; OpenSSL would otherwise happily build a key
|
|
52
|
-
# whose signatures can never verify.
|
|
53
51
|
if priv_bn <= 0 || priv_bn >= group.order
|
|
54
52
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::KEY_INVALID, "ecdsa private scalar out of range [1, n-1]")
|
|
55
53
|
end
|
|
@@ -64,10 +62,6 @@ module Saro
|
|
|
64
62
|
])
|
|
65
63
|
key = OpenSSL::PKey::EC.new(asn1.to_der)
|
|
66
64
|
|
|
67
|
-
# Cross-check that the imported public point really belongs to the
|
|
68
|
-
# private scalar. Without this a mismatched pair imports cleanly and
|
|
69
|
-
# produces signatures that can never verify (rust's
|
|
70
|
-
# EcdsaKeyPair::from_private_key_and_public_key rejects it at import).
|
|
71
65
|
begin
|
|
72
66
|
key.check_key
|
|
73
67
|
rescue OpenSSL::PKey::PKeyError, OpenSSL::PKey::ECError => e
|
|
@@ -179,15 +173,10 @@ module Saro
|
|
|
179
173
|
end
|
|
180
174
|
end
|
|
181
175
|
|
|
182
|
-
# Verifies a raw (already decoded) signature.
|
|
183
|
-
# Use #verify_base64 for a base64url-encoded signature: the branch used to
|
|
184
|
-
# be picked from the string's encoding tag, so a raw signature that merely
|
|
185
|
-
# carried a UTF-8 tag was silently run through the base64 decoder.
|
|
186
176
|
def verify(body, signature)
|
|
187
177
|
verify_bytes(body, signature)
|
|
188
178
|
end
|
|
189
179
|
|
|
190
|
-
# Verifies a base64url-encoded signature.
|
|
191
180
|
def verify_base64(body, signature_base64)
|
|
192
181
|
sig_bytes = begin
|
|
193
182
|
Saro::Dat::Util.decode_base64_url(signature_base64)
|
|
@@ -201,33 +190,25 @@ module Saro
|
|
|
201
190
|
body = normalize_body(body)
|
|
202
191
|
return false if body.nil? || body.empty?
|
|
203
192
|
|
|
204
|
-
# Same reason as DatCrypto#decrypt: the raw signature is split in half by
|
|
205
|
-
# byte offset, which String#[] only honours on a binary string.
|
|
206
193
|
sig_bytes = sig_bytes.b if sig_bytes.is_a?(String) && sig_bytes.encoding != Encoding::BINARY
|
|
207
194
|
|
|
208
|
-
# false 는 "서명이 안 맞는다"만 뜻한다. 예전에는 `rescue StandardError => false`
|
|
209
|
-
# 가 잘못된 키 타입·손상된 핸들·라이브러리 버그까지 전부 삼켜서 프로그래밍
|
|
210
|
-
# 오류가 위조 시도로 보고됐다. 그래서 연산 실패는 SIG_BACKEND 로 갈라 낸다.
|
|
211
195
|
if @config[:name] == "HMAC"
|
|
212
196
|
begin
|
|
213
197
|
actual_sig = OpenSSL::HMAC.digest(@config[:hash], @verifying_key, body)
|
|
214
198
|
rescue StandardError => e
|
|
215
199
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_BACKEND, "hmac computation failed", cause: e)
|
|
216
200
|
end
|
|
217
|
-
# 길이가 다르면 볼 것도 없이 불일치다.
|
|
218
201
|
return false unless sig_bytes.is_a?(String) && actual_sig.bytesize == sig_bytes.bytesize
|
|
219
202
|
OpenSSL.fixed_length_secure_compare(actual_sig, sig_bytes)
|
|
220
203
|
else
|
|
221
204
|
der_sig = begin
|
|
222
205
|
raw_to_der_signature(sig_bytes)
|
|
223
206
|
rescue StandardError
|
|
224
|
-
# r||s 길이가 곡선과 안 맞는다 = 서명 자체의 형식 오류. 위조가 아니다.
|
|
225
207
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_MALFORMED, "ecdsa signature is not a valid r||s pair")
|
|
226
208
|
end
|
|
227
209
|
begin
|
|
228
210
|
@verifying_key.dsa_verify_asn1(OpenSSL::Digest.digest(@config[:hash], body), der_sig)
|
|
229
211
|
rescue OpenSSL::PKey::PKeyError, OpenSSL::PKey::ECError
|
|
230
|
-
# OpenSSL 이 서명을 해독조차 못 한 경우다. 불일치로 본다.
|
|
231
212
|
false
|
|
232
213
|
rescue StandardError => e
|
|
233
214
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_BACKEND, "ecdsa verification failed to run", cause: e)
|
|
@@ -249,7 +230,6 @@ module Saro
|
|
|
249
230
|
|
|
250
231
|
private
|
|
251
232
|
|
|
252
|
-
# Avoid a needless copy when the body is already UTF-8 encoded bytes.
|
|
253
233
|
def normalize_body(body)
|
|
254
234
|
return body unless body.is_a?(String)
|
|
255
235
|
enc = body.encoding
|
|
@@ -262,7 +242,7 @@ module Saro
|
|
|
262
242
|
r = asn1.value[0].value
|
|
263
243
|
s = asn1.value[1].value
|
|
264
244
|
|
|
265
|
-
size = @config[:private_len]
|
|
245
|
+
size = @config[:private_len]
|
|
266
246
|
r_bytes = r.to_s(2).rjust(size, "\x00".b)
|
|
267
247
|
s_bytes = s.to_s(2).rjust(size, "\x00".b)
|
|
268
248
|
|
data/lib/saro/dat/util.rb
CHANGED
|
@@ -10,12 +10,6 @@ module Saro
|
|
|
10
10
|
|
|
11
11
|
U64_MAX = 0xFFFFFFFFFFFFFFFF
|
|
12
12
|
|
|
13
|
-
# Strict unsigned 64-bit decimal parse, matching rust's `parse::<u64>()`.
|
|
14
|
-
# Ruby's String#to_i never raises and Integer() accepts `0x`, `_` and
|
|
15
|
-
# surrounding whitespace, so the character set is checked explicitly.
|
|
16
|
-
# 무엇을 파싱하다 실패했는지에 따라 코드가 갈린다(토큰이면 TOKEN_MALFORMED,
|
|
17
|
-
# 인증서면 CERT_MALFORMED). 여기서는 중립적인 인자 오류로 두고, 각 호출부에서
|
|
18
|
-
# 정확한 코드로 감싼다.
|
|
19
13
|
def parse_u64(s)
|
|
20
14
|
unless s.is_a?(String) && s.match?(/\A[0-9]+\z/)
|
|
21
15
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_ARGUMENT_INVALID, "not an unsigned decimal integer: #{s.inspect}")
|
|
@@ -27,7 +21,6 @@ module Saro
|
|
|
27
21
|
v
|
|
28
22
|
end
|
|
29
23
|
|
|
30
|
-
# Strict unsigned 64-bit hex parse, matching rust's `u64::from_str_radix(s, 16)`.
|
|
31
24
|
def parse_u64_hex(s)
|
|
32
25
|
unless s.is_a?(String) && s.match?(/\A[0-9a-fA-F]+\z/)
|
|
33
26
|
raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_ARGUMENT_INVALID, "not an unsigned hex integer: #{s.inspect}")
|
|
@@ -59,9 +52,6 @@ module Saro
|
|
|
59
52
|
return "".b if s.empty?
|
|
60
53
|
end
|
|
61
54
|
|
|
62
|
-
# Base64.decode64 is RFC 2045 and silently drops invalid characters, so
|
|
63
|
-
# "!!!!invalid@@@@" would decode to arbitrary bytes instead of raising.
|
|
64
|
-
# urlsafe_decode64 is strict, matching rust's decoder.
|
|
65
55
|
s = s.to_s
|
|
66
56
|
rem = s.bytesize % 4
|
|
67
57
|
s += ("=" * (4 - rem)) if rem > 0
|
data/lib/saro-dat.rb
CHANGED
data/saro-dat.gemspec
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Gem::Specification.new do |spec|
|
|
4
4
|
spec.name = "saro-dat"
|
|
5
|
-
spec.version = "4.6.
|
|
5
|
+
spec.version = "4.6.1"
|
|
6
6
|
spec.authors = ["marker"]
|
|
7
7
|
spec.email = ["j@saro.me"]
|
|
8
8
|
|
|
@@ -17,8 +17,6 @@ Gem::Specification.new do |spec|
|
|
|
17
17
|
|
|
18
18
|
spec.metadata["keywords"] = "dat, distributed, access, token, web, session, security, authentication"
|
|
19
19
|
|
|
20
|
-
# Specify which files should be added to the gem when it is released.
|
|
21
|
-
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
22
20
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
23
21
|
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features|_pypi)/}) }
|
|
24
22
|
end
|
|
@@ -30,7 +28,7 @@ Gem::Specification.new do |spec|
|
|
|
30
28
|
spec.add_dependency "base64"
|
|
31
29
|
spec.add_dependency "logger"
|
|
32
30
|
|
|
33
|
-
spec.add_development_dependency "minitest", "~>
|
|
31
|
+
spec.add_development_dependency "minitest", "~> 6.0"
|
|
34
32
|
spec.add_development_dependency "benchmark"
|
|
35
33
|
spec.add_development_dependency "parallel"
|
|
36
34
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: saro-dat
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.6.
|
|
4
|
+
version: 4.6.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- marker
|
|
@@ -57,14 +57,14 @@ dependencies:
|
|
|
57
57
|
requirements:
|
|
58
58
|
- - "~>"
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '
|
|
60
|
+
version: '6.0'
|
|
61
61
|
type: :development
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '
|
|
67
|
+
version: '6.0'
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: benchmark
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|