Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 279 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"twilio": "^4.11.0",
"winston": "^3.19.0"
},
"devDependencies": {
Expand All @@ -45,9 +46,12 @@
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0",
"@types/twilio": "^3.19.3",
"jest": "^30.2.0",
"nodemon": "^3.1.14",
"prisma": "^5.22.0",
"@types/supertest": "^6.0.2",
"supertest": "^6.3.3",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import { connectDb } from './db'
import { scheduleSessionCleanup } from './jobs/sessionCleanup'
import healthRouter from './routes/health'
import authRouter from './routes/auth'
import whatsappRouter from './routes/whatsapp'

const app = express()

// Security and parsing middleware
app.use(helmet())
app.use(cors())
app.use(express.json())
app.use(express.urlencoded({ extended: false }))

// Logging and rate limiting
app.use(requestLogger)
Expand All @@ -26,6 +28,7 @@ app.use(rateLimiter)
// Public routes
app.use('/health', healthRouter)
app.use('/api/auth', authRouter)
app.use('/api/whatsapp', whatsappRouter)

// Protected routes (require valid JWT)
// All routes mounted below this line are automatically protected.
Expand Down
50 changes: 50 additions & 0 deletions src/routes/whatsapp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import express, { Request, Response } from 'express'
import { validateRequest, twiml } from 'twilio'
import { handleWhatsAppMessage } from '../whatsapp/handler'
import { config } from '../config/env'

const router = express.Router()

/**
* Health check for Twilio webhook
*/
router.get('/webhook', (_req: Request, res: Response) => {
res.status(200).send('WhatsApp webhook is alive')
})

/**
* Handles incoming WhatsApp messages from Twilio
* https://www.twilio.com/docs/usage/security#validating-requests
*/
router.post('/webhook', async (req: Request, res: Response) => {
const signature = req.header('x-twilio-signature')
const authToken = process.env.TWILIO_AUTH_TOKEN || ''

const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`

if (!signature || !authToken) {
return res.status(403).send('Forbidden')
}

const isValid = validateRequest(authToken, signature, url, req.body)
if (!isValid && config.nodeEnv === 'production') {
return res.status(403).send('Forbidden')
}

const from = (req.body.From as string) || ''
const body = (req.body.Body as string) || ''

try {
const response = await handleWhatsAppMessage(from, body)
const responseTwiml = new twiml.MessagingResponse()
responseTwiml.message(response.body)
res.type('text/xml').send(responseTwiml.toString())
} catch (error) {
console.error('[WhatsApp webhook] error handling message:', error)
const errorTwiml = new twiml.MessagingResponse()
errorTwiml.message('Sorry, something went wrong processing your request.')
res.type('text/xml').send(errorTwiml.toString())
}
})

export default router
49 changes: 24 additions & 25 deletions src/stellar/wallet.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { Keypair } from '@stellar/stellar-sdk';
import * as crypto from 'crypto';

const ENCRYPTION_KEY = process.env.WALLET_ENCRYPTION_KEY || '';
const ALGORITHM = 'aes-256-gcm';

function getEncryptionKey(): string {
const key = process.env.WALLET_ENCRYPTION_KEY || '';
if (!key || key.length !== 64) {
throw new Error('WALLET_ENCRYPTION_KEY must be 64 hex characters (32 bytes)');
}
return key;
}

interface CustodialWallet {
userId: string;
publicKey: string;
Expand All @@ -20,19 +27,15 @@ const walletStore = new Map<string, CustodialWallet>();
* SECURITY: Never log secret keys. Use environment-based encryption key.
*/
function encryptSecret(secret: string): { encrypted: string; iv: string; authTag: string } {
if (!ENCRYPTION_KEY || ENCRYPTION_KEY.length !== 64) {
throw new Error('WALLET_ENCRYPTION_KEY must be 64 hex characters (32 bytes)');
}

const key = Buffer.from(ENCRYPTION_KEY, 'hex');
const key = Buffer.from(getEncryptionKey(), 'hex');
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);

let encrypted = cipher.update(secret, 'utf8', 'hex');
encrypted += cipher.final('hex');

const authTag = cipher.getAuthTag();

return {
encrypted,
iv: iv.toString('hex'),
Expand All @@ -44,23 +47,19 @@ function encryptSecret(secret: string): { encrypted: string; iv: string; authTag
* Decrypt secret key
*/
function decryptSecret(encrypted: string, iv: string, authTag: string): string {
if (!ENCRYPTION_KEY || ENCRYPTION_KEY.length !== 64) {
throw new Error('WALLET_ENCRYPTION_KEY must be 64 hex characters (32 bytes)');
}

const key = Buffer.from(ENCRYPTION_KEY, 'hex');
const key = Buffer.from(getEncryptionKey(), 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex'));
decipher.setAuthTag(Buffer.from(authTag, 'hex'));

let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');

return decrypted;
}

/**
* Create custodial wallet for user
*
*
* SECURITY NOTE: This is a custodial solution where the backend holds user keys.
* Users trust the backend to secure their funds. Consider non-custodial alternatives
* for production use cases requiring higher security guarantees.
Expand All @@ -69,22 +68,22 @@ export async function createCustodialWallet(userId: string): Promise<CustodialWa
if (walletStore.has(userId)) {
throw new Error(`Wallet already exists for user ${userId}`);
}

const keypair = Keypair.random();
const { encrypted, iv, authTag } = encryptSecret(keypair.secret());

const wallet: CustodialWallet = {
userId,
publicKey: keypair.publicKey(),
encryptedSecret: encrypted,
iv,
authTag,
};

walletStore.set(userId, wallet);

console.log(`[Wallet] Created for user ${userId}: ${wallet.publicKey}`);

return wallet;
}

Expand All @@ -100,11 +99,11 @@ export async function getWalletByUserId(userId: string): Promise<CustodialWallet
*/
export async function getKeypairForUser(userId: string): Promise<Keypair> {
const wallet = await getWalletByUserId(userId);

if (!wallet) {
throw new Error(`No wallet found for user ${userId}`);
}

const secret = decryptSecret(wallet.encryptedSecret, wallet.iv, wallet.authTag);
return Keypair.fromSecret(secret);
}
Expand All @@ -114,4 +113,4 @@ export async function getKeypairForUser(userId: string): Promise<Keypair> {
*/
export function listWallets(): string[] {
return Array.from(walletStore.values()).map(w => w.publicKey);
}
}
130 changes: 130 additions & 0 deletions src/whatsapp/__tests__/whatsapp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import express from 'express'
import request from 'supertest'
import crypto from 'crypto'

import { clearUsersForTests, getUserForTests } from '../userManager'

// Twilio signature helper (per https://www.twilio.com/docs/usage/security)
function computeTwilioSignature(authToken: string, url: string, params: Record<string, any>) {
const keys = Object.keys(params).sort()
const data = [url, ...keys.map((k) => `${k}${params[k]}`)].join('')
return crypto.createHmac('sha1', authToken).update(data, 'utf8').digest('base64')
}

function createApp() {
const app = express()
app.use(express.urlencoded({ extended: false }))
app.use(express.json())

// Ensure we load whatsapp router after env is set for production signature validation.
const { default: whatsappRouter } = require('../../routes/whatsapp')
app.use('/api/whatsapp', whatsappRouter)

return app
}

describe('WhatsApp webhook', () => {
const authToken = 'test-token'
const url = 'http://127.0.0.1/api/whatsapp/webhook'

beforeEach(() => {
process.env.TWILIO_AUTH_TOKEN = authToken
process.env.NODE_ENV = 'production'
process.env.WALLET_ENCRYPTION_KEY = 'a'.repeat(64)

// Required env vars for config/env.ts
process.env.STELLAR_NETWORK = 'TESTNET'
process.env.STELLAR_RPC_URL = 'https://example.com'
process.env.AGENT_SECRET_KEY = 'SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
process.env.VAULT_CONTRACT_ID = 'vault-contract'
process.env.USDC_TOKEN_ADDRESS = 'usdc-token'
process.env.ANTHROPIC_API_KEY = 'test'
process.env.JWT_SEED = 'test-seed'
process.env.JWT_SESSION_TTL_HOURS = '24'
process.env.JWT_NONCE_TTL_MS = '300000'
process.env.JWT_CLEANUP_INTERVAL_MS = '86400000'
process.env.DATABASE_URL = 'postgresql://user:pass@localhost/db'

clearUsersForTests()
})

it('rejects invalid Twilio signature in production', async () => {
const app = createApp()

const res = await request(app)
.post('/api/whatsapp/webhook') .set('Host', '127.0.0.1')
.set('X-Forwarded-Proto', 'http') .set('X-Twilio-Signature', 'invalid')
.send({ From: 'whatsapp:+10000000000', Body: 'balance' })

expect(res.status).toBe(403)
})

it('accepts valid signature and sends OTP for new user', async () => {
const app = createApp()
const params = { From: 'whatsapp:+10000000000', Body: 'hello' }
const signature = computeTwilioSignature(authToken, url, params)

const res = await request(app)
.post('/api/whatsapp/webhook')
.set('Host', '127.0.0.1')
.set('X-Forwarded-Proto', 'http')
.set('X-Twilio-Signature', signature)
.send(params)

expect(res.status).toBe(200)
expect(res.text).toContain('<Response>')
expect(res.text).toContain('verification code')

// Ensure user created and not verified
const user = getUserForTests('+10000000000')
expect(user).not.toBeNull()
expect(user?.verified).toBe(false)
})

it('verifies OTP and allows balance queries', async () => {
const app = createApp()
const from = 'whatsapp:+10000000000'

// First message creates user and sends OTP
const firstParams = { From: from, Body: 'hello' }
const signature1 = computeTwilioSignature(authToken, url, firstParams)
const firstRes = await request(app)
.post('/api/whatsapp/webhook')
.set('Host', '127.0.0.1')
.set('X-Forwarded-Proto', 'http')
.set('X-Twilio-Signature', signature1)
.send(firstParams)

expect(firstRes.status).toBe(200)
const otpMatch = firstRes.text.match(/(\d{6})/)
expect(otpMatch).not.toBeNull()

const otp = otpMatch?.[1] || ''

// Reply with OTP
const verifyParams = { From: from, Body: otp }
const signature2 = computeTwilioSignature(authToken, url, verifyParams)
const verifyRes = await request(app)
.post('/api/whatsapp/webhook')
.set('Host', '127.0.0.1')
.set('X-Forwarded-Proto', 'http')
.set('X-Twilio-Signature', signature2)
.send(verifyParams)

expect(verifyRes.status).toBe(200)
expect(verifyRes.text).toContain('Your account is now verified')

// Now ask for balance
const balanceParams = { From: from, Body: 'balance' }
const signature3 = computeTwilioSignature(authToken, url, balanceParams)
const balanceRes = await request(app)
.post('/api/whatsapp/webhook')
.set('Host', '127.0.0.1')
.set('X-Forwarded-Proto', 'http')
.set('X-Twilio-Signature', signature3)
.send(balanceParams)

expect(balanceRes.status).toBe(200)
expect(balanceRes.text).toContain('Your current balance')
})
})
Loading