saro-dat 4.3.4 → 4.6.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: 87e1f1949bc6ed97274fa1141ca2d76915b75a09e6010f111f4bfce321316396
4
- data.tar.gz: '05139251e2a28e03dd44cbe709b6612267373046c67c789052679dfbd13aec23'
3
+ metadata.gz: 9e9d9a8073169dbbd0fb625d174acca960f30687ccf78b0d0d9424ca2d4d8ba2
4
+ data.tar.gz: 1e727fef007baebc0960c14cbe57362db21d0311fc6486b0d6f3164d5da42dee
5
5
  SHA512:
6
- metadata.gz: 6b43afc3cac0deb85eb1bbd5174049d91d07a033319078d7f0af2ecc5b3e290e8a0dd768e7a2de9db7cdedc50d62827f2103e5065a8811400c23376aef28650f
7
- data.tar.gz: 3a4840105836a569f47af5cecb6bc57bd7aa79ee511b74d66e1cee2b96b4359c226e624849ab9f64be7d1d3cbd560d886bb0d33bd34fb51a6ebc9f6e53adbb71
6
+ metadata.gz: afea00f884102f1a5e84bf8992a024cb08810d04637625f0d09553b9e48ae0e1d01e9946734177db764a76846c1ff083d02c592c492d331d39bf1ffd8d05945b
7
+ data.tar.gz: 2b8184f5c8279fc6717d15ccd4a82f4580d6e90dfb660eb6c4a151fa03ae5727cfa7cd08e694a192c8ddd96ecf9ba2a0b64d77dd6fcf21121d577445231bcb6c
data/.idea/vcs.xml CHANGED
@@ -3,5 +3,6 @@
3
3
  <component name="VcsDirectoryMappings">
4
4
  <mapping directory="" vcs="Git" />
5
5
  <mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
6
+ <mapping directory="$PROJECT_DIR$/.." vcs="Git" />
6
7
  </component>
7
8
  </project>
data/PUBLISH.md CHANGED
@@ -9,7 +9,9 @@ bundle install
9
9
  ```
10
10
  gem build saro-dat.gemspec
11
11
  gem signin
12
- gem push saro-dat-4.3.4.gem
12
+ # glob, not a pinned filename: a hardcoded version silently pushes a stale
13
+ # package after a version bump
14
+ gem push saro-dat-*.gem
13
15
  ```
14
16
 
15
17
  ## install
data/README.md CHANGED
@@ -6,14 +6,14 @@
6
6
  - [Example](https://dat.saro.me/libs/gems-saro-dat)
7
7
 
8
8
  ### Support Platform
9
- - [Rust](https://github.com/saro-lab/dat/tree/master/clients/dat-rust)
10
- - [Java, Kotlin](https://github.com/saro-lab/dat/tree/master/clients/dat-maven)
11
- - [Javascript, Typescript](https://github.com/saro-lab/dat/tree/master/clients/dat-npm)
12
- - [C#](https://github.com/saro-lab/dat/tree/master/clients/dat-nuget)
13
- - [Python](https://github.com/saro-lab/dat/tree/master/clients/dat-pypi)
14
- - [Go](https://github.com/saro-lab/dat/tree/master/clients/dat-go)
15
- - [Ruby](https://github.com/saro-lab/dat/tree/master/clients/dat-ruby)
16
- - [C/C++ (Vcpkg)](https://github.com/saro-lab/dat/tree/master/clients/dat-vcpkg)
9
+ - [Rust](https://github.com/saro-lab/dat/tree/master/dat-rust)
10
+ - [Java, Kotlin](https://github.com/saro-lab/dat/tree/master/dat-maven)
11
+ - [Javascript, Typescript](https://github.com/saro-lab/dat/tree/master/dat-npm)
12
+ - [C#](https://github.com/saro-lab/dat/tree/master/dat-nuget)
13
+ - [Python](https://github.com/saro-lab/dat/tree/master/dat-pypi)
14
+ - [Go](https://github.com/saro-lab/dat/tree/master/dat-go)
15
+ - [Ruby](https://github.com/saro-lab/dat/tree/master/dat-ruby)
16
+ - [C/C++ (Vcpkg)](https://github.com/saro-lab/dat/tree/master/dat-vcpkg)
17
17
  - [Cert(Key) Server (Docker)](https://github.com/saro-lab/dat)
18
18
 
19
19
  ## Support algorithm
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'openssl'
4
4
  require 'securerandom'
5
+ require_relative 'error'
5
6
  require_relative 'util'
6
7
 
7
8
  module Saro
@@ -23,7 +24,7 @@ module Saro
23
24
  def self.get_crypto_config(algorithm)
24
25
  config = CRYPTO_CONFIG[algorithm]
25
26
  return config if config
26
- raise ArgumentError, "Unsupported DAT Crypto Algorithm: #{algorithm}"
27
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_ALG_UNSUPPORTED, "unknown crypto algorithm: #{algorithm}")
27
28
  end
28
29
 
29
30
  class DatCrypto
@@ -31,6 +32,14 @@ module Saro
31
32
 
32
33
  def initialize(algorithm, key_bytes, config = nil)
33
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
+ if key_bytes.bytesize != @config[:length]
38
+ raise Saro::Dat::Error.new(
39
+ Saro::Dat::ErrorCode::KEY_INVALID,
40
+ "#{algorithm} key must be #{@config[:length]} bytes, got #{key_bytes.bytesize}"
41
+ )
42
+ end
34
43
  @algorithm = algorithm
35
44
  @key_bytes = key_bytes
36
45
  end
@@ -63,20 +72,35 @@ module Saro
63
72
  cipher.iv_len = 12
64
73
  cipher.iv = nonce
65
74
 
66
- ciphertext = cipher.update(data) + cipher.final
67
- tag = cipher.auth_tag
75
+ begin
76
+ ciphertext = cipher.update(data) + cipher.final
77
+ tag = cipher.auth_tag
78
+ rescue OpenSSL::Cipher::CipherError => e
79
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CRYPTO_BACKEND, "aes-gcm encrypt failed", cause: e)
80
+ end
68
81
 
69
82
  nonce + ciphertext + tag
70
83
  end
71
84
 
85
+ # Decrypts base64url-encoded ciphertext.
86
+ def decrypt_base64(base64_str)
87
+ decrypt(Saro::Dat::Util.decode_base64_url(base64_str))
88
+ end
89
+
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.
72
94
  def decrypt(data)
73
- if data.is_a?(String) && data.encoding != Encoding::BINARY
74
- data = Saro::Dat::Util.decode_base64_url(data)
75
- end
76
95
  return "".b if data.nil? || data.empty?
77
96
 
78
- if data.length <= 12 + 16 # nonce(12) + tag(16)
79
- raise ArgumentError, "Invalid data length"
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
+ data = data.b if data.is_a?(String) && data.encoding != Encoding::BINARY
101
+
102
+ if data.bytesize <= 12 + 16 # nonce(12) + tag(16)
103
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CRYPTO_DATA_INVALID, "ciphertext is shorter than iv(12) + tag(16)")
80
104
  end
81
105
 
82
106
  nonce = data[0, 12]
@@ -90,7 +114,15 @@ module Saro
90
114
  cipher.iv = nonce
91
115
  cipher.auth_tag = tag
92
116
 
93
- res = cipher.update(ciphertext) + cipher.final
117
+ # cipher.final 에서 나는 실패는 GCM 인증 태그 불일치다 — 변조된 secure 이거나
118
+ # 잘못된 인증서 키다. 예전에는 OpenSSL::Cipher::CipherError 가 그대로 공개
119
+ # API 밖으로 새어 나갔다. 서명 검증을 건너뛰는 경로에서는 이것이 유일한
120
+ # 무결성 검사이므로 백엔드 오류(CRYPTO_BACKEND)와 반드시 구분한다.
121
+ begin
122
+ res = cipher.update(ciphertext) + cipher.final
123
+ rescue OpenSSL::Cipher::CipherError => e
124
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CRYPTO_TAG_MISMATCH, "gcm authentication tag mismatch", cause: e)
125
+ end
94
126
  res.force_encoding('BINARY')
95
127
  res
96
128
  end
data/lib/saro/dat/dat.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'error'
3
4
  require_relative 'util'
4
5
 
5
6
  module Saro
@@ -7,6 +8,13 @@ module Saro
7
8
  class Dat
8
9
  attr_reader :dat, :expire, :cid, :plain, :secure, :signature, :format
9
10
 
11
+ # 파싱이 실패한 이유. 성공이면 nil 이다.
12
+ #
13
+ # 예전에는 빈 `rescue StandardError` 가 모든 실패를 삼키고 @format=false
14
+ # 하나만 남겼다. 어느 필드가 왜 틀렸는지가 전부 사라져 호출부는
15
+ # "Invalid DAT: Format" 밖에 볼 수 없었다.
16
+ attr_reader :error
17
+
10
18
  def initialize(dat_str)
11
19
  @dat = dat_str || ''
12
20
  @format = false
@@ -15,22 +23,79 @@ module Saro
15
23
  @plain = "".b
16
24
  @secure = "".b
17
25
  @signature = "".b
26
+ @error = nil
27
+
28
+ if @dat.empty?
29
+ @error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::TOKEN_MALFORMED, "token is empty")
30
+ return
31
+ end
32
+
33
+ # 1) 먼저 구조를 확정한다. 파트가 5개가 아니면 그건 만료된 토큰이 아니라
34
+ # 애초에 토큰이 아니다.
35
+ # split 의 limit 을 -1 로 준다: 기본값은 뒤쪽 빈 필드를 버려서
36
+ # "a.b.c.d.e." (6필드) 가 5파트로 보였고, 빈 서명("a.b.c.d.")은
37
+ # 4파트로 보여 서명 오류를 구조 오류로 오인하게 만들었다.
38
+ parts = @dat.split('.', -1)
39
+ if parts.length != 5
40
+ @error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::TOKEN_MALFORMED, "expected exactly 5 dot-separated fields")
41
+ return
42
+ end
43
+
44
+ # 2) 구조가 맞은 뒤에야 값을 본다. 필드마다 어디서 틀렸는지 코드를 붙인다.
45
+ begin
46
+ @expire = Saro::Dat::Util.parse_u64(parts[0])
47
+ rescue Saro::Dat::Error => e
48
+ @error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::TOKEN_MALFORMED, "expire field is not a plain decimal u64", cause: e)
49
+ return
50
+ end
51
+
52
+ begin
53
+ @cid = Saro::Dat::Util.parse_u64_hex(parts[1])
54
+ rescue Saro::Dat::Error => e
55
+ @error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::TOKEN_MALFORMED, "cid field is not a plain hex u64", cause: e)
56
+ return
57
+ end
58
+
59
+ begin
60
+ @plain = Saro::Dat::Util.decode_base64_url(parts[2])
61
+ rescue Saro::Dat::Error => e
62
+ @error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::TOKEN_MALFORMED, "plain field is not base64url", cause: e)
63
+ return
64
+ end
65
+
66
+ begin
67
+ @secure = Saro::Dat::Util.decode_base64_url(parts[3])
68
+ rescue Saro::Dat::Error => e
69
+ @error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::TOKEN_MALFORMED, "secure field is not base64url", cause: e)
70
+ return
71
+ end
72
+
73
+ # 빈 서명은 구조 오류가 아니라 서명 오류다 (error.pre2.md: DAT_SIG_MALFORMED
74
+ # 가 "빈 서명"을 포함한다). 위조(SIG_MISMATCH)와도 구분된다.
75
+ if parts[4].empty?
76
+ @error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_MALFORMED, "signature field is empty")
77
+ return
78
+ end
18
79
 
19
- if !@dat.empty?
20
- parts = @dat.split('.')
21
- if parts.length == 5
22
- begin
23
- @expire = parts[0].to_i
24
- @cid = parts[1].to_i(16)
25
- @plain = Saro::Dat::Util.decode_base64_url(parts[2])
26
- @secure = Saro::Dat::Util.decode_base64_url(parts[3])
27
- @signature = Saro::Dat::Util.decode_base64_url(parts[4])
28
- @format = (!@signature.empty? && @expire >= 0)
29
- rescue StandardError
30
- @format = false
31
- end
32
- end
80
+ begin
81
+ @signature = Saro::Dat::Util.decode_base64_url(parts[4])
82
+ rescue Saro::Dat::Error => e
83
+ @error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_MALFORMED, "signature field is not base64url", cause: e)
84
+ return
33
85
  end
86
+
87
+ if @signature.empty?
88
+ @error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_MALFORMED, "signature field is empty")
89
+ return
90
+ end
91
+
92
+ @format = true
93
+ end
94
+
95
+ # 파싱에 실패했으면 그 코드로 던진다.
96
+ def raise_if_invalid!
97
+ raise @error if @error
98
+ nil
34
99
  end
35
100
 
36
101
  def self.from_value(value)
@@ -40,7 +105,7 @@ module Saro
40
105
 
41
106
  def expired
42
107
  return true unless @format
43
- Time.now.to_i > @expire
108
+ Time.now.to_i >= @expire
44
109
  end
45
110
 
46
111
  alias_method :expired?, :expired
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'error'
3
4
  require_relative 'crypto'
4
5
  require_relative 'signature'
5
6
  require_relative 'util'
@@ -10,10 +11,20 @@ module Saro
10
11
  attr_reader :cid, :signature_key, :crypto_key, :dat_issuance_start_seconds, :dat_issuance_end_seconds, :dat_ttl_seconds
11
12
 
12
13
  def initialize(cid, dat_issuance_start_seconds, dat_issuance_duration_seconds, dat_ttl_seconds, signature_key, crypto_key)
13
- @cid = cid
14
- @dat_issuance_start_seconds = dat_issuance_start_seconds
15
- @dat_issuance_end_seconds = dat_issuance_start_seconds + dat_issuance_duration_seconds
16
- @dat_ttl_seconds = dat_ttl_seconds
14
+ @cid = u64!("cid", cid)
15
+ @dat_issuance_start_seconds = u64!("dat_issuance_start_seconds", dat_issuance_start_seconds)
16
+ duration = u64!("dat_issuance_duration_seconds", dat_issuance_duration_seconds)
17
+ @dat_ttl_seconds = u64!("dat_ttl_seconds", dat_ttl_seconds)
18
+ # Ruby 정수는 자동으로 bignum 이 되므로 그냥 더하면 u64 를 넘겨도 조용히
19
+ # 통과한다. 기준 구현(rust)의 checked_add 와 같은 경계를 여기서 강제한다.
20
+ @dat_issuance_end_seconds = u64!(
21
+ "dat_issuance_start_seconds + dat_issuance_duration_seconds",
22
+ @dat_issuance_start_seconds + duration
23
+ )
24
+ u64!(
25
+ "dat_issuance_start_seconds + dat_issuance_duration_seconds + dat_ttl_seconds",
26
+ @dat_issuance_end_seconds + @dat_ttl_seconds
27
+ )
17
28
  @signature_key = signature_key
18
29
  @crypto_key = crypto_key
19
30
  end
@@ -41,12 +52,15 @@ module Saro
41
52
 
42
53
  def self.imports(format_str)
43
54
  parts = format_str.split(".")
44
- raise ArgumentError, "Invalid Certificate format" if parts.length != 8
55
+ if parts.length != 8
56
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CERT_MALFORMED, "expected exactly 8 dot-separated fields")
57
+ end
45
58
 
46
- cid = parts[0].to_i(16)
47
- dat_issuance_start_seconds = parts[1].to_i
48
- dat_issuance_duration_seconds = parts[2].to_i
49
- dat_ttl_seconds = parts[3].to_i
59
+ # 필드 파싱 실패는 인증서가 깨진 것이지 호출자의 인자 문제가 아니다.
60
+ cid = _field("cid") { Saro::Dat::Util.parse_u64_hex(parts[0]) }
61
+ dat_issuance_start_seconds = _field("issuance_start_seconds") { Saro::Dat::Util.parse_u64(parts[1]) }
62
+ dat_issuance_duration_seconds = _field("issuance_duration_seconds") { Saro::Dat::Util.parse_u64(parts[2]) }
63
+ dat_ttl_seconds = _field("dat_ttl_seconds") { Saro::Dat::Util.parse_u64(parts[3]) }
50
64
  signature_algorithm = parts[4]
51
65
  crypto_algorithm = parts[5]
52
66
  signature_key = Saro::Dat::DatSignature.imports(signature_algorithm, parts[6])
@@ -76,6 +90,23 @@ module Saro
76
90
  @signature_key.support_verify_only
77
91
  end
78
92
 
93
+ U64_MAX = 0xFFFFFFFFFFFFFFFF
94
+ private_constant :U64_MAX
95
+
96
+ private def u64!(name, value)
97
+ unless value.is_a?(Integer) && !value.is_a?(TrueClass) && value >= 0 && value <= U64_MAX
98
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CERT_MALFORMED, "#{name} must fit in u64: #{value}")
99
+ end
100
+ value
101
+ end
102
+
103
+ def self._field(name)
104
+ yield
105
+ rescue Saro::Dat::Error => e
106
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CERT_MALFORMED, "#{name} field is not a plain number", cause: e)
107
+ end
108
+ private_class_method :_field
109
+
79
110
  # For Ruby conventions
80
111
  alias_method :issuable?, :issuable
81
112
  alias_method :expired?, :expired
@@ -4,6 +4,7 @@ require 'net/http'
4
4
  require 'uri'
5
5
  require 'logger'
6
6
  require 'thread'
7
+ require_relative 'error'
7
8
  require_relative 'dat_manager'
8
9
  require_relative 'dat'
9
10
 
@@ -12,6 +13,11 @@ module Saro
12
13
  class DatCmsManager
13
14
  DAT_CMS_API_VERSION = "v1"
14
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
+ STOP_JOIN_TIMEOUT_SECONDS = 1.0
20
+
15
21
  def initialize(uri:, token:, interval_seconds: 60, verify_only: false, dat_manager: nil)
16
22
  @uri = uri
17
23
  @token = token
@@ -19,10 +25,19 @@ module Saro
19
25
  @verify_only = verify_only
20
26
  @manager = dat_manager || DatManager.new
21
27
  @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.
22
32
  @lock = Mutex.new
33
+ @lifecycle = Mutex.new
34
+ @stop_cond = ConditionVariable.new
23
35
  @stopped = false
24
36
  @logger = Logger.new($stdout)
25
37
  @logger.level = Logger::DEBUG
38
+ # 최초 sync 실패는 여전히 생성을 막지 않는다(list.md F-3). 다만 이제 로그로만
39
+ # 사라지지 않고 #last_error 로 조회할 수 있다.
40
+ @last_error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_NOT_SYNCED)
26
41
 
27
42
  sync
28
43
 
@@ -32,17 +47,55 @@ module Saro
32
47
  end
33
48
 
34
49
  def stop
35
- @lock.synchronize do
50
+ thread = nil
51
+ @lifecycle.synchronize do
52
+ return if @stopped
36
53
  @stopped = true
37
- @thread&.kill # 혹은 다른 방식으로 스레드 중지
54
+ # Wakes the scheduler out of its sleep immediately instead of killing
55
+ # it in the middle of a request.
56
+ @stop_cond.broadcast
57
+ thread = @thread
38
58
  end
59
+ thread&.join(STOP_JOIN_TIMEOUT_SECONDS)
60
+ nil
61
+ end
62
+
63
+ def stopped?
64
+ @lifecycle.synchronize { @stopped }
65
+ end
66
+
67
+ # 마지막 동기화 실패. 한 번도 성공하지 못했으면 DAT_CMS_NOT_SYNCED, 정상이면 nil.
68
+ # 재시도 여부는 err.retry 로 판정한다.
69
+ attr_reader :last_error
70
+
71
+ def version
72
+ @version
39
73
  end
40
74
 
41
75
  def sync
76
+ err = sync_or_raise
77
+ @last_error = nil
78
+ err
79
+ rescue Saro::Dat::Error => e
80
+ # 상태 신호는 실패로 기록하지 않는다 — 이전 동기화가 도는 중일 뿐이다.
81
+ unless e.retry == :state
82
+ @last_error = e
83
+ @logger.error("[CRITICAL] DAT CMS SYNC #{@uri}: #{e.code} #{e.detail}")
84
+ end
85
+ nil
86
+ rescue StandardError => e
87
+ @last_error = Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_UNKNOWN, "unclassified cms failure", cause: e)
88
+ @logger.error("[CRITICAL] DAT CMS SYNC #{@uri}: #{e.message}")
89
+ nil
90
+ end
91
+
92
+ # 실패를 코드로 던진다. #sync 는 이것을 잡아 #last_error 에 담기만 한다 —
93
+ # 기존 호출부가 갑자기 예외를 받지 않도록.
94
+ private def sync_or_raise
42
95
  # non-blocking lock
43
96
  unless @lock.try_lock
44
- @logger.warn("Last request ignored (Duplicate request)")
45
- return
97
+ @logger.debug("cms sync skipped, previous sync still running: #{@uri}")
98
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_SYNC_IN_PROGRESS)
46
99
  end
47
100
 
48
101
  begin
@@ -50,55 +103,82 @@ module Saro
50
103
  request = Net::HTTP::Get.new(url)
51
104
  request["Authorization"] = @token
52
105
 
53
- response = Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == 'https', open_timeout: 10, read_timeout: 10) do |http|
54
- http.request(request)
55
- end
106
+ # 연결 거부·DNS 실패·TLS 실패·타임아웃이 전부 여기로 온다. 전부 일시적이다.
107
+ response =
108
+ begin
109
+ Net::HTTP.start(url.host, url.port, use_ssl: url.scheme == 'https', open_timeout: 10, read_timeout: 10) do |http|
110
+ http.request(request)
111
+ end
112
+ rescue StandardError => e
113
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_UNREACHABLE, "cannot reach #{@uri}", cause: e)
114
+ end
56
115
 
57
- if response.code != "200"
58
- @logger.error("Response status error, status:#{response.code} in #{url}")
59
- return
60
- end
116
+ # HTTP 상태를 갈라 낸다. 예전에는 전부 하나의 로그라 401(영구)에도
117
+ # 60초마다 영원히 재시도했다.
118
+ status = response.code.to_i
119
+ raise self.class.http_status_error(status) unless status.between?(200, 299)
61
120
 
62
121
  body = response.body
63
122
  if body.nil? || body.empty?
64
123
  @logger.debug("No new certificate: #{url}")
65
- return
124
+ return nil
66
125
  end
67
126
 
68
127
  lines = body.split("\n", 2)
69
128
  if lines.length < 2
70
129
  if body.start_with?("\n")
71
- @logger.error("Invalid response: #{url}")
72
- return
130
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_MALFORMED, "response has no version line")
73
131
  end
74
132
  @logger.debug("No new certificate: #{url}")
75
- return
133
+ return nil
76
134
  end
77
135
 
78
136
  new_version_str = lines[0].strip
79
137
  new_certificates = lines[1].strip
80
138
 
81
- if new_version_str.empty?
82
- @logger.error("Invalid version in response: #{url}")
83
- return
139
+ unless new_version_str.match?(/\A[0-9]+\z/)
140
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_MALFORMED, "version line is not a plain decimal integer")
84
141
  end
142
+ new_version = new_version_str.to_i
85
143
 
86
- begin
87
- new_version = Integer(new_version_str)
88
- renew_count = @manager.imports(new_certificates, clear: false)
89
- @version = new_version
90
- @logger.debug("Renewed #{renew_count} certificates for version #{new_version}: #{url}")
91
- rescue ArgumentError => e
92
- @logger.error("Failed to parse version or certificates: #{e.message}")
144
+ # 서버가 우리보다 과거 버전을 돌려주면 전체 재동기화 지시다. 오류가 아니라
145
+ # 상태 신호이며, 아래 imports 가 clear: true 라 그 자체로 처리된다.
146
+ if new_version < @version
147
+ @logger.warn("#{Saro::Dat::ErrorCode::CMS_VERSION_RESET}: #{@version} -> #{new_version}")
93
148
  end
94
149
 
95
- rescue StandardError => e
96
- @logger.error("[Exception] DAT CMS Sync #{@uri}: #{e.message}")
150
+ # 인증서 적용 실패의 원인(CERT_*/KEY_*)을 버리지 않고 체이닝한다.
151
+ renew_count =
152
+ begin
153
+ # clear: true, like rust's `manager.import(&certs, true)`. The CMS
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)
158
+ rescue Saro::Dat::Error => e
159
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_IMPORT_FAILED, "cannot apply received certificates", cause: e)
160
+ end
161
+
162
+ # Only a successful import advances the version, matching rust:
163
+ # a rejected payload is re-requested rather than skipped.
164
+ @version = new_version
165
+ @logger.debug("Renewed #{renew_count} certificates for version #{new_version}: #{url}")
166
+ nil
97
167
  ensure
98
168
  @lock.unlock
99
169
  end
100
170
  end
101
171
 
172
+ def self.http_status_error(status)
173
+ case status
174
+ when 401 then Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_UNAUTHORIZED, "http 401")
175
+ when 403 then Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_FORBIDDEN, "http 403")
176
+ when 404 then Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_ENDPOINT_NOT_FOUND, "http 404")
177
+ when 500..599 then Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_SERVER_ERROR, "http #{status}")
178
+ else Saro::Dat::Error.new(Saro::Dat::ErrorCode::CMS_HTTP_STATUS, "http #{status}")
179
+ end
180
+ end
181
+
102
182
  def get_manager
103
183
  @manager
104
184
  end
@@ -120,16 +200,26 @@ module Saro
120
200
  def schedule_sync
121
201
  @thread = Thread.new do
122
202
  loop do
123
- sleep(@interval_seconds)
124
- break if @stopped
203
+ break unless wait_interval
125
204
  run_sync_task
126
205
  end
127
206
  end
128
207
  end
129
208
 
209
+ # Interruptible sleep: returns false as soon as `stop` signals, so the
210
+ # scheduler exits promptly without Thread#kill.
211
+ def wait_interval
212
+ @lifecycle.synchronize do
213
+ return false if @stopped
214
+ @stop_cond.wait(@lifecycle, @interval_seconds)
215
+ !@stopped
216
+ end
217
+ end
218
+
130
219
  def run_sync_task
131
220
  sync
132
221
  rescue StandardError => e
222
+ # sync 는 이미 스스로 삼키므로 여기 오는 것은 예상 밖의 실패다.
133
223
  @logger.error("Error in sync task: #{e.message}")
134
224
  end
135
225
  end
@@ -168,13 +258,21 @@ module Saro
168
258
  end
169
259
 
170
260
  def build
171
- parsed = URI.parse(@uri)
172
-
261
+ parsed =
262
+ begin
263
+ URI.parse(@uri)
264
+ rescue URI::InvalidURIError => e
265
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_URI_INVALID, "cannot be parsed as a uri", cause: e)
266
+ end
267
+
268
+ unless %w[http https].include?(parsed.scheme)
269
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_URI_INVALID, "scheme must be http or https")
270
+ end
173
271
  if parsed.path && parsed.path != '' && parsed.path != '/'
174
- raise ArgumentError, "uri must be path-less: #{@uri}"
272
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_URI_INVALID, "must be path-less: #{@uri}")
175
273
  end
176
274
  if parsed.query
177
- raise ArgumentError, "uri must be query-less: #{@uri}"
275
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_URI_INVALID, "must be query-less: #{@uri}")
178
276
  end
179
277
 
180
278
  path = @verify_only ? "/v1/certs/verify-only" : "/v1/certs"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'set'
4
+ require_relative 'error'
4
5
  require_relative 'dat_certificate'
5
6
  require_relative 'dat'
6
7
  require_relative 'signature'
@@ -23,23 +24,38 @@ module Saro
23
24
  end
24
25
 
25
26
  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
+ return 0 if input_certs.nil? || input_certs.empty?
31
+
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
+ seen_cids = Set.new
35
+ input_certs.each do |cert|
36
+ if seen_cids.include?(cert.cid)
37
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CERT_DUPLICATE_CID, "duplicate cid #{cert.cid.to_s(16)}")
38
+ end
39
+ seen_cids.add(cert.cid)
40
+ end
41
+
26
42
  renew_count = 0
27
43
  @write_lock.synchronize do
28
44
  certificates = clear ? [] : @state.certificates.dup
29
45
 
30
- before_cids = Set.new(certificates.map(&:cid))
31
- seen_cids = Set.new
46
+ cids = Set.new(certificates.map(&:cid))
32
47
 
33
48
  input_certs.each do |cert|
34
- raise ArgumentError, "Duplicate CID: #{cert.cid}" if seen_cids.include?(cert.cid)
35
- seen_cids.add(cert.cid)
36
- next if cert.expired
37
- next if before_cids.include?(cert.cid)
49
+ next if cids.include?(cert.cid)
38
50
 
51
+ cids.add(cert.cid)
39
52
  certificates << cert
40
53
  renew_count += 1
41
54
  end
42
55
 
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
+ certificates.reject!(&:expired)
43
59
  certificates.sort_by!(&:dat_issuance_end_seconds)
44
60
 
45
61
  # Find latest issuable certificate as issuer
@@ -68,24 +84,73 @@ module Saro
68
84
  end
69
85
 
70
86
  def issue(plain, secure)
71
- issuer = @state.issuer
72
- raise RuntimeError, "Invalid DAT: Signing Key Does Not Exist" unless issuer
87
+ state = @state
88
+ issuer = state.issuer
89
+ unless issuer
90
+ # 예전에는 이 다섯 가지가 "Invalid DAT: Signing Key Does Not Exist"
91
+ # 문자열 하나였다. 대응이 전부 다르다 — 발급창 전이면 기다리면 되고,
92
+ # verify-only 뿐이면 배포 설정 실수이며, 0건이면 CMS 접속 문제다.
93
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::MANAGER_NO_CERTIFICATE) if state.certificates.empty?
94
+ raise Saro::Dat::Error.new(
95
+ Saro::Dat::ErrorCode::MANAGER_NO_ISSUABLE_CERTIFICATE,
96
+ cause: no_issuable_cause(state.certificates)
97
+ )
98
+ end
73
99
 
74
100
  self.class._issue(issuer, plain, secure)
75
101
  end
76
102
 
77
103
  def parse(dat_input)
78
104
  dat = Saro::Dat::Dat.from_value(dat_input)
79
- raise ArgumentError, "Invalid DAT: Format" unless dat.format
105
+ # 파싱 실패의 코드를 그대로 올린다.
106
+ dat.raise_if_invalid!
80
107
 
81
- certificate = @state.by_cid[dat.cid]
108
+ # 만료를 cid 조회보다 먼저 본다. 기준 구현(rust)이 토큰을 읽는 시점에
109
+ # 만료를 판정하므로, 모르는 cid 의 만료 토큰도 만료로 보고된다.
110
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::TOKEN_EXPIRED) if dat.expired?
82
111
 
83
- raise ArgumentError, "Invalid DAT: CID(Certificate ID) Not Found" unless certificate
112
+ certificate = @state.by_cid[dat.cid]
113
+ unless certificate
114
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CERT_NOT_FOUND, "cid #{dat.cid.to_s(16)}")
115
+ end
84
116
 
85
117
  self.class._parse(certificate, dat)
86
118
  end
87
119
 
88
- private
120
+ # 발급 가능한 인증서가 없을 때 왜 없는지 가려낸다.
121
+ private def no_issuable_cause(certificates)
122
+ now = Time.now.to_i
123
+ signable_seen = false
124
+ not_yet = false
125
+ ended = false
126
+
127
+ certificates.each do |cert|
128
+ next unless cert.signable
129
+ signable_seen = true
130
+ if now < cert.dat_issuance_start_seconds
131
+ not_yet = true
132
+ elsif now > cert.dat_issuance_end_seconds
133
+ ended = true
134
+ end
135
+ end
136
+
137
+ code =
138
+ if !signable_seen
139
+ Saro::Dat::ErrorCode::CERT_VERIFY_ONLY
140
+ elsif not_yet
141
+ # 기다리면 풀리는 유일한 사유다. 하나라도 있으면 이것을 앞세운다.
142
+ Saro::Dat::ErrorCode::CERT_NOT_YET_ISSUABLE
143
+ elsif ended
144
+ Saro::Dat::ErrorCode::CERT_ISSUANCE_ENDED
145
+ else
146
+ Saro::Dat::ErrorCode::CERT_EXPIRED
147
+ end
148
+ Saro::Dat::Error.new(code)
149
+ end
150
+
151
+ # NOTE: 아래 `private` 는 `def self.` 메서드에 적용되지 않는다. _issue/_parse 는
152
+ # 예전부터 실제로는 public 이었고 테스트·벤치가 그렇게 쓰고 있다. 오해를 없애려
153
+ # 위치를 옮기고, 정말 감춰야 하는 것만 private_class_method 로 막는다.
89
154
 
90
155
  def self._issue(cert, plain, secure)
91
156
  now = Time.now.to_i
@@ -105,11 +170,14 @@ module Saro
105
170
 
106
171
  def self._parse(cert, dat_input)
107
172
  dat = Saro::Dat::Dat.from_value(dat_input)
108
- raise RuntimeError, "Invalid DAT: Format" unless dat.format
109
- raise RuntimeError, "Invalid DAT: Expired" if dat.expired?
173
+ # 같은 조건이 여기서는 RuntimeError, DatManager#parse 에서는 ArgumentError
174
+ # 였다. 이제 양쪽 모두 같은 코드를 던진다.
175
+ dat.raise_if_invalid!
176
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::TOKEN_EXPIRED) if dat.expired?
110
177
 
178
+ # verify 는 불일치일 때만 false 를 준다. 연산 실패는 SIG_BACKEND 로 올라온다.
111
179
  unless cert.signature_key.verify(dat.body_string, dat.signature)
112
- raise RuntimeError, "Invalid DAT: Signature"
180
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_MISMATCH)
113
181
  end
114
182
 
115
183
  decrypted_secure = cert.crypto_key.decrypt(dat.secure)
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Saro
4
+ module Dat
5
+ # DAT 통합 오류 코드 (error.pre2.md).
6
+ #
7
+ # 코드 문자열은 모든 공식 클라이언트와 CMS 서버가 공유하는 공개 계약이다. 메시지는 자유롭게 바꿔도
8
+ # 되지만 코드는 바꾸지 않는다.
9
+ #
10
+ # - 분류는 원인이다. "어느 함수에서 났는가"가 아니라 "무엇이 잘못됐는가"다.
11
+ # - *_UNKNOWN 은 각 영역의 폴백 전용이다. "알 수 없는 X" 라는 뜻으로 쓰지 않는다.
12
+ # - 하위 원인은 버리지 않고 #cause 로 보존한다.
13
+ module ErrorCode
14
+ # TOKEN : DAT 토큰 문자열
15
+ TOKEN_MALFORMED = "DAT_TOKEN_MALFORMED"
16
+ TOKEN_EXPIRED = "DAT_TOKEN_EXPIRED"
17
+ TOKEN_UNKNOWN = "DAT_TOKEN_UNKNOWN"
18
+
19
+ # CERT : 인증서
20
+ CERT_MALFORMED = "DAT_CERT_MALFORMED"
21
+ CERT_EXPIRED = "DAT_CERT_EXPIRED"
22
+ CERT_NOT_YET_ISSUABLE = "DAT_CERT_NOT_YET_ISSUABLE"
23
+ CERT_ISSUANCE_ENDED = "DAT_CERT_ISSUANCE_ENDED"
24
+ CERT_VERIFY_ONLY = "DAT_CERT_VERIFY_ONLY"
25
+ CERT_NOT_FOUND = "DAT_CERT_NOT_FOUND"
26
+ CERT_NOT_SYNCED = "DAT_CERT_NOT_SYNCED"
27
+ CERT_DUPLICATE_CID = "DAT_CERT_DUPLICATE_CID"
28
+ CERT_UNKNOWN = "DAT_CERT_UNKNOWN"
29
+
30
+ # SIG : 서명
31
+ SIG_MISMATCH = "DAT_SIG_MISMATCH"
32
+ SIG_MALFORMED = "DAT_SIG_MALFORMED"
33
+ SIG_KEY_MISSING = "DAT_SIG_KEY_MISSING"
34
+ SIG_BACKEND = "DAT_SIG_BACKEND"
35
+ SIG_UNKNOWN = "DAT_SIG_UNKNOWN"
36
+
37
+ # CRYPTO : secure 페이로드
38
+ CRYPTO_TAG_MISMATCH = "DAT_CRYPTO_TAG_MISMATCH"
39
+ CRYPTO_DATA_INVALID = "DAT_CRYPTO_DATA_INVALID"
40
+ CRYPTO_BACKEND = "DAT_CRYPTO_BACKEND"
41
+ CRYPTO_UNKNOWN = "DAT_CRYPTO_UNKNOWN"
42
+
43
+ # KEY : 키 재료
44
+ KEY_INVALID = "DAT_KEY_INVALID"
45
+ KEY_VERIFY_ONLY_UNSUPPORTED = "DAT_KEY_VERIFY_ONLY_UNSUPPORTED"
46
+ KEY_UNKNOWN = "DAT_KEY_UNKNOWN"
47
+
48
+ # MANAGER : 매니저 보유 상태
49
+ MANAGER_NO_CERTIFICATE = "DAT_MANAGER_NO_CERTIFICATE"
50
+ MANAGER_NO_ISSUABLE_CERTIFICATE = "DAT_MANAGER_NO_ISSUABLE_CERTIFICATE"
51
+ MANAGER_DISPOSED = "DAT_MANAGER_DISPOSED"
52
+ MANAGER_UNKNOWN = "DAT_MANAGER_UNKNOWN"
53
+
54
+ # CMS : 서버 응답·전송
55
+ CMS_UNREACHABLE = "DAT_CMS_UNREACHABLE"
56
+ CMS_UNAUTHORIZED = "DAT_CMS_UNAUTHORIZED"
57
+ CMS_FORBIDDEN = "DAT_CMS_FORBIDDEN"
58
+ CMS_ENDPOINT_NOT_FOUND = "DAT_CMS_ENDPOINT_NOT_FOUND"
59
+ CMS_SERVER_ERROR = "DAT_CMS_SERVER_ERROR"
60
+ CMS_HTTP_STATUS = "DAT_CMS_HTTP_STATUS"
61
+ CMS_MALFORMED = "DAT_CMS_MALFORMED"
62
+ CMS_IMPORT_FAILED = "DAT_CMS_IMPORT_FAILED"
63
+ CMS_VERSION_RESET = "DAT_CMS_VERSION_RESET"
64
+ CMS_NOT_SYNCED = "DAT_CMS_NOT_SYNCED"
65
+ CMS_SYNC_IN_PROGRESS = "DAT_CMS_SYNC_IN_PROGRESS"
66
+ CMS_NOT_SUPPORTED = "DAT_CMS_NOT_SUPPORTED"
67
+ CMS_UNKNOWN = "DAT_CMS_UNKNOWN"
68
+
69
+ # CONFIG : 호출자가 넘긴 값
70
+ CONFIG_ALG_UNSUPPORTED = "DAT_CONFIG_ALG_UNSUPPORTED"
71
+ CONFIG_URI_INVALID = "DAT_CONFIG_URI_INVALID"
72
+ CONFIG_ARGUMENT_INVALID = "DAT_CONFIG_ARGUMENT_INVALID"
73
+ CONFIG_UNKNOWN = "DAT_CONFIG_UNKNOWN"
74
+
75
+ # INTERNAL : 실행 환경
76
+ INTERNAL_UNAVAILABLE = "DAT_INTERNAL_UNAVAILABLE"
77
+ INTERNAL_UNKNOWN = "DAT_INTERNAL_UNKNOWN"
78
+
79
+ # 재시도 분류. 애매하면 :permanent 다 — 영구 오류에 대한 무한 재시도가
80
+ # 이 체계 이전의 실제 결함이었다.
81
+ TRANSIENT = [
82
+ CERT_NOT_YET_ISSUABLE, CERT_NOT_SYNCED, MANAGER_NO_CERTIFICATE,
83
+ CMS_UNREACHABLE, CMS_SERVER_ERROR, CMS_NOT_SYNCED
84
+ ].freeze
85
+
86
+ STATE = [CMS_VERSION_RESET, CMS_SYNC_IN_PROGRESS].freeze
87
+
88
+ # 위조·변조 시도의 직접 증거.
89
+ SECURITY = [SIG_MISMATCH, CRYPTO_TAG_MISMATCH].freeze
90
+ end
91
+
92
+ # DAT 의 단일 오류 타입.
93
+ #
94
+ # 예전에는 정의만 되어 있고 아무도 쓰지 않았다. 대신 ArgumentError 와
95
+ # RuntimeError 가 같은 조건에 뒤섞여 던져져서(예: "Invalid DAT: Format" 이
96
+ # DatManager#parse 에서는 ArgumentError, DatManager._parse 에서는
97
+ # RuntimeError) 호출부가 어느 쪽을 잡아야 할지 알 수 없었다.
98
+ class Error < StandardError
99
+ # 공개 계약인 오류 코드. 모든 공식 클라이언트에서 동일하다.
100
+ attr_reader :code
101
+ # 사람이 읽는 설명. 자유롭게 바꿔도 된다.
102
+ attr_reader :detail
103
+
104
+ def initialize(code, detail = nil, cause: nil)
105
+ @code = code
106
+ @detail = detail
107
+ @explicit_cause = cause
108
+ super(detail ? "#{code}: #{detail}" : code)
109
+ end
110
+
111
+ # Ruby 는 rescue 안에서 raise 하면 #cause 를 자동으로 채운다. 그 동작은
112
+ # 그대로 두고, 명시적으로 넘긴 하위 원인이 있으면 그쪽을 우선한다.
113
+ # DAT_MANAGER_NO_ISSUABLE_CERTIFICATE 의 사유 코드가 이 경로로 실린다.
114
+ def cause
115
+ @explicit_cause || super
116
+ end
117
+
118
+ # :transient - 같은 입력으로 재시도하면 해소될 수 있다. 백오프 후 재시도.
119
+ # :permanent - 설정·입력·배포를 고쳐야 한다. 재시도하지 않는다.
120
+ # :state - 오류가 아닌 상태 신호. 흐름 제어에만 쓴다.
121
+ #
122
+ # 중간값을 두지 않는다 — 호출부가 분기할 수 없기 때문이다.
123
+ def retry
124
+ if @code == ErrorCode::MANAGER_NO_ISSUABLE_CERTIFICATE
125
+ # 발급창 시작 전이면 기다리면 풀린다. 나머지 사유는 안 풀린다.
126
+ c = cause
127
+ return :transient if c.is_a?(Error) && c.code == ErrorCode::CERT_NOT_YET_ISSUABLE
128
+ return :permanent
129
+ end
130
+ return :transient if ErrorCode::TRANSIENT.include?(@code)
131
+ return :state if ErrorCode::STATE.include?(@code)
132
+ :permanent
133
+ end
134
+
135
+ # 위조·변조 시도의 직접 증거. 다른 실패와 같은 경로로 로깅하지 않는다.
136
+ def security_event?
137
+ ErrorCode::SECURITY.include?(@code)
138
+ end
139
+
140
+ def inspect
141
+ "#<Saro::Dat::Error #{@code}#{@detail ? " #{@detail.inspect}" : ''}>"
142
+ end
143
+
144
+ class << self
145
+ # 어떤 예외에서든 DAT 오류 코드를 꺼낸다. DAT 오류가 아니면 nil 이다.
146
+ def code_of(e)
147
+ e.is_a?(Error) ? e.code : nil
148
+ end
149
+
150
+ # DAT 오류가 아닌 것을 감싼다. 이미 Error 면 그대로 둔다.
151
+ # 원본은 cause 로 반드시 보존한다.
152
+ def wrap(code, detail, e)
153
+ return e if e.is_a?(Error)
154
+ new(code, detail, cause: e)
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'openssl'
4
+ require_relative 'error'
4
5
  require_relative 'util'
5
6
 
6
7
  module Saro
@@ -30,7 +31,7 @@ module Saro
30
31
  def self.get_signature_config(algorithm)
31
32
  config = SIGNATURE_CONFIG[algorithm]
32
33
  return config if config
33
- raise ArgumentError, "Unsupported DAT Crypto Algorithm: #{algorithm}"
34
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_ALG_UNSUPPORTED, "unknown signature algorithm: #{algorithm}")
34
35
  end
35
36
 
36
37
  class DatSignature
@@ -46,15 +47,34 @@ module Saro
46
47
  private_class_method def self.create_ec_key(curve_name, priv_bn = nil, pub_octet = nil)
47
48
  if priv_bn
48
49
  group = OpenSSL::PKey::EC::Group.new(curve_name)
50
+
51
+ # d must be in [1, n-1]; OpenSSL would otherwise happily build a key
52
+ # whose signatures can never verify.
53
+ if priv_bn <= 0 || priv_bn >= group.order
54
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::KEY_INVALID, "ecdsa private scalar out of range [1, n-1]")
55
+ end
56
+
49
57
  pub_octet ||= group.generator.mul(priv_bn).to_octet_string(:uncompressed)
50
-
58
+
51
59
  asn1 = OpenSSL::ASN1::Sequence.new([
52
60
  OpenSSL::ASN1::Integer.new(1),
53
61
  OpenSSL::ASN1::OctetString.new(priv_bn.to_s(2).rjust((group.degree + 7) / 8, "\x00".b)),
54
62
  OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::ObjectId.new(curve_name)], 0, :CONTEXT_SPECIFIC),
55
63
  OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::BitString.new(pub_octet)], 1, :CONTEXT_SPECIFIC)
56
64
  ])
57
- OpenSSL::PKey::EC.new(asn1.to_der)
65
+ key = OpenSSL::PKey::EC.new(asn1.to_der)
66
+
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
+ begin
72
+ key.check_key
73
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::PKey::ECError => e
74
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::KEY_INVALID, "ecdsa key pair rejected: #{e.message}", cause: e)
75
+ end
76
+
77
+ key
58
78
  elsif pub_octet
59
79
  spki = OpenSSL::ASN1::Sequence.new([
60
80
  OpenSSL::ASN1::Sequence.new([
@@ -63,9 +83,15 @@ module Saro
63
83
  ]),
64
84
  OpenSSL::ASN1::BitString.new(pub_octet)
65
85
  ])
66
- OpenSSL::PKey::EC.new(spki.to_der)
86
+ key = OpenSSL::PKey::EC.new(spki.to_der)
87
+ begin
88
+ key.check_key
89
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::PKey::ECError => e
90
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::KEY_INVALID, "ecdsa public key rejected: #{e.message}", cause: e)
91
+ end
92
+ key
67
93
  else
68
- raise ArgumentError, "Either private key or public key must be provided"
94
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_ARGUMENT_INVALID, "either private key or public key must be provided")
69
95
  end
70
96
  end
71
97
 
@@ -76,6 +102,11 @@ module Saro
76
102
  new(algorithm, key, key, config)
77
103
  else
78
104
  key = OpenSSL::PKey::EC.generate(config[:curve])
105
+ begin
106
+ key.check_key
107
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::PKey::ECError => e
108
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::INTERNAL_UNKNOWN, "generated ecdsa key pair is invalid: #{e.message}", cause: e)
109
+ end
79
110
  new(algorithm, key, key, config)
80
111
  end
81
112
  end
@@ -86,7 +117,7 @@ module Saro
86
117
 
87
118
  if config[:name] == "HMAC"
88
119
  if bytes_data.bytesize != config[:hmac_len]
89
- raise ArgumentError, "Invalid HMAC key length: expected #{config[:hmac_len]}, got #{bytes_data.bytesize}"
120
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::KEY_INVALID, "hmac key must be #{config[:hmac_len]} bytes, got #{bytes_data.bytesize}")
90
121
  end
91
122
  new(algorithm, bytes_data, bytes_data, config)
92
123
  else
@@ -106,7 +137,7 @@ module Saro
106
137
  elsif bytes_data.bytesize == public_len
107
138
  verifying_key = create_ec_key(config[:curve], nil, bytes_data)
108
139
  else
109
- raise ArgumentError, "Invalid ECDSA key length"
140
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::KEY_INVALID, "ecdsa key length matches neither private+public nor public")
110
141
  end
111
142
 
112
143
  new(algorithm, signing_key, verifying_key, config)
@@ -115,7 +146,7 @@ module Saro
115
146
 
116
147
  def exports(verify_only = false)
117
148
  if verify_only && !support_verify_only
118
- raise ArgumentError, "#{config[:name]} does not supported verifying only key"
149
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::KEY_VERIFY_ONLY_UNSUPPORTED, @algorithm.to_s)
119
150
  end
120
151
 
121
152
  if @config[:name] == "HMAC"
@@ -136,9 +167,9 @@ module Saro
136
167
  end
137
168
 
138
169
  def sign(body)
139
- raise ArgumentError, "Signature key is not supported - verifying only key" unless @signing_key
170
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_KEY_MISSING, "this key is verify-only") unless @signing_key
140
171
  body = normalize_body(body)
141
- raise ArgumentError, "Sign Error - body is empty" if body.nil? || body.empty?
172
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_ARGUMENT_INVALID, "body to sign is empty") if body.nil? || body.empty?
142
173
 
143
174
  if @config[:name] == "HMAC"
144
175
  OpenSSL::HMAC.digest(@config[:hash], @signing_key, body)
@@ -148,30 +179,58 @@ module Saro
148
179
  end
149
180
  end
150
181
 
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.
151
186
  def verify(body, signature)
187
+ verify_bytes(body, signature)
188
+ end
189
+
190
+ # Verifies a base64url-encoded signature.
191
+ def verify_base64(body, signature_base64)
192
+ sig_bytes = begin
193
+ Saro::Dat::Util.decode_base64_url(signature_base64)
194
+ rescue Saro::Dat::Error => e
195
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_MALFORMED, "signature is not base64url", cause: e)
196
+ end
197
+ verify_bytes(body, sig_bytes)
198
+ end
199
+
200
+ private def verify_bytes(body, sig_bytes)
152
201
  body = normalize_body(body)
153
202
  return false if body.nil? || body.empty?
154
203
 
155
- sig_bytes = if signature.is_a?(String) && signature.encoding != Encoding::BINARY
156
- Saro::Dat::Util.decode_base64_url(signature)
157
- else
158
- signature
159
- end
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
+ sig_bytes = sig_bytes.b if sig_bytes.is_a?(String) && sig_bytes.encoding != Encoding::BINARY
160
207
 
208
+ # false 는 "서명이 안 맞는다"만 뜻한다. 예전에는 `rescue StandardError => false`
209
+ # 가 잘못된 키 타입·손상된 핸들·라이브러리 버그까지 전부 삼켜서 프로그래밍
210
+ # 오류가 위조 시도로 보고됐다. 그래서 연산 실패는 SIG_BACKEND 로 갈라 낸다.
161
211
  if @config[:name] == "HMAC"
162
212
  begin
163
213
  actual_sig = OpenSSL::HMAC.digest(@config[:hash], @verifying_key, body)
164
- # Use fixed-time comparison if possible
165
- return actual_sig == sig_bytes
166
- rescue StandardError
167
- return false
214
+ rescue StandardError => e
215
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_BACKEND, "hmac computation failed", cause: e)
168
216
  end
217
+ # 길이가 다르면 볼 것도 없이 불일치다.
218
+ return false unless sig_bytes.is_a?(String) && actual_sig.bytesize == sig_bytes.bytesize
219
+ OpenSSL.fixed_length_secure_compare(actual_sig, sig_bytes)
169
220
  else
221
+ der_sig = begin
222
+ raw_to_der_signature(sig_bytes)
223
+ rescue StandardError
224
+ # r||s 길이가 곡선과 안 맞는다 = 서명 자체의 형식 오류. 위조가 아니다.
225
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_MALFORMED, "ecdsa signature is not a valid r||s pair")
226
+ end
170
227
  begin
171
- der_sig = raw_to_der_signature(sig_bytes)
172
228
  @verifying_key.dsa_verify_asn1(OpenSSL::Digest.digest(@config[:hash], body), der_sig)
173
- rescue StandardError
229
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::PKey::ECError
230
+ # OpenSSL 이 서명을 해독조차 못 한 경우다. 불일치로 본다.
174
231
  false
232
+ rescue StandardError => e
233
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::SIG_BACKEND, "ecdsa verification failed to run", cause: e)
175
234
  end
176
235
  end
177
236
  end
data/lib/saro/dat/util.rb CHANGED
@@ -1,12 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'base64'
4
+ require_relative 'error'
4
5
 
5
6
  module Saro
6
7
  module Dat
7
8
  module Util
8
9
  module_function
9
10
 
11
+ U64_MAX = 0xFFFFFFFFFFFFFFFF
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
+ def parse_u64(s)
20
+ unless s.is_a?(String) && s.match?(/\A[0-9]+\z/)
21
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_ARGUMENT_INVALID, "not an unsigned decimal integer: #{s.inspect}")
22
+ end
23
+ v = s.to_i
24
+ if v > U64_MAX
25
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_ARGUMENT_INVALID, "exceeds u64: #{s.inspect}")
26
+ end
27
+ v
28
+ end
29
+
30
+ # Strict unsigned 64-bit hex parse, matching rust's `u64::from_str_radix(s, 16)`.
31
+ def parse_u64_hex(s)
32
+ unless s.is_a?(String) && s.match?(/\A[0-9a-fA-F]+\z/)
33
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_ARGUMENT_INVALID, "not an unsigned hex integer: #{s.inspect}")
34
+ end
35
+ v = s.to_i(16)
36
+ if v > U64_MAX
37
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_ARGUMENT_INVALID, "exceeds u64: #{s.inspect}")
38
+ end
39
+ v
40
+ end
41
+
10
42
  def encode_base64_url(s)
11
43
  return "".b if s.nil?
12
44
  if s.is_a?(String)
@@ -27,12 +59,18 @@ module Saro
27
59
  return "".b if s.empty?
28
60
  end
29
61
 
30
- # More robust way for older Ruby
31
- s = s.to_s.tr('-_', '+/')
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
+ s = s.to_s
32
66
  rem = s.bytesize % 4
33
67
  s += ("=" * (4 - rem)) if rem > 0
34
68
 
35
- Base64.decode64(s).b
69
+ begin
70
+ Base64.urlsafe_decode64(s).b
71
+ rescue ArgumentError => e
72
+ raise Saro::Dat::Error.new(Saro::Dat::ErrorCode::CONFIG_ARGUMENT_INVALID, "not a valid base64url string", cause: e)
73
+ end
36
74
  end
37
75
 
38
76
  def decode_base64_url_str(s)
data/lib/saro-dat.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Error 는 다른 모든 파일이 참조하므로 가장 먼저 로드한다.
4
+ require_relative 'saro/dat/error'
3
5
  require_relative 'saro/dat/util'
4
6
  require_relative 'saro/dat/crypto'
5
7
  require_relative 'saro/dat/signature'
@@ -7,9 +9,3 @@ require_relative 'saro/dat/dat_certificate'
7
9
  require_relative 'saro/dat/dat'
8
10
  require_relative 'saro/dat/dat_manager'
9
11
  require_relative 'saro/dat/dat_cms_manager'
10
-
11
- module Saro
12
- module Dat
13
- class Error < StandardError; end
14
- end
15
- end
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.3.4"
5
+ spec.version = "4.6.0"
6
6
  spec.authors = ["marker"]
7
7
  spec.email = ["j@saro.me"]
8
8
 
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.3.4
4
+ version: 4.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - marker
@@ -116,6 +116,7 @@ files:
116
116
  - lib/saro/dat/dat_certificate.rb
117
117
  - lib/saro/dat/dat_cms_manager.rb
118
118
  - lib/saro/dat/dat_manager.rb
119
+ - lib/saro/dat/error.rb
119
120
  - lib/saro/dat/signature.rb
120
121
  - lib/saro/dat/util.rb
121
122
  - saro-dat.gemspec