@voxepay/checkout 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Pure JavaScript RSA Encryption for card data
3
+ * No Node.js crypto dependency — works directly in the browser
4
+ */
5
+
6
+ // ============ BigInteger Implementation (minimal, for RSA) ============
7
+
8
+ class BigInt {
9
+ private digits: number[];
10
+ private negative: boolean;
11
+
12
+ constructor(value?: string | number[], radix?: number) {
13
+ this.digits = [];
14
+ this.negative = false;
15
+
16
+ if (typeof value === 'string') {
17
+ this.fromString(value, radix || 10);
18
+ } else if (Array.isArray(value)) {
19
+ this.digits = value;
20
+ }
21
+ }
22
+
23
+ private fromString(s: string, radix: number): void {
24
+ if (s.length === 0) return;
25
+
26
+ let start = 0;
27
+ if (s[0] === '-') {
28
+ this.negative = true;
29
+ start = 1;
30
+ }
31
+
32
+ if (radix === 16) {
33
+ // Parse hex string
34
+ for (let i = s.length; i > start; i -= 7) {
35
+ const from = Math.max(start, i - 7);
36
+ this.digits.push(parseInt(s.substring(from, i), 16));
37
+ }
38
+ } else {
39
+ // Parse decimal
40
+ for (let i = s.length; i > start; i -= 7) {
41
+ const from = Math.max(start, i - 7);
42
+ this.digits.push(parseInt(s.substring(from, i), 10));
43
+ }
44
+ }
45
+ this.trim();
46
+ }
47
+
48
+ private trim(): void {
49
+ while (this.digits.length > 0 && this.digits[this.digits.length - 1] === 0) {
50
+ this.digits.pop();
51
+ }
52
+ }
53
+
54
+ isZero(): boolean {
55
+ return this.digits.length === 0;
56
+ }
57
+ }
58
+
59
+ // ============ Minimal forge-compatible RSA using raw math ============
60
+
61
+ /**
62
+ * Convert a string to its hex representation
63
+ */
64
+ const toHex = (str: string): string => {
65
+ let hex = '';
66
+ for (let i = 0; i < str.length; i++) {
67
+ hex += str.charCodeAt(i).toString(16).padStart(2, '0');
68
+ }
69
+ return hex;
70
+ };
71
+
72
+ /**
73
+ * Convert hex string to byte array
74
+ */
75
+ const hexToBytes = (hex: string): number[] => {
76
+ const bytes: number[] = [];
77
+ for (let i = 0; i < hex.length; i += 2) {
78
+ bytes.push(parseInt(hex.substring(i, i + 2), 16));
79
+ }
80
+ return bytes;
81
+ };
82
+
83
+ /**
84
+ * Convert byte array to base64 string
85
+ */
86
+ const bytesToBase64 = (bytes: number[]): string => {
87
+ if (typeof btoa !== 'undefined') {
88
+ // Browser
89
+ return btoa(String.fromCharCode(...bytes));
90
+ }
91
+ // Node.js fallback
92
+ return Buffer.from(bytes).toString('base64');
93
+ };
94
+
95
+ // ============ BigInteger arithmetic for RSA ============
96
+
97
+ /**
98
+ * Arbitrary precision integer using base-2^26 representation
99
+ */
100
+ class RSABigInt {
101
+ static BASE = 0x4000000; // 2^26
102
+ static BITS = 26;
103
+
104
+ data: number[];
105
+ sign: number;
106
+
107
+ constructor() {
108
+ this.data = [];
109
+ this.sign = 0;
110
+ }
111
+
112
+ static fromHex(hex: string): RSABigInt {
113
+ const n = new RSABigInt();
114
+ if (hex.length === 0) return n;
115
+
116
+ n.sign = 1;
117
+ // Process hex string from right to left, 26 bits at a time
118
+ // 26 bits ≈ 6.5 hex chars, so we'll use 6 hex chars (24 bits) per digit
119
+ // and handle the alignment
120
+ let bitPos = 0;
121
+ let current = 0;
122
+
123
+ for (let i = hex.length - 1; i >= 0; i--) {
124
+ const nibble = parseInt(hex[i], 16);
125
+ current |= nibble << bitPos;
126
+ bitPos += 4;
127
+ if (bitPos >= RSABigInt.BITS) {
128
+ n.data.push(current & (RSABigInt.BASE - 1));
129
+ current = current >>> RSABigInt.BITS;
130
+ bitPos -= RSABigInt.BITS;
131
+ }
132
+ }
133
+ if (current > 0 || bitPos > 0) {
134
+ n.data.push(current);
135
+ }
136
+
137
+ n.trim();
138
+ return n;
139
+ }
140
+
141
+ static fromBytes(bytes: number[]): RSABigInt {
142
+ const hex = bytes.map(b => b.toString(16).padStart(2, '0')).join('');
143
+ return RSABigInt.fromHex(hex);
144
+ }
145
+
146
+ trim(): void {
147
+ while (this.data.length > 0 && this.data[this.data.length - 1] === 0) {
148
+ this.data.pop();
149
+ }
150
+ if (this.data.length === 0) this.sign = 0;
151
+ }
152
+
153
+ isZero(): boolean {
154
+ return this.data.length === 0;
155
+ }
156
+
157
+ bitLength(): number {
158
+ if (this.data.length === 0) return 0;
159
+ const topWord = this.data[this.data.length - 1];
160
+ let bits = (this.data.length - 1) * RSABigInt.BITS;
161
+ let w = topWord;
162
+ while (w > 0) {
163
+ bits++;
164
+ w >>>= 1;
165
+ }
166
+ return bits;
167
+ }
168
+
169
+ toBytes(length?: number): number[] {
170
+ // Convert to hex then to bytes
171
+ let hex = '';
172
+ for (let i = this.data.length - 1; i >= 0; i--) {
173
+ const h = this.data[i].toString(16);
174
+ if (i === this.data.length - 1) {
175
+ hex += h;
176
+ } else {
177
+ // Pad to full digit width (26 bits ≈ 7 hex chars, but only 6 guaranteed)
178
+ const padLen = Math.ceil(RSABigInt.BITS / 4);
179
+ hex += h.padStart(padLen, '0');
180
+ }
181
+ }
182
+ // Ensure even length
183
+ if (hex.length % 2 !== 0) hex = '0' + hex;
184
+
185
+ const bytes = hexToBytes(hex);
186
+
187
+ if (length && bytes.length < length) {
188
+ // Pad with leading zeros
189
+ const padded = new Array(length - bytes.length).fill(0);
190
+ return padded.concat(bytes);
191
+ }
192
+ return bytes;
193
+ }
194
+
195
+ clone(): RSABigInt {
196
+ const r = new RSABigInt();
197
+ r.data = this.data.slice();
198
+ r.sign = this.sign;
199
+ return r;
200
+ }
201
+
202
+ /**
203
+ * Compare absolute values: returns -1, 0, or 1
204
+ */
205
+ compareAbs(other: RSABigInt): number {
206
+ if (this.data.length !== other.data.length) {
207
+ return this.data.length > other.data.length ? 1 : -1;
208
+ }
209
+ for (let i = this.data.length - 1; i >= 0; i--) {
210
+ if (this.data[i] !== other.data[i]) {
211
+ return this.data[i] > other.data[i] ? 1 : -1;
212
+ }
213
+ }
214
+ return 0;
215
+ }
216
+
217
+ /**
218
+ * Multiply two RSABigInts
219
+ */
220
+ multiply(other: RSABigInt): RSABigInt {
221
+ if (this.isZero() || other.isZero()) return new RSABigInt();
222
+
223
+ const result = new RSABigInt();
224
+ result.data = new Array(this.data.length + other.data.length).fill(0);
225
+ result.sign = 1;
226
+
227
+ for (let i = 0; i < this.data.length; i++) {
228
+ let carry = 0;
229
+ for (let j = 0; j < other.data.length; j++) {
230
+ const prod = this.data[i] * other.data[j] + result.data[i + j] + carry;
231
+ result.data[i + j] = prod & (RSABigInt.BASE - 1);
232
+ carry = Math.floor(prod / RSABigInt.BASE);
233
+ }
234
+ if (carry > 0) {
235
+ result.data[i + other.data.length] += carry;
236
+ }
237
+ }
238
+
239
+ result.trim();
240
+ return result;
241
+ }
242
+
243
+ /**
244
+ * Modular exponentiation: this^exp mod m
245
+ * Uses square-and-multiply method
246
+ */
247
+ modPow(exp: RSABigInt, mod: RSABigInt): RSABigInt {
248
+ if (mod.isZero()) throw new Error('Division by zero');
249
+
250
+ let result = RSABigInt.fromHex('1');
251
+ let base = this.mod(mod);
252
+ const expBits = exp.bitLength();
253
+
254
+ for (let i = 0; i < expBits; i++) {
255
+ const wordIdx = Math.floor(i / RSABigInt.BITS);
256
+ const bitIdx = i % RSABigInt.BITS;
257
+
258
+ if (wordIdx < exp.data.length && (exp.data[wordIdx] & (1 << bitIdx)) !== 0) {
259
+ result = result.multiply(base).mod(mod);
260
+ }
261
+ base = base.multiply(base).mod(mod);
262
+ }
263
+
264
+ return result;
265
+ }
266
+
267
+ /**
268
+ * this mod m (positive remainder)
269
+ */
270
+ mod(m: RSABigInt): RSABigInt {
271
+ const result = this.divmod(m);
272
+ return result.remainder;
273
+ }
274
+
275
+ /**
276
+ * Division with remainder
277
+ */
278
+ divmod(divisor: RSABigInt): { quotient: RSABigInt; remainder: RSABigInt } {
279
+ if (divisor.isZero()) throw new Error('Division by zero');
280
+ if (this.isZero()) return { quotient: new RSABigInt(), remainder: new RSABigInt() };
281
+
282
+ const cmp = this.compareAbs(divisor);
283
+ if (cmp < 0) return { quotient: new RSABigInt(), remainder: this.clone() };
284
+ if (cmp === 0) {
285
+ const one = RSABigInt.fromHex('1');
286
+ return { quotient: one, remainder: new RSABigInt() };
287
+ }
288
+
289
+ // Binary long division
290
+ const quotient = new RSABigInt();
291
+ quotient.data = new Array(this.data.length).fill(0);
292
+ quotient.sign = 1;
293
+
294
+ let remainder = new RSABigInt();
295
+
296
+ const totalBits = this.bitLength();
297
+
298
+ for (let i = totalBits - 1; i >= 0; i--) {
299
+ // Shift remainder left by 1 bit
300
+ remainder = remainder.shiftLeft(1);
301
+
302
+ // Set lowest bit of remainder to bit i of this
303
+ const wordIdx = Math.floor(i / RSABigInt.BITS);
304
+ const bitIdx = i % RSABigInt.BITS;
305
+ if (wordIdx < this.data.length && (this.data[wordIdx] & (1 << bitIdx)) !== 0) {
306
+ if (remainder.data.length === 0) remainder.data.push(0);
307
+ remainder.data[0] |= 1;
308
+ remainder.sign = 1;
309
+ }
310
+
311
+ // If remainder >= divisor, subtract
312
+ if (remainder.compareAbs(divisor) >= 0) {
313
+ remainder = remainder.subtract(divisor);
314
+ const qWordIdx = Math.floor(i / RSABigInt.BITS);
315
+ const qBitIdx = i % RSABigInt.BITS;
316
+ while (quotient.data.length <= qWordIdx) quotient.data.push(0);
317
+ quotient.data[qWordIdx] |= (1 << qBitIdx);
318
+ }
319
+ }
320
+
321
+ quotient.trim();
322
+ remainder.trim();
323
+ return { quotient, remainder };
324
+ }
325
+
326
+ /**
327
+ * Shift left by n bits
328
+ */
329
+ shiftLeft(n: number): RSABigInt {
330
+ if (this.isZero() || n === 0) return this.clone();
331
+
332
+ const wordShift = Math.floor(n / RSABigInt.BITS);
333
+ const bitShift = n % RSABigInt.BITS;
334
+
335
+ const result = new RSABigInt();
336
+ result.sign = this.sign;
337
+ result.data = new Array(this.data.length + wordShift + 1).fill(0);
338
+
339
+ for (let i = 0; i < this.data.length; i++) {
340
+ result.data[i + wordShift] |= (this.data[i] << bitShift) & (RSABigInt.BASE - 1);
341
+ if (bitShift > 0) {
342
+ result.data[i + wordShift + 1] |= this.data[i] >>> (RSABigInt.BITS - bitShift);
343
+ }
344
+ }
345
+
346
+ result.trim();
347
+ return result;
348
+ }
349
+
350
+ /**
351
+ * Subtract (absolute values, assumes this >= other)
352
+ */
353
+ subtract(other: RSABigInt): RSABigInt {
354
+ const result = new RSABigInt();
355
+ result.sign = 1;
356
+ result.data = new Array(Math.max(this.data.length, other.data.length)).fill(0);
357
+
358
+ let borrow = 0;
359
+ for (let i = 0; i < result.data.length; i++) {
360
+ let diff = (this.data[i] || 0) - (other.data[i] || 0) - borrow;
361
+ if (diff < 0) {
362
+ diff += RSABigInt.BASE;
363
+ borrow = 1;
364
+ } else {
365
+ borrow = 0;
366
+ }
367
+ result.data[i] = diff;
368
+ }
369
+
370
+ result.trim();
371
+ return result;
372
+ }
373
+ }
374
+
375
+ // ============ PKCS#1 v1.5 Padding ============
376
+
377
+ /**
378
+ * Apply PKCS#1 v1.5 type 2 padding for encryption
379
+ */
380
+ const pkcs1Pad = (data: number[], keyByteLen: number): number[] => {
381
+ if (data.length > keyByteLen - 11) {
382
+ throw new Error('Message too long for RSA encryption');
383
+ }
384
+
385
+ const padLen = keyByteLen - data.length - 3;
386
+ const padded = new Array(keyByteLen);
387
+ padded[0] = 0x00;
388
+ padded[1] = 0x02;
389
+
390
+ // Fill with random non-zero bytes
391
+ for (let i = 0; i < padLen; i++) {
392
+ // Use crypto.getRandomValues if available, otherwise Math.random
393
+ let r = 0;
394
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
395
+ const arr = new Uint8Array(1);
396
+ while (r === 0) {
397
+ crypto.getRandomValues(arr);
398
+ r = arr[0];
399
+ }
400
+ } else {
401
+ while (r === 0) {
402
+ r = Math.floor(Math.random() * 255) + 1;
403
+ }
404
+ }
405
+ padded[2 + i] = r;
406
+ }
407
+
408
+ padded[2 + padLen] = 0x00;
409
+ for (let i = 0; i < data.length; i++) {
410
+ padded[3 + padLen + i] = data[i];
411
+ }
412
+
413
+ return padded;
414
+ };
415
+
416
+ // ============ RSA Encryption Config ============
417
+
418
+ const RSA_CONFIG = {
419
+ modulus:
420
+ '009c7b3ba621a26c4b02f48cfc07ef6ee0aed8e12b4bd11c5cc0abf80d5206be69e1891e60fc88e2d565e2fabe4d0cf630e318a6c721c3ded718d0c530cdf050387ad0a30a336899bbda877d0ec7c7c3ffe693988bfae0ffbab71b25468c7814924f022cb5fda36e0d2c30a7161fa1c6fb5fbd7d05adbef7e68d48f8b6c5f511827c4b1c5ed15b6f20555affc4d0857ef7ab2b5c18ba22bea5d3a79bd1834badb5878d8c7a4b19da20c1f62340b1f7fbf01d2f2e97c9714a9df376ac0ea58072b2b77aeb7872b54a89667519de44d0fc73540beeaec4cb778a45eebfbefe2d817a8a8319b2bc6d9fa714f5289ec7c0dbc43496d71cf2a642cb679b0fc4072fd2cf',
421
+ publicExponent: '010001',
422
+ };
423
+
424
+ // ============ Public API ============
425
+
426
+ export interface AuthDataParams {
427
+ version: string;
428
+ pan: string;
429
+ pin: string;
430
+ expiryDate: string;
431
+ cvv: string;
432
+ }
433
+
434
+ /**
435
+ * Generates encrypted auth data for card payment
436
+ * Uses pure JavaScript RSA — no Node.js crypto dependency
437
+ *
438
+ * @param params - Card details for encryption
439
+ * @returns Base64 encoded encrypted auth data
440
+ */
441
+ export const generateAuthData = async ({
442
+ version,
443
+ pan,
444
+ pin,
445
+ expiryDate,
446
+ cvv,
447
+ }: AuthDataParams): Promise<string> => {
448
+ try {
449
+ // Build the cipher text
450
+ const authDataCipher = `${version}Z${pan}Z${pin}Z${expiryDate}Z${cvv}`;
451
+ const messageBytes = hexToBytes(toHex(authDataCipher));
452
+
453
+ // Parse RSA public key
454
+ const modulus = RSABigInt.fromHex(RSA_CONFIG.modulus);
455
+ const exponent = RSABigInt.fromHex(RSA_CONFIG.publicExponent);
456
+
457
+ // Determine key size in bytes
458
+ const keyByteLen = Math.ceil(modulus.bitLength() / 8);
459
+
460
+ // Apply PKCS#1 v1.5 padding
461
+ const padded = pkcs1Pad(messageBytes, keyByteLen);
462
+
463
+ // Convert padded message to BigInt
464
+ const m = RSABigInt.fromBytes(padded);
465
+
466
+ // RSA encrypt: c = m^e mod n
467
+ const c = m.modPow(exponent, modulus);
468
+
469
+ // Convert result to bytes (padded to key length)
470
+ const encryptedBytes = c.toBytes(keyByteLen);
471
+
472
+ // Base64 encode
473
+ return bytesToBase64(encryptedBytes);
474
+ } catch (error) {
475
+ console.error('[VoxePay] Encryption error:', error);
476
+ throw new Error('Failed to encrypt card data');
477
+ }
478
+ };
479
+
480
+ /**
481
+ * Converts MM/YY format to YYMM format required by API
482
+ */
483
+ export const formatExpiryForApi = (expiry: string): string => {
484
+ const cleaned = expiry.replace(/\s|\//g, '');
485
+ if (cleaned.length !== 4) return '';
486
+ const month = cleaned.substring(0, 2);
487
+ const year = cleaned.substring(2, 4);
488
+ return `${year}${month}`;
489
+ };
490
+
491
+ /**
492
+ * Removes spaces and formatting from PAN
493
+ */
494
+ export const cleanPan = (pan: string): string => {
495
+ return pan.replace(/\s/g, '');
496
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Input formatting utilities for VoxePay Checkout
3
+ */
4
+
5
+ /**
6
+ * Format card number with spaces every 4 digits
7
+ */
8
+ export function formatCardNumber(value: string): string {
9
+ const cleaned = value.replace(/\D/g, '');
10
+ const groups = cleaned.match(/.{1,4}/g) || [];
11
+ return groups.join(' ').slice(0, 23); // Max: 19 digits + 4 spaces
12
+ }
13
+
14
+ /**
15
+ * Format expiry date as MM/YY
16
+ */
17
+ export function formatExpiry(value: string): string {
18
+ const cleaned = value.replace(/\D/g, '');
19
+
20
+ if (cleaned.length === 0) return '';
21
+ if (cleaned.length === 1) {
22
+ return parseInt(cleaned) > 1 ? `0${cleaned}` : cleaned;
23
+ }
24
+ if (cleaned.length === 2) {
25
+ const month = parseInt(cleaned);
26
+ if (month > 12) return '12';
27
+ if (month === 0) return '01';
28
+ return cleaned;
29
+ }
30
+
31
+ const month = cleaned.slice(0, 2);
32
+ const year = cleaned.slice(2, 4);
33
+ return `${month}/${year}`;
34
+ }
35
+
36
+ /**
37
+ * Format CVV (numbers only, max 4 digits)
38
+ */
39
+ export function formatCVV(value: string): string {
40
+ return value.replace(/\D/g, '').slice(0, 4);
41
+ }
42
+
43
+ /**
44
+ * Format currency amount
45
+ */
46
+ export function formatAmount(amount: number, currency: string): string {
47
+ const formatter = new Intl.NumberFormat('en-US', {
48
+ style: 'currency',
49
+ currency: currency,
50
+ minimumFractionDigits: 2,
51
+ });
52
+
53
+ // Convert from smallest unit (cents/kobo) to main unit
54
+ return formatter.format(amount / 100);
55
+ }
56
+
57
+ /**
58
+ * Get currency symbol
59
+ */
60
+ export function getCurrencySymbol(currency: string): string {
61
+ const symbols: Record<string, string> = {
62
+ NGN: '₦',
63
+ USD: '$',
64
+ EUR: '€',
65
+ GBP: '£',
66
+ GHS: '₵',
67
+ KES: 'KSh',
68
+ ZAR: 'R',
69
+ };
70
+ return symbols[currency.toUpperCase()] || currency;
71
+ }