Skip to content

Encryption Architecture

Broker credentials are the most sensitive data in the platform. They grant direct access to trading accounts with real money. These credentials must be encrypted at rest, in transit, and during processing. The private key never leaves hardware.

4pass implements a 3-level encryption key hierarchy with per-user isolation, ensuring that a breach of one user's data cannot cascade to any other user.


Encryption Key Hierarchy

flowchart TB
    subgraph HSM["AWS KMS HSM (FIPS 140-2 Level 3)"]
        MASTER["Master Key<br/>(RSA-4096 or AES-256)<br/>Never exportable"]
    end

    subgraph Server["Application Server"]
        ACCOUNT_KEY["Per-Account Encryption Key<br/>32-byte AES-256 key<br/>(unique per trading account)"]
        DECRYPT["Decrypted Credentials<br/>(in-memory only, never persisted)"]
    end

    subgraph DB["PostgreSQL"]
        ENC_ACCOUNT_KEY["Encrypted Account Key<br/>(wrapped by master key)"]
        ENC_CREDS["Encrypted Credentials<br/>AES-256-GCM ciphertext<br/>+ 12-byte nonce + GCM tag"]
    end

    MASTER -->|"Envelope decrypt<br/>(KMS API call)"| ACCOUNT_KEY
    ENC_ACCOUNT_KEY -->|"Stored encrypted"| ACCOUNT_KEY
    ACCOUNT_KEY -->|"AES-256-GCM decrypt"| DECRYPT
    ENC_CREDS -->|"Read from DB"| DECRYPT

Three levels of protection:

Level Key Protected By Purpose
Level 1 KMS Master Key AWS HSM hardware (non-exportable) Encrypts/decrypts per-account keys
Level 2 Per-Account Key Encrypted by master key, stored in DB Encrypts/decrypts actual credentials
Level 3 Credential Ciphertext Encrypted by per-account key The broker API keys and secrets at rest

Why 3 Levels?

If the database is compromised, the attacker gets Level 3 (ciphertext) and Level 2 (encrypted account keys) but cannot decrypt anything without Level 1 (the KMS master key in the HSM). If application memory is dumped, the attacker gets one user's decrypted credentials but not others — each account has a unique key.


Frontend-to-Backend Encryption

When a user submits broker credentials through the dashboard, a hybrid RSA-4096 + AES-256-GCM encryption scheme protects the data end-to-end — even if TLS is compromised by a MITM proxy or CDN misconfiguration.

sequenceDiagram
    participant Browser as Browser (Web Crypto API)
    participant API as FastAPI
    participant KMS as AWS KMS HSM
    participant DB as PostgreSQL

    Browser->>API: 1. GET /auth/encryption-key
    API->>KMS: GetPublicKey(key_id)
    KMS-->>API: RSA-4096 public key (PEM)
    API-->>Browser: Public key (cached)

    Note over Browser: 2. Generate random AES-256 key<br/>crypto.subtle.generateKey("AES-GCM", 256)

    Note over Browser: 3. Encrypt credentials with AES-GCM<br/>Random 12-byte IV per operation

    Note over Browser: 4. Encrypt AES key with RSA-OAEP-256<br/>crypto.subtle.encrypt("RSA-OAEP", publicKey, aesKey)

    Browser->>API: 5. POST /trading/accounts<br/>{encrypted_key, encrypted_data, iv, tag}

    API->>KMS: 6. Decrypt(encrypted_key)<br/>Private key stays in HSM
    KMS-->>API: Decrypted AES-256 key

    API->>API: 7. AES-GCM decrypt credentials<br/>using decrypted AES key + IV + tag

    API->>API: 8. Re-encrypt with per-account<br/>storage key (AES-256-GCM)

    API->>DB: 9. Store encrypted credentials<br/>(nonce + ciphertext + GCM tag)

    Note over API: AES key and plaintext credentials<br/>exist only in memory, then garbage collected

Critical security properties of this flow:

  • Forward secrecy — each credential submission generates a fresh random AES-256 key. Compromising one request's key doesn't decrypt any other request.
  • HSM-backed decryption — the RSA-4096 private key never leaves the KMS HSM. Step 6 is a KMS API call; the plaintext AES key is returned to the application, but the private key remains in hardware.
  • Web Crypto API — the browser uses the native crypto.subtle API (not a JavaScript library), which runs in a secure context and is resistant to side-channel attacks.
  • No plaintext in transit — even though HTTPS encrypts the connection, the credential payload is independently encrypted. A TLS-intercepting proxy sees only ciphertext.

Storage Encryption (Per-User AES-256-GCM)

Once credentials reach the server, they are re-encrypted with a per-account key for storage in PostgreSQL.

Property Value
Algorithm AES-256-GCM (authenticated encryption)
Key size 32 bytes (256 bits) per trading account
Nonce (IV) 12 bytes (96 bits), random per encryption operation
Authentication GCM tag provides integrity + confidentiality
Key isolation Each TradingAccount has its own unique encryption key
Key storage Account keys encrypted with master key, stored in encrypted_key DB column
Encryption version Version 2 (current); Version 1 (Fernet/AES-128-CBC) for legacy data

Database column format:

┌──────────┬────────────────────────┬──────────┐
│ 12-byte  │     Variable-length    │ 16-byte  │
│  Nonce   │      Ciphertext        │ GCM Tag  │
└──────────┴────────────────────────┴──────────┘
         Base64-encoded, stored as TEXT

Nonce Uniqueness

A 12-byte random nonce is generated for every encryption operation using os.urandom(12). Reusing a nonce with the same key in AES-GCM completely breaks the security of the scheme. The random generation ensures this never happens (collision probability: 2^-48 per pair).


Master Key Management

The master key that protects per-account keys has two modes, selected automatically based on the USE_AWS_KMS environment variable:

Property Value
Key type KMS-managed AES-256 symmetric key
Envelope encryption KMS encrypts per-account keys; application uses plaintext data keys
HSM backing FIPS 140-2 Level 3 hardware security module
Audit All key operations logged in AWS CloudTrail
Rotation KMS automatic key rotation (annual)
Key ID AWS_KMS_KEY_ID environment variable
Region AWS_REGION / AWS_DEFAULT_REGION (default: ap-southeast-1)
Encrypt account key:
  KMS.Encrypt(plaintext=account_key, key_id=master_key)
    → encrypted_account_key (stored in DB)

Decrypt account key:
  KMS.Decrypt(ciphertext=encrypted_account_key)
    → plaintext account_key (in memory only)
Property Value
Key type Fernet symmetric key derived from ENCRYPTION_KEY env var
Derivation PBKDF2-HMAC-SHA256, 100,000 iterations
Salt Configurable via KEY_DERIVATION_SALT
Purpose Local development and testing only
PBKDF2(ENCRYPTION_KEY, salt, iterations=100000, hash=SHA256)
  → 32-byte Fernet key
  → Encrypt/decrypt per-account keys locally

Automatic Mode Selection

The application automatically selects KMS or local mode based on USE_AWS_KMS=true|false. No code changes required when moving between development and production — only environment variables change.


Encryption Versioning

4pass supports two encryption versions with automatic forward migration:

Version Algorithm Key Size Status Introduced
v1 Fernet (AES-128-CBC + HMAC-SHA256) 128-bit Legacy Initial release
v2 AES-256-GCM 256-bit Current (default) Security upgrade

Automatic upgrade on read:

flowchart TB
    READ["Read encrypted credential"] --> CHECK{"Version?"}
    CHECK -->|v1| DECRYPT_V1["Decrypt with Fernet"]
    CHECK -->|v2| DECRYPT_V2["Decrypt with AES-256-GCM"]
    DECRYPT_V1 --> REENCRYPT["Re-encrypt as v2"]
    REENCRYPT --> SAVE["Save upgraded ciphertext"]
    DECRYPT_V2 --> USE["Return plaintext"]
    SAVE --> USE
  • Existing v1 data is decrypted and transparently re-encrypted as v2 on the next read
  • New data always uses v2 (AES-256-GCM)
  • Backward compatibility maintained — both versions can be decrypted indefinitely
  • The version byte is prepended to the ciphertext, enabling future algorithm upgrades

Security Properties Summary

Property Mechanism Guarantee
Forward secrecy Unique AES-256 key per frontend request Compromising one submission doesn't decrypt others
Per-user isolation Unique 32-byte key per trading account Compromising one account key affects only that account
HSM-backed RSA-4096 private key in KMS FIPS 140-2 L3 hardware Private key never exists in software memory
Authenticated encryption AES-256-GCM with 128-bit authentication tag Tampering detected and rejected before decryption
No plaintext at rest Credentials encrypted before DB write Database dump reveals only ciphertext
No plaintext in transit RSA+AES hybrid encryption over HTTPS Double encryption layer — TLS failure doesn't expose credentials
Audit trail KMS operations logged in CloudTrail All decryption events are traceable and alertable
Key rotation KMS automatic annual rotation + version migration Forward security without downtime

What an Attacker Needs

To decrypt a single user's broker credentials, an attacker must simultaneously possess:

  1. Database access (to get the encrypted credential and encrypted account key)
  2. KMS Decrypt permission (to unwrap the account key with the master key)
  3. The specific account key (to decrypt the credential with AES-256-GCM)

Compromising any one of these alone reveals nothing.