class E3DB::Client

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

@!attribute [r] config

@return [Config] the client configuration object

Constants

DEFAULT_QUERY_COUNT

Attributes

config[R]

Public Class Methods

generate_keypair() click to toggle source

Generate a random Curve25519 keypair.

@return [Array<String>] A two element array containing the public and private keys (respectively) for the new keypair.

# 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
new(config) click to toggle source

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

# 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
register(registration_token, client_name, public_key, private_key=nil, backup=false, api_url=E3DB::DEFAULT_API_URL) click to toggle source

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

# 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

Public Instance Methods

backup(client_id, registration_token) click to toggle source

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.

# 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) click to toggle source

Query the server for information about an E3DB client.

@param client_id [String] client ID to look up @return [ClientInfo] information about this client

# 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) click to toggle source

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

# 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) click to toggle source

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]

# 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) click to toggle source

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.

# 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) click to toggle source

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.

# 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) click to toggle source

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.

# 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) click to toggle source

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.

# 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) click to toggle source

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]

# 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_sharing() click to toggle source

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

@return [Array<IncomingSharingPolicy>]

# 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_sharing() click to toggle source

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

@return [Array<OutgoingSharingPolicy>]

# 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) { |rec| ... } click to toggle source

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

# 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) click to toggle source

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

# 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) click to toggle source

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.

# 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) click to toggle source

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.

# 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) click to toggle source

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.

# 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) click to toggle source

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

# 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

Private Instance Methods

delete_access_key(writer_id, user_id, reader_id, type) click to toggle source

Delete the access key for the given combination of writer, user, reader and type.

Returns nil in all cases.

# File lib/e3db/client.rb, line 858
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
get_access_key(writer_id, user_id, type) click to toggle source

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.

# File lib/e3db/client.rb, line 792
def get_access_key(writer_id, user_id, type)
  get_cached_key(writer_id, user_id, type)[:ak]
end
get_cached_key(writer_id, user_id, type) click to toggle source

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

# File lib/e3db/client.rb, line 803
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
get_eak(writer_id, user_id, type) click to toggle source

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.

# File lib/e3db/client.rb, line 783
def get_eak(writer_id, user_id, type)
  get_cached_key(writer_id, user_id, type)[:eak]
end
get_url(*paths) click to toggle source
# File lib/e3db/client.rb, line 875
def get_url(*paths)
  "#{@config.api_url.chomp('/')}/#{ paths.map { |x| CGI.escape x }.join('/')}"
end
put_access_key(writer_id, user_id, reader_id, type, ak) click to toggle source

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.

# File lib/e3db/client.rb, line 825
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
query1(query) click to toggle source

Fetch a single page of query results. Used internally by {Client#query}.

# File lib/e3db/client.rb, line 869
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