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
.encryptedfile. - 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
.encryptedfile 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
| File | Role |
|---|---|
app/services/crypto-core.ts | Encryption/decryption functions (Web Crypto API, shared client/server) |
app/services/crypto.server.ts | Server-side re-export of crypto-core |
app/services/crypto-cache.ts | In-memory password/private key cache (client-side, per session) |
app/components/ide/EncryptedFileViewer.tsx | Password prompt + decrypted file editor |
app/routes/api.drive.files.tsx | Server-side encrypt action |
Cryptographic Parameters
| Parameter | Value |
|---|---|
| RSA key size | 2048 bits |
| RSA padding | OAEP with SHA-256 |
| AES key size | 256 bits |
| AES mode | GCM (authenticated) |
| AES IV size | 12 bytes (96 bits) |
| KDF | PBKDF2 with SHA-256 |
| KDF iterations | 100,000 |
| KDF salt size | 16 bytes (128 bits) |
| Private key format | PKCS8 (DER, Base64-encoded) |
| Public key format | SPKI (DER, Base64-encoded) |