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
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,9 @@ export type {
SignedShellTransaction,
SignatureTypeName,
} from "./types.js";
export {
validateAddress,
validateNonNegativeBigInt,
validateNonNegativeInteger,
validateRpcUrl,
} from "./validation.js";
42 changes: 25 additions & 17 deletions src/keystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment on lines +183 to +190
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);
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type {
ShellWitnessRootResult,
SignedShellTransaction,
} from "./types.js";
import { validateRpcUrl } from "./validation.js";

/**
* Pre-configured viem chain definition for Shell Devnet.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand All @@ -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);
}
24 changes: 24 additions & 0 deletions src/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
132 changes: 132 additions & 0 deletions src/validation.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
Comment on lines +18 to +22

/**
* 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}`);
}
}
Comment on lines +31 to +35

/**
* 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)
*
Comment on lines +55 to +59
* @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]";

Comment on lines +71 to +74
// 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;
}
Loading