Skip to content

Security Guide

Secure credential management and cryptographic practices.

Overview

Arbiter-Bot handles sensitive credentials for multiple platforms:

Platform Credential Type Security Level
Polymarket Ethereum Private Key Critical
Kalshi RSA Private Key Critical
Users API Keys Standard

All credentials are encrypted at rest using AES-256-GCM with HKDF-derived keys.

Security Architecture

┌─────────────────────────────────────────────────────────────────┐
│                       Security Layers                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌─────────────────┐    ┌─────────────────────────────────────┐ │
│  │ Secrets Provider │    │ Credential Store                   │ │
│  │                  │    │                                     │ │
│  │ - AWS Secrets    │───>│ - AES-256-GCM encryption           │ │
│  │   Manager        │    │ - HKDF key derivation              │ │
│  │ - Environment    │    │ - Per-user key isolation           │ │
│  │   Variables      │    │ - Memory zeroization               │ │
│  └─────────────────┘    └─────────────────────────────────────┘ │
│           │                           │                          │
│           │                           ▼                          │
│           │              ┌─────────────────────────────────────┐ │
│           │              │ Key Rotation Manager               │ │
│           │              │                                     │ │
│           └─────────────>│ - Version management               │ │
│                          │ - Zero-downtime rotation           │ │
│                          │ - Audit logging                     │ │
│                          └─────────────────────────────────────┘ │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Key Hierarchy

Master keys are derived into user-specific keys for isolation:

Master Key (from env or AWS KMS)
    └── HKDF-SHA256 with salt
            ├── User A Key (derived with user_id as info)
            │       │
            │       ├── Polymarket Private Key (encrypted)
            │       └── Kalshi Private Key (encrypted)
            └── User B Key (derived with different user_id)
                    ├── Polymarket Private Key (encrypted)
                    └── Kalshi Private Key (encrypted)

Credential Store

The CredentialStore provides secure encryption and decryption.

Creating a Store

use arbiter_engine::security::credential_store::CredentialStore;

// Development: use environment variable
let master_key = std::env::var("MASTER_KEY")
    .expect("MASTER_KEY required")
    .into_bytes();

// Must be exactly 32 bytes for AES-256
assert_eq!(master_key.len(), 32);

// Create store (generates random salt)
let store = CredentialStore::new(&master_key)?;

// IMPORTANT: Persist the salt for recovery
let salt = store.salt().clone();
persist_salt(&salt);  // Your persistence logic

// On restart, restore with salt
let store = CredentialStore::with_salt(&master_key, salt)?;

Encrypting Credentials

let user_id = "user123";
let credential_id = "poly_private_key";
let private_key = b"0x1234...";

// Encrypt with AAD binding (user_id + credential_id)
let encrypted = store.encrypt(user_id, credential_id, private_key)?;

// Store encrypted bytes (can go to database)
let bytes = encrypted.to_bytes();
save_to_database(user_id, credential_id, &bytes);

Decrypting Credentials

// Load from database
let bytes = load_from_database(user_id, credential_id);
let encrypted = EncryptedCredential::from_bytes(&bytes)?;

// Decrypt (returns SecretBytes that zeroizes on drop)
let secret = store.decrypt(user_id, credential_id, &encrypted)?;

// Use the secret
let key_bytes = secret.expose();
sign_transaction(key_bytes)?;

// When `secret` goes out of scope, memory is zeroized

One-Step Operations

// Encrypt and cache in one operation
store.encrypt_and_store(user_id, "kalshi_key", &kalshi_private_key)?;

// Retrieve and decrypt in one operation
let secret = store.get_and_decrypt(user_id, "kalshi_key")?;

Secrets Providers

Unified interface for retrieving secrets from various backends.

Environment Provider (Development)

use arbiter_engine::security::secrets_provider::{EnvSecretsProvider, SecretsProvider};

let provider = EnvSecretsProvider;

// Get individual secret
let api_key = provider.get_secret("POLY_API_KEY").await?;
println!("API Key: [REDACTED, {} bytes]", api_key.expose().len());

// Get field from structured secret (NAME_FIELD format)
let secret = provider.get_secret_field("DATABASE", "PASSWORD").await?;
// Looks for DATABASE_PASSWORD env var

AWS Secrets Manager (Production)

use arbiter_engine::security::secrets_provider::AwsSecretsProvider;

// Create provider with region
let provider = AwsSecretsProvider::new(Some("us-east-1")).await?
    .with_prefix("arbiter/prod")      // Optional prefix
    .with_cache_ttl(Duration::minutes(5));

// Get secret (cached for 5 minutes)
let secret = provider.get_secret("exchange-credentials").await?;

// Get field from JSON secret
let api_key = provider.get_secret_field("exchange-credentials", "poly_api_key").await?;

// Invalidate cache when needed
provider.invalidate("exchange-credentials").await;

Composite Provider (Fallback Chain)

use arbiter_engine::security::secrets_provider::CompositeSecretsProvider;

// Try AWS first, fall back to environment
let provider = CompositeSecretsProvider::default_chain(Some("us-east-1")).await;

// Will try AWS Secrets Manager first, then env vars
let secret = provider.get_secret("POLY_PRIVATE_KEY").await?;

Loading Exchange Credentials

use arbiter_engine::security::secrets_provider::load_exchange_credentials;

let provider = CompositeSecretsProvider::default_chain(None).await;

// Load all exchange credentials at once
let creds = load_exchange_credentials(&provider, "arbiter/credentials").await?;

// Use credentials
let poly_client = PolymarketClient::new(
    creds.poly_private_key.expose(),
    creds.poly_api_key.expose(),
    creds.poly_api_secret.expose(),
    creds.poly_api_passphrase.expose(),
)?;

Key Rotation

Zero-downtime key rotation for credential encryption.

Rotation Workflow

1. Add new key version     ──> Both keys active
2. Activate new version    ──> New encryptions use v2
3. Re-encrypt credentials  ──> All credentials now v2
4. Retire old version      ──> Old key no longer for decrypt
5. Remove old version      ──> Cleanup complete

Creating the Manager

use arbiter_engine::security::key_rotation::KeyRotationManager;

// Initial setup
let master_key = generate_master_key();
let manager = KeyRotationManager::new(&master_key)?;

// IMPORTANT: Persist the salt for each key version
for version_info in manager.list_versions() {
    persist_key_version(
        version_info.version,
        version_info.salt(),
    );
}

// On restart, restore with persisted salt
let salt = load_key_version_salt(1);
let manager = KeyRotationManager::with_key_and_salt(&master_key, salt)?;

Performing Rotation

// Step 1: Add new key version
let new_key = generate_new_master_key();
let new_version = manager.add_key_version(&new_key)?;
println!("Added key version: {}", new_version);

// Persist new salt immediately
let versions = manager.list_versions();
let new_info = versions.iter().find(|v| v.version == new_version).unwrap();
persist_key_version(new_version, new_info.salt());

// Step 2: Activate new version
manager.activate_key_version(new_version)?;
println!("Active version: {}", manager.active_version());

// Step 3: Re-encrypt all credentials
let count = manager.re_encrypt_all()?;
println!("Re-encrypted {} credentials", count);

// Step 4: Retire old version
manager.retire_key_version(1)?;

// Step 5: Remove old version (after grace period)
manager.remove_key_version(1)?;

Audit Logging

All rotation events are logged:

let log = manager.audit_log();

for entry in log {
    println!(
        "[{}] {:?} (version {}): {}",
        entry.timestamp,
        entry.event_type,
        entry.key_version,
        entry.details
    );
}

// Example output:
// [2026-01-23T10:00:00Z] KeyAdded (version 1): Initial key version added
// [2026-01-23T10:05:00Z] KeyAdded (version 2): Key version added for rotation
// [2026-01-23T10:05:01Z] RotationStarted (version 2): Switched active version
// [2026-01-23T10:05:02Z] CredentialReEncrypted (version 2): Credential poly_key...

Secure Memory Handling

Sensitive data is zeroized when no longer needed.

SecretBytes

use arbiter_engine::security::credential_store::SecretBytes;

let secret = store.decrypt(user_id, credential_id, &encrypted)?;

// Access the secret
let key_bytes = secret.expose();
perform_cryptographic_operation(key_bytes)?;

// When `secret` goes out of scope, memory is zeroized automatically
drop(secret);  // Explicit drop triggers zeroization

Debug Redaction

Secrets are never exposed in debug output:

let secret = SecretBytes::new(b"sensitive-data".to_vec());
println!("{:?}", secret);
// Output: SecretBytes([REDACTED, 14 bytes])

let encrypted = store.encrypt(user_id, credential_id, b"secret")?;
println!("{:?}", encrypted);
// Output: EncryptedCredential { nonce: "[REDACTED]", ciphertext_len: 22 }

Types That Zeroize

Type Zeroizes On
SecretBytes Drop
SecretValue Drop
EncryptedCredential Drop
DerivedKey Drop
CredentialStore Drop

AAD Binding

Ciphertext is bound to context using Additional Authenticated Data.

How It Works

// Encryption binds ciphertext to (user_id, credential_id)
let encrypted = store.encrypt("user123", "poly_key", &private_key)?;

// Decryption verifies AAD matches
store.decrypt("user123", "poly_key", &encrypted)?;  // OK

// Wrong user_id fails AAD verification
store.decrypt("user456", "poly_key", &encrypted);   // ERROR

// Wrong credential_id fails AAD verification
store.decrypt("user123", "kalshi_key", &encrypted); // ERROR

Security Properties

Attack Prevention
Cross-user ciphertext swap AAD includes user_id
Cross-credential swap AAD includes credential_id
Ciphertext modification GCM authentication tag
Nonce reuse Random 96-bit nonce per encryption

Production Configuration

AWS Secrets Manager Setup

Store credentials as JSON in Secrets Manager:

{
  "poly_private_key": "0x1234...",
  "poly_api_key": "pk_...",
  "poly_api_secret": "sk_...",
  "poly_api_passphrase": "passphrase",
  "kalshi_key_id": "key_abc123",
  "kalshi_private_key": "-----BEGIN RSA PRIVATE KEY-----\n..."
}

IAM Policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue"
      ],
      "Resource": [
        "arn:aws:secretsmanager:us-east-1:123456789:secret:arbiter/*"
      ]
    }
  ]
}

Master Key Management

For production, use AWS KMS for master key:

// Retrieve master key from KMS
let kms_client = aws_sdk_kms::Client::new(&config);
let response = kms_client
    .decrypt()
    .ciphertext_blob(Blob::new(encrypted_master_key))
    .send()
    .await?;

let master_key = response.plaintext().unwrap().as_ref();
let store = CredentialStore::with_salt(master_key, persisted_salt)?;

Error Handling

Credential Errors

use arbiter_engine::security::credential_store::CredentialError;

match store.decrypt(user_id, cred_id, &encrypted) {
    Ok(secret) => use_secret(secret),
    Err(CredentialError::NotFound(id)) => {
        // Credential doesn't exist
        return Err(anyhow!("Credential {} not found", id));
    }
    Err(CredentialError::DecryptionFailed(e)) => {
        // Tampering, wrong key, or AAD mismatch
        warn!(error = %e, "Decryption failed - possible tampering");
        return Err(anyhow!("Credential decryption failed"));
    }
    Err(CredentialError::InvalidMasterKeyLength) => {
        // Master key not 32 bytes
        panic!("Invalid master key configuration");
    }
    _ => return Err(anyhow!("Credential error")),
}

Secrets Provider Errors

use arbiter_engine::security::secrets_provider::SecretsError;

match provider.get_secret("API_KEY").await {
    Ok(value) => use_secret(value),
    Err(SecretsError::NotFound(name)) => {
        warn!(secret = %name, "Secret not found in any provider");
    }
    Err(SecretsError::AwsError(e)) => {
        error!(error = %e, "AWS Secrets Manager error");
    }
    Err(SecretsError::ParseError(e)) => {
        error!(error = %e, "Failed to parse secret JSON");
    }
    Err(SecretsError::ConfigError(e)) => {
        error!(error = %e, "Configuration error");
    }
}

Best Practices

Do

  • Use 32-byte (256-bit) master keys
  • Persist salts for each key version
  • Rotate keys regularly (quarterly recommended)
  • Use AWS Secrets Manager in production
  • Audit all credential access
  • Use SecretBytes for in-memory secrets
  • Verify provider availability before use

Don't

  • Store master keys in code or version control
  • Log secrets or encrypted credentials
  • Reuse nonces (random nonces prevent this)
  • Skip AAD binding
  • Store plaintext credentials anywhere
  • Share master keys across environments
  • Skip key rotation after security incidents

Security Checklist

Item Status
Master key from secure source (KMS/HSM)
Salts persisted for recovery
Credentials encrypted at rest
Memory zeroization enabled
Debug output redacted
AAD binding enforced
Key rotation scheduled
Audit logging enabled
IAM policies configured
Environment isolation

Troubleshooting

Decryption Fails After Restart

Error: DecryptionFailed - aead::Error

Cause: Salt was not persisted and restored.

Solution:

// Always persist salt after creating store
let salt = store.salt().clone();
save_to_persistent_storage(&salt);

// Restore salt on restart
let salt = load_from_persistent_storage();
let store = CredentialStore::with_salt(&master_key, salt)?;

AWS Secrets Manager Timeout

Error: AwsError - timeout

Cause: Network issues or IAM permissions.

Solution: 1. Check VPC endpoints for Secrets Manager 2. Verify IAM role has secretsmanager:GetSecretValue 3. Check security group egress rules

Key Version Not Found

Error: KeyVersionNotFound(2)

Cause: Attempting to use a removed key version.

Solution: 1. Don't remove key versions until all credentials are re-encrypted 2. Use re_encrypt_all() before retiring old versions 3. Maintain at least one active key version