@stellar/typescript-wallet-sdk 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@ type Sep10Params = {
7
7
  webAuthEndpoint: string;
8
8
  homeDomain: string;
9
9
  httpClient: AxiosInstance;
10
+ serverSigningKey?: string;
10
11
  };
11
12
  /**
12
13
  * @alias Auth alias for Sep10 class.
@@ -23,6 +24,7 @@ export declare class Sep10 {
23
24
  private webAuthEndpoint;
24
25
  private homeDomain;
25
26
  private httpClient;
27
+ private serverSigningKey?;
26
28
  /**
27
29
  * Creates a new instance of the Sep10 class.
28
30
  *
@@ -103,6 +103,12 @@ export declare class NoAccountAndNoSponsorError extends Error {
103
103
  export declare class Sep38PriceOnlyOneAmountError extends Error {
104
104
  constructor();
105
105
  }
106
+ export declare class ChallengeValidationFailedError extends Error {
107
+ constructor(cause: Error);
108
+ }
109
+ export declare class NetworkPassphraseMismatchError extends Error {
110
+ constructor(expected: string, received: string);
111
+ }
106
112
  export declare class ChallengeTxnIncorrectSequenceError extends Error {
107
113
  constructor();
108
114
  }
@@ -56,6 +56,7 @@ export type RecoveryServer = {
56
56
  endpoint: string;
57
57
  authEndpoint: string;
58
58
  homeDomain: string;
59
+ signingKey?: string;
59
60
  walletSigner?: WalletSigner;
60
61
  clientDomain?: string;
61
62
  };
@@ -4,6 +4,7 @@ export declare enum Sep7OperationType {
4
4
  pay = "pay"
5
5
  }
6
6
  export declare const URI_MSG_MAX_LENGTH = 300;
7
+ export declare const URI_REPLACE_MAX_LENGTH = 4096;
7
8
  export type Sep7Replacement = {
8
9
  id: string;
9
10
  path: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stellar/typescript-wallet-sdk",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "engines": {
5
5
  "node": ">=18"
6
6
  },
@@ -109,6 +109,7 @@ export class Anchor {
109
109
  webAuthEndpoint: tomlInfo.webAuthEndpoint,
110
110
  homeDomain: this.homeDomain,
111
111
  httpClient: this.httpClient,
112
+ serverSigningKey: tomlInfo.signingKey,
112
113
  });
113
114
  }
114
115
 
@@ -1,5 +1,10 @@
1
1
  import { AxiosInstance } from "axios";
2
- import { TransactionBuilder, Transaction } from "@stellar/stellar-sdk";
2
+ import {
3
+ TransactionBuilder,
4
+ Transaction,
5
+ FeeBumpTransaction,
6
+ WebAuth,
7
+ } from "@stellar/stellar-sdk";
3
8
  import { decode } from "jws";
4
9
 
5
10
  import { Config } from "../";
@@ -10,6 +15,8 @@ import {
10
15
  InvalidTokenError,
11
16
  MissingTokenError,
12
17
  ExpiredTokenError,
18
+ ChallengeValidationFailedError,
19
+ NetworkPassphraseMismatchError,
13
20
  } from "../Exceptions";
14
21
  import {
15
22
  AuthenticateParams,
@@ -31,6 +38,7 @@ type Sep10Params = {
31
38
  webAuthEndpoint: string;
32
39
  homeDomain: string;
33
40
  httpClient: AxiosInstance;
41
+ serverSigningKey?: string;
34
42
  };
35
43
 
36
44
  /**
@@ -49,6 +57,7 @@ export class Sep10 {
49
57
  private webAuthEndpoint: string;
50
58
  private homeDomain: string;
51
59
  private httpClient: AxiosInstance;
60
+ private serverSigningKey?: string;
52
61
 
53
62
  /**
54
63
  * Creates a new instance of the Sep10 class.
@@ -57,12 +66,14 @@ export class Sep10 {
57
66
  * @param {Sep10Params} params - Parameters to initialize the Sep10 instance.
58
67
  */
59
68
  constructor(params: Sep10Params) {
60
- const { cfg, webAuthEndpoint, homeDomain, httpClient } = params;
69
+ const { cfg, webAuthEndpoint, homeDomain, httpClient, serverSigningKey } =
70
+ params;
61
71
 
62
72
  this.cfg = cfg;
63
73
  this.webAuthEndpoint = webAuthEndpoint;
64
74
  this.homeDomain = homeDomain;
65
75
  this.httpClient = httpClient;
76
+ this.serverSigningKey = serverSigningKey;
66
77
  }
67
78
 
68
79
  /**
@@ -150,9 +161,46 @@ export class Sep10 {
150
161
  challengeResponse,
151
162
  walletSigner,
152
163
  }: SignParams): Promise<Transaction> {
164
+ const networkPassphrase = this.cfg.stellar.network;
165
+
166
+ if (
167
+ challengeResponse.network_passphrase &&
168
+ challengeResponse.network_passphrase !== (networkPassphrase as string)
169
+ ) {
170
+ throw new NetworkPassphraseMismatchError(
171
+ networkPassphrase,
172
+ challengeResponse.network_passphrase,
173
+ );
174
+ }
175
+
176
+ try {
177
+ const webAuthDomain = new URL(this.webAuthEndpoint).hostname;
178
+
179
+ if (this.serverSigningKey) {
180
+ WebAuth.readChallengeTx(
181
+ challengeResponse.transaction,
182
+ this.serverSigningKey,
183
+ networkPassphrase,
184
+ this.homeDomain,
185
+ webAuthDomain,
186
+ );
187
+ } else {
188
+ readChallengeTx(
189
+ challengeResponse.transaction,
190
+ networkPassphrase,
191
+ this.homeDomain,
192
+ webAuthDomain,
193
+ );
194
+ }
195
+ } catch (e) {
196
+ throw new ChallengeValidationFailedError(
197
+ e instanceof Error ? e : new Error(String(e)),
198
+ );
199
+ }
200
+
153
201
  let transaction: Transaction = TransactionBuilder.fromXDR(
154
202
  challengeResponse.transaction,
155
- challengeResponse.network_passphrase,
203
+ networkPassphrase,
156
204
  ) as Transaction;
157
205
 
158
206
  // check if verifying client domain as well
@@ -160,7 +208,7 @@ export class Sep10 {
160
208
  if (op.type === "manageData" && op.name === "client_domain") {
161
209
  transaction = await walletSigner.signWithDomainAccount({
162
210
  transactionXDR: challengeResponse.transaction,
163
- networkPassphrase: challengeResponse.network_passphrase,
211
+ networkPassphrase,
164
212
  accountKp,
165
213
  });
166
214
  }
@@ -188,14 +236,153 @@ export class Sep10 {
188
236
  }
189
237
  }
190
238
 
191
- const validateToken = (token: string) => {
239
+ /**
240
+ * @internal
241
+ * @param {string} token - The JWT token to validate.
242
+ */
243
+ export const validateToken = (token: string) => {
192
244
  const parsedToken = decode(token);
193
245
  if (!parsedToken) {
194
246
  throw new InvalidTokenError();
195
247
  }
196
- if (parsedToken.expiresAt < Math.floor(Date.now() / 1000)) {
197
- throw new ExpiredTokenError(parsedToken.expiresAt);
248
+ const payload =
249
+ typeof parsedToken.payload === "string"
250
+ ? JSON.parse(parsedToken.payload)
251
+ : parsedToken.payload;
252
+ const exp = payload?.exp;
253
+ if (typeof exp === "number" && exp < Math.floor(Date.now() / 1000)) {
254
+ throw new ExpiredTokenError(exp);
255
+ }
256
+ };
257
+
258
+ /*
259
+ * Validates a SEP-10 challenge transaction without requiring the server's
260
+ * signing key. This performs all structural validations from the SEP-10 spec
261
+ * (sequence number, operation types, timebounds, home domain, web_auth_domain,
262
+ * nonce format) but skips the server account and signature checks.
263
+ *
264
+ * Used as a fallback when the anchor's stellar.toml does not publish a
265
+ * SIGNING_KEY, providing strong protection against malformed or malicious
266
+ * challenge transactions.
267
+ *
268
+ * @internal
269
+ * @see {@link https://github.com/stellar/js-stellar-sdk/blob/v13.0.0-beta.1/src/webauth/utils.ts#L188 | WebAuth.readChallengeTx}
270
+ */
271
+ const readChallengeTx = (
272
+ challengeTx: string,
273
+ networkPassphrase: string,
274
+ homeDomain: string,
275
+ webAuthDomain: string,
276
+ ): { tx: Transaction; clientAccountID: string } => {
277
+ let transaction: Transaction;
278
+ try {
279
+ transaction = new Transaction(challengeTx, networkPassphrase);
280
+ } catch {
281
+ try {
282
+ // eslint-disable-next-line no-new
283
+ new FeeBumpTransaction(challengeTx, networkPassphrase);
284
+ } catch {
285
+ throw new Error(
286
+ "Invalid challenge: unable to deserialize challengeTx transaction string",
287
+ );
288
+ }
289
+ throw new Error(
290
+ "Invalid challenge: expected a Transaction but received a FeeBumpTransaction",
291
+ );
292
+ }
293
+
294
+ // verify sequence number
295
+ const sequence = Number.parseInt(transaction.sequence, 10);
296
+ if (sequence !== 0) {
297
+ throw new Error("The transaction sequence number should be zero");
298
+ }
299
+
300
+ // verify operations
301
+ if (transaction.operations.length < 1) {
302
+ throw new Error("The transaction should contain at least one operation");
303
+ }
304
+
305
+ const [operation, ...subsequentOperations] = transaction.operations;
306
+
307
+ if (!operation.source) {
308
+ throw new Error(
309
+ "The transaction's operation should contain a source account",
310
+ );
311
+ }
312
+ const clientAccountID: string = operation.source;
313
+
314
+ // verify memo
315
+ if (transaction.memo.type !== "none") {
316
+ if (clientAccountID.startsWith("M")) {
317
+ throw new Error(
318
+ "The transaction has a memo but the client account ID is a muxed account",
319
+ );
320
+ }
321
+ if (transaction.memo.type !== "id") {
322
+ throw new Error("The transaction's memo must be of type `id`");
323
+ }
324
+ }
325
+
326
+ if (operation.type !== "manageData") {
327
+ throw new Error("The transaction's operation type should be 'manageData'");
328
+ }
329
+
330
+ // verify timebounds
331
+ if (!transaction.timeBounds) {
332
+ throw new Error("The transaction requires timebounds");
333
+ }
334
+
335
+ if (Number.parseInt(transaction.timeBounds.maxTime, 10) === 0) {
336
+ throw new Error("The transaction requires non-infinite timebounds");
337
+ }
338
+
339
+ const now = Math.floor(Date.now() / 1000);
340
+ const gracePeriod = 60 * 5;
341
+ const minTime = Number.parseInt(transaction.timeBounds.minTime, 10) || 0;
342
+ const maxTime = Number.parseInt(transaction.timeBounds.maxTime, 10) || 0;
343
+ if (now < minTime - gracePeriod || now > maxTime + gracePeriod) {
344
+ throw new Error("The transaction has expired");
345
+ }
346
+
347
+ // verify nonce value
348
+ if (operation.value === undefined || !operation.value) {
349
+ throw new Error("The transaction's operation value should not be null");
198
350
  }
351
+
352
+ if (Buffer.from(operation.value.toString(), "base64").length !== 48) {
353
+ throw new Error(
354
+ "The transaction's operation value should be a 64 bytes base64 random string",
355
+ );
356
+ }
357
+
358
+ // verify home domain
359
+ if (`${homeDomain} auth` !== operation.name) {
360
+ throw new Error(
361
+ "Invalid homeDomains: the transaction's operation key name " +
362
+ "does not match the expected home domain",
363
+ );
364
+ }
365
+
366
+ // verify subsequent operations are all manageData
367
+ for (const op of subsequentOperations) {
368
+ if (op.type !== "manageData") {
369
+ throw new Error(
370
+ "The transaction has operations that are not of type 'manageData'",
371
+ );
372
+ }
373
+ if (op.name === "web_auth_domain") {
374
+ if (op.value === undefined) {
375
+ throw new Error("'web_auth_domain' operation value should not be null");
376
+ }
377
+ if (op.value.compare(Buffer.from(webAuthDomain)) !== 0) {
378
+ throw new Error(
379
+ `'web_auth_domain' operation value does not match ${webAuthDomain}`,
380
+ );
381
+ }
382
+ }
383
+ }
384
+
385
+ return { tx: transaction, clientAccountID };
199
386
  };
200
387
 
201
388
  const createAuthSignToken = async (
@@ -280,6 +280,22 @@ export class Sep38PriceOnlyOneAmountError extends Error {
280
280
  }
281
281
  }
282
282
 
283
+ export class ChallengeValidationFailedError extends Error {
284
+ constructor(cause: Error) {
285
+ super(`SEP-10 challenge validation failed: ${cause.message}`);
286
+ Object.setPrototypeOf(this, ChallengeValidationFailedError.prototype);
287
+ }
288
+ }
289
+
290
+ export class NetworkPassphraseMismatchError extends Error {
291
+ constructor(expected: string, received: string) {
292
+ super(
293
+ `Network passphrase mismatch: expected "${expected}" but server returned "${received}"`,
294
+ );
295
+ Object.setPrototypeOf(this, NetworkPassphraseMismatchError.prototype);
296
+ }
297
+ }
298
+
283
299
  export class ChallengeTxnIncorrectSequenceError extends Error {
284
300
  constructor() {
285
301
  super("Challenge transaction sequence number must be 0");
@@ -76,6 +76,7 @@ export class Recovery extends AccountRecover {
76
76
  webAuthEndpoint: server.authEndpoint,
77
77
  homeDomain: server.homeDomain,
78
78
  httpClient: this.httpClient,
79
+ ...(server.signingKey && { serverSigningKey: server.signingKey }),
79
80
  });
80
81
  }
81
82
 
@@ -37,11 +37,17 @@ export class AuthToken {
37
37
  const authToken = new AuthToken();
38
38
 
39
39
  const decoded = decode(str);
40
- authToken.issuer = decoded.payload.iss;
41
- authToken.principalAccount = decoded.payload.sub;
42
- authToken.issuedAt = decoded.payload.iat;
43
- authToken.expiresAt = decoded.payload.exp;
44
- authToken.clientDomain = decoded.payload.client_domain;
40
+ // jws.decode only auto-parses payload as JSON when header contains
41
+ // typ:"JWT". Some anchors omit typ, returning a raw JSON string instead.
42
+ const payload =
43
+ typeof decoded.payload === "string"
44
+ ? JSON.parse(decoded.payload)
45
+ : decoded.payload;
46
+ authToken.issuer = payload.iss;
47
+ authToken.principalAccount = payload.sub;
48
+ authToken.issuedAt = payload.iat;
49
+ authToken.expiresAt = payload.exp;
50
+ authToken.clientDomain = payload.client_domain;
45
51
  authToken.token = str;
46
52
  return authToken;
47
53
  };
@@ -63,6 +63,7 @@ export type RecoveryServer = {
63
63
  endpoint: string;
64
64
  authEndpoint: string;
65
65
  homeDomain: string;
66
+ signingKey?: string;
66
67
  walletSigner?: WalletSigner;
67
68
  clientDomain?: string;
68
69
  };
@@ -6,6 +6,7 @@ export enum Sep7OperationType {
6
6
  }
7
7
 
8
8
  export const URI_MSG_MAX_LENGTH = 300;
9
+ export const URI_REPLACE_MAX_LENGTH = 4096;
9
10
 
10
11
  export type Sep7Replacement = {
11
12
  id: string;
@@ -7,6 +7,7 @@ import {
7
7
  IsValidSep7UriResult,
8
8
  WEB_STELLAR_SCHEME,
9
9
  URI_MSG_MAX_LENGTH,
10
+ URI_REPLACE_MAX_LENGTH,
10
11
  } from "../Types";
11
12
  import {
12
13
  Sep7InvalidUriError,
@@ -162,16 +163,55 @@ export const sep7ReplacementsFromString = (
162
163
  return [];
163
164
  }
164
165
 
166
+ if (replacements.length > URI_REPLACE_MAX_LENGTH) {
167
+ throw new Sep7InvalidUriError(
168
+ "the 'replace' parameter exceeds the maximum allowed length",
169
+ );
170
+ }
171
+
165
172
  const [txrepString, hintsString] = replacements.split(HINT_DELIMITER);
166
- const hintsList = hintsString.split(LIST_DELIMITER);
167
173
 
168
- const hintsMap: { [id: string]: string } = {};
174
+ const txrepList = txrepString.split(LIST_DELIMITER);
175
+ const txrepIds: string[] = [];
176
+ txrepList.forEach((item) => {
177
+ const parts = item.split(ID_DELIMITER);
178
+ if (parts.length < 2 || !parts[0] || !parts[1]) {
179
+ throw new Sep7InvalidUriError(
180
+ "the 'replace' parameter has an entry missing a path or reference identifier",
181
+ );
182
+ }
183
+ const id = parts[1];
184
+ if (txrepIds.indexOf(id) === -1) {
185
+ txrepIds.push(id);
186
+ }
187
+ });
169
188
 
170
- hintsList
171
- .map((item) => item.split(ID_DELIMITER))
172
- .forEach(([id, hint]) => (hintsMap[id] = hint));
189
+ const hintsMap = Object.create(null) as Record<string, string>;
173
190
 
174
- const txrepList = txrepString.split(LIST_DELIMITER);
191
+ if (hintsString) {
192
+ const hintsList = hintsString.split(LIST_DELIMITER);
193
+ hintsList.forEach((item) => {
194
+ const parts = item.split(ID_DELIMITER);
195
+ if (parts.length < 2 || !parts[0] || !parts[1]) {
196
+ throw new Sep7InvalidUriError(
197
+ "the 'replace' parameter has a hint entry missing an identifier or hint value",
198
+ );
199
+ }
200
+ hintsMap[parts[0]] = parts[1];
201
+ });
202
+ }
203
+
204
+ const hintIds = Object.keys(hintsMap);
205
+
206
+ const isBalanced =
207
+ txrepIds.length === hintIds.length &&
208
+ txrepIds.every((id) => Object.prototype.hasOwnProperty.call(hintsMap, id));
209
+
210
+ if (!isBalanced) {
211
+ throw new Sep7InvalidUriError(
212
+ "the 'replace' parameter has unbalanced reference identifiers",
213
+ );
214
+ }
175
215
 
176
216
  const replacementsList = txrepList
177
217
  .map((item) => item.split(ID_DELIMITER))