OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-wallet commits / 3cd8d56

Phase 2.6: Add comprehensive input validation with cryptographic checksums
IMPLEMENTATION: 1. Created lib/address-validator.cjs: - Bitcoin address validation with Base58Check and Bech32/Bech32m checksums - Ethereum address validation with EIP-55 mixed-case checksum - Network-aware validation (testnet vs mainnet) - Detailed error messages for invalid addresses 2. Created lib/amount-validator.cjs: - Amount format validation (BigInt, no decimals, no scientific notation) - Range validation (positive amounts, maximum supply limits) - Balance sufficiency checks (amount + fee <= balance) - Human-readable formatting helpers 3. Created lib/input-sanitizer.cjs: - Contact name sanitization (prevents injection attacks) - Device name sanitization - Notes sanitization (allows newlines but blocks HTML) - Chain/network validation with whitelist - Hex string sanitization - Invite code format validation 4. Applied validation to critical endpoints: - POST /api/send: * Address checksum validation * Amount format and range validation * Balance sufficiency check with fee * Detailed logging of transactions - POST /api/estimate-fee: * Address checksum validation * Amount format validation * Returns formatted amounts - POST /api/contacts: * Address checksum validation * Name and notes sanitization * Chain validation - PUT /api/contacts/:id: * Name and notes sanitization - POST /api/device/join: * Invite code format validation * Device name sanitization SECURITY BENEFITS: - Prevents sending to invalid addresses (typo protection) - Detects corrupted/forged addresses via checksum verification - Blocks amount overflow attacks (enforces 21M BTC / 1B ETH limits) - Prevents negative or zero amounts - Prevents injection attacks via sanitized inputs - Protects against null byte attacks - Enforces reasonable length limits (DoS prevention) VALIDATION DETAILS: - Bitcoin: Base58Check (P2PKH/P2SH) + Bech32/Bech32m (SegWit v0/v1+) - Ethereum: EIP-55 mixed-case checksum (Keccak-256 hash) - Amounts: BigInt validation, no decimals, max supply checks - Names: Alphanumeric + safe punctuation only - Notes: Newlines allowed, HTML blocked TESTING: - Syntax validated - Ready for deployment and integration testing Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
3cd8d5697b0789fc6a242652b9141127cd78e1cd
by plankatron <fleeky@gmail.com> · 2026-01-27T19:45:36.000Z

Changed files (4) +837 -14

lib/address-validator.cjs +295 -0
……@@ -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
24const crypto = require('bare-crypto')
25
26/**
27 * Bitcoin Base58 alphabet (no 0, O, I, l to avoid confusion)
28 */
29const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
30
31/**
32 * Decode Base58 string to buffer
33 * @param {string} str - Base58 encoded string
34 * @returns {Buffer} Decoded bytes
35 */
36function 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 */
70function 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 */
87const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
88
89/**
90 * Bech32 polymod for checksum
91 */
92function 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 */
113function 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 */
128function verifyBech32(hrp, data) {
129 return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 1
130}
131
132/**
133 * Verify Bech32m checksum (SegWit v1+)
134 */
135function verifyBech32m(hrp, data) {
136 return bech32Polymod(bech32HrpExpand(hrp).concat(data)) === 0x2bc830a3
137}
138
139/**
140 * Decode Bech32 address
141 */
142function 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 */
170function 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 */
230function 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 */
277function 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
291module.exports = {
292 validateAddress,
293 validateBitcoinAddress,
294 validateEthereumAddress
295}
lib/amount-validator.cjs +163 -0
……@@ -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 */
28const MAX_BTC = 21_000_000n * 100_000_000n // 21 million BTC in satoshi
29const 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 */
37function 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 */
93function 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 */
112function 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 */
134function 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
158module.exports = {
159 validateAmount,
160 validateSufficientBalance,
161 formatAmount,
162 parseAmount
163}
lib/input-sanitizer.cjs +230 -0
……@@ -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 */
35function 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 */
61function 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 */
83function 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 */
105function 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 */
128function 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 */
149function 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 */
171function 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 */
201function 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
221module.exports = {
222 sanitizeString,
223 sanitizeContactName,
224 sanitizeDeviceName,
225 sanitizeNotes,
226 validateChain,
227 validateNetwork,
228 sanitizeHex,
229 validateInviteCode
230}
wallet-server.cjs +149 -14
……@@ -74,6 +74,25 @@ const {
7474 getRateLimitStatus
7575} = require('./lib/rate-limiter.cjs')
7676
77const {
78 validateAddress
79} = require('./lib/address-validator.cjs')
80
81const {
82 validateAmount,
83 validateSufficientBalance,
84 formatAmount
85} = require('./lib/amount-validator.cjs')
86
87const {
88 sanitizeContactName,
89 sanitizeDeviceName,
90 sanitizeNotes,
91 validateChain,
92 validateNetwork,
93 validateInviteCode
94} = require('./lib/input-sanitizer.cjs')
95
7796// Load configuration from file
7897const config = loadConfig()
7998
……@@ -707,7 +726,7 @@ const routes = {
707726 'POST /api/device/join': withRateLimit(requireAuth(async (req, res) => {
708727 try {
709728 const body = await parseBody(req)
710 const { inviteCode, deviceName } = body
729 let { inviteCode, deviceName } = body
711730
712731 if (!inviteCode) {
713732 return sendError(res, 'Invite code required', 400)
……@@ -717,7 +736,16 @@ const routes = {
717736 return sendError(res, 'Device name required', 400)
718737 }
719738
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
720747 // Join wallet (receives BOTH mnemonics from primary device)
748 // This will also verify invite signature and expiration
721749 const { device, identityMnemonic, walletMnemonic } = await joinWallet(inviteCode, deviceName)
722750
723751 // Save both mnemonics
……@@ -858,18 +886,36 @@ const routes = {
858886 'POST /api/contacts': requireAuth(async (req, res) => {
859887 try {
860888 const body = await parseBody(req)
861 const { address, chain, name, notes } = body
889 let { address, chain, name, notes } = body
862890
863891 if (!address || !chain || !name) {
864892 return sendError(res, 'Missing required fields: address, chain, name', 400)
865893 }
866894
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)
870908 }
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)
873919 }
874920
875921 const identity = await loadIdentity()
……@@ -898,12 +944,24 @@ const routes = {
898944 'PUT /api/contacts/': requireAuth(async (req, res, id) => {
899945 try {
900946 const body = await parseBody(req)
901 const { name, notes, metadata } = body
947 let { name, notes, metadata } = body
902948
903949 if (!id) {
904950 return sendError(res, 'Contact ID required', 400)
905951 }
906952
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
907965 const identity = await loadIdentity()
908966 await initializeStorage(identity)
909967
……@@ -1068,12 +1126,33 @@ const routes = {
10681126 'POST /api/estimate-fee': withRateLimit(requireAuth(async (req, res) => {
10691127 try {
10701128 const body = await parseBody(req)
1071 const { chain, network, to, amount } = body
1129 let { chain, network, to, amount } = body
10721130
10731131 if (!chain || !network || !to || !amount) {
10741132 return sendError(res, 'Missing required fields', 400)
10751133 }
10761134
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
10771156 const walletMnemonic = loadWalletMnemonicCached()
10781157 if (!walletMnemonic) {
10791158 return sendError(res, 'Wallet not initialized', 400)
……@@ -1085,12 +1164,16 @@ const routes = {
10851164 // Quote the transaction to get fee estimate
10861165 const quote = await account.quoteSendTransaction({
10871166 to,
1088 value: BigInt(amount)
1167 value: amountBigInt
10891168 })
10901169
10911170 sendJSON(res, {
10921171 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 }
10941177 })
10951178 } catch (err) {
10961179 console.error('Fee estimation error:', err)
……@@ -1102,12 +1185,36 @@ const routes = {
11021185 'POST /api/send': withRateLimit(requireAuth(async (req, res) => {
11031186 try {
11041187 const body = await parseBody(req)
1105 const { chain, network, to, amount } = body
1188 let { chain, network, to, amount } = body
11061189
11071190 if (!chain || !network || !to || !amount) {
11081191 return sendError(res, 'Missing required fields', 400)
11091192 }
11101193
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
11111218 const walletMnemonic = loadWalletMnemonicCached()
11121219 if (!walletMnemonic) {
11131220 return sendError(res, 'Wallet not initialized', 400)
……@@ -1116,15 +1223,43 @@ const routes = {
11161223 const { wdk } = await initializeWDK(walletMnemonic, { testnet: network === 'testnet' })
11171224 const account = await wdk.getAccount(chain, 0)
11181225
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
11191248 // Send the transaction
11201249 const result = await account.sendTransaction({
11211250 to,
1122 value: BigInt(amount)
1251 value: amountBigInt
11231252 })
11241253
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
11251259 sendJSON(res, {
11261260 hash: result.hash,
1127 fee: result.fee.toString()
1261 fee: result.fee.toString(),
1262 amount: amountBigInt.toString()
11281263 })
11291264 } catch (err) {
11301265 console.error('Send transaction error:', err)