kstor 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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