Class: E3DB::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/e3db/client.rb

Overview

A connection to the E3DB service used to perform database operations.

Defined Under Namespace

Classes: Result

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config) ⇒ Client

Create a connection to the E3DB service given a configuration.

Parameters:

  • config (Config)

    configuration and credentials to use



284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# File 'lib/e3db/client.rb', line 284

def initialize(config)
  @config = config
  @public_key = RbNaCl::PublicKey.new(base64decode(@config.public_key))
  @private_key = RbNaCl::PrivateKey.new(base64decode(@config.private_key))

  @oauth_client = OAuth2::Client.new(
      config.api_key_id,
      config.api_secret,
      :site => config.api_url,
      :token_url => '/v1/auth/token',
      :auth_scheme => :basic_auth,
      :raise_errors => false)

  if config.logging
    @oauth_client.connection.response :logger, ::Logger.new($stdout)
  end

  @conn = Faraday.new(DEFAULT_API_URL) do |faraday|
    faraday.use TokenHelper, @oauth_client
    faraday.request :json
    faraday.response :raise_error
    if config.logging
      faraday.response :logger, nil, :bodies => true
    end
    faraday.adapter :net_http_persistent
  end

  @ak_cache = LruRedux::ThreadSafeCache.new(1024)
end

Instance Attribute Details

#configConfig (readonly)

Returns the client configuration object.

Returns:

  • (Config)

    the client configuration object



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
# File 'lib/e3db/client.rb', line 214

class Client
  include Crypto
  class << self
    include Crypto
  end

  attr_reader :config

  # Register a new client with a specific account given that account's registration token
  #
  # @param registration_token [String]  Token for a specific InnoVault account
  # @param client_name        [String]  Unique name for the client being registered
  # @param public_key         [String]  Base64URL-encoded public key component of a Curve25519 keypair
  # @param private_key        [String]  Optional Base64URL-encoded private key component of a Curve25519 keypair
  # @param backup             [Boolean] Optional flag to automatically back up the newly-created credentials to the account service
  # @param api_url            [String]  Optional URL of the API against which to register
  # @return [ClientDetails] Credentials and details about the newly-created client
  def self.register(registration_token, client_name, public_key, private_key=nil, backup=false, api_url=E3DB::DEFAULT_API_URL)
    url = "#{api_url.chomp('/')}/v1/account/e3db/clients/register"
    payload = JSON.generate({:token => registration_token, :client => {:name => client_name, :public_key => {:curve25519 => public_key}}})

    conn = Faraday.new(api_url) do |faraday|
      faraday.request :json
      faraday.response :raise_error
      faraday.adapter :net_http_persistent
    end

    resp = conn.post(url, payload)
    client_info = ClientDetails.new(JSON.parse(resp.body, symbolize_names: true))
    backup_client_id = resp.headers['x-backup-client']

    if backup
      if private_key.nil?
        raise 'Cannot back up client credentials without a private key!'
      end

      # Instantiate a client
      config = E3DB::Config.new(
          :version      => 1,
          :client_id    => client_info.client_id,
          :api_key_id   => client_info.api_key_id,
          :api_secret   => client_info.api_secret,
          :client_email => '',
          :public_key   => public_key,
          :private_key  => private_key,
          :api_url      => api_url,
          :logging      => false
      )
      client = E3DB::Client.new(config)

      # Back the client up
      client.backup(backup_client_id, registration_token)
    end

    client_info
  end

  # Generate a random Curve25519 keypair.
  #
  # @return [Array<String>] A two element array containing the public and private keys (respectively) for the new keypair.
  def self.generate_keypair
    keys = RbNaCl::PrivateKey.generate

    return encode_public_key(keys.public_key), encode_private_key(keys)
  end

  # Create a connection to the E3DB service given a configuration.
  #
  # @param config [Config] configuration and credentials to use
  # @return [Client] a connection to the E3DB service
  def initialize(config)
    @config = config
    @public_key = RbNaCl::PublicKey.new(base64decode(@config.public_key))
    @private_key = RbNaCl::PrivateKey.new(base64decode(@config.private_key))

    @oauth_client = OAuth2::Client.new(
        config.api_key_id,
        config.api_secret,
        :site => config.api_url,
        :token_url => '/v1/auth/token',
        :auth_scheme => :basic_auth,
        :raise_errors => false)

    if config.logging
      @oauth_client.connection.response :logger, ::Logger.new($stdout)
    end

    @conn = Faraday.new(DEFAULT_API_URL) do |faraday|
      faraday.use TokenHelper, @oauth_client
      faraday.request :json
      faraday.response :raise_error
      if config.logging
        faraday.response :logger, nil, :bodies => true
      end
      faraday.adapter :net_http_persistent
    end

    @ak_cache = LruRedux::ThreadSafeCache.new(1024)
  end

  # Query the server for information about an E3DB client.
  #
  # @param client_id [String] client ID to look up
  # @return [ClientInfo] information about this client
  def client_info(client_id)
    if client_id.include? "@"
      raise "Client discovery by email is not supported!"
    else
      resp = @conn.get(get_url('v1', 'storage', 'clients', client_id))
    end

    ClientInfo.new(JSON.parse(resp.body, symbolize_names: true))
  end

  # Query the server for a client's public key.
  #
  # @param client_id [String] client ID to look up
  # @return [RbNaCl::PublicKey] decoded Curve25519 public key
  def client_key(client_id)
    if client_id == @config.client_id
      @public_key
    else
      decode_public_key(client_info(client_id).public_key.curve25519)
    end
  end

  # Read a single record by ID from E3DB and return it.
  #
  # @param record_id [String] record ID to look up
  # @param fields    [Array]  Optional array of fields to filter
  # @return [Record] decrypted record object
  def read(record_id, fields = nil)
    path = get_url('v1', 'storage', 'records', record_id)

    unless fields.nil?
      resp = @conn.get(path) do |req|
        req.options.params_encoder = Faraday::FlatParamsEncoder
        req.params['field'] = fields
      end
    else
      resp = @conn.get(path)
    end

    record = Record.new(JSON.parse(resp.body, symbolize_names: true))
    writer_id = record.meta.writer_id
    user_id = record.meta.user_id
    type = record.meta.type
    decrypt_record(record, get_eak(writer_id, user_id, type))
  end

  # Write a new record to the E3DB storage service.
  #
  # @param type [String] free-form content type name of this record
  # @param data [Hash<String, String>] record data to be stored encrypted
  # @param plain [Hash<String, String>] record data to be stored unencrypted for querying
  # @return [Record] the newly created record object (with decrypted values).
  def write(type, data, plain=Hash.new)
    url = get_url('v1', 'storage', 'records')
    id = @config.client_id

    begin
      eak = get_eak(id, id, type)
    rescue Faraday::ClientError => e
      if e.response[:status] == 404
        eak = create_writer_key(type)
      else
        raise e
      end
    end

    resp = @conn.post(url, encrypt_record(type, data, plain, id, eak).to_hash)
    decrypt_record(resp.body, eak)
  end

  # Update an existing record in the E3DB storage service.
  #
  # If the record has been modified by another client since it was
  # read, this method raises {ConflictError}, which should be caught
  # by the caller so that the record can be re-fetched and the update retried.
  #
  # The metadata of the input record will be updated in-place to reflect
  # the new version number and modification time returned by the server.
  #
  # @param record [Record] the record to update
  # @return [Nil] Always returns nil.
  def update(record)
    record_id = record.meta.record_id
    version = record.meta.version
    url = get_url('v1', 'storage', 'records', 'safe', record_id, version)

    begin
      type = record.meta.type
      encrypted_record = encrypt_existing(record, get_eak(@config.client_id, @config.client_id, record.meta.type))
      resp = @conn.put(url, encrypted_record.to_hash)
      json = JSON.parse(resp.body, symbolize_names: true)
      record.meta = Meta.new(json[:meta])
      nil
    rescue Faraday::ClientError => e
      if e.response[:status] == 409
        raise E3DB::ConflictError, record
      else
        raise e   # re-raise on other failures
      end
   end
  end

  # Delete a record from the E3DB storage service. If a version
  # is provided and does not match, an E3DB::ConflicError exception
  # will be raised.
  #
  # Always returns +nil+.
  #
  # @param record_id [String] unique ID of record to delete
  # @param version [String] version ID that must match before deleting the record.
  # @return [Nil] Always returns nil.
  def delete(record_id, version=nil)
    if version.nil?
      resp = @conn.delete(get_url('v1', 'storage', 'records', record_id))
    else
      begin
        resp = @conn.delete(get_url('v1', 'storage', 'records', 'safe', record_id, version))
      rescue Faraday::ClientError => e
        if e.response[:status] == 409
          raise E3DB::ConflictError, record_id
        else
          raise e   # re-raise on other failures
        end
      end
    end
    
    nil
  end

  # Back up the client's configuration to E3DB in a serialized
  # format that can be read by the Admin Console. The stored
  # configuration will be shared with the specified client, and the
  # account service notified that the sharing has taken place.
  #
  # @param client_id          [String] Unique ID of the client to which we're backing up
  # @param registration_token [String] Original registration token used to create the client
  # @return [Nil] Always returns nil.
  def backup(client_id, registration_token)
    credentials = {
        :version      => '1',
        :client_id    => @config.client_id.to_json,
        :api_key_id   => @config.api_key_id.to_json,
        :api_secret   => @config.api_secret.to_json,
        :client_email => @config.client_email.to_json,
        :public_key   => @config.public_key.to_json,
        :private_key  => @config.private_key.to_json,
        :api_url      => @config.api_url.to_json
    }

    write('tozny.key_backup', credentials, {:client => @config.client_id})
    share('tozny.key_backup', client_id)

    url = get_url('v1', 'account', 'backup', registration_token, @config.client_id)
    @conn.post(url)

    nil
  end

  class Query < Dry::Struct
    attribute :count,               Types::Int
    attribute :include_data,        Types::Bool.optional
    attribute :writer_ids,          Types::Coercible::Array.member(Types::String).optional
    attribute :user_ids,            Types::Coercible::Array.member(Types::String).optional
    attribute :record_ids,          Types::Coercible::Array.member(Types::String).optional
    attribute :content_types,       Types::Coercible::Array.member(Types::String).optional
    attribute :plain,               Types::Hash.optional
    attribute :after_index,         Types::Int.optional
    attribute :include_all_writers, Types::Bool.optional

    def after_index=(index)
      @after_index = index
    end

    def as_json
      JSON.generate(to_hash.reject { |k, v| v.nil? })
    end
  end

  private_constant :Query

  DEFAULT_QUERY_COUNT = 100
  private_constant :DEFAULT_QUERY_COUNT

  # A set of records returned by {Client#query}. This implements the
  # `Enumerable` interface which can be used to loop over the records
  # in the result set (using eg: `Enumerable#each`).
  #
  # Every traversal of the result set will execute a query to the server,
  # so if multiple in-memory traversals are needed, use `Enumerable#to_a` to
  # fetch all records into an array first.
  class Result
    include Enumerable
    include Crypto

    def initialize(client, query)
      @client = client
      @query = query
    end

    # Invoke a block for each record matching a query.
    def each
      # Every invocation of 'each' gets its own copy of the query since
      # it will be modified as we loop through the result pages. This
      # allows multiple traversals of the same result set to start from
      # the beginning each time.
      q = Query.new(@query.to_hash)
      loop do
        json = @client.instance_eval { query1(q) }
        results = json[:results]
        results.each do |r|
          if q.include_data
            record = @client.decrypt_record(Record.new({ :meta => r[:meta], :data => r[:record_data] }), EAK.new(r[:access_key]))
          else
            record = Record.new(data: Hash.new, meta: Meta.new(r[:meta]))
          end

          yield record
        end

        if results.length < q.count
          break
        end

        q.after_index = json[:last_index]
      end
    end
  end

  # Query E3DB records according to a set of selection criteria.
  #
  # The default behavior is to return all records written by the
  # current authenticated client.
  #
  # To restrict the results to a particular type, pass a type or
  # list of types as the `type` argument.
  #
  # To restrict the results to a set of clients, pass a single or
  # list of client IDs as the `writer` argument. To list records
  # written by any client that has shared with the current client,
  # pass the special token `:any` as the `writer` argument.
  #
  # If a block is supplied, each record matching the query parameters
  # is fetched from the server and yielded to the block.
  #
  # If no block is supplied, a {Result} is returned that will
  # iterate over the records matching the query parameters. This
  # iterator is lazy and will query the server each time it is used,
  # so calling `Enumerable#to_a` to convert to an array is recommended
  # if multiple traversals are necessary.
  #
  # @param writer [String,Array<String>,:all] select records written by these client IDs or :all for all writers
  # @param record [String,Array<String>] select records with these record IDs
  # @param type [String,Array<string>] select records with these types
  # @param plain [Hash] plaintext query expression to select
  # @param data [Boolean] include data in records
  # @param page_size [Integer] number of records to fetch per request
  # @return [Result] a result set object enumerating matched records
  def query(data: true, writer: nil, record: nil, type: nil, plain: nil, page_size: DEFAULT_QUERY_COUNT)
    all_writers = false
    if writer == :all
      all_writers = true
      writer = []
    end

    q = Query.new(after_index: 0, include_data: data, writer_ids: writer,
                  record_ids: record, content_types: type, plain: plain,
                  user_ids: nil, count: page_size,
                  include_all_writers: all_writers)
    result = Result.new(self, q)
    if block_given?
      result.each do |rec|
        yield rec
      end
    else
      result
    end
  end

  # Grant another E3DB client access to records of a particular type.
  #
  # @param type [String] type of records to share
  # @param reader_id [String] client ID of reader to grant access to
  # @return [Nil] Always returns nil.
  def share(type, reader_id)
    if reader_id == @config.client_id
      return
    elsif reader_id.include? "@"
      reader_id = client_info(reader_id).client_id
    end

    id = @config.client_id
    ak = get_access_key(id, id, type)

    begin
      put_access_key(id, id, reader_id, type, ak)
    rescue Faraday::ClientError => e
      # Ignore 403, means AK already exists.
      if e.response[:status] != 403
        raise e
      end
    end

    url = get_url('v1', 'storage', 'policy', id, id, reader_id, type)
    @conn.put(url, JSON.generate({:allow => [{:read => {}}]}))
    nil
  end

  # Revoke another E3DB client's access to records of a particular type.
  #
  # @param type [String] type of records to revoke access to
  # @param reader_id [String] client ID of reader to revoke access from    
  # @return [Nil] Always returns nil.
  def revoke(type, reader_id)
    if reader_id == @config.client_id
      return
    elsif reader_id.include? "@"
      reader_id = client_info(reader_id).client_id
    end

    id = @config.client_id
    url = get_url('v1', 'storage', 'policy', id, id, reader_id, type)
    @conn.put(url, JSON.generate({:deny => [{:read => {}}]}))

    delete_access_key(id, id, reader_id, type)
    nil
  end

  # Gets a list of record types that this client has shared with
  # others.
  #
  # @return [Array<OutgoingSharingPolicy>]
  def outgoing_sharing
    url = get_url('v1', 'storage', 'policy', 'outgoing')
    resp = @conn.get(url)
    json = JSON.parse(resp.body, symbolize_names: true)
    return json.map {|x| OutgoingSharingPolicy.new(x)}
  end

  # Gets a list of record types that others have shared with this
  # client.
  #
  # @return [Array<IncomingSharingPolicy>]
  def incoming_sharing
    url = get_url('v1', 'storage', 'policy', 'incoming')
    resp = @conn.get(url)
    json = JSON.parse(resp.body, symbolize_names: true)
    return json.map {|x| IncomingSharingPolicy.new(x)}
  end

  # Create and return a key for encrypting the given record type.
  # The value returned is encrypted such that it can only be used by this
  # client.
  #
  # Can be saved for use with {#encrypt_existing}, {#encrypt_record}
  # and {#decrypt_record} later.
  #
  # @return [EAK]
  def create_writer_key(type)
    id = @config.client_id
    begin
      put_access_key(id, id, id, type, new_access_key)
    rescue Faraday::ClientError => e
      # Ignore 409, as it means a key already exists. Otherwise, raise.
      if e.response[:status] != 409
        raise e
      end
    end

    get_eak(id, id, type)
  end

  # Retrieve a key for reading records shared with this client. 
  #
  # +writer_id+ is the ID of the client who wrote the shared records. +user_id+
  # is the ID of the user that the record pertains to. +type+ is the type of
  # the shared record.
  #
  # The value returned is encrypted such that it can only be used by
  # this client.
  #
  # @return [EAK]
  def get_reader_key(writer_id, user_id, type)
    get_eak(writer_id, user_id, type)
  end

  # Encrypts an existing record. The record must contain plaintext values.
  #
  # +plain_record+ should be a {Record} instance to encrypt. 
  # +eak+ should be an {EAK} instance.
  #
  # @return [Record] An instance containg the encrypted data.
  def encrypt_existing(plain_record, eak)
    cache_key = [plain_record.meta.writer_id, plain_record.meta.user_id, plain_record.meta.type]
    if ! @ak_cache.has_key? cache_key
      @ak_cache[cache_key] = { :eak => eak, :ak => decrypt_box(eak.eak, eak.authorizer_public_key.curve25519, @private_key) }
    end
    ak = @ak_cache[cache_key][:ak]

    encrypted_record = Record.new(meta: plain_record.meta, data: Hash.new)
    plain_record.data.each do |k, v|
      dk =   new_data_key
      efN =  secret_box_random_nonce
      ef =   RbNaCl::SecretBox.new(dk).encrypt(efN, v)

      edkN = secret_box_random_nonce
      edk =  RbNaCl::SecretBox.new(ak).encrypt(edkN, dk)

      encrypted_record.data[k] = [edk, edkN, ef, efN].map { |f| base64encode(f) }.join(".")
    end

    encrypted_record
  end

  # Encrypt a new record consisting of the given data.
  #
  # +type+ is a string giving the record type. +data+ should be a
  # dictionary of string values. +plain+ should be a dictionary of
  # string values. +id+ should be the ID of the client creating the
  # record. +eak+ should be an {EAK} instance.
  #
  # @return [Record] An instance containing the encrypted data.
  def encrypt_record(type, data, plain, id, eak)
    meta = Meta.new(record_id: nil, writer_id: id, user_id: id,
                    type: type, plain: plain, created: nil,
                    last_modified: nil, version: nil)

    encrypt_existing(Record.new(:meta => meta, :data => data), eak)
  end

  # Decrypts a record using the given secret key.
  #
  # The record should be either a JSON document (as a string) or a
  # {Record} instance. +eak+ should be an {EAK} instance.
  #
  # @return [Record] An instance containing the decrypted data.
  def decrypt_record(encrypted_record, eak)
    encrypted_record = Record.new(JSON.parse(encrypted_record, symbolize_names: true)) if encrypted_record.is_a? String
    raise 'Can only decrypt JSON string or Record instance.' if ! encrypted_record.is_a? Record

    cache_key = [encrypted_record.meta.writer_id, encrypted_record.meta.user_id, encrypted_record.meta.type]
    if ! @ak_cache.has_key? cache_key
      @ak_cache[cache_key] = { :eak => eak, :ak => decrypt_box(eak.eak, eak.authorizer_public_key.curve25519, @private_key) }
    end
    ak = @ak_cache[cache_key][:ak]

    plain_record = Record.new(data: Hash.new, meta: Meta.new(encrypted_record.meta))
    encrypted_record[:data].each do |k, v|
      edk, edkN, ef, efN = v.split('.', 4).map { |f| base64decode(f) }

      dk = RbNaCl::SecretBox.new(ak).decrypt(edkN, edk)
      pv = RbNaCl::SecretBox.new(dk).decrypt(efN, ef)

      plain_record.data[k] = pv
    end

    plain_record
  end

  private

  # Returns the encrypted access key for this client for the
  # given writer/user/type combination. Throws
  # Faraday::ResourceNotFound if the key does not exist.
  #
  # Returns an instance of E3DB::EAK.
  def get_eak(writer_id, user_id, type)
    get_cached_key(writer_id, user_id, type)[:eak]
  end

  # Retrieve the access key for the given combination of writer,
  # user and typ with this client as reader. Throws
  # Faraday::ResourceNotFound if the key does not exist.
  #
  # Returns an string of bytes representing the access key.
  def get_access_key(writer_id, user_id, type)
    get_cached_key(writer_id, user_id, type)[:ak]
  end

  # Manages EAK caching, and goes to the server if a given access
  # key has not been fetched. Throws Faraday::ResourceNotFound if
  # the key does not exist.
  #
  # Returns a dictionary with +:eak+ and +:ak+ entries (containing
  # the encrypted and unencrypted versions of the key,
  # respectively).
  def get_cached_key(writer_id, user_id, type)
    cache_key = [writer_id, user_id, type]
    if @ak_cache.has_key? cache_key
      @ak_cache[cache_key]
    else
      url = get_url('v1', 'storage', 'access_keys', writer_id, user_id, @config.client_id, type)
      json = JSON.parse(@conn.get(url).body, symbolize_names: true)
      @ak_cache[cache_key] = {
        :eak => EAK.new(json),
        :ak => decrypt_box(json[:eak], json[:authorizer_public_key][:curve25519], @private_key)
      }
    end
  end

  # Store an access key for the given combination of writer, user,
  # reader and type. `ak` should be an string of bytes representing
  # the access key.
  #
  # If an access key for the given combination exists, this method
  # will have no effect.
  #
  # Returns +nil+ in all cases.
  def put_access_key(writer_id, user_id, reader_id, type, ak)
    if reader_id == @client_id
      reader_key = @public_key
    else
      resp = @conn.get(get_url('v1', 'storage', 'clients', reader_id))
      reader_key = decode_public_key(JSON.parse(resp.body, symbolize_names: true)[:public_key][:curve25519])
    end

    encoded_eak = encrypt_box(ak, reader_key, @private_key)

    url = get_url('v1', 'storage', 'access_keys', writer_id, user_id, reader_id, type)
    resp = @conn.put(url, { :eak => encoded_eak })
    cache_key = [writer_id, user_id, type]
    @ak_cache[cache_key] = {
      ak: ak,
      eak: EAK.new(
        {
          eak: encoded_eak,
          authorizer_public_key: {
            curve25519: encode_public_key(reader_key)
          },
          authorizer_id: @config.client_id
        }
      )
    }

    nil
  end

  # Delete the access key for the given combination of writer, user,
  # reader and type.
  #
  # Returns nil in all cases.
  def delete_access_key(writer_id, user_id, reader_id, type)
    url = get_url('v1', 'storage', 'access_keys', writer_id, user_id, reader_id, type)
    @conn.delete(url)

    cache_key = [writer_id, user_id, type]
    @ak_cache.delete(cache_key)

    nil
  end

  # Fetch a single page of query results. Used internally by {Client#query}.
  def query1(query)
    url = get_url('v1', 'storage', 'search')
    resp = @conn.post(url, query.as_json)
    return JSON.parse(resp.body, symbolize_names: true)
  end

  def get_url(*paths)
    "#{@config.api_url.chomp('/')}/#{ paths.map { |x| CGI.escape x }.join('/')}"
  end
end

Class Method Details

.generate_keypairArray<String>

Generate a random Curve25519 keypair.

Returns:

  • (Array<String>)

    A two element array containing the public and private keys (respectively) for the new keypair.



274
275
276
277
278
# File 'lib/e3db/client.rb', line 274

def self.generate_keypair
  keys = RbNaCl::PrivateKey.generate

  return encode_public_key(keys.public_key), encode_private_key(keys)
end

.register(registration_token, client_name, public_key, private_key = nil, backup = false, api_url = E3DB::DEFAULT_API_URL) ⇒ ClientDetails

Register a new client with a specific account given that account’s registration token

Parameters:

  • registration_token (String)

    Token for a specific InnoVault account

  • client_name (String)

    Unique name for the client being registered

  • public_key (String)

    Base64URL-encoded public key component of a Curve25519 keypair

  • private_key (String) (defaults to: nil)

    Optional Base64URL-encoded private key component of a Curve25519 keypair

  • backup (Boolean) (defaults to: false)

    Optional flag to automatically back up the newly-created credentials to the account service

  • api_url (String) (defaults to: E3DB::DEFAULT_API_URL)

    Optional URL of the API against which to register

Returns:

  • (ClientDetails)

    Credentials and details about the newly-created client



231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/e3db/client.rb', line 231

def self.register(registration_token, client_name, public_key, private_key=nil, backup=false, api_url=E3DB::DEFAULT_API_URL)
  url = "#{api_url.chomp('/')}/v1/account/e3db/clients/register"
  payload = JSON.generate({:token => registration_token, :client => {:name => client_name, :public_key => {:curve25519 => public_key}}})

  conn = Faraday.new(api_url) do |faraday|
    faraday.request :json
    faraday.response :raise_error
    faraday.adapter :net_http_persistent
  end

  resp = conn.post(url, payload)
  client_info = ClientDetails.new(JSON.parse(resp.body, symbolize_names: true))
  backup_client_id = resp.headers['x-backup-client']

  if backup
    if private_key.nil?
      raise 'Cannot back up client credentials without a private key!'
    end

    # Instantiate a client
    config = E3DB::Config.new(
        :version      => 1,
        :client_id    => client_info.client_id,
        :api_key_id   => client_info.api_key_id,
        :api_secret   => client_info.api_secret,
        :client_email => '',
        :public_key   => public_key,
        :private_key  => private_key,
        :api_url      => api_url,
        :logging      => false
    )
    client = E3DB::Client.new(config)

    # Back the client up
    client.backup(backup_client_id, registration_token)
  end

  client_info
end

Instance Method Details

#backup(client_id, registration_token) ⇒ Nil

Back up the client’s configuration to E3DB in a serialized format that can be read by the Admin Console. The stored configuration will be shared with the specified client, and the account service notified that the sharing has taken place.

Parameters:

  • client_id (String)

    Unique ID of the client to which we’re backing up

  • registration_token (String)

    Original registration token used to create the client

Returns:

  • (Nil)

    Always returns nil.



455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
# File 'lib/e3db/client.rb', line 455

def backup(client_id, registration_token)
  credentials = {
      :version      => '1',
      :client_id    => @config.client_id.to_json,
      :api_key_id   => @config.api_key_id.to_json,
      :api_secret   => @config.api_secret.to_json,
      :client_email => @config.client_email.to_json,
      :public_key   => @config.public_key.to_json,
      :private_key  => @config.private_key.to_json,
      :api_url      => @config.api_url.to_json
  }

  write('tozny.key_backup', credentials, {:client => @config.client_id})
  share('tozny.key_backup', client_id)

  url = get_url('v1', 'account', 'backup', registration_token, @config.client_id)
  @conn.post(url)

  nil
end

#client_info(client_id) ⇒ ClientInfo

Query the server for information about an E3DB client.

Parameters:

  • client_id (String)

    client ID to look up

Returns:



318
319
320
321
322
323
324
325
326
# File 'lib/e3db/client.rb', line 318

def client_info(client_id)
  if client_id.include? "@"
    raise "Client discovery by email is not supported!"
  else
    resp = @conn.get(get_url('v1', 'storage', 'clients', client_id))
  end

  ClientInfo.new(JSON.parse(resp.body, symbolize_names: true))
end

#client_key(client_id) ⇒ RbNaCl::PublicKey

Query the server for a client’s public key.

Parameters:

  • client_id (String)

    client ID to look up

Returns:

  • (RbNaCl::PublicKey)

    decoded Curve25519 public key



332
333
334
335
336
337
338
# File 'lib/e3db/client.rb', line 332

def client_key(client_id)
  if client_id == @config.client_id
    @public_key
  else
    decode_public_key(client_info(client_id).public_key.curve25519)
  end
end

#create_writer_key(type) ⇒ EAK

Create and return a key for encrypting the given record type. The value returned is encrypted such that it can only be used by this client.

Can be saved for use with #encrypt_existing, #encrypt_record and #decrypt_record later.

Returns:



675
676
677
678
679
680
681
682
683
684
685
686
687
# File 'lib/e3db/client.rb', line 675

def create_writer_key(type)
  id = @config.client_id
  begin
    put_access_key(id, id, id, type, new_access_key)
  rescue Faraday::ClientError => e
    # Ignore 409, as it means a key already exists. Otherwise, raise.
    if e.response[:status] != 409
      raise e
    end
  end

  get_eak(id, id, type)
end

#decrypt_record(encrypted_record, eak) ⇒ Record

Decrypts a record using the given secret key.

The record should be either a JSON document (as a string) or a Record instance. +eak+ should be an EAK instance.

Returns:

  • (Record)

    An instance containing the decrypted data.



753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
# File 'lib/e3db/client.rb', line 753

def decrypt_record(encrypted_record, eak)
  encrypted_record = Record.new(JSON.parse(encrypted_record, symbolize_names: true)) if encrypted_record.is_a? String
  raise 'Can only decrypt JSON string or Record instance.' if ! encrypted_record.is_a? Record

  cache_key = [encrypted_record.meta.writer_id, encrypted_record.meta.user_id, encrypted_record.meta.type]
  if ! @ak_cache.has_key? cache_key
    @ak_cache[cache_key] = { :eak => eak, :ak => decrypt_box(eak.eak, eak.authorizer_public_key.curve25519, @private_key) }
  end
  ak = @ak_cache[cache_key][:ak]

  plain_record = Record.new(data: Hash.new, meta: Meta.new(encrypted_record.meta))
  encrypted_record[:data].each do |k, v|
    edk, edkN, ef, efN = v.split('.', 4).map { |f| base64decode(f) }

    dk = RbNaCl::SecretBox.new(ak).decrypt(edkN, edk)
    pv = RbNaCl::SecretBox.new(dk).decrypt(efN, ef)

    plain_record.data[k] = pv
  end

  plain_record
end

#delete(record_id, version = nil) ⇒ Nil

Delete a record from the E3DB storage service. If a version is provided and does not match, an E3DB::ConflicError exception will be raised.

Always returns +nil+.

Parameters:

  • record_id (String)

    unique ID of record to delete

  • version (String) (defaults to: nil)

    version ID that must match before deleting the record.

Returns:

  • (Nil)

    Always returns nil.



429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
# File 'lib/e3db/client.rb', line 429

def delete(record_id, version=nil)
  if version.nil?
    resp = @conn.delete(get_url('v1', 'storage', 'records', record_id))
  else
    begin
      resp = @conn.delete(get_url('v1', 'storage', 'records', 'safe', record_id, version))
    rescue Faraday::ClientError => e
      if e.response[:status] == 409
        raise E3DB::ConflictError, record_id
      else
        raise e   # re-raise on other failures
      end
    end
  end
  
  nil
end

#encrypt_existing(plain_record, eak) ⇒ Record

Encrypts an existing record. The record must contain plaintext values.

+plain_record+ should be a Record instance to encrypt. +eak+ should be an EAK instance.

Returns:

  • (Record)

    An instance containg the encrypted data.



709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
# File 'lib/e3db/client.rb', line 709

def encrypt_existing(plain_record, eak)
  cache_key = [plain_record.meta.writer_id, plain_record.meta.user_id, plain_record.meta.type]
  if ! @ak_cache.has_key? cache_key
    @ak_cache[cache_key] = { :eak => eak, :ak => decrypt_box(eak.eak, eak.authorizer_public_key.curve25519, @private_key) }
  end
  ak = @ak_cache[cache_key][:ak]

  encrypted_record = Record.new(meta: plain_record.meta, data: Hash.new)
  plain_record.data.each do |k, v|
    dk =   new_data_key
    efN =  secret_box_random_nonce
    ef =   RbNaCl::SecretBox.new(dk).encrypt(efN, v)

    edkN = secret_box_random_nonce
    edk =  RbNaCl::SecretBox.new(ak).encrypt(edkN, dk)

    encrypted_record.data[k] = [edk, edkN, ef, efN].map { |f| base64encode(f) }.join(".")
  end

  encrypted_record
end

#encrypt_record(type, data, plain, id, eak) ⇒ Record

Encrypt a new record consisting of the given data.

+type+ is a string giving the record type. +data+ should be a dictionary of string values. +plain+ should be a dictionary of string values. +id+ should be the ID of the client creating the record. +eak+ should be an EAK instance.

Returns:

  • (Record)

    An instance containing the encrypted data.



739
740
741
742
743
744
745
# File 'lib/e3db/client.rb', line 739

def encrypt_record(type, data, plain, id, eak)
  meta = Meta.new(record_id: nil, writer_id: id, user_id: id,
                  type: type, plain: plain, created: nil,
                  last_modified: nil, version: nil)

  encrypt_existing(Record.new(:meta => meta, :data => data), eak)
end

#get_reader_key(writer_id, user_id, type) ⇒ EAK

Retrieve a key for reading records shared with this client.

+writer_id+ is the ID of the client who wrote the shared records. +user_id+ is the ID of the user that the record pertains to. +type+ is the type of the shared record.

The value returned is encrypted such that it can only be used by this client.

Returns:



699
700
701
# File 'lib/e3db/client.rb', line 699

def get_reader_key(writer_id, user_id, type)
  get_eak(writer_id, user_id, type)
end

#incoming_sharingArray<IncomingSharingPolicy>

Gets a list of record types that others have shared with this client.

Returns:



660
661
662
663
664
665
# File 'lib/e3db/client.rb', line 660

def incoming_sharing
  url = get_url('v1', 'storage', 'policy', 'incoming')
  resp = @conn.get(url)
  json = JSON.parse(resp.body, symbolize_names: true)
  return json.map {|x| IncomingSharingPolicy.new(x)}
end

#outgoing_sharingArray<OutgoingSharingPolicy>

Gets a list of record types that this client has shared with others.

Returns:



649
650
651
652
653
654
# File 'lib/e3db/client.rb', line 649

def outgoing_sharing
  url = get_url('v1', 'storage', 'policy', 'outgoing')
  resp = @conn.get(url)
  json = JSON.parse(resp.body, symbolize_names: true)
  return json.map {|x| OutgoingSharingPolicy.new(x)}
end

#query(data: true, writer: nil, record: nil, type: nil, plain: nil, page_size: DEFAULT_QUERY_COUNT) ⇒ Result

Query E3DB records according to a set of selection criteria.

The default behavior is to return all records written by the current authenticated client.

To restrict the results to a particular type, pass a type or list of types as the type argument.

To restrict the results to a set of clients, pass a single or list of client IDs as the writer argument. To list records written by any client that has shared with the current client, pass the special token :any as the writer argument.

If a block is supplied, each record matching the query parameters is fetched from the server and yielded to the block.

If no block is supplied, a Result is returned that will iterate over the records matching the query parameters. This iterator is lazy and will query the server each time it is used, so calling Enumerable#to_a to convert to an array is recommended if multiple traversals are necessary.

Parameters:

  • writer (String, Array<String>, :all) (defaults to: nil)

    select records written by these client IDs or :all for all writers

  • record (String, Array<String>) (defaults to: nil)

    select records with these record IDs

  • type (String, Array<string>) (defaults to: nil)

    select records with these types

  • plain (Hash) (defaults to: nil)

    plaintext query expression to select

  • data (Boolean) (defaults to: true)

    include data in records

  • page_size (Integer) (defaults to: DEFAULT_QUERY_COUNT)

    number of records to fetch per request

Returns:

  • (Result)

    a result set object enumerating matched records



575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
# File 'lib/e3db/client.rb', line 575

def query(data: true, writer: nil, record: nil, type: nil, plain: nil, page_size: DEFAULT_QUERY_COUNT)
  all_writers = false
  if writer == :all
    all_writers = true
    writer = []
  end

  q = Query.new(after_index: 0, include_data: data, writer_ids: writer,
                record_ids: record, content_types: type, plain: plain,
                user_ids: nil, count: page_size,
                include_all_writers: all_writers)
  result = Result.new(self, q)
  if block_given?
    result.each do |rec|
      yield rec
    end
  else
    result
  end
end

#read(record_id, fields = nil) ⇒ Record

Read a single record by ID from E3DB and return it.

Parameters:

  • record_id (String)

    record ID to look up

  • fields (Array) (defaults to: nil)

    Optional array of fields to filter

Returns:

  • (Record)

    decrypted record object



345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/e3db/client.rb', line 345

def read(record_id, fields = nil)
  path = get_url('v1', 'storage', 'records', record_id)

  unless fields.nil?
    resp = @conn.get(path) do |req|
      req.options.params_encoder = Faraday::FlatParamsEncoder
      req.params['field'] = fields
    end
  else
    resp = @conn.get(path)
  end

  record = Record.new(JSON.parse(resp.body, symbolize_names: true))
  writer_id = record.meta.writer_id
  user_id = record.meta.user_id
  type = record.meta.type
  decrypt_record(record, get_eak(writer_id, user_id, type))
end

#revoke(type, reader_id) ⇒ Nil

Revoke another E3DB client’s access to records of a particular type.

Parameters:

  • type (String)

    type of records to revoke access to

  • reader_id (String)

    client ID of reader to revoke access from

Returns:

  • (Nil)

    Always returns nil.



630
631
632
633
634
635
636
637
638
639
640
641
642
643
# File 'lib/e3db/client.rb', line 630

def revoke(type, reader_id)
  if reader_id == @config.client_id
    return
  elsif reader_id.include? "@"
    reader_id = client_info(reader_id).client_id
  end

  id = @config.client_id
  url = get_url('v1', 'storage', 'policy', id, id, reader_id, type)
  @conn.put(url, JSON.generate({:deny => [{:read => {}}]}))

  delete_access_key(id, id, reader_id, type)
  nil
end

#share(type, reader_id) ⇒ Nil

Grant another E3DB client access to records of a particular type.

Parameters:

  • type (String)

    type of records to share

  • reader_id (String)

    client ID of reader to grant access to

Returns:

  • (Nil)

    Always returns nil.



601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
# File 'lib/e3db/client.rb', line 601

def share(type, reader_id)
  if reader_id == @config.client_id
    return
  elsif reader_id.include? "@"
    reader_id = client_info(reader_id).client_id
  end

  id = @config.client_id
  ak = get_access_key(id, id, type)

  begin
    put_access_key(id, id, reader_id, type, ak)
  rescue Faraday::ClientError => e
    # Ignore 403, means AK already exists.
    if e.response[:status] != 403
      raise e
    end
  end

  url = get_url('v1', 'storage', 'policy', id, id, reader_id, type)
  @conn.put(url, JSON.generate({:allow => [{:read => {}}]}))
  nil
end

#update(record) ⇒ Nil

Update an existing record in the E3DB storage service.

If the record has been modified by another client since it was read, this method raises E3DB::ConflictError, which should be caught by the caller so that the record can be re-fetched and the update retried.

The metadata of the input record will be updated in-place to reflect the new version number and modification time returned by the server.

Parameters:

  • record (Record)

    the record to update

Returns:

  • (Nil)

    Always returns nil.



399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'lib/e3db/client.rb', line 399

def update(record)
  record_id = record.meta.record_id
  version = record.meta.version
  url = get_url('v1', 'storage', 'records', 'safe', record_id, version)

  begin
    type = record.meta.type
    encrypted_record = encrypt_existing(record, get_eak(@config.client_id, @config.client_id, record.meta.type))
    resp = @conn.put(url, encrypted_record.to_hash)
    json = JSON.parse(resp.body, symbolize_names: true)
    record.meta = Meta.new(json[:meta])
    nil
  rescue Faraday::ClientError => e
    if e.response[:status] == 409
      raise E3DB::ConflictError, record
    else
      raise e   # re-raise on other failures
    end
 end
end

#write(type, data, plain = Hash.new) ⇒ Record

Write a new record to the E3DB storage service.

Parameters:

  • type (String)

    free-form content type name of this record

  • data (Hash<String, String>)

    record data to be stored encrypted

  • plain (Hash<String, String>) (defaults to: Hash.new)

    record data to be stored unencrypted for querying

Returns:

  • (Record)

    the newly created record object (with decrypted values).



370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
# File 'lib/e3db/client.rb', line 370

def write(type, data, plain=Hash.new)
  url = get_url('v1', 'storage', 'records')
  id = @config.client_id

  begin
    eak = get_eak(id, id, type)
  rescue Faraday::ClientError => e
    if e.response[:status] == 404
      eak = create_writer_key(type)
    else
      raise e
    end
  end

  resp = @conn.post(url, encrypt_record(type, data, plain, id, eak).to_hash)
  decrypt_record(resp.body, eak)
end