Security Best Practices
Learning Objectives
Implement secure key management with proper storage and access controls
Validate all inputs to prevent injection and manipulation attacks
Verify transactions to prevent partial payment and other exploits
Secure infrastructure with proper authentication and encryption
Audit code for common XRPL security vulnerabilities
// NEVER DO THIS
const SECRET = 'sEdXXXXXXXXXXXXXXXXXXXXXX' // Hardcoded secret
// NEVER DO THIS
const wallet = xrpl.Wallet.fromSeed(process.env.XRPL_SECRET) // Secrets in env vars
// BETTER: Use secret management service
class SecureKeyManager {
constructor(secretsClient) {
this.secretsClient = secretsClient // AWS Secrets Manager, HashiCorp Vault, etc.
this.cachedWallet = null
}
async getWallet() {
if (this.cachedWallet) return this.cachedWallet
// Fetch from secure storage
const secret = await this.secretsClient.getSecret('xrpl/hot-wallet')
// Validate format before use
if (!this.isValidSecret(secret.seed)) {
throw new Error('Invalid secret format')
}
this.cachedWallet = xrpl.Wallet.fromSeed(secret.seed)
// Clear from memory after TTL
setTimeout(() => {
this.cachedWallet = null
}, 300000) // 5 minutes
return this.cachedWallet
}
isValidSecret(seed) {
// Validate seed format
if (!seed) return false
if (typeof seed !== 'string') return false
if (!seed.startsWith('s')) return false
if (seed.length < 28 || seed.length > 35) return false
// Try to derive wallet
try {
xrpl.Wallet.fromSeed(seed)
return true
} catch {
return false
}
}
}
```
class WalletArchitecture {
/*
HOT WALLET:
- Holds operational funds (e.g., 1-24 hours of expected volume)
- Keys accessible to application
- Automated transactions
- Monitor for anomalies
COLD WALLET:
- Holds majority of funds
- Keys in offline/hardware storage
- Manual transactions only
- Replenishes hot wallet periodically
*/
constructor(hotWalletManager, coldWalletAddress) {
this.hotWallet = hotWalletManager
this.coldAddress = coldWalletAddress
// Operational limits
this.maxSingleTransaction = 10000 // XRP
this.dailyLimit = 100000 // XRP
this.dailyUsed = 0
}
async sendPayment(destination, amountXRP) {
// Enforce limits
if (amountXRP > this.maxSingleTransaction) {
throw new Error(Amount ${amountXRP} exceeds single transaction limit)
}
if (this.dailyUsed + amountXRP > this.dailyLimit) {
throw new Error(Would exceed daily limit. Used: ${this.dailyUsed})
}
const wallet = await this.hotWallet.getWallet()
// Execute transaction
const result = await this.executePayment(wallet, destination, amountXRP)
if (result.success) {
this.dailyUsed += amountXRP
}
return result
}
async checkHotWalletBalance(client) {
const wallet = await this.hotWallet.getWallet()
const info = await client.request({
command: 'account_info',
account: wallet.address,
ledger_index: 'validated'
})
return parseInt(info.result.account_data.Balance) / 1_000_000
}
}
```
async function createMultiSigPayment(client, signers, destination, amount) {
// Multi-sig requires coordinating multiple signers
const payment = {
TransactionType: 'Payment',
Account: multiSigAddress,
Destination: destination,
Amount: xrpl.xrpToDrops(amount)
}
// Autofill without signing
const prepared = await client.autofill(payment)
// Collect signatures from required signers
const signatures = []
for (const signer of signers) {
const signed = signer.wallet.sign(prepared, true) // multisign = true
signatures.push({
Signer: {
Account: signer.wallet.address,
SigningPubKey: signed.signedTransaction.SigningPubKey,
TxnSignature: signed.signedTransaction.TxnSignature
}
})
}
// Combine signatures
const multiSigned = xrpl.multisign([...signatures.map(s => s.Signer)])
return client.submit(multiSigned)
}
```
class InputValidator {
static validateAddress(address, fieldName = 'address') {
if (!address) {
throw new ValidationError(${fieldName} is required)
}
if (typeof address !== 'string') {
throw new ValidationError(${fieldName} must be a string)
}
// Basic format check
if (!address.startsWith('r')) {
throw new ValidationError(${fieldName} must start with 'r')
}
if (address.length < 25 || address.length > 35) {
throw new ValidationError(${fieldName} has invalid length)
}
// Use library validation
if (!xrpl.isValidAddress(address)) {
throw new ValidationError(${fieldName} is not a valid XRPL address)
}
return address
}
static validateAmount(amount, fieldName = 'amount') {
if (amount === undefined || amount === null) {
throw new ValidationError(${fieldName} is required)
}
// Handle XRP amounts
if (typeof amount === 'string' || typeof amount === 'number') {
const numAmount = Number(amount)
if (isNaN(numAmount)) {
throw new ValidationError(${fieldName} must be a valid number)
}
if (numAmount <= 0) {
throw new ValidationError(${fieldName} must be positive)
}
if (numAmount > 100_000_000_000) { // Max XRP
throw new ValidationError(${fieldName} exceeds maximum)
}
// Check decimal precision (max 6 decimals for XRP)
const decimalPart = amount.toString().split('.')[1] || ''
if (decimalPart.length > 6) {
throw new ValidationError(${fieldName} has too many decimal places)
}
return numAmount
}
// Handle issued currency amounts
if (typeof amount === 'object') {
this.validateAddress(amount.issuer, ${fieldName}.issuer)
this.validateCurrencyCode(amount.currency, ${fieldName}.currency)
this.validateAmount(amount.value, ${fieldName}.value)
return amount
}
throw new ValidationError(${fieldName} has invalid format)
}
static validateCurrencyCode(currency, fieldName = 'currency') {
if (!currency) {
throw new ValidationError(${fieldName} is required)
}
// Standard 3-character code
if (currency.length === 3 && /^[A-Z0-9]+$/.test(currency)) {
return currency
}
// 160-bit hex code
if (currency.length === 40 && /^[0-9A-Fa-f]+$/.test(currency)) {
return currency.toUpperCase()
}
throw new ValidationError(${fieldName} is not a valid currency code)
}
static validateDestinationTag(tag, fieldName = 'destinationTag') {
if (tag === undefined || tag === null) {
return undefined // Optional
}
const numTag = Number(tag)
if (!Number.isInteger(numTag)) {
throw new ValidationError(${fieldName} must be an integer)
}
if (numTag < 0 || numTag > 4294967295) { // uint32 max
throw new ValidationError(${fieldName} out of valid range)
}
return numTag
}
}
class ValidationError extends Error {
constructor(message) {
super(message)
this.name = 'ValidationError'
}
}
```
function sanitizePaymentRequest(request) {
return {
destination: InputValidator.validateAddress(request.destination),
amount: InputValidator.validateAmount(request.amount),
destinationTag: InputValidator.validateDestinationTag(request.destinationTag),
// Explicitly whitelist allowed fields - reject unexpected fields
}
}
// API endpoint example
app.post('/api/send-payment', async (req, res) => {
try {
// Validate and sanitize all input
const sanitized = sanitizePaymentRequest(req.body)
// Additional business logic validation
await validatePaymentAllowed(sanitized, req.user)
// Execute
const result = await paymentService.send(sanitized)
res.json({ success: true, hash: result.hash })
} catch (error) {
if (error instanceof ValidationError) {
res.status(400).json({ error: error.message })
} else {
console.error('Payment error:', error)
res.status(500).json({ error: 'Internal error' })
}
}
})
```
class PaymentVerifier {
static verifyIncomingPayment(tx, expectedDestination, minAmount) {
const checks = []
// Check 1: Must be a Payment transaction
if (tx.TransactionType !== 'Payment') {
return { valid: false, reason: 'Not a Payment transaction' }
}
// Check 2: Must have succeeded
const result = tx.meta?.TransactionResult
if (result !== 'tesSUCCESS') {
return { valid: false, reason: Transaction failed: ${result} }
}
// Check 3: Must be validated
if (!tx.validated) {
return { valid: false, reason: 'Transaction not yet validated' }
}
// Check 4: Destination must match
if (tx.Destination !== expectedDestination) {
return { valid: false, reason: 'Wrong destination' }
}
// Check 5: CRITICAL - Use delivered_amount, never Amount
const deliveredAmount = this.getDeliveredAmount(tx)
if (!deliveredAmount) {
return { valid: false, reason: 'Cannot determine delivered amount' }
}
// Check 6: Amount meets minimum
if (deliveredAmount.value < minAmount) {
return {
valid: false,
reason: Amount ${deliveredAmount.value} below minimum ${minAmount}
}
}
// Check 7: Reject partial payments (optional but recommended)
if (this.isPartialPayment(tx)) {
return {
valid: false,
reason: 'Partial payments not accepted',
isPartialPayment: true
}
}
return {
valid: true,
amount: deliveredAmount,
from: tx.Account,
destinationTag: tx.DestinationTag,
hash: tx.hash,
ledger: tx.ledger_index
}
}
static getDeliveredAmount(tx) {
// Always use delivered_amount for successful payments
const delivered = tx.meta?.delivered_amount
if (delivered) {
if (typeof delivered === 'string') {
return { currency: 'XRP', value: parseInt(delivered) / 1_000_000 }
}
return {
currency: delivered.currency,
issuer: delivered.issuer,
value: parseFloat(delivered.value)
}
}
// Fallback only for non-partial payments
if (!this.isPartialPayment(tx)) {
const amount = tx.Amount
if (typeof amount === 'string') {
return { currency: 'XRP', value: parseInt(amount) / 1_000_000 }
}
return {
currency: amount.currency,
issuer: amount.issuer,
value: parseFloat(amount.value)
}
}
return null // Cannot determine for partial payment without delivered_amount
}
static isPartialPayment(tx) {
const tfPartialPayment = 0x00020000
return (tx.Flags & tfPartialPayment) !== 0
}
}
```
async function verifyTransactionSucceeded(client, hash, maxWaitMs = 60000) {
const startTime = Date.now()
while (Date.now() - startTime < maxWaitMs) {
try {
const result = await client.request({
command: 'tx',
transaction: hash
})
if (result.result.validated) {
const txResult = result.result.meta.TransactionResult
return {
confirmed: true,
success: txResult === 'tesSUCCESS',
result: txResult,
ledger: result.result.ledger_index,
fee: result.result.Fee
}
}
} catch (error) {
if (error.data?.error !== 'txnNotFound') {
throw error
}
}
await sleep(1000)
}
return { confirmed: false, reason: 'Timeout waiting for validation' }
}
```
const crypto = require('crypto')
class APIAuthenticator {
constructor(secretKey) {
this.secretKey = secretKey
}
// HMAC-based request signing
signRequest(method, path, body, timestamp) {
const payload = ${timestamp}.${method}.${path}.${JSON.stringify(body)}
return crypto.createHmac('sha256', this.secretKey)
.update(payload)
.digest('hex')
}
verifyRequest(req) {
const timestamp = req.headers['x-timestamp']
const signature = req.headers['x-signature']
// Check timestamp is recent (prevent replay attacks)
const now = Date.now()
const requestTime = parseInt(timestamp)
if (isNaN(requestTime) || Math.abs(now - requestTime) > 30000) {
throw new Error('Request timestamp invalid or expired')
}
// Verify signature
const expectedSignature = this.signRequest(
req.method,
req.path,
req.body,
timestamp
)
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
throw new Error('Invalid signature')
}
return true
}
}
```
class RateLimiter {
constructor(options = {}) {
this.windowMs = options.windowMs || 60000
this.maxRequests = options.maxRequests || 100
this.requests = new Map()
}
checkLimit(identifier) {
const now = Date.now()
const windowStart = now - this.windowMs
// Get request timestamps for this identifier
let timestamps = this.requests.get(identifier) || []
// Filter to current window
timestamps = timestamps.filter(t => t > windowStart)
if (timestamps.length >= this.maxRequests) {
const resetTime = Math.min(...timestamps) + this.windowMs
throw new RateLimitError(resetTime - now)
}
// Record this request
timestamps.push(now)
this.requests.set(identifier, timestamps)
return {
remaining: this.maxRequests - timestamps.length,
resetIn: this.windowMs
}
}
}
class RateLimitError extends Error {
constructor(retryAfterMs) {
super('Rate limit exceeded')
this.retryAfter = retryAfterMs
}
}
```
const SecurityChecklist = {
keyManagement: [
'□ Secrets stored in secure vault (not env vars or code)',
'□ Hot wallet has limited funds',
'□ Cold wallet for majority of funds',
'□ Keys rotated periodically',
'□ Multi-sig for high-value operations'
],
inputValidation: [
'□ All addresses validated with isValidAddress()',
'□ All amounts validated (type, range, precision)',
'□ Destination tags validated as integers',
'□ Currency codes validated',
'□ No user input passed directly to transactions'
],
transactionSecurity: [
'□ Always use delivered_amount for incoming payments',
'□ Partial payments detected and handled',
'□ LastLedgerSequence set on all outgoing transactions',
'□ Transaction results verified (not just submission)',
'□ Sequence numbers managed properly'
],
infrastructure: [
'□ TLS/SSL for all connections',
'□ API authentication implemented',
'□ Rate limiting in place',
'□ Audit logging enabled',
'□ Monitoring and alerting configured'
],
operational: [
'□ Daily limits enforced',
'□ Anomaly detection for unusual patterns',
'□ Incident response plan documented',
'□ Regular security reviews scheduled'
]
}
```
- Scans code for hardcoded secrets
- Checks for delivered_amount usage in payment processing
- Validates input validation implementation
- Reports security findings with severity levels
Time Investment: 3-4 hours
End of Lesson 14
Key Takeaways
Never trust Amount field:
Always use delivered_amount for incoming payments.
Secure key storage:
Use proper secrets management, not environment variables.
Validate all inputs:
Address, amount, currency, destination tag—everything.
Verify transaction results:
Submission is not confirmation.
Implement limits:
Daily limits, single transaction limits, rate limits. ---