diff --git a/src/client.ts b/src/client.ts index 262ffb2..8b44892 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2512,7 +2512,7 @@ export class StellarSplitClient extends TypedEventEmitter { * * @returns The new invoice ID and the transaction hash. * @example - * const result = await client.createInvoice({ /* params */ }); + * const result = await client.createInvoice({ ...params }); * @param params - The parameters for the method. * @throws {Error} If the method fails. */ @@ -2909,7 +2909,7 @@ export class StellarSplitClient extends TypedEventEmitter { * * @returns The transaction hash. * @example - * const result = await client.pay({ /* params */ }); + * const result = await client.pay({ ...params }); * @param params - The parameters for the method. * @throws {Error} If the method fails. */ @@ -3454,7 +3454,7 @@ export class StellarSplitClient extends TypedEventEmitter { * the invoice data is returned. Throws {@link TokenGateAccessDeniedError} when * the caller does not meet the balance requirement (and `strict !== false`). * @example - * const result = await client.getInvoice({ /* params */ }); + * const result = await client.getInvoice({ ...params }); * @param params - The parameters for the method. * @returns The result of the method. * @throws {Error} If the method fails. diff --git a/src/index.ts b/src/index.ts index 5295dac..f225cf1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1387,8 +1387,13 @@ export { encryptSigningKeyToPem, writeEncryptedSigningKeyFile, } from "./signing/adapters/EncryptedFileSigner.js"; -export { CloudKmsSigner } from "./signing/adapters/CloudKmsSigner.js"; -export type { KmsClient } from "./signing/adapters/CloudKmsSigner.js"; +export { CloudKmsSigner, isRegionError } from "./signing/adapters/CloudKmsSigner.js"; +export type { + KmsClient, + KmsClientSignOptions, + CloudKmsSignerOptions, + CloudKmsSignerEventMap, +} from "./signing/adapters/CloudKmsSigner.js"; // --------------------------------------------------------------------------- // #588 — Soroban Transaction Footprint Optimizer diff --git a/src/signing/adapters/CloudKmsSigner.ts b/src/signing/adapters/CloudKmsSigner.ts index 0583904..4dc2823 100644 --- a/src/signing/adapters/CloudKmsSigner.ts +++ b/src/signing/adapters/CloudKmsSigner.ts @@ -1,4 +1,15 @@ import type { Signer } from "../signer.js"; +import { TypedEventEmitter } from "../../events/TypedEventEmitter.js"; + +/** + * Options for signing with {@link KmsClient}. + */ +export interface KmsClientSignOptions { + /** + * Target cloud KMS region for this signing request. + */ + region?: string; +} /** * Minimal client contract for a cloud KMS (AWS KMS, GCP Cloud KMS, Azure Key @@ -11,28 +22,241 @@ export interface KmsClient { * Sign `digest` (typically a 32-byte transaction hash) with the key * identified by `keyId`, returning the raw signature bytes. */ - sign(keyId: string, digest: Buffer): Promise; + sign(keyId: string, digest: Buffer, options?: KmsClientSignOptions): Promise; +} + +/** + * Options for configuring {@link CloudKmsSigner}. + */ +export interface CloudKmsSignerOptions { + /** + * Primary Cloud KMS region. + */ + region?: string; + + /** + * Fallback Cloud KMS regions to attempt in order if a region-specific error + * (e.g., HTTP 503, timeout, connection failure) occurs in the primary region. + */ + fallbackRegions?: string[]; +} + +/** + * Events emitted by {@link CloudKmsSigner}. + */ +export interface CloudKmsSignerEventMap { + regionFallback: { from: string; to: string }; +} + +/** + * Returns true if an error is indicative of a region-specific or transient + * availability failure (e.g. 503 Service Unavailable, timeout, network error) + * rather than a permanent non-region error (e.g. 403 Forbidden, 401 Unauthorized, + * 400 Validation, 404 KeyNotFound). + */ +export function isRegionError(err: unknown): boolean { + if (!err) return false; + + if (typeof err === "object") { + const errorObj = err as Record; + + if (typeof errorObj.isRegionError === "boolean") { + return errorObj.isRegionError; + } + + const status = (errorObj.statusCode ?? errorObj.status ?? errorObj.code) as number | string | undefined; + + // Non-region HTTP status codes + if ( + status === 400 || + status === 401 || + status === 403 || + status === 404 || + status === 422 || + status === "400" || + status === "401" || + status === "403" || + status === "404" || + status === "422" + ) { + return false; + } + + // Region-specific HTTP status codes + if ( + status === 408 || + status === 429 || + status === 500 || + status === 502 || + status === 503 || + status === 504 || + status === "408" || + status === "429" || + status === "500" || + status === "502" || + status === "503" || + status === "504" + ) { + return true; + } + + // Known node network / timeout error codes + const code = typeof errorObj.code === "string" ? errorObj.code.toUpperCase() : ""; + if ( + code === "ETIMEDOUT" || + code === "ECONNRESET" || + code === "ECONNREFUSED" || + code === "EHOSTUNREACH" || + code === "ENOTFOUND" || + code === "EAI_AGAIN" || + code === "UND_ERR_CONNECT_TIMEOUT" || + code === "UND_ERR_SOCKET" + ) { + return true; + } + + const name = typeof errorObj.name === "string" ? errorObj.name : ""; + if (name === "TimeoutError" || name === "AbortError") { + return true; + } + } + + // Inspect error message string + const message = err instanceof Error ? err.message : String(err); + const lowerMsg = message.toLowerCase(); + + // Explicit non-region error keywords + const nonRegionKeywords = [ + "accessdenied", + "access denied", + "permissiondenied", + "permission denied", + "unauthorized", + "forbidden", + "invalidkey", + "invalid key", + "keynotfound", + "key not found", + "notfoundexception", + "validationexception", + "invalidparameter", + "invalid parameter", + "invalidargument", + "invalid argument", + "unrecognizedclientexception", + ]; + + for (const keyword of nonRegionKeywords) { + if (lowerMsg.includes(keyword)) { + return false; + } + } + + // Region-specific / transient error keywords + const regionKeywords = [ + "503", + "502", + "504", + "408", + "429", + "service unavailable", + "serviceunavailable", + "unavailable", + "timeout", + "timed out", + "gateway timeout", + "bad gateway", + "connection refused", + "connection reset", + "network error", + "econnreset", + "econnrefused", + "etimedout", + "enotfound", + "ehostunreach", + "region unavailable", + "endpoint unreachable", + "rate limit", + "throttled", + "throttling", + "kms unavailable", + "internal server error", + ]; + + for (const keyword of regionKeywords) { + if (lowerMsg.includes(keyword)) { + return true; + } + } + + return false; } /** * {@link Signer} that delegates signing to an injected {@link KmsClient}. * + * Supports multi-region fallback: when configured with fallback regions, + * transient or region-specific errors (503, timeouts, network disconnects) + * will cause the signer to sequentially attempt signing in each fallback region + * in order, emitting a `regionFallback` event on each switch. + * * @example * ```ts - * const signer = new CloudKmsSigner(awsKmsClient, "alias/split-signing-key"); + * const signer = new CloudKmsSigner(awsKmsClient, "alias/split-signing-key", { + * region: "us-east-1", + * fallbackRegions: ["us-west-2", "eu-central-1"], + * }); + * signer.on("regionFallback", ({ from, to }) => { + * console.warn(`Cloud KMS region failed (${from}), falling back to ${to}`); + * }); * const signature = await signer.sign(txHash); * ``` */ -export class CloudKmsSigner implements Signer { +export class CloudKmsSigner extends TypedEventEmitter implements Signer { readonly kmsClient: KmsClient; readonly keyId: string; + readonly region?: string; + readonly fallbackRegions: string[]; - constructor(kmsClient: KmsClient, keyId: string) { + constructor(kmsClient: KmsClient, keyId: string, options?: CloudKmsSignerOptions) { + super(); this.kmsClient = kmsClient; this.keyId = keyId; + this.region = options?.region; + this.fallbackRegions = options?.fallbackRegions ? [...options.fallbackRegions] : []; } async sign(txHash: Buffer): Promise { - return this.kmsClient.sign(this.keyId, txHash); + const regions: string[] = [ + ...(this.region ? [this.region] : []), + ...this.fallbackRegions, + ]; + + if (regions.length === 0) { + return this.kmsClient.sign(this.keyId, txHash); + } + + let lastError: unknown; + + for (let i = 0; i < regions.length; i++) { + const currentRegion = regions[i]; + try { + return await this.kmsClient.sign(this.keyId, txHash, { region: currentRegion }); + } catch (error) { + lastError = error; + + const hasNextRegion = i + 1 < regions.length; + if (hasNextRegion && isRegionError(error)) { + const nextRegion = regions[i + 1]; + this.emit("regionFallback", { from: currentRegion, to: nextRegion }); + continue; + } + + throw error; + } + } + + throw lastError; } } + diff --git a/test/sdkExports.test.ts b/test/sdkExports.test.ts index dcded5f..5dd1355 100644 --- a/test/sdkExports.test.ts +++ b/test/sdkExports.test.ts @@ -6,6 +6,7 @@ describe("public API surface (issues #586, #588, #589)", () => { expect(typeof sdk.KeypairSigner).toBe("function"); expect(typeof sdk.EncryptedFileSigner).toBe("function"); expect(typeof sdk.CloudKmsSigner).toBe("function"); + expect(typeof sdk.isRegionError).toBe("function"); expect(typeof sdk.encryptSigningKeyToPem).toBe("function"); expect(typeof sdk.writeEncryptedSigningKeyFile).toBe("function"); }); diff --git a/test/signing.test.ts b/test/signing.test.ts index 3c27ab1..39a7f5b 100644 --- a/test/signing.test.ts +++ b/test/signing.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { randomBytes } from "node:crypto"; import { Keypair } from "@stellar/stellar-sdk"; import { KeypairSigner } from "../src/signing/adapters/KeypairSigner.js"; -import { CloudKmsSigner } from "../src/signing/adapters/CloudKmsSigner.js"; +import { CloudKmsSigner, isRegionError } from "../src/signing/adapters/CloudKmsSigner.js"; import type { KmsClient } from "../src/signing/adapters/CloudKmsSigner.js"; import { EncryptedFileSigner, @@ -60,6 +60,225 @@ describe("CloudKmsSigner", () => { expect(signer.keyId).toBe("key-id"); expect(() => new CloudKmsSigner(fakeKms, "key-id")).not.toThrow(); }); + + it("stores configured region and fallbackRegions", () => { + const fakeKms = { sign: vi.fn(async () => Buffer.alloc(64, 1)) }; + const signer = new CloudKmsSigner(fakeKms, "key-id", { + region: "us-east-1", + fallbackRegions: ["us-west-2", "eu-central-1"], + }); + expect(signer.region).toBe("us-east-1"); + expect(signer.fallbackRegions).toEqual(["us-west-2", "eu-central-1"]); + }); + + it("signs successfully in primary region when region is configured", async () => { + const signature = randomBytes(64); + const kmsClient: KmsClient = { + sign: vi.fn(async (_keyId, _digest, options) => { + expect(options?.region).toBe("us-east-1"); + return signature; + }), + }; + const signer = new CloudKmsSigner(kmsClient, "alias/split-key", { + region: "us-east-1", + fallbackRegions: ["us-west-2"], + }); + + const result = await signer.sign(TX_HASH); + expect(result).toEqual(signature); + expect(kmsClient.sign).toHaveBeenCalledTimes(1); + expect(kmsClient.sign).toHaveBeenCalledWith("alias/split-key", TX_HASH, { region: "us-east-1" }); + }); + + it("retries in fallback region on HTTP 503 error and emits regionFallback event", async () => { + const signature = randomBytes(64); + const fallbackEvents: Array<{ from: string; to: string }> = []; + + const kmsClient: KmsClient = { + sign: vi.fn(async (_keyId, _digest, options) => { + if (options?.region === "us-east-1") { + const err = new Error("503 Service Unavailable"); + (err as unknown as { statusCode: number }).statusCode = 503; + throw err; + } + if (options?.region === "us-west-2") { + return signature; + } + throw new Error("unexpected region"); + }), + }; + + const signer = new CloudKmsSigner(kmsClient, "alias/split-key", { + region: "us-east-1", + fallbackRegions: ["us-west-2"], + }); + signer.on("regionFallback", (event) => fallbackEvents.push(event)); + + const result = await signer.sign(TX_HASH); + expect(result).toEqual(signature); + expect(kmsClient.sign).toHaveBeenCalledTimes(2); + expect(kmsClient.sign).toHaveBeenNthCalledWith(1, "alias/split-key", TX_HASH, { region: "us-east-1" }); + expect(kmsClient.sign).toHaveBeenNthCalledWith(2, "alias/split-key", TX_HASH, { region: "us-west-2" }); + expect(fallbackEvents).toEqual([{ from: "us-east-1", to: "us-west-2" }]); + }); + + it("retries in fallback region on timeout error and emits regionFallback event", async () => { + const signature = randomBytes(64); + const fallbackEvents: Array<{ from: string; to: string }> = []; + + const kmsClient: KmsClient = { + sign: vi.fn(async (_keyId, _digest, options) => { + if (options?.region === "us-east-1") { + const timeoutErr = new Error("Request timed out after 5000ms"); + timeoutErr.name = "TimeoutError"; + throw timeoutErr; + } + if (options?.region === "us-west-2") { + return signature; + } + throw new Error("unexpected region"); + }), + }; + + const signer = new CloudKmsSigner(kmsClient, "alias/split-key", { + region: "us-east-1", + fallbackRegions: ["us-west-2"], + }); + signer.on("regionFallback", (event) => fallbackEvents.push(event)); + + const result = await signer.sign(TX_HASH); + expect(result).toEqual(signature); + expect(fallbackEvents).toEqual([{ from: "us-east-1", to: "us-west-2" }]); + }); + + it("retries through multiple fallback regions in order and emits regionFallback event for each switch", async () => { + const signature = randomBytes(64); + const fallbackEvents: Array<{ from: string; to: string }> = []; + + const kmsClient: KmsClient = { + sign: vi.fn(async (_keyId, _digest, options) => { + if (options?.region === "us-east-1") { + const err = new Error("KMS unavailable in region us-east-1"); + (err as unknown as { statusCode: number }).statusCode = 503; + throw err; + } + if (options?.region === "us-west-2") { + const err = new Error("Connection reset by peer"); + (err as unknown as { code: string }).code = "ECONNRESET"; + throw err; + } + if (options?.region === "eu-central-1") { + return signature; + } + throw new Error("unexpected region"); + }), + }; + + const signer = new CloudKmsSigner(kmsClient, "alias/split-key", { + region: "us-east-1", + fallbackRegions: ["us-west-2", "eu-central-1"], + }); + signer.on("regionFallback", (event) => fallbackEvents.push(event)); + + const result = await signer.sign(TX_HASH); + expect(result).toEqual(signature); + expect(kmsClient.sign).toHaveBeenCalledTimes(3); + expect(fallbackEvents).toEqual([ + { from: "us-east-1", to: "us-west-2" }, + { from: "us-west-2", to: "eu-central-1" }, + ]); + }); + + it("throws last region error when all fallback regions fail", async () => { + const fallbackEvents: Array<{ from: string; to: string }> = []; + + const kmsClient: KmsClient = { + sign: vi.fn(async (_keyId, _digest, options) => { + const err = new Error(`503 unavailable in ${options?.region}`); + (err as unknown as { statusCode: number }).statusCode = 503; + throw err; + }), + }; + + const signer = new CloudKmsSigner(kmsClient, "alias/split-key", { + region: "us-east-1", + fallbackRegions: ["us-west-2"], + }); + signer.on("regionFallback", (event) => fallbackEvents.push(event)); + + await expect(signer.sign(TX_HASH)).rejects.toThrow("503 unavailable in us-west-2"); + expect(kmsClient.sign).toHaveBeenCalledTimes(2); + expect(fallbackEvents).toEqual([{ from: "us-east-1", to: "us-west-2" }]); + }); + + it("does NOT trigger fallback on non-region errors (e.g. PermissionDenied, 403, 401)", async () => { + const fallbackEvents: Array<{ from: string; to: string }> = []; + + const kmsClient: KmsClient = { + sign: vi.fn(async () => { + const err = new Error("AccessDenied: User is not authorized to perform: kms:Sign"); + (err as unknown as { statusCode: number }).statusCode = 403; + throw err; + }), + }; + + const signer = new CloudKmsSigner(kmsClient, "alias/split-key", { + region: "us-east-1", + fallbackRegions: ["us-west-2", "eu-central-1"], + }); + signer.on("regionFallback", (event) => fallbackEvents.push(event)); + + await expect(signer.sign(TX_HASH)).rejects.toThrow(/AccessDenied/); + expect(kmsClient.sign).toHaveBeenCalledTimes(1); + expect(fallbackEvents).toHaveLength(0); + }); + + it("does NOT trigger fallback on validation or key not found errors", async () => { + const kmsClient: KmsClient = { + sign: vi.fn(async () => { + throw new Error("ValidationException: 1 validation error detected"); + }), + }; + + const signer = new CloudKmsSigner(kmsClient, "alias/split-key", { + region: "us-east-1", + fallbackRegions: ["us-west-2"], + }); + + await expect(signer.sign(TX_HASH)).rejects.toThrow(/ValidationException/); + expect(kmsClient.sign).toHaveBeenCalledTimes(1); + }); + + it("isRegionError correctly classifies region vs non-region errors", () => { + expect(isRegionError(null)).toBe(false); + expect(isRegionError(undefined)).toBe(false); + + // Region errors + expect(isRegionError({ statusCode: 503 })).toBe(true); + expect(isRegionError({ status: 502 })).toBe(true); + expect(isRegionError({ statusCode: 504 })).toBe(true); + expect(isRegionError({ statusCode: 408 })).toBe(true); + expect(isRegionError({ statusCode: 429 })).toBe(true); + expect(isRegionError({ code: "ETIMEDOUT" })).toBe(true); + expect(isRegionError({ code: "ECONNRESET" })).toBe(true); + expect(isRegionError({ code: "ECONNREFUSED" })).toBe(true); + expect(isRegionError({ name: "TimeoutError" })).toBe(true); + expect(isRegionError(new Error("503 Service Unavailable"))).toBe(true); + expect(isRegionError(new Error("Gateway Timeout"))).toBe(true); + expect(isRegionError(new Error("Request timed out"))).toBe(true); + expect(isRegionError(new Error("KMS region unavailable"))).toBe(true); + + // Non-region errors + expect(isRegionError({ statusCode: 403 })).toBe(false); + expect(isRegionError({ statusCode: 401 })).toBe(false); + expect(isRegionError({ statusCode: 400 })).toBe(false); + expect(isRegionError({ statusCode: 404 })).toBe(false); + expect(isRegionError(new Error("AccessDenied: User not authorized"))).toBe(false); + expect(isRegionError(new Error("PermissionDenied"))).toBe(false); + expect(isRegionError(new Error("Unauthorized"))).toBe(false); + expect(isRegionError(new Error("KeyNotFound: Key does not exist"))).toBe(false); + expect(isRegionError(new Error("ValidationException: Invalid parameter"))).toBe(false); + }); }); describe("EncryptedFileSigner", () => {