Context
Every user gets a Stellar keypair generated server-side immediately after registration. The secret key is encrypted with AES-256-GCM before storage. This module is called fire-and-forget from AuthService.register() and must never block the registration response.
📄 Reference docs:
backend/docs/standards/blockchain.md — keypair generation, AES-256-GCM encryption, testnet funding
backend/docs/standards/security.md — encryption at rest standard
Objective
Implement WalletsModule with WalletsService that:
- Generates a Stellar keypair
- Encrypts the secret key with AES-256-GCM
- Saves to the
wallets table
- (Testnet only) Funds the account via Friendbot
What to implement
Encryption utilities — src/modules/wallets/wallets.crypto.ts
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
export function encryptSecretKey(secretKey: string, keyHex: string): string {
const key = Buffer.from(keyHex, 'hex'); // 32 bytes
const iv = randomBytes(16);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(secretKey, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return [iv.toString('base64'), authTag.toString('base64'), encrypted.toString('base64')].join(':');
}
export function decryptSecretKey(stored: string, keyHex: string): string {
const [ivB64, tagB64, ctB64] = stored.split(':');
const key = Buffer.from(keyHex, 'hex');
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivB64, 'base64'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
return decipher.update(Buffer.from(ctB64, 'base64')).toString('utf8') + decipher.final('utf8');
}
src/modules/wallets/wallets.service.ts
async generateAndSave(userId: string): Promise<void> {
const keypair = Keypair.random();
const publicKey = keypair.publicKey();
const secretKey = keypair.secret(); // starts with 'S'
const encryptedKey = encryptSecretKey(secretKey, this.config.getOrThrow('WALLET_ENCRYPTION_KEY'));
await this.walletsRepository.create({ userId, publicKey, encryptedSecretKey: encryptedKey });
if (this.config.get('STELLAR_NETWORK') === 'testnet') {
await this.fundTestnetAccount(publicKey).catch((err) =>
this.logger.error(`Friendbot funding failed for ${publicKey}`, err),
);
}
}
async getDecryptedKeypair(userId: string): Promise<Keypair> {
const wallet = await this.walletsRepository.findByUserId(userId);
if (!wallet) throw new AppException('WALLET_NOT_FOUND', 'No wallet found for this user', 404);
const secretKey = decryptSecretKey(wallet.encryptedSecretKey, this.config.getOrThrow('WALLET_ENCRYPTION_KEY'));
const keypair = Keypair.fromSecret(secretKey);
// Zero out the plain secret after use — it stays in memory only for the duration of this call
return keypair;
}
Packages to install
npm install @stellar/stellar-sdk
Acceptance Criteria
Unit Tests required
src/modules/wallets/wallets.crypto.spec.ts
encryptSecretKey returns a string in iv:tag:ciphertext format
decryptSecretKey(encryptSecretKey(secret, key), key) === secret (round-trip)
- Different calls with the same input produce different ciphertexts (random IV)
- Tampered ciphertext throws during decryption
src/modules/wallets/wallets.service.spec.ts
generateAndSave: calls Keypair.random() and saves to repository
generateAndSave: stores encrypted (not plain) secret key
generateAndSave: calls Friendbot on testnet
generateAndSave: does NOT call Friendbot on mainnet
generateAndSave: does not throw if Friendbot fails
getDecryptedKeypair: throws WALLET_NOT_FOUND if no wallet exists
Complexity: High
Depends on: setup issue, migrations issue, register/login issue
Context
Every user gets a Stellar keypair generated server-side immediately after registration. The secret key is encrypted with AES-256-GCM before storage. This module is called fire-and-forget from
AuthService.register()and must never block the registration response.Objective
Implement
WalletsModulewithWalletsServicethat:walletstableWhat to implement
Encryption utilities —
src/modules/wallets/wallets.crypto.tssrc/modules/wallets/wallets.service.tsPackages to install
Acceptance Criteria
walletsfor the new userwallets.public_keyis a valid Stellar public key (starts withG, 56 chars)wallets.encrypted_secret_keyis stored iniv:authTag:ciphertextformat (not plain text)decryptSecretKeyyields the original Stellar secret keygenerateAndSaveis called fire-and-forget — registration response is not delayedUnit Tests required
src/modules/wallets/wallets.crypto.spec.tsencryptSecretKeyreturns a string iniv:tag:ciphertextformatdecryptSecretKey(encryptSecretKey(secret, key), key) === secret(round-trip)src/modules/wallets/wallets.service.spec.tsgenerateAndSave: callsKeypair.random()and saves to repositorygenerateAndSave: stores encrypted (not plain) secret keygenerateAndSave: calls Friendbot on testnetgenerateAndSave: does NOT call Friendbot on mainnetgenerateAndSave: does not throw if Friendbot failsgetDecryptedKeypair: throwsWALLET_NOT_FOUNDif no wallet existsComplexity: High
Depends on: setup issue, migrations issue, register/login issue