e3db 2.0.0 → 2.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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('ijones+feedback@tozny.com')
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', 'ijones+feedback@tozny.com')
117
+ client.revoke('test-contact', isaac_client_id)
141
118
 
142
119
  # Delete the record we created above
143
120
  client.delete(record_id)
@@ -9,8 +9,8 @@
9
9
  require 'e3db/version'
10
10
  require 'e3db/types'
11
11
  require 'e3db/config'
12
- require 'e3db/client'
13
12
  require 'e3db/crypto'
13
+ require 'e3db/client'
14
14
 
15
15
  # Ruby client library for the Tozny End-to-End Encrypted Database (E3DB) service.
16
16
  #
@@ -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
- super('Conflict updating record: ' + record.meta.record_id)
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(Crypto.base64decode(@config.public_key))
183
- @private_key = RbNaCl::PrivateKey.new(Crypto.base64decode(@config.private_key))
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 or e-mail address to look up
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
- base_url = get_url('v1', 'storage', 'clients', 'find')
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
- Crypto.decode_public_key(client_info(client_id).public_key.curve25519)
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
- decrypt_record(read_raw(record_id))
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
- meta = Meta.new(record_id: nil, writer_id: id, user_id: id,
266
- type: type, plain: plain, created: nil,
267
- last_modified: nil, version: nil)
268
- record = Record.new(meta: meta, data: data)
269
- resp = @conn.post(url, encrypt_record(record).to_hash)
270
- decrypt_record(Record.new(JSON.parse(resp.body, symbolize_names: true)))
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
- resp = @conn.put(url, encrypt_record(record).to_hash)
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
- end
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
- def delete(record_id)
304
- resp = @conn.delete(get_url('v1', 'storage', 'records', record_id))
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, raw)
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
- record = Record.new(meta: r[:meta], data: r[:record_data] || Hash.new)
360
- if q.include_data && !@raw
361
- access_key = r[:access_key]
362
- if access_key
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, raw: false, writer: nil, record: nil, type: nil, plain: nil, page_size: DEFAULT_QUERY_COUNT)
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, raw)
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 or e-mail address of reader to grant access to
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, id, type)
447
- put_access_key(id, id, reader_id, type, ak)
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
- sprintf('%s/%s', @config.api_url.chomp('/'), paths.map { |x| CGI.escape x }.join('/'))
876
+ "#{@config.api_url.chomp('/')}/#{ paths.map { |x| CGI.escape x }.join('/')}"
494
877
  end
495
878
  end
496
879
  end