Fishbowl
Browse documentation

Encryption

Overview

The simplest setup needs no encryption at all. For one or a few devices, enter your calendar credentials directly on each device during its on-device setup, where they stay on that device. The browser encryption described below is for entering credentials once in the web dashboard and pushing them to your devices remotely, which is convenient for larger deployments.

Fishbowl uses hybrid RSA + AES encryption to protect calendar credentials. When you enter credentials (such as Microsoft App Registration secrets, EWS passwords, or Google service account keys) in the web dashboard, they are encrypted in the browser using the device's RSA public key before being sent to the server.

The server stores only ciphertext and cannot decrypt your credentials. Only the Fishbowl device that holds the corresponding private key can decrypt and use them.

How It Works

Each Fishbowl device generates an RSA-2048 key pair on first boot. The public key is uploaded to the server; the private key never leaves the device.

When you save credentials through the web dashboard, the browser performs the following steps:

  1. Generates a random AES-256 key (32 bytes) and IV (16 bytes)
  2. Encrypts the plaintext JSON with AES-256-CBC
  3. Wraps the AES key + IV (48 bytes) with RSA-OAEP (SHA-256 hash, SHA-1 MGF1)
  4. Produces a dot-separated string: base64(RSA(aesKey + iv)).base64(AES(plaintext))

The device decrypts by reversing the process: RSA-OAEP decrypt to recover the AES key and IV, then AES-256-CBC decrypt to recover the plaintext JSON.

Payload Format

The encrypted payload is a string with two Base64 segments separated by a dot:

<base64 RSA ciphertext>.<base64 AES ciphertext>
Segment Contents
1 RSA-OAEP encrypted (AES key ∥ IV): 48 bytes of plaintext wrapped with the device's public key
2 AES-256-CBC encrypted JSON credentials

Credential JSON Structures

The plaintext that gets encrypted is a JSON string. The structure depends on the calendar type.

Microsoft App Registration (calendar type 6)

{
    "client_id": "your-client-id",
    "tenant_id": "your-tenant-id",
    "client_secret": "your-client-secret"
}

EWS / Microsoft Exchange (calendar type 3)

{
    "server_address": "https://outlook.office365.com/EWS/Exchange.asmx",
    "username": "[email protected]",
    "password": "your-password",
    "is_application_impersonator": false
}

Google Service Account (calendar type 7)

The plaintext is the raw Google service account JSON key file contents (the JSON file you download from the Google Cloud Console).

Self-Service Encryption

If you prefer to encrypt credentials yourself, for example, from a provisioning script or CI pipeline, you can use the API directly.

The steps are:

  1. Fetch the device's public key from the API (the PEM-encoded RSA public key is available on the device resource)
  2. Encrypt the credentials JSON using the hybrid scheme described above
  3. POST the encrypted payload to POST /api/devices/encrypted-payloads

Below are sample implementations in Python, Node.js, and PHP.

Python

Using the cryptography library:

import os
import base64
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7


def hybrid_encrypt(pem_public_key: str, plaintext: str) -> str:
    """
    Encrypt plaintext using hybrid RSA-OAEP + AES-256-CBC.
    Returns a dot-separated string: base64(RSA(key+iv)).base64(AES(plaintext))
    """
    # Load the RSA public key
    public_key = serialization.load_pem_public_key(pem_public_key.encode())

    # Generate random AES-256 key (32 bytes) and IV (16 bytes)
    aes_key = os.urandom(32)
    iv = os.urandom(16)

    # AES-256-CBC encrypt the plaintext (with PKCS7 padding)
    padder = PKCS7(128).padder()
    padded = padder.update(plaintext.encode("utf-8")) + padder.finalize()
    cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
    encryptor = cipher.encryptor()
    aes_ciphertext = encryptor.update(padded) + encryptor.finalize()

    # RSA-OAEP encrypt the AES key + IV (48 bytes)
    rsa_ciphertext = public_key.encrypt(
        aes_key + iv,
        padding.OAEP(
            mgf=padding.MGF1(algorithm=hashes.SHA1()),
            algorithm=hashes.SHA256(),
            label=None,
        ),
    )

    return base64.b64encode(rsa_ciphertext).decode() + "." + base64.b64encode(aes_ciphertext).decode()

Node.js

Using the built-in crypto module:

const crypto = require("crypto");

function hybridEncrypt(pemPublicKey, plaintext) {
  // Generate random AES-256 key (32 bytes) and IV (16 bytes)
  const aesKey = crypto.randomBytes(32);
  const iv = crypto.randomBytes(16);

  // AES-256-CBC encrypt the plaintext
  const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
  const aesCiphertext = Buffer.concat([
    cipher.update(plaintext, "utf8"),
    cipher.final(),
  ]);

  // RSA-OAEP encrypt the AES key + IV (48 bytes)
  const rsaCiphertext = crypto.publicEncrypt(
    {
      key: pemPublicKey,
      padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
      oaepHash: "sha256",
      // Node.js uses SHA-1 for MGF1 by default, matching our scheme
    },
    Buffer.concat([aesKey, iv])
  );

  return rsaCiphertext.toString("base64") + "." + aesCiphertext.toString("base64");
}

PHP

Using openssl_* functions:

function hybridEncrypt(string $pemPublicKey, string $plaintext): string
{
    // Generate random AES-256 key (32 bytes) and IV (16 bytes)
    $aesKey = openssl_random_pseudo_bytes(32);
    $iv = openssl_random_pseudo_bytes(16);

    // AES-256-CBC encrypt the plaintext
    $aesCiphertext = openssl_encrypt(
        $plaintext,
        'aes-256-cbc',
        $aesKey,
        OPENSSL_RAW_DATA,
        $iv
    );

    // RSA-OAEP encrypt the AES key + IV (48 bytes)
    // Note: openssl_public_encrypt with OPENSSL_PKCS1_OAEP_PADDING uses
    // SHA-1 for both the hash and MGF1 by default.
    // To match our scheme (SHA-256 hash, SHA-1 MGF1), use openssl_pkey_get_public
    // with the low-level EVP functions or use phpseclib.
    // The example below uses phpseclib/phpseclib v3:
    $publicKey = \phpseclib3\Crypt\PublicKeyLoader::load($pemPublicKey)
        ->withHash('sha256')
        ->withMGFHash('sha1');

    $rsaCiphertext = $publicKey->encrypt($aesKey . $iv);

    return base64_encode($rsaCiphertext) . '.' . base64_encode($aesCiphertext);
}

Contact

Contact us if you would like assistance setting up automated credential provisioning.