kstor 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: af39f01820f488e20a4b602f5151e87e07176268cf543aecc90f19bcb6166ec2
4
+ data.tar.gz: 84352bc5dddd13f4e33c1efac2aae1d360c4a53fb1be24b2fd32d8e61111b770
5
+ SHA512:
6
+ metadata.gz: 646da6b5709b454e971d4a129890c6718ad1b177c614f113fd77556df12bfb4feb7c6d44d8beb4e17af4bb9b44d68c2321acf8c54b42fc5f6bc728d6b4e0a40d
7
+ data.tar.gz: 71264b16719b3d516ff7c5bdea5fc362bedee48c23ed804401877010a65f5cb7cc75336266c70699bbebc63737ef1f775eb4986193ba7722cb289e1a18c5c709
data/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # KStor
2
+
3
+ KStor stores and shares secrets among teams of users.
4
+
5
+ It doesn't work yet.
6
+
7
+ This is the server part, supporting a command-line client and a web user interface.
data/bin/kstor ADDED
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'kstor'
5
+
6
+ require 'json'
7
+ require 'socket'
8
+ require 'etc'
9
+ require 'fileutils'
10
+ require 'slop'
11
+
12
+ module KStor
13
+ # Manage KStor client configuration and state on disk.
14
+ module ClientState
15
+ class << self
16
+ DEFAULT_CONFIG = {
17
+ 'socket' => '/home/jpi/code/kstor/testworkdir/kstor.socket'
18
+ }.freeze
19
+
20
+ def load_config(progr)
21
+ DEFAULT_CONFIG.merge(load_config_file(progr))
22
+ end
23
+
24
+ def session_id_file(progr)
25
+ dir = File.join(xdg_runtime, progr)
26
+ FileUtils.mkdir_p(dir)
27
+ file = File.join(dir, 'session-id')
28
+ FileUtils.touch(file)
29
+ FileUtils.chmod(0o600, file)
30
+ file
31
+ end
32
+
33
+ def load_session_id(progr)
34
+ sid = nil
35
+ File.open(session_id_file(progr)) { |f| sid = f.read.chomp }
36
+ sid = nil if sid.empty?
37
+
38
+ sid
39
+ end
40
+
41
+ private
42
+
43
+ def xdg_config
44
+ ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config'))
45
+ end
46
+
47
+ def xdg_state
48
+ ENV.fetch('XDG_STATE_HOME', File.join(Dir.home, '.local', 'state'))
49
+ end
50
+
51
+ def xdg_runtime
52
+ dir = ENV.fetch('XDG_RUNTIME_DIR', nil)
53
+ return dir if dir
54
+
55
+ warn('XDG_RUNTIME_DIR is undefined, using XDG_STATE_HOME instead')
56
+ xdg_state
57
+ end
58
+
59
+ def config_file(progr)
60
+ dir = File.join(xdg_config, progr)
61
+ FileUtils.mkdir_p(dir)
62
+ File.join(dir, 'session-id')
63
+ end
64
+
65
+ def load_config_file(progr)
66
+ data = File.read(config_file(progr))
67
+ YAML.parse(data)
68
+ rescue Errno::ENOENT
69
+ {}
70
+ end
71
+ end
72
+ end
73
+
74
+ # Sub-commands that can be invoked from the command-line.
75
+ module ClientSubCommands
76
+ def group_create
77
+ request('group-create') do |o|
78
+ o.string('-n', '--name', 'Group name')
79
+ end
80
+ end
81
+
82
+ def secret_create
83
+ req = request('secret-create') do |o|
84
+ o.string('-p', '--plaintext', 'Value of the secret')
85
+ o.array('-g', '--group_ids', 'Groups that can unlock the secret')
86
+ o.string('-a', '--app', 'application of this secret')
87
+ o.string('-d', '--database', 'database of this secret')
88
+ o.string('-l', '--login', 'login of this secret')
89
+ o.string('-S', '--server', 'server of this secret')
90
+ o.string('-u', '--url', 'url for this secret')
91
+ end
92
+ reorganize_secret_meta_args(req)
93
+ end
94
+
95
+ def secret_search
96
+ request('secret-search') do |o|
97
+ o.string('-a', '--app', 'secrets for this application')
98
+ o.string('-d', '--database', 'secrets for this database')
99
+ o.string('-l', '--login', 'secrets for this login')
100
+ o.string('-s', '--server', 'secrets for this server')
101
+ o.string('-u', '--url', 'secrets for this url')
102
+ end
103
+ end
104
+
105
+ def secret_unlock
106
+ request('secret-unlock') do |o|
107
+ o.string('-s', '--secret-id', 'secret ID to unlock')
108
+ end
109
+ end
110
+
111
+ def secret_update_meta
112
+ req = request('secret-update-meta') do |o|
113
+ o.string('-s', '--secret-id', 'secret ID to modify')
114
+ o.string('-a', '--app', 'new application of this secret')
115
+ o.string('-d', '--database', 'new database of this secret')
116
+ o.string('-l', '--login', 'new login of this secret')
117
+ o.string('-S', '--server', 'new server of this secret')
118
+ o.string('-u', '--url', 'new url for this secret')
119
+ end
120
+ reorganize_secret_meta_args(req)
121
+ end
122
+
123
+ def secret_update_value
124
+ request('secret-update-value') do |o|
125
+ o.string('-s', '--secret-id', 'secret ID to modify')
126
+ o.string('-p', '--plaintext', 'new plaintext value')
127
+ end
128
+ end
129
+
130
+ def secret_delete
131
+ request('secret-delete') do |o|
132
+ o.string('-s', '--secret-id', 'secret ID to delete')
133
+ end
134
+ end
135
+ end
136
+
137
+ # KStor command-line client.
138
+ class Client
139
+ include ClientSubCommands
140
+
141
+ def initialize
142
+ @progr = File.basename($PROGRAM_NAME)
143
+ @config = ClientState.load_config(@progr)
144
+ @user = user_from_argv
145
+ end
146
+
147
+ def run
148
+ request_type = ARGV.shift
149
+ resp = send_request(request_type)
150
+ handle_error!(resp) if resp.error?
151
+
152
+ puts format_response(resp)
153
+ return unless resp.respond_to?(:session_id)
154
+
155
+ File.open(ClientState.session_id_file(@progr), 'w') do |f|
156
+ f.puts(resp.session_id)
157
+ end
158
+ end
159
+
160
+ private
161
+
162
+ def format_response(resp)
163
+ data = resp.args.dup
164
+ data.delete('session_id')
165
+ JSON.pretty_generate(data)
166
+ end
167
+
168
+ def reorganize_secret_meta_args(req)
169
+ req.args['meta'] = {
170
+ 'app' => req.args.delete('app'),
171
+ 'database' => req.args.delete('database'),
172
+ 'login' => req.args.delete('login'),
173
+ 'server' => req.args.delete('server'),
174
+ 'url' => req.args.delete('url')
175
+ }
176
+ req.args['meta'].compact!
177
+ req
178
+ end
179
+
180
+ def handle_error!(resp)
181
+ if resp.args['code'] == 'AUTH/BADSESSION'
182
+ FileUtils.rm(ClientState.session_id_file(@progr))
183
+ warn('session expired')
184
+ else
185
+ warn(resp.args['message'])
186
+ end
187
+ exit 1
188
+ end
189
+
190
+ def send_request(request_type)
191
+ meth = method_name(request_type)
192
+
193
+ req = __send__(meth)
194
+ socket = UNIXSocket.new(@config['socket'])
195
+ socket.send(req.serialize, 0)
196
+
197
+ data, = socket.recvfrom(4096)
198
+ Response.parse(data)
199
+ rescue UnparsableResponse
200
+ warn('server speaks funny; look at logs!')
201
+ exit(1)
202
+ end
203
+
204
+ REQUEST_METHODS = {
205
+ 'group-create' => :group_create,
206
+ 'secret-create' => :secret_create,
207
+ 'secret-search' => :secret_search,
208
+ 'secret-unlock' => :secret_unlock,
209
+ 'secret-update-meta' => :secret_update_meta,
210
+ 'secret-update-value' => :secret_update_value,
211
+ 'secret-delete' => :secret_delete
212
+ }.freeze
213
+
214
+ def method_name(request_type)
215
+ if request_type.nil?
216
+ base_usage
217
+ exit 0
218
+ end
219
+ meth = REQUEST_METHODS[request_type]
220
+ if meth.nil?
221
+ warn("Unknown request type #{request_type.inspect}")
222
+ base_usage
223
+ exit 1
224
+ end
225
+ meth
226
+ end
227
+
228
+ def user_from_argv
229
+ md = /^(-u|--user)$/.match(ARGV.first)
230
+ md ? ARGV.shift(2).last : Etc.getlogin
231
+ end
232
+
233
+ def base_usage
234
+ puts "usage: #{@progr} [--user USER] <req-type> [--help | REQ-ARGS]"
235
+ request_types = %w[group-create secret-create secret-search secret-unlock]
236
+ puts "request types: #{request_types.join(', ')}"
237
+ end
238
+
239
+ def ask_password
240
+ require 'io/console'
241
+
242
+ $stdout.print 'Password: '
243
+ password = $stdin.noecho(&:gets)
244
+ $stdout.puts('')
245
+
246
+ password.chomp
247
+ end
248
+
249
+ def auth
250
+ session_id = ClientState.load_session_id(@progr)
251
+
252
+ if session_id
253
+ { 'session_id' => session_id }
254
+ else
255
+ { 'login' => @user, 'password' => ask_password }
256
+ end
257
+ end
258
+
259
+ def request(type, &block)
260
+ hreq = {}
261
+ hreq['type'] = type
262
+ hreq['args'] = parse_opts(type) do |o|
263
+ block.call(o)
264
+ end
265
+ KStor::Message.parse_request(hreq.merge(auth).to_json)
266
+ end
267
+
268
+ def parse_opts(request_type)
269
+ opts = Slop.parse do |o|
270
+ o.banner = <<-EOUSAGE
271
+ usage: #{@progr} [--user USER] #{request_type} [--help | REQ-ARGS]
272
+ EOUSAGE
273
+ o.on('-h', '--help', 'show this text') do
274
+ puts o
275
+ exit 0
276
+ end
277
+ o.separator('')
278
+ yield o
279
+ end
280
+ opts.to_hash.compact
281
+ end
282
+ end
283
+ end
284
+
285
+ cli = KStor::Client.new
286
+ cli.run
data/bin/kstor-srv ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'kstor'
5
+
6
+ KStor::Log.reporting_level = Journald::LOG_DEBUG
7
+
8
+ config = KStor::Config.load(ARGV.shift)
9
+
10
+ store = KStor::Store.new(config.database)
11
+ session_store = KStor::SessionStore.new(
12
+ config.session_idle_timeout,
13
+ config.session_life_timeout
14
+ )
15
+ request_handler = KStor::Controller::RequestHandler.new(store, session_store)
16
+
17
+ server = KStor::Server.new(
18
+ controller: request_handler,
19
+ socket_path: config.socket,
20
+ nworkers: config.nworkers
21
+ )
22
+
23
+ me = File.basename($PROGRAM_NAME)
24
+ Process.setproctitle(me)
25
+
26
+ server.start
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module KStor
6
+ # Configuration items stored as YAML.
7
+ class Config
8
+ # Default values for configuration items.
9
+ #
10
+ # They are used when loading configuration from a file, and for defining
11
+ # accessor methods.
12
+ #
13
+ # @!attribute [r] database
14
+ # @return [String] path to SQLite database file
15
+ #
16
+ # @!attribute [r] socket
17
+ # @return [String] path to KStor server listening socket
18
+ #
19
+ # @!attribute [r] nworkers
20
+ # @return [Integer] number of worker threads
21
+ #
22
+ # @!attribute [r] session_idle_timeout
23
+ # @return [Integer] seconds of inactivity before a session is closed
24
+ #
25
+ # @!attribute [r] session_life_timeout
26
+ # @return [Integer] seconds before a session is closed
27
+ DEFAULTS = {
28
+ 'database' => 'data/db.sqlite',
29
+ 'socket' => 'run/kstor-server.socket',
30
+ 'nworkers' => 5,
31
+ 'session_idle_timeout' => 15 * 60,
32
+ 'session_life_timeout' => 4 * 60 * 60
33
+ }.freeze
34
+
35
+ class << self
36
+ # Load configuration from a file.
37
+ #
38
+ # For each missing configuration item in file, use the default from
39
+ # DEFAULTS.
40
+ #
41
+ # @param path [String] path to config file
42
+ # @return [KStor::Config] configuration object
43
+ def load(path)
44
+ hash = if path && File.file?(path)
45
+ YAML.load_file(path)
46
+ else
47
+ {}
48
+ end
49
+ new(DEFAULTS.merge(hash))
50
+ end
51
+ end
52
+
53
+ # Create configuration from hash data.
54
+ #
55
+ # @param hash [Hash] configuration items
56
+ def initialize(hash)
57
+ @data = hash
58
+ end
59
+
60
+ DEFAULTS.each_key do |k|
61
+ define_method(k.to_sym) do
62
+ @data[k]
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KStor
4
+ module Controller
5
+ # Handle user authentication and sessions.
6
+ class Authentication
7
+ def initialize(store, session_store)
8
+ @store = store
9
+ @sessions = session_store
10
+ end
11
+
12
+ def authenticate(req)
13
+ if @store.users?
14
+ unlock_user(req)
15
+ else
16
+ create_first_user(req)
17
+ end
18
+ end
19
+
20
+ # return true if login is allowed to access the database.
21
+ def allowed?(user)
22
+ user.status == 'new' || user.status == 'active'
23
+ end
24
+
25
+ def unlock_user(req)
26
+ if req.respond_to?(:session_id)
27
+ session_id = req.session_id
28
+ user, secret_key = load_session(session_id)
29
+ else
30
+ user = load_user(req.login)
31
+ secret_key = user.secret_key(req.password)
32
+ session = Session.create(user, secret_key)
33
+ @sessions << session
34
+ session_id = session.id
35
+ end
36
+ user.unlock(secret_key)
37
+
38
+ [user, session_id]
39
+ end
40
+
41
+ def load_session(sid)
42
+ Log.debug("loading session #{sid}")
43
+ session = @sessions[sid]
44
+ raise Error.for_code('AUTH/BADSESSION', sid) unless session
45
+
46
+ [session.user, session.secret_key]
47
+ end
48
+
49
+ def load_user(login)
50
+ Log.debug("authenticating user #{login.inspect}")
51
+ user = @store.user_by_login(login)
52
+ Log.debug("loaded user ##{user.id} #{user.login}")
53
+ unless user && allowed?(user)
54
+ raise Error.for_code('AUTH/FORBIDDEN', login)
55
+ end
56
+
57
+ user
58
+ end
59
+
60
+ def create_first_user(req)
61
+ raise Error.for_code('AUTH/MISSING') unless req.respond_to?(:login)
62
+
63
+ Log.info("no user in database, creating #{req.login.inspect}")
64
+ user = Model::User.new(
65
+ login: req.login, name: req.login, status: 'new', keychain: {}
66
+ )
67
+ secret_key = user.secret_key(req.password)
68
+ user.unlock(secret_key)
69
+ @store.user_create(user)
70
+ Log.info("user #{user.login} created")
71
+
72
+ session = Session.create(user, secret_key)
73
+ @sessions << session
74
+
75
+ [user, session.id]
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kstor/store'
4
+ require 'kstor/model'
5
+ require 'kstor/crypto'
6
+
7
+ module KStor
8
+ class SecretNotFound < Error
9
+ error_code 'SECRET/NOTFOUND'
10
+ error_message 'Secret #%s not found.'
11
+ end
12
+
13
+ module Controller
14
+ # Handle secret-related requests.
15
+ class Secret
16
+ def initialize(store)
17
+ @store = store
18
+ end
19
+
20
+ def handle_request(user, req)
21
+ case req.type
22
+ when 'secret-create' then handle_create(user, req)
23
+ when 'secret-search' then handle_search(user, req)
24
+ when 'secret-unlock' then handle_unlock(user, req)
25
+ when 'secret-update-meta' then handle_update_meta(user, req)
26
+ when 'secret-update-value' then handle_update_value(user, req)
27
+ when 'secret-delete' then handle_delete(user, req)
28
+ else
29
+ raise Error.for_code('REQ/UNKNOWN', req.type)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def handle_create(user, req)
36
+ meta = Model::SecretMeta.new(**req.args['meta'])
37
+ secret_groups = req.args['group_ids'].map { |gid| groups[gid.to_i] }
38
+ secret_id = create(
39
+ user, req.args['plaintext'], secret_groups, meta
40
+ )
41
+ Response.new('secret.created', 'secret_id' => secret_id)
42
+ end
43
+
44
+ def handle_search(user, req)
45
+ secrets = search(user, Model::SecretMeta.new(**req.args))
46
+ args = secrets.map do |s|
47
+ h = s.to_h
48
+ h.delete('group_id')
49
+ h
50
+ end
51
+ Response.new(
52
+ 'secret.list',
53
+ 'secrets' => args
54
+ )
55
+ end
56
+
57
+ def handle_unlock(user, req)
58
+ secret_id = req.args['secret_id']
59
+ secret = unlock(user, secret_id)
60
+ args = unlock_format(secret)
61
+
62
+ Response.new('secret.value', **args)
63
+ end
64
+
65
+ def handle_update_meta(user, req)
66
+ meta = Model::SecretMeta.new(req.args['meta'])
67
+ Log.debug("secret#handle_update_meta: meta=#{meta.to_h.inspect}")
68
+ update_meta(user, req.args['secret_id'], meta)
69
+ Response.new('secret.updated', 'secret_id' => req.args['secret_id'])
70
+ end
71
+
72
+ def handle_update_value(user, req)
73
+ update_value(user, req.args['secret_id'], req.args['plaintext'])
74
+ Response.new('secret.updated', 'secret_id' => req.args['secret_id'])
75
+ end
76
+
77
+ def handle_delete(user, req)
78
+ delete(user, req.args['secret_id'])
79
+ Response.new('secret.deleted', 'secret_id' => req.args['secret_id'])
80
+ end
81
+
82
+ def users
83
+ @users ||= @store.users
84
+ end
85
+
86
+ def groups
87
+ @groups ||= @store.groups
88
+ end
89
+
90
+ # in: metadata wildcards
91
+ # needs: private key of one common group between user and secrets
92
+ # out: array of:
93
+ # - secret id
94
+ # - secret metadata
95
+ # - secret metadata and value authors
96
+ def search(user, meta)
97
+ return [] if user.keychain.empty?
98
+
99
+ @store.secrets_for_user(user.id).select do |secret|
100
+ unlock_metadata(user, secret)
101
+ secret.metadata.match?(meta)
102
+ end
103
+ end
104
+
105
+ # in: secret_id
106
+ # needs: private key of one common group between user and secret
107
+ # out: plaintext
108
+ def unlock(user, secret_id)
109
+ secret = @store.secret_fetch(secret_id, user.id)
110
+ group_privk = user.keychain[secret.group_id].privk
111
+
112
+ value_author = users[secret.value_author_id]
113
+ secret.unlock(value_author.pubk, group_privk)
114
+
115
+ meta_author = users[secret.meta_author_id]
116
+ secret.unlock_metadata(meta_author.pubk, group_privk)
117
+
118
+ secret
119
+ end
120
+
121
+ # in: plaintext, group ids, metadata
122
+ # needs: encrypted metadata and ciphertext for each group
123
+ # out: secret id
124
+ def create(user, plaintext, groups, meta)
125
+ encrypted_data = {}
126
+ Log.debug("secret#create: group_ids = #{groups.inspect}")
127
+ groups.each do |g|
128
+ encrypted_data[g.id] = [
129
+ Crypto.encrypt_secret_value(g.pubk, user.privk, plaintext),
130
+ Crypto.encrypt_secret_metadata(g.pubk, user.privk, meta.to_h)
131
+ ]
132
+ end
133
+ @store.secret_create(user.id, encrypted_data)
134
+ end
135
+
136
+ # in: secret id, metadata
137
+ # needs: every group public key for this secret, user private key
138
+ # out: nil
139
+ def update_meta(user, secret_id, partial_meta)
140
+ secret = @store.secret_fetch(secret_id, user.id)
141
+ unlock_metadata(user, secret)
142
+ meta = secret.metadata.merge(partial_meta)
143
+ group_ids = @store.groups_for_secret(secret.id)
144
+ group_encrypted_metadata = group_ids.to_h do |group_id|
145
+ group_pubk = groups[group_id].pubk
146
+ author_privk = user.privk
147
+ encrypted_meta = Crypto.encrypt_secret_metadata(
148
+ group_pubk, author_privk, meta.to_h
149
+ )
150
+ [group_id, encrypted_meta]
151
+ end
152
+ @store.secret_setmeta(secret.id, user.id, group_encrypted_metadata)
153
+ end
154
+
155
+ # in: secret id, plaintext
156
+ # needs: every group public key for this secret, user private key
157
+ # out: nil
158
+ def update_value(user, secret_id, plaintext)
159
+ secret = @store.secret_fetch(secret_id, user.id)
160
+ group_ids = @store.groups_for_secret(secret.id)
161
+ group_ciphertexts = group_ids.to_h do |group_id|
162
+ group_pubk = groups[group_id].pubk
163
+ author_privk = user.privk
164
+ encrypted_value = Crypto.encrypt_secret_value(
165
+ group_pubk, author_privk, plaintext
166
+ )
167
+ [group_id, encrypted_value]
168
+ end
169
+ @store.secret_setvalue(secret.id, user.id, group_ciphertexts)
170
+ end
171
+
172
+ # in: secret id
173
+ # needs: nil
174
+ # out: nil
175
+ def delete(user, secret_id)
176
+ # Check if user can see this secret:
177
+ secret = @store.secret_fetch(secret_id, user.id)
178
+ raise Error.for_code('SECRET/NOTFOUND', secret_id) if secret.nil?
179
+
180
+ @store.secret_delete(secret_id)
181
+ end
182
+
183
+ def unlock_format(secret)
184
+ args = secret.to_h
185
+ args['value_author'] = users[secret.value_author_id].to_h
186
+ args['metadata_author'] = users[secret.meta_author_id].to_h
187
+
188
+ group_ids = @store.groups_for_secret(secret.id)
189
+ args['groups'] = groups.values_at(*group_ids).map(&:to_h)
190
+
191
+ args
192
+ end
193
+
194
+ def unlock_metadata(user, secret)
195
+ group_privk = user.keychain[secret.group_id].privk
196
+ author = users[secret.meta_author_id]
197
+ secret.unlock_metadata(author.pubk, group_privk)
198
+ end
199
+ end
200
+ end
201
+ end