Repository: robocorp/rpaframework

Issue:
Add Crypto library
A library with operations for:
- Encrypting / decrypting strings
- Encrypting / decrypting files
- Hashing strings
- Hashing files
- Generating keys

Optionally a small CLI utility for generating keys manually.


Discussion:

Resolving patch:
diff --git a/packages/main/src/RPA/Crypto.py b/packages/main/src/RPA/Crypto.py
new file mode 100644
--- /dev/null
+++ b/packages/main/src/RPA/Crypto.py
@@ -0,0 +1,336 @@
+import base64
+from enum import Enum, auto
+from pathlib import Path
+from typing import Optional, Union
+
+from cryptography.fernet import Fernet, InvalidToken
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.backends import default_backend
+from robot.libraries.BuiltIn import BuiltIn, RobotNotRunningError
+
+from RPA.RobotLogListener import RobotLogListener
+from RPA.Robocloud.Secrets import Secrets
+
+try:
+    BuiltIn().import_library("RPA.RobotLogListener")
+except RobotNotRunningError:
+    pass
+
+
+class Hash(Enum):
+    """Supported hashing algorithms."""
+
+    MD5 = auto()
+    SHA1 = auto()
+    SHA224 = auto()
+    SHA256 = auto()
+    SHA384 = auto()
+    SHA3_224 = auto()
+    SHA3_256 = auto()
+    SHA3_384 = auto()
+    SHA3_512 = auto()
+    SHA512 = auto()
+    SHA512_224 = auto()
+    SHA512_256 = auto()
+
+
+def to_hash_context(element: Hash) -> hashes.HashContext:
+    """Convert hash enum value to hash context instance."""
+    method = getattr(hashes, str(element.name))
+    return hashes.Hash(method(), backend=default_backend())
+
+
+class Crypto:
+    """Library for common encryption and hashing operations.
+
+    It uses the `Fernet <https://github.com/fernet/spec/blob/master/Spec.md>`_
+    format for encryption. More specifically, it uses AES in
+    CBC mode with a 128-bit key for encryption and HMAC with SHA256 for
+    authentication.
+
+    To use the encryption features, generate a key with the command line
+    utility ``rpa-crypto`` or with the keyword ``Generate Key``. Store
+    the key in a secure place, such as Robocorp Vault, and load it within
+    the execution before calling encryption/decryption keywords.
+
+    *Example usage with Robocorp Vault*
+
+    Create an encryption key with the CLI utility:
+
+    .. code-block:: console
+
+        > rpa-crypto key
+        rGx1edA07yz7uD08ChiPSunn8vaauRxw0pAbsal9zjM=
+
+    Store the key in Robocorp Vault, in this case with the name ``EncryptionKey``.
+
+    Load the key from the vault before encryption operations:
+
+    .. code-block:: robotframework
+
+        Use encryption key from vault    EncryptionKey
+        ${encrypted}=   Encrypt file    orders.xlsx
+        Add work item file    ${encrypted}    name=Orders
+
+    In another task, this same key can be used to decrypt the file:
+
+    .. code-block:: robotframework
+
+        Use encryption key from vault    EncryptionKey
+        ${encrypted}=    Get work item file    Orders
+        ${orders}=   Decrypt file    ${encrypted}
+    """
+
+    ROBOT_LIBRARY_SCOPE = "GLOBAL"
+    ROBOT_LIBRARY_DOC_FORMAT = "REST"
+
+    def __init__(self):
+        self._secrets = Secrets()
+        self._key = None
+        listener = RobotLogListener()
+        listener.register_protected_keywords(
+            ["RPA.Crypto.generate_key", "RPA.Crypto.use_encryption_key"]
+        )
+
+    def generate_key(self) -> str:
+        """Generate a Fernet encryption key as base64 string.
+
+        This key can be used for encryption/decryption operations
+        with this library.
+
+        *NOTE:* Store the generated key in a secure place!
+        If the key is lost, the encrypted data can not be recovered.
+        If anyone else gains access to it, they can decrypt your data.
+        """
+        return Fernet.generate_key().decode("utf-8")
+
+    def use_encryption_key(self, key: str):
+        """Set key for all following encryption/decryption operations.
+
+        :param key: Encryption key as base64 string
+
+        Assumes the given key has been generated previously using
+        either the keyword ``Generate Key`` or with the matching command
+        line utility.
+
+        Example:
+
+        .. code-block:: robotframework
+
+            ${key}=    Read file    encryption.key
+            Use encryption key      ${key}
+        """
+        self._key = Fernet(key)
+
+    def use_encryption_key_from_vault(self, name: str, key: Optional[str] = None):
+        """Load an encryption key from Robocorp Vault.
+
+        :param name: Name of secret in Vault
+        :param key: Name of encryption key in secret
+
+        If the secret only has one value, the key argument is optional.
+
+        Example:
+
+        .. code-block:: robotframework
+
+            # Secret with one value
+            Use encryption key from vault    Encryption
+            # Secret with multiple values
+            Use encryption key from vault    name=Encryption    key=CryptoKey
+        """
+        secret = self._secrets.get_secret(name)
+
+        if key:
+            value = secret[key]
+        elif len(secret) == 1:
+            value = list(secret.values())[0]
+        elif len(secret) == 0:
+            raise ValueError(f"Secret '{name}' has no values")
+        else:
+            options = ", ".join(str(k) for k in secret.keys())
+            raise ValueError(f"Secret '{name}' has multiple values: {options}")
+
+        self.use_encryption_key(value)
+
+    def hash_string(self, text: str, method: Hash = Hash.SHA1, encoding="utf-8") -> str:
+        """Calculate a hash from a string, in base64 format.
+
+        :param text: String to hash
+        :param method: Used hashing method
+        :param encoding: Used text encoding
+
+        Example:
+
+        .. code-block:: robotframework
+
+            ${digest}=    Hash string    A value that will be hashed
+            Should be equal    ${digest}    uSlyRHlbu8NzY29YMZhDUpdErP4=
+        """
+        if isinstance(text, str):
+            text = text.encode(encoding)
+
+        context = to_hash_context(method)
+        context.update(text)
+
+        digest = context.finalize()
+        return base64.b64encode(digest).decode("utf-8")
+
+    def hash_file(self, path: str, method: Hash = Hash.SHA1) -> str:
+        """Calculate a hash from a file, in base64 format.
+
+        :param path: Path to file
+        :param method: The used hashing method
+
+        Example:
+
+        .. code-block:: robotframework
+
+            ${digest}=    Hash file    orders.xlsx    method=MD5
+            Should not be equal    ${digest}    uSlyRHlbu8NzY29YMZhDUpdErP4=
+        """
+        context = to_hash_context(method)
+        with open(path, "rb") as infile:
+            while True:
+                chunk = infile.read(65536)
+                if not chunk:
+                    break
+                context.update(chunk)
+
+        digest = context.finalize()
+        return base64.b64encode(digest).decode("utf-8")
+
+    def encrypt_string(self, text: Union[str, bytes], encoding="utf-8") -> bytes:
+        """Encrypt a string.
+
+        :param text: Source text to encrypt
+        :param encoding: Used text encoding
+
+        Example:
+
+        .. code-block:: robotframework
+
+            Use encryption key    ${key}
+            ${token}=    Encrypt string    This is a secret, don't share it
+        """
+        if not self._key:
+            raise ValueError("No encryption key set")
+
+        if isinstance(text, str):
+            text = text.encode(encoding)
+
+        token = self._key.encrypt(text)
+        return token
+
+    def decrypt_string(
+        self, data: Union[str, bytes], encoding="utf-8"
+    ) -> Union[str, bytes]:
+        """Decrypt a string.
+
+        :param data: Encrypted data as base64 string
+        :param encoding: Original encoding of string
+
+        Returns the decrypted string that is parsed with the given encoding,
+        or if the encoding is ``None`` the raw bytes are returned.
+
+        Example:
+
+        .. code-block:: robotframework
+
+            Use encryption key    ${key}
+            ${text}=    Decrypt string    ${token}
+            Log    Secret string is: ${text}
+        """
+        if not self._key:
+            raise ValueError("No encryption key set")
+
+        if isinstance(data, str):
+            data = data.encode("utf-8")
+
+        try:
+            text = self._key.decrypt(data)
+        except InvalidToken as err:
+            raise ValueError(
+                "Failed to decrypt string (malformed content or invalid signature)"
+            ) from err
+
+        if encoding is not None:
+            text = text.decode(encoding)
+
+        return text
+
+    def encrypt_file(self, path: str, output: Optional[str] = None) -> str:
+        """Encrypt a file.
+
+        :param path: Path to source input file
+        :param output: Path to encrypted output file
+
+        If not output path is given, it will generate one from the input path.
+        The resulting output path is returned.
+
+        Example:
+
+        .. code-block:: robotframework
+
+            Use encryption key    ${key}
+            ${path}=    Encrypt file    orders.xlsx
+            Log    Path to encrypted file is: ${path}
+        """
+        path = Path(path)
+        if not self._key:
+            raise ValueError("No encryption key set")
+
+        if output:
+            output = Path(output)
+        else:
+            output = path.parent / (path.name + ".enc")
+
+        with open(path, "rb") as infile:
+            data = infile.read()
+            token = self._key.encrypt(data)
+
+        with open(output, "wb") as outfile:
+            outfile.write(token)
+            return str(output)
+
+    def decrypt_file(self, path: str, output: Optional[str] = None) -> str:
+        """Decrypt a file.
+
+        :param path: Path to encrypted input file
+        :param output: Path to decrypted output file
+
+        If not output path is given, it will generate one from the input path.
+        The resulting output path is returned.
+
+        Example:
+
+        .. code-block:: robotframework
+
+            Use encryption key    ${key}
+            ${path}=    Decrypt file    orders.xlsx.enc
+            Log    Path to decrypted file is: ${path}
+        """
+        path = Path(path)
+        if not self._key:
+            raise ValueError("No encryption key set")
+
+        if output:
+            output = Path(output)
+        elif path.name.endswith(".enc"):
+            output = path.parent / path.name[: -len(".enc")]
+        else:
+            parts = (path.stem, "dec", path.suffix[1:])
+            output = path.parent / ".".join(part for part in parts if part.strip())
+
+        try:
+            with open(path, "rb") as infile:
+                token = infile.read()
+                data = self._key.decrypt(token)
+        except InvalidToken as err:
+            raise ValueError(
+                "Failed to decrypt file (malformed content or invalid signature)"
+            ) from err
+
+        with open(output, "wb") as outfile:
+            outfile.write(data)
+            return str(output)
diff --git a/packages/main/src/RPA/scripts/crypto.py b/packages/main/src/RPA/scripts/crypto.py
new file mode 100644
--- /dev/null
+++ b/packages/main/src/RPA/scripts/crypto.py
@@ -0,0 +1,180 @@
+import argparse
+import logging
+import sys
+import traceback
+from RPA.Crypto import Crypto, Hash
+
+
+DESCRIPTION = """
+Command-line utility for generating encryption keys, and
+hashing, encrypting, and decrypting data. Commonly
+used with the RPA.Crypto library.
+"""
+
+
+def create_parser():
+    """Create argument parser with all available subcommands."""
+    parser = argparse.ArgumentParser(
+        description=DESCRIPTION.strip(), formatter_class=argparse.RawTextHelpFormatter
+    )
+    parser.add_argument(
+        "-v", "--verbose", action="store_true", help="be more talkative"
+    )
+
+    subparsers = parser.add_subparsers(title="available commands")
+    key_parser(subparsers)
+    hash_parser(subparsers)
+    encrypt_parser(subparsers)
+    decrypt_parser(subparsers)
+
+    return parser
+
+
+def key_args(parser):
+    """Add common argument group for providing encryption keys."""
+    key = parser.add_argument_group("encryption key arguments")
+    mxg = key.add_mutually_exclusive_group(required=True)
+    mxg.add_argument("-t", "--text", help="encryption key as text")
+    mxg.add_argument("-f", "--file", help="encryption key as file")
+    mxg.add_argument("-s", "--secret", help="encryption key as Robocorp Vault secret")
+
+
+def load_key(args):
+    """Parse encryption key arguments into a Crypto library instance."""
+    lib = Crypto()
+
+    if args.text:
+        lib.use_encryption_key(args.text)
+    elif args.file:
+        with open(args.file) as infile:
+            lib.use_encryption_key(infile.read())
+    elif args.secret:
+        name, _, key = args.secret.partition(".")
+        lib.use_encryption_key_from_vault(name, key)
+    else:
+        raise RuntimeError("Unhandled encryption key type")
+
+    return lib
+
+
+def key_parser(parent):
+    """Create parser for 'key' subcommand."""
+    parser = parent.add_parser("key", help="generate encryption key")
+    parser.set_defaults(func=key_command)
+
+
+def key_command(args):
+    """Execute 'key' subcommand."""
+    del args  # unused
+
+    key = Crypto().generate_key()
+    print(key)
+
+    logging.warning(
+        "\nNOTE: Store the generated key in a secure place!"
+        "\nIf the key is lost, the encrypted data can not be recovered."
+        "\nIf anyone else gains access to it, they can decrypt your data."
+    )
+
+
+def hash_parser(parent):
+    """Create parser for 'hash' subcommand."""
+    parser = parent.add_parser("hash", help="calculate hash digest")
+    parser.set_defaults(func=hash_command)
+    parser.add_argument(
+        "input", nargs="?", type=argparse.FileType("r"), default=sys.stdin
+    )
+    parser.add_argument(
+        "-m",
+        "--method",
+        choices=[h.name for h in Hash],
+        default="SHA1",
+        help="hashing method (default: %(default)s)",
+    )
+
+
+def hash_command(args):
+    """Execute 'hash' subcommand."""
+    method = Hash[args.method]
+    data = args.input.read()
+    digest = Crypto().hash_string(data, method)
+    print(digest)
+
+
+def encrypt_parser(parent):
+    """Create parser for 'encrypt' subcommand."""
+    parser = parent.add_parser("encrypt", help="encrypt data")
+    parser.set_defaults(func=encrypt_command)
+    parser.add_argument(
+        "input",
+        help="path to input file, or stdin",
+        nargs="?",
+        type=argparse.FileType("rb"),
+        default=sys.stdin,
+    )
+    parser.add_argument(
+        "output",
+        help="path to output file, or stdout",
+        nargs="?",
+        type=argparse.FileType("wb"),
+        default=sys.stdout.buffer,
+    )
+    key_args(parser)
+
+
+def encrypt_command(args):
+    """Execute 'encrypt' subcommand."""
+    lib = load_key(args)
+    token = lib.encrypt_string(args.input.read())
+    args.output.write(token)
+
+
+def decrypt_parser(parent):
+    """Create parser for 'decrypt' subcommand."""
+    parser = parent.add_parser("decrypt", help="decrypt data")
+    parser.set_defaults(func=decrypt_command)
+    parser.add_argument(
+        "input",
+        help="path to input file, or stdin",
+        nargs="?",
+        type=argparse.FileType("rb"),
+        default=sys.stdin,
+    )
+    parser.add_argument(
+        "output",
+        help="path to output file, or stdout",
+        nargs="?",
+        type=argparse.FileType("wb"),
+        default=sys.stdout.buffer,
+    )
+    key_args(parser)
+
+
+def decrypt_command(args):
+    """Execute 'decrypt' subcommand."""
+    lib = load_key(args)
+    token = lib.decrypt_string(args.input.read(), encoding=None)
+    args.output.write(token)
+
+
+def main():
+    parser = create_parser()
+    args = parser.parse_args()
+
+    logging.basicConfig(
+        level=logging.DEBUG if args.verbose else logging.INFO, format="%(message)s"
+    )
+
+    try:
+        args.func(args)
+    except KeyboardInterrupt:
+        logging.warning("Aborted by user")
+        sys.exit(1)
+    except Exception as exc:  # pylint: disable=broad-except
+        logging.debug(traceback.format_exc())
+        logging.error("Command failed: %s", exc)
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()


Test patch:
diff --git a/packages/main/tests/python/test_crypto.py b/packages/main/tests/python/test_crypto.py
new file mode 100644
--- /dev/null
+++ b/packages/main/tests/python/test_crypto.py
@@ -0,0 +1,216 @@
+import base64
+import contextlib
+import os
+import tempfile
+from contextlib import contextmanager
+
+import mock
+import pytest
+from RPA.Crypto import Crypto, Hash
+from RPA.Robocloud.Secrets import Secret
+
+
+@contextmanager
+def temp_path(content=None, suffix=None):
+    with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp:
+        path = temp.name
+        if content:
+            temp.write(content)
+    try:
+        yield path
+    finally:
+        with contextlib.suppress(FileNotFoundError):
+            os.unlink(path)
+
+
+def test_generate_key():
+    key = Crypto().generate_key()
+    assert isinstance(key, str)
+    assert len(key) > 0
+
+    keys = [Crypto().generate_key() for _ in range(50)]
+    assert len(set(keys)) == len(keys)
+
+
+def test_set_key():
+    lib = Crypto()
+    assert lib._key is None
+
+    key = lib.generate_key()
+    assert lib._key is None
+
+    lib.use_encryption_key(key)
+    assert lib._key is not None
+
+    with pytest.raises(ValueError):
+        lib.use_encryption_key("somethingelse")
+
+
+def test_hash_string():
+    result = Crypto().hash_string("avalue")
+    assert result == "eNOTzEOqI0k2ijVhEmKow1YPFGo="
+
+
+def test_hash_string_unicode():
+    result = Crypto().hash_string("谷歌翻译是你的朋友")
+    assert result == "J5TSpa76g8qV1pws6H6ztDEfYeo="
+
+
+def test_hash_string_method():
+    result = Crypto().hash_string("avalue", method=Hash.MD5)
+    assert result == "3ObaKiohbtSuG4Q8+kIKTg=="
+
+
+def test_hash_file():
+    with temp_path("avalue".encode("utf-8")) as infile:
+        result = Crypto().hash_file(infile)
+        assert result == "eNOTzEOqI0k2ijVhEmKow1YPFGo="
+
+
+def test_encrypt_decrypt_string():
+    lib = Crypto()
+
+    key = lib.generate_key()
+    lib.use_encryption_key(key)
+
+    text = "An example string\nWith some secret content"
+    encrypted = lib.encrypt_string(text)
+
+    assert encrypted != text
+    assert base64.urlsafe_b64decode(encrypted) != text
+
+    decrypted = lib.decrypt_string(encrypted)
+    assert decrypted == text
+
+
+def test_decrypt_wrong_key():
+    lib = Crypto()
+
+    key1 = lib.generate_key()
+    key2 = lib.generate_key()
+    assert key1 != key2
+
+    lib.use_encryption_key(key1)
+    text = "An example string\nWith some secret content"
+    token = lib.encrypt_string(text)
+
+    lib.use_encryption_key(key2)
+    with pytest.raises(ValueError):
+        lib.decrypt_string(token)
+
+
+def test_encrypt_decrypt_file():
+    lib = Crypto()
+
+    key = lib.generate_key()
+    lib.use_encryption_key(key)
+
+    text = "An example string\nWith some secret content"
+
+    with temp_path() as encrypted:
+        with temp_path(text.encode("utf-8")) as original:
+            result = lib.encrypt_file(original, encrypted)
+            assert result == encrypted
+            assert os.stat(result).st_size > 0
+
+        with temp_path() as decrypted:
+            result = lib.decrypt_file(encrypted, decrypted)
+            assert result == decrypted
+            with open(result) as resultfile:
+                assert resultfile.read() == text
+
+
+def test_encrypt_file_default_paths():
+    lib = Crypto()
+
+    key = lib.generate_key()
+    lib.use_encryption_key(key)
+
+    text = "An example string\nWith some secret content"
+
+    with temp_path(text.encode("utf-8")) as original:
+        encrypted, decrypted = "", ""
+        try:
+            encrypted = lib.encrypt_file(original)
+            assert encrypted == original + ".enc"
+
+            decrypted = lib.decrypt_file(encrypted)
+            assert decrypted == original
+        finally:
+            with contextlib.suppress(FileNotFoundError):
+                os.unlink(encrypted)
+                os.unlink(decrypted)
+
+
+def test_encrypt_file_suffix():
+    lib = Crypto()
+
+    key = lib.generate_key()
+    lib.use_encryption_key(key)
+
+    text = "An example string\nWith some secret content"
+
+    with temp_path(text.encode("utf-8")) as original:
+        with temp_path(suffix=".bin") as encrypted:
+            lib.encrypt_file(original, encrypted)
+            result = lib.decrypt_file(encrypted)
+            try:
+                assert os.path.isfile(result)
+                assert result.endswith(".dec.bin")
+            finally:
+                with contextlib.suppress(FileNotFoundError):
+                    os.unlink(result)
+
+
+def test_set_key_vault_no_key():
+    lib = Crypto()
+    lib._secrets = mock_secrets = mock.Mock()
+
+    key = lib.generate_key()
+    mock_secrets.get_secret.return_value = Secret("MockSecret", "", {"key": key})
+
+    lib.use_encryption_key_from_vault("SomeKeyValue")
+    assert mock_secrets.get_secret.called_once_with("SomeKeyValue")
+    assert lib._key is not None
+
+
+def test_set_key_vault_key():
+    lib = Crypto()
+    lib._secrets = mock_secrets = mock.Mock()
+
+    key = lib.generate_key()
+    mock_secrets.get_secret.return_value = Secret(
+        "MockSecret", "", {"first": "something", "second": key}
+    )
+
+    lib.use_encryption_key_from_vault("SomeKeyValue", "second")
+    assert mock_secrets.get_secret.called_once_with("SomeKeyValue")
+    assert lib._key is not None
+
+
+def test_set_key_vault_error_multiple():
+    lib = Crypto()
+    lib._secrets = mock_secrets = mock.Mock()
+
+    key = lib.generate_key()
+    mock_secrets.get_secret.return_value = Secret(
+        "MockSecret", "", {"first": "something", "second": key}
+    )
+
+    with pytest.raises(ValueError):
+        lib.use_encryption_key_from_vault("SomeKeyValue")
+    assert mock_secrets.get_secret.called_once_with("SomeKeyValue")
+    assert lib._key is None
+
+
+def test_set_key_vault_error_empty():
+    lib = Crypto()
+    lib._secrets = mock_secrets = mock.Mock()
+
+    key = lib.generate_key()
+    mock_secrets.get_secret.return_value = Secret("MockSecret", "", {})
+
+    with pytest.raises(ValueError):
+        lib.use_encryption_key_from_vault("SomeKeyValue")
+    assert mock_secrets.get_secret.called_once_with("SomeKeyValue")
+    assert lib._key is None
diff --git a/packages/main/tests/robot/test_crypto.robot b/packages/main/tests/robot/test_crypto.robot
new file mode 100644
--- /dev/null
+++ b/packages/main/tests/robot/test_crypto.robot
@@ -0,0 +1,24 @@
+*** Settings ***
+Library    RPA.Crypto
+
+*** Tasks ***
+Generate and use key
+    ${key}=    Generate key
+    Should not be empty    ${key}
+    Use encryption key     ${key}
+
+Hash string defaults
+    ${hash}=    Hash string    avalue
+    Should be equal    ${hash}    eNOTzEOqI0k2ijVhEmKow1YPFGo=
+
+Hash string change method
+    ${hash}=    Hash string    avalue    MD5
+    Should be equal    ${hash}    3ObaKiohbtSuG4Q8+kIKTg==
+
+Encrypt string
+    ${key}=    Generate key
+    Use encryption key    ${key}
+    ${enc}=    Encrypt string    Something very important here
+    Log    ${enc}
+    ${dec}=    Decrypt string    ${enc}
+    Should be equal    ${dec}    Something very important here
diff --git a/packages/main/tests/robot/test_crypto_cli.robot b/packages/main/tests/robot/test_crypto_cli.robot
new file mode 100644
--- /dev/null
+++ b/packages/main/tests/robot/test_crypto_cli.robot
@@ -0,0 +1,39 @@
+*** Settings ***
+Documentation     Test cases for for Crypto library CLI
+Suite setup       Create test data
+Library           OperatingSystem
+Library           Process
+
+*** Variables ***
+${PLAINTEXT_FILE}    ${OUTPUT_DIR}${/}plaintext.txt
+
+*** Tasks ***
+Generate key
+    ${result}=    Command    key
+    Should not be empty      ${result.stdout}
+
+Hash string defaults
+    ${result}=    Command    hash  ${PLAINTEXT_FILE}
+    Should be equal    ${result.stdout}    eNOTzEOqI0k2ijVhEmKow1YPFGo=
+
+Hash string change method
+    ${result}=    Command    hash  --method=MD5  ${PLAINTEXT_FILE}
+    Should be equal    ${result.stdout}    3ObaKiohbtSuG4Q8+kIKTg==
+
+Encrypt string
+    ${key}=    Command  key
+    ${enc}=    Command  encrypt  -t  ${key.stdout}  ${PLAINTEXT_FILE}  ${OUTPUT_DIR}${/}encrypted.bin
+    ${dec}=    Command  decrypt  -t  ${key.stdout}  ${OUTPUT_DIR}${/}encrypted.bin
+    Should be equal    ${dec.stdout}    avalue
+
+*** Keywords ***
+Create test data
+    Create file    ${PLAINTEXT_FILE}    avalue
+
+Command
+    [Arguments]   @{args}
+    ${is_win}=    Evaluate         platform.system() == "Windows"    platform
+    ${script}=    Set variable if  ${is_win}    rpa-crypto.cmd    rpa-crypto
+    ${result}=    Run process      ${script}    --verbose    @{args}
+    Should be equal as integers    ${result.rc}    0    message=${result.stderr}
+    [Return]      ${result}