e3db 2.0.0 → 2.1.1
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 +4 -4
- data/.gitignore +1 -0
- data/LICENSE.md +255 -0
- data/README.md +44 -23
- data/examples/registration.rb +79 -0
- data/examples/simple.rb +2 -25
- data/lib/e3db.rb +1 -1
- data/lib/e3db/client.rb +440 -57
- data/lib/e3db/config.rb +2 -0
- data/lib/e3db/crypto.rb +32 -108
- data/lib/e3db/version.rb +1 -1
- data/travis-install-configfile.sh +18 -0
- metadata +5 -4
- data/LICENSE.txt +0 -21
data/examples/simple.rb
CHANGED
@@ -58,10 +58,6 @@ end
|
|
58
58
|
isaac_client_id = 'db1744b9-3fb6-4458-a291-0bc677dba08b'
|
59
59
|
client.share('test-contact', isaac_client_id)
|
60
60
|
|
61
|
-
# Share all of the records of type 'test-contact' with Isaac's email address.
|
62
|
-
# This only works if the client has opted into discovery of their client_id.
|
63
|
-
client.share('test-contact', 'ijones+feedback@tozny.com')
|
64
|
-
|
65
61
|
# ---------------------------------------------------------
|
66
62
|
# More complex queries
|
67
63
|
# ---------------------------------------------------------
|
@@ -106,38 +102,19 @@ end
|
|
106
102
|
# ---------------------------------------------------------
|
107
103
|
# Learning about other clients
|
108
104
|
# ---------------------------------------------------------
|
109
|
-
isaac_client_info = client.client_info(
|
105
|
+
isaac_client_info = client.client_info(isaac_client_id)
|
110
106
|
puts isaac_client_info.inspect
|
111
107
|
|
112
108
|
# Fetch the public key:
|
113
109
|
isaac_pub_key = client.client_key(isaac_client_id)
|
114
110
|
puts isaac_pub_key.inspect
|
115
111
|
|
116
|
-
# ---------------------------------------------------------
|
117
|
-
# More reading and inspection of records
|
118
|
-
# ---------------------------------------------------------
|
119
|
-
|
120
|
-
# read_raw gets a record without decrypting its data
|
121
|
-
rawRecord = client.read_raw(record_id)
|
122
|
-
newRecord = client.read(record_id)
|
123
|
-
|
124
|
-
# So let's compare them:
|
125
|
-
|
126
|
-
puts (rawRecord.meta == newRecord.meta).to_s # true
|
127
|
-
puts (rawRecord.data == newRecord.data).to_s # false
|
128
|
-
|
129
|
-
puts newRecord.data[:name] + ' encrypts to ' + rawRecord.data[:name]
|
130
|
-
|
131
|
-
# Records contain a few other fields that are fun to look at, and this gives
|
132
|
-
# you a good sense for what's encrypted and what's not:
|
133
|
-
puts rawRecord.inspect
|
134
|
-
|
135
112
|
# ---------------------------------------------------------
|
136
113
|
# Clean up - Comment these out if you want to experiment
|
137
114
|
# ---------------------------------------------------------
|
138
115
|
|
139
116
|
# Revoke the sharing created by the client.share
|
140
|
-
client.revoke('test-contact',
|
117
|
+
client.revoke('test-contact', isaac_client_id)
|
141
118
|
|
142
119
|
# Delete the record we created above
|
143
120
|
client.delete(record_id)
|
data/lib/e3db.rb
CHANGED
data/lib/e3db/client.rb
CHANGED
@@ -40,7 +40,12 @@ module E3DB
|
|
40
40
|
# the affected record and retry the update operation.
|
41
41
|
class ConflictError < StandardError
|
42
42
|
def initialize(record)
|
43
|
-
|
43
|
+
if record.is_a? E3DB::Record
|
44
|
+
super('Conflict updating record: ' + record.meta.record_id)
|
45
|
+
else
|
46
|
+
super('Conflict updating record: ' + record)
|
47
|
+
end
|
48
|
+
|
44
49
|
@record = record
|
45
50
|
end
|
46
51
|
|
@@ -66,12 +71,34 @@ module E3DB
|
|
66
71
|
# @return [String] the client's unique ID string
|
67
72
|
# @!attribute public_key
|
68
73
|
# @return [PublicKey] the client's public key information
|
74
|
+
# @!attribute validated
|
75
|
+
# @return [Bool]
|
69
76
|
class ClientInfo < Dry::Struct
|
70
77
|
attribute :client_id, Types::Strict::String
|
71
78
|
attribute :public_key, PublicKey
|
72
79
|
attribute :validated, Types::Strict::Bool
|
73
80
|
end
|
74
81
|
|
82
|
+
# Information about a newly-created E3DB client
|
83
|
+
#
|
84
|
+
# @!attribute client_id
|
85
|
+
# @return [String] the client's unique ID string
|
86
|
+
# @!attribute api_key_id
|
87
|
+
# @return [String]
|
88
|
+
# @!attribute api_secret
|
89
|
+
# @return [String]
|
90
|
+
# @!attribute public_key
|
91
|
+
# @return [PublicKey] the client's public key information
|
92
|
+
# @!attribute name
|
93
|
+
# @return [String] the client's name.
|
94
|
+
class ClientDetails < Dry::Struct
|
95
|
+
attribute :client_id, Types::Strict::String
|
96
|
+
attribute :api_key_id, Types::Strict::String
|
97
|
+
attribute :api_secret, Types::Strict::String
|
98
|
+
attribute :public_key, PublicKey
|
99
|
+
attribute :name, Types::Strict::String
|
100
|
+
end
|
101
|
+
|
75
102
|
# Meta-information about an E3DB record, such as who wrote it,
|
76
103
|
# when it was written, and the type of data stored.
|
77
104
|
#
|
@@ -166,23 +193,99 @@ module E3DB
|
|
166
193
|
attribute :record_type, Types::Strict::String
|
167
194
|
end
|
168
195
|
|
196
|
+
# Represents an "encrypted access key" that can be used to encrypt
|
197
|
+
# and decrypt records.
|
198
|
+
#
|
199
|
+
# Should only be obtained by calling {Client#create_writer_key} or
|
200
|
+
# {Client#get_reader_key}.
|
201
|
+
#
|
202
|
+
# To serialize to JSON (for storage), use `JSON.dump(eak.to_hash)`.
|
203
|
+
# To load from JSON, use `E3DB::EAK.new(JSON.parse(eak, symbolize_names: true))`.
|
204
|
+
class EAK < Dry::Struct
|
205
|
+
attribute :eak, Types::Strict::String
|
206
|
+
attribute :authorizer_public_key, PublicKey
|
207
|
+
attribute :authorizer_id, Types::Strict::String
|
208
|
+
end
|
209
|
+
|
169
210
|
# A connection to the E3DB service used to perform database operations.
|
170
211
|
#
|
171
212
|
# @!attribute [r] config
|
172
213
|
# @return [Config] the client configuration object
|
173
214
|
class Client
|
215
|
+
include Crypto
|
216
|
+
class << self
|
217
|
+
include Crypto
|
218
|
+
end
|
219
|
+
|
174
220
|
attr_reader :config
|
175
221
|
|
222
|
+
# Register a new client with a specific account given that account's registration token
|
223
|
+
#
|
224
|
+
# @param registration_token [String] Token for a specific InnoVault account
|
225
|
+
# @param client_name [String] Unique name for the client being registered
|
226
|
+
# @param public_key [String] Base64URL-encoded public key component of a Curve25519 keypair
|
227
|
+
# @param private_key [String] Optional Base64URL-encoded private key component of a Curve25519 keypair
|
228
|
+
# @param backup [Boolean] Optional flag to automatically back up the newly-created credentials to the account service
|
229
|
+
# @param api_url [String] Optional URL of the API against which to register
|
230
|
+
# @return [ClientDetails] Credentials and details about the newly-created client
|
231
|
+
def self.register(registration_token, client_name, public_key, private_key=nil, backup=false, api_url=E3DB::DEFAULT_API_URL)
|
232
|
+
url = "#{api_url.chomp('/')}/v1/account/e3db/clients/register"
|
233
|
+
payload = JSON.generate({:token => registration_token, :client => {:name => client_name, :public_key => {:curve25519 => public_key}}})
|
234
|
+
|
235
|
+
conn = Faraday.new(api_url) do |faraday|
|
236
|
+
faraday.request :json
|
237
|
+
faraday.response :raise_error
|
238
|
+
faraday.adapter :net_http_persistent
|
239
|
+
end
|
240
|
+
|
241
|
+
resp = conn.post(url, payload)
|
242
|
+
client_info = ClientDetails.new(JSON.parse(resp.body, symbolize_names: true))
|
243
|
+
backup_client_id = resp.headers['x-backup-client']
|
244
|
+
|
245
|
+
if backup
|
246
|
+
if private_key.nil?
|
247
|
+
raise 'Cannot back up client credentials without a private key!'
|
248
|
+
end
|
249
|
+
|
250
|
+
# Instantiate a client
|
251
|
+
config = E3DB::Config.new(
|
252
|
+
:version => 1,
|
253
|
+
:client_id => client_info.client_id,
|
254
|
+
:api_key_id => client_info.api_key_id,
|
255
|
+
:api_secret => client_info.api_secret,
|
256
|
+
:client_email => '',
|
257
|
+
:public_key => public_key,
|
258
|
+
:private_key => private_key,
|
259
|
+
:api_url => api_url,
|
260
|
+
:logging => false
|
261
|
+
)
|
262
|
+
client = E3DB::Client.new(config)
|
263
|
+
|
264
|
+
# Back the client up
|
265
|
+
client.backup(backup_client_id, registration_token)
|
266
|
+
end
|
267
|
+
|
268
|
+
client_info
|
269
|
+
end
|
270
|
+
|
271
|
+
# Generate a random Curve25519 keypair.
|
272
|
+
#
|
273
|
+
# @return [Array<String>] A two element array containing the public and private keys (respectively) for the new keypair.
|
274
|
+
def self.generate_keypair
|
275
|
+
keys = RbNaCl::PrivateKey.generate
|
276
|
+
|
277
|
+
return encode_public_key(keys.public_key), encode_private_key(keys)
|
278
|
+
end
|
279
|
+
|
176
280
|
# Create a connection to the E3DB service given a configuration.
|
177
281
|
#
|
178
282
|
# @param config [Config] configuration and credentials to use
|
179
283
|
# @return [Client] a connection to the E3DB service
|
180
284
|
def initialize(config)
|
181
285
|
@config = config
|
182
|
-
@public_key = RbNaCl::PublicKey.new(
|
183
|
-
@private_key = RbNaCl::PrivateKey.new(
|
286
|
+
@public_key = RbNaCl::PublicKey.new(base64decode(@config.public_key))
|
287
|
+
@private_key = RbNaCl::PrivateKey.new(base64decode(@config.private_key))
|
184
288
|
|
185
|
-
@ak_cache = LruRedux::ThreadSafeCache.new(1024)
|
186
289
|
@oauth_client = OAuth2::Client.new(
|
187
290
|
config.api_key_id,
|
188
291
|
config.api_secret,
|
@@ -204,17 +307,17 @@ module E3DB
|
|
204
307
|
end
|
205
308
|
faraday.adapter :net_http_persistent
|
206
309
|
end
|
310
|
+
|
311
|
+
@ak_cache = LruRedux::ThreadSafeCache.new(1024)
|
207
312
|
end
|
208
313
|
|
209
314
|
# Query the server for information about an E3DB client.
|
210
315
|
#
|
211
|
-
# @param client_id [String] client ID
|
316
|
+
# @param client_id [String] client ID to look up
|
212
317
|
# @return [ClientInfo] information about this client
|
213
318
|
def client_info(client_id)
|
214
319
|
if client_id.include? "@"
|
215
|
-
|
216
|
-
url = base_url + sprintf('?email=%s', CGI.escape(client_id))
|
217
|
-
resp = @conn.post(url)
|
320
|
+
raise "Client discovery by email is not supported!"
|
218
321
|
else
|
219
322
|
resp = @conn.get(get_url('v1', 'storage', 'clients', client_id))
|
220
323
|
end
|
@@ -230,27 +333,32 @@ module E3DB
|
|
230
333
|
if client_id == @config.client_id
|
231
334
|
@public_key
|
232
335
|
else
|
233
|
-
|
336
|
+
decode_public_key(client_info(client_id).public_key.curve25519)
|
234
337
|
end
|
235
338
|
end
|
236
339
|
|
237
|
-
# Read a single record by ID from E3DB and return it without
|
238
|
-
# decrypting the data fields.
|
239
|
-
#
|
240
|
-
# @param record_id [String] record ID to look up
|
241
|
-
# @return [Record] encrypted record object
|
242
|
-
def read_raw(record_id)
|
243
|
-
resp = @conn.get(get_url('v1', 'storage', 'records', record_id))
|
244
|
-
json = JSON.parse(resp.body, symbolize_names: true)
|
245
|
-
Record.new(json)
|
246
|
-
end
|
247
|
-
|
248
340
|
# Read a single record by ID from E3DB and return it.
|
249
341
|
#
|
250
342
|
# @param record_id [String] record ID to look up
|
343
|
+
# @param fields [Array] Optional array of fields to filter
|
251
344
|
# @return [Record] decrypted record object
|
252
|
-
def read(record_id)
|
253
|
-
|
345
|
+
def read(record_id, fields = nil)
|
346
|
+
path = get_url('v1', 'storage', 'records', record_id)
|
347
|
+
|
348
|
+
unless fields.nil?
|
349
|
+
resp = @conn.get(path) do |req|
|
350
|
+
req.options.params_encoder = Faraday::FlatParamsEncoder
|
351
|
+
req.params['field'] = fields
|
352
|
+
end
|
353
|
+
else
|
354
|
+
resp = @conn.get(path)
|
355
|
+
end
|
356
|
+
|
357
|
+
record = Record.new(JSON.parse(resp.body, symbolize_names: true))
|
358
|
+
writer_id = record.meta.writer_id
|
359
|
+
user_id = record.meta.user_id
|
360
|
+
type = record.meta.type
|
361
|
+
decrypt_record(record, get_eak(writer_id, user_id, type))
|
254
362
|
end
|
255
363
|
|
256
364
|
# Write a new record to the E3DB storage service.
|
@@ -258,16 +366,23 @@ module E3DB
|
|
258
366
|
# @param type [String] free-form content type name of this record
|
259
367
|
# @param data [Hash<String, String>] record data to be stored encrypted
|
260
368
|
# @param plain [Hash<String, String>] record data to be stored unencrypted for querying
|
261
|
-
# @return [Record] the newly created record object
|
369
|
+
# @return [Record] the newly created record object (with decrypted values).
|
262
370
|
def write(type, data, plain=Hash.new)
|
263
371
|
url = get_url('v1', 'storage', 'records')
|
264
372
|
id = @config.client_id
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
373
|
+
|
374
|
+
begin
|
375
|
+
eak = get_eak(id, id, type)
|
376
|
+
rescue Faraday::ClientError => e
|
377
|
+
if e.response[:status] == 404
|
378
|
+
eak = create_writer_key(type)
|
379
|
+
else
|
380
|
+
raise e
|
381
|
+
end
|
382
|
+
end
|
383
|
+
|
384
|
+
resp = @conn.post(url, encrypt_record(type, data, plain, id, eak).to_hash)
|
385
|
+
decrypt_record(resp.body, eak)
|
271
386
|
end
|
272
387
|
|
273
388
|
# Update an existing record in the E3DB storage service.
|
@@ -280,28 +395,82 @@ module E3DB
|
|
280
395
|
# the new version number and modification time returned by the server.
|
281
396
|
#
|
282
397
|
# @param record [Record] the record to update
|
398
|
+
# @return [Nil] Always returns nil.
|
283
399
|
def update(record)
|
284
400
|
record_id = record.meta.record_id
|
285
401
|
version = record.meta.version
|
286
402
|
url = get_url('v1', 'storage', 'records', 'safe', record_id, version)
|
403
|
+
|
287
404
|
begin
|
288
|
-
|
405
|
+
type = record.meta.type
|
406
|
+
encrypted_record = encrypt_existing(record, get_eak(@config.client_id, @config.client_id, record.meta.type))
|
407
|
+
resp = @conn.put(url, encrypted_record.to_hash)
|
408
|
+
json = JSON.parse(resp.body, symbolize_names: true)
|
409
|
+
record.meta = Meta.new(json[:meta])
|
410
|
+
nil
|
289
411
|
rescue Faraday::ClientError => e
|
290
412
|
if e.response[:status] == 409
|
291
413
|
raise E3DB::ConflictError, record
|
292
414
|
else
|
293
415
|
raise e # re-raise on other failures
|
294
416
|
end
|
295
|
-
|
296
|
-
json = JSON.parse(resp.body, symbolize_names: true)
|
297
|
-
record.meta = Meta.new(json[:meta])
|
417
|
+
end
|
298
418
|
end
|
299
419
|
|
300
|
-
# Delete a record from the E3DB storage service.
|
420
|
+
# Delete a record from the E3DB storage service. If a version
|
421
|
+
# is provided and does not match, an E3DB::ConflicError exception
|
422
|
+
# will be raised.
|
423
|
+
#
|
424
|
+
# Always returns +nil+.
|
301
425
|
#
|
302
426
|
# @param record_id [String] unique ID of record to delete
|
303
|
-
|
304
|
-
|
427
|
+
# @param version [String] version ID that must match before deleting the record.
|
428
|
+
# @return [Nil] Always returns nil.
|
429
|
+
def delete(record_id, version=nil)
|
430
|
+
if version.nil?
|
431
|
+
resp = @conn.delete(get_url('v1', 'storage', 'records', record_id))
|
432
|
+
else
|
433
|
+
begin
|
434
|
+
resp = @conn.delete(get_url('v1', 'storage', 'records', 'safe', record_id, version))
|
435
|
+
rescue Faraday::ClientError => e
|
436
|
+
if e.response[:status] == 409
|
437
|
+
raise E3DB::ConflictError, record_id
|
438
|
+
else
|
439
|
+
raise e # re-raise on other failures
|
440
|
+
end
|
441
|
+
end
|
442
|
+
end
|
443
|
+
|
444
|
+
nil
|
445
|
+
end
|
446
|
+
|
447
|
+
# Back up the client's configuration to E3DB in a serialized
|
448
|
+
# format that can be read by the Admin Console. The stored
|
449
|
+
# configuration will be shared with the specified client, and the
|
450
|
+
# account service notified that the sharing has taken place.
|
451
|
+
#
|
452
|
+
# @param client_id [String] Unique ID of the client to which we're backing up
|
453
|
+
# @param registration_token [String] Original registration token used to create the client
|
454
|
+
# @return [Nil] Always returns nil.
|
455
|
+
def backup(client_id, registration_token)
|
456
|
+
credentials = {
|
457
|
+
:version => '1',
|
458
|
+
:client_id => @config.client_id.to_json,
|
459
|
+
:api_key_id => @config.api_key_id.to_json,
|
460
|
+
:api_secret => @config.api_secret.to_json,
|
461
|
+
:client_email => @config.client_email.to_json,
|
462
|
+
:public_key => @config.public_key.to_json,
|
463
|
+
:private_key => @config.private_key.to_json,
|
464
|
+
:api_url => @config.api_url.to_json
|
465
|
+
}
|
466
|
+
|
467
|
+
write('tozny.key_backup', credentials, {:client => @config.client_id})
|
468
|
+
share('tozny.key_backup', client_id)
|
469
|
+
|
470
|
+
url = get_url('v1', 'account', 'backup', registration_token, @config.client_id)
|
471
|
+
@conn.post(url)
|
472
|
+
|
473
|
+
nil
|
305
474
|
end
|
306
475
|
|
307
476
|
class Query < Dry::Struct
|
@@ -338,11 +507,11 @@ module E3DB
|
|
338
507
|
# fetch all records into an array first.
|
339
508
|
class Result
|
340
509
|
include Enumerable
|
510
|
+
include Crypto
|
341
511
|
|
342
|
-
def initialize(client, query
|
512
|
+
def initialize(client, query)
|
343
513
|
@client = client
|
344
514
|
@query = query
|
345
|
-
@raw = raw
|
346
515
|
end
|
347
516
|
|
348
517
|
# Invoke a block for each record matching a query.
|
@@ -356,18 +525,12 @@ module E3DB
|
|
356
525
|
json = @client.instance_eval { query1(q) }
|
357
526
|
results = json[:results]
|
358
527
|
results.each do |r|
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
record = @client.instance_eval {
|
364
|
-
ak = decrypt_eak(access_key)
|
365
|
-
decrypt_record_with_key(record, ak)
|
366
|
-
}
|
367
|
-
else
|
368
|
-
record = @client.instance_eval { decrypt_record(record) }
|
369
|
-
end
|
528
|
+
if q.include_data
|
529
|
+
record = @client.decrypt_record(Record.new({ :meta => r[:meta], :data => r[:record_data] }), EAK.new(r[:access_key]))
|
530
|
+
else
|
531
|
+
record = Record.new(data: Hash.new, meta: Meta.new(r[:meta]))
|
370
532
|
end
|
533
|
+
|
371
534
|
yield record
|
372
535
|
end
|
373
536
|
|
@@ -407,10 +570,9 @@ module E3DB
|
|
407
570
|
# @param type [String,Array<string>] select records with these types
|
408
571
|
# @param plain [Hash] plaintext query expression to select
|
409
572
|
# @param data [Boolean] include data in records
|
410
|
-
# @param raw [Boolean] when true don't decrypt record data
|
411
573
|
# @param page_size [Integer] number of records to fetch per request
|
412
574
|
# @return [Result] a result set object enumerating matched records
|
413
|
-
def query(data: true,
|
575
|
+
def query(data: true, writer: nil, record: nil, type: nil, plain: nil, page_size: DEFAULT_QUERY_COUNT)
|
414
576
|
all_writers = false
|
415
577
|
if writer == :all
|
416
578
|
all_writers = true
|
@@ -421,7 +583,7 @@ module E3DB
|
|
421
583
|
record_ids: record, content_types: type, plain: plain,
|
422
584
|
user_ids: nil, count: page_size,
|
423
585
|
include_all_writers: all_writers)
|
424
|
-
result = Result.new(self, q
|
586
|
+
result = Result.new(self, q)
|
425
587
|
if block_given?
|
426
588
|
result.each do |rec|
|
427
589
|
yield rec
|
@@ -434,7 +596,8 @@ module E3DB
|
|
434
596
|
# Grant another E3DB client access to records of a particular type.
|
435
597
|
#
|
436
598
|
# @param type [String] type of records to share
|
437
|
-
# @param reader_id [String] client ID
|
599
|
+
# @param reader_id [String] client ID of reader to grant access to
|
600
|
+
# @return [Nil] Always returns nil.
|
438
601
|
def share(type, reader_id)
|
439
602
|
if reader_id == @config.client_id
|
440
603
|
return
|
@@ -443,17 +606,27 @@ module E3DB
|
|
443
606
|
end
|
444
607
|
|
445
608
|
id = @config.client_id
|
446
|
-
ak = get_access_key(id, id,
|
447
|
-
|
609
|
+
ak = get_access_key(id, id, type)
|
610
|
+
|
611
|
+
begin
|
612
|
+
put_access_key(id, id, reader_id, type, ak)
|
613
|
+
rescue Faraday::ClientError => e
|
614
|
+
# Ignore 403, means AK already exists.
|
615
|
+
if e.response[:status] != 403
|
616
|
+
raise e
|
617
|
+
end
|
618
|
+
end
|
448
619
|
|
449
620
|
url = get_url('v1', 'storage', 'policy', id, id, reader_id, type)
|
450
621
|
@conn.put(url, JSON.generate({:allow => [{:read => {}}]}))
|
622
|
+
nil
|
451
623
|
end
|
452
624
|
|
453
625
|
# Revoke another E3DB client's access to records of a particular type.
|
454
626
|
#
|
455
627
|
# @param type [String] type of records to revoke access to
|
456
|
-
# @param reader_id [String] client ID of reader to revoke access from
|
628
|
+
# @param reader_id [String] client ID of reader to revoke access from
|
629
|
+
# @return [Nil] Always returns nil.
|
457
630
|
def revoke(type, reader_id)
|
458
631
|
if reader_id == @config.client_id
|
459
632
|
return
|
@@ -464,8 +637,15 @@ module E3DB
|
|
464
637
|
id = @config.client_id
|
465
638
|
url = get_url('v1', 'storage', 'policy', id, id, reader_id, type)
|
466
639
|
@conn.put(url, JSON.generate({:deny => [{:read => {}}]}))
|
640
|
+
|
641
|
+
delete_access_key(id, id, reader_id, type)
|
642
|
+
nil
|
467
643
|
end
|
468
644
|
|
645
|
+
# Gets a list of record types that this client has shared with
|
646
|
+
# others.
|
647
|
+
#
|
648
|
+
# @return [Array<OutgoingSharingPolicy>]
|
469
649
|
def outgoing_sharing
|
470
650
|
url = get_url('v1', 'storage', 'policy', 'outgoing')
|
471
651
|
resp = @conn.get(url)
|
@@ -473,6 +653,10 @@ module E3DB
|
|
473
653
|
return json.map {|x| OutgoingSharingPolicy.new(x)}
|
474
654
|
end
|
475
655
|
|
656
|
+
# Gets a list of record types that others have shared with this
|
657
|
+
# client.
|
658
|
+
#
|
659
|
+
# @return [Array<IncomingSharingPolicy>]
|
476
660
|
def incoming_sharing
|
477
661
|
url = get_url('v1', 'storage', 'policy', 'incoming')
|
478
662
|
resp = @conn.get(url)
|
@@ -480,8 +664,207 @@ module E3DB
|
|
480
664
|
return json.map {|x| IncomingSharingPolicy.new(x)}
|
481
665
|
end
|
482
666
|
|
667
|
+
# Create and return a key for encrypting the given record type.
|
668
|
+
# The value returned is encrypted such that it can only be used by this
|
669
|
+
# client.
|
670
|
+
#
|
671
|
+
# Can be saved for use with {#encrypt_existing}, {#encrypt_record}
|
672
|
+
# and {#decrypt_record} later.
|
673
|
+
#
|
674
|
+
# @return [EAK]
|
675
|
+
def create_writer_key(type)
|
676
|
+
id = @config.client_id
|
677
|
+
begin
|
678
|
+
put_access_key(id, id, id, type, new_access_key)
|
679
|
+
rescue Faraday::ClientError => e
|
680
|
+
# Ignore 409, as it means a key already exists. Otherwise, raise.
|
681
|
+
if e.response[:status] != 409
|
682
|
+
raise e
|
683
|
+
end
|
684
|
+
end
|
685
|
+
|
686
|
+
get_eak(id, id, type)
|
687
|
+
end
|
688
|
+
|
689
|
+
# Retrieve a key for reading records shared with this client.
|
690
|
+
#
|
691
|
+
# +writer_id+ is the ID of the client who wrote the shared records. +user_id+
|
692
|
+
# is the ID of the user that the record pertains to. +type+ is the type of
|
693
|
+
# the shared record.
|
694
|
+
#
|
695
|
+
# The value returned is encrypted such that it can only be used by
|
696
|
+
# this client.
|
697
|
+
#
|
698
|
+
# @return [EAK]
|
699
|
+
def get_reader_key(writer_id, user_id, type)
|
700
|
+
get_eak(writer_id, user_id, type)
|
701
|
+
end
|
702
|
+
|
703
|
+
# Encrypts an existing record. The record must contain plaintext values.
|
704
|
+
#
|
705
|
+
# +plain_record+ should be a {Record} instance to encrypt.
|
706
|
+
# +eak+ should be an {EAK} instance.
|
707
|
+
#
|
708
|
+
# @return [Record] An instance containg the encrypted data.
|
709
|
+
def encrypt_existing(plain_record, eak)
|
710
|
+
cache_key = [plain_record.meta.writer_id, plain_record.meta.user_id, plain_record.meta.type]
|
711
|
+
if ! @ak_cache.has_key? cache_key
|
712
|
+
@ak_cache[cache_key] = { :eak => eak, :ak => decrypt_box(eak.eak, eak.authorizer_public_key.curve25519, @private_key) }
|
713
|
+
end
|
714
|
+
ak = @ak_cache[cache_key][:ak]
|
715
|
+
|
716
|
+
encrypted_record = Record.new(meta: plain_record.meta, data: Hash.new)
|
717
|
+
plain_record.data.each do |k, v|
|
718
|
+
dk = new_data_key
|
719
|
+
efN = secret_box_random_nonce
|
720
|
+
ef = RbNaCl::SecretBox.new(dk).encrypt(efN, v)
|
721
|
+
|
722
|
+
edkN = secret_box_random_nonce
|
723
|
+
edk = RbNaCl::SecretBox.new(ak).encrypt(edkN, dk)
|
724
|
+
|
725
|
+
encrypted_record.data[k] = [edk, edkN, ef, efN].map { |f| base64encode(f) }.join(".")
|
726
|
+
end
|
727
|
+
|
728
|
+
encrypted_record
|
729
|
+
end
|
730
|
+
|
731
|
+
# Encrypt a new record consisting of the given data.
|
732
|
+
#
|
733
|
+
# +type+ is a string giving the record type. +data+ should be a
|
734
|
+
# dictionary of string values. +plain+ should be a dictionary of
|
735
|
+
# string values. +id+ should be the ID of the client creating the
|
736
|
+
# record. +eak+ should be an {EAK} instance.
|
737
|
+
#
|
738
|
+
# @return [Record] An instance containing the encrypted data.
|
739
|
+
def encrypt_record(type, data, plain, id, eak)
|
740
|
+
meta = Meta.new(record_id: nil, writer_id: id, user_id: id,
|
741
|
+
type: type, plain: plain, created: nil,
|
742
|
+
last_modified: nil, version: nil)
|
743
|
+
|
744
|
+
encrypt_existing(Record.new(:meta => meta, :data => data), eak)
|
745
|
+
end
|
746
|
+
|
747
|
+
# Decrypts a record using the given secret key.
|
748
|
+
#
|
749
|
+
# The record should be either a JSON document (as a string) or a
|
750
|
+
# {Record} instance. +eak+ should be an {EAK} instance.
|
751
|
+
#
|
752
|
+
# @return [Record] An instance containing the decrypted data.
|
753
|
+
def decrypt_record(encrypted_record, eak)
|
754
|
+
encrypted_record = Record.new(JSON.parse(encrypted_record, symbolize_names: true)) if encrypted_record.is_a? String
|
755
|
+
raise 'Can only decrypt JSON string or Record instance.' if ! encrypted_record.is_a? Record
|
756
|
+
|
757
|
+
cache_key = [encrypted_record.meta.writer_id, encrypted_record.meta.user_id, encrypted_record.meta.type]
|
758
|
+
if ! @ak_cache.has_key? cache_key
|
759
|
+
@ak_cache[cache_key] = { :eak => eak, :ak => decrypt_box(eak.eak, eak.authorizer_public_key.curve25519, @private_key) }
|
760
|
+
end
|
761
|
+
ak = @ak_cache[cache_key][:ak]
|
762
|
+
|
763
|
+
plain_record = Record.new(data: Hash.new, meta: Meta.new(encrypted_record.meta))
|
764
|
+
encrypted_record[:data].each do |k, v|
|
765
|
+
edk, edkN, ef, efN = v.split('.', 4).map { |f| base64decode(f) }
|
766
|
+
|
767
|
+
dk = RbNaCl::SecretBox.new(ak).decrypt(edkN, edk)
|
768
|
+
pv = RbNaCl::SecretBox.new(dk).decrypt(efN, ef)
|
769
|
+
|
770
|
+
plain_record.data[k] = pv
|
771
|
+
end
|
772
|
+
|
773
|
+
plain_record
|
774
|
+
end
|
775
|
+
|
483
776
|
private
|
484
777
|
|
778
|
+
# Returns the encrypted access key for this client for the
|
779
|
+
# given writer/user/type combination. Throws
|
780
|
+
# Faraday::ResourceNotFound if the key does not exist.
|
781
|
+
#
|
782
|
+
# Returns an instance of E3DB::EAK.
|
783
|
+
def get_eak(writer_id, user_id, type)
|
784
|
+
get_cached_key(writer_id, user_id, type)[:eak]
|
785
|
+
end
|
786
|
+
|
787
|
+
# Retrieve the access key for the given combination of writer,
|
788
|
+
# user and typ with this client as reader. Throws
|
789
|
+
# Faraday::ResourceNotFound if the key does not exist.
|
790
|
+
#
|
791
|
+
# Returns an string of bytes representing the access key.
|
792
|
+
def get_access_key(writer_id, user_id, type)
|
793
|
+
get_cached_key(writer_id, user_id, type)[:ak]
|
794
|
+
end
|
795
|
+
|
796
|
+
# Manages EAK caching, and goes to the server if a given access
|
797
|
+
# key has not been fetched. Throws Faraday::ResourceNotFound if
|
798
|
+
# the key does not exist.
|
799
|
+
#
|
800
|
+
# Returns a dictionary with +:eak+ and +:ak+ entries (containing
|
801
|
+
# the encrypted and unencrypted versions of the key,
|
802
|
+
# respectively).
|
803
|
+
def get_cached_key(writer_id, user_id, type)
|
804
|
+
cache_key = [writer_id, user_id, type]
|
805
|
+
if @ak_cache.has_key? cache_key
|
806
|
+
@ak_cache[cache_key]
|
807
|
+
else
|
808
|
+
url = get_url('v1', 'storage', 'access_keys', writer_id, user_id, @config.client_id, type)
|
809
|
+
json = JSON.parse(@conn.get(url).body, symbolize_names: true)
|
810
|
+
@ak_cache[cache_key] = {
|
811
|
+
:eak => EAK.new(json),
|
812
|
+
:ak => decrypt_box(json[:eak], json[:authorizer_public_key][:curve25519], @private_key)
|
813
|
+
}
|
814
|
+
end
|
815
|
+
end
|
816
|
+
|
817
|
+
# Store an access key for the given combination of writer, user,
|
818
|
+
# reader and type. `ak` should be an string of bytes representing
|
819
|
+
# the access key.
|
820
|
+
#
|
821
|
+
# If an access key for the given combination exists, this method
|
822
|
+
# will have no effect.
|
823
|
+
#
|
824
|
+
# Returns +nil+ in all cases.
|
825
|
+
def put_access_key(writer_id, user_id, reader_id, type, ak)
|
826
|
+
if reader_id == @client_id
|
827
|
+
reader_key = @public_key
|
828
|
+
else
|
829
|
+
resp = @conn.get(get_url('v1', 'storage', 'clients', reader_id))
|
830
|
+
reader_key = decode_public_key(JSON.parse(resp.body, symbolize_names: true)[:public_key][:curve25519])
|
831
|
+
end
|
832
|
+
|
833
|
+
encoded_eak = encrypt_box(ak, reader_key, @private_key)
|
834
|
+
|
835
|
+
url = get_url('v1', 'storage', 'access_keys', writer_id, user_id, reader_id, type)
|
836
|
+
resp = @conn.put(url, { :eak => encoded_eak })
|
837
|
+
cache_key = [writer_id, user_id, type]
|
838
|
+
@ak_cache[cache_key] = {
|
839
|
+
ak: ak,
|
840
|
+
eak: EAK.new(
|
841
|
+
{
|
842
|
+
eak: encoded_eak,
|
843
|
+
authorizer_public_key: {
|
844
|
+
curve25519: encode_public_key(reader_key)
|
845
|
+
},
|
846
|
+
authorizer_id: @config.client_id
|
847
|
+
}
|
848
|
+
)
|
849
|
+
}
|
850
|
+
|
851
|
+
nil
|
852
|
+
end
|
853
|
+
|
854
|
+
# Delete the access key for the given combination of writer, user,
|
855
|
+
# reader and type.
|
856
|
+
#
|
857
|
+
# Returns nil in all cases.
|
858
|
+
def delete_access_key(writer_id, user_id, reader_id, type)
|
859
|
+
url = get_url('v1', 'storage', 'access_keys', writer_id, user_id, reader_id, type)
|
860
|
+
@conn.delete(url)
|
861
|
+
|
862
|
+
cache_key = [writer_id, user_id, type]
|
863
|
+
@ak_cache.delete(cache_key)
|
864
|
+
|
865
|
+
nil
|
866
|
+
end
|
867
|
+
|
485
868
|
# Fetch a single page of query results. Used internally by {Client#query}.
|
486
869
|
def query1(query)
|
487
870
|
url = get_url('v1', 'storage', 'search')
|
@@ -490,7 +873,7 @@ module E3DB
|
|
490
873
|
end
|
491
874
|
|
492
875
|
def get_url(*paths)
|
493
|
-
|
876
|
+
"#{@config.api_url.chomp('/')}/#{ paths.map { |x| CGI.escape x }.join('/')}"
|
494
877
|
end
|
495
878
|
end
|
496
879
|
end
|