e3db 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +142 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/e3db.gemspec +32 -0
- data/lib/e3db.rb +20 -0
- data/lib/e3db/client.rb +316 -0
- data/lib/e3db/config.rb +87 -0
- data/lib/e3db/crypto.rb +139 -0
- data/lib/e3db/types.rb +10 -0
- data/lib/e3db/version.rb +3 -0
- metadata +212 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 8130f8d97af89a3b0dadd56a137f215950df2ff6
|
4
|
+
data.tar.gz: 1d8c7ea1c68e443417fe8426ae742e3546f3e378
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 40e554e311c5c25c8ea9eeffb2a33c0a27073b9d852ae23895b2577f0d279e909c6401eccc60b0725c0e6a395f558b6059a80801b3a4c65566f4f14795c6ede4
|
7
|
+
data.tar.gz: ae4c58bacbeba5924d47a181b2d9e9f179bbdda47bc5987edc3a4baaa8e5b47dc19be87b206042d8832747bf813a9e2468c6414c3063715937a71629e2f591b6
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (C) 2017, Tozny, LLC.
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,142 @@
|
|
1
|
+
|
2
|
+
# Introduction
|
3
|
+
|
4
|
+
The Tozny End-to-End Encrypted Database (E3DB) is a storage platform
|
5
|
+
with powerful sharing and consent management features.
|
6
|
+
[Read more on our blog.](https://tozny.com/blog/announcing-project-e3db-the-end-to-end-encrypted-database/)
|
7
|
+
|
8
|
+
E3DB provides a familiar JSON-based NoSQL-style API for reading, writing,
|
9
|
+
and querying data stored securely in the cloud.
|
10
|
+
|
11
|
+
# Installation
|
12
|
+
|
13
|
+
Add this line to your application's Gemfile:
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
gem 'e3db'
|
17
|
+
```
|
18
|
+
|
19
|
+
And then execute:
|
20
|
+
|
21
|
+
$ bundle
|
22
|
+
|
23
|
+
Or install it yourself as:
|
24
|
+
|
25
|
+
$ gem install e3db
|
26
|
+
|
27
|
+
At runtime, you will need the `libsodium` cryptography library
|
28
|
+
required by the native RbNaCl Ruby library. On most platforms
|
29
|
+
a package is available by default:
|
30
|
+
|
31
|
+
```shell
|
32
|
+
$ brew install libsodium (Mac OS X)
|
33
|
+
$ apt-get install libsodium-dev (Ubuntu)
|
34
|
+
```
|
35
|
+
|
36
|
+
For more information including libsodium installation instructions
|
37
|
+
for Windows, see the [libsodium web site](https://download.libsodium.org/doc/installation/).
|
38
|
+
|
39
|
+
_Windows Users:_ Make sure to download a recent "MSVC" build. Once downloaded, find the most recent `libsodium.dll` inside the ZIP file and copy it to somewhere in your `PATH`.
|
40
|
+
|
41
|
+
## Registering a client
|
42
|
+
|
43
|
+
1. Download and install the E3DB Command-Line interface (CLI) from our
|
44
|
+
[GitHub releases page](https://github.com/tozny/e3db-go/releases).
|
45
|
+
|
46
|
+
2. Register an account using the CLI:
|
47
|
+
|
48
|
+
```shell
|
49
|
+
$ e3db register me@mycompany.com
|
50
|
+
```
|
51
|
+
|
52
|
+
This will create a new default configuration with a randomly
|
53
|
+
generated key pair and API credentials, saving it in `$HOME/.tozny/e3db.json`.
|
54
|
+
|
55
|
+
## Loading configuration and creating a client
|
56
|
+
|
57
|
+
Use the `E3DB::Config.default` class method to load the default
|
58
|
+
client configuration, and pass it to the `E3DB::Client` constructor:
|
59
|
+
|
60
|
+
```ruby
|
61
|
+
require 'e3db'
|
62
|
+
config = E3DB::Config.default
|
63
|
+
client = E3DB::Client.new(config)
|
64
|
+
```
|
65
|
+
|
66
|
+
### Using profiles to manage multiple configurations
|
67
|
+
|
68
|
+
The E3DB Command-Line Interface allows you to register and manage
|
69
|
+
multiple keys and credentials using _profiles_. To register a new
|
70
|
+
client under a different profile:
|
71
|
+
|
72
|
+
```shell
|
73
|
+
$ e3db register --profile=development developers@mycompany.com
|
74
|
+
```
|
75
|
+
|
76
|
+
You can then use `E3DB::Config.load_profile` to load a specific profile
|
77
|
+
inside your Ruby application:
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
config = E3DB::Config.load_profile('development')
|
81
|
+
client = E3DB::Client.new(config)
|
82
|
+
```
|
83
|
+
|
84
|
+
## Writing a record
|
85
|
+
|
86
|
+
To write new records to the database, first create a blank record
|
87
|
+
of the correct type using `E3DB::Client#new_record`. Then fill in
|
88
|
+
the fields of the record's `data` hash. Finally, write the record
|
89
|
+
to the database with `E3DB::Client#write`, which returns the
|
90
|
+
unique ID of the newly created record.
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
record = client.new_record('contact')
|
94
|
+
record.data[:first_name] = 'Jon'
|
95
|
+
record.data[:last_name] = 'Snow'
|
96
|
+
record.data[:phone] = '555-555-1212'
|
97
|
+
record_id = client.write(record)
|
98
|
+
printf("Wrote record %s\n", record_id)
|
99
|
+
```
|
100
|
+
|
101
|
+
## Querying Records
|
102
|
+
|
103
|
+
E3DB supports many options for querying records based on the fields
|
104
|
+
stored in record metadata. Refer to the API documentation for the
|
105
|
+
complete set of options that can be passed to `E3DB::Client#query`.
|
106
|
+
|
107
|
+
For example, to list all records of type `contact` and print a
|
108
|
+
simple report containing names and phone numbers:
|
109
|
+
|
110
|
+
```ruby
|
111
|
+
client.query(type: 'contact') do |record|
|
112
|
+
fullname = record.data[:first_name] + ' ' + record.data[:last_name]
|
113
|
+
printf("%-40s %s\n", fullname, record.data[:phone])
|
114
|
+
end
|
115
|
+
```
|
116
|
+
|
117
|
+
## Development
|
118
|
+
|
119
|
+
Before running tests, register an `integration-test` profile using
|
120
|
+
the E3DB command-line tool:
|
121
|
+
|
122
|
+
```shell
|
123
|
+
$ e3db -p integration-test register me+test@mycompany.com
|
124
|
+
```
|
125
|
+
|
126
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then,
|
127
|
+
run `rake spec` to run the tests. You can also run `bin/console` for an
|
128
|
+
interactive prompt that will allow you to experiment.
|
129
|
+
|
130
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
131
|
+
To release a new version, update the version number in `version.rb`, and
|
132
|
+
then run `bundle exec rake release`, which will create a git tag for the
|
133
|
+
version, push git commits and tags, and push the `.gem` file to
|
134
|
+
[rubygems.org](https://rubygems.org).
|
135
|
+
|
136
|
+
## Contributing
|
137
|
+
|
138
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/tozny/e3db-ruby.
|
139
|
+
|
140
|
+
## License
|
141
|
+
|
142
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "e3db"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/e3db.gemspec
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'e3db/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "e3db"
|
8
|
+
spec.version = E3DB::VERSION
|
9
|
+
spec.authors = ["Tozny, LLC"]
|
10
|
+
spec.email = ["info@tozny.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{e3db client SDK}
|
13
|
+
spec.homepage = "https://tozny.com/e3db"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
17
|
+
spec.bindir = "exe"
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_development_dependency "bundler", "~> 1.12"
|
22
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
23
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
24
|
+
spec.add_development_dependency 'simplecov', '~> 0.14.1'
|
25
|
+
|
26
|
+
spec.add_dependency 'dry-struct', '~> 0.2.1'
|
27
|
+
spec.add_dependency 'lru_redux', '~> 1.1'
|
28
|
+
spec.add_dependency 'rbnacl', '~> 4.0', '>= 4.0.2'
|
29
|
+
spec.add_dependency 'net-http-persistent', '~> 2.9.4'
|
30
|
+
spec.add_dependency 'faraday_middleware', '~> 0.11.0.1'
|
31
|
+
spec.add_dependency 'oauth2', '~> 1.3', '>= 1.3.1'
|
32
|
+
end
|
data/lib/e3db.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
#
|
2
|
+
# e3db.rb
|
3
|
+
#
|
4
|
+
# Copyright (C) 2017, Tozny, LLC.
|
5
|
+
# All Rights Reserved.
|
6
|
+
#
|
7
|
+
|
8
|
+
|
9
|
+
require 'e3db/version'
|
10
|
+
require 'e3db/types'
|
11
|
+
require 'e3db/config'
|
12
|
+
require 'e3db/client'
|
13
|
+
require 'e3db/crypto'
|
14
|
+
|
15
|
+
# Ruby client library for the Tozny End-to-End Encrypted Database (E3DB) service.
|
16
|
+
#
|
17
|
+
# @author Tozny, LLC.
|
18
|
+
# @version 1.0.0
|
19
|
+
module E3DB
|
20
|
+
end
|
data/lib/e3db/client.rb
ADDED
@@ -0,0 +1,316 @@
|
|
1
|
+
#
|
2
|
+
# client.rb --- E3DB API client.
|
3
|
+
#
|
4
|
+
# Copyright (C) 2017, Tozny, LLC.
|
5
|
+
# All Rights Reserved.
|
6
|
+
#
|
7
|
+
|
8
|
+
|
9
|
+
require 'faraday'
|
10
|
+
require 'faraday_middleware'
|
11
|
+
require 'oauth2'
|
12
|
+
require 'rbnacl'
|
13
|
+
require 'base64'
|
14
|
+
require 'lru_redux'
|
15
|
+
|
16
|
+
module E3DB
|
17
|
+
# Faraday middleware to automatically refresh authentication tokens and
|
18
|
+
# pass them to API requests.
|
19
|
+
class TokenHelper < Faraday::Middleware
|
20
|
+
def initialize(app, client)
|
21
|
+
super(app)
|
22
|
+
@client = client
|
23
|
+
@token = nil
|
24
|
+
end
|
25
|
+
|
26
|
+
def call(env)
|
27
|
+
if @token.nil? or @token.expired?
|
28
|
+
@token = @client.client_credentials.get_token
|
29
|
+
end
|
30
|
+
|
31
|
+
env[:request_headers]['Authorization'] ||= %(Bearer #{@token.token})
|
32
|
+
@app.call env
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private_constant :TokenHelper
|
37
|
+
|
38
|
+
# A client's public key information.
|
39
|
+
#
|
40
|
+
# @!attribute curve25519
|
41
|
+
# @return [String] a Base64URL Encoded Curve25519 public key
|
42
|
+
class PublicKey < Dry::Struct
|
43
|
+
attribute :curve25519, Types::Strict::String
|
44
|
+
end
|
45
|
+
|
46
|
+
# Information sent by the E3DB service about a client.
|
47
|
+
#
|
48
|
+
# @!attribute client_id
|
49
|
+
# @return [String] the client's unique ID string
|
50
|
+
# @!attribute public_key
|
51
|
+
# @return [PublicKey] the client's public key information
|
52
|
+
class ClientInfo < Dry::Struct
|
53
|
+
attribute :client_id, Types::Strict::String
|
54
|
+
attribute :public_key, PublicKey
|
55
|
+
attribute :validated, Types::Strict::Bool
|
56
|
+
end
|
57
|
+
|
58
|
+
# Meta-information about an E3DB record, such as who wrote it,
|
59
|
+
# when it was written, and the type of data stored.
|
60
|
+
#
|
61
|
+
# @!attribute record_id
|
62
|
+
# @return [String,nil] the unique ID of this record, or nil if not yet written
|
63
|
+
# @!attribute writer_id
|
64
|
+
# @return [String] the client ID that wrote this record
|
65
|
+
# @!attribute user_id
|
66
|
+
# @return [String] the subject client ID (currently == writer_id)
|
67
|
+
# @!attribute type
|
68
|
+
# @return [String] a free-form description of record content type
|
69
|
+
# @!attribute plain
|
70
|
+
# @return [Hash<String, String>] this record's plaintext record metadata
|
71
|
+
# @!attribute created
|
72
|
+
# @return [Time, nil] when this record was created, or nil if unavailable
|
73
|
+
# @!attribute last_modified
|
74
|
+
# @return [Time, nil] when this record was last modified, or nil if unavailable
|
75
|
+
class Meta < Dry::Struct
|
76
|
+
attribute :record_id, Types::Strict::String.optional
|
77
|
+
attribute :writer_id, Types::Strict::String
|
78
|
+
attribute :user_id, Types::Strict::String
|
79
|
+
attribute :type, Types::Strict::String
|
80
|
+
attribute :plain, Types::Strict::Hash.default { Hash.new }
|
81
|
+
attribute :created, Types::Json::DateTime.optional
|
82
|
+
attribute :last_modified, Types::Json::DateTime.optional
|
83
|
+
end
|
84
|
+
|
85
|
+
# A E3DB record containing data and metadata. Records are
|
86
|
+
# a key/value mapping containing data serialized
|
87
|
+
# into strings. All records are encrypted prior to sending them
|
88
|
+
# to the server for storage, and decrypted in the client after
|
89
|
+
# they are read.
|
90
|
+
#
|
91
|
+
# The {Client#new_record} method should be used to create a
|
92
|
+
# new record that can be written to the database with
|
93
|
+
# {Client#write}.
|
94
|
+
#
|
95
|
+
# To read a record by their unique ID, use {Client#read}, or to
|
96
|
+
# query a set of records based on their attributes, use {Client#query}.
|
97
|
+
#
|
98
|
+
# @!attribute meta
|
99
|
+
# @return [Meta] meta-information about this record
|
100
|
+
# @!attribute data
|
101
|
+
# @return [Hash<String, String>] this record's application-specific data
|
102
|
+
class Record < Dry::Struct
|
103
|
+
attribute :meta, Meta
|
104
|
+
attribute :data, Types::Strict::Hash.default { Hash.new }
|
105
|
+
end
|
106
|
+
|
107
|
+
# A connection to the E3DB service used to perform database operations.
|
108
|
+
#
|
109
|
+
# @!attribute [r] config
|
110
|
+
# @return [Config] the client configuration object
|
111
|
+
class Client
|
112
|
+
attr_reader :config
|
113
|
+
|
114
|
+
# Create a connection to the E3DB service given a configuration.
|
115
|
+
#
|
116
|
+
# @param config [Config] configuration and credentials to use
|
117
|
+
# @return [Client] a connection to the E3DB service
|
118
|
+
def initialize(config)
|
119
|
+
@config = config
|
120
|
+
@public_key = RbNaCl::PublicKey.new(Crypto.base64decode(@config.public_key))
|
121
|
+
@private_key = RbNaCl::PrivateKey.new(Crypto.base64decode(@config.private_key))
|
122
|
+
|
123
|
+
@ak_cache = LruRedux::ThreadSafeCache.new(1024)
|
124
|
+
@oauth_client = OAuth2::Client.new(
|
125
|
+
config.api_key_id,
|
126
|
+
config.api_secret,
|
127
|
+
:site => config.api_url,
|
128
|
+
:token_url => '/v1/auth/token',
|
129
|
+
:auth_scheme => :basic_auth,
|
130
|
+
:raise_errors => false)
|
131
|
+
|
132
|
+
if config.logging
|
133
|
+
@oauth_client.connection.response :logger, ::Logger.new($stdout)
|
134
|
+
end
|
135
|
+
|
136
|
+
@conn = Faraday.new(DEFAULT_API_URL) do |faraday|
|
137
|
+
faraday.use TokenHelper, @oauth_client
|
138
|
+
faraday.request :json
|
139
|
+
faraday.response :raise_error
|
140
|
+
if config.logging
|
141
|
+
faraday.response :logger, nil, :bodies => true
|
142
|
+
end
|
143
|
+
faraday.adapter :net_http_persistent
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Query the server for information about an E3DB client.
|
148
|
+
#
|
149
|
+
# @param client_id [String] client ID to look up
|
150
|
+
# @return [ClientInfo] information about this client
|
151
|
+
def client_info(client_id)
|
152
|
+
resp = @conn.get(get_url('v1', 'storage', 'clients', client_id))
|
153
|
+
ClientInfo.new(JSON.parse(resp.body, symbolize_names: true))
|
154
|
+
end
|
155
|
+
|
156
|
+
# Query the server for a client's public key.
|
157
|
+
#
|
158
|
+
# @param client_id [String] client ID to look up
|
159
|
+
# @return [RbNaCl::PublicKey] decoded Curve25519 public key
|
160
|
+
def client_key(client_id)
|
161
|
+
if client_id == @config.client_id
|
162
|
+
@public_key
|
163
|
+
else
|
164
|
+
Crypto.decode_public_key(client_info(client_id).public_key.curve25519)
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Read a single record by ID from E3DB and return it without
|
169
|
+
# decrypting the data fields.
|
170
|
+
#
|
171
|
+
# @param record_id [String] record ID to look up
|
172
|
+
# @return [Record] encrypted record object
|
173
|
+
def read_raw(record_id)
|
174
|
+
resp = @conn.get(get_url('v1', 'storage', 'records', record_id))
|
175
|
+
json = JSON.parse(resp.body, symbolize_names: true)
|
176
|
+
Record.new(json)
|
177
|
+
end
|
178
|
+
|
179
|
+
# Read a single record by ID from E3DB and return it.
|
180
|
+
#
|
181
|
+
# @param record_id [String] record ID to look up
|
182
|
+
# @return [Record] decrypted record object
|
183
|
+
def read(record_id)
|
184
|
+
decrypt_record(read_raw(record_id))
|
185
|
+
end
|
186
|
+
|
187
|
+
# Create a new, empty record that can be written to E3DB
|
188
|
+
# by calling {Client#write}.
|
189
|
+
#
|
190
|
+
# @param type [String] free-form content type of this record
|
191
|
+
# @return [Record] an empty record of `type`
|
192
|
+
def new_record(type)
|
193
|
+
id = @config.client_id
|
194
|
+
meta = Meta.new(record_id: nil, writer_id: id, user_id: id,
|
195
|
+
type: type, plain: Hash.new, created: nil,
|
196
|
+
last_modified: nil)
|
197
|
+
Record.new(meta: meta, data: Hash.new)
|
198
|
+
end
|
199
|
+
|
200
|
+
# Write a new record to the E3DB storage service.
|
201
|
+
#
|
202
|
+
# Create new records with {Client#new_record}.
|
203
|
+
#
|
204
|
+
# @param record [Record] record to write
|
205
|
+
# @return [String] the unique ID of the written record
|
206
|
+
def write(record)
|
207
|
+
url = get_url('v1', 'storage', 'records')
|
208
|
+
resp = @conn.post(url, encrypt_record(record).to_hash)
|
209
|
+
json = JSON.parse(resp.body, symbolize_names: true)
|
210
|
+
json[:meta][:record_id]
|
211
|
+
end
|
212
|
+
|
213
|
+
# Delete a record from the E3DB storage service.
|
214
|
+
#
|
215
|
+
# @param record_id [String] unique ID of record to delete
|
216
|
+
def delete(record_id)
|
217
|
+
resp = @conn.delete(get_url('v1', 'storage', 'records', record_id))
|
218
|
+
end
|
219
|
+
|
220
|
+
class Query < Dry::Struct
|
221
|
+
attribute :count, Types::Int
|
222
|
+
attribute :include_data, Types::Bool.optional
|
223
|
+
attribute :writer_ids, Types::Coercible::Array.member(Types::String).optional
|
224
|
+
attribute :user_ids, Types::Coercible::Array.member(Types::String).optional
|
225
|
+
attribute :record_ids, Types::Coercible::Array.member(Types::String).optional
|
226
|
+
attribute :content_types, Types::Coercible::Array.member(Types::String).optional
|
227
|
+
attribute :plain, Types::Hash.optional
|
228
|
+
attribute :after_index, Types::Int.optional
|
229
|
+
|
230
|
+
def after_index=(index)
|
231
|
+
@after_index = index
|
232
|
+
end
|
233
|
+
|
234
|
+
def as_json
|
235
|
+
JSON.generate(to_hash.reject { |k, v| v.nil? })
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
private_constant :Query
|
240
|
+
|
241
|
+
DEFAULT_QUERY_COUNT = 100
|
242
|
+
private_constant :DEFAULT_QUERY_COUNT
|
243
|
+
|
244
|
+
# Query E3DB records according to a set of selection criteria.
|
245
|
+
#
|
246
|
+
# Each record (optionally including data) is yielded to the block
|
247
|
+
# argument.
|
248
|
+
#
|
249
|
+
# @param writer [String,Array<String>] select records written by these client IDs
|
250
|
+
# @param record [String,Array<String>] select records with these record IDs
|
251
|
+
# @param type [String,Array<string>] select records with these types
|
252
|
+
# @param plain [Hash] plaintext query expression to select
|
253
|
+
# @param data [Boolean] include data in records
|
254
|
+
# @param raw [Boolean] when true don't decrypt record data
|
255
|
+
def query(data: true, raw: false, writer: nil, record: nil, type: nil, plain: nil)
|
256
|
+
q = Query.new(after_index: 0, include_data: data, writer_ids: writer,
|
257
|
+
record_ids: record, content_types: type, plain: plain,
|
258
|
+
user_ids: nil, count: DEFAULT_QUERY_COUNT)
|
259
|
+
url = get_url('v1', 'storage', 'search')
|
260
|
+
loop do
|
261
|
+
resp = @conn.post(url, q.as_json)
|
262
|
+
json = JSON.parse(resp.body, symbolize_names: true)
|
263
|
+
results = json[:results]
|
264
|
+
results.each do |r|
|
265
|
+
record = Record.new(meta: r[:meta], data: r[:record_data] || Hash.new)
|
266
|
+
if data && !raw
|
267
|
+
record = decrypt_record(record)
|
268
|
+
end
|
269
|
+
yield record
|
270
|
+
end
|
271
|
+
|
272
|
+
if results.length < q.count
|
273
|
+
break
|
274
|
+
end
|
275
|
+
|
276
|
+
q.after_index = json[:last_index]
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
# Grant another E3DB client access to records of a particular type.
|
281
|
+
#
|
282
|
+
# @param type [String] type of records to share
|
283
|
+
# @param reader_id [String] client ID of reader to grant access to
|
284
|
+
def share(type, reader_id)
|
285
|
+
if reader_id == @config.client_id
|
286
|
+
return
|
287
|
+
end
|
288
|
+
|
289
|
+
id = @config.client_id
|
290
|
+
ak = get_access_key(id, id, id, type)
|
291
|
+
put_access_key(id, id, reader_id, type, ak)
|
292
|
+
|
293
|
+
url = get_url('v1', 'storage', 'policy', id, id, reader_id, type)
|
294
|
+
@conn.put(url, JSON.generate({:allow => [{:read => {}}]}))
|
295
|
+
end
|
296
|
+
|
297
|
+
# Revoke another E3DB client's access to records of a particular type.
|
298
|
+
#
|
299
|
+
# @param type [String] type of records to revoke access to
|
300
|
+
# @param reader_id [String] client ID of reader to revoke access from
|
301
|
+
def revoke(type, reader_id)
|
302
|
+
if reader_id == @config.client_id
|
303
|
+
return
|
304
|
+
end
|
305
|
+
|
306
|
+
id = @config.client_id
|
307
|
+
url = get_url('v1', 'storage', 'policy', id, id, reader_id, type)
|
308
|
+
@conn.put(url, JSON.generate({:deny => [{:read => {}}]}))
|
309
|
+
end
|
310
|
+
|
311
|
+
private
|
312
|
+
def get_url(*paths)
|
313
|
+
sprintf('%s/%s', @config.api_url.chomp('/'), paths.map { |x| URI.escape x }.join('/'))
|
314
|
+
end
|
315
|
+
end
|
316
|
+
end
|
data/lib/e3db/config.rb
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
#
|
2
|
+
# config.rb --- E3DB configuration files.
|
3
|
+
#
|
4
|
+
# Copyright (C) 2017, Tozny, LLC.
|
5
|
+
# All Rights Reserved.
|
6
|
+
#
|
7
|
+
|
8
|
+
|
9
|
+
module E3DB
|
10
|
+
DEFAULT_API_URL = 'https://dev.e3db.com/'
|
11
|
+
|
12
|
+
# Configuration and credentials for E3DB.
|
13
|
+
#
|
14
|
+
# Typically a configuration is loaded from a JSON file generated
|
15
|
+
# during registration via the E3DB administration console
|
16
|
+
# or command-line tool. To load a configuration from a JSON file,
|
17
|
+
# use {Config.load}.
|
18
|
+
#
|
19
|
+
# @!attribute version
|
20
|
+
# @return [Int] the version number of the configuration format (currently 1)
|
21
|
+
# @!attribute client_id
|
22
|
+
# @return [String] the client's unique client identifier
|
23
|
+
# @!attribute api_key_id
|
24
|
+
# @return [String] the client's non-secret API key component
|
25
|
+
# @!attribute api_secret
|
26
|
+
# @return [String] the client's confidential API key component
|
27
|
+
# @!attribute public_key
|
28
|
+
# @return [String] the client's Base64URL encoded Curve25519 public key
|
29
|
+
# @!attribute private_key
|
30
|
+
# @return [String] the client's Base64URL encoded Curve25519 private key
|
31
|
+
# @!attribute api_base_url
|
32
|
+
# @return [String] the base URL for the E3DB API service
|
33
|
+
# @!attribute auth_base_url
|
34
|
+
# @return [String] the base URL for the E3DB authentication service
|
35
|
+
# @!attribute logging
|
36
|
+
# _Warning:_ Log output will contain confidential authentication
|
37
|
+
# tokens---do not enable in production if log output isn't confidential!
|
38
|
+
# @return [Boolean] a flag to enable HTTP logging when true
|
39
|
+
class Config < Dry::Struct
|
40
|
+
attribute :version, Types::Int
|
41
|
+
attribute :client_id, Types::String
|
42
|
+
attribute :api_key_id, Types::String
|
43
|
+
attribute :api_secret, Types::String
|
44
|
+
attribute :public_key, Types::String
|
45
|
+
attribute :private_key, Types::String
|
46
|
+
attribute :api_url, Types::String.default(DEFAULT_API_URL)
|
47
|
+
attribute :logging, Types::Bool
|
48
|
+
|
49
|
+
# Load configuration from a JSON file created during registration
|
50
|
+
# or with {Config.save}.
|
51
|
+
#
|
52
|
+
# The configuration file should contain a single JSON object
|
53
|
+
# with the following structure:
|
54
|
+
#
|
55
|
+
# {
|
56
|
+
# "version": 1,
|
57
|
+
# "client_id": "UUID",
|
58
|
+
# "api_key_id": "API_KEY",
|
59
|
+
# "api_secret": "API_SECRET",
|
60
|
+
# "public_key": "PUBLIC_KEY",
|
61
|
+
# "private_key": "PRIVATE_KEY",
|
62
|
+
# "api_url": "URL",
|
63
|
+
# }
|
64
|
+
#
|
65
|
+
# @param filename [String] pathname of JSON configuration to load
|
66
|
+
# @return [Config] the configuration object loaded from the file
|
67
|
+
def self.load(filename)
|
68
|
+
json = JSON.parse(File.read(filename), symbolize_names: true)
|
69
|
+
if json[:version] != 1
|
70
|
+
raise StandardError, "Unsupported config version: #{json[:version]}"
|
71
|
+
end
|
72
|
+
Config.new(json.merge(:logging => false))
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.default
|
76
|
+
return self.load(File.join(Dir.home, '.tozny', 'e3db.json'))
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.load_profile(profile)
|
80
|
+
return self.load(File.join(Dir.home, '.tozny', profile, 'e3db.json'))
|
81
|
+
end
|
82
|
+
|
83
|
+
def logging=(value)
|
84
|
+
@logging = value
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
data/lib/e3db/crypto.rb
ADDED
@@ -0,0 +1,139 @@
|
|
1
|
+
#
|
2
|
+
# crypto.rb --- E3DB cryptographic operations.
|
3
|
+
#
|
4
|
+
# Copyright (C) 2017, Tozny, LLC.
|
5
|
+
# All Rights Reserved.
|
6
|
+
#
|
7
|
+
|
8
|
+
|
9
|
+
module E3DB
|
10
|
+
class Client
|
11
|
+
private
|
12
|
+
def get_access_key(writer_id, user_id, reader_id, type)
|
13
|
+
ak_cache_key = [writer_id, user_id, type]
|
14
|
+
if @ak_cache.key? ak_cache_key
|
15
|
+
return @ak_cache[ak_cache_key]
|
16
|
+
end
|
17
|
+
|
18
|
+
url = get_url('v1', 'storage', 'access_keys', writer_id, user_id, reader_id, type)
|
19
|
+
resp = @conn.get(url)
|
20
|
+
json = JSON.parse(resp.body, symbolize_names: true)
|
21
|
+
|
22
|
+
k = json[:authorizer_public_key][:curve25519]
|
23
|
+
authorizer_pubkey = Crypto.decode_public_key(k)
|
24
|
+
|
25
|
+
fields = json[:eak].split('.', 2)
|
26
|
+
ciphertext = Crypto.base64decode(fields[0])
|
27
|
+
nonce = Crypto.base64decode(fields[1])
|
28
|
+
box = RbNaCl::Box.new(authorizer_pubkey, @private_key)
|
29
|
+
|
30
|
+
ak = box.decrypt(nonce, ciphertext)
|
31
|
+
@ak_cache[ak_cache_key] = ak
|
32
|
+
ak
|
33
|
+
end
|
34
|
+
|
35
|
+
def put_access_key(writer_id, user_id, reader_id, type, ak)
|
36
|
+
ak_cache_key = [writer_id, user_id, type]
|
37
|
+
@ak_cache[ak_cache_key] = ak
|
38
|
+
|
39
|
+
reader_key = client_key(reader_id)
|
40
|
+
nonce = RbNaCl::Random.random_bytes(RbNaCl::Box.nonce_bytes)
|
41
|
+
eak = RbNaCl::Box.new(reader_key, @private_key).encrypt(nonce, ak)
|
42
|
+
|
43
|
+
encoded_eak = sprintf('%s.%s', Crypto.base64encode(eak), Crypto.base64encode(nonce))
|
44
|
+
|
45
|
+
url = get_url('v1', 'storage', 'access_keys', writer_id, user_id, reader_id, type)
|
46
|
+
@conn.put(url, { :eak => encoded_eak })
|
47
|
+
end
|
48
|
+
|
49
|
+
def decrypt_record(encrypted_record)
|
50
|
+
record = Record.new(meta: encrypted_record.meta.clone, data: Hash.new)
|
51
|
+
|
52
|
+
writer_id = record.meta.writer_id
|
53
|
+
user_id = record.meta.user_id
|
54
|
+
type = record.meta.type
|
55
|
+
ak = get_access_key(writer_id, user_id, @config.client_id, type)
|
56
|
+
|
57
|
+
encrypted_record.data.each do |k, v|
|
58
|
+
fields = v.split('.', 4)
|
59
|
+
|
60
|
+
edk = Crypto.base64decode(fields[0])
|
61
|
+
edkN = Crypto.base64decode(fields[1])
|
62
|
+
ef = Crypto.base64decode(fields[2])
|
63
|
+
efN = Crypto.base64decode(fields[3])
|
64
|
+
|
65
|
+
dk = RbNaCl::SecretBox.new(ak).decrypt(edkN, edk)
|
66
|
+
pv = RbNaCl::SecretBox.new(dk).decrypt(efN, ef)
|
67
|
+
|
68
|
+
record.data[k] = pv
|
69
|
+
end
|
70
|
+
|
71
|
+
record
|
72
|
+
end
|
73
|
+
|
74
|
+
def encrypt_record(plaintext_record)
|
75
|
+
record = Record.new(meta: plaintext_record.meta.clone, data: Hash.new)
|
76
|
+
|
77
|
+
writer_id = record.meta.writer_id
|
78
|
+
user_id = record.meta.user_id
|
79
|
+
type = record.meta.type
|
80
|
+
|
81
|
+
begin
|
82
|
+
ak = get_access_key(writer_id, user_id, @config.client_id, type)
|
83
|
+
rescue Faraday::ResourceNotFound
|
84
|
+
ak = RbNaCl::Random.random_bytes(RbNaCl::SecretBox.key_bytes)
|
85
|
+
put_access_key(writer_id, user_id, @config.client_id, type, ak)
|
86
|
+
end
|
87
|
+
|
88
|
+
plaintext_record.data.each do |k, v|
|
89
|
+
dk = Crypto.secret_box_random_key
|
90
|
+
efN = Crypto.secret_box_random_nonce
|
91
|
+
ef = RbNaCl::SecretBox.new(dk).encrypt(efN, v)
|
92
|
+
edkN = Crypto.secret_box_random_nonce
|
93
|
+
edk = RbNaCl::SecretBox.new(ak).encrypt(edkN, dk)
|
94
|
+
|
95
|
+
record.data[k] = sprintf('%s.%s.%s.%s',
|
96
|
+
Crypto.base64encode(edk), Crypto.base64encode(edkN),
|
97
|
+
Crypto.base64encode(ef), Crypto.base64encode(efN))
|
98
|
+
end
|
99
|
+
|
100
|
+
record
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
class Crypto
|
105
|
+
def self.decode_public_key(s)
|
106
|
+
RbNaCl::PublicKey.new(base64decode(s))
|
107
|
+
end
|
108
|
+
|
109
|
+
def self.encode_public_key(k)
|
110
|
+
base64encode(k.to_bytes)
|
111
|
+
end
|
112
|
+
|
113
|
+
def self.decode_private_key(s)
|
114
|
+
RbNaCl::PrivateKey.new(base64decode(s))
|
115
|
+
end
|
116
|
+
|
117
|
+
def self.encode_private_key(k)
|
118
|
+
base64encode(k.to_bytes)
|
119
|
+
end
|
120
|
+
|
121
|
+
def self.secret_box_random_key
|
122
|
+
RbNaCl::Random.random_bytes(RbNaCl::SecretBox.key_bytes)
|
123
|
+
end
|
124
|
+
|
125
|
+
def self.secret_box_random_nonce
|
126
|
+
RbNaCl::Random.random_bytes(RbNaCl::SecretBox.nonce_bytes)
|
127
|
+
end
|
128
|
+
|
129
|
+
def self.base64encode(x)
|
130
|
+
Base64.urlsafe_encode64(x, padding: false)
|
131
|
+
end
|
132
|
+
|
133
|
+
def self.base64decode(x)
|
134
|
+
Base64.urlsafe_decode64(x)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
private_constant :Crypto
|
139
|
+
end
|
data/lib/e3db/types.rb
ADDED
data/lib/e3db/version.rb
ADDED
metadata
ADDED
@@ -0,0 +1,212 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: e3db
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Tozny, LLC
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-05-04 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ~>
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.12'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ~>
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.12'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ~>
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '10.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ~>
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '10.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ~>
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ~>
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: simplecov
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ~>
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 0.14.1
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ~>
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 0.14.1
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: dry-struct
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ~>
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: 0.2.1
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ~>
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 0.2.1
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: lru_redux
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ~>
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '1.1'
|
90
|
+
type: :runtime
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ~>
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '1.1'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rbnacl
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ~>
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '4.0'
|
104
|
+
- - '>='
|
105
|
+
- !ruby/object:Gem::Version
|
106
|
+
version: 4.0.2
|
107
|
+
type: :runtime
|
108
|
+
prerelease: false
|
109
|
+
version_requirements: !ruby/object:Gem::Requirement
|
110
|
+
requirements:
|
111
|
+
- - ~>
|
112
|
+
- !ruby/object:Gem::Version
|
113
|
+
version: '4.0'
|
114
|
+
- - '>='
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: 4.0.2
|
117
|
+
- !ruby/object:Gem::Dependency
|
118
|
+
name: net-http-persistent
|
119
|
+
requirement: !ruby/object:Gem::Requirement
|
120
|
+
requirements:
|
121
|
+
- - ~>
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: 2.9.4
|
124
|
+
type: :runtime
|
125
|
+
prerelease: false
|
126
|
+
version_requirements: !ruby/object:Gem::Requirement
|
127
|
+
requirements:
|
128
|
+
- - ~>
|
129
|
+
- !ruby/object:Gem::Version
|
130
|
+
version: 2.9.4
|
131
|
+
- !ruby/object:Gem::Dependency
|
132
|
+
name: faraday_middleware
|
133
|
+
requirement: !ruby/object:Gem::Requirement
|
134
|
+
requirements:
|
135
|
+
- - ~>
|
136
|
+
- !ruby/object:Gem::Version
|
137
|
+
version: 0.11.0.1
|
138
|
+
type: :runtime
|
139
|
+
prerelease: false
|
140
|
+
version_requirements: !ruby/object:Gem::Requirement
|
141
|
+
requirements:
|
142
|
+
- - ~>
|
143
|
+
- !ruby/object:Gem::Version
|
144
|
+
version: 0.11.0.1
|
145
|
+
- !ruby/object:Gem::Dependency
|
146
|
+
name: oauth2
|
147
|
+
requirement: !ruby/object:Gem::Requirement
|
148
|
+
requirements:
|
149
|
+
- - ~>
|
150
|
+
- !ruby/object:Gem::Version
|
151
|
+
version: '1.3'
|
152
|
+
- - '>='
|
153
|
+
- !ruby/object:Gem::Version
|
154
|
+
version: 1.3.1
|
155
|
+
type: :runtime
|
156
|
+
prerelease: false
|
157
|
+
version_requirements: !ruby/object:Gem::Requirement
|
158
|
+
requirements:
|
159
|
+
- - ~>
|
160
|
+
- !ruby/object:Gem::Version
|
161
|
+
version: '1.3'
|
162
|
+
- - '>='
|
163
|
+
- !ruby/object:Gem::Version
|
164
|
+
version: 1.3.1
|
165
|
+
description:
|
166
|
+
email:
|
167
|
+
- info@tozny.com
|
168
|
+
executables: []
|
169
|
+
extensions: []
|
170
|
+
extra_rdoc_files: []
|
171
|
+
files:
|
172
|
+
- .gitignore
|
173
|
+
- .rspec
|
174
|
+
- .travis.yml
|
175
|
+
- Gemfile
|
176
|
+
- LICENSE.txt
|
177
|
+
- README.md
|
178
|
+
- Rakefile
|
179
|
+
- bin/console
|
180
|
+
- bin/setup
|
181
|
+
- e3db.gemspec
|
182
|
+
- lib/e3db.rb
|
183
|
+
- lib/e3db/client.rb
|
184
|
+
- lib/e3db/config.rb
|
185
|
+
- lib/e3db/crypto.rb
|
186
|
+
- lib/e3db/types.rb
|
187
|
+
- lib/e3db/version.rb
|
188
|
+
homepage: https://tozny.com/e3db
|
189
|
+
licenses:
|
190
|
+
- MIT
|
191
|
+
metadata: {}
|
192
|
+
post_install_message:
|
193
|
+
rdoc_options: []
|
194
|
+
require_paths:
|
195
|
+
- lib
|
196
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
197
|
+
requirements:
|
198
|
+
- - '>='
|
199
|
+
- !ruby/object:Gem::Version
|
200
|
+
version: '0'
|
201
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
202
|
+
requirements:
|
203
|
+
- - '>='
|
204
|
+
- !ruby/object:Gem::Version
|
205
|
+
version: '0'
|
206
|
+
requirements: []
|
207
|
+
rubyforge_project:
|
208
|
+
rubygems_version: 2.0.14.1
|
209
|
+
signing_key:
|
210
|
+
specification_version: 4
|
211
|
+
summary: e3db client SDK
|
212
|
+
test_files: []
|