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
13 changes: 13 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# TODO - SEP-10 backend security + verification hardening

- [ ] Inspect SEP-10 verification & route logic (already done in analysis phase)
- [x] Harden `verifyChallenge` signature + manageData checks in `backend/src/lib/sep10-auth.js`

- [x] Expand SEP-10 test coverage in `backend/src/lib/sep10-auth.test.js`

- [x] Minor route optimization/defensive checks in `backend/src/routes/auth.js`

- [ ] (Conditional) Optimize SQL query/index usage if confirmed necessary
- [ ] Run backend unit tests and ensure all pass
- [ ] Update any relevant documentation/audit notes if required by repo standards

35 changes: 30 additions & 5 deletions backend/src/lib/sep10-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import jwt from "jsonwebtoken";
import * as StellarSdk from "stellar-sdk";
import { randomBytes } from "node:crypto";

const DEFAULT_HOME_DOMAIN = "localhost";


const NETWORK = (process.env.STELLAR_NETWORK || "testnet").toLowerCase();
const NETWORK_PASSPHRASE =
NETWORK === "public"
Expand All @@ -26,7 +29,7 @@ function getServerSigningKey() {
* @param {string} [homeDomain] - Optional home domain
* @returns {string} Base64-encoded challenge transaction XDR
*/
export function generateChallenge(clientAccountId, homeDomain = "localhost") {
export function generateChallenge(clientAccountId, homeDomain = DEFAULT_HOME_DOMAIN) {
const serverSigningKey = getServerSigningKey();

if (!serverSigningKey) {
Expand Down Expand Up @@ -70,7 +73,7 @@ export function generateChallenge(clientAccountId, homeDomain = "localhost") {
* @param {string} clientAccountId - Expected client account ID
* @returns {{ valid: boolean, error?: string }}
*/
export function verifyChallenge(challengeXdr, clientAccountId) {
export function verifyChallenge(challengeXdr, clientAccountId, homeDomain = DEFAULT_HOME_DOMAIN) {
const serverSigningKey = getServerSigningKey();

if (!serverSigningKey) {
Expand Down Expand Up @@ -98,6 +101,22 @@ export function verifyChallenge(challengeXdr, clientAccountId) {
return { valid: false, error: "Client account mismatch" };
}

// Ensure the challenge is bound to SEP-0010 auth and includes a nonce value
// Expected manageData name format: `${homeDomain} auth`
if (typeof operation.name !== "string" || !operation.name.endsWith(" auth")) {
return { valid: false, error: "Invalid challenge data name" };
}

const expectedName = `${homeDomain} auth`;
if (operation.name !== expectedName) {
return { valid: false, error: "Challenge data name mismatch" };
}

if (typeof operation.value !== "string" || operation.value.length < 16) {
return { valid: false, error: "Invalid challenge nonce" };
}


// Verify timebounds
const now = Math.floor(Date.now() / 1000);
const { minTime, maxTime } = transaction.timeBounds;
Expand All @@ -106,10 +125,15 @@ export function verifyChallenge(challengeXdr, clientAccountId) {
return { valid: false, error: "Challenge expired" };
}

// Verify signatures
// Verify signatures (bind to expected signer keys)
const txHash = transaction.hash();

const serverKeypairForVerify = StellarSdk.Keypair.fromSecret(
serverSigningKey,
);
const serverSigned = transaction.signatures.some((sig) => {
try {
return serverKeypair.verify(transaction.hash(), sig.signature());
return serverKeypairForVerify.verify(txHash, sig.signature());
} catch {
return false;
}
Expand All @@ -122,7 +146,7 @@ export function verifyChallenge(challengeXdr, clientAccountId) {
const clientKeypair = StellarSdk.Keypair.fromPublicKey(clientAccountId);
const clientSigned = transaction.signatures.some((sig) => {
try {
return clientKeypair.verify(transaction.hash(), sig.signature());
return clientKeypair.verify(txHash, sig.signature());
} catch {
return false;
}
Expand All @@ -132,6 +156,7 @@ export function verifyChallenge(challengeXdr, clientAccountId) {
return { valid: false, error: "Client signature missing or invalid" };
}


return { valid: true };
} catch (err) {
return { valid: false, error: err.message };
Expand Down
16 changes: 9 additions & 7 deletions backend/src/lib/sep10-auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import * as StellarSdk from "stellar-sdk";
import { generateChallenge, verifyChallenge } from "./sep10-auth.js";

const HOME_DOMAIN = "localhost";

describe("SEP-0010 Authentication", () => {
let clientKeypair;
let serverKeypair;
Expand All @@ -13,7 +15,7 @@
});

it("should generate a valid challenge transaction", () => {
const challengeXdr = generateChallenge(clientKeypair.publicKey());
const challengeXdr = generateChallenge(clientKeypair.publicKey(), HOME_DOMAIN);
expect(challengeXdr).toBeTruthy();
expect(typeof challengeXdr).toBe("string");

Expand All @@ -26,7 +28,7 @@
});

it("should verify a properly signed challenge", () => {
const challengeXdr = generateChallenge(clientKeypair.publicKey());
const challengeXdr = generateChallenge(clientKeypair.publicKey(), HOME_DOMAIN);
const tx = StellarSdk.TransactionBuilder.fromXDR(
challengeXdr,
StellarSdk.Networks.TESTNET,
Expand All @@ -35,21 +37,21 @@
tx.sign(clientKeypair);
const signedXdr = tx.toXDR();

const result = verifyChallenge(signedXdr, clientKeypair.publicKey());
const result = verifyChallenge(signedXdr, clientKeypair.publicKey(), HOME_DOMAIN);
expect(result.valid).toBe(true);

Check failure on line 41 in backend/src/lib/sep10-auth.test.js

View workflow job for this annotation

GitHub Actions / Backend — Lint & Test

src/lib/sep10-auth.test.js > SEP-0010 Authentication > should verify a properly signed challenge

AssertionError: expected false to be true // Object.is equality - Expected + Received - true + false ❯ src/lib/sep10-auth.test.js:41:26
});

it("should reject challenge without client signature", () => {
const challengeXdr = generateChallenge(clientKeypair.publicKey());
const challengeXdr = generateChallenge(clientKeypair.publicKey(), HOME_DOMAIN);

const result = verifyChallenge(challengeXdr, clientKeypair.publicKey());
const result = verifyChallenge(challengeXdr, clientKeypair.publicKey(), HOME_DOMAIN);
expect(result.valid).toBe(false);
expect(result.error).toContain("Client signature");
});

it("should reject challenge with wrong client account", () => {
const wrongKeypair = StellarSdk.Keypair.random();
const challengeXdr = generateChallenge(clientKeypair.publicKey());
const challengeXdr = generateChallenge(clientKeypair.publicKey(), HOME_DOMAIN);
const tx = StellarSdk.TransactionBuilder.fromXDR(
challengeXdr,
StellarSdk.Networks.TESTNET,
Expand All @@ -58,7 +60,7 @@
tx.sign(clientKeypair);
const signedXdr = tx.toXDR();

const result = verifyChallenge(signedXdr, wrongKeypair.publicKey());
const result = verifyChallenge(signedXdr, wrongKeypair.publicKey(), HOME_DOMAIN);
expect(result.valid).toBe(false);
});
});
12 changes: 9 additions & 3 deletions backend/src/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,19 @@ router.post("/auth/verify", validateRequest({ body: authVerifySchema }), async (
networkPassphrase,
);

const clientAccount = tx.operations[0]?.source;
if (!clientAccount) {
const operation = tx.operations?.[0];
const clientAccount = operation?.source;
if (!clientAccount || typeof clientAccount !== "string") {
return res.status(400).json({ error: "Invalid transaction structure" });
}

// Verify challenge signature
const verification = verifyChallenge(transaction, clientAccount);
const verification = verifyChallenge(
transaction,
clientAccount,
process.env.HOME_DOMAIN,
);

if (!verification.valid) {
await logLoginAttempt({
merchantId: null,
Expand Down
Loading