Class: E3DB::Client
- Inherits:
-
Object
- Object
- E3DB::Client
- 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
-
#config ⇒ Config
readonly
The client configuration object.
Class Method Summary collapse
-
.generate_keypair ⇒ Array<String>
Generate a random Curve25519 keypair.
-
.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.
Instance Method Summary collapse
-
#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.
-
#client_info(client_id) ⇒ ClientInfo
Query the server for information about an E3DB client.
-
#client_key(client_id) ⇒ RbNaCl::PublicKey
Query the server for a client’s public key.
-
#create_writer_key(type) ⇒ EAK
Create and return a key for encrypting the given record type.
-
#decrypt_record(encrypted_record, eak) ⇒ Record
Decrypts a record using the given secret key.
-
#delete(record_id, version = nil) ⇒ Nil
Delete a record from the E3DB storage service.
-
#encrypt_existing(plain_record, eak) ⇒ Record
Encrypts an existing record.
-
#encrypt_record(type, data, plain, id, eak) ⇒ Record
Encrypt a new record consisting of the given data.
-
#get_reader_key(writer_id, user_id, type) ⇒ EAK
Retrieve a key for reading records shared with this client.
-
#incoming_sharing ⇒ Array<IncomingSharingPolicy>
Gets a list of record types that others have shared with this client.
-
#initialize(config) ⇒ Client
constructor
Create a connection to the E3DB service given a configuration.
-
#outgoing_sharing ⇒ Array<OutgoingSharingPolicy>
Gets a list of record types that this client has shared with others.
-
#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.
-
#read(record_id, fields = nil) ⇒ Record
Read a single record by ID from E3DB and return it.
-
#revoke(type, reader_id) ⇒ Nil
Revoke another E3DB client’s access to records of a particular type.
-
#share(type, reader_id) ⇒ Nil
Grant another E3DB client access to records of a particular type.
-
#update(record) ⇒ Nil
Update an existing record in the E3DB storage service.
-
#write(type, data, plain = Hash.new) ⇒ Record
Write a new record to the E3DB storage service.
Constructor Details
#initialize(config) ⇒ Client
Create a connection to the E3DB service given a configuration.
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
#config ⇒ Config (readonly)
Returns 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..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..writer_id user_id = record..user_id type = record..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..record_id version = record..version url = get_url('v1', 'storage', 'records', 'safe', record_id, version) begin type = record..type encrypted_record = encrypt_existing(record, get_eak(@config.client_id, @config.client_id, record..type)) resp = @conn.put(url, encrypted_record.to_hash) json = JSON.parse(resp.body, symbolize_names: true) record. = 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..writer_id, plain_record..user_id, plain_record..type] if ! @ak_cache.has_key? cache_key @ak_cache[cache_key] = { :eak => eak, :ak => decrypt_box(eak.eak, eak..curve25519, @private_key) } end ak = @ak_cache[cache_key][:ak] encrypted_record = Record.new(meta: plain_record., 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.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 => , :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..writer_id, encrypted_record..user_id, encrypted_record..type] if ! @ak_cache.has_key? cache_key @ak_cache[cache_key] = { :eak => eak, :ak => decrypt_box(eak.eak, eak..curve25519, @private_key) } end ak = @ak_cache[cache_key][:ak] plain_record = Record.new(data: Hash.new, meta: Meta.new(encrypted_record.)) 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_keypair ⇒ Array<String>
Generate a random Curve25519 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
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.
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.
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.
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.
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
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..writer_id, encrypted_record..user_id, encrypted_record..type] if ! @ak_cache.has_key? cache_key @ak_cache[cache_key] = { :eak => eak, :ak => decrypt_box(eak.eak, eak..curve25519, @private_key) } end ak = @ak_cache[cache_key][:ak] plain_record = Record.new(data: Hash.new, meta: Meta.new(encrypted_record.)) 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+.
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
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..writer_id, plain_record..user_id, plain_record..type] if ! @ak_cache.has_key? cache_key @ak_cache[cache_key] = { :eak => eak, :ak => decrypt_box(eak.eak, eak..curve25519, @private_key) } end ak = @ak_cache[cache_key][:ak] encrypted_record = Record.new(meta: plain_record., 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.
739 740 741 742 743 744 745 |
# File 'lib/e3db/client.rb', line 739 def encrypt_record(type, data, plain, id, eak) = 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 => , :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.
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_sharing ⇒ Array<IncomingSharingPolicy>
Gets a list of record types that others have shared with this client.
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_sharing ⇒ Array<OutgoingSharingPolicy>
Gets a list of record types that this client has shared with others.
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.
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.
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..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..writer_id user_id = record..user_id type = record..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.
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.
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.
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..record_id version = record..version url = get_url('v1', 'storage', 'records', 'safe', record_id, version) begin type = record..type encrypted_record = encrypt_existing(record, get_eak(@config.client_id, @config.client_id, record..type)) resp = @conn.put(url, encrypted_record.to_hash) json = JSON.parse(resp.body, symbolize_names: true) record. = 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.
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 |