| … | … | @@ -0,0 +1,295 @@ |
| 1 | /** | |
| 2 | * FILE: lib/address-validator.cjs | |
| 3 | * STATUS: ACTIVE | |
| 4 | * RUNTIME: Bare | |
| 5 | * MODULE: CommonJS | |
| 6 | * | |
| 7 | * PURPOSE: | |
| 8 | * Address validation with cryptographic checksum verification | |
| 9 | * | |
| 10 | * USED BY: | |
| 11 | * - wallet-server.cjs (transaction and contact validation) | |
| 12 | * | |
| 13 | * KEY EXPORTS: | |
| 14 | * - validateAddress(address, chain, network) - Generic validator | |
| 15 | * - validateBitcoinAddress(address, network) - Bitcoin with Base58Check/Bech32 | |
| 16 | * - validateEthereumAddress(address) - Ethereum with EIP-55 checksum | |
| 17 | * | |
| 18 | * VALIDATION: | |
| 19 | * - Bitcoin: Base58Check (P2PKH, P2SH) and Bech32/Bech32m (SegWit) | |
| 20 | * - Ethereum: EIP-55 mixed-case checksum | |
| 21 | * - Returns: { valid: boolean, reason?: string, warning?: string } | |
| 22 | */ | |
| 23 | ||
| 24 | const crypto = require('bare-crypto') | |
| 25 | ||
| 26 | /** | |
| 27 | * Bitcoin Base58 alphabet (no 0, O, I, l to avoid confusion) | |
| 28 | */ | |
| 29 | const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' | |
| 30 | ||
| 31 | /** | |
| 32 | * Decode Base58 string to buffer | |
| 33 | * @param {string} str - Base58 encoded string | |
| 34 | * @returns {Buffer} Decoded bytes | |
| 35 | */ | |
| 36 | function base58Decode(str) { | |
| 37 | const bytes = [] | |
| 38 | for (let i = 0; i < str.length; i++) { | |
| 39 | const char = str[i] | |
| 40 | let value = BASE58_ALPHABET.indexOf(char) | |
| 41 | if (value === -1) { | |
| 42 | throw new Error('Invalid Base58 character') | |
| 43 | } | |
| 44 | ||
| 45 | for (let j = 0; j < bytes.length; j++) { | |
| 46 | value += bytes[j] * 58 | |
| 47 | bytes[j] = value & 0xff | |
| 48 | value >>= 8 | |
| 49 | } | |
| 50 | ||
| 51 | while (value > 0) { | |
| 52 | bytes.push(value & 0xff) | |
| 53 | value >>= 8 | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | // Add leading zeros | |
| 58 | for (let i = 0; i < str.length && str[i] === '1'; i++) { | |
| 59 | bytes.push(0) | |
| 60 | } | |
| 61 | ||
| 62 | return Buffer.from(bytes.reverse()) | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * Verify Base58Check checksum | |
| 67 | * @param {Buffer} payload - Decoded Base58 bytes | |
| 68 | * @returns {boolean} True if checksum valid | |
| 69 | */ | |
| 70 | function verifyBase58Check(payload) { | |
| 71 | if (payload.length < 5) return false | |
| 72 | ||
| 73 | const data = payload.slice(0, -4) | |
| 74 | const checksum = payload.slice(-4) | |
| 75 | ||
| 76 | // Double SHA-256 hash | |
| 77 | const hash1 = crypto.createHash('sha256').update(data).digest() | |
| 78 | const hash2 = crypto.createHash('sha256').update(hash1).digest() | |
| 79 | ||
| 80 | // Compare first 4 bytes | |
| 81 | return checksum.equals(hash2.slice(0, 4)) | |
| 82 | } | |
| 83 | ||
| 84 | /** | |
| 85 | * Bech32 character set | |
| 86 | */ | |
| 87 | const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' | |
| 88 | ||
| 89 | /** | |
| 90 | * Bech32 polymod for checksum | |
| 91 | */ | |
| 92 | function bech32Polymod(values) { | |
| 93 | const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] | |
| 94 | let chk = 1 | |
| 95 | ||
| 96 | for (const value of values) { | |
| 97 | const top = chk >> 25 | |
| 98 | chk = ((chk & 0x1ffffff) << 5) ^ value | |
| 99 | ||
| 100 | for (let i = 0; i < 5; i++) { | |
| 101 | if ((top >> i) & 1) { | |
| 102 | chk ^= GEN[i] | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | return chk | |
| 108 | } | |
| 109 | ||
| 110 | /** | |
| 111 | * Expand HRP (Human Readable Part) for Bech32 | |
| 112 | */ | |
| 113 | function bech32HrpExpand(hrp) { | |
| 114 | const result = [] | |
| 115 | for (let i = 0; i < hrp.length; i++) { | |
| 116 | result.push(hrp.charCodeAt(i) >> 5) | |
| 117 | } | |
| 118 | result.push(0) | |
| 119 | for (let i = 0; i < hrp.length; i++) { | |
| 120 | result.push(hrp.charCodeAt(i) & 31) | |
| 121 | } | |
| 122 | return result | |
| 123 | } | |
| 124 | ||
| 125 | /** | |
| 126 | * Verify Bech32 checksum (SegWit v0) | |
| 127 | */ | |
| 128 | function verifyBech32(hrp, data) { | |
| 129 | return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 1 | |
| 130 | } | |
| 131 | ||
| 132 | /** | |
| 133 | * Verify Bech32m checksum (SegWit v1+) | |
| 134 | */ | |
| 135 | function verifyBech32m(hrp, data) { | |
| 136 | return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 0x2bc830a3 | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * Decode Bech32 address | |
| 141 | */ | |
| 142 | function bech32Decode(address) { | |
| 143 | address = address.toLowerCase() | |
| 144 | ||
| 145 | const pos = address.lastIndexOf('1') | |
| 146 | if (pos < 1 || pos + 7 > address.length || address.length > 90) { | |
| 147 | throw new Error('Invalid Bech32 address') | |
| 148 | } | |
| 149 | ||
| 150 | const hrp = address.slice(0, pos) | |
| 151 | const data = [] | |
| 152 | ||
| 153 | for (let i = pos + 1; i < address.length; i++) { | |
| 154 | const d = BECH32_CHARSET.indexOf(address[i]) | |
| 155 | if (d === -1) { | |
| 156 | throw new Error('Invalid Bech32 character') | |
| 157 | } | |
| 158 | data.push(d) | |
| 159 | } | |
| 160 | ||
| 161 | return { hrp, data } | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Validate Bitcoin address with checksum | |
| 166 | * @param {string} address - Bitcoin address | |
| 167 | * @param {string} network - 'testnet' or 'mainnet' | |
| 168 | * @returns {Object} { valid: boolean, reason?: string } | |
| 169 | */ | |
| 170 | function validateBitcoinAddress(address, network = 'testnet') { | |
| 171 | // P2PKH (1... or m...) and P2SH (3... or 2...) | |
| 172 | if (address.match(/^[123mn][a-zA-HJ-NP-Z0-9]{25,34}$/)) { | |
| 173 | try { | |
| 174 | const decoded = base58Decode(address) | |
| 175 | ||
| 176 | if (!verifyBase58Check(decoded)) { | |
| 177 | return { valid: false, reason: 'Invalid Base58Check checksum' } | |
| 178 | } | |
| 179 | ||
| 180 | const version = decoded[0] | |
| 181 | ||
| 182 | // Check version byte matches network | |
| 183 | const validVersions = network === 'testnet' ? [0x6f, 0xc4] : [0x00, 0x05] | |
| 184 | if (!validVersions.includes(version)) { | |
| 185 | return { valid: false, reason: `Invalid network for address format (expected ${network})` } | |
| 186 | } | |
| 187 | ||
| 188 | return { valid: true } | |
| 189 | } catch (err) { | |
| 190 | return { valid: false, reason: `Base58 decode error: ${err.message}` } | |
| 191 | } | |
| 192 | } | |
| 193 | ||
| 194 | // Bech32 (bc1... or tb1...) | |
| 195 | if (address.match(/^(bc1|tb1)[a-z0-9]{39,87}$/)) { | |
| 196 | try { | |
| 197 | const prefix = network === 'testnet' ? 'tb' : 'bc' | |
| 198 | if (!address.startsWith(prefix)) { | |
| 199 | return { valid: false, reason: `Invalid network prefix (expected ${prefix}1...)` } | |
| 200 | } | |
| 201 | ||
| 202 | const { hrp, data } = bech32Decode(address) | |
| 203 | ||
| 204 | if (hrp !== prefix) { | |
| 205 | return { valid: false, reason: 'Network mismatch in HRP' } | |
| 206 | } | |
| 207 | ||
| 208 | // Try Bech32 (v0) first, then Bech32m (v1+) | |
| 209 | const validBech32 = verifyBech32(hrp, data) | |
| 210 | const validBech32m = verifyBech32m(hrp, data) | |
| 211 | ||
| 212 | if (!validBech32 && !validBech32m) { | |
| 213 | return { valid: false, reason: 'Invalid Bech32/Bech32m checksum' } | |
| 214 | } | |
| 215 | ||
| 216 | return { valid: true } | |
| 217 | } catch (err) { | |
| 218 | return { valid: false, reason: `Bech32 decode error: ${err.message}` } | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | return { valid: false, reason: 'Unrecognized Bitcoin address format' } | |
| 223 | } | |
| 224 | ||
| 225 | /** | |
| 226 | * Validate Ethereum address with EIP-55 checksum | |
| 227 | * @param {string} address - Ethereum address (0x...) | |
| 228 | * @returns {Object} { valid: boolean, reason?: string, warning?: string } | |
| 229 | */ | |
| 230 | function validateEthereumAddress(address) { | |
| 231 | // Must start with 0x and be 40 hex chars | |
| 232 | if (!address.match(/^0x[a-fA-F0-9]{40}$/)) { | |
| 233 | return { valid: false, reason: 'Invalid Ethereum address format (expected 0x + 40 hex chars)' } | |
| 234 | } | |
| 235 | ||
| 236 | const addressNoPrefix = address.slice(2) | |
| 237 | ||
| 238 | // All lowercase or all uppercase = no checksum (valid but warn) | |
| 239 | if (addressNoPrefix === addressNoPrefix.toLowerCase() || | |
| 240 | addressNoPrefix === addressNoPrefix.toUpperCase()) { | |
| 241 | return { valid: true, warning: 'No EIP-55 checksum (address not checksummed)' } | |
| 242 | } | |
| 243 | ||
| 244 | // Verify EIP-55 checksum | |
| 245 | // Keccak-256 hash of lowercase address | |
| 246 | const hash = crypto.createHash('keccak256') | |
| 247 | .update(Buffer.from(addressNoPrefix.toLowerCase(), 'utf8')) | |
| 248 | .digest('hex') | |
| 249 | ||
| 250 | for (let i = 0; i < 40; i++) { | |
| 251 | const char = addressNoPrefix[i] | |
| 252 | const hashChar = hash[i] | |
| 253 | ||
| 254 | // If hash char >= 8, address char should be uppercase | |
| 255 | if (parseInt(hashChar, 16) >= 8) { | |
| 256 | if (char !== char.toUpperCase()) { | |
| 257 | return { valid: false, reason: 'Invalid EIP-55 checksum (case mismatch)' } | |
| 258 | } | |
| 259 | } else { | |
| 260 | // If hash char < 8, address char should be lowercase | |
| 261 | if (char !== char.toLowerCase()) { | |
| 262 | return { valid: false, reason: 'Invalid EIP-55 checksum (case mismatch)' } | |
| 263 | } | |
| 264 | } | |
| 265 | } | |
| 266 | ||
| 267 | return { valid: true } | |
| 268 | } | |
| 269 | ||
| 270 | /** | |
| 271 | * Generic address validator (dispatches to chain-specific validator) | |
| 272 | * @param {string} address - Blockchain address | |
| 273 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 274 | * @param {string} network - 'testnet' or 'mainnet' | |
| 275 | * @returns {Object} { valid: boolean, reason?: string, warning?: string } | |
| 276 | */ | |
| 277 | function validateAddress(address, chain, network = 'testnet') { | |
| 278 | if (!address || typeof address !== 'string') { | |
| 279 | return { valid: false, reason: 'Address is required' } | |
| 280 | } | |
| 281 | ||
| 282 | if (chain === 'bitcoin') { | |
| 283 | return validateBitcoinAddress(address, network) | |
| 284 | } else if (chain === 'ethereum') { | |
| 285 | return validateEthereumAddress(address) | |
| 286 | } else { | |
| 287 | return { valid: false, reason: `Unsupported chain: ${chain}` } | |
| 288 | } | |
| 289 | } | |
| 290 | ||
| 291 | module.exports = { | |
| 292 | validateAddress, | |
| 293 | validateBitcoinAddress, | |
| 294 | validateEthereumAddress | |
| 295 | } |
| … | … | @@ -0,0 +1,163 @@ |
| 1 | /** | |
| 2 | * FILE: lib/amount-validator.cjs | |
| 3 | * STATUS: ACTIVE | |
| 4 | * RUNTIME: Bare | |
| 5 | * MODULE: CommonJS | |
| 6 | * | |
| 7 | * PURPOSE: | |
| 8 | * Amount validation with range checks and format validation | |
| 9 | * | |
| 10 | * USED BY: | |
| 11 | * - wallet-server.cjs (transaction validation) | |
| 12 | * | |
| 13 | * KEY EXPORTS: | |
| 14 | * - validateAmount(amountStr, chain) - Format and range validation | |
| 15 | * - validateSufficientBalance(amount, balance, fee, chain) - Balance check | |
| 16 | * - formatAmount(amount, chain) - Human-readable formatting | |
| 17 | * | |
| 18 | * VALIDATION: | |
| 19 | * - Checks for positive amounts | |
| 20 | * - Validates BigInt format (no decimals, scientific notation) | |
| 21 | * - Enforces maximum limits (21M BTC, 1B ETH) | |
| 22 | * - Balance sufficiency checks | |
| 23 | */ | |
| 24 | ||
| 25 | /** | |
| 26 | * Maximum supply limits (prevent overflow attacks) | |
| 27 | */ | |
| 28 | const MAX_BTC = 21_000_000n * 100_000_000n // 21 million BTC in satoshi | |
| 29 | const MAX_ETH = 1_000_000_000n * 1_000_000_000_000_000_000n // 1 billion ETH in wei | |
| 30 | ||
| 31 | /** | |
| 32 | * Validate amount string format and range | |
| 33 | * @param {string|number} amountStr - Amount in smallest unit (satoshi/wei) | |
| 34 | * @param {string} chain - 'bitcoin' or 'ethereum' | |
| 35 | * @returns {Object} { valid: boolean, amount?: bigint, reason?: string } | |
| 36 | */ | |
| 37 | function validateAmount(amountStr, chain) { | |
| 38 | // Check if amount is provided | |
| 39 | if (amountStr === undefined || amountStr === null || amountStr === '') { | |
| 40 | return { valid: false, reason: 'Amount is required' } | |
| 41 | } | |
| 42 | ||
| 43 | // Try to convert to BigInt | |
| 44 | let amount | |
| 45 | try { | |
| 46 | // Remove any spaces or underscores (common in large numbers) | |
| 47 | const cleaned = String(amountStr).replace(/[\s_]/g, '') | |
| 48 | ||
| 49 | // Check for scientific notation (not supported by BigInt) | |
| 50 | if (cleaned.includes('e') || cleaned.includes('E')) { | |
| 51 | return { valid: false, reason: 'Scientific notation not supported (use smallest unit: satoshi/wei)' } | |
| 52 | } | |
| 53 | ||
| 54 | // Check for decimal point (BigInt doesn't support decimals) | |
| 55 | if (cleaned.includes('.')) { | |
| 56 | return { valid: false, reason: 'Decimal values not supported (use smallest unit: satoshi/wei)' } | |
| 57 | } | |
| 58 | ||
| 59 | // Check for negative sign | |
| 60 | if (cleaned.includes('-')) { | |
| 61 | return { valid: false, reason: 'Amount cannot be negative' } | |
| 62 | } | |
| 63 | ||
| 64 | amount = BigInt(cleaned) | |
| 65 | } catch (err) { | |
| 66 | return { valid: false, reason: `Invalid amount format: ${err.message}` } | |
| 67 | } | |
| 68 | ||
| 69 | // Check if positive | |
| 70 | if (amount <= 0n) { | |
| 71 | return { valid: false, reason: 'Amount must be greater than zero' } | |
| 72 | } | |
| 73 | ||
| 74 | // Check maximum limits (prevent overflow) | |
| 75 | if (chain === 'bitcoin' && amount > MAX_BTC) { | |
| 76 | return { valid: false, reason: 'Amount exceeds maximum (21M BTC = 2,100,000,000,000,000 satoshi)' } | |
| 77 | } | |
| 78 | if (chain === 'ethereum' && amount > MAX_ETH) { | |
| 79 | return { valid: false, reason: 'Amount exceeds maximum (1B ETH)' } | |
| 80 | } | |
| 81 | ||
| 82 | return { valid: true, amount } | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * Validate sufficient balance for transaction | |
| 87 | * @param {bigint} amount - Amount to send | |
| 88 | * @param {bigint} balance - Available balance | |
| 89 | * @param {bigint} fee - Transaction fee | |
| 90 | * @param {string} chain - Blockchain identifier | |
| 91 | * @returns {Object} { valid: boolean, reason?: string } | |
| 92 | */ | |
| 93 | function validateSufficientBalance(amount, balance, fee, chain) { | |
| 94 | const total = amount + fee | |
| 95 | ||
| 96 | if (total > balance) { | |
| 97 | return { | |
| 98 | valid: false, | |
| 99 | reason: `Insufficient balance: need ${formatAmount(total, chain)}, have ${formatAmount(balance, chain)}` | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | return { valid: true } | |
| 104 | } | |
| 105 | ||
| 106 | /** | |
| 107 | * Format amount for display (human-readable) | |
| 108 | * @param {bigint|string} amount - Amount in smallest unit | |
| 109 | * @param {string} chain - Blockchain identifier | |
| 110 | * @returns {string} Formatted amount with unit | |
| 111 | */ | |
| 112 | function formatAmount(amount, chain) { | |
| 113 | const amountBigInt = typeof amount === 'string' ? BigInt(amount) : amount | |
| 114 | ||
| 115 | if (chain === 'bitcoin') { | |
| 116 | // 1 BTC = 100,000,000 satoshi (1e8) | |
| 117 | const btc = Number(amountBigInt) / 1e8 | |
| 118 | return `${btc.toFixed(8)} BTC (${amountBigInt} satoshi)` | |
| 119 | } else if (chain === 'ethereum') { | |
| 120 | // 1 ETH = 1,000,000,000,000,000,000 wei (1e18) | |
| 121 | const eth = Number(amountBigInt) / 1e18 | |
| 122 | return `${eth.toFixed(18)} ETH (${amountBigInt} wei)` | |
| 123 | } | |
| 124 | ||
| 125 | return String(amountBigInt) | |
| 126 | } | |
| 127 | ||
| 128 | /** | |
| 129 | * Parse human-readable amount to smallest unit | |
| 130 | * @param {string} amountStr - Amount in BTC/ETH | |
| 131 | * @param {string} chain - Blockchain identifier | |
| 132 | * @returns {Object} { valid: boolean, amount?: bigint, reason?: string } | |
| 133 | */ | |
| 134 | function parseAmount(amountStr, chain) { | |
| 135 | try { | |
| 136 | const amount = parseFloat(amountStr) | |
| 137 | ||
| 138 | if (isNaN(amount) || amount <= 0) { | |
| 139 | return { valid: false, reason: 'Invalid amount' } | |
| 140 | } | |
| 141 | ||
| 142 | if (chain === 'bitcoin') { | |
| 143 | // Convert BTC to satoshi | |
| 144 | const satoshi = BigInt(Math.floor(amount * 1e8)) | |
| 145 | return { valid: true, amount: satoshi } | |
| 146 | } else if (chain === 'ethereum') { | |
| 147 | // Convert ETH to wei | |
| 148 | const wei = BigInt(Math.floor(amount * 1e18)) | |
| 149 | return { valid: true, amount: wei } | |
| 150 | } | |
| 151 | ||
| 152 | return { valid: false, reason: `Unsupported chain: ${chain}` } | |
| 153 | } catch (err) { | |
| 154 | return { valid: false, reason: `Parse error: ${err.message}` } | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | module.exports = { | |
| 159 | validateAmount, | |
| 160 | validateSufficientBalance, | |
| 161 | formatAmount, | |
| 162 | parseAmount | |
| 163 | } |
| … | … | @@ -0,0 +1,230 @@ |
| 1 | /** | |
| 2 | * FILE: lib/input-sanitizer.cjs | |
| 3 | * STATUS: ACTIVE | |
| 4 | * RUNTIME: Bare | |
| 5 | * MODULE: CommonJS | |
| 6 | * | |
| 7 | * PURPOSE: | |
| 8 | * Input sanitization to prevent injection attacks and validate user inputs | |
| 9 | * | |
| 10 | * USED BY: | |
| 11 | * - wallet-server.cjs (all user input validation) | |
| 12 | * | |
| 13 | * KEY EXPORTS: | |
| 14 | * - sanitizeString(str, maxLength) - Generic string sanitization | |
| 15 | * - sanitizeContactName(name) - Contact name validation | |
| 16 | * - sanitizeDeviceName(name) - Device name validation | |
| 17 | * - sanitizeNotes(notes) - Notes/description validation | |
| 18 | * - validateChain(chain) - Chain identifier validation | |
| 19 | * - validateNetwork(network) - Network identifier validation | |
| 20 | * | |
| 21 | * PROTECTION: | |
| 22 | * - Removes null bytes (string termination attacks) | |
| 23 | * - Enforces length limits (DoS prevention) | |
| 24 | * - Validates character sets (injection prevention) | |
| 25 | * - Trims whitespace (normalization) | |
| 26 | */ | |
| 27 | ||
| 28 | /** | |
| 29 | * Generic string sanitization | |
| 30 | * @param {string} str - Input string | |
| 31 | * @param {number} maxLength - Maximum allowed length | |
| 32 | * @returns {string} Sanitized string | |
| 33 | * @throws {Error} If input is invalid | |
| 34 | */ | |
| 35 | function sanitizeString(str, maxLength = 1000) { | |
| 36 | if (typeof str !== 'string') { | |
| 37 | throw new Error('Input must be a string') | |
| 38 | } | |
| 39 | ||
| 40 | // Trim whitespace | |
| 41 | let sanitized = str.trim() | |
| 42 | ||
| 43 | // Enforce max length | |
| 44 | if (sanitized.length > maxLength) { | |
| 45 | throw new Error(`Input too long (max ${maxLength} characters)`) | |
| 46 | } | |
| 47 | ||
| 48 | // Remove null bytes (can cause string termination attacks) | |
| 49 | sanitized = sanitized.replace(/\0/g, '') | |
| 50 | ||
| 51 | return sanitized | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * Sanitize contact name | |
| 56 | * Allows: alphanumeric, spaces, and common punctuation | |
| 57 | * @param {string} name - Contact name | |
| 58 | * @returns {string} Sanitized name | |
| 59 | * @throws {Error} If name is invalid | |
| 60 | */ | |
| 61 | function sanitizeContactName(name) { | |
| 62 | const sanitized = sanitizeString(name, 100) | |
| 63 | ||
| 64 | if (sanitized.length === 0) { | |
| 65 | throw new Error('Name cannot be empty') | |
| 66 | } | |
| 67 | ||
| 68 | // Only allow safe characters (alphanumeric, spaces, common punctuation) | |
| 69 | if (!sanitized.match(/^[a-zA-Z0-9\s\-_.,'()]+$/)) { | |
| 70 | throw new Error('Name contains invalid characters (allowed: letters, numbers, spaces, -_.\'(),)') | |
| 71 | } | |
| 72 | ||
| 73 | return sanitized | |
| 74 | } | |
| 75 | ||
| 76 | /** | |
| 77 | * Sanitize device name | |
| 78 | * Allows: alphanumeric, spaces, hyphens, underscores | |
| 79 | * @param {string} name - Device name | |
| 80 | * @returns {string} Sanitized name | |
| 81 | * @throws {Error} If name is invalid | |
| 82 | */ | |
| 83 | function sanitizeDeviceName(name) { | |
| 84 | const sanitized = sanitizeString(name, 50) | |
| 85 | ||
| 86 | if (sanitized.length === 0) { | |
| 87 | throw new Error('Device name cannot be empty') | |
| 88 | } | |
| 89 | ||
| 90 | // Only allow alphanumeric, spaces, hyphens, underscores | |
| 91 | if (!sanitized.match(/^[a-zA-Z0-9\s\-_]+$/)) { | |
| 92 | throw new Error('Device name contains invalid characters (allowed: letters, numbers, spaces, -_)') | |
| 93 | } | |
| 94 | ||
| 95 | return sanitized | |
| 96 | } | |
| 97 | ||
| 98 | /** | |
| 99 | * Sanitize notes/description fields | |
| 100 | * Allows: alphanumeric, spaces, and extended punctuation | |
| 101 | * @param {string} notes - Notes text | |
| 102 | * @returns {string} Sanitized notes | |
| 103 | * @throws {Error} If notes are invalid | |
| 104 | */ | |
| 105 | function sanitizeNotes(notes) { | |
| 106 | // Notes can be empty | |
| 107 | if (!notes || notes.trim().length === 0) { | |
| 108 | return '' | |
| 109 | } | |
| 110 | ||
| 111 | const sanitized = sanitizeString(notes, 500) | |
| 112 | ||
| 113 | // Allow more characters for notes (including newlines) | |
| 114 | // Block only dangerous characters (null bytes already removed by sanitizeString) | |
| 115 | if (sanitized.match(/[<>]/)) { | |
| 116 | throw new Error('Notes contain invalid characters (< and > not allowed)') | |
| 117 | } | |
| 118 | ||
| 119 | return sanitized | |
| 120 | } | |
| 121 | ||
| 122 | /** | |
| 123 | * Validate chain identifier | |
| 124 | * @param {string} chain - Chain name | |
| 125 | * @returns {string} Validated chain | |
| 126 | * @throws {Error} If chain is invalid | |
| 127 | */ | |
| 128 | function validateChain(chain) { | |
| 129 | if (typeof chain !== 'string') { | |
| 130 | throw new Error('Chain must be a string') | |
| 131 | } | |
| 132 | ||
| 133 | const validChains = ['bitcoin', 'ethereum'] | |
| 134 | const lowerChain = chain.toLowerCase() | |
| 135 | ||
| 136 | if (!validChains.includes(lowerChain)) { | |
| 137 | throw new Error(`Invalid chain: ${chain} (supported: ${validChains.join(', ')})`) | |
| 138 | } | |
| 139 | ||
| 140 | return lowerChain | |
| 141 | } | |
| 142 | ||
| 143 | /** | |
| 144 | * Validate network identifier | |
| 145 | * @param {string} network - Network name | |
| 146 | * @returns {string} Validated network | |
| 147 | * @throws {Error} If network is invalid | |
| 148 | */ | |
| 149 | function validateNetwork(network) { | |
| 150 | if (typeof network !== 'string') { | |
| 151 | throw new Error('Network must be a string') | |
| 152 | } | |
| 153 | ||
| 154 | const validNetworks = ['testnet', 'mainnet', 'sepolia'] | |
| 155 | const lowerNetwork = network.toLowerCase() | |
| 156 | ||
| 157 | if (!validNetworks.includes(lowerNetwork)) { | |
| 158 | throw new Error(`Invalid network: ${network} (supported: ${validNetworks.join(', ')})`) | |
| 159 | } | |
| 160 | ||
| 161 | return lowerNetwork | |
| 162 | } | |
| 163 | ||
| 164 | /** | |
| 165 | * Sanitize hex string (for keys, hashes, etc.) | |
| 166 | * @param {string} hex - Hex string | |
| 167 | * @param {number} expectedLength - Expected length (0 = any length) | |
| 168 | * @returns {string} Sanitized hex string (lowercase) | |
| 169 | * @throws {Error} If hex is invalid | |
| 170 | */ | |
| 171 | function sanitizeHex(hex, expectedLength = 0) { | |
| 172 | if (typeof hex !== 'string') { | |
| 173 | throw new Error('Hex string must be a string') | |
| 174 | } | |
| 175 | ||
| 176 | // Remove 0x prefix if present | |
| 177 | let sanitized = hex.toLowerCase() | |
| 178 | if (sanitized.startsWith('0x')) { | |
| 179 | sanitized = sanitized.slice(2) | |
| 180 | } | |
| 181 | ||
| 182 | // Validate hex characters | |
| 183 | if (!sanitized.match(/^[0-9a-f]*$/)) { | |
| 184 | throw new Error('Invalid hex string (must contain only 0-9, a-f)') | |
| 185 | } | |
| 186 | ||
| 187 | // Check length if specified | |
| 188 | if (expectedLength > 0 && sanitized.length !== expectedLength) { | |
| 189 | throw new Error(`Invalid hex length (expected ${expectedLength}, got ${sanitized.length})`) | |
| 190 | } | |
| 191 | ||
| 192 | return sanitized | |
| 193 | } | |
| 194 | ||
| 195 | /** | |
| 196 | * Validate invite code format | |
| 197 | * @param {string} inviteCode - Base64 invite code | |
| 198 | * @returns {string} Validated invite code | |
| 199 | * @throws {Error} If invite code is invalid | |
| 200 | */ | |
| 201 | function validateInviteCode(inviteCode) { | |
| 202 | if (typeof inviteCode !== 'string') { | |
| 203 | throw new Error('Invite code must be a string') | |
| 204 | } | |
| 205 | ||
| 206 | const sanitized = inviteCode.trim() | |
| 207 | ||
| 208 | // Check for valid base64 characters | |
| 209 | if (!sanitized.match(/^[A-Za-z0-9+/]+=*$/)) { | |
| 210 | throw new Error('Invalid invite code format (must be base64)') | |
| 211 | } | |
| 212 | ||
| 213 | // Check reasonable length (should be ~200-300 chars for signed invite) | |
| 214 | if (sanitized.length < 50 || sanitized.length > 1000) { | |
| 215 | throw new Error('Invalid invite code length') | |
| 216 | } | |
| 217 | ||
| 218 | return sanitized | |
| 219 | } | |
| 220 | ||
| 221 | module.exports = { | |
| 222 | sanitizeString, | |
| 223 | sanitizeContactName, | |
| 224 | sanitizeDeviceName, | |
| 225 | sanitizeNotes, | |
| 226 | validateChain, | |
| 227 | validateNetwork, | |
| 228 | sanitizeHex, | |
| 229 | validateInviteCode | |
| 230 | } |
| … | … | @@ -74,6 +74,25 @@ const { |
| 74 | 74 | getRateLimitStatus |
| 75 | 75 | } = require('./lib/rate-limiter.cjs') |
| 76 | 76 | |
| 77 | const { | |
| 78 | validateAddress | |
| 79 | } = require('./lib/address-validator.cjs') | |
| 80 | ||
| 81 | const { | |
| 82 | validateAmount, | |
| 83 | validateSufficientBalance, | |
| 84 | formatAmount | |
| 85 | } = require('./lib/amount-validator.cjs') | |
| 86 | ||
| 87 | const { | |
| 88 | sanitizeContactName, | |
| 89 | sanitizeDeviceName, | |
| 90 | sanitizeNotes, | |
| 91 | validateChain, | |
| 92 | validateNetwork, | |
| 93 | validateInviteCode | |
| 94 | } = require('./lib/input-sanitizer.cjs') | |
| 95 | ||
| 77 | 96 | // Load configuration from file |
| 78 | 97 | const config = loadConfig() |
| 79 | 98 | |
| … | … | @@ -707,7 +726,7 @@ const routes = { |
| 707 | 726 | 'POST /api/device/join': withRateLimit(requireAuth(async (req, res) => { |
| 708 | 727 | try { |
| 709 | 728 | const body = await parseBody(req) |
| 710 | const { inviteCode, deviceName } = body | |
| 729 | let { inviteCode, deviceName } = body | |
| 711 | 730 | |
| 712 | 731 | if (!inviteCode) { |
| 713 | 732 | return sendError(res, 'Invite code required', 400) |
| … | … | @@ -717,7 +736,16 @@ const routes = { |
| 717 | 736 | return sendError(res, 'Device name required', 400) |
| 718 | 737 | } |
| 719 | 738 | |
| 739 | // Validate and sanitize inputs | |
| 740 | try { | |
| 741 | inviteCode = validateInviteCode(inviteCode) | |
| 742 | deviceName = sanitizeDeviceName(deviceName) | |
| 743 | } catch (err) { | |
| 744 | return sendError(res, err.message, 400) | |
| 745 | } | |
| 746 | ||
| 720 | 747 | // Join wallet (receives BOTH mnemonics from primary device) |
| 748 | // This will also verify invite signature and expiration | |
| 721 | 749 | const { device, identityMnemonic, walletMnemonic } = await joinWallet(inviteCode, deviceName) |
| 722 | 750 | |
| 723 | 751 | // Save both mnemonics |
| … | … | @@ -858,18 +886,36 @@ const routes = { |
| 858 | 886 | 'POST /api/contacts': requireAuth(async (req, res) => { |
| 859 | 887 | try { |
| 860 | 888 | const body = await parseBody(req) |
| 861 | const { address, chain, name, notes } = body | |
| 889 | let { address, chain, name, notes } = body | |
| 862 | 890 | |
| 863 | 891 | if (!address || !chain || !name) { |
| 864 | 892 | return sendError(res, 'Missing required fields: address, chain, name', 400) |
| 865 | 893 | } |
| 866 | 894 | |
| 867 | // Validate address format | |
| 868 | if (chain === 'bitcoin' && !address.match(/^(bc1|tb1|[13])[a-zA-HJ-NP-Z0-9]{25,62}$/)) { | |
| 869 | return sendError(res, 'Invalid Bitcoin address format', 400) | |
| 895 | // Validate and sanitize chain | |
| 896 | try { | |
| 897 | chain = validateChain(chain) | |
| 898 | } catch (err) { | |
| 899 | return sendError(res, err.message, 400) | |
| 900 | } | |
| 901 | ||
| 902 | // Validate address with checksum | |
| 903 | const config = getConfig() | |
| 904 | const network = config.network || 'testnet' | |
| 905 | const addressValidation = validateAddress(address, chain, network) | |
| 906 | if (!addressValidation.valid) { | |
| 907 | return sendError(res, `Invalid address: ${addressValidation.reason}`, 400) | |
| 870 | 908 | } |
| 871 | if (chain === 'ethereum' && !address.match(/^0x[a-fA-F0-9]{40}$/)) { | |
| 872 | return sendError(res, 'Invalid Ethereum address format', 400) | |
| 909 | if (addressValidation.warning) { | |
| 910 | console.warn(`⚠️ Address validation warning: ${addressValidation.warning}`) | |
| 911 | } | |
| 912 | ||
| 913 | // Sanitize name and notes | |
| 914 | try { | |
| 915 | name = sanitizeContactName(name) | |
| 916 | notes = notes ? sanitizeNotes(notes) : '' | |
| 917 | } catch (err) { | |
| 918 | return sendError(res, err.message, 400) | |
| 873 | 919 | } |
| 874 | 920 | |
| 875 | 921 | const identity = await loadIdentity() |
| … | … | @@ -898,12 +944,24 @@ const routes = { |
| 898 | 944 | 'PUT /api/contacts/': requireAuth(async (req, res, id) => { |
| 899 | 945 | try { |
| 900 | 946 | const body = await parseBody(req) |
| 901 | const { name, notes, metadata } = body | |
| 947 | let { name, notes, metadata } = body | |
| 902 | 948 | |
| 903 | 949 | if (!id) { |
| 904 | 950 | return sendError(res, 'Contact ID required', 400) |
| 905 | 951 | } |
| 906 | 952 | |
| 953 | // Sanitize name and notes if provided | |
| 954 | try { | |
| 955 | if (name !== undefined) { | |
| 956 | name = sanitizeContactName(name) | |
| 957 | } | |
| 958 | if (notes !== undefined) { | |
| 959 | notes = sanitizeNotes(notes) | |
| 960 | } | |
| 961 | } catch (err) { | |
| 962 | return sendError(res, err.message, 400) | |
| 963 | } | |
| 964 | ||
| 907 | 965 | const identity = await loadIdentity() |
| 908 | 966 | await initializeStorage(identity) |
| 909 | 967 | |
| … | … | @@ -1068,12 +1126,33 @@ const routes = { |
| 1068 | 1126 | 'POST /api/estimate-fee': withRateLimit(requireAuth(async (req, res) => { |
| 1069 | 1127 | try { |
| 1070 | 1128 | const body = await parseBody(req) |
| 1071 | const { chain, network, to, amount } = body | |
| 1129 | let { chain, network, to, amount } = body | |
| 1072 | 1130 | |
| 1073 | 1131 | if (!chain || !network || !to || !amount) { |
| 1074 | 1132 | return sendError(res, 'Missing required fields', 400) |
| 1075 | 1133 | } |
| 1076 | 1134 | |
| 1135 | // Validate and sanitize chain and network | |
| 1136 | try { | |
| 1137 | chain = validateChain(chain) | |
| 1138 | network = validateNetwork(network) | |
| 1139 | } catch (err) { | |
| 1140 | return sendError(res, err.message, 400) | |
| 1141 | } | |
| 1142 | ||
| 1143 | // Validate recipient address with checksum | |
| 1144 | const addressValidation = validateAddress(to, chain, network) | |
| 1145 | if (!addressValidation.valid) { | |
| 1146 | return sendError(res, `Invalid recipient address: ${addressValidation.reason}`, 400) | |
| 1147 | } | |
| 1148 | ||
| 1149 | // Validate amount format and range | |
| 1150 | const amountValidation = validateAmount(amount, chain) | |
| 1151 | if (!amountValidation.valid) { | |
| 1152 | return sendError(res, `Invalid amount: ${amountValidation.reason}`, 400) | |
| 1153 | } | |
| 1154 | const amountBigInt = amountValidation.amount | |
| 1155 | ||
| 1077 | 1156 | const walletMnemonic = loadWalletMnemonicCached() |
| 1078 | 1157 | if (!walletMnemonic) { |
| 1079 | 1158 | return sendError(res, 'Wallet not initialized', 400) |
| … | … | @@ -1085,12 +1164,16 @@ const routes = { |
| 1085 | 1164 | // Quote the transaction to get fee estimate |
| 1086 | 1165 | const quote = await account.quoteSendTransaction({ |
| 1087 | 1166 | to, |
| 1088 | value: BigInt(amount) | |
| 1167 | value: amountBigInt | |
| 1089 | 1168 | }) |
| 1090 | 1169 | |
| 1091 | 1170 | sendJSON(res, { |
| 1092 | 1171 | fee: quote.fee.toString(), |
| 1093 | total: (BigInt(amount) + quote.fee).toString() | |
| 1172 | total: (amountBigInt + quote.fee).toString(), | |
| 1173 | formatted: { | |
| 1174 | fee: formatAmount(quote.fee, chain), | |
| 1175 | total: formatAmount(amountBigInt + quote.fee, chain) | |
| 1176 | } | |
| 1094 | 1177 | }) |
| 1095 | 1178 | } catch (err) { |
| 1096 | 1179 | console.error('Fee estimation error:', err) |
| … | … | @@ -1102,12 +1185,36 @@ const routes = { |
| 1102 | 1185 | 'POST /api/send': withRateLimit(requireAuth(async (req, res) => { |
| 1103 | 1186 | try { |
| 1104 | 1187 | const body = await parseBody(req) |
| 1105 | const { chain, network, to, amount } = body | |
| 1188 | let { chain, network, to, amount } = body | |
| 1106 | 1189 | |
| 1107 | 1190 | if (!chain || !network || !to || !amount) { |
| 1108 | 1191 | return sendError(res, 'Missing required fields', 400) |
| 1109 | 1192 | } |
| 1110 | 1193 | |
| 1194 | // Validate and sanitize chain and network | |
| 1195 | try { | |
| 1196 | chain = validateChain(chain) | |
| 1197 | network = validateNetwork(network) | |
| 1198 | } catch (err) { | |
| 1199 | return sendError(res, err.message, 400) | |
| 1200 | } | |
| 1201 | ||
| 1202 | // Validate recipient address with checksum | |
| 1203 | const addressValidation = validateAddress(to, chain, network) | |
| 1204 | if (!addressValidation.valid) { | |
| 1205 | return sendError(res, `Invalid recipient address: ${addressValidation.reason}`, 400) | |
| 1206 | } | |
| 1207 | if (addressValidation.warning) { | |
| 1208 | console.warn(`⚠️ Address validation warning: ${addressValidation.warning}`) | |
| 1209 | } | |
| 1210 | ||
| 1211 | // Validate amount format and range | |
| 1212 | const amountValidation = validateAmount(amount, chain) | |
| 1213 | if (!amountValidation.valid) { | |
| 1214 | return sendError(res, `Invalid amount: ${amountValidation.reason}`, 400) | |
| 1215 | } | |
| 1216 | const amountBigInt = amountValidation.amount | |
| 1217 | ||
| 1111 | 1218 | const walletMnemonic = loadWalletMnemonicCached() |
| 1112 | 1219 | if (!walletMnemonic) { |
| 1113 | 1220 | return sendError(res, 'Wallet not initialized', 400) |
| … | … | @@ -1116,15 +1223,43 @@ const routes = { |
| 1116 | 1223 | const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' }) |
| 1117 | 1224 | const account = await wdk.getAccount(chain, 0) |
| 1118 | 1225 | |
| 1226 | // Check balance | |
| 1227 | const balance = await account.getBalance() | |
| 1228 | ||
| 1229 | // Get fee estimate | |
| 1230 | let fee | |
| 1231 | try { | |
| 1232 | const quote = await account.quoteSendTransaction({ | |
| 1233 | to, | |
| 1234 | value: amountBigInt | |
| 1235 | }) | |
| 1236 | fee = quote.fee | |
| 1237 | } catch (err) { | |
| 1238 | console.error('Fee estimation failed:', err) | |
| 1239 | return sendError(res, `Cannot estimate fee: ${err.message}`, 400) | |
| 1240 | } | |
| 1241 | ||
| 1242 | // Check sufficient balance (amount + fee) | |
| 1243 | const balanceCheck = validateSufficientBalance(amountBigInt, balance, fee, chain) | |
| 1244 | if (!balanceCheck.valid) { | |
| 1245 | return sendError(res, balanceCheck.reason, 400) | |
| 1246 | } | |
| 1247 | ||
| 1119 | 1248 | // Send the transaction |
| 1120 | 1249 | const result = await account.sendTransaction({ |
| 1121 | 1250 | to, |
| 1122 | value: BigInt(amount) | |
| 1251 | value: amountBigInt | |
| 1123 | 1252 | }) |
| 1124 | 1253 | |
| 1254 | console.log(`✅ Transaction sent: ${result.hash}`) | |
| 1255 | console.log(` Amount: ${formatAmount(amountBigInt, chain)}`) | |
| 1256 | console.log(` Fee: ${formatAmount(fee, chain)}`) | |
| 1257 | console.log(` To: ${to}`) | |
| 1258 | ||
| 1125 | 1259 | sendJSON(res, { |
| 1126 | 1260 | hash: result.hash, |
| 1127 | fee: result.fee.toString() | |
| 1261 | fee: result.fee.toString(), | |
| 1262 | amount: amountBigInt.toString() | |
| 1128 | 1263 | }) |
| 1129 | 1264 | } catch (err) { |
| 1130 | 1265 | console.error('Send transaction error:', err) |