Client SDK in Swift for Tozny's E3DB.
The Tozny End-to-End Encrypted Database (E3DB) is a storage platform with powerful sharing and consent management features. Read more on our blog.
E3DB provides a familiar JSON-based NoSQL-style API for reading, writing, and querying data stored securely in the cloud.
Get started by registering for a free account at Tozny's Console. Then create a Client Registration Token from the console and copy the token value.
To run the example project, clone the repo, and run pod install
from the
Example directory first.
Finally, paste the token value into the ViewController.swift
source for the
line:
private let e3dbToken = "<PASTE_CLIENT_TOKEN_HERE>"
- iOS 9.0+
E3db is available through CocoaPods. To install it, simply add the following line to your Podfile:
pod "E3db", :git => 'https://github.com/tozny/e3db-swift'
Full API documentation can be found here. Code examples for the most common operations can be found below.
Use the client token generated from the Tozny Console to register a new client:
import E3db
// This is the main client performing E3db operations
// (for the remaining examples, we'll assume a non-optional client instance)
var e3db: Client?
Client.register(token: e3dbToken, clientName: "ExampleApp") { result in
switch result {
// The operation was successful, here's the configuration
case .success(let config):
// create main E3db client with config
self.e3db = Client(config: config)
case .failure(let error):
print("An error occurred attempting registration: \(error).")
}
}
}
Create a dictionary of String
key-value pairs to store a record in E3db. The
keys of the dictionary will remain unencrypted, but the values will be encrypted
before ever leaving the device.
// Wrap message in RecordData type to designate
// it as sensitive information for encryption
let recordData = RecordData(cleartext: ["SSN": "123-45-6789"])
// Can optionally include arbitrary metadata as `plain`
// where neither keys nor values are encrypted
e3db.write(type: "UserInfo", data: recordData, plain: ["Sent from": "my iPhone"]) { result in
switch result {
// The operation was successful, here's the record
case .success(let record):
// `record.meta` holds metadata associated
// with the record, such as type.
print("Wrote record! \(record.meta.recordId)")
case .failure(let error):
print("An error occurred attempting to write the data: \(error)")
}
}
}
You can request several records at once by specifying QueryParams
, but if you
already have the recordId
of the record you want to read, you can request it
directly.
// Perform read operation with the recordId of the
// written record, decrypting it after getting the
// encrypted data from the server.
e3db.read(recordId: recordId) { result in
switch result {
// The operation was successful, here's the record
case .success(let record):
// The record returned contains the same dictionary
// supplied to the `RecordData` struct during the write
print("Record data: \(record.data)")
case .failure(let error):
print("An error occurred attempting to read the record: \(error)")
}
}
To request several records, and even filter on a set of optional parameters,
pass a QueryParams
instance to the query
method.
// Keep track of queried batches
var lastRead: Double?
// Construct query, filtering to:
// - return only 5 records at a time,
// - only "UserInfo" type records,
// - including records written by others
// that have been shared with this client
let q1 = QueryParams(count: 5, types: ["UserInfo"], includeAllWriters: true)
e3db.query(params: q1) { result in
switch result {
// The operation was successful, here's the `QueryResponse`,
// which has the resulting records and an index for last record
case .success(let resp):
print("Records: \(resp.records)")
lastRead = resp.last
case .failure(let error):
print("An error occurred attempting to query records: \(error)")
}
}
// Query for next batch using `next`
let q2 = q1.next(after: lastRead!)
e3db.query(params: q2) { result in
// ...
}
Possible filters include:
count
: Limit the number of records returned by the query beyond the defaultincludeData
: Supply the full decrypted record data in the result recordswriterIds
: Filter to records written by these IDsuserIds
: Filter to records with these user IDsrecordIds
: Filter to only the records identified by these IDstypes
: Filter to records that match the given typesafter
: Number to facilitate paging the results -- used with thelast
property of the resultingQueryResponse
includeAllWriters
: Set this flag to include records that have been shared with you, defaults tofalse
Records can be shared to allow other clients access. Grant clients read access
by specifying which client and which type of record share
. Inversely, access
can be removed with the revoke
method.
// Get the recipient client ID externally
let otherClient: UUID = ???
// Share records of type "UserInfo" with another client
e3db.share(type: "UserInfo", readerId: otherClient) { result in
guard case .success = result else {
return print("An error occurred attempting to grant access to records: \(result.error)")
}
// Sharing was successful!
}
// Remove access to "UserInfo" records from the given client
e3db.revoke(type: "UserInfo", readerId: otherClient) { result in
guard case .success = result else {
return print("An error occurred attempting to revoke access to records: \(result.error)")
}
// Revoking was successful!
}
The E3DB SDK allows you to encrypt documents for local storage, which can
be decrypted later, by the client that created the document or any client with
which the document has been shared
. Note that locally encrypted documents
cannot be written directly to E3DB -- they must be decrypted locally and
written using the write
or update
methods.
Local encryption (and decryption) requires two steps:
- Create a 'writer key' (for encryption) or obtain a 'reader key' (for decryption).
- Call
encrypt
to encrypt a new document. For decryption, calldecrypt
.
The 'writer key' and 'reader key' are both EAKInfo
objects. An EAKInfo
object holds an encrypted key that can be used by the intended client to encrypt
or decrypt associated documents. A writer key can be created by calling
createWriterKey
; a 'reader key' can be obtained by calling getReaderKey
.
(Note that the client calling getReaderKey
will only receive a key if the
writer of those records has given access to the calling client through the
share
operation.)
The createWriterKey
and getReaderKey
are networked operations, (which means
they are asynchronous operations as well), but can be performed once ahead of
time. The EAKInfo
instances returned from those operations are safe to store
locally, and can be used in the non-networked operations of encrypt
and
decrypt
.
Here is an example of encrypting a document locally:
let recordData = RecordData(cleartext: ["SSN": "123-45-6789"])
let recordType = "UserInfo"
e3db.createWriterKey(type: type) { result in
switch result {
// The operation was successful, here's the `EAKInfo` instance,
// you can think of this as the "encryption key", but it's also encrypted,
// so you don't have to worry about storing it in plaintext or exposing it.
case .success(let eak):
// attempt to create an encrypted document with the EAKInfo
let encrypted = try? self.e3db.encrypt(type: recordType, data: recordData, eakInfo: eak)
print("Encrypted document: \(encrypted!)")
case .failure(let error):
print("An error occurred attempting to create writer key: \(error)")
}
}
(Note that the EAKInfo
instance is safe to store with the encrypted data, as
it is also encrypted). The client can decrypt the given record as follows:
let encrypted = // get encrypted document (e.g. read from local storage)
let writerKey = // get stored EAKInfo instance (e.g. from local storage)
// attempt to decrypt an encrypted document with the EAKInfo instance
let decrypted = try e3db.decrypt(encryptedDoc: encrypted, eakInfo: writerKey)
print("Decrypted document: \(decrypted!)")
When two clients have a sharing relationship, the 'reader' can locally decrypt any documents encrypted by the 'writer,' without using E3DB for storage.
- The 'writer' must first share records with a 'reader', using the
share
method. - The 'reader' must then obtain a reader key using
getReaderKey
.
Note that these are networked operations. However, the EAKInfo
instance can be
saved for later use.
let encrypted = // get encrypted document (e.g. read from local storage)
let writerID = // ID of writer that produced record
let recordType = "UserInfo"
var eakInfo: EAKInfo?
e3db.getReaderKey(writerId: writerID, userId: writerID, type: recordType) { result in
switch result {
// The operation was successful, here's the `EAKInfo` instance,
// you can think of this as the "encryption key", but it's also encrypted,
// so you don't have to worry about storing it in plaintext or exposing it.
case .success(let eak):
self.eakInfo = eak
case .failure(let error):
print("An error occurred attempting to get reader key: \(error)")
}
}
The EAKInfo
type conforms to Swift's Codable
protocol for easy
serialization, e.g. for saving to UserDefaults
:
// store in UserDefaults
// assumes eakInfo is a non-optional `EAKInfo` instance
let eakData = try JSONEncoder().encode(eakInfo)
UserDefaults.standard.set(eakData, forKey: "myReaderKey")
// retrieve from UserDefaults
guard let eakData = (UserDefaults.standard.value(forKey: "myReaderKey") as? Data) else {
return print("Could not retrieve eak data from defaults")
}
// deserialize into eakInfo
let eakInfo = try JSONDecoder().decode(EAKInfo.self, from: eakData)
After obtaining a reader key, the 'reader' can then decrypt any records encrypted by the writer as follows:
// attempt to decrypt an encrypted document with the EAKInfo instance
let decrypted = try e3db.decrypt(encryptedDoc: encrypted, eakInfo: eakInfo)
print("Decrypted document: \(decrypted)")
Every E3DB client created with this SDK is capable of signing documents and verifying the signature associated with a document. By attaching signatures to documents, clients can be confident in:
- Document integrity - the document's contents have not been altered (because the signature will not match).
- Proof-of-authorship - The author of the document held the private signing key associated with the given public key when the document was created.
Signatures require the target type to conform to the Signable
protocol. This
protocol requires one method to be implemented:
func serialized() -> String
This method must provide a reproducible string representation of the data to
sign and verify. This requires the serialization to be deterministic -- i.e.
types such as Dictionary
and Set
must be serialized in a reproducible order.
The E3db types of EncryptedDocument
and SignedDocument
conform to the
Signable
protocol.
To create a signature, use the sign
method. (This example assumes an encrypted
document as create above):
let encrypted = // get encrypted document (or anything that conforms to `Signable`)
let signedDoc = try e3db.sign(document: encrypted)
print("Signed Document: \(signedDoc)")
To verify a document, use the verify
method. Here, we use the same signedDoc
instance as above. config
holds the private & public keys for the client.
(Note that, in general, verify
requires the public signing key of the client
that wrote the record):
guard try e3db.verify(signed: signed, pubSigKey: config.publicSigKey)) else {
return print("Document failed verification")
}
// Document verified!
If desired, E3DB Clients can be provided with a URLSession
instance. This can
allow custom configuration for networked calls, including pinning TLS sessions
to trusted certificate(s).
Simply supply a pre-configured URLSession
to either the Client.register
or
the Client.init
methods.
let config = // load config from secure storage
// set custom delegate
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
let e3db = Client(config: config, urlSession: session)
The following shows an example of how to use the URLSessionDelegate
callback
to restrict network activity to an intermediate certificate in a cert chain.
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
// Adapted from OWASP https://www.owasp.org/index.php/Certificate_and_Public_Key_Pinning#iOS
let cancel = URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust,
SecTrustEvaluate(trust, nil) == errSecSuccess,
let serverCert = SecTrustGetCertificateAtIndex(trust, 1) else { // checks intermediate cert (index 1)
return completionHandler(cancel, nil)
}
let pinnedCertData = loadTrustedCertData() // load cert (e.g. from file)
let serverCertData = SecCertificateCopyData(serverCert) as Data
guard pinnedCertData == serverCertData else {
return completionHandler(cancel, nil)
}
// pinning succeeded
completionHandler(.useCredential, URLCredential(trust: trust))
}