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
1 change: 1 addition & 0 deletions .github/workflows/playwrightTests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ jobs:
playwright:
name: "Playwright e2e Tests"
runs-on: ubuntu-latest
timeout-minutes: 5
container:
image: mcr.microsoft.com/playwright:v1.43.0-jammy
steps:
Expand Down
3 changes: 3 additions & 0 deletions @stellar/typescript-wallet-sdk-km/CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Release notes - Typescript Wallet SDK Key Manager - 1.10.0
* Version bump

# Release notes - Typescript Wallet SDK Key Manager - 1.9.0
* Version bump

Expand Down
2 changes: 1 addition & 1 deletion @stellar/typescript-wallet-sdk-km/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stellar/typescript-wallet-sdk-km",
"version": "1.9.0",
"version": "1.10.0",
"engines": {
"node": ">=18"
},
Expand Down
3 changes: 3 additions & 0 deletions @stellar/typescript-wallet-sdk-soroban/CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Release notes - Typescript Wallet SDK Soroban - 1.10.0
* Version bump

# Release notes - Typescript Wallet SDK Soroban - 1.9.0
* Version bump

Expand Down
2 changes: 1 addition & 1 deletion @stellar/typescript-wallet-sdk-soroban/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stellar/typescript-wallet-sdk-soroban",
"version": "1.9.0",
"version": "1.10.0",
"engines": {
"node": ">=18"
},
Expand Down
9 changes: 9 additions & 0 deletions @stellar/typescript-wallet-sdk/CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# Release notes - Typescript Wallet SDK - 1.10.0

### Added
* Validate SEP-10 challenge transaction (#228)

### Fixed
* Fix broken JWT expiration check in SEP-10 authentication (#226)
* Fix TypeError crash in SEP-7 replace parameter parsing (#227)

# Release notes - Typescript Wallet SDK - 1.9.0

### Added
Expand Down
2 changes: 1 addition & 1 deletion @stellar/typescript-wallet-sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stellar/typescript-wallet-sdk",
"version": "1.9.0",
"version": "1.10.0",
"engines": {
"node": ">=18"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export class Anchor {
webAuthEndpoint: tomlInfo.webAuthEndpoint,
homeDomain: this.homeDomain,
httpClient: this.httpClient,
serverSigningKey: tomlInfo.signingKey,
});
}

Expand Down
201 changes: 194 additions & 7 deletions @stellar/typescript-wallet-sdk/src/walletSdk/Auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { AxiosInstance } from "axios";
import { TransactionBuilder, Transaction } from "@stellar/stellar-sdk";
import {
TransactionBuilder,
Transaction,
FeeBumpTransaction,
WebAuth,
} from "@stellar/stellar-sdk";
import { decode } from "jws";

import { Config } from "../";
Expand All @@ -10,6 +15,8 @@ import {
InvalidTokenError,
MissingTokenError,
ExpiredTokenError,
ChallengeValidationFailedError,
NetworkPassphraseMismatchError,
} from "../Exceptions";
import {
AuthenticateParams,
Expand All @@ -31,6 +38,7 @@ type Sep10Params = {
webAuthEndpoint: string;
homeDomain: string;
httpClient: AxiosInstance;
serverSigningKey?: string;
};

/**
Expand All @@ -49,6 +57,7 @@ export class Sep10 {
private webAuthEndpoint: string;
private homeDomain: string;
private httpClient: AxiosInstance;
private serverSigningKey?: string;

/**
* Creates a new instance of the Sep10 class.
Expand All @@ -57,12 +66,14 @@ export class Sep10 {
* @param {Sep10Params} params - Parameters to initialize the Sep10 instance.
*/
constructor(params: Sep10Params) {
const { cfg, webAuthEndpoint, homeDomain, httpClient } = params;
const { cfg, webAuthEndpoint, homeDomain, httpClient, serverSigningKey } =
params;

this.cfg = cfg;
this.webAuthEndpoint = webAuthEndpoint;
this.homeDomain = homeDomain;
this.httpClient = httpClient;
this.serverSigningKey = serverSigningKey;
}

/**
Expand Down Expand Up @@ -150,17 +161,54 @@ export class Sep10 {
challengeResponse,
walletSigner,
}: SignParams): Promise<Transaction> {
const networkPassphrase = this.cfg.stellar.network;

if (
challengeResponse.network_passphrase &&
challengeResponse.network_passphrase !== (networkPassphrase as string)
) {
throw new NetworkPassphraseMismatchError(
networkPassphrase,
challengeResponse.network_passphrase,
);
}

try {
const webAuthDomain = new URL(this.webAuthEndpoint).hostname;

if (this.serverSigningKey) {
WebAuth.readChallengeTx(
challengeResponse.transaction,
this.serverSigningKey,
networkPassphrase,
this.homeDomain,
webAuthDomain,
);
} else {
readChallengeTx(
challengeResponse.transaction,
networkPassphrase,
this.homeDomain,
webAuthDomain,
);
}
} catch (e) {
throw new ChallengeValidationFailedError(
e instanceof Error ? e : new Error(String(e)),
);
}

let transaction: Transaction = TransactionBuilder.fromXDR(
challengeResponse.transaction,
challengeResponse.network_passphrase,
networkPassphrase,
) as Transaction;

// check if verifying client domain as well
for (const op of transaction.operations) {
if (op.type === "manageData" && op.name === "client_domain") {
transaction = await walletSigner.signWithDomainAccount({
transactionXDR: challengeResponse.transaction,
networkPassphrase: challengeResponse.network_passphrase,
networkPassphrase,
accountKp,
});
}
Expand Down Expand Up @@ -188,14 +236,153 @@ export class Sep10 {
}
}

const validateToken = (token: string) => {
/**
* @internal
* @param {string} token - The JWT token to validate.
*/
export const validateToken = (token: string) => {
const parsedToken = decode(token);
if (!parsedToken) {
throw new InvalidTokenError();
}
if (parsedToken.expiresAt < Math.floor(Date.now() / 1000)) {
throw new ExpiredTokenError(parsedToken.expiresAt);
const payload =
typeof parsedToken.payload === "string"
? JSON.parse(parsedToken.payload)
: parsedToken.payload;
const exp = payload?.exp;
if (typeof exp === "number" && exp < Math.floor(Date.now() / 1000)) {
throw new ExpiredTokenError(exp);
}
};

/*
* Validates a SEP-10 challenge transaction without requiring the server's
* signing key. This performs all structural validations from the SEP-10 spec
* (sequence number, operation types, timebounds, home domain, web_auth_domain,
* nonce format) but skips the server account and signature checks.
*
* Used as a fallback when the anchor's stellar.toml does not publish a
* SIGNING_KEY, providing strong protection against malformed or malicious
* challenge transactions.
*
* @internal
* @see {@link https://github.com/stellar/js-stellar-sdk/blob/v13.0.0-beta.1/src/webauth/utils.ts#L188 | WebAuth.readChallengeTx}
*/
const readChallengeTx = (
challengeTx: string,
networkPassphrase: string,
homeDomain: string,
webAuthDomain: string,
): { tx: Transaction; clientAccountID: string } => {
let transaction: Transaction;
try {
transaction = new Transaction(challengeTx, networkPassphrase);
} catch {
try {
// eslint-disable-next-line no-new
new FeeBumpTransaction(challengeTx, networkPassphrase);
} catch {
throw new Error(
"Invalid challenge: unable to deserialize challengeTx transaction string",
);
}
throw new Error(
"Invalid challenge: expected a Transaction but received a FeeBumpTransaction",
);
}

// verify sequence number
const sequence = Number.parseInt(transaction.sequence, 10);
if (sequence !== 0) {
throw new Error("The transaction sequence number should be zero");
}

// verify operations
if (transaction.operations.length < 1) {
throw new Error("The transaction should contain at least one operation");
}

const [operation, ...subsequentOperations] = transaction.operations;

if (!operation.source) {
throw new Error(
"The transaction's operation should contain a source account",
);
}
const clientAccountID: string = operation.source;

// verify memo
if (transaction.memo.type !== "none") {
if (clientAccountID.startsWith("M")) {
throw new Error(
"The transaction has a memo but the client account ID is a muxed account",
);
}
if (transaction.memo.type !== "id") {
throw new Error("The transaction's memo must be of type `id`");
}
}

if (operation.type !== "manageData") {
throw new Error("The transaction's operation type should be 'manageData'");
}

// verify timebounds
if (!transaction.timeBounds) {
throw new Error("The transaction requires timebounds");
}

if (Number.parseInt(transaction.timeBounds.maxTime, 10) === 0) {
throw new Error("The transaction requires non-infinite timebounds");
}

const now = Math.floor(Date.now() / 1000);
const gracePeriod = 60 * 5;
const minTime = Number.parseInt(transaction.timeBounds.minTime, 10) || 0;
const maxTime = Number.parseInt(transaction.timeBounds.maxTime, 10) || 0;
if (now < minTime - gracePeriod || now > maxTime + gracePeriod) {
throw new Error("The transaction has expired");
}

// verify nonce value
if (operation.value === undefined || !operation.value) {
throw new Error("The transaction's operation value should not be null");
}

if (Buffer.from(operation.value.toString(), "base64").length !== 48) {
throw new Error(
"The transaction's operation value should be a 64 bytes base64 random string",
);
}

// verify home domain
if (`${homeDomain} auth` !== operation.name) {
throw new Error(
"Invalid homeDomains: the transaction's operation key name " +
"does not match the expected home domain",
);
}

// verify subsequent operations are all manageData
for (const op of subsequentOperations) {
if (op.type !== "manageData") {
throw new Error(
"The transaction has operations that are not of type 'manageData'",
);
}
if (op.name === "web_auth_domain") {
if (op.value === undefined) {
throw new Error("'web_auth_domain' operation value should not be null");
}
if (op.value.compare(Buffer.from(webAuthDomain)) !== 0) {
throw new Error(
`'web_auth_domain' operation value does not match ${webAuthDomain}`,
);
}
}
}

return { tx: transaction, clientAccountID };
};

const createAuthSignToken = async (
Expand Down
16 changes: 16 additions & 0 deletions @stellar/typescript-wallet-sdk/src/walletSdk/Exceptions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,22 @@ export class Sep38PriceOnlyOneAmountError extends Error {
}
}

export class ChallengeValidationFailedError extends Error {
constructor(cause: Error) {
super(`SEP-10 challenge validation failed: ${cause.message}`);
Object.setPrototypeOf(this, ChallengeValidationFailedError.prototype);
}
}

export class NetworkPassphraseMismatchError extends Error {
constructor(expected: string, received: string) {
super(
`Network passphrase mismatch: expected "${expected}" but server returned "${received}"`,
);
Object.setPrototypeOf(this, NetworkPassphraseMismatchError.prototype);
}
}

export class ChallengeTxnIncorrectSequenceError extends Error {
constructor() {
super("Challenge transaction sequence number must be 0");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export class Recovery extends AccountRecover {
webAuthEndpoint: server.authEndpoint,
homeDomain: server.homeDomain,
httpClient: this.httpClient,
...(server.signingKey && { serverSigningKey: server.signingKey }),
});
}

Expand Down
16 changes: 11 additions & 5 deletions @stellar/typescript-wallet-sdk/src/walletSdk/Types/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,17 @@ export class AuthToken {
const authToken = new AuthToken();

const decoded = decode(str);
authToken.issuer = decoded.payload.iss;
authToken.principalAccount = decoded.payload.sub;
authToken.issuedAt = decoded.payload.iat;
authToken.expiresAt = decoded.payload.exp;
authToken.clientDomain = decoded.payload.client_domain;
// jws.decode only auto-parses payload as JSON when header contains
// typ:"JWT". Some anchors omit typ, returning a raw JSON string instead.
const payload =
typeof decoded.payload === "string"
? JSON.parse(decoded.payload)
: decoded.payload;
authToken.issuer = payload.iss;
authToken.principalAccount = payload.sub;
authToken.issuedAt = payload.iat;
authToken.expiresAt = payload.exp;
authToken.clientDomain = payload.client_domain;
authToken.token = str;
return authToken;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export type RecoveryServer = {
endpoint: string;
authEndpoint: string;
homeDomain: string;
signingKey?: string;
walletSigner?: WalletSigner;
clientDomain?: string;
};
Expand Down
Loading
Loading