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