Skip to content
Closed
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
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
197 changes: 190 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,149 @@ 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 exp = parsedToken.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
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
1 change: 1 addition & 0 deletions @stellar/typescript-wallet-sdk/src/walletSdk/Types/sep7.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export enum Sep7OperationType {
}

export const URI_MSG_MAX_LENGTH = 300;
export const URI_REPLACE_MAX_LENGTH = 4096;

export type Sep7Replacement = {
id: string;
Expand Down
52 changes: 46 additions & 6 deletions @stellar/typescript-wallet-sdk/src/walletSdk/Uri/sep7Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
IsValidSep7UriResult,
WEB_STELLAR_SCHEME,
URI_MSG_MAX_LENGTH,
URI_REPLACE_MAX_LENGTH,
} from "../Types";
import {
Sep7InvalidUriError,
Expand Down Expand Up @@ -162,16 +163,55 @@ export const sep7ReplacementsFromString = (
return [];
}

if (replacements.length > URI_REPLACE_MAX_LENGTH) {
throw new Sep7InvalidUriError(
"the 'replace' parameter exceeds the maximum allowed length",
);
}

const [txrepString, hintsString] = replacements.split(HINT_DELIMITER);
const hintsList = hintsString.split(LIST_DELIMITER);

const hintsMap: { [id: string]: string } = {};
const txrepList = txrepString.split(LIST_DELIMITER);
const txrepIds: string[] = [];
txrepList.forEach((item) => {
const parts = item.split(ID_DELIMITER);
if (parts.length < 2 || !parts[0] || !parts[1]) {
throw new Sep7InvalidUriError(
"the 'replace' parameter has an entry missing a path or reference identifier",
);
}
const id = parts[1];
if (txrepIds.indexOf(id) === -1) {
txrepIds.push(id);
}
});

hintsList
.map((item) => item.split(ID_DELIMITER))
.forEach(([id, hint]) => (hintsMap[id] = hint));
const hintsMap = Object.create(null) as Record<string, string>;

const txrepList = txrepString.split(LIST_DELIMITER);
if (hintsString) {
const hintsList = hintsString.split(LIST_DELIMITER);
hintsList.forEach((item) => {
const parts = item.split(ID_DELIMITER);
if (parts.length < 2 || !parts[0] || !parts[1]) {
throw new Sep7InvalidUriError(
"the 'replace' parameter has a hint entry missing an identifier or hint value",
);
}
hintsMap[parts[0]] = parts[1];
});
}

const hintIds = Object.keys(hintsMap);

const isBalanced =
txrepIds.length === hintIds.length &&
txrepIds.every((id) => Object.prototype.hasOwnProperty.call(hintsMap, id));

if (!isBalanced) {
throw new Sep7InvalidUriError(
"the 'replace' parameter has unbalanced reference identifiers",
);
}

const replacementsList = txrepList
.map((item) => item.split(ID_DELIMITER))
Expand Down
Loading
Loading