From 4a2461e4b511a4aa3c7815519bd69bce1d377e27 Mon Sep 17 00:00:00 2001 From: LucienSong Date: Tue, 26 May 2026 23:41:49 +0800 Subject: [PATCH] security(sdk): add input validation and memory clearing - SDK-KEYSTORE-001: Clear password buffer from memory after decryption * Convert password string to Uint8Array and fill with zeros in finally block * Ensures sensitive password data is not retained in memory - SDK-INPUT-001: Add comprehensive input validation for transaction inputs * Validate addresses: must be valid Shell addresses (0x + 64 hex chars) or null * Validate amounts: must be non-negative bigints * Validate nonces: must be non-negative integers * Validate gas parameters: must be non-negative integers * Added validateAddress, validateNonNegativeBigInt, validateNonNegativeInteger functions - SDK-RPC-001: Add RPC URL validation * Validate protocol: https/wss for remote, http/ws allowed for localhost only * Reject private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8) * Validate in createShellPublicClient, createShellWsClient, createShellProvider * Added validateRpcUrl function All validations are exported in the public API via validation module. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/index.ts | 6 ++ src/keystore.ts | 42 ++++++++------ src/provider.ts | 8 +++ src/transactions.ts | 24 ++++++++ src/validation.ts | 132 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 195 insertions(+), 17 deletions(-) create mode 100644 src/validation.ts diff --git a/src/index.ts b/src/index.ts index 50e4b5f..0c1b6c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -137,3 +137,9 @@ export type { SignedShellTransaction, SignatureTypeName, } from "./types.js"; +export { + validateAddress, + validateNonNegativeBigInt, + validateNonNegativeInteger, + validateRpcUrl, +} from "./validation.js"; diff --git a/src/keystore.ts b/src/keystore.ts index 92769cb..c04768d 100644 --- a/src/keystore.ts +++ b/src/keystore.ts @@ -180,29 +180,37 @@ export async function decryptKeystore( const nonce = hexToBytes(ek.cipher_params.nonce); const ciphertext = hexToBytes(ek.ciphertext); - const derivedKeyHex = await argon2id({ - password, - salt, - iterations: ek.kdf_params.t_cost, - memorySize: ek.kdf_params.m_cost, - parallelism: ek.kdf_params.p_cost, - hashLength: 32, - outputType: "hex", - }); - const derivedKey = hexToBytes(derivedKeyHex); + // Convert password to Uint8Array for secure erasure + const passwordBytes = new TextEncoder().encode(password); try { - const chacha = xchacha20poly1305(derivedKey, nonce); - // Plaintext is sk-only; public key comes from the JSON `public_key` field. - const secretKey = chacha.decrypt(ciphertext); + const derivedKeyHex = await argon2id({ + password, + salt, + iterations: ek.kdf_params.t_cost, + memorySize: ek.kdf_params.m_cost, + parallelism: ek.kdf_params.p_cost, + hashLength: 32, + outputType: "hex", + }); + const derivedKey = hexToBytes(derivedKeyHex); + try { - const adapter = adapterFromKeyPair(parsed.signatureType, parsed.publicKey, secretKey); - return new ShellSigner(parsed.signatureType, adapter); + const chacha = xchacha20poly1305(derivedKey, nonce); + // Plaintext is sk-only; public key comes from the JSON `public_key` field. + const secretKey = chacha.decrypt(ciphertext); + try { + const adapter = adapterFromKeyPair(parsed.signatureType, parsed.publicKey, secretKey); + return new ShellSigner(parsed.signatureType, adapter); + } finally { + secretKey.fill(0); + } } finally { - secretKey.fill(0); + derivedKey.fill(0); } } finally { - derivedKey.fill(0); + // Clear the password from memory + passwordBytes.fill(0); } } diff --git a/src/provider.ts b/src/provider.ts index ec39bfc..f849e82 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -37,6 +37,7 @@ import type { ShellWitnessRootResult, SignedShellTransaction, } from "./types.js"; +import { validateRpcUrl } from "./validation.js"; /** * Pre-configured viem chain definition for Shell Devnet. @@ -335,6 +336,8 @@ export function createShellPublicClient( const chain = options.chain ?? shellDevnet; const rpcHttpUrl = options.rpcHttpUrl ?? chain.rpcUrls.default.http[0]; + validateRpcUrl(rpcHttpUrl); + return createPublicClient({ chain, transport: http(rpcHttpUrl), @@ -364,6 +367,8 @@ export function createShellWsClient(options: CreateShellPublicClientOptions = {} throw new Error("chain does not define a default WebSocket RPC URL"); } + validateRpcUrl(rpcWsUrl); + return createPublicClient({ chain, transport: webSocket(rpcWsUrl), @@ -390,5 +395,8 @@ export function createShellProvider(options: CreateShellPublicClientOptions = {} const client = createShellPublicClient(options); const chain = options.chain ?? shellDevnet; const rpcHttpUrl = options.rpcHttpUrl ?? chain.rpcUrls.default.http[0]; + + validateRpcUrl(rpcHttpUrl); + return new ShellProvider(client, rpcHttpUrl); } diff --git a/src/transactions.ts b/src/transactions.ts index 9a0826a..ce0f884 100644 --- a/src/transactions.ts +++ b/src/transactions.ts @@ -41,6 +41,7 @@ import { encodeSetValidationCodeCalldata, } from "./system-contracts.js"; import { shellAddressToBytes } from "./address.js"; +import { validateAddress, validateNonNegativeBigInt, validateNonNegativeInteger } from "./validation.js"; /** Default transaction type: `2` (Shell PQTx format; encodes EIP-1559 fee fields, which are scaffolded and not yet enforced on-chain). */ export const DEFAULT_TX_TYPE = 2; @@ -199,6 +200,29 @@ function toRlpAccessList( * @returns A `ShellTransactionRequest` ready for signing. */ export function buildTransaction(options: BuildTransactionOptions): ShellTransactionRequest { + // Validate inputs + validateNonNegativeInteger(options.chainId, "chainId"); + validateNonNegativeInteger(options.nonce, "nonce"); + validateAddress(options.to, "to"); + if (options.value !== undefined) { + validateNonNegativeBigInt(options.value, "value"); + } + if (options.gasLimit !== undefined) { + validateNonNegativeInteger(options.gasLimit, "gasLimit"); + } + if (options.maxFeePerGas !== undefined) { + validateNonNegativeInteger(options.maxFeePerGas, "maxFeePerGas"); + } + if (options.maxPriorityFeePerGas !== undefined) { + validateNonNegativeInteger(options.maxPriorityFeePerGas, "maxPriorityFeePerGas"); + } + if (options.txType !== undefined) { + validateNonNegativeInteger(options.txType, "txType"); + } + if (options.maxFeePerBlobGas !== undefined && options.maxFeePerBlobGas !== null) { + validateNonNegativeInteger(options.maxFeePerBlobGas, "maxFeePerBlobGas"); + } + return { chain_id: options.chainId, nonce: options.nonce, diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..ecb595b --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,132 @@ +/** + * Input validation utilities for Shell Chain SDK. + * + * Provides validators for transaction inputs (addresses, amounts, nonces) and RPC URLs. + * + * @module validation + */ + +import { isShellAddress } from "./address.js"; + +/** + * Validate that a value is a non-negative bigint. + * + * @param value - The value to validate. + * @param fieldName - Human-readable field name for error messages. + * @throws {Error} If the value is not a non-negative bigint. + */ +export function validateNonNegativeBigInt(value: bigint, fieldName: string): void { + if (typeof value !== "bigint" || value < 0n) { + throw new Error(`${fieldName} must be a non-negative bigint, got ${value}`); + } +} + +/** + * Validate that a value is a non-negative integer. + * + * @param value - The value to validate. + * @param fieldName - Human-readable field name for error messages. + * @throws {Error} If the value is not a non-negative integer. + */ +export function validateNonNegativeInteger(value: number, fieldName: string): void { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${fieldName} must be a non-negative integer, got ${value}`); + } +} + +/** + * Validate that an address is a valid Shell address (0x + 64 hex chars). + * + * Accepts null for contract deployment transactions. + * + * @param address - The address to validate (can be null). + * @param fieldName - Human-readable field name for error messages. + * @throws {Error} If the address is not null and not a valid Shell address. + */ +export function validateAddress(address: string | null, fieldName: string): void { + if (address !== null && !isShellAddress(address)) { + throw new Error(`${fieldName} must be null or a valid Shell address (0x + 64 hex chars), got ${address}`); + } +} + +/** + * Validate that an RPC URL is secure. + * + * Rules: + * - Must be https:// (or http:// for localhost only) + * - Cannot point to private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8) + * - WebSocket URLs must start with wss:// (or ws:// for localhost only) + * + * @param urlString - The RPC URL to validate. + * @throws {Error} If the URL fails validation. + */ +export function validateRpcUrl(urlString: string): void { + let url: URL; + try { + url = new URL(urlString); + } catch { + throw new Error(`Invalid RPC URL: ${urlString}`); + } + + const protocol = url.protocol; + const hostname = url.hostname; + const isLocal = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; + + // Check protocol: https/wss for remote, http/ws allowed for localhost + const isHttp = protocol === "http:"; + const isHttps = protocol === "https:"; + const isWs = protocol === "ws:"; + const isWss = protocol === "wss:"; + + if (!isHttp && !isHttps && !isWs && !isWss) { + throw new Error(`RPC URL must use http, https, ws, or wss protocol, got ${protocol}`); + } + + if ((isHttp || isWs) && !isLocal) { + throw new Error(`Insecure RPC URL: ${isHttp ? "http" : "ws"} only allowed for localhost`); + } + + // Check for private IP ranges + if (!isLocal && isPrivateIp(hostname)) { + throw new Error(`RPC URL cannot point to private IP range: ${hostname}`); + } +} + +/** + * Check if a hostname is in a private IP range. + * + * @param hostname - The hostname to check. + * @returns true if the hostname is a private IP address. + */ +function isPrivateIp(hostname: string): boolean { + // Try to resolve as IP address + const ipRegex = /^(?:\d{1,3}\.){3}\d{1,3}$/; + if (!ipRegex.test(hostname)) { + return false; // Not an IP address (could be a domain) + } + + const parts = hostname.split(".").map(Number); + if (parts.some(p => p < 0 || p > 255)) { + return false; // Invalid IP + } + + // 10.0.0.0/8 + if (parts[0] === 10) return true; + + // 172.16.0.0/12 + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; + + // 192.168.0.0/16 + if (parts[0] === 192 && parts[1] === 168) return true; + + // 127.0.0.0/8 (loopback, but already handled by localhost check) + if (parts[0] === 127) return true; + + // 0.0.0.0/8 + if (parts[0] === 0) return true; + + // 255.255.255.255 (broadcast) + if (parts[0] === 255 && parts[1] === 255 && parts[2] === 255 && parts[3] === 255) return true; + + return false; +}