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