diff --git a/src/client/LazyInitializer.ts b/src/client/LazyInitializer.ts index 36c42eb..e670e67 100644 --- a/src/client/LazyInitializer.ts +++ b/src/client/LazyInitializer.ts @@ -1,5 +1,5 @@ /** - * LazyInitializer — single-promise initialization gate. + * LazyInitializer — single-promise initialization gate with configurable retry. * * Stores a factory function and a nullable Promise state. The first call to * .get() invokes the factory and caches the resulting Promise. All subsequent @@ -12,10 +12,28 @@ * Issue #479 */ +import { pbkdf2 } from "node:crypto"; + // --------------------------------------------------------------------------- // LazyInitializer // --------------------------------------------------------------------------- +/** + * Options for {@link LazyInitializer}. + */ +export interface LazyInitializerOptions { + /** + * Maximum number of retry attempts after a failed initialization. + * @default 3 + */ + maxRetries?: number; + /** + * Delay between retry attempts in milliseconds. + * @default 1000 + */ + retryDelayMs?: number; +} + /** * Generic lazy initializer with single-flight coalescing and failure reset. * @@ -38,9 +56,13 @@ export class LazyInitializer { private readonly factory: () => Promise; private _promise: Promise | null = null; private _resolved = false; + private readonly maxRetries: number; + private readonly retryDelayMs: number; - constructor(factory: () => Promise) { + constructor(factory: () => Promise, options?: LazyInitializerOptions) { this.factory = factory; + this.maxRetries = options?.maxRetries ?? 3; + this.retryDelayMs = options?.retryDelayMs ?? 1_000; } /** @@ -51,25 +73,31 @@ export class LazyInitializer { * same Promise without calling the factory again. * - If not yet started, calls the factory and caches the resulting Promise. * - If the previous attempt failed, clears the cached Promise and retries. + * - Retries up to maxRetries times with retryDelayMs between attempts. */ get(): Promise { if (!this._promise) { - this._promise = this.factory().then( - (value) => { - this._resolved = true; - return value; - }, - (err: unknown) => { - // Reset so the next call retries initialization. - this._promise = null; - this._resolved = false; - throw err; - }, - ); + this._promise = this._attempt(0); } return this._promise; } + private async _attempt(attempt: number): Promise { + try { + const value = await this.factory(); + this._resolved = true; + return value; + } catch (err) { + if (attempt < this.maxRetries) { + await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs)); + return this._attempt(attempt + 1); + } + this._promise = null; + this._resolved = false; + throw err; + } + } + /** * Returns true synchronously when the factory has completed successfully. * Returns false when initialization is pending or hasn't started. diff --git a/src/client/SplitClient.ts b/src/client/SplitClient.ts index e6a36c8..61cb2f3 100644 --- a/src/client/SplitClient.ts +++ b/src/client/SplitClient.ts @@ -18,7 +18,7 @@ import { rpc as SorobanRpc } from "@stellar/stellar-sdk"; import { LazyInitializer } from "./LazyInitializer.js"; -import { RpcConnectionError } from "../errors.js"; +import { RpcConnectionError, RequestTimeoutError } from "../errors.js"; // --------------------------------------------------------------------------- // Public config @@ -31,6 +31,8 @@ export interface SplitClientConfig { networkPassphrase: string; /** Deployed StellarSplit contract ID. */ contractId: string; + /** Per-request timeout in milliseconds. Defaults to 30 000. */ + requestTimeoutMs?: number; } // --------------------------------------------------------------------------- @@ -79,7 +81,7 @@ export class SplitClient { */ async getLedger(): Promise { const server = await this.ensureConnected(); - const info = await server.getLatestLedger(); + const info = await this._withTimeout(server.getLatestLedger(), "getLedger"); return info.sequence; } @@ -91,7 +93,7 @@ export class SplitClient { tx: Parameters[0], ): Promise> { const server = await this.ensureConnected(); - return server.simulateTransaction(tx); + return this._withTimeout(server.simulateTransaction(tx), "simulateTransaction"); } /** @@ -102,7 +104,7 @@ export class SplitClient { tx: Parameters[0], ): Promise> { const server = await this.ensureConnected(); - return server.sendTransaction(tx); + return this._withTimeout(server.sendTransaction(tx), "sendTransaction"); } /** @@ -124,6 +126,24 @@ export class SplitClient { return this._lazy.get(); } + /** + * Wraps a promise with timeout enforcement. If the underlying SDK throws a + * timeout-related error, it is converted to RequestTimeoutError. + */ + private async _withTimeout(promise: Promise, method: string): Promise { + const timeoutMs = this.config.requestTimeoutMs ?? 30_000; + if (timeoutMs <= 0) return promise; + + try { + return await promise; + } catch (err: unknown) { + if (err instanceof Error && /timeout/i.test(err.message)) { + throw new RequestTimeoutError(method, timeoutMs); + } + throw err; + } + } + /** * Factory that creates and validates the SorobanRpc.Server connection. * Wraps construction errors in RpcConnectionError. @@ -133,6 +153,7 @@ export class SplitClient { try { const server = new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith("http://"), + timeout: this.config.requestTimeoutMs ?? 30_000, }); return server; } catch (err: unknown) { diff --git a/src/signing/adapters/EncryptedFileSigner.ts b/src/signing/adapters/EncryptedFileSigner.ts index 07307d7..29296b0 100644 --- a/src/signing/adapters/EncryptedFileSigner.ts +++ b/src/signing/adapters/EncryptedFileSigner.ts @@ -1,4 +1,4 @@ -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { createCipheriv, createDecipheriv, pbkdf2, randomBytes } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import { Keypair } from "@stellar/stellar-sdk"; import type { Signer } from "../signer.js"; @@ -69,6 +69,8 @@ export class EncryptedFileSigner implements Signer { private readonly aesKey: Buffer; /** Weak reference to the decrypted keypair — cleared by GC or clearCache(). */ private cachedKeypairRef: WeakRef | null = null; + /** Promise-based lock serialising key rotation and sign operations. */ + private _rotationLock: Promise = Promise.resolve(); constructor(filePath: string, options: EncryptedFileSignerOptions) { this.filePath = filePath; @@ -87,6 +89,39 @@ export class EncryptedFileSigner implements Signer { this.cachedKeypairRef = null; } + /** + * Rotates the encrypted signing key to a new file. + * + * The new file is loaded and validated before the in-memory state is + * replaced. A promise-based lock ensures that: + * - Signing operations already in flight complete with the old key. + * - Only one rotation is in progress at a time. + * - All new signing operations after rotation use the new key. + * + * @param newKeyFilePath Path to the new encrypted PEM key file. + * @param passphrase Passphrase used to derive the AES-256 decryption key. + */ + async rotateKey(newKeyFilePath: string, passphrase: string): Promise { + const previousLock = this._rotationLock; + + let resolveRotation!: () => void; + this._rotationLock = new Promise((resolve) => { + resolveRotation = resolve; + }); + + try { + await previousLock; + const newAesKey = await this._deriveKey(passphrase); + const newKeypair = await this._loadKeypairFromFile(newKeyFilePath, newAesKey); + + this.filePath = newKeyFilePath; + this.aesKey = newAesKey; + this.cachedKeypairRef = new WeakRef(newKeypair); + } finally { + resolveRotation(); + } + } + async sign(txHash: Buffer): Promise { const keypair = await this._getKeypair(); return Buffer.from(keypair.sign(txHash)); @@ -95,17 +130,22 @@ export class EncryptedFileSigner implements Signer { private async _getKeypair(): Promise { const cached = this.cachedKeypairRef?.deref(); if (cached) return cached; - // Re-read + decrypt on first use / after GC. The keypair is kept alive - // for the duration of this call even though only a WeakRef is stored. + await this._rotationLock; + const cachedAgain = this.cachedKeypairRef?.deref(); + if (cachedAgain) return cachedAgain; const keypair = await this._loadKeypair(); this.cachedKeypairRef = new WeakRef(keypair); return keypair; } private async _loadKeypair(): Promise { - const content = await readFile(this.filePath, "utf8"); + return this._loadKeypairFromFile(this.filePath, this.aesKey); + } + + private async _loadKeypairFromFile(filePath: string, aesKey: Buffer): Promise { + const content = await readFile(filePath, "utf8"); const { iv, authTag, ciphertext } = parseEncryptedPayload(content); - const decipher = createDecipheriv("aes-256-gcm", this.aesKey, iv); + const decipher = createDecipheriv("aes-256-gcm", aesKey, iv); decipher.setAuthTag(authTag); const plaintext = Buffer.concat([ decipher.update(ciphertext), @@ -114,6 +154,22 @@ export class EncryptedFileSigner implements Signer { const secret = extractSecretFromPem(plaintext.toString("utf8")); return Keypair.fromSecret(secret); } + + private async _deriveKey(passphrase: string): Promise { + return new Promise((resolve, reject) => { + pbkdf2( + passphrase, + "split-sdk-rotation-salt", + 100_000, + 32, + "sha256", + (err, derivedKey) => { + if (err) reject(err); + else resolve(Buffer.from(derivedKey)); + }, + ); + }); + } } function parseEncryptedPayload(content: string): {