Encryption

July 11, 2026 · View on GitHub

Hybrid RSA+AES encryption for protecting files stored on Google Drive.

Features

  • Hybrid Encryption: RSA-OAEP (2048-bit) + AES-256-GCM for each file
  • Password Protection: Private key encrypted with PBKDF2-derived key (100,000 iterations)
  • Per-File AES Key: Each encryption generates a fresh AES-256 key
  • Self-Contained Files: Each encrypted file contains everything needed for decryption (except the password)
  • Client-Side Decryption: Encrypted files and history records are decrypted in the browser using the Web Crypto API
  • Password Caching: Enter password once per session
  • Double-Encryption Prevention: Already-encrypted content is never re-encrypted

Encrypted File Format

Encrypted files use YAML frontmatter followed by Base64-encoded data:

---
encrypted: true
description: "Production deploy token"        # optional, unencrypted
publicMetadata: {"email":"ops@example.com"}  # optional, unencrypted
key: <Base64: encrypted private key (IV + AES-GCM ciphertext)>
salt: <Base64: PBKDF2 salt (16 bytes)>
---
<Base64: key_length(2 bytes) + RSA-encrypted AES key + IV(12 bytes) + AES-GCM ciphertext>

The key and salt fields are copied from your encryption settings at the time of encryption. This makes each file self-contained — decryptable with only the password.

description and publicMetadata are optional searchable metadata. They are deliberately stored outside the ciphertext so the Secret Manager can list and search files before they are unlocked. Treat them like the file name: never put a secret value in either field. publicMetadata accepts string values and is intended for visible identifiers such as an account email or environment name.

Binary File Encryption

Binary files (images, PDFs, etc.) are encoded before encryption using a prefix format:

BINARY:{mimeType}\n{base64data}

When encrypting, the binary content is read as Base64 and prefixed with BINARY:{mimeType}\n. This combined string is then encrypted using the standard hybrid encryption flow. On decryption, the BINARY: prefix is detected and the content is decoded back to binary.

How It Works

Setup (Once)

Password
  → Generate RSA-2048 key pair (SPKI/PKCS8, Base64)
  → Derive AES-256 key from password using PBKDF2 (SHA-256, 100k iterations, random salt)
  → Encrypt private key with derived key (AES-256-GCM)
  → Store in settings: publicKey, encryptedPrivateKey, salt

Encryption (Per File)

Plaintext
  → Generate random AES-256 key
  → Encrypt plaintext with AES-256-GCM (random IV)
  → Encrypt AES key with RSA public key (RSA-OAEP, SHA-256)
  → Pack: key_length(2) + encrypted_AES_key + IV(12) + ciphertext
  → Base64 encode → Wrap with YAML frontmatter

Decryption (Per File)

Password + salt
  → Derive AES-256 key via PBKDF2
  → Decrypt private key (AES-256-GCM)
  → Unwrap: extract encrypted AES key, IV, ciphertext
  → Decrypt AES key with RSA private key (RSA-OAEP)
  → Decrypt content with AES key (AES-256-GCM)
  → Plaintext

Fast Decryption (Cached Private Key)

After the first successful decryption, the decrypted private key is cached in memory (crypto-cache.ts). Subsequent decryptions use decryptWithPrivateKey() which skips the PBKDF2 key derivation step entirely:

Cached private key (already decrypted)
  → Unwrap: extract encrypted AES key, IV, ciphertext
  → Decrypt AES key with RSA private key (RSA-OAEP)
  → Decrypt content with AES key (AES-256-GCM)
  → Plaintext

Binary Layout of Encrypted Data

After Base64 decoding the data section:

Offset  Length    Content
0       2         AES key length (big-endian uint16)
2       key_len   RSA-OAEP encrypted AES-256 key
2+kl    12        AES-GCM IV (nonce)
2+kl+12 rest      AES-GCM ciphertext (includes 16-byte auth tag)

With RSA-2048 and OAEP-SHA256, key_len is typically 256 bytes.

Usage

Encrypt a File

Right-click a file in the file tree → Encrypt. The file content is encrypted and the file is renamed with .encrypted extension.

Open an Encrypted File

Click an .encrypted file in the tree. A password prompt appears. Enter your encryption password to view and edit the decrypted content.

The password is cached in memory for the session. Subsequent encrypted files are decrypted automatically.

Secret Manager Dashboard Widget

The dashboard's secret-manager widget provides a local-first interface for encrypted values:

  • Choose a root folder in the widget settings, or leave it blank to list every .encrypted file.
  • Create a named secret in the root or a nested directory. The value is encrypted immediately and written to the local cache; Push sends it to Drive.
  • Browse nested folders and search across file names, descriptions, and visible metadata fields.
  • Unlock a value with the encryption password, copy it, or edit and re-encrypt it in place. A private key or password already cached during the session unlocks it automatically.
  • Duplicate names are checked against both the local tree and Drive when online.

Creating or updating a secret requires encryption to be configured in Settings. The value field is encrypted; users must still avoid copying secret values into the unencrypted description or visible fields.

Permanently Decrypt a File

To permanently remove encryption from a file:

  • Right-click the .encrypted file in the file tree → Decrypt, or
  • Open the encrypted file and click the Decrypt button in the editor toolbar.

This decrypts the content on Drive, removes the .encrypted extension from the filename, and updates the sync metadata. For binary files, the original binary content is restored.

Temp Upload / Download

In the encrypted file editor:

  • Temp Upload: Re-encrypts the edited content and saves to the temp area (for cross-device transfer without full sync)
  • Temp Download: Fetches temp content, decrypts, and shows a diff for review

Chat & Workflow History Encryption

When encryption is enabled and encryptChatHistory / encryptWorkflowHistory are turned on in settings, new chat histories and workflow execution/request records are encrypted before saving to Drive. Older unencrypted records remain readable. When loading an encrypted record, the app decrypts it client-side using your cached private key or prompts for your password.

Workflow Encryption Command

The gemihub-command workflow node supports an encrypt command that encrypts a Drive file programmatically. It reads the file content, encrypts it using the configured encryption keys, updates the file on Drive, and appends .encrypted to the filename. Optional text becomes the searchable description; optional metadata accepts a JSON object of unencrypted string fields. Encryption must be configured in settings for this command to work.

The drive-read node can set saveMetadataTo while reading a file. For an encrypted file, that variable receives the unencrypted description and public metadata as JSON independently of the decrypted content saved through saveTo.

Security Notes

  • Password is never stored — only the encrypted private key and salt are saved in settings
  • Decryption happens in the browser — encrypted files and history records are decrypted client-side using the Web Crypto API; the server never sees plaintext of encrypted content
  • Encryption for saving uses the public key only — the server calls the same shared crypto module (crypto-core.ts) to encrypt with the public key; no password is needed for encryption
  • Each file has a unique AES key — compromising one file's AES key does not affect others
  • RSA key pair is generated once — stored encrypted with your password in settings
  • Forgetting your password means data loss — there is no recovery mechanism
  • Do not regenerate keys while encrypted files exist — regenerating the RSA key pair creates new keys; files encrypted with the previous key pair become permanently undecryptable

Python Decryption Script

Encrypted files are self-contained and can be decrypted without Gemini Hub using the following Python script.

Requirements

pip install cryptography

Script

#!/usr/bin/env python3
"""Decrypt Gemini Hub encrypted files without the application."""
import base64, sys, re, getpass
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.asymmetric import padding

def decrypt_file(filepath: str, password: str) -> str:
    with open(filepath, 'r') as f:
        content = f.read()

    # Parse YAML frontmatter
    match = re.match(r'^---\n([\s\S]*?)\n---\n([\s\S]*)$', content)
    if not match:
        raise ValueError("Invalid encrypted file format")

    frontmatter, encrypted_data = match.groups()
    key_match = re.search(r'key:\s*(.+)', frontmatter)
    salt_match = re.search(r'salt:\s*(.+)', frontmatter)
    if not key_match or not salt_match:
        raise ValueError("Missing key or salt in frontmatter")

    enc_private_key = base64.b64decode(key_match.group(1).strip())
    salt = base64.b64decode(salt_match.group(1).strip())
    data = base64.b64decode(encrypted_data.strip())

    # Derive key from password (PBKDF2-SHA256, 100k iterations)
    kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100000)
    derived_key = kdf.derive(password.encode())

    # Decrypt private key (AES-256-GCM)
    iv, enc_priv = enc_private_key[:12], enc_private_key[12:]
    private_key_pem = AESGCM(derived_key).decrypt(iv, enc_priv, None)
    private_key = serialization.load_der_private_key(base64.b64decode(private_key_pem), None)

    # Parse encrypted data: key_length(2) + enc_aes_key + iv(12) + enc_content
    key_len = (data[0] << 8) | data[1]
    enc_aes_key = data[2:2+key_len]
    content_iv = data[2+key_len:2+key_len+12]
    enc_content = data[2+key_len+12:]

    # Decrypt AES key with RSA private key (RSA-OAEP, SHA-256)
    aes_key = private_key.decrypt(enc_aes_key, padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None))

    # Decrypt content (AES-256-GCM)
    return AESGCM(aes_key).decrypt(content_iv, enc_content, None).decode('utf-8')

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} <encrypted_file>")
        sys.exit(1)
    password = getpass.getpass("Password: ")
    print(decrypt_file(sys.argv[1], password))

Usage

python decrypt.py path/to/file.md.encrypted
# Enter password when prompted

Key Files

FileRole
app/services/crypto-core.tsEncryption/decryption functions (Web Crypto API, shared client/server)
app/services/crypto.server.tsServer-side re-export of crypto-core
app/services/crypto-cache.tsIn-memory password/private key cache (client-side, per session)
app/components/ide/EncryptedFileViewer.tsxPassword prompt + decrypted file editor
app/routes/api.drive.files.tsxServer-side encrypt action

Cryptographic Parameters

ParameterValue
RSA key size2048 bits
RSA paddingOAEP with SHA-256
AES key size256 bits
AES modeGCM (authenticated)
AES IV size12 bytes (96 bits)
KDFPBKDF2 with SHA-256
KDF iterations100,000
KDF salt size16 bytes (128 bits)
Private key formatPKCS8 (DER, Base64-encoded)
Public key formatSPKI (DER, Base64-encoded)