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.
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