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
SecretBytesfor 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¶
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¶
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¶
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
Related Documentation¶
- ADR-009: Credential Management - Architecture decision
- Deployment Guide - Production setup
- Environment Variables - Configuration reference
- Multi-Tenancy - Per-user isolation