Skip to content

Replace simplified auth verification with real SEP-10 signature verification #4

Description

@DeFiVC

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

  1. Server generates a challenge transaction (manage_data operation with a random nonce)
  2. Client signs the transaction with their Stellar private key via Freighter
  3. Client submits the signed XDR back to the server
  4. 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

  1. Start the API server
  2. Request a challenge: POST /api/auth/challenge with a valid Stellar address
  3. Sign the challenge XDR using Freighter (or manually with a Stellar keypair)
  4. Submit the signed XDR: POST /api/auth/verify
  5. Verify: valid signatures succeed, tampered signatures fail, expired challenges fail

References

Metadata

Metadata

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignadvancedAdvanced difficultyenhancementNew feature or requestsecuritySecurity concerntypescriptTypeScript language

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions