المبرمجين

تجعل رزمة تطوير البرمجيات SDK الخاصة بنا من السهل والأمان تخزين السجلات في بلدها الأصلي

1
أنشئ حسابًا واحصل على مفتاح API
إنشاء حساب ←
2
استخدم SDK للغة الخاصة بك
3
يجب عليك استخدام InCountry SDK لضمان تشفير البيانات
  • Python
  • Node JS
  • Java
Python

Installation
The recommended way to install the SDK is to use pipenv (or pip):

$ pipenv install incountry

Countries List
For a full list of supported countries and their codes please follow this link.
Usage
To access your data in InCountry using Python SDK, you need to create an instance of Storage class.

class Storage:
    def __init__(
        self,
        api_key: str = None,           # Required when using API key authorization, or as environment variable INC_API_KEY
        client_id: str = None,         # Required when using oAuth authorization, can be also set via INC_CLIENT_ID
        client_secret: str = None,     # Required when using oAuth authorization, can be also set via INC_CLIENT_SECRET
        environment_id: str = None,    # Required to be passed in, or as environment variable INC_API_KEY
        secret_key_accessor=None,      # Instance of SecretKeyAccessor class. Used to fetch encryption secret
        endpoint: str = None,          # Optional. Defines API URL. Can also be set up using environment variable INC_ENDPOINT
        encrypt: bool = True,          # Optional. If False, encryption is not used
        options: Dict[str, Any] = {},  # Optional. Use it to fine-tune some configurations
        debug: bool = False,           # Optional. If True enables some debug logging
    ):
        ...

api_key, client_id, client_secret, and environment_id can be fetched from your dashboard on Incountry site.

endpoint defines API URL and is used to override default one.

You can turn off encryption (not recommended). Set encrypt property to false if you want to do this.
options allows you to tweak some SDK configurations

{
    "http_options": {
        "timeout": int,         # In seconds. Should be greater than 0
    },
    "auth_endpoints": dict,     # custom endpoints regional map to use for fetching oAuth tokens

    "countries_endpoint": str,  # If your PoPAPI configuration relies on a custom PoPAPI server
                                # (rather than the default one) use `countriesEndpoint` option
                                # to specify the endpoint responsible for fetching supported countries list

    "endpoint_mask": str,       # Defines API base hostname part to use.
                                # If set, all requests will be sent to https://${country}${endpointMask} host
                                # instead of the default one (https://${country}-mt-01.api.incountry.io)
}

Below is an example how to create a storage instance

from incountry import Storage, SecretKeyAccessor

storage = Storage(
    api_key="<api_key>",
    environment_id="<env_id>",
    debug=True,
    secret_key_accessor=SecretKeyAccessor(lambda: "password"),
    options={
        "http_options": {
            "timeout": 5
        },
        "countries_endpoint": "https://private-pop.incountry.io/countries",
        "endpoint_mask" ".private-pop.incountry.io",
    }
)

oAuth Authentication
SDK also supports oAuth authentication credentials instead of plain API key authorization. oAuth authentication flow is mutually exclusive with API key authentication – you will need to provide either API key or oAuth credentials.

Below is the example how to create storage instance with oAuth credentials (and also provide custom oAuth endpoint):

from incountry import Storage, SecretKeyAccessor

storage = Storage(
    client_id="<client_id>",
    client_secret="<client_secret>",
    environment_id="<env_id>",
    debug=True,
    secret_key_accessor=SecretKeyAccessor(lambda: "password"),
    options={
        "auth_endpoints": {
            "default": "https://auth-server-default.com",
            "emea": "https://auth-server-emea.com",
            "apac": "https://auth-server-apac.com",
            "amer": "https://auth-server-amer.com",
        }
    }
)

Encryption key/secret
secret_key_accessor is used to pass a key or secret used for encryption.

Note: even though SDK uses PBKDF2 to generate a cryptographically strong encryption key, you must make sure you provide a secret/password which follows modern security best practices and standards.

SecretKeyAccessor class constructor allows you to pass a function that should return either a string representing your secret or a dict (we call it secrets_data object):

{
  "secrets": [{
       "secret": str,
       "version": int, # Should be an integer greater than or equal to 0
       "isKey": bool,  # Should be True only for user-defined encryption keys
    }
  }, ....],
  "currentVersion": int,
}

secrets_data allows you to specify multiple keys/secrets which SDK will use for decryption based on the version of the key or secret used for encryption. Meanwhile SDK will encrypt only using key/secret that matches currentVersion provided in secrets_data object.

This enables the flexibility required to support Key Rotation policies when secrets/keys need to be changed with time. SDK will encrypt data using current secret/key while maintaining the ability to decrypt records encrypted with old keys/secrets. SDK also provides a method for data migration which allows to re-encrypt data with the newest key/secret. For details please see migrate method.

SDK allows you to use custom encryption keys, instead of secrets. Please note that user-defined encryption key should be a 32-characters ‘utf8’ encoded string as required by AES-256 cryptographic algorithm.

Here are some examples how you can use SecretKeyAccessor.

# Get secret from variable
from incountry import SecretKeyAccessor

password = "password"
secret_key_accessor = SecretKeyAccessor(lambda: password)

# Get secrets via http request
from incountry import SecretKeyAccessor
import requests as req

def get_secrets_data():
    url = "<your_secret_url>"
    r = req.get(url)
    return r.json() # assuming response is a `secrets_data` object

secret_key_accessor = SecretKeyAccessor(get_secrets_data)

Writing data to Storage
Use write method in order to create/replace (by key) a record.

def write(
        self,
        country: str,
        key: str,
        body: str = None,
        key2: str = None,
        key3: str = None,
        profile_key: str = None,
        range_key: int = None,
    ) -> Dict:
    ...


# write returns created record dict on success
{
    "record": Dict
}

Below is the example of how you may use write method

write_result = storage.write(
    country="us",
    key="user_1",
    body="some PII data",
    profile_key="customer",
    range_key=10000,
    key2="english",
    key3="rolls-royce",
)

# write_result would be as follows
write_result = {
    "record": {
        "key": "user_1",
        "body": "some PII data",
        "profile_key": "customer",
        "range_key": 10000,
        "key2": "english",
        "key3": "rolls-royce",
    }
}

Encryption
InCountry uses client-side encryption for your data. Note that only body is encrypted. Some of other fields are hashed. Here is how data is transformed and stored in InCountry database:

{
    key,           # hashed
    body,          # encrypted
    profile_key,   # hashed
    range_key,     # plain
    key2,          # hashed
    key3,          # hashed
}

Batches
Use batch_write method to create/replace multiple records at once.

def batch_write(self, country: str, records: list) -> Dict:
    ...


# batch_write returns the following dict of created records
{
    "records": List
}

Below you can see the example of how to use this method

batch_result = storage.batch_write(
    country="us",
    records=[
        {"key": "key1", "body": "body1", ...},
        {"key": "key2", "body": "body2", ...},
    ],
)

# batch_result would be as follows
batch_result = {
    "records": [
        {"key": "key1", "body": "body1", ...},
        {"key": "key2", "body": "body2", ...},
    ]
}

Reading stored data
Stored record can be read by key using read method. It accepts an object with two fields: country and key

def read(self, country: str, key: str) -> Dict:
    ...


# read returns record dict if the record is found
{
    "record": Dict
}

You can use read method as follows:

read_result = storage.read(country="us", key="user1")

# read_result would be as follows
read_result = {
    "record": {
        "key": "user_1",
        "body": "some PII data",
        "profile_key": "customer",
        "range_key": 10000,
        "key2": "english",
        "key3": "rolls-royce",
    }
}

Find records
It is possible to search records by keys or version using find method.

def find(
        self,
        country: str, # country code
        limit: int = None, # maximum amount of records to retrieve. Defaults to 100
        offset: int = None, # specifies the number of records to skip
        key: Union[str, List[str], Dict] = None,
        key2: Union[str, List[str], Dict] = None,
        key3: Union[str, List[str], Dict] = None,
        profile_key: Union[str, List[str], Dict] = None,
        range_key: Union[int, List[int], Dict] = None,
        version: Union[int, List[int], Dict] = None,
    ) -> Dict:
    ...

Note: SDK returns 100 records at most.

The return object looks like the following:

{
    "data": List,
    "errors": List, # optional
    "meta": {
        "limit": int,
        "offset": int,
        "total": int,  # total records matching filter, ignoring limit
    }
}

You can use the following types for string filter parameters (key, key2, key3, profile_key):

# single value
key2="value1" # records with key2 equal to "value1"

# list of values
key3=["value1", "value2"] # records with key3 equal to "value1" or "value2"

# dict with $not operator
key2={"$not": "value1"} # records with key2 not equal "value1"
key3={"$not": ["value1", "value2"]} # records with key3 equal to neither "value1" or "value2"

You can use the following types for range_key int filter parameter:

# single value
range_key=1 # records with range_key equal to 1

# list of values
range_key=[1, 2] # records with range_key equal to 1 or 2

# dict with comparison operators
range_key={"$gt": 1} # records with range_key greater than 1
range_key={"$gte": 1} # records with range_key greater than or equal to 1
range_key={"$lt": 1} # records with range_key less than 1
range_key={"$lte": 1} # records with range_key less than or equal to 1

# you can combine different comparison operators
range_key={"$gt": 1, "$lte": 10} # records with range_key greater than 1 and less than or equal to 10

# you can't combine similar comparison operators - e.g. $gt and $gte, $lt and $lte

You can use the following types for version int filter parameter:

# single value
version=1 # records with version equal to 1

# list of values
version=[1, 2] # records with version equal to 1 or 2

# dict with $not operator
version={"$not": 1} # records with version not equal 1
version={"$not": [1, 2]} # records with version equal to neither 1 or 2

Here is the example of how find method can be used:

find_result = storage.find(country="us", limit=10, offset=10, key2="value1", key3=["value2", "value3"])

# find_result would be as follows
find_result = {
    "data": [
        {
            "key": "<key>",
            "body": "<body>",
            "key2": "value1",
            "key3": "value2",
            ...
        }
    ],
    "meta": {
        "limit": 10,
        "offset": 10,
        "total": 100,
    }
}

Error handling
There could be a situation when find method will receive records that could not be decrypted. For example, if one changed the encryption key while the found data is encrypted with the older version of that key. In such cases find() method return data will be as follows:

{
    "data": [...],  # successfully decrypted records
    "errors": [{
        "rawData",  # raw record which caused decryption error
        "error",    # decryption error description
    }, ...],
    "meta": { ... }
}

Find one record matching filter
If you need to find only one of the records matching filter, you can use the find_one method.

def find_one(
        self,
        country: str,
        offset: int = None,
        key: Union[str, List[str], Dict] = None,
        key2: Union[str, List[str], Dict] = None,
        key3: Union[str, List[str], Dict] = None,
        profile_key: Union[str, List[str], Dict] = None,
        range_key: Union[int, List[int], Dict] = None,
        version: Union[int, List[int], Dict] = None,
    ) -> Union[Dict, None]:
    ...


# If record is not found, find_one will return `None`. Otherwise it will return record dict
{
    "record": Dict
}

Below is the example of using find_one method:

find_one_result = storage.find_one(country="us", key2="english", key3=["rolls-royce", "bmw"])

# find_one_result would be as follows
find_one_result = {
    "record": {
        "key": "user_1",
        "body": "some PII data",
        "profile_key": "customer",
        "range_key": 10000,
        "key2": "english",
        "key3": "rolls-royce",
    }
}

Delete records
Use delete method in order to delete a record from InCountry storage. It is only possible using key field.

def delete(self, country: str, key: str) -> Dict:
    ...


# delete returns the following dict on success
{
    "success": True
}

Below is the example of using delete method:

delete_result = storage.delete(country="us", key="<key>")

# delete_result would be as follows
delete_result = {
    "success": True
}

Data Migration and Key Rotation support
Using secret_key_accessor that provides secrets_data object enables key rotation and data migration support.

SDK introduces migrate method which allows you to re-encrypt data encrypted with old versions of the secret.

def migrate(self, country: str, limit: int = None) -> Dict:
    ...


# migrate returns the following dict with meta information
{
    "migrated": int   # the amount of records migrated
    "total_left": int # the amount of records left to migrate (amount of records with version
                      # different from `currentVersion` provided by `secret_key_accessor`)
}

You should specify country you want to conduct migration in and limit for precise amount of records to migrate.

Note: maximum number of records migrated per request is 100

For a detailed example of a migration script please see /examples/full_migration.py
Error Handling
InCountry Python SDK throws following Exceptions:

  • StorageClientException – used for various input validation errors. Can be thrown by all public methods.
  • StorageServerException – thrown if SDK failed to communicate with InCountry servers or if server response validation failed.
  • StorageCryptoException – thrown during encryption/decryption procedures (both default and custom). This may be a sign of malformed/corrupt data or a wrong encryption key provided to the SDK.
  • StorageException – general exception. Inherited by all other exceptions

We suggest gracefully handling all the possible exceptions:

try:
    # use InCountry Storage instance here
except StorageClientException as e:
    # some input validation error
except StorageServerException as e:
    # some server error
except StorageCryptoException as e:
    # some encryption error
except StorageException as e:
    # general error
except Exception as e:
    # something else happened not related to InCountry SDK

Custom Encryption Support
SDK supports the ability to provide custom encryption/decryption methods if you decide to use your own algorithm instead of the default one.

Storage constructor allows you to pass custom_encryption_configs param – an array of custom encryption configurations with the following schema, which enables custom encryption:

{
    "encrypt": Callable,
    "decrypt": Callable,
    "isCurrent": bool,
    "version": str
}

Both encrypt and decrypt attributes should be functions implementing the following interface (with exactly same argument names)

encrypt(input:str, key:bytes, key_version:int) -> str:
    ...

decrypt(input:str, key:bytes, key_version:int) -> str:
    ...

They should accept raw data to encrypt/decrypt, key data (represented as bytes array) and key version received from SecretKeyAccessor. The resulted encrypted/decrypted data should be a string.


NOTE
You should provide a specific encryption key via secrets_data passed to SecretKeyAccessor. This secret should use flag isForCustomEncryption instead of the regular isKey.

secrets_data = {
  "secrets": [{
       "secret": "<secret for custom encryption>",
       "version": 1,
       "isForCustomEncryption": True,
    }
  }],
  "currentVersion": 1,
}

secret_accessor = SecretKeyAccessor(lambda: secrets_data)

version attribute is used to differ one custom encryption from another and from the default encryption as well. This way SDK will be able to successfully decrypt any old data if encryption changes with time.

isCurrent attribute allows to specify one of the custom encryption configurations to use for encryption. Only one configuration can be set as "isCurrent": True.

If none of the configurations have "isCurrent": True then the SDK will use default encryption to encrypt stored data. At the same time it will keep the ability to decrypt old data, encrypted with custom encryption (if any).

Here’s an example of how you can set up SDK to use custom encryption (using Fernet encryption method from https://cryptography.io/en/latest/fernet/)

import os

from incountry import InCrypto, SecretKeyAccessor, Storage
from cryptography.fernet import Fernet

def enc(input, key, key_version):
    cipher = Fernet(key)
    return cipher.encrypt(input.encode("utf8")).decode("utf8")

def dec(input, key, key_version):
    cipher = Fernet(key)
    return cipher.decrypt(input.encode("utf8")).decode("utf8")

custom_encryption_configs = [
    {
        "encrypt": enc,
        "decrypt": dec,
        "version": "test",
        "isCurrent": True,
    }
]

key = InCrypto.b_to_base64(os.urandom(InCrypto.KEY_LENGTH))  # Fernet uses 32-byte length key encoded using base64

secret_key_accessor = SecretKeyAccessor(
    lambda: {
        "currentVersion": 1,
        "secrets": [{"secret": key, "version": 1, "isForCustomEncryption": True}],
    }
)

storage = Storage(
    api_key="<api_key>",
    environment_id="<env_id>",
    secret_key_accessor=secret_key_accessor,
    custom_encryption_configs=custom_encryption_configs,
)

storage.write(country="us", key="<key>", body="<body>")

Testing Locally

  1. In terminal run pipenv run tests for unit tests
  2. In terminal run pipenv run integrations to run integration tests
Node JS

Installation
SDK is available via NPM:

npm install incountry --save

Countries List
For a full list of supported countries and their codes please follow this link.
Usage
To access your data in InCountry using NodeJS SDK, you need to create an instance of Storage class using async factory method createStorage.

const { createStorage } = require('incountry');
const storage = await createStorage({
  apiKey: 'API_KEY',                // {string} Required when using API key authorization, or as environment variable INC_API_KEY
  environmentId: 'ENVIRONMENT_ID',  // {string} Required to be passed in, or as environment variable INC_ENVIRONMENT_ID
  oauth: {
    clientId: '',                   // {string} Required when using oAuth authorization, can be also set via INC_CLIENT_ID
    clientSecret: '',               // {string} Required when using oAuth authorization, can be also set via INC_CLIENT_SECRET
    authEndpoints: '',              // {object} Optional - custom endpoints regional map to use for fetching oAuth tokens
  },
  endpoint: 'INC_URL',              // {string} Optional - Defines API URL
  encrypt: true,                    // {boolean} Optional - If false, encryption is not used. If omitted is set to true.
  getSecrets: () => '',             // {GetSecretsCallback} Optional - Used to fetch encryption secret

  /**
   * {string} Optional
   * Defines API base hostname part to use.
   * If set, all requests will be sent to https://${country}${endpointMask} host instead of the default
   * one (https://${country}-mt-01.api.incountry.io)
   */
  endpointMask: '',

  /**
   * {string} Optional
   * If your PoPAPI configuration relies on a custom PoPAPI server (rather than the default one)
   * use `countriesEndpoint` option to specify the endpoint responsible for fetching supported countries list.
   */
  countriesEndpoint: '',
});

apiKey, oauth.clientId, oauth.clientSecret and environmentId can be fetched from your dashboard on Incountry site.

Otherwise you can create an instance of Storage class and run all async checks by yourself (or not run at your own risk!)

const { Storage } = require('incountry');
const storage = new Storage({
  apiKey: 'API_KEY',
  environmentId: 'ENVIRONMENT_ID',
  endpoint: 'INC_URL',
  encrypt: true,
  getSecrets: () => '',
});

await storage.validate();

validate method fetches secret data using GetSecretsCallback and validates it. If custom encryption configs were provided they would also be checked with all matching secrets.
oAuth Authentication
SDK also supports oAuth authentication credentials instead of plain API key authorization. oAuth authentication flow is mutually exclusive with API key authentication – you will need to provide either API key or oAuth credentials.

Below is the example how to create storage instance with oAuth credentials (and also provide custom oAuth endpoint):

const { Storage } = require('incountry');
const storage = new Storage({
  environmentId: 'ENVIRONMENT_ID',
  endpoint: 'INC_URL',
  encrypt: true,
  getSecrets: () => '',
  oauth: {
    clientId: 'CLIENT_ID',
    clientSecret: 'CLIENT_SECRET',
    authEndpoints: {
      "default": "https://auth-server-default.com",
      "emea": "https://auth-server-emea.com",
      "apac": "https://auth-server-apac.com",
      "amer": "https://auth-server-amer.com",
    },
  },
});

Encryption key/secret
GetSecretsCallback is used to pass a key or secret used for encryption.
Note: even though SDK uses PBKDF2 to generate a cryptographically strong encryption key, you must make sure you provide a secret/password which follows modern security best practices and standards.
GetSecretsCallback is a function that should return either a string representing your secret or an object (we call it SecretsData) or a Promise which resolves to that string or object:

{
  secrets: [
    {
      secret: 'aaa',                // {string}
      version: 0                    // {number} Should be a non negative integer
    },
    {
      secret: 'bbbbbbbbbbbb...bbb', // {string} Should be a 32-characters 'utf8' encoded string
      version: 1,                   // {number} Should be a non negative integer
      isKey: true                   // {boolean} Should be true only for user-defined encryption key
    },
    {
      secret: 'ccc',                // {string}
      version: 2,                   // {number} Should be a non negative integer
      isForCustomEncryption: true   // {boolean} Should be true only for custom encryption
    }
  ],
  currentVersion: 1                 // {number} Should be a non negative integer
};

GetSecretsCallback allows you to specify multiple keys/secrets which SDK will use for decryption based on the version of the key or secret used for encryption. Meanwhile SDK will encrypt only using key/secret that matches currentVersion provided in SecretsData object.

This enables the flexibility required to support Key Rotation policies when secrets/keys need to be changed with time. SDK will encrypt data using current secret/key while maintaining the ability to decrypt records encrypted with old keys/secrets. SDK also provides a method for data migration which allows to re-encrypt data with the newest key/secret. For details please see migrate method.

SDK allows you to use custom encryption keys, instead of secrets. Please note that user-defined encryption key should be a 32-characters ‘utf8’ encoded string as it’s required by AES-256 cryptographic algorithm.

Here are some examples of GetSecretsCallback.

/**
 * @callback GetSecretsCallback
 * @returns {string|SecretsData|Promise<string>|Promise<SecretsData>}
 */
// Synchronous
const getSecretsSync = () => 'longAndStrongPassword';

// Asynchronous
const getSecretsAsync = async () => {
  const secretsData = await getSecretsDataFromSomewhere();
  return secretsData;
};

// Using promises syntax
const getSecretsPromise = () =>
  new Promise(resolve => {
    getPasswordFromSomewhere(secretsData => {
      resolve(secretsData);
    });
  });

Logging
By default SDK outputs logs into console in JSON format. You can override this behavior passing logger object as a Storage constructor parameter. Logger object must look like the following:

// Custom logger must implement `write` method
const customLogger = {
  write: (logLevel, message) => {} // {(logLevel:string, message: string) => void}
};

const storage = await createStorage({
  apiKey: '',
  environmentId: '',
  getSecrets: () => '', // {GetSecretsCallback}
  logger: customLogger
});

Writing data to Storage
Use write method in order to create/replace (by key) a record.

/**
 * @typedef Record
 * @property {string} key
 * @property {string|null} body
 * @property {string|null} profile_key
 * @property {string|null} key2
 * @property {string|null} key3
 * @property {number|null} range_key
 * @property {number} version - used internally by the SDK to handle record’s encryption secret version, no need to provide it manually
 */

/**
  * @param {string} countryCode - Country code
  * @param {Record} record
  * @param {object} [requestOptions]
  * @return {Promise<{ record: Record }>} Written record
  */
async write(countryCode, record, requestOptions = {}) {
  /* ... */
}

Below is the example of how you may use write method:

const record = {
  key: '<key>',
  body: '<body>',
  profile_key: '<profile_key>',
  range_key: 0,
  key2: '<key2>,
  key3: '<key3>'
}

const writeResult = await storage.write(country, record);

Encryption
InCountry uses client-side encryption for your data. Note that only body is encrypted. Some of other fields are hashed. Here is how data is transformed and stored in InCountry database:

{
  key,          // hashed
  body,         // encrypted
  profile_key,  // hashed
  range_key,    // plain
  key2,         // hashed
  key3          // hashed
}

Batches
Use batchWrite method to create/replace multiple records at once

/**
 * @param {string} countryCode
 * @param {Array<Record>} records
 * @return {Promise<{ records: Array<Record> }>} Written records
 */
async batchWrite(countryCode, records) {
  /* ... */
}

Example of usage:

batchResult = await storage.batchWrite(country, records);

Reading stored data
Stored record can be read by key using read method. It accepts an object with two fields: country and key. It returns a Promise which resolves to { record } or { record: null } if there is no record with this key.

/**
 * @param {string} countryCode Country code
 * @param {string} recordKey
 * @param {object} [requestOptions]
 * @return {Promise<{ record: Record|null }>} Matching record
 */
async read(countryCode, recordKey, requestOptions = {}) {
  /* ... */
}

Example of usage:

const readResult = await storage.read(country, key);

Find records
It is possible to search by random keys using find method.

You can specify filter object for every record key combining different queries:

  • single value
  • several values as an array
  • a logical NOT operator for version
  • comparison operators for range_key

The options parameter defines the limit – number of records to return and the offset– starting index. It can be used to implement pagination. Note: SDK returns 100 records at most.

/**
 * @typedef {string | Array<string>} FilterStringValue
*/
/**
 * @typedef { number | Array<number> | { $not: number | Array<number> } | { $gt?: number, $gte?: number, $lt?: number, $lte?: number }} FilterNumberValue
*/
/**
 * @typedef {Object.<string,{FilterStringValue | FilterNumberValue}>} FindFilter
*/
/**
 * @typedef FindOptions
 * @property {number} limit
 * @property {number} offset
*/
/**
 * @typedef FindResultsMeta
 * @property {number} total
 * @property {number} count
 * @property {number} limit
 * @property {number} offset
*/
/**
 * Find records matching filter.
 * @param {string} countryCode - Country code.
 * @param {FindFilter} filter - The filter to apply.
 * @param {FindOptions} options - The options to pass to PoP.
 * @param {object} [requestOptions]
 * @return {Promise<{ meta: FindResultsMeta }, records: Array<Record>, errors?: Array<{ error: InCryptoError, rawData: Record  }> } Matching records.
 */
async find(countryCode, filter, options = {}, requestOptions = {}) {
  /* ... */
}

Example of usage:

const filter = {
  key: 'abc',
  key2: ['def', 'jkl'],
  profile_key: 'test2',
  range_key: { $gte: 5, $lte: 100 },
  version: { $not: [0, 1] },
}

const options = {
  limit: 100,
  offset: 0,
};

const findResult = await storage.find(country, filter, options);

And the return object findResult looks like the following:

{
  records: [{/* record */}],
  errors: [],
  meta: {
    limit: 100,
    offset: 0,
    total: 24
  }
}

Error handling
There could be a situation when find method will receive records that could not be decrypted. For example, if one changed the encryption key while the found data is encrypted with the older version of that key. In such cases find() method return data will be as follows:

{
  records: [/* successfully decrypted records */],
  errors: [/* errors */],
  meta: {/* ... */}
}

Find one record matching filter
If you need to find the first record matching filter, you can use the findOne method. If record not found, it will return null.

/**
 * @param {string} countryCode - Country code.
 * @param {FindFilter} filter - The filter to apply.
 * @param {FindOptions} options - The options to pass to PoP.
 * @param {object} [requestOptions]
 * @return {Promise<{ record: Record|null }>} Matching record.
 */
async findOne(countryCode, filter, options = {}, requestOptions = {}) {
  /* ... */
}

Example of usage:

const findOneResult = await storage.findOne(country, filter);

Delete records
Use delete method in order to delete a record from InCountry storage. It is only possible using key field.

/**
 * Delete a record by ket.
 * @param {string} countryCode - Country code.
 * @param {string} recordKey
 * @param {object} [requestOptions]
 * @return {Promise<{ success: true }>} Operation result.
 */
async delete(countryCode, recordKey, requestOptions = {}) {
  /* ... */
}

Example of usage:

const deleteResult = await storage.delete(country, key);

Data Migration and Key Rotation support
Using GetSecretCallback that provides secretsData object enables key rotation and data migration support.

SDK introduces migrate method which allows you to re-encrypt data encrypted with old versions of the secret. It returns an object which contains some information about the migration – the amount of records migrated (migrated) and the amount of records left to migrate (total_left) (which basically means the amount of records with version different from currentVersion provided by GetSecretsCallback).

For a detailed example of a migration script please see examples/migration.js

/**
 * @typedef MigrateResultMeta
 * @property {number} migrated Non Negative Int - The amount of records migrated
 * @property {number} total_left Non Negative Int - The amount of records left to migrate
*/
/**
 * @param {string} countryCode - Country code.
 * @param {number} limit - Find limit
 * @returns {Promise<{ meta: MigrateResultMeta }>}
 */
async migrate(countryCode, limit = FIND_LIMIT, findFilterOptional = {}) {
  /* ... */
}

Example of usage:

const migrateResult = await storage.migrate(country, limit);

Error Handling
InCountry Node SDK throws following Exceptions:

  • StorageClientError – used for various input validation errors. Can be thrown by all public methods.
  • StorageServerError – thrown if SDK failed to communicate with InCountry servers or if server response validation failed.
  • StorageCryptoError – thrown during encryption/decryption procedures (both default and custom). This may be a sign of malformed/corrupt data or a wrong encryption key provided to the SDK.
  • StorageError – general exception. Inherited by all other exceptions

We suggest gracefully handling all the possible exceptions:

try {
  // use InCountry Storage instance here
} catch(e) {
  if (e instanceof StorageClientError) {
    // some input validation error
  } else if (e instanceof StorageServerError) {
    // some server error
  } else if (e instanceof StorageCryptoError) {
    // some encryption error
  } else {
    // ...
  }
}

Custom encryption
SDK supports the ability to provide custom encryption/decryption methods if you decide to use your own algorithm instead of the default one.

createStorage(options, customEncConfigs) allows you to pass an array of custom encryption configurations with the following schema, which enables custom encryption:

{
  encrypt: (text: string, secret: string, secretVersion: string) => Promise<string> | string,
  decrypt: (encryptedText: string, secret: string, secretVersion: string) => Promise<string> | string,
  isCurrent: boolean, // Optional but at most one in array should be isCurrent: true
  version: string
}

They should accept raw data to encrypt/decrypt, key data and key version received from SecretKeyAccessor. The resulted encrypted/decrypted data should be a string.

version attribute is used to differ one custom encryption from another and from the default encryption as well. This way SDK will be able to successfully decrypt any old data if encryption changes with time.

isCurrent attribute allows to specify one of the custom encryption configurations to use for encryption. Only one configuration can be set as isCurrent: true.

Here’s an example of how you can set up SDK to use custom encryption (using XXTEA encryption algorithm):

const xxtea = require('xxtea');
const encrypt = function(text, secret) {
  return xxtea.encrypt(text, secret);
};

const decrypt = function(encryptedText, secret) {
  return xxtea.decrypt(encryptedText, secret);
};

const config = {
  encrypt,
  decrypt,
  isCurrent: true,
  version: 'current',
};

const getSecretsCallback = () => {
  return {
    secrets: [
      {
        secret: 'longAndStrongPassword',
        version: 1,
        isForCustomEncryption: true
      }
    ],
    currentVersion: 1,
  };
}

const options = {
  apiKey: 'API_KEY',
  environmentId: 'ENVIRONMENT_ID',
  encrypt: true,
  getSecrets: getSecretsCallback,
}};

const storage = await createStorage(options, [config]);

await storage.write('us', { key: '<key>', body: '<body>' });

Testing Locally

  1. In terminal run npm test for unit tests
  2. In terminal run npm run integrations to run integration tests
Java

Installation
Incountry Storage SDK requires Java Developer Kit 1.8 or higher, recommended language level 8.
For Maven users please add this section to your dependencies list

<dependency>
  <groupId>com.incountry</groupId>
  <artifactId>incountry-java-client</artifactId>
  <version>2.1.0</version>
</dependency>

For Gradle users please add this line to your dependencies list

compile "com.incountry:incountry-java-client:2.1.0"

Countries List
For a full list of supported countries and their codes please follow this link.
Usage
Use StorageImpl class to access your data in InCountry using Java SDK.

public class StorageImpl implements Storage {
  /**
   * creating Storage instance
   *
   * @param environmentID     Required to be passed in, or as environment variable INC_API_KEY
                              with {@link #getInstance()}
   * @param apiKey            Required to be passed in, or as environment variable INC_ENVIRONMENT_ID
                              with {@link #getInstance()}
   * @param endpoint          Optional. Defines API URL.
   *                          Default endpoint will be used if this param is null
   * @param secretKeyAccessor Instance of SecretKeyAccessor class. Used to fetch encryption secret
   * @return instance of Storage
   * @throws StorageClientException if configuration validation finished with errors
   * @throws StorageServerException if server connection failed or server response error
   */
  public static Storage getInstance(String environmentID, String apiKey, String endpoint,
                            SecretKeyAccessor secretKeyAccessor) throws StorageServerException {...}
//...
}

Parameters environmentID and apiKey (or clientId and clientSecret instead of apiKey) can be fetched from your dashboard on Incountry site.

You can turn off encryption (not recommended) by providing null value for parameter secretKeyAccessor.

Below is an example how to create a storage instance:

SecretKeyAccessor secretKeyAccessor = () -> SecretsDataGenerator.fromPassword("<password>");
String endPoint = "https://us-mt-01.api.incountry.io";
String envId = "<env_id>";
String apiKey = "<api_key>";
Storage storage = StorageImpl.getInstance(envId, apiKey, endPoint, secretKeyAccessor);

oAuth Authentication
SDK also supports oAuth authentication credentials instead of plain API key authorization. oAuth authentication flow is mutually exclusive with API key authentication – you will need to provide either API key or oAuth credentials.

Below is the example how to create storage instance with oAuth credentials (and also provide custom oAuth endpoint):

Map<String, String> authEndpointsMap = new HashMap<>();
authEndpointsMap.put("emea", "https://auth-server-emea.com");
authEndpointsMap.put("apac", "https://auth-server-apac.com");
authEndpointsMap.put("amer", "https://auth-server-amer.com");

StorageConfig config = new StorageConfig()
   //can be also set via environment variable INC_CLIENT_ID with {@link #getInstance()}
   .setClientId(CLIENT_ID)
   //can be also set via environment variable INC_CLIENT_SECRET with {@link #getInstance()}
   .setClientSecret(SECRET)
   .setAuthEndpoints(authEndpointsMap)
   .setDefaultAuthEndpoint("https://auth-server-default.com")
   .setEndpointMask(ENDPOINT_MASK)
   .setEnvId(ENV_ID);
Storage storage = StorageImpl.getInstance(config);

Note: parameter endpointMask is used for switching from default InCountry host family (api.incountry.io) to a different one. For example setting endpointMask==-private.incountry.io will make all further requests to be sent to https://{COUNTRY_CODE}-private.incountry.io If your PoPAPI configuration relies on a custom PoPAPI server (rather than the default one) use countriesEndpoint option to specify the endpoint responsible for fetching supported countries list.

StorageConfig config = new StorageConfig()
   .setCountriesEndpoint(countriesEndpoint)
   //...
Storage storage = StorageImpl.getInstance(config);

Encryption key/secret
SDK provides SecretKeyAccessor interface which allows you to pass your own secrets/keys to the SDK.

/**
 * Secrets accessor. Method {@link SecretKeyAccessor#getSecretsData()} is invoked on each encryption/decryption.
 */
public interface SecretKeyAccessor {

    /**
     * get your container with secrets
     *
     * @return SecretsData
     * @throws StorageClientException when something goes wrong during getting secrets
     */
    SecretsData getSecretsData() throws StorageClientException;
}


public class SecretsData {
    /**
     * creates a container with secrets
     *
     * @param secrets non-empty list of secrets. One of the secrets must have
     *        same version as currentVersion in SecretsData
     * @param currentVersion Should be a non-negative integer
     * @throws StorageClientException when parameter validation fails
     */
     public SecretsData(List<SecretKey> secrets, int currentVersion)
                throws StorageClientException {...}
    //...
}


public class SecretKey {
    /**
    * Creates a secret key
    *
    * @param secret  secret/key
    * @param version secret version, should be a non-negative integer
    * @param isKey   should be True only for user-defined encryption keys
    * @throws StorageClientException when parameter validation fails
    */
    public SecretKey(String secret, int version, boolean isKey)
              throws StorageClientException {...}
    //...
}

You can implement SecretKeyAccessor interface and pass secrets/keys in multiple ways:

  1. As a constant SecretsData object
    SecretsData secretsData = new SecretsData(secretsList, currentVersion);
    SecretKeyAccessor accessor = () -> secretsData;
  2. As a function that dynamically fetches secrets
    SecretKeyAccessor accessor = () -> loadSecretsData();
    
    private SecretsData loadSecretsData()  {
       String url = "<your_secret_url>";
       String responseJson = loadFromUrl(url).asJson();
       return SecretsDataGenerator.fromJson(responseJson);
    }

You can also use SecretsDataGenerator class for creating SecretsData instances:

  1. from a String password
    SecretsData secretsData = SecretsDataGenerator.fromPassword("<password>");
  2. from a JSON string representing SecretsData object
    SecretsData secretsData = SecretsDataGenerator.fromJson(jsonString);
    {
    "secrets": [
        {
        "secret": "secret0",
        "version": 0,
        "isKey": false
        },
        {
        "secret": "secret1",
        "version": 1,
        "isKey": false
        }
    ],
    "currentVersion": 1
    }

SecretsData allows you to specify multiple keys/secrets which SDK will use for decryption based on the version of the key or secret used for encryption.
Meanwhile SDK will encrypt only using key/secret that matches currentVersion provided in SecretsData object. This enables the flexibility required to support Key Rotation policies when secrets/keys need to be changed with time.
SDK will encrypt data using current secret/key while maintaining the ability to decrypt records encrypted with old keys/secrets.
SDK also provides a method for data migration which allows to re-encrypt data with the newest key/secret. For details please see migrate method.
SDK allows you to use custom encryption keys, instead of secrets. Please note that user-defined encryption key should be a 32-characters ‘utf8’ encoded string as required by AES-256 cryptographic algorithm.
Note: even though SDK uses PBKDF2 to generate a cryptographically strong encryption key, you must make sure you provide a secret/password which follows modern security best practices and standards.
Writing data to Storage
Use write method in order to create a record.

public interface Storage {
    /**
     * Write data to remote storage
     *
     * @param country country identifier
     * @param record  object which encapsulate data which must be written in storage
     * @return recorded record
     * @throws StorageClientException if validation finished with errors
     * @throws StorageServerException if server connection failed or server response error
     * @throws StorageCryptoException if encryption failed
     */
    Record write(String country, Record record)
          throws StorageClientException, StorageServerException, StorageCryptoException;
    //...
}

Here is how you initialize a record object:

public class Record {
    /**
     * Full constructor
     *
     * @param key        Required, record key
     * @param body       Optional, data to be stored and encrypted
     * @param profileKey Optional, profile key
     * @param rangeKey   Optional, range key
     * @param key2       Optional, key2
     * @param key3       Optional, key3
     */
    public Record(String key, String body, String profileKey, Long rangeKey, String key2, String key3)
    //...
}

Below is the example of how you may use write method:

key = "user_1";
body = "some PII data";
profileKey = "customer";
rangeKey = 10000l;
key2 = "english";
key3 = "insurance";
Record record = new Record(key, body, profileKey, rangeKey, key2, key3);
storage.write("us", record);

Encryption
InCountry uses client-side encryption for your data. Note that only body is encrypted. Some of other fields are hashed. Here is how data is transformed and stored in InCountry database:

public class Record {
    private String key;          // hashed
    private String body;         // encrypted
    private String profileKey;   // hashed
    private Long rangeKey;       // plain
    private String key2;         // hashed
    private String key3;         // hashed
    //...
}

Batches
Use the batchWrite method to write multiple records to the storage in a single request.

public interface Storage {
     /**
      * Write multiple records at once in remote storage
      *
      *  @param country country identifier
      *  @param records record list
      *  @return BatchRecord object which contains list of recorded records
      *  @throws StorageClientException if validation finished with errors
      *  @throws StorageServerException if server connection failed or server response error
      *  @throws StorageCryptoException if record encryption failed
      */ 
     BatchRecord batchWrite(String country, List<Record> records)
          throws StorageClientException, StorageServerException, StorageCryptoException;
     //...
}

Below is the example of how you may use batchWrite method

List<Record> list = new ArrayList<>();
list.add(new Record(firstKey, firstBody, firstProfileKey, firstRangeKey, firstKey2, firstKey3));
list.add(new Record(secondKey, secondBody, secondProfileKey, secondRangeKey, secondKey2, secondKey3));
storage.batchWrite("us", list);

Reading stored data
Stored record can be read by key using read method.

public interface Storage {
   /**
    * Read data from remote storage
    *
    * @param country   country identifier
    * @param key record unique identifier
    * @return Record object which contains required data
    * @throws StorageClientException if validation finished with errors
    * @throws StorageServerException if server connection failed or server response error
    * @throws StorageCryptoException if decryption failed
    */
    Record read(String country, String key)
        throws StorageClientException, StorageServerException, StorageCryptoException;
    //...
}

Below is the example of how you may use read method:

String key = "user_1";
Record record = storage.read("us", key);
String decryptedBody = record.getBody();

Record contains the following properties: key, body, key2, key3, profileKey, rangeKey.

These properties can be accessed using getters, for example:

String key2 = record.getKey2();
String body = record.getBody();

Find records
It is possible to search by random keys using find method.

public interface Storage {
   /**
    * Find records in remote storage according to filters
    *
    * @param country country identifier
    * @param builder object representing find filters and search options
    * @return BatchRecord object which contains required records
    * @throws StorageClientException if validation finished with errors
    * @throws StorageServerException if server connection failed or server response error
    * @throws StorageCryptoException if decryption failed
    */
    BatchRecord find(String country, FindFilterBuilder builder)
         throws StorageClientException, StorageServerException, StorageCryptoException;
    //...
}

Use FindFilterBuilder class to refine your find request.

Below is the example how to use find method along with FindFilterBuilder:

FindFilterBuilder builder = FindFilterBuilder.create()
                  .key2Eq("someKey")
                  .key3Eq("firstValue","secondValue")
                  .rangeKeyBetween(123l, 456l);

BatchRecord findResult = storage.find("us", builder);
if (findResult.getCount() > 0) {
    Record record = findResult.getRecords().get(0);
    //...
}

The request will return records, filtered according to the following pseudo-sql

key2 = 'someKey' AND key3 in ('firstValue' , 'secondValue') AND (123 < = `rangeKey` < = 456)

All conditions added via FindFilterBuilder are joined using logical AND. You may not add multiple conditions for the same key – if you do only the last one will be used.

SDK returns 100 records at most. Use limit and offset to iterate through the records.

FindFilterBuilder builder = FindFilterBuilder.create()
                  //...
                  .limitAndOffset(20, 80);
BatchRecord records = storage.find("us", builder);

Next predicate types are available for each string key field of class Record via individual methods of FindFilterBuilder:

EQUALS         (FindFilterBuilder::keyEq)
               (FindFilterBuilder::key2Eq)
               (FindFilterBuilder::key3Eq)
               (FindFilterBuilder::profileKeyEq)
NOT_EQUALS     (FindFilterBuilder::keyNotEq)
               (FindFilterBuilder::key2NotEq)
               (FindFilterBuilder::key3NotEq)
               (FindFilterBuilder::profileKeyNotEq)

You can use the following builder methods for filtering by numerical rangeKey field:

EQUALS              (FindFilterBuilder::rangeKeyEq)
IN                  (FindFilterBuilder::rangeKeyIn)
GREATER             (FindFilterBuilder::rangeKeyGT)
GREATER OR EQUALS   (FindFilterBuilder::rangeKeyGTE)
LESS                (FindFilterBuilder::rangeKeyLT)
LESS OR EQUALS      (FindFilterBuilder::rangeKeyLTE)
BETWEEN             (FindFilterBuilder::rangeKeyBetween)

Method find returns BatchRecord object which contains a list of Record and some metadata:

class BatchRecord {
    private int count;
    private int limit;
    private int offset;
    private int total;
    private List<Record> records;
    //...
}

These fields can be accessed using getters, for example:

int limit = records.getTotal();

BatchRecord.getErrors() allows you to get a List of RecordException objects which contains detailed information about records that failed to be processed correctly during find request.
Find one record matching filter
If you need to find the first record matching filter, you can use findOne method:

public interface Storage {
   /**
    * Find only one first record in remote storage according to filters
    *
    * @param country country identifier
    * @param builder object representing find filters
    * @return founded record or null
    * @throws StorageClientException if validation finished with errors
    * @throws StorageServerException if server connection failed or server response error
    * @throws StorageCryptoException if decryption failed
    */
    Record findOne(String country, FindFilterBuilder builder)
           throws StorageClientException, StorageServerException, StorageCryptoException;
    //...
}

It works the same way as find but returns the first record or null if no records found.

Here is the example of how findOne method can be used:

FindFilterBuilder builder = FindFilterBuilder.create()
                .key2Eq("someKey")
                .key3Eq("firstValue", "secondValue")
                .rangeKeyBetween(123l, 456l);

Record record = storage.findOne("us", builder);
//...

Delete records
Use delete method in order to delete a record from InCountry storage. It is only possible using key field.

public interface Storage {
    /**
    * Delete record from remote storage
    *
    * @param country   country code of the record
    * @param key the record's key
    * @return true when record was deleted
    * @throws StorageClientException if validation finished with errors
    * @throws StorageServerException if server connection failed
    */
    boolean delete(String country, String key)
            throws StorageClientException, StorageServerException;
    //...
}

Below is the example of how you may use delete method:

String key = "user_1";
storage.delete("us", key);

Data Migration and Key Rotation support
Using SecretKeyAccessor that provides SecretsData object enables key rotation and data migration support.

SDK introduces method migrate

public interface Storage {
   /**
    * Make batched key-rotation-migration of records
    *
    * @param country country identifier
    * @param limit   batch-limit parameter
    * @return MigrateResult object which contain total records
    *         left to migrate and total amount of migrated records
    * @throws StorageClientException if validation finished with errors
    * @throws StorageServerException if server connection failed or server response error
    * @throws StorageCryptoException if decryption failed
    */
    MigrateResult migrate(String country, int limit)
           throws StorageClientException, StorageServerException, StorageCryptoException;
    //...
}

It allows you to re-encrypt data encrypted with old versions of the secret. You should specify country you want to conduct migration in and limit for precise amount of records to migrate. migrate returns a MigrateResult object which contains some information about the migration – the amount of records migrated (migrated) and the amount of records left to migrate (totalLeft) (which basically means the amount of records with version different from currentVersion provided by SecretKeyAccessor)

public class MigrateResult {
    private int migrated;
    private int totalLeft;
    //...
}

For detailed example of a migration usage please follow this link.
Error handling
InCountry Java SDK throws following Exceptions:

  • StorageClientException – used for various input validation errors
  • StorageServerException – thrown if SDK failed to communicate with InCountry servers or if server response validation failed.
  • StorageCryptoException – thrown during encryption/decryption procedures (both default and custom). This may be a sign of malformed/corrupt data or a wrong encryption key provided to the SDK.
  • StorageException – general exception. Inherited by all other exceptions

We suggest gracefully handling all the possible exceptions:

public void test() {
    try {
        // use InCountry Storage instance here
    } catch (StorageClientException e) {
        // some input validation error
    } catch (StorageServerException e) {
        // some server error
    } catch (StorageCryptoException e) {
        // some encryption error
    } catch (StorageException e) {
        // general error
    } catch (Exception e) {
        // something else happened not related to InCountry SDK
    }
}

Custom Encryption Support
SDK supports the ability to provide custom encryption/decryption methods if you decide to use your own algorithm instead of the default one. One of the overloaded versions of the method getInstance in class StorageImpl allows you to pass a list of custom encryption implementations:

/**
  * creating Storage instance
  *
  * @param config Configuration for Storage initialization
  * @return instance of Storage
  * @throws StorageClientException if configuration validation finished with errors
  * @throws StorageCryptoException if custom encryption fails during initialization
  * @throws StorageServerException if server connection failed or server response error
  */
public static Storage getInstance(StorageConfig config)
              throws StorageClientException, StorageServerException {...}

Class StorageConfig is a container with Storage configuration, using Builder pattern. Use method setCustomEncryptionConfigsList for passing a list of custom encryption implementations:

public class StorageConfig {
   private String envId;
       private String apiKey;
       private String endPoint;
       private SecretKeyAccessor secretKeyAccessor;
       private List<Crypto> customEncryptionConfigsList;
       private boolean normalizeKeys;
       private String clientId;
       private String clientSecret;
       private String authEndPoint;
       private Integer httpTimeout;
    //...

    /**
     * for custom encryption
     *
     * @param customEncryptionConfigsList List with custom encryption functions
     * @return StorageConfig
     */
    public StorageConfig setCustomEncryptionConfigsList(List<Crypto> customEncryptionConfigsList) {
        this.customEncryptionConfigsList = customEncryptionConfigsList;
        return this;
    }

    /**
     * Set HTTP requests timeout. Parameter is optional. Should be greater than 0.
     * Default value is 30 seconds.
     *
     * @param httpTimeout timeout in seconds
     * @return StorageConfig
     */
    public StorageConfig setHttpTimeout(Integer httpTimeout) {
        this.httpTimeout = httpTimeout;
        return this;
    }


    //...
}

For using of custom encryption you need to implement the following interface:

public interface Crypto {
    /**
     * encrypts data with secret
     *
     * @param text      data for encryption
     * @param secretKey secret
     * @return encrypted data as String
     * @throws StorageClientException when parameters validation fails
     * @throws StorageCryptoException when decryption fails
     */
    String encrypt(String text, SecretKey secretKey)
            throws StorageClientException, StorageCryptoException;

    /**
     * decrypts data with Secret
     *
     * @param cipherText encrypted data
     * @param secretKey  secret
     * @return decrypted data as String
     * @throws StorageClientException when parameters validation fails
     * @throws StorageCryptoException when decryption fails
     */
    String decrypt(String cipherText, SecretKey secretKey)
            throws StorageClientException, StorageCryptoException;

    /**
     * version of encryption algorithm as String
     *
     * @return version
     */
    String getVersion();

    /**
     * only one CustomCrypto can be current. This parameter
     * used only during {@link com.incountry.residence.sdk.Storage}
     * initialisation. Changing this parameter will be ignored after initialization
     *
     * @return is current or not
     */
    boolean isCurrent();
}

NOTE
You should provide a specific SecretKey via SecretsData passed to SecretKeyAccessor. This secret should have flag isForCustomEncryption set to true and flag isKey set to false:

public class SecretKey {
    /**
     * @param secret secret/key
     * @param version secret version, should be a non-negative integer
     * @param isKey should be True only for user-defined encryption keys
     * @param isForCustomEncryption should be True for using this key in custom encryption
     *                              implementations. Either ({@link #isKey} or
     *                              {@link #isForCustomEncryption}) can be True at the same
     *                              moment, not both
     * @throws StorageClientException when parameter validation fails
     */
    public SecretKey(String secret, int version, boolean isKey, boolean isForCustomEncryption)
              throws StorageClientException {...}
    //...
}

You can set isForCustomEncryption using SecretsData JSON format as well:

secrets_data = {
  "secrets": [{
       "secret": "<secret for custom encryption>",
       "version": 1,
       "isForCustomEncryption": true,
    }
  }],
  "currentVersion": 1,
}

version attribute is used to differ one custom encryption from another and from the default encryption as well. This way SDK will be able to successfully decrypt any old data if encryption changes with time.
isCurrent attribute allows to specify one of the custom encryption implementations that will be used for encryption. Only one implementation can be set as isCurrent() == true.
If none of the configurations have isCurrent() == true then the SDK will use default encryption to encrypt stored data. At the same time it will keep the ability to decrypt old data, encrypted with custom encryption (if any).
Here’s an example of how you can set up SDK to use custom encryption (using Fernet encryption from https://github.com/l0s/fernet-java8 )

/**
 * Example of custom implementation of {@link Crypto} using Fernet algorithm
 */
public class FernetCrypto implements Crypto {
    private static final String VERSION = "fernet custom encryption";
    private boolean current;
    private Validator<String> validator;

    public FernetCrypto(boolean current) {
        this.current = current;
        this.validator = new StringValidator() {
        };
    }

    @Override
    public String encrypt(String text, SecretKey secretKey)
            throws StorageCryptoException {
        try {
            Key key = new Key(secretKey.getSecret());
            Token result = Token.generate(key, text);
            return result.serialise();
        } catch (IllegalStateException ex) {
            throw new StorageCryptoException("Encryption error", ex);
        }
    }

    @Override
    public String decrypt(String cipherText, SecretKey secretKey)
            throws StorageCryptoException {
        try {
            Key key = new Key(secretKey.getSecret());
            Token result = Token.fromString(cipherText);
            return result.validateAndDecrypt(key, validator);
        } catch (PayloadValidationException ex) {
            throw new StorageCryptoException("Decryption error", ex);
        }
    }

    @Override
    public String getVersion() {
        return VERSION;
    }

    @Override
    public boolean isCurrent() {
        return current;
    }
}

Project dependencies
The following is a list of compile dependencies for this project. These dependencies are required to compile and run the application:

GroupIdArtifactIdVersionType
javax.xml.bindjaxb-api2.3.1jar
javax.activationjavax.activation-api1.2.0jar
commons-codeccommons-codec1.14jar
org.apache.logging.log4jlog4j-api2.13.2jar
org.apache.logging.log4jlog4j-core2.13.2jar
com.google.code.gsongson2.8.6jar

Dependency Tree

compileClasspath
+--- javax.xml.bind:jaxb-api:2.3.1
|    \--- javax.activation:javax.activation-api:1.2.0
+--- commons-codec:commons-codec:1.14
+--- org.apache.logging.log4j:log4j-api:2.13.2
+--- org.apache.logging.log4j:log4j-core:2.13.2
|    \--- org.apache.logging.log4j:log4j-api:2.13.2
\--- com.google.code.gson:gson:2.8.6

Minimal JVM memory options

-Xms8m
-Xmx16m
-XX:MaxMetaspaceSize=32m