Description
The current auth flow in src/modules/auth/auth.service.ts uses a simplified string comparison for challenge verification (line 78):
if (signedChallenge !== stored.challengeToken) {
throw new UnauthorizedError("Invalid signed challenge");
}
This means anyone who knows the challenge token can authenticate — there is no cryptographic verification that the caller actually controls the Stellar private key. This must be replaced with real SEP-10 signature verification.
How SEP-10 works
- Server generates a challenge transaction (manage_data operation with a random nonce)
- Client signs the transaction with their Stellar private key via Freighter
- Client submits the signed XDR back to the server
- Server decodes the XDR, verifies the signature matches the claimed public key, and checks time bounds
What needs to change
src/modules/auth/auth.service.ts
createChallenge method (lines 20-55)
Replace the current JSON-based challenge with a real SEP-10 challenge transaction:
import * as StellarSdk from "@stellar/stellar-sdk";
async createChallenge(stellarAddress: string): Promise<ChallengeResponse> {
const account = new StellarSdk.Account(stellarAddress, "0");
const challengeNonce = crypto.randomBytes(32).toString("base64");
const transaction = new StellarSdk.TransactionBuilder(account, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: getNetworkPassphrase(),
})
.addOperation(
StellarSdk.Operation.manageData({
name: "chainlearn_auth",
value: challengeNonce,
})
)
.addMemo(StellarSdk.Memo.text("ChainLearn Auth"))
.setTimeout(300) // 5 minutes
.build();
const challengeXDR = transaction.toXDR();
// Store the challenge for verification
await redis.setex(
`${CHALLENGE_PREFIX}${stellarAddress}`,
CHALLENGE_TTL_SECONDS,
challengeXDR
);
return {
challenge: challengeXDR,
networkPassphrase: getNetworkPassphrase(),
};
}
verifyChallenge method (lines 61-109)
Replace the string comparison with XDR decoding and signature verification:
async verifyChallenge(
stellarAddress: string,
signedChallengeXDR: string
): Promise<AuthResponse> {
// 1. Retrieve stored challenge
const storedXDR = await redis.get(`${CHALLENGE_PREFIX}${stellarAddress}`);
if (!storedXDR) {
throw new UnauthorizedError("Challenge expired or not found");
}
// 2. Decode the signed transaction
let signedTx;
try {
signedTx = new StellarSdk.Transaction(
signedChallengeXDR,
getNetworkPassphrase()
);
} catch {
throw new UnauthorizedError("Invalid transaction format");
}
// 3. Verify the transaction source matches the claimed address
if (signedTx.source !== stellarAddress) {
throw new UnauthorizedError("Transaction source does not match address");
}
// 4. Verify the signature
const keypair = StellarSdk.Keypair.fromPublicKey(stellarAddress);
const signatureValid = signedTx.signatures.some((sig) => {
try {
keypair.verify(signedTx.hash(), sig.signature());
return true;
} catch {
return false;
}
});
if (!signatureValid) {
throw new UnauthorizedError("Invalid signature");
}
// 5. Check time bounds
if (!signedTx.timeBounds) {
throw new UnauthorizedError("Transaction missing time bounds");
}
const now = Math.floor(Date.now() / 1000);
if (now > Number(signedTx.timeBounds.maxTime)) {
throw new UnauthorizedError("Challenge has expired");
}
// 6. Verify the manage_data operation exists
const hasManageData = signedTx.operations.some(
(op) => op.type === "manageData" && op.name === "chainlearn_auth"
);
if (!hasManageData) {
throw new UnauthorizedError("Invalid challenge transaction");
}
// 7. Clean up challenge (single-use)
await redis.del(`${CHALLENGE_PREFIX}${stellarAddress}`);
// 8. Find or create user (same as before)
let user = await db.query.users.findFirst({
where: eq(users.stellarAddress, stellarAddress),
});
let isNewUser = false;
if (!user) {
[user] = await db.insert(users).values({ stellarAddress }).returning();
isNewUser = true;
}
return {
token: "",
user: { id: user.id, stellarAddress: user.stellarAddress, displayName: user.displayName, isNewUser },
};
}
Security considerations
- Time bounds: The transaction must have
timeBounds to prevent replay attacks
- Single-use: Delete the challenge from Redis after successful verification
- Operation validation: Verify the transaction contains the expected
manage_data operation
- Rate limiting: The existing rate limiter on auth routes helps prevent brute force
Files to change
src/modules/auth/auth.service.ts — both createChallenge and verifyChallenge methods
src/modules/auth/auth.types.ts — update ChallengeResponse type if needed
How to test
- Start the API server
- Request a challenge:
POST /api/auth/challenge with a valid Stellar address
- Sign the challenge XDR using Freighter (or manually with a Stellar keypair)
- Submit the signed XDR:
POST /api/auth/verify
- Verify: valid signatures succeed, tampered signatures fail, expired challenges fail
References
Description
The current auth flow in
src/modules/auth/auth.service.tsuses a simplified string comparison for challenge verification (line 78):This means anyone who knows the challenge token can authenticate — there is no cryptographic verification that the caller actually controls the Stellar private key. This must be replaced with real SEP-10 signature verification.
How SEP-10 works
What needs to change
src/modules/auth/auth.service.tscreateChallengemethod (lines 20-55)Replace the current JSON-based challenge with a real SEP-10 challenge transaction:
verifyChallengemethod (lines 61-109)Replace the string comparison with XDR decoding and signature verification:
Security considerations
timeBoundsto prevent replay attacksmanage_dataoperationFiles to change
src/modules/auth/auth.service.ts— bothcreateChallengeandverifyChallengemethodssrc/modules/auth/auth.types.ts— updateChallengeResponsetype if neededHow to test
POST /api/auth/challengewith a valid Stellar addressPOST /api/auth/verifyReferences
src/utils/crypto.tssrc/stellar/client.ts