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
161 changes: 157 additions & 4 deletions src/anchors/AnchorVerifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,95 @@
* confirm bidirectional verification:
*
* 1. Load the issuer account from Horizon to obtain its `home_domain`.
* 2. Fetch the TOML from that `home_domain`.
* 3. Assert that the TOML's CURRENCIES array contains an entry matching
* 2. Optionally verify the TLS certificate fingerprint for that domain
* against a caller-supplied pin (#780).
* 3. Fetch the TOML from that `home_domain`.
* 4. Assert that the TOML's CURRENCIES array contains an entry matching
* both `assetCode` and the issuer address.
*
* Returns a `VerificationResult` describing the outcome so callers can
* decide how to handle partial / failed states.
*/

import { Horizon } from "@stellar/stellar-sdk";
import { CertificatePinningError } from "../errors.js";
import { StellarTomlParser } from "./StellarTomlParser.js";
import type { TomlCurrency, StellarTomlParserOptions } from "./StellarTomlParser.js";

// ---------------------------------------------------------------------------
// Certificate fingerprint fetcher
// ---------------------------------------------------------------------------

/**
* Retrieves the SHA-256 fingerprint of the TLS certificate served by `domain`
* on port 443. Returns a colon-separated uppercase hex string in the
* standard `openssl` format (e.g. `"AA:BB:CC:..."`).
*
* Uses Node.js `node:https` and `node:crypto` — only available in Node.js
* environments. Browser environments should not configure
* `pinnedCertFingerprints` since TLS certificate inspection is unavailable
* in that context.
*
* @internal Exported for testing purposes; prefer using the
* `_fetchCertFingerprint` constructor option to inject a test double.
*/
export async function defaultFetchCertFingerprint(
domain: string,
timeoutMs = 10_000,
): Promise<string> {
// Dynamic imports keep the browser bundle clean.
const [httpsModule, cryptoModule] = await Promise.all([
import("node:https"),
import("node:crypto"),
]);
const https = httpsModule;
const { createHash } = cryptoModule;

return new Promise<string>((resolve, reject) => {
const req = https.request(
{
host: domain,
port: 443,
method: "HEAD",
path: "/",
// Allow self-signed / expired certs so we can read the raw DER bytes;
// the fingerprint comparison is the trust decision.
rejectUnauthorized: false,
},
(res: any) => {
const socket = res.socket as import("tls").TLSSocket;
const cert = socket.getPeerCertificate(false);

if (!cert || !cert.raw) {
reject(new Error(`No certificate received from domain "${domain}"`));
req.destroy();
return;
}

// SHA-256 in AA:BB:CC... format
const hash = createHash("sha256")
.update(cert.raw)
.digest("hex")
.toUpperCase()
.match(/.{1,2}/g)!
.join(":");

resolve(hash);
req.destroy();
},
);

req.setTimeout(timeoutMs, () => {
req.destroy(
new Error(`Certificate fetch timed out for domain "${domain}"`),
);
});

req.on("error", reject);
req.end();
});
}

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -48,6 +125,38 @@ export interface AnchorVerifierOptions extends StellarTomlParserOptions {
* @default "https://horizon.stellar.org"
*/
horizonUrl?: string;

/**
* Optional map of domain → expected SHA-256 TLS certificate fingerprint.
*
* When a domain appears in this map, `AnchorVerifier` will retrieve the
* server's TLS certificate before trusting any response from that domain
* and compare its SHA-256 fingerprint against the configured value.
*
* A mismatch throws a {@link CertificatePinningError} naming the domain,
* protecting against compromised DNS or rogue Certificate Authorities.
*
* Fingerprint format: colon-separated uppercase hex pairs, as produced by
* `openssl x509 -fingerprint -sha256`, e.g.:
* ```
* "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
* ```
*
* Certificate pinning uses Node.js `node:https`; browser environments
* skip the fingerprint check automatically when `_fetchCertFingerprint`
* is not injected and `node:https` is unavailable.
*/
pinnedCertFingerprints?: Record<string, string>;

/**
* Override the function used to fetch TLS certificate fingerprints.
*
* Intended for testing — inject a mock that returns a controlled
* fingerprint without making a real TLS connection.
*
* @internal
*/
_fetchCertFingerprint?: (domain: string, timeoutMs?: number) => Promise<string>;
}

// ---------------------------------------------------------------------------
Expand All @@ -58,10 +167,16 @@ export interface AnchorVerifierOptions extends StellarTomlParserOptions {
* Verifies that an asset issuer's `home_domain` TOML correctly lists the
* asset, confirming the anchor's on-chain ↔ off-chain consistency.
*
* When `pinnedCertFingerprints` is provided, the TLS certificate of each
* pinned domain is checked before its TOML is fetched or trusted (#780).
*
* @example
* ```ts
* const verifier = new AnchorVerifier({
* horizonUrl: "https://horizon.stellar.org",
* pinnedCertFingerprints: {
* "circle.io": "AA:BB:CC:...",
* },
* });
*
* const result = await verifier.verify("GA5ZSEJ...", "USDC");
Expand All @@ -73,22 +188,36 @@ export interface AnchorVerifierOptions extends StellarTomlParserOptions {
export class AnchorVerifier {
private readonly _server: Horizon.Server;
private readonly _parser: StellarTomlParser;
private readonly _pinnedCertFingerprints: Record<string, string>;
private readonly _fetchTimeoutMs: number;
private readonly _fetchCertFingerprintFn: (
domain: string,
timeoutMs?: number,
) => Promise<string>;

constructor(options: AnchorVerifierOptions = {}) {
this._server = new Horizon.Server(
options.horizonUrl ?? "https://horizon.stellar.org",
);
this._fetchTimeoutMs = options.fetchTimeoutMs ?? 10_000;
this._parser = new StellarTomlParser({
tomlCacheTtlMs: options.tomlCacheTtlMs,
fetchTimeoutMs: options.fetchTimeoutMs,
});
this._pinnedCertFingerprints = options.pinnedCertFingerprints ?? {};
this._fetchCertFingerprintFn =
options._fetchCertFingerprint ?? defaultFetchCertFingerprint;
}

/**
* Perform the full bidirectional anchor verification.
*
* @param assetIssuer - Stellar G… address of the asset issuer account.
* @param assetCode - Asset code to look up in the CURRENCIES array.
*
* @throws {CertificatePinningError} when a pinned domain serves a
* certificate whose SHA-256 fingerprint does not match the configured
* value.
*/
async verify(
assetIssuer: string,
Expand Down Expand Up @@ -121,7 +250,31 @@ export class AnchorVerifier {
}

// -------------------------------------------------------------------
// Step 2: Fetch TOML from home_domain
// Step 2 (optional): Certificate pinning check (#780)
//
// When the caller has configured a fingerprint for this domain, verify
// the server's TLS certificate before fetching or trusting any content.
// -------------------------------------------------------------------
const expectedFingerprint = this._pinnedCertFingerprints[homeDomain];
if (expectedFingerprint) {
const actualFingerprint = await this._fetchCertFingerprintFn(
homeDomain,
this._fetchTimeoutMs,
);

if (
actualFingerprint.toUpperCase() !== expectedFingerprint.toUpperCase()
) {
throw new CertificatePinningError(
homeDomain,
expectedFingerprint,
actualFingerprint,
);
}
}

// -------------------------------------------------------------------
// Step 3: Fetch TOML from home_domain
// -------------------------------------------------------------------
const tomlUrl = `https://${homeDomain}/.well-known/stellar.toml`;
let metadata: Awaited<ReturnType<StellarTomlParser["fetch"]>>;
Expand All @@ -136,7 +289,7 @@ export class AnchorVerifier {
}

// -------------------------------------------------------------------
// Step 3: Find a matching CURRENCIES entry
// Step 4: Find a matching CURRENCIES entry
// -------------------------------------------------------------------
const currencies = metadata.CURRENCIES ?? [];
const match = currencies.find(
Expand Down
25 changes: 25 additions & 0 deletions src/anchors/StellarTomlParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@

// `toml` is a CommonJS package — we import it as a namespace.
import * as toml from "toml";
import { UnsupportedTomlVersionError } from "../errors.js";

// ---------------------------------------------------------------------------
// Supported TOML schema versions (#779)
// ---------------------------------------------------------------------------

/**
* Stellar TOML schema versions that this parser accepts.
*
* Versions outside this list cause {@link StellarTomlParser.fetch} to throw
* an `UnsupportedTomlVersionError` before the parsed metadata is returned.
* This prevents silently producing incorrect data when a breaking schema
* revision is deployed by an anchor.
*/
export const SUPPORTED_TOML_VERSIONS: readonly number[] = [2.0, 2.1];

// ---------------------------------------------------------------------------
// SEP-1 typed structures
Expand Down Expand Up @@ -235,6 +250,16 @@ export class StellarTomlParser {
);
}

// VERSION check (#779): this is the first validation after successful
// parsing. When the TOML carries a VERSION field we verify it is one we
// support so we never silently process a breaking schema revision.
if (parsed["VERSION"] !== undefined) {
const version = Number(parsed["VERSION"]);
if (!SUPPORTED_TOML_VERSIONS.includes(version)) {
throw new UnsupportedTomlVersionError(String(parsed["VERSION"]));
}
}

return {
domain,
tomlUrl,
Expand Down
47 changes: 47 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2141,6 +2141,53 @@ export class StellarTomlFetchError extends StellarSplitError {
}
}

/**
* Thrown when a TLS certificate fingerprint for an anchor HTTPS endpoint does
* not match the configured pinned fingerprint (#780).
*
* The fingerprint should be a colon-separated uppercase hex string in the
* standard `openssl` format, e.g. `"AA:BB:CC:..."`.
*/
export class CertificatePinningError extends StellarSplitError {
readonly domain: string;
readonly expectedFingerprint: string;
readonly actualFingerprint: string;

constructor(domain: string, expectedFingerprint: string, actualFingerprint: string) {
super(
`Certificate fingerprint mismatch for domain "${domain}": ` +
`expected "${expectedFingerprint}", got "${actualFingerprint}"`,
"CERTIFICATE_PINNING_ERROR",
{ domain, expectedFingerprint, actualFingerprint },
);
this.name = "CertificatePinningError";
this.domain = domain;
this.expectedFingerprint = expectedFingerprint;
this.actualFingerprint = actualFingerprint;
Object.setPrototypeOf(this, new.target.prototype);
}
}

/**
* Thrown when a `stellar.toml` file carries a VERSION that is not listed in
* {@link SUPPORTED_TOML_VERSIONS} (#779).
*/
export class UnsupportedTomlVersionError extends StellarSplitError {
readonly encounteredVersion: string;

constructor(encounteredVersion: string) {
super(
`Unsupported stellar.toml VERSION "${encounteredVersion}". ` +
`Supported versions: ${JSON.stringify([2.0, 2.1])}`,
"UNSUPPORTED_TOML_VERSION",
{ encounteredVersion },
);
this.name = "UnsupportedTomlVersionError";
this.encounteredVersion = encounteredVersion;
Object.setPrototypeOf(this, new.target.prototype);
}
}

/** Thrown when all channel accounts in the pool are busy and the acquire timeout elapses. */
export class ChannelExhaustedError extends StellarSplitError {
readonly poolSize: number;
Expand Down
46 changes: 46 additions & 0 deletions src/fees/trend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,49 @@ export class FeeTrendAnalyzer {
this.buffer.evictOldestWhile((sample) => sample.capturedAt < cutoff);
}
}

// ---------------------------------------------------------------------------
// computeMovingAverage (#781)
// ---------------------------------------------------------------------------

/**
* Computes a Simple Moving Average (SMA) series over `samples`.
*
* The returned array has the same length as `samples`. The first
* `windowSize - 1` entries are padded with `NaN` because there are not yet
* enough data points to fill a complete window.
*
* The function is pure — it neither reads nor mutates any external state.
*
* @param samples - Input data series (e.g. fee samples in stroops).
* @param windowSize - Number of consecutive samples per average window.
* Must be an integer ≥ 1; throws `RangeError` otherwise.
* @returns - SMA series of the same length as `samples`.
*
* @example
* ```ts
* computeMovingAverage([100, 200, 300, 400, 500], 3);
* // => [NaN, NaN, 200, 300, 400]
* ```
*
* @throws {RangeError} when `windowSize` is less than 1.
*/
export function computeMovingAverage(
samples: number[],
windowSize: number,
): number[] {
if (!Number.isInteger(windowSize) || windowSize < 1) {
throw new RangeError(
`windowSize must be an integer ≥ 1, got ${windowSize}`,
);
}

return samples.map((_, i) => {
if (i < windowSize - 1) return NaN;
let sum = 0;
for (let j = i - windowSize + 1; j <= i; j++) {
sum += samples[j]!;
}
return sum / windowSize;
});
}
9 changes: 9 additions & 0 deletions src/wallets/adapters/XBullAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ export class XBullAdapter implements WalletAdapter {
private setupAccountChangeListener(): void {
if (!window.xbull) return;

// Drop the previous registration before installing a new one so that
// repeated connect() calls (e.g. reconnect after a dropped session or a
// component remount) never leave more than one live listener registered
// with the wallet. Mirrors the approach used by LobstrAdapter.
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}

this.unsubscribe = window.xbull.onAccountChange((publicKey: string) => {
this.currentPublicKey = publicKey;

Expand Down
Loading
Loading