masks 0.11.0 → 0.12.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.
@@ -3,6 +3,9 @@ module Masks
3
3
  class SessionsController < BaseController
4
4
  RESTARTED = "masks_restarted_at".freeze
5
5
  RESTART_WITHIN = 60
6
+ SAME_ORIGIN = %w[same-origin none].freeze
7
+
8
+ before_action :refuse_cross_site, only: :destroy
6
9
 
7
10
  def show
8
11
  if masks_configured? && (masks_signed_in? || (masks_tokens && masks_refresh!))
@@ -10,7 +13,7 @@ module Masks
10
13
 
11
14
  render json: masked.to_h
12
15
  else
13
- masks_forget
16
+ masks_forget unless masks_configured?
14
17
  masks_refuse_json
15
18
  end
16
19
  end
@@ -40,7 +43,8 @@ module Masks
40
43
  def callback
41
44
  pending = masks_requests.claim(params[:state])
42
45
 
43
- return refuse(params[:error], params[:error_description], pending) if params[:error].present?
46
+ return refuse(params[:error], params[:error_description], pending) if params[:error].present? && pending
47
+ return unstarted if params[:error].present?
44
48
  return stale if pending.nil?
45
49
 
46
50
  tokens = masks_session.complete(
@@ -83,15 +87,42 @@ module Masks
83
87
  if masks_wants_json?
84
88
  render json: { "signed_in" => false, "logout_url" => upstream }.compact
85
89
  else
86
- redirect_to upstream || masks_config.after_sign_out, allow_other_host: upstream.present?
90
+ redirect_to upstream || masks_after_sign_out_path, allow_other_host: upstream.present?
87
91
  end
88
92
  end
89
93
 
94
+ def signed_out
95
+ return stale_sign_out if masks_farewells.claim(params[:state].to_s).nil?
96
+
97
+ redirect_to masks_after_sign_out_path
98
+ end
99
+
90
100
  private
91
101
 
102
+ def refuse_cross_site
103
+ return if same_origin?
104
+
105
+ answer("invalid_request", "that request came from another site", :forbidden, failed: "sign-out")
106
+ end
107
+
108
+ def same_origin?
109
+ site = request.headers["Sec-Fetch-Site"].to_s
110
+
111
+ return false unless site.empty? || SAME_ORIGIN.include?(site)
112
+
113
+ sent = request.headers["Origin"].to_s
114
+
115
+ sent.empty? || [ request.base_url, own_origin ].include?(sent)
116
+ end
117
+
118
+ def own_origin
119
+ URI.join(masks_config.redirect_uri_for(request), "/").to_s.chomp("/")
120
+ end
121
+
92
122
  def renew_session
93
123
  carried = session.to_hash.slice(REQUESTS, HANDSHAKES, Masks::Rails::Authentication::STEPPED_UP)
94
124
 
125
+ masks_forget
95
126
  reset_session
96
127
  carried.each { |key, value| session[key] = value }
97
128
  end
@@ -130,6 +161,16 @@ module Masks
130
161
  )
131
162
  end
132
163
 
164
+ def unstarted
165
+ answer("invalid_state", "masks refused a sign-in that this browser did not start, or one that expired",
166
+ :bad_request)
167
+ end
168
+
169
+ def stale_sign_out
170
+ answer("invalid_state", "that sign-out is not one this browser started, or it expired", :bad_request,
171
+ failed: "sign-out")
172
+ end
173
+
133
174
  def restart
134
175
  session[RESTARTED] = Time.now.to_i
135
176
  redirect_to start_path
@@ -145,11 +186,11 @@ module Masks
145
186
  answer(code, description, :bad_request)
146
187
  end
147
188
 
148
- def answer(code, description, status)
189
+ def answer(code, description, status, failed: "sign-in")
149
190
  if masks_wants_json?
150
191
  render json: { "error" => code, "error_description" => description }, status: status
151
192
  else
152
- render plain: "sign-in failed — #{code}: #{description}", status: status
193
+ render plain: "#{failed} failed — #{code}: #{description}", status: status
153
194
  end
154
195
  end
155
196
  end
data/config/routes.rb CHANGED
@@ -4,6 +4,7 @@ Masks::Rails::Engine.routes.draw do
4
4
  get "/avatar", to: "avatars#show", as: :avatar
5
5
  get "/callback", to: "sessions#callback", as: :callback
6
6
  match "/logout", to: "sessions#destroy", via: %i[get post delete], as: :logout
7
+ get "/logout/return", to: "sessions#signed_out", as: :logout_return
7
8
  post "/logout/backchannel", to: "logouts#create", as: :backchannel_logout
8
9
 
9
10
  get "/handshake", to: "handshakes#show", as: :handshake
@@ -74,7 +74,7 @@ module Masks
74
74
  "SERVER_PORT" => uri.port.to_s,
75
75
  "SERVER_PROTOCOL" => "HTTP/1.1",
76
76
  "REMOTE_ADDR" => LOOPBACK,
77
- "HTTP_HOST" => uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}",
77
+ "HTTP_HOST" => Nonces.origin(uri).delete_prefix("#{uri.scheme}://"),
78
78
  "HTTPS" => uri.scheme == "https" ? "on" : "off",
79
79
  "rack.url_scheme" => uri.scheme,
80
80
  "rack.input" => StringIO.new(input),
@@ -6,17 +6,22 @@ module Masks
6
6
  AUTH_METHOD = "client_secret_basic".freeze
7
7
 
8
8
  attr_reader :issuer, :name, :resource, :redirect_uris, :scope, :return_to,
9
- :backchannel_logout_uri
9
+ :post_logout_redirect_uri
10
10
 
11
11
  def initialize(issuer, name:, resource:, redirect_uris:, return_to:,
12
- scope: Session::DEFAULT_SCOPE, backchannel_logout_uri: nil)
12
+ scope: Session::DEFAULT_SCOPE, backchannel_logout_uri: nil, post_logout_redirect_uri: nil)
13
13
  @issuer = Issuer.resolve(issuer)
14
14
  @name = name.to_s
15
15
  @resource = resource.to_s
16
16
  @redirect_uris = Array(redirect_uris).map(&:to_s)
17
17
  @scope = Array(scope).flat_map { |value| value.to_s.split(/\s+/) }.reject(&:empty?)
18
18
  @return_to = return_to.to_s
19
- @backchannel_logout_uri = backchannel_logout_uri&.to_s
19
+ @backchannel_logout_uri = backchannel_logout_uri.to_s if present?(backchannel_logout_uri)
20
+ @post_logout_redirect_uri = post_logout_redirect_uri.to_s if present?(post_logout_redirect_uri)
21
+ end
22
+
23
+ def backchannel_logout_uri
24
+ @backchannel_logout_uri if @backchannel_logout_uri && issuer.backchannel_logout?
20
25
  end
21
26
 
22
27
  def endpoint
@@ -36,7 +41,10 @@ module Masks
36
41
  [ "state", state ]
37
42
  ]
38
43
 
39
- query << [ "backchannel_logout_uri", backchannel_logout_uri ] if backchannel_logout_uri
44
+ backchannel = backchannel_logout_uri
45
+
46
+ query << [ "backchannel_logout_uri", backchannel ] if backchannel
47
+ query << [ "post_logout_redirect_uri", post_logout_redirect_uri ] if post_logout_redirect_uri
40
48
  redirect_uris.each { |uri| query << [ "redirect_uris", uri ] }
41
49
 
42
50
  "#{endpoint}?#{URI.encode_www_form(query)}"
@@ -4,6 +4,7 @@ module Masks
4
4
  OPEN_TIMEOUT = 5
5
5
  READ_TIMEOUT = 10
6
6
  READ_LIMIT = 1 << 20
7
+ FETCH_LIMIT = 8 << 20
7
8
 
8
9
  module_function
9
10
 
@@ -43,19 +44,7 @@ module Masks
43
44
  end
44
45
 
45
46
  def fetch(url, headers = {})
46
- uri = checked(URI.parse(url.to_s))
47
- embedded = Masks::Client.embedded_for(uri)
48
-
49
- return embedded.deliver(Net::HTTP::Get.new(uri, headers)) if embedded
50
-
51
- Net::HTTP.start(
52
- uri.hostname, uri.port,
53
- use_ssl: uri.scheme == "https",
54
- open_timeout: OPEN_TIMEOUT,
55
- read_timeout: READ_TIMEOUT
56
- ) { |http| http.request(Net::HTTP::Get.new(uri, headers)) }
57
- rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
58
- raise Unreachable, "#{uri.host} is unreachable (#{e.class})"
47
+ deliver(Net::HTTP::Get.new(checked(URI.parse(url.to_s)), headers), limit: FETCH_LIMIT)
59
48
  end
60
49
 
61
50
  def json(verb, url, body, headers)
@@ -9,7 +9,7 @@ module Masks
9
9
  def verify(token, issuer:, audience:, algorithms: ALGORITHMS)
10
10
  held = Verifier
11
11
  .new(issuer, audience: audience, algorithms: algorithms)
12
- .verify(token, required: %w[iss aud iat jti events], typ: Verifier::LOGOUT_TOKEN)
12
+ .verify(token, required: %w[iss aud iat exp jti events], typ: Verifier::LOGOUT_TOKEN)
13
13
 
14
14
  new(held).validate!
15
15
  end
@@ -37,6 +37,10 @@ module Masks
37
37
  Time.at(claims["iat"].to_i).utc
38
38
  end
39
39
 
40
+ def expires_at
41
+ Time.at(claims["exp"].to_i).utc
42
+ end
43
+
40
44
  def validate!
41
45
  refuse!("logout token carries a nonce, so it is an id token") if claims.key?("nonce")
42
46
  refuse!("logout token names neither a subject nor a session") if subject.nil? && sid.nil?
@@ -57,7 +57,7 @@ module Masks
57
57
 
58
58
  parameters = [
59
59
  [ "error", error.code ],
60
- [ "error_description", error.description ],
60
+ [ "error_description", (error.description if error.code) ],
61
61
  [ "scope", error.scope || (scopes.join(" ") if scopes.any?) ],
62
62
  [ "resource_metadata", metadata_url ]
63
63
  ]
@@ -28,8 +28,16 @@ module Masks
28
28
  typed!(header["typ"], Array(typ))
29
29
 
30
30
  claims
31
- rescue JWT::DecodeError => e
32
- raise InvalidToken, e.message
31
+ rescue JWT::ExpiredSignature
32
+ raise InvalidToken, "that token has expired"
33
+ rescue JWT::InvalidIssuerError
34
+ raise InvalidToken, "that token was issued by another server"
35
+ rescue JWT::InvalidAudError
36
+ raise InvalidToken, "that token was issued for another app"
37
+ rescue JWT::MissingRequiredClaim
38
+ raise InvalidToken, "that token is missing a claim it needs"
39
+ rescue JWT::DecodeError
40
+ raise InvalidToken, "that token was not signed by #{issuer.url}"
33
41
  end
34
42
 
35
43
  private
data/lib/masks/client.rb CHANGED
@@ -46,9 +46,6 @@ module Masks
46
46
  # [verifier] an API checking a bearer it was handed
47
47
  # [resource] an API publishing what it is and which scopes it takes
48
48
  # [handshake] an app registering itself, once, without a copied secret
49
- #
50
- # Everything reachable from here talks HTTP to a masks issuer and holds no
51
- # state of its own beyond the discovery cache in ::registry.
52
49
  module Client
53
50
  class << self
54
51
  attr_writer :plain_http
@@ -78,7 +75,6 @@ module Masks
78
75
  # cheap after the first.
79
76
  #
80
77
  # issuer = Masks::Client.issuer("https://auth.example")
81
- # issuer.authorization_url(client_id: id, redirect_uri: uri)
82
78
  #
83
79
  # @return [Masks::Client::Issuer]
84
80
  def issuer(url, **options)
@@ -9,6 +9,10 @@ module Masks
9
9
  if respond_to?(:helper_method)
10
10
  helper_method :masked, :masks_login_url
11
11
  end
12
+
13
+ if respond_to?(:rescue_from) && method_defined?(:render)
14
+ rescue_from Masks::Rails::Cache::Unavailable, with: :masks_cache_unavailable
15
+ end
12
16
  end
13
17
 
14
18
  REQUESTS = "masks_requests".freeze
@@ -40,24 +44,42 @@ module Masks
40
44
  def masks_tokens
41
45
  return @masks_tokens if defined?(@masks_tokens)
42
46
 
43
- @masks_tokens = Masks::Client::Tokens.from_h(session[masks_config.session_key])
47
+ held = masks_held
48
+
49
+ if held.present? && masks_departed?(held)
50
+ masks_forget
51
+ return nil
52
+ end
53
+
54
+ @masks_tokens = Masks::Client::Tokens.from_h(held)
55
+ end
56
+
57
+ def masks_cache
58
+ @masks_cache ||= Masks::Rails::Cache.new(masks_config.cache)
44
59
  end
45
60
 
46
61
  def masks_store(tokens, identity: nil)
47
62
  held = tokens.to_h.except("id_token")
63
+ held.delete("token_type") if held["token_type"] == "Bearer"
48
64
  held["identity"] = identity&.slice(*IDENTITY) || masks_held["identity"]
65
+ held["signed_in_at"] = masks_held["signed_in_at"] || Masks::Rails::Cache.now
49
66
 
50
- session[masks_config.session_key] = held.compact
67
+ masks_session_cookies.write(held.compact)
68
+ @masks_held = held.compact
51
69
  @masks_tokens = tokens
52
70
  @masked = nil
53
71
  end
54
72
 
55
73
  def masks_held
56
- session[masks_config.session_key] || {}
74
+ @masks_held ||= masks_session_cookies.read || {}
75
+ end
76
+
77
+ def masks_session_cookies
78
+ Masks::Rails::SessionCookies.new(request, masks_config.session_key)
57
79
  end
58
80
 
59
81
  IDENTITY = [
60
- "sub", "name", "preferred_username", "email", "email_verified", "tenant",
82
+ "sub", "sid", "name", "preferred_username", "email", "email_verified", "tenant",
61
83
  "picture", Masks::Client::Claims::AVATARS, Masks::Client::Claims::ORGANIZATION,
62
84
  Masks::Client::Claims::ORGANIZATIONS
63
85
  ].freeze
@@ -69,7 +91,8 @@ module Masks
69
91
  end
70
92
 
71
93
  def masks_forget
72
- session.delete(masks_config.session_key)
94
+ masks_session_cookies.clear
95
+ @masks_held = {}
73
96
  @masks_tokens = nil
74
97
  @masked = nil
75
98
  end
@@ -129,22 +152,34 @@ module Masks
129
152
  end
130
153
 
131
154
  def masks_refresh!
132
- return false unless masks_configured?
133
- return false if masks_tokens&.refresh_token.nil?
155
+ return false unless masks_configured? && masks_tokens
134
156
 
135
- refreshed = masks_session.refresh(
136
- masks_tokens.refresh_token,
137
- resource: masks_config.resource_for(request)
138
- )
157
+ spent = masks_tokens.refresh_token
139
158
 
140
- masks_store(refreshed, identity: masks_refreshed_identity(refreshed))
141
- true
142
- rescue Masks::Client::Unregistered
143
- masks_disconnect!
144
- false
145
- rescue Masks::Client::Rejected
146
- masks_forget
147
- false
159
+ if spent.nil?
160
+ masks_forget
161
+ return false
162
+ end
163
+
164
+ renewal = begin
165
+ masks_cache.renew(masks_config.issuer_for(request).to_s, spent) { masks_renewal(spent) }
166
+ rescue Masks::Rails::Cache::Unavailable
167
+ Masks::Rails::Renewal.unreachable
168
+ end
169
+
170
+ case renewal.outcome
171
+ when :renewed
172
+ masks_store(renewal.tokens, identity: renewal.identity)
173
+ true
174
+ when :unregistered
175
+ masks_disconnect!
176
+ false
177
+ when :refused
178
+ masks_forget
179
+ false
180
+ else
181
+ false
182
+ end
148
183
  end
149
184
 
150
185
  def masks_refreshed_identity(tokens)
@@ -154,18 +189,9 @@ module Masks
154
189
 
155
190
  profile = masks_session.profile(tokens)
156
191
 
157
- profile.empty? ? nil : held.merge(profile.slice(*IDENTITY))
158
- end
159
-
160
- def masks_logout_url(state:)
161
- return nil unless masks_configured?
192
+ return nil if profile.empty? || profile["sub"].to_s != held["sub"].to_s
162
193
 
163
- masks_session.end_session_url(
164
- post_logout_redirect_uri: masks_config.return_to_for(request),
165
- state: state
166
- )
167
- rescue Masks::Client::Error
168
- nil
194
+ held.merge(profile.slice(*IDENTITY))
169
195
  end
170
196
 
171
197
  def masks_login_url(return_to: nil, organization: nil, privilege: nil)
@@ -194,14 +220,41 @@ module Masks
194
220
 
195
221
  private
196
222
 
223
+ def masks_departed?(held)
224
+ identity = held["identity"]
225
+
226
+ return false unless identity.is_a?(Hash)
227
+
228
+ masks_cache.departed?(masks_config.issuer_for(request).to_s, identity, since: held["signed_in_at"].to_i)
229
+ rescue Masks::Rails::Configuration::Unconfigured
230
+ false
231
+ end
232
+
233
+ def masks_renewal(spent)
234
+ refreshed = masks_session.refresh(spent, resource: masks_config.resource_for(request))
235
+ refreshed = Masks::Client::Tokens.from_h(refreshed.to_h.merge("refresh_token" => spent)) if refreshed.refresh_token.nil?
236
+
237
+ Masks::Rails::Renewal.renewed(refreshed, masks_refreshed_identity(refreshed))
238
+ rescue Masks::Client::Unregistered
239
+ Masks::Rails::Renewal.unregistered
240
+ rescue Masks::Client::Rejected => e
241
+ e.status.to_i >= 500 || e.status.to_i == 429 ? Masks::Rails::Renewal.unreachable : Masks::Rails::Renewal.refused
242
+ rescue Masks::Client::Error
243
+ Masks::Rails::Renewal.unreachable
244
+ end
245
+
197
246
  def masks_sign_out_upstream
198
247
  return nil unless masks_configured?
199
248
 
200
249
  farewell = masks_farewells.open
201
- url = masks_logout_url(state: farewell.id)
202
250
 
203
- masks_farewells.clear! if url.nil?
204
- url
251
+ masks_session.end_session_url(
252
+ post_logout_redirect_uri: masks_config.post_logout_redirect_uri_for(request),
253
+ state: farewell.id
254
+ )
255
+ rescue Masks::Client::Error
256
+ masks_farewells.clear!
257
+ nil
205
258
  end
206
259
 
207
260
  def masks_after_sign_out_path
@@ -268,6 +321,18 @@ module Masks
268
321
  redirect_to Masks::Rails::Engine.routes.url_helpers.start_path
269
322
  end
270
323
 
324
+ def masks_cache_unavailable(error)
325
+ response.headers["Cache-Control"] = "no-store"
326
+ response.headers["Retry-After"] = "5"
327
+
328
+ if masks_wants_json?
329
+ render json: { "error" => "temporarily_unavailable", "error_description" => error.message },
330
+ status: :service_unavailable
331
+ else
332
+ render plain: "Signing in is unavailable right now. Try again in a moment.", status: :service_unavailable
333
+ end
334
+ end
335
+
271
336
  def masks_refuse_json
272
337
  response.headers["Cache-Control"] = "no-store"
273
338
 
@@ -0,0 +1,173 @@
1
+ module Masks
2
+ module Rails
3
+ class Cache
4
+ class Unavailable < Masks::Client::Error; end
5
+
6
+ CLAIM = 30
7
+ RESULT = 60
8
+ WAIT = 0.05
9
+ PATIENCE = 30
10
+ LOGOUT = 600
11
+ LOGOUT_LEEWAY = 120
12
+ DEPARTURE = 30 * 24 * 3600
13
+ CIPHER = "aes-256-gcm".freeze
14
+ NONCE = 12
15
+ TAG = 16
16
+
17
+ def self.key(*parts)
18
+ "masks:#{OpenSSL::Digest::SHA256.hexdigest(parts.map(&:to_s).join("\0"))}"
19
+ end
20
+
21
+ def self.now
22
+ (Time.now.to_f * 1000).to_i
23
+ end
24
+
25
+ attr_reader :store
26
+
27
+ def initialize(store)
28
+ @store = store
29
+ end
30
+
31
+ def first?(key, expires_in:)
32
+ add(self.class.key("proof", key), expires_in)
33
+ end
34
+
35
+ def first_logout?(issuer, logout)
36
+ add(self.class.key("logout", issuer, logout.jti), logout_memory(logout))
37
+ end
38
+
39
+ def depart!(issuer, logout)
40
+ kind, value = logout.sid.to_s.empty? ? [ "sub", logout.subject ] : [ "sid", logout.sid ]
41
+
42
+ kept = begin
43
+ write(self.class.key("departed", issuer, kind, value), self.class.now, expires_in: DEPARTURE)
44
+ rescue Unavailable
45
+ false
46
+ end
47
+
48
+ return if kept
49
+
50
+ forgotten { delete(self.class.key("logout", issuer, logout.jti)) }
51
+
52
+ raise Unavailable, "the cache did not keep that logout"
53
+ end
54
+
55
+ def departed?(issuer, identity, since:)
56
+ [ [ "sid", identity["sid"] ], [ "sub", identity["sub"] ] ].any? do |kind, value|
57
+ next false if value.to_s.empty?
58
+
59
+ at = read(self.class.key("departed", issuer, kind, value))
60
+
61
+ at.is_a?(Integer) && at >= since
62
+ end
63
+ end
64
+
65
+ def renew(issuer, refresh_token)
66
+ result = self.class.key("refreshed", issuer, refresh_token)
67
+ claim = self.class.key("refreshing", issuer, refresh_token)
68
+ found = opened(result, refresh_token)
69
+
70
+ return found if found
71
+ return awaited(result, claim, refresh_token) unless add(claim, CLAIM)
72
+
73
+ begin
74
+ held = yield
75
+
76
+ forgotten { write(result, sealed(refresh_token, held), expires_in: RESULT) } unless held.outcome == :unreachable
77
+
78
+ held
79
+ ensure
80
+ forgotten { delete(claim) }
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def add(key, expires_in)
87
+ return true if write(key, 1, unless_exist: true, expires_in: expires_in)
88
+ return false unless read(key).nil?
89
+
90
+ raise Unavailable, "the cache neither kept that key nor holds it, so it is not answering"
91
+ end
92
+
93
+ def read(key)
94
+ reached { store.read(key) }
95
+ end
96
+
97
+ def write(key, value, **options)
98
+ reached { store.write(key, value, **options) }
99
+ end
100
+
101
+ def delete(key)
102
+ reached { store.delete(key) }
103
+ end
104
+
105
+ def reached
106
+ yield
107
+ rescue Unavailable
108
+ raise
109
+ rescue StandardError => e
110
+ raise Unavailable, "the cache could not be reached (#{e.class})"
111
+ end
112
+
113
+ def forgotten
114
+ yield
115
+ rescue Unavailable
116
+ nil
117
+ end
118
+
119
+ def logout_memory(logout)
120
+ [ LOGOUT, logout.expires_at.to_i - Time.now.to_i + LOGOUT_LEEWAY ].max
121
+ end
122
+
123
+ def awaited(result, claim, refresh_token)
124
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PATIENCE
125
+
126
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
127
+ sleep WAIT
128
+
129
+ found = opened(result, refresh_token)
130
+
131
+ return found if found
132
+ return opened(result, refresh_token) || Renewal.unreachable if read(claim).nil?
133
+ end
134
+
135
+ Renewal.unreachable
136
+ end
137
+
138
+ def cipher(mode, refresh_token)
139
+ held = OpenSSL::Cipher.new(CIPHER).public_send(mode)
140
+ held.key = OpenSSL::KDF.hkdf(refresh_token.to_s, salt: "masks", info: "masks/rails shared refresh",
141
+ length: 32, hash: "SHA256")
142
+ held
143
+ end
144
+
145
+ def sealed(refresh_token, held)
146
+ plain = JSON.generate(held.to_h)
147
+ sealing = cipher(:encrypt, refresh_token)
148
+ nonce = SecureRandom.random_bytes(NONCE)
149
+ sealing.iv = nonce
150
+ sealing.auth_data = ""
151
+ body = sealing.update(plain) + sealing.final
152
+
153
+ Base64.strict_encode64(nonce + sealing.auth_tag(TAG) + body)
154
+ end
155
+
156
+ def opened(result, refresh_token)
157
+ held = read(result)
158
+
159
+ return nil if held.nil?
160
+
161
+ raw = Base64.strict_decode64(held.to_s)
162
+ opening = cipher(:decrypt, refresh_token)
163
+ opening.iv = raw.byteslice(0, NONCE)
164
+ opening.auth_tag = raw.byteslice(NONCE, TAG)
165
+ opening.auth_data = ""
166
+
167
+ Renewal.from_h(JSON.parse(opening.update(raw.byteslice((NONCE + TAG)..).to_s) + opening.final))
168
+ rescue ArgumentError, TypeError, OpenSSL::Cipher::CipherError, JSON::ParserError
169
+ Renewal.unreachable
170
+ end
171
+ end
172
+ end
173
+ end