kstor 0.4.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.
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module KStor
6
+ # Internal exception when a request is received with neither a session ID nor
7
+ # a login/password pair.
8
+ #
9
+ # We can't use a KStor::Error here because kstor/error.rb require()s
10
+ # kstor/message.rb .
11
+ class RequestMissesAuthData < RuntimeError
12
+ end
13
+
14
+ class UnparsableResponse < RuntimeError
15
+ end
16
+
17
+ # A user request or response.
18
+ class Message
19
+ attr_reader :type
20
+ attr_reader :args
21
+
22
+ def initialize(type, args)
23
+ @type = type
24
+ @args = args
25
+ end
26
+
27
+ def to_h
28
+ { 'type' => @type, 'args' => @args }
29
+ end
30
+
31
+ def serialize
32
+ to_h.to_json
33
+ end
34
+
35
+ # Parse a user request, freshly arrived from the network.
36
+ #
37
+ # @param str [String] serialized request
38
+ # @return [LoginRequest,SessionRequest] a request
39
+ # @raise [KStor::RequestMissesAuthData]
40
+ def self.parse_request(str)
41
+ data = JSON.parse(str)
42
+ if data.key?('login') && data.key?('password')
43
+ LoginRequest.new(
44
+ data['login'], data['password'],
45
+ data['type'], data['args']
46
+ )
47
+ elsif data.key?('session_id')
48
+ SessionRequest.new(
49
+ data['session_id'], data['type'], data['args']
50
+ )
51
+ else
52
+ raise RequestMissesAuthData
53
+ end
54
+ end
55
+ end
56
+
57
+ # A user authentication request.
58
+ class LoginRequest < Message
59
+ attr_reader :login
60
+ attr_reader :password
61
+
62
+ def initialize(login, password, type, args)
63
+ @login = login
64
+ @password = password
65
+ super(type, args)
66
+ end
67
+
68
+ def inspect
69
+ fmt = [
70
+ '#<KStor::LoginRequest:%<id>x',
71
+ '@login=%<login>s',
72
+ '@password="******"',
73
+ '@args=%<args>s>'
74
+ ].join(' ')
75
+ format(fmt, id: object_id, login: @login.inspect, args: @args.inspect)
76
+ end
77
+
78
+ def to_h
79
+ super.merge('login' => @login, 'password' => @password)
80
+ end
81
+ end
82
+
83
+ # A user request with a session ID.
84
+ class SessionRequest < Message
85
+ attr_reader :session_id
86
+
87
+ def initialize(session_id, type, args)
88
+ @session_id = session_id
89
+ super(type, args)
90
+ end
91
+
92
+ def inspect
93
+ fmt = [
94
+ '#<KStor::SessionRequest:%<id>x',
95
+ '@session_id=******',
96
+ '@args=%<args>s>'
97
+ ].join(' ')
98
+ format(fmt, id: object_id, args: @args.inspect)
99
+ end
100
+
101
+ def to_h
102
+ super.merge('session_id' => @session_id)
103
+ end
104
+ end
105
+
106
+ # Response to a user request.
107
+ class Response < Message
108
+ attr_accessor :session_id
109
+
110
+ def initialize(type, args)
111
+ @session_id = nil
112
+ super
113
+ end
114
+
115
+ def self.parse(str)
116
+ data = JSON.parse(str)
117
+ resp = new(data['type'], data['args'])
118
+ resp.session_id = data['session_id']
119
+ resp
120
+ rescue JSON::ParserError => e
121
+ raise UnparsableResponse, e.message
122
+ end
123
+
124
+ def error?
125
+ @type == 'error'
126
+ end
127
+
128
+ def to_h
129
+ super.merge('session_id' => @session_id)
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,437 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+
6
+ require 'kstor/crypto'
7
+
8
+ module KStor
9
+ module Model
10
+ # @!macro [new] dsl_model_properties_rw
11
+ # @!attribute $1
12
+ # @return returns value of property $1
13
+
14
+ # Base class for model objects.
15
+ class Base
16
+ class << self
17
+ attr_reader :properties
18
+
19
+ # Define a named property
20
+ #
21
+ # @param name [Symbol] name of the property
22
+ # @param read_only [Boolean] false to define both a getter and a setter
23
+ def property(name, read_only: false)
24
+ @properties ||= []
25
+ @properties << name
26
+ define_method(name) do
27
+ @data[name]
28
+ end
29
+ return if read_only
30
+
31
+ define_method("#{name}=".to_sym) do |value|
32
+ @data[name] = value
33
+ @dirty = true
34
+ end
35
+ end
36
+
37
+ # Check if a property is defined.
38
+ #
39
+ # @param name [Symbol] name of the property
40
+ # @return [Boolean] true if it is defined
41
+ def property?(name)
42
+ @properties.include?(name)
43
+ end
44
+ end
45
+
46
+ # Create a model object from hash values
47
+ #
48
+ # @param values [Hash] property values
49
+ # @return [KStor::Model::Base] a new model object
50
+ def initialize(**values)
51
+ @data = {}
52
+ values.each do |k, v|
53
+ @data[k] = v if self.class.property?(k)
54
+ end
55
+ @dirty = false
56
+ end
57
+
58
+ # Check if properties were modified since instanciation
59
+ #
60
+ # @return [Boolean] true if modified
61
+ def dirty?
62
+ @dirty
63
+ end
64
+
65
+ # Tell the object that dirty properties were persisted.
66
+ def clean
67
+ @dirty = false
68
+ end
69
+
70
+ # Represent model object as a Hash
71
+ #
72
+ # @return [Hash] a hash of model object properties
73
+ def to_h
74
+ @data.to_h { |k, v| [k.to_s, v.respond_to?(:to_h) ? v.to_h : v] }
75
+ end
76
+ end
77
+
78
+ # A group of users that can access the same set of secrets.
79
+ class Group < Base
80
+ # @!macro dsl_model_properties_rw
81
+ property :id
82
+ # @!macro dsl_model_properties_rw
83
+ property :name
84
+ # @!macro dsl_model_properties_rw
85
+ property :pubk
86
+
87
+ # Dump properties except pubk.
88
+ def to_h
89
+ h = super
90
+ h.delete('pubk')
91
+
92
+ h
93
+ end
94
+ end
95
+
96
+ # An item in a user keychain: associates a group and it's private key,
97
+ # encrypted with the user's key pair.
98
+ #
99
+ # Initially encrypted, the {#privk} property will be nil until {#unlock}ed.
100
+ class KeychainItem < Base
101
+ # @!macro dsl_model_properties_rw
102
+ property :group_id
103
+ # @!macro dsl_model_properties_rw
104
+ property :group_pubk
105
+ # @!macro dsl_model_properties_rw
106
+ property :encrypted_privk
107
+ # @!macro dsl_model_properties_rw
108
+ property :privk
109
+
110
+ # Decrypt group private key.
111
+ #
112
+ # Calling this method will set the {#privk} property.
113
+ #
114
+ # @param group_pubk [PublicKey] public key to verify ciphertext signature
115
+ # @param user_privk [PrivateKey] private key of owner of keychain item
116
+ def unlock(group_pubk, user_privk)
117
+ self.privk = Crypto.decrypt_group_privk(
118
+ group_pubk, user_privk, encrypted_privk
119
+ )
120
+ end
121
+
122
+ # Re-encrypt group private key.
123
+ #
124
+ # Calling this will overwrite the {#encrypted_privk} property.
125
+ #
126
+ # @param user_pubk [KStor::Crypto::PublicKey] public key of keychain item
127
+ # owner
128
+ def encrypt(user_pubk)
129
+ self.encrypted_privk = Crypto.encrypt_group_privk(
130
+ user_pubk, privk, privk
131
+ )
132
+ end
133
+
134
+ # Forget about decrypted group private key.
135
+ #
136
+ # This will unset {#privk} property.
137
+ def lock
138
+ self.privk = nil
139
+ end
140
+
141
+ # Check if group private key was decrypted.
142
+ #
143
+ # @return [Boolean] false if decrypted
144
+ def locked?
145
+ privk.nil?
146
+ end
147
+
148
+ # Check if group private key was decrypted.
149
+ #
150
+ # @return [Boolean] true if decrypted
151
+ def unlocked?
152
+ !locked?
153
+ end
154
+
155
+ # Dump properties except {#encrypted_privk}.
156
+ def to_h
157
+ h = super
158
+ h.delete('encrypted_privk')
159
+ h
160
+ end
161
+ end
162
+
163
+ # A person allowed to connect to the application.
164
+ class User < Base
165
+ # @!macro dsl_model_properties_rw
166
+ property :id
167
+ # @!macro dsl_model_properties_rw
168
+ property :login
169
+ # @!macro dsl_model_properties_rw
170
+ property :name
171
+ # @!macro dsl_model_properties_rw
172
+ property :status
173
+ # @!macro dsl_model_properties_rw
174
+ property :pubk
175
+ # @!macro dsl_model_properties_rw
176
+ property :kdf_params
177
+ # @!macro dsl_model_properties_rw
178
+ property :encrypted_privk
179
+ # @!macro dsl_model_properties_rw
180
+ property :privk
181
+ # @!macro dsl_model_properties_rw
182
+ property :keychain
183
+
184
+ # Derive secret key from password.
185
+ #
186
+ # If user has no keypair yet, initialize it.
187
+ #
188
+ # @param password [String] plaintext password
189
+ # @return [KStor::Crypto::SecretKey] derived secret key
190
+ def secret_key(password)
191
+ Log.debug("model: deriving secret key for user #{login}")
192
+ reset_password(password) unless initialized?
193
+ Crypto.key_derive(password, kdf_params)
194
+ end
195
+
196
+ # Decrypt user private key and keychain.
197
+ #
198
+ # This will set the {#privk} property and call {KeychainItem#unlock} on
199
+ # the keychain.
200
+ #
201
+ # @param secret_key [KStor::Crypto::SecretKey] secret key derived from
202
+ # password
203
+ # @see #secret_key
204
+ def unlock(secret_key)
205
+ return if unlocked?
206
+
207
+ Log.debug("model: unlock user #{login}")
208
+ self.privk = Crypto.decrypt_user_privk(secret_key, encrypted_privk)
209
+ keychain.each_value { |it| it.unlock(it.group_pubk, privk) }
210
+ end
211
+
212
+ # Re-encrypt user private key and keychain.
213
+ #
214
+ # This will overwrite the {#encrypted_privk} property and call
215
+ # {KeychainItem#encrypt} on the keychain.
216
+ #
217
+ # @param secret_key [KStor::Crypto::SecretKey] secret key derived from
218
+ # password
219
+ # @see #secret_key
220
+ def encrypt(secret_key)
221
+ Log.debug("model: lock user data for #{login}")
222
+ self.encrypted_privk = Crypto.encrypt_user_privk(
223
+ secret_key, privk
224
+ )
225
+ keychain.each_value { |it| it.encrypt(pubk) }
226
+ end
227
+
228
+ # Forget about the user's decrypted private key and the group private
229
+ # keys in the keychain.
230
+ #
231
+ # This will unset the {#privk} property and call {KeychainItem#lock} on
232
+ # the keychain.
233
+ def lock
234
+ return if locked?
235
+
236
+ self.privk = nil
237
+ keychain.each_value(&:lock)
238
+ end
239
+
240
+ # Check if some sensitive data was decrypted.
241
+ #
242
+ # @return [Boolean] true if private key or keychain was decrypted
243
+ def locked?
244
+ privk.nil? && keychain.all? { |_, it| it.locked? }
245
+ end
246
+
247
+ # Check if no sensitive data was decrypted.
248
+ #
249
+ # @return [Boolean] true if neither private key nor any keychain iyem was
250
+ # decrypted.
251
+ def unlocked?
252
+ !privk.nil? || keychain.any? { |_, it| it.unlocked? }
253
+ end
254
+
255
+ # Generate a new key pair and throw away all keychain
256
+ # items.
257
+ #
258
+ # @param password [String] new user password
259
+ def reset_password(password)
260
+ Log.info("model: resetting password for user #{login}")
261
+ reset_key_pair
262
+ secret_key = Crypto.key_derive(password)
263
+ self.kdf_params = secret_key.kdf_params
264
+ encrypt(secret_key)
265
+ self.keychain = {}
266
+ # FIXME: delete keychain items from database!
267
+ # they won't be useable (decryption key is lost) but will provoke
268
+ # errors.
269
+ end
270
+
271
+ # Re-encrypt private key and keychain with a new secret key derived from
272
+ # the new password.
273
+ #
274
+ # @param password [String] old password
275
+ # @param new_password [String] new password
276
+ def change_password(password, new_password)
277
+ Log.info("model: changing password for user #{login}")
278
+ old_secret_key = secret_key(password)
279
+ unlock(old_secret_key)
280
+ new_secret_key = secret_key(new_password)
281
+ encrypt(new_secret_key)
282
+ end
283
+
284
+ # Dump properties except {#encrypted_privk} and {#pubk}.
285
+ def to_h
286
+ h = super
287
+ h.delete('encrypted_privk')
288
+ h.delete('pubk')
289
+ h['keychain'] = keychain.transform_values(&:to_h) if keychain
290
+
291
+ h
292
+ end
293
+
294
+ private
295
+
296
+ def initialized?
297
+ kdf_params && pubk && encrypted_privk
298
+ end
299
+
300
+ def reset_key_pair
301
+ Log.info("model: generating new key pair for user #{login}")
302
+ self.pubk, self.privk = Crypto.generate_key_pair
303
+ self.keychain = {}
304
+ end
305
+ end
306
+
307
+ # Metadata for a secret.
308
+ #
309
+ # This is not a "real" model object: just a helper class to convert
310
+ # metadata to and from database.
311
+ class SecretMeta
312
+ # Secret is defined for this application
313
+ attr_accessor :app
314
+ # Secret is defined for this database
315
+ attr_accessor :database
316
+ # Secret is defined for this login
317
+ attr_accessor :login
318
+ # Secret is related to this server
319
+ attr_accessor :server
320
+ # Secret should be used at this URL
321
+ attr_accessor :url
322
+
323
+ def initialize(values)
324
+ @app = values['app']
325
+ @database = values['database']
326
+ @login = values['login']
327
+ @server = values['server']
328
+ @url = values['url']
329
+ end
330
+
331
+ def to_h
332
+ { 'app' => @app, 'database' => @database, 'login' => @login,
333
+ 'server' => @server, 'url' => @url }.compact
334
+ end
335
+
336
+ def serialize
337
+ Crypto::ArmoredHash.from_hash(to_h)
338
+ end
339
+
340
+ def merge(other)
341
+ values = to_h.merge(other.to_h)
342
+ values.reject! { |_, v| v.empty? }
343
+ self.class.new(values)
344
+ end
345
+
346
+ def self.load(armored_hash)
347
+ new(armored_hash.to_hash)
348
+ end
349
+
350
+ def match?(meta)
351
+ self_h = to_h
352
+ other_h = meta.to_h
353
+ other_h.each do |k, wildcard|
354
+ val = self_h[k]
355
+ next if val.nil?
356
+ next if wildcard.nil?
357
+
358
+ key_matched = File.fnmatch?(
359
+ wildcard, val, File::FNM_CASEFOLD | File::FNM_DOTMATCH
360
+ )
361
+ return false unless key_matched
362
+ end
363
+ true
364
+ end
365
+ end
366
+
367
+ # A secret, with metadata and a value that are kept encrypted on disk.
368
+ class Secret < Base
369
+ # @!macro dsl_model_properties_rw
370
+ property :id
371
+ # @!macro dsl_model_properties_rw
372
+ property :value_author_id
373
+ # @!macro dsl_model_properties_rw
374
+ property :meta_author_id
375
+ # @!macro dsl_model_properties_rw
376
+ property :group_id
377
+ # @!macro dsl_model_properties_rw
378
+ property :ciphertext
379
+ # @!macro dsl_model_properties_rw
380
+ property :plaintext
381
+ # @!macro dsl_model_properties_rw
382
+ property :encrypted_metadata
383
+ # @!macro dsl_model_properties_rw
384
+ property :metadata, read_only: true
385
+
386
+ def metadata=(armored_hash)
387
+ @data[:metadata] = armored_hash ? SecretMeta.load(armored_hash) : nil
388
+ end
389
+
390
+ # Decrypt secret value.
391
+ #
392
+ # This will set the {#plaintext} property.
393
+ #
394
+ # @param author_pubk [KStor::Crypto::PublicKey] key to verify signature
395
+ # by the user that last set the value
396
+ # @param group_privk [KStor::Crypto::PrivateKey] private key of a group
397
+ # that can decrypt this secret value.
398
+ def unlock(author_pubk, group_privk)
399
+ self.plaintext = Crypto.decrypt_secret_value(
400
+ author_pubk, group_privk, ciphertext
401
+ )
402
+ end
403
+
404
+ # Decrypt secret metadata.
405
+ #
406
+ # This will set the {#metadata} property.
407
+ #
408
+ # @param author_pubk [KStor::Crypto::PublicKey] key to verify signature
409
+ # by the user that last set metadata
410
+ # @param group_privk [KStor::Crypto::PrivateKey] private key of a group
411
+ # that can decrypt this secret metadata.
412
+ def unlock_metadata(author_pubk, group_privk)
413
+ self.metadata = Crypto.decrypt_secret_metadata(
414
+ author_pubk, group_privk, encrypted_metadata
415
+ )
416
+ end
417
+
418
+ # Forget about the decrypted value and metadata.
419
+ def lock
420
+ self.metadata = nil
421
+ self.plaintext = nil
422
+ end
423
+
424
+ # Dump properties except {#ciphertext}, {#encrypted_metadata},
425
+ # {#value_author_id} and {#meta_author_id}.
426
+ def to_h
427
+ h = super
428
+ h.delete('ciphertext')
429
+ h.delete('encrypted_metadata')
430
+ h.delete('value_author_id')
431
+ h.delete('meta_author_id')
432
+
433
+ h
434
+ end
435
+ end
436
+ end
437
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kstor/socket_server'
4
+ require 'kstor/controller'
5
+ require 'kstor/message'
6
+ require 'kstor/log'
7
+ require 'kstor/error'
8
+
9
+ module KStor
10
+ # Error: invalid request.
11
+ class InvalidMessage < Error
12
+ error_code 'MSG/INVALID'
13
+ error_message 'JSON error: %s'
14
+ end
15
+
16
+ # Listen for clients and respond to their requests.
17
+ class Server < SocketServer
18
+ def initialize(controller:, **args)
19
+ @controller = controller
20
+ super(**args)
21
+ end
22
+
23
+ def work(client)
24
+ client_data, = client.recvfrom(4096)
25
+ Log.debug("server: read #{client_data.bytesize} bytes from client")
26
+ server_data = handle_client_data(client_data)
27
+ Log.debug("server: sending #{server_data.bytesize} bytes of response " \
28
+ 'to client')
29
+ client.send(server_data, 0)
30
+ rescue Errno::EPIPE
31
+ Log.info('server: client unexpectedly broke connection')
32
+ ensure
33
+ client.close
34
+ end
35
+
36
+ private
37
+
38
+ def handle_client_data(data)
39
+ req = Message.parse_request(data)
40
+ resp = @controller.handle_request(req)
41
+ Log.debug("server: response = #{resp.inspect}")
42
+ resp.serialize
43
+ rescue JSON::ParserError => e
44
+ err = Error.for_code('MSG/INVALID', e.message)
45
+ Log.info("server: #{err}")
46
+ err.response.serialize
47
+ rescue RequestMissesAuthData
48
+ raise Error.for_code('MSG/INVALID', 'Missing authentication data')
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mutex_m'
4
+
5
+ module KStor
6
+ # A user session in memory.
7
+ class Session
8
+ attr_reader :id
9
+ attr_reader :user
10
+ attr_reader :secret_key
11
+ attr_reader :created_at
12
+ attr_reader :updated_at
13
+
14
+ def initialize(sid, user, secret_key)
15
+ @id = sid
16
+ @user = user
17
+ @secret_key = secret_key
18
+ @created_at = Time.now
19
+ @updated_at = Time.now
20
+ end
21
+
22
+ def update
23
+ @updated_at = Time.now
24
+ self
25
+ end
26
+
27
+ def self.create(user, secret_key)
28
+ sid = SecureRandom.urlsafe_base64(16)
29
+ new(sid, user, secret_key)
30
+ end
31
+ end
32
+
33
+ # Collection of user sessions (in memory)
34
+ #
35
+ # FIXME make it thread safe!
36
+ class SessionStore
37
+ def initialize(idle_timeout, life_timeout)
38
+ @idle_timeout = idle_timeout
39
+ @life_timeout = life_timeout
40
+ @sessions = {}
41
+ @sessions.extend(Mutex_m)
42
+ end
43
+
44
+ def <<(session)
45
+ @sessions.synchronize do
46
+ @sessions[session.id] = session
47
+ end
48
+ end
49
+
50
+ def [](sid)
51
+ @sessions.synchronize do
52
+ s = @sessions[sid.to_s]
53
+ return nil if s.nil?
54
+
55
+ if invalid?(s)
56
+ @sessions.delete(s.id)
57
+ return nil
58
+ end
59
+
60
+ s.update
61
+ end
62
+ end
63
+
64
+ def purge
65
+ now = Time.now
66
+ @sessions.synchronize do
67
+ @sessions.delete_if { |_, s| invalid?(s, now) }
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def invalid?(session, now = Time.now)
74
+ return true if session.created_at + @life_timeout < now
75
+ return true if session.updated_at + @idle_timeout < now
76
+
77
+ false
78
+ end
79
+ end
80
+ end