@stellar/typescript-wallet-sdk 4.0.0 → 4.0.1

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.
@@ -115,9 +115,18 @@ export declare class NetworkPassphraseMismatchError extends Error {
115
115
  export declare class ChallengeTxnIncorrectSequenceError extends Error {
116
116
  constructor();
117
117
  }
118
+ /**
119
+ * @deprecated No longer thrown. `Server.signChallengeTransaction` now validates
120
+ * challenges with `WebAuth.readChallengeTx` and reports every failure, including
121
+ * an invalid server signature, as `ChallengeValidationFailedError`. This class
122
+ * will be removed in the next major release.
123
+ */
118
124
  export declare class ChallengeTxnInvalidSignatureError extends Error {
119
125
  constructor();
120
126
  }
127
+ export declare class ChallengeTxnClientAccountMismatchError extends Error {
128
+ constructor(expected: string, received: string);
129
+ }
121
130
  export declare class AllowHttpOnNonTestnetError extends Error {
122
131
  constructor();
123
132
  }
@@ -5,14 +5,30 @@
5
5
  import { SignChallengeTxnParams, SignChallengeTxnResponse, AnchorTransaction } from "../Types";
6
6
  /**
7
7
  * Helper method for signing a SEP-10 challenge transaction if valid.
8
+ *
9
+ * The challenge is validated with `WebAuth.readChallengeTx` from
10
+ * `@stellar/stellar-sdk` — the same validator used by `Sep10.sign()` — which
11
+ * checks the full SEP-10 structure and binds the challenge to the expected
12
+ * anchor: the transaction source must be the anchor's `SIGNING_KEY`, the
13
+ * `<home domain> auth` operation must match the expected home domain, and any
14
+ * `web_auth_domain` operation must match the host of the anchor's
15
+ * `WEB_AUTH_ENDPOINT` (falling back to `anchorDomain` when the TOML omits it).
16
+ * The client account the challenge was issued for is then checked against
17
+ * `accountKp`, so this helper only signs challenges meant for it. Muxed client
18
+ * accounts are supported: the comparison is made on the underlying `G...`
19
+ * address, since that is the key that signs. The muxed id itself is not pinned
20
+ * — a caller that needs to bind a specific subaccount should check the
21
+ * challenge's client account itself.
22
+ *
8
23
  * @param {SignChallengeTxnParams} params - The Authentication params.
9
24
  * @param {AccountKeypair} params.accountKp - Keypair for the Stellar account signing the transaction.
10
25
  * @param {string} [params.challengeTx] - The challenge transaction given by an anchor for authentication.
11
26
  * @param {string} [params.networkPassphrase] - The network passphrase for the network authenticating on.
12
27
  * @param {string} [params.anchorDomain] - Domain hosting stellar.toml file containing `SIGNING_KEY`.
28
+ * @param {string|string[]} [params.homeDomain] - Expected home domain(s) of the challenge. Defaults to `anchorDomain`.
13
29
  * @returns {Promise<SignChallengeTxnResponse>} The signed transaction.
14
30
  */
15
- export declare const signChallengeTransaction: ({ accountKp, challengeTx, networkPassphrase, anchorDomain, }: SignChallengeTxnParams) => Promise<SignChallengeTxnResponse>;
31
+ export declare const signChallengeTransaction: ({ accountKp, challengeTx, networkPassphrase, anchorDomain, homeDomain, }: SignChallengeTxnParams) => Promise<SignChallengeTxnResponse>;
16
32
  /**
17
33
  * Helper method for parsing a JSON string into an AnchorTransaction.
18
34
  * @param {string} transaction - The json string of an anchor transaction.
@@ -54,6 +54,11 @@ export type SignChallengeTxnParams = {
54
54
  challengeTx: string;
55
55
  networkPassphrase: string;
56
56
  anchorDomain: string;
57
+ /**
58
+ * The home domain(s) the challenge is expected to be scoped to. Defaults to
59
+ * `anchorDomain`. Pass an array for anchors that serve multiple home domains.
60
+ */
61
+ homeDomain?: string | string[];
57
62
  };
58
63
  export type SignChallengeTxnResponse = {
59
64
  transaction: XdrEncodedTransaction;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stellar/typescript-wallet-sdk",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -312,6 +312,12 @@ export class ChallengeTxnIncorrectSequenceError extends Error {
312
312
  }
313
313
  }
314
314
 
315
+ /**
316
+ * @deprecated No longer thrown. `Server.signChallengeTransaction` now validates
317
+ * challenges with `WebAuth.readChallengeTx` and reports every failure, including
318
+ * an invalid server signature, as `ChallengeValidationFailedError`. This class
319
+ * will be removed in the next major release.
320
+ */
315
321
  export class ChallengeTxnInvalidSignatureError extends Error {
316
322
  constructor() {
317
323
  super("Invalid signature for challenge transaction");
@@ -319,6 +325,19 @@ export class ChallengeTxnInvalidSignatureError extends Error {
319
325
  }
320
326
  }
321
327
 
328
+ export class ChallengeTxnClientAccountMismatchError extends Error {
329
+ constructor(expected: string, received: string) {
330
+ super(
331
+ `Challenge transaction client account mismatch: expected "${expected}" ` +
332
+ `but challenge was issued for "${received}"`,
333
+ );
334
+ Object.setPrototypeOf(
335
+ this,
336
+ ChallengeTxnClientAccountMismatchError.prototype,
337
+ );
338
+ }
339
+ }
340
+
322
341
  export class AllowHttpOnNonTestnetError extends Error {
323
342
  constructor() {
324
343
  super("Can only allow Http on Testnet");
@@ -6,8 +6,9 @@
6
6
  import {
7
7
  Transaction,
8
8
  TransactionBuilder,
9
- Keypair,
10
9
  StellarToml,
10
+ WebAuth,
11
+ extractBaseAddress,
11
12
  } from "@stellar/stellar-sdk";
12
13
 
13
14
  import { parseToml } from "../Utils";
@@ -21,18 +22,35 @@ import {
21
22
  } from "../Types";
22
23
  import {
23
24
  ChallengeTxnIncorrectSequenceError,
24
- ChallengeTxnInvalidSignatureError,
25
+ ChallengeTxnClientAccountMismatchError,
26
+ ChallengeValidationFailedError,
25
27
  UnknownAnchorTransactionError,
26
28
  InvalidJsonError,
27
29
  } from "../Exceptions";
28
30
 
29
31
  /**
30
32
  * Helper method for signing a SEP-10 challenge transaction if valid.
33
+ *
34
+ * The challenge is validated with `WebAuth.readChallengeTx` from
35
+ * `@stellar/stellar-sdk` — the same validator used by `Sep10.sign()` — which
36
+ * checks the full SEP-10 structure and binds the challenge to the expected
37
+ * anchor: the transaction source must be the anchor's `SIGNING_KEY`, the
38
+ * `<home domain> auth` operation must match the expected home domain, and any
39
+ * `web_auth_domain` operation must match the host of the anchor's
40
+ * `WEB_AUTH_ENDPOINT` (falling back to `anchorDomain` when the TOML omits it).
41
+ * The client account the challenge was issued for is then checked against
42
+ * `accountKp`, so this helper only signs challenges meant for it. Muxed client
43
+ * accounts are supported: the comparison is made on the underlying `G...`
44
+ * address, since that is the key that signs. The muxed id itself is not pinned
45
+ * — a caller that needs to bind a specific subaccount should check the
46
+ * challenge's client account itself.
47
+ *
31
48
  * @param {SignChallengeTxnParams} params - The Authentication params.
32
49
  * @param {AccountKeypair} params.accountKp - Keypair for the Stellar account signing the transaction.
33
50
  * @param {string} [params.challengeTx] - The challenge transaction given by an anchor for authentication.
34
51
  * @param {string} [params.networkPassphrase] - The network passphrase for the network authenticating on.
35
52
  * @param {string} [params.anchorDomain] - Domain hosting stellar.toml file containing `SIGNING_KEY`.
53
+ * @param {string|string[]} [params.homeDomain] - Expected home domain(s) of the challenge. Defaults to `anchorDomain`.
36
54
  * @returns {Promise<SignChallengeTxnResponse>} The signed transaction.
37
55
  */
38
56
  export const signChallengeTransaction = async ({
@@ -40,25 +58,50 @@ export const signChallengeTransaction = async ({
40
58
  challengeTx,
41
59
  networkPassphrase,
42
60
  anchorDomain,
61
+ homeDomain,
43
62
  }: SignChallengeTxnParams): Promise<SignChallengeTxnResponse> => {
44
- const tx = TransactionBuilder.fromXDR(
63
+ const parsedTx = TransactionBuilder.fromXDR(
45
64
  challengeTx,
46
65
  networkPassphrase,
47
66
  ) as Transaction;
48
67
 
49
- if (parseInt(tx.sequence) !== 0) {
68
+ if (parseInt(parsedTx.sequence) !== 0) {
50
69
  throw new ChallengeTxnIncorrectSequenceError();
51
70
  }
52
71
 
53
72
  const tomlResp = await StellarToml.Resolver.resolve(anchorDomain);
54
- const parsedToml = parseToml(tomlResp);
55
- const anchorKp = Keypair.fromPublicKey(parsedToml.signingKey);
73
+ const { signingKey, webAuthEndpoint } = parseToml(tomlResp);
56
74
 
57
- const isValid =
58
- tx.signatures.length &&
59
- anchorKp.verify(tx.hash(), tx.signatures[0].signature());
60
- if (!isValid) {
61
- throw new ChallengeTxnInvalidSignatureError();
75
+ let tx: Transaction;
76
+ let clientAccountID: string;
77
+ try {
78
+ // Kept inside the try so a malformed WEB_AUTH_ENDPOINT surfaces as a
79
+ // ChallengeValidationFailedError rather than a raw TypeError.
80
+ const webAuthDomain = webAuthEndpoint
81
+ ? new URL(webAuthEndpoint).hostname
82
+ : anchorDomain;
83
+
84
+ ({ tx, clientAccountID } = WebAuth.readChallengeTx(
85
+ challengeTx,
86
+ signingKey,
87
+ networkPassphrase,
88
+ homeDomain ?? anchorDomain,
89
+ webAuthDomain,
90
+ ));
91
+ } catch (e) {
92
+ throw new ChallengeValidationFailedError(
93
+ e instanceof Error ? e : new Error(String(e)),
94
+ );
95
+ }
96
+
97
+ // readChallengeTx returns the operation source verbatim, which is an `M...`
98
+ // address for a muxed client account. accountKp.publicKey is always the
99
+ // underlying `G...` key, so compare base addresses.
100
+ if (extractBaseAddress(clientAccountID) !== accountKp.publicKey) {
101
+ throw new ChallengeTxnClientAccountMismatchError(
102
+ accountKp.publicKey,
103
+ clientAccountID,
104
+ );
62
105
  }
63
106
 
64
107
  accountKp.sign(tx);
@@ -94,6 +94,11 @@ export type SignChallengeTxnParams = {
94
94
  challengeTx: string;
95
95
  networkPassphrase: string;
96
96
  anchorDomain: string;
97
+ /**
98
+ * The home domain(s) the challenge is expected to be scoped to. Defaults to
99
+ * `anchorDomain`. Pass an array for anchors that serve multiple home domains.
100
+ */
101
+ homeDomain?: string | string[];
97
102
  };
98
103
 
99
104
  export type SignChallengeTxnResponse = {
@@ -1,6 +1,21 @@
1
1
  import axios from "axios";
2
- import { TransactionBuilder } from "@stellar/stellar-sdk";
2
+ import sinon from "sinon";
3
+ import {
4
+ Account,
5
+ Asset,
6
+ Keypair,
7
+ MuxedAccount,
8
+ Operation,
9
+ StellarToml,
10
+ Transaction,
11
+ TransactionBuilder,
12
+ WebAuth,
13
+ } from "@stellar/stellar-sdk";
3
14
  import { Wallet, Server } from "../src";
15
+ import {
16
+ ChallengeTxnClientAccountMismatchError,
17
+ ChallengeValidationFailedError,
18
+ } from "../src/walletSdk/Exceptions";
4
19
 
5
20
  let wallet;
6
21
  let account;
@@ -78,3 +93,206 @@ describe("Server helpers", () => {
78
93
  expect(parsed.kind).toBe("withdrawal");
79
94
  });
80
95
  });
96
+
97
+ describe("signChallengeTransaction validation", () => {
98
+ const anchorA = "anchor-a.example.com";
99
+ const anchorB = "anchor-b.example.com";
100
+
101
+ let anchorAKp;
102
+ let anchorBKp;
103
+ let accountKp;
104
+
105
+ beforeEach(() => {
106
+ anchorAKp = Keypair.random();
107
+ anchorBKp = Keypair.random();
108
+ accountKp = Wallet.TestNet().stellar().account().createKeypair();
109
+
110
+ // anchorDomain always resolves to anchor A's stellar.toml
111
+ sinon.stub(StellarToml.Resolver, "resolve").resolves({
112
+ SIGNING_KEY: anchorAKp.publicKey(),
113
+ WEB_AUTH_ENDPOINT: `https://${anchorA}/auth`,
114
+ DOCUMENTATION: {},
115
+ } as StellarToml.Api.StellarToml);
116
+ });
117
+
118
+ afterEach(() => {
119
+ sinon.restore();
120
+ });
121
+
122
+ // webAuthDomain defaults to anchor A, which is what the stubbed
123
+ // WEB_AUTH_ENDPOINT resolves to for anchorDomain.
124
+ const buildChallenge = (
125
+ serverKp,
126
+ homeDomain,
127
+ clientAccountID,
128
+ webAuthDomain = anchorA,
129
+ ) =>
130
+ WebAuth.buildChallengeTx(
131
+ serverKp,
132
+ clientAccountID,
133
+ homeDomain,
134
+ 300,
135
+ networkPassphrase,
136
+ webAuthDomain,
137
+ );
138
+
139
+ it("should sign a challenge issued by the expected anchor", async () => {
140
+ const challengeTx = buildChallenge(anchorAKp, anchorA, accountKp.publicKey);
141
+
142
+ const signedResp = await Server.signChallengeTransaction({
143
+ accountKp,
144
+ challengeTx,
145
+ networkPassphrase,
146
+ anchorDomain: anchorA,
147
+ });
148
+
149
+ const signedTxn = TransactionBuilder.fromXDR(
150
+ signedResp.transaction,
151
+ networkPassphrase,
152
+ );
153
+ expect(signedTxn.signatures.length).toBe(2);
154
+ });
155
+
156
+ it("should reject a challenge issued by a different anchor", async () => {
157
+ // A relays a challenge that anchor B issued for the same account, ordering
158
+ // its own signature first so that signatures[0] matches the SIGNING_KEY
159
+ // published at anchorDomain. Only the home domain, web auth domain and
160
+ // transaction source bind the challenge to the anchor that issued it.
161
+ const relayed = TransactionBuilder.fromXDR(
162
+ buildChallenge(anchorBKp, anchorB, accountKp.publicKey, anchorB),
163
+ networkPassphrase,
164
+ ) as Transaction;
165
+
166
+ const anchorBSignature = relayed.signatures[0];
167
+ relayed.signatures.length = 0;
168
+ relayed.sign(anchorAKp);
169
+ relayed.signatures.push(anchorBSignature);
170
+
171
+ await expect(
172
+ Server.signChallengeTransaction({
173
+ accountKp,
174
+ challengeTx: relayed.toXDR(),
175
+ networkPassphrase,
176
+ anchorDomain: anchorA,
177
+ }),
178
+ ).rejects.toThrow(ChallengeValidationFailedError);
179
+ });
180
+
181
+ it("should sign a challenge issued for a muxed account of the signing key", async () => {
182
+ // readChallengeTx returns the M... source verbatim; the underlying G... key
183
+ // is the correct signer, so this must be accepted.
184
+ const muxed = new MuxedAccount(
185
+ new Account(accountKp.publicKey, "0"),
186
+ "1234",
187
+ ).accountId();
188
+ expect(muxed.startsWith("M")).toBe(true);
189
+
190
+ const challengeTx = buildChallenge(anchorAKp, anchorA, muxed);
191
+
192
+ const signedResp = await Server.signChallengeTransaction({
193
+ accountKp,
194
+ challengeTx,
195
+ networkPassphrase,
196
+ anchorDomain: anchorA,
197
+ });
198
+
199
+ const signedTxn = TransactionBuilder.fromXDR(
200
+ signedResp.transaction,
201
+ networkPassphrase,
202
+ );
203
+ expect(signedTxn.signatures.length).toBe(2);
204
+ });
205
+
206
+ it("should reject a muxed challenge whose underlying key is not the signer", async () => {
207
+ const otherMuxed = new MuxedAccount(
208
+ new Account(Keypair.random().publicKey(), "0"),
209
+ "1234",
210
+ ).accountId();
211
+
212
+ await expect(
213
+ Server.signChallengeTransaction({
214
+ accountKp,
215
+ challengeTx: buildChallenge(anchorAKp, anchorA, otherMuxed),
216
+ networkPassphrase,
217
+ anchorDomain: anchorA,
218
+ }),
219
+ ).rejects.toThrow(ChallengeTxnClientAccountMismatchError);
220
+ });
221
+
222
+ it("should reject a challenge issued for a different account", async () => {
223
+ const otherAccount = Keypair.random().publicKey();
224
+ const challengeTx = buildChallenge(anchorAKp, anchorA, otherAccount);
225
+
226
+ await expect(
227
+ Server.signChallengeTransaction({
228
+ accountKp,
229
+ challengeTx,
230
+ networkPassphrase,
231
+ anchorDomain: anchorA,
232
+ }),
233
+ ).rejects.toThrow(ChallengeTxnClientAccountMismatchError);
234
+ });
235
+
236
+ it("should reject a transaction that is not a SEP-10 challenge", async () => {
237
+ const notAChallenge = new TransactionBuilder(
238
+ new Account(anchorAKp.publicKey(), "-1"),
239
+ { fee: "100", networkPassphrase },
240
+ )
241
+ .addOperation(
242
+ Operation.payment({
243
+ destination: anchorAKp.publicKey(),
244
+ asset: Asset.native(),
245
+ amount: "1000",
246
+ }),
247
+ )
248
+ .setTimeout(300)
249
+ .build();
250
+ notAChallenge.sign(anchorAKp);
251
+
252
+ await expect(
253
+ Server.signChallengeTransaction({
254
+ accountKp,
255
+ challengeTx: notAChallenge.toXDR(),
256
+ networkPassphrase,
257
+ anchorDomain: anchorA,
258
+ }),
259
+ ).rejects.toThrow(ChallengeValidationFailedError);
260
+ });
261
+
262
+ it("should wrap a malformed WEB_AUTH_ENDPOINT as a validation failure", async () => {
263
+ sinon.restore();
264
+ sinon.stub(StellarToml.Resolver, "resolve").resolves({
265
+ SIGNING_KEY: anchorAKp.publicKey(),
266
+ WEB_AUTH_ENDPOINT: "not a url",
267
+ DOCUMENTATION: {},
268
+ } as StellarToml.Api.StellarToml);
269
+
270
+ await expect(
271
+ Server.signChallengeTransaction({
272
+ accountKp,
273
+ challengeTx: buildChallenge(anchorAKp, anchorA, accountKp.publicKey),
274
+ networkPassphrase,
275
+ anchorDomain: anchorA,
276
+ }),
277
+ ).rejects.toThrow(ChallengeValidationFailedError);
278
+ });
279
+
280
+ it("should accept an explicit homeDomain that differs from anchorDomain", async () => {
281
+ const homeDomain = "wallet-home.example.com";
282
+ const challengeTx = buildChallenge(
283
+ anchorAKp,
284
+ homeDomain,
285
+ accountKp.publicKey,
286
+ );
287
+
288
+ const signedResp = await Server.signChallengeTransaction({
289
+ accountKp,
290
+ challengeTx,
291
+ networkPassphrase,
292
+ anchorDomain: anchorA,
293
+ homeDomain,
294
+ });
295
+
296
+ expect(signedResp.networkPassphrase).toBe(networkPassphrase);
297
+ });
298
+ });