Skip to content

Commit 3ce8c59

Browse files
fix(anchor-sdk)!: validate SEP-10 challenges before handing them to a signer (#999)
`Sep10Client.authenticate()` fetched the anchor's challenge and passed the raw XDR straight into the caller-supplied `sign` callback with no checks at all. None of the SEP-10 mandated client-side validation ran: no verification of the anchor's signature over the transaction, no source-account check, no `sequence == 0`, no `<home_domain> auth` Manage Data operation check, no operation-source check, no time bounds. `network_passphrase` was parsed by the Zod schema and then never compared to anything. The giveaway was in sep1.ts: `SIGNING_KEY` — the single value that can prove a challenge came from the anchor — was parsed out of `stellar.toml` and never read by any code path. Impact: a hostile or compromised anchor, or an on-path attacker against a plain-http WEB_AUTH_ENDPOINT (the constructor accepted one), could return an ordinary transaction instead of a challenge — a payment, or a set_options adding a signer — and have it blind-signed by the consumer's wallet, hardware device, or KMS. The module docstring offered "this package never holds key material" as a safety property; not holding the key does not make signing an unchecked transaction safe, it just relocates the consequence to whoever does hold it. Fix: `verifyChallenge()` runs `WebAuth.readChallengeTx` — the reference implementation of every required check — and `authenticate()` calls it BEFORE `sign`. A `network_passphrase` that disagrees with the configured network is rejected first, since otherwise the signature would be checked against the wrong network's transaction hash. Non-https endpoints are refused at construction. `WebAuth` is re-exported from pulse-core rather than added as a direct dependency of anchor-sdk, following the existing `StrKey` re-export at pulse-core/src/index.ts:45. pulse-core already depends on @stellar/stellar-sdk, so the runtime dependency tree is unchanged and the bundle-size job is unaffected. `Sep10Client.fromToml(toml, homeDomain)` is the recommended constructor: it takes SIGNING_KEY and NETWORK_PASSPHRASE from the toml the caller already fetches, so the verification key cannot be forgotten. Anchors publishing no SIGNING_KEY are refused rather than trusted. BREAKING CHANGE: `Sep10Client` now requires `serverAccountId`, `networkPassphrase`, `homeDomain` and `webAuthDomain`. These cannot be optional — an optional verification key is one callers omit, which is the bug. Taken under the security exception in STABILITY.md ("if a covered surface is itself the vulnerability"); anchor-sdk goes 0.1.0 -> 0.2.0 and CHANGELOG.md carries the required `### Security` entry with migration. A GitHub Security Advisory still needs publishing per SECURITY.md. Tests: 14 new cases in test/sep10.test.ts built against real challenge transactions. Each rejection case asserts `sign` was never called — that the challenge is refused is secondary, that it never reached the signer is the point. Verified as genuine regressions: with the `verifyChallenge` call removed, 6 of them fail. The three SEP-10 cases in test/sep24.test.ts moved here. They drove authenticate() with placeholder XDR ("AAAA-challenge") and only passed because nothing inspected it; they now use real challenges. 67 tests pass in anchor-sdk, 614 in pulse-core. Co-authored-by: Salmatcre8 <118213044+Salmatcre8@users.noreply.github.com>
1 parent 884a2c0 commit 3ce8c59

8 files changed

Lines changed: 489 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,45 @@ Per-package changelogs live in each package directory.
4444

4545
### Security
4646

47+
- **`@orbital-stellar/anchor-sdk` 0.1.0 does not validate SEP-10 challenges
48+
before signing them.** `Sep10Client.authenticate()` passed the anchor's
49+
challenge XDR straight to the caller-supplied `sign` callback with no checks:
50+
no verification of the anchor's signature, no source-account or `sequence == 0`
51+
check, no `<home_domain> auth` Manage Data check, no time bounds. The
52+
`network_passphrase` in the response was parsed and then ignored, and
53+
`SIGNING_KEY` — the one value that can attribute a challenge to an anchor —
54+
was read from `stellar.toml` and never used.
55+
56+
A hostile or compromised anchor, or an on-path attacker against a plain-`http`
57+
`WEB_AUTH_ENDPOINT`, could return an ordinary transaction (a payment, or a
58+
`set_options` adding a signer) and have it blind-signed by the consumer's
59+
wallet, hardware device, or KMS.
60+
61+
Fixed in **0.2.0**. `Sep10Client` now verifies every challenge with
62+
`WebAuth.readChallengeTx` before `sign` is invoked, rejects a
63+
`network_passphrase` that disagrees with the configured network, and refuses a
64+
non-`https` endpoint at construction.
65+
66+
This is a **breaking change to a surface that was itself the vulnerability**,
67+
taken under the security exception in [`STABILITY.md`](./STABILITY.md).
68+
A GitHub Security Advisory is to be published per [`SECURITY.md`](./SECURITY.md).
69+
70+
**Migration.** `Sep10Client` now requires the anchor's identity. The smallest
71+
change is to build it from the anchor's own `stellar.toml`:
72+
73+
```ts
74+
// before - no way to tell whose challenge you were signing
75+
const client = new Sep10Client(toml.WEB_AUTH_ENDPOINT);
76+
77+
// after - SIGNING_KEY and NETWORK_PASSPHRASE come from the toml you already fetch
78+
const toml = await discoverAnchor("anchor.example");
79+
const client = Sep10Client.fromToml(toml, "anchor.example");
80+
```
81+
82+
Or pass them explicitly: `new Sep10Client(endpoint, { serverAccountId,
83+
networkPassphrase, homeDomain, webAuthDomain })`. Anchors that publish no
84+
`SIGNING_KEY` are now refused rather than trusted.
85+
4786
---
4887

4988
## [0.1.0] - 2026-05-28

packages/anchor-sdk/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@orbital-stellar/anchor-sdk",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "SDK for interacting with Stellar Anchors (SEP-12, SEP-24, SEP-31).",
55
"license": "MIT",
66
"engines": {
@@ -40,6 +40,7 @@
4040
"zod": "^4.4.3"
4141
},
4242
"devDependencies": {
43+
"@stellar/stellar-sdk": "^16.1.0",
4344
"@types/node": "^26.1.2",
4445
"@vitest/coverage-v8": "^4.1.10",
4546
"vite": "^8.1.5",

packages/anchor-sdk/src/sep10.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { z } from "zod";
2+
import { WebAuth } from "@orbital-stellar/pulse-core";
3+
import type { StellarToml } from "./sep1.js";
24
import { stripTrailingSlashes } from "./strings.js";
35

46
/**
@@ -9,6 +11,12 @@ import { stripTrailingSlashes } from "./strings.js";
911
* secret key. This package never holds key material: a consumer can sign with
1012
* a hardware wallet, a KMS, or `Keypair.sign` - the SDK only sees the signed
1113
* XDR that comes back.
14+
*
15+
* That delegation is only safe if the challenge is checked first. Not holding
16+
* the key does not reduce the risk of signing the wrong transaction, it just
17+
* moves the risk to whoever does hold it. So `Sep10Client` validates every
18+
* challenge against the anchor's `SIGNING_KEY` before the signer ever sees it -
19+
* see `verifyChallenge`.
1220
*/
1321

1422
/** Thrown when the anchor rejects the challenge or returns an unusable one. */
@@ -35,6 +43,21 @@ const Sep10TokenSchema = z.object({ token: z.string() });
3543
export type ChallengeSigner = (challenge: Sep10Challenge) => Promise<string> | string;
3644

3745
export type Sep10ClientOptions = {
46+
/**
47+
* The anchor's `SIGNING_KEY` from its `stellar.toml` (see `discoverAnchor`).
48+
* The challenge must carry a valid signature from this key or it is not the
49+
* anchor's challenge and must never reach the signer.
50+
*/
51+
serverAccountId: string;
52+
/** Expected network passphrase, e.g. `Networks.TESTNET`. */
53+
networkPassphrase: string;
54+
/**
55+
* Home domain(s) expected in the challenge's first Manage Data key. Pass an
56+
* array when an anchor serves several.
57+
*/
58+
homeDomain: string | string[];
59+
/** Domain expected as the value of the `web_auth_domain` Manage Data entry. */
60+
webAuthDomain: string;
3861
/** Transport override; defaults to the global `fetch`. */
3962
transport?: (input: string, init?: RequestInit) => Promise<Response>;
4063
/** Request timeout in milliseconds. Defaults to 10 000. */
@@ -54,15 +77,122 @@ export type Sep10AuthenticateParams = {
5477

5578
export class Sep10Client {
5679
private readonly webAuthEndpoint: string;
80+
private readonly serverAccountId: string;
81+
private readonly networkPassphrase: string;
82+
private readonly homeDomain: string | string[];
83+
private readonly webAuthDomain: string;
5784
private readonly transport: (input: string, init?: RequestInit) => Promise<Response>;
5885
private readonly timeoutMs: number;
5986

60-
constructor(webAuthEndpoint: string, options: Sep10ClientOptions = {}) {
87+
constructor(webAuthEndpoint: string, options: Sep10ClientOptions) {
88+
// The challenge we are about to sign arrives over this connection. Without
89+
// TLS an on-path attacker chooses the transaction, so refuse rather than
90+
// authenticate over cleartext.
91+
if (!/^https:\/\//i.test(webAuthEndpoint)) {
92+
throw new Sep10AuthError(`WEB_AUTH_ENDPOINT must be https, got "${webAuthEndpoint}"`);
93+
}
94+
if (!options?.serverAccountId) {
95+
throw new Sep10AuthError(
96+
"serverAccountId is required - it is the anchor's SIGNING_KEY and the only way to prove a challenge came from the anchor",
97+
);
98+
}
99+
if (!options.networkPassphrase) {
100+
throw new Sep10AuthError("networkPassphrase is required");
101+
}
102+
if (!options.homeDomain || (Array.isArray(options.homeDomain) && !options.homeDomain.length)) {
103+
throw new Sep10AuthError("homeDomain is required");
104+
}
105+
if (!options.webAuthDomain) {
106+
throw new Sep10AuthError("webAuthDomain is required");
107+
}
108+
61109
this.webAuthEndpoint = stripTrailingSlashes(webAuthEndpoint);
110+
this.serverAccountId = options.serverAccountId;
111+
this.networkPassphrase = options.networkPassphrase;
112+
this.homeDomain = options.homeDomain;
113+
this.webAuthDomain = options.webAuthDomain;
62114
this.transport = options.transport ?? fetch.bind(globalThis);
63115
this.timeoutMs = options.timeoutMs ?? 10_000;
64116
}
65117

118+
/**
119+
* Builds a client from a `stellar.toml` fetched with `discoverAnchor`, taking
120+
* `WEB_AUTH_ENDPOINT`, `SIGNING_KEY` and `NETWORK_PASSPHRASE` from it.
121+
*
122+
* Prefer this over the constructor: it is the path that cannot forget to pass
123+
* `SIGNING_KEY`, without which no challenge can be attributed to the anchor.
124+
*
125+
* @param toml - Parsed `stellar.toml` for `homeDomain`.
126+
* @param homeDomain - The domain the toml was fetched from; also the value
127+
* expected in the challenge's Manage Data key.
128+
* @throws {Sep10AuthError} if the toml omits `WEB_AUTH_ENDPOINT`,
129+
* `SIGNING_KEY`, or `NETWORK_PASSPHRASE`.
130+
*/
131+
static fromToml(
132+
toml: StellarToml,
133+
homeDomain: string,
134+
options: Partial<Omit<Sep10ClientOptions, "serverAccountId">> = {},
135+
): Sep10Client {
136+
if (!toml.WEB_AUTH_ENDPOINT) {
137+
throw new Sep10AuthError(`${homeDomain} stellar.toml has no WEB_AUTH_ENDPOINT`);
138+
}
139+
if (!toml.SIGNING_KEY) {
140+
throw new Sep10AuthError(
141+
`${homeDomain} stellar.toml has no SIGNING_KEY - challenges from this anchor cannot be verified, refusing to authenticate`,
142+
);
143+
}
144+
const networkPassphrase = options.networkPassphrase ?? toml.NETWORK_PASSPHRASE;
145+
if (!networkPassphrase) {
146+
throw new Sep10AuthError(
147+
`${homeDomain} stellar.toml has no NETWORK_PASSPHRASE - pass networkPassphrase explicitly`,
148+
);
149+
}
150+
151+
return new Sep10Client(toml.WEB_AUTH_ENDPOINT, {
152+
...options,
153+
serverAccountId: toml.SIGNING_KEY,
154+
networkPassphrase,
155+
homeDomain: options.homeDomain ?? homeDomain,
156+
webAuthDomain: options.webAuthDomain ?? new URL(toml.WEB_AUTH_ENDPOINT).host,
157+
});
158+
}
159+
160+
/**
161+
* Runs every SEP-10 check the client is responsible for before the challenge
162+
* is handed to a signer: the server's signature over the transaction, source
163+
* account, sequence number 0, the `<home_domain> auth` Manage Data operation
164+
* and its source, `web_auth_domain`, and time bounds.
165+
*
166+
* @throws {Sep10AuthError} if the challenge is not a well-formed challenge
167+
* from the configured anchor on the configured network.
168+
*/
169+
verifyChallenge(challenge: Sep10Challenge): void {
170+
// An anchor may echo the network it built the challenge for. If it does and
171+
// it disagrees with ours, stop here - otherwise readChallengeTx would be
172+
// checking the signature against the wrong network's transaction hash.
173+
if (
174+
challenge.network_passphrase !== undefined &&
175+
challenge.network_passphrase !== this.networkPassphrase
176+
) {
177+
throw new Sep10AuthError(
178+
`challenge is for network "${challenge.network_passphrase}", expected "${this.networkPassphrase}"`,
179+
);
180+
}
181+
182+
try {
183+
WebAuth.readChallengeTx(
184+
challenge.transaction,
185+
this.serverAccountId,
186+
this.networkPassphrase,
187+
this.homeDomain,
188+
this.webAuthDomain,
189+
);
190+
} catch (error) {
191+
const reason = error instanceof Error ? error.message : String(error);
192+
throw new Sep10AuthError(`challenge failed validation (${reason})`);
193+
}
194+
}
195+
66196
/** GET the challenge transaction the anchor wants signed. */
67197
async challenge(params: {
68198
account: string;
@@ -120,6 +250,12 @@ export class Sep10Client {
120250
...(params.clientDomain !== undefined ? { clientDomain: params.clientDomain } : {}),
121251
});
122252

253+
// Validate BEFORE signing. `params.sign` may reach a hardware wallet or a
254+
// KMS, and whatever we hand it can be signed and broadcast - a hostile or
255+
// compromised anchor returning a payment or a set_options adding a signer
256+
// must be rejected here, not noticed afterwards.
257+
this.verifyChallenge(challenge);
258+
123259
const signed = await params.sign(challenge);
124260
if (typeof signed !== "string" || signed === "") {
125261
throw new Sep10AuthError("signer returned an empty transaction");

packages/anchor-sdk/test/errorPaths.test.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
Sep1DiscoveryError,
44
Sep10AuthError,
55
Sep10Client,
6+
type Sep10ClientOptions,
67
Sep24Client,
78
Sep24Error,
89
discoverAnchor,
@@ -25,6 +26,22 @@ function failingTransport(message: string) {
2526
});
2627
}
2728

29+
/**
30+
* Builds a Sep10Client for the transport-level cases below. These exercise
31+
* `challenge()` / `token()` directly and never sign, so the verification
32+
* parameters just need to be present and well-formed - `test/sep10.test.ts`
33+
* covers what they actually do.
34+
*/
35+
function sep10ClientWith(options: Partial<Sep10ClientOptions> = {}): Sep10Client {
36+
return new Sep10Client("https://a.example.com/auth", {
37+
serverAccountId: "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ",
38+
networkPassphrase: "Test SDF Network ; September 2015",
39+
homeDomain: "a.example.com",
40+
webAuthDomain: "a.example.com",
41+
...options,
42+
});
43+
}
44+
2845
describe("SEP-1 failure paths", () => {
2946
it("rejects an empty home domain before making a request", async () => {
3047
const transport = vi.fn();
@@ -69,28 +86,28 @@ describe("SEP-1 failure paths", () => {
6986
describe("SEP-10 failure paths", () => {
7087
it("reports a non-2xx challenge response", async () => {
7188
const transport = vi.fn(async () => new Response("no", { status: 400 }));
72-
const client = new Sep10Client("https://a.example.com/auth", { transport });
89+
const client = sep10ClientWith({ transport });
7390

7491
await expect(client.challenge({ account: "GABC" })).rejects.toThrow(/returned 400/);
7592
});
7693

7794
it("rejects a challenge body with no transaction", async () => {
7895
const transport = vi.fn(async () => jsonResponse({ nope: true }));
79-
const client = new Sep10Client("https://a.example.com/auth", { transport });
96+
const client = sep10ClientWith({ transport });
8097

8198
await expect(client.challenge({ account: "GABC" })).rejects.toBeInstanceOf(Sep10AuthError);
8299
});
83100

84101
it("surfaces the anchor's error message when the token exchange is rejected", async () => {
85102
const transport = vi.fn(async () => jsonResponse({ error: "invalid signature" }, 401));
86-
const client = new Sep10Client("https://a.example.com/auth", { transport });
103+
const client = sep10ClientWith({ transport });
87104

88105
await expect(client.token("signed")).rejects.toThrow(/invalid signature/);
89106
});
90107

91108
it("rejects a token response with no token", async () => {
92109
const transport = vi.fn(async () => jsonResponse({ jwt: "wrong-field" }));
93-
const client = new Sep10Client("https://a.example.com/auth", { transport });
110+
const client = sep10ClientWith({ transport });
94111

95112
await expect(client.token("signed")).rejects.toThrow(/did not contain a token/);
96113
});
@@ -101,7 +118,7 @@ describe("SEP-10 failure paths", () => {
101118
calls.push(url);
102119
return jsonResponse({ transaction: "AAAA" });
103120
});
104-
const client = new Sep10Client("https://a.example.com/auth", { transport });
121+
const client = sep10ClientWith({ transport });
105122

106123
await client.challenge({ account: "GABC", memo: "12345", clientDomain: "app.example.com" });
107124

@@ -110,9 +127,7 @@ describe("SEP-10 failure paths", () => {
110127
});
111128

112129
it("wraps a transport failure", async () => {
113-
const client = new Sep10Client("https://a.example.com/auth", {
114-
transport: failingTransport("ECONNRESET"),
115-
});
130+
const client = sep10ClientWith({ transport: failingTransport("ECONNRESET") });
116131

117132
await expect(client.challenge({ account: "GABC" })).rejects.toThrow(/request to .* failed/);
118133
});
@@ -199,10 +214,7 @@ describe("request timeouts", () => {
199214
});
200215

201216
it("aborts a stalled SEP-10 challenge", async () => {
202-
const client = new Sep10Client("https://a.example.com/auth", {
203-
transport: stallingTransport(),
204-
timeoutMs: 5,
205-
});
217+
const client = sep10ClientWith({ transport: stallingTransport(), timeoutMs: 5 });
206218
await expect(client.challenge({ account: "GABC" })).rejects.toThrow(/request to .* failed/);
207219
});
208220

0 commit comments

Comments
 (0)