elpaypg 0.0.0-stage → 2.0.0
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.
- package/.env.example +8 -0
- package/README.md +176 -2
- package/package.json +21 -4
- package/public/elpay-checkout.js +769 -0
- package/public/elpay-sms-gateway.apk +0 -0
- package/public/elpay-sms-gateway.apk.sha256 +1 -0
- package/public/elpay.js +785 -0
- package/public/fonts/dm-sans.woff2 +0 -0
- package/public/fonts/sora.woff2 +0 -0
- package/public/views/checkout.html +2839 -0
- package/public/views/partials/auth.html +110 -0
- package/public/views/partials/footer.html +54 -0
- package/public/views/partials/header.html +101 -0
- package/public/views/partials/modals.html +179 -0
- package/public/views/partials/payment-methods.html +526 -0
- package/public/views/partials/sidebar.html +75 -0
- package/public/views/partials/splash.html +35 -0
- package/src/__tests__/smoke.test.js +8 -0
- package/src/auth-guard.js +95 -0
- package/src/index.js +137 -0
- package/src/router.js +46 -0
- package/src/routes/admin.routes.js +2421 -0
- package/src/routes/checkout.routes.js +2000 -0
- package/src/routes/customer.routes.js +650 -0
- package/src/routes/device.routes.js +303 -0
- package/src/routes/payments.routes.js +446 -0
- package/src/routes/pollers.routes.js +653 -0
- package/src/services/observer-poller.service.js +369 -0
- package/src/services/tunnel-manager.js +130 -0
- package/src/services/whatsapp-berry.service.js +743 -0
- package/src/vendor/common/__tests__/common.test.js +297 -0
- package/src/vendor/common/__tests__/common.test.ts +160 -0
- package/src/vendor/common/contracts.js +64 -0
- package/src/vendor/common/contracts.ts +188 -0
- package/src/vendor/common/crypto.js +132 -0
- package/src/vendor/common/crypto.ts +112 -0
- package/src/vendor/common/enums.js +91 -0
- package/src/vendor/common/enums.ts +91 -0
- package/src/vendor/common/index.js +12 -0
- package/src/vendor/common/index.ts +8 -0
- package/src/vendor/common/money.js +62 -0
- package/src/vendor/common/money.ts +56 -0
- package/src/vendor/common/observation.js +27 -0
- package/src/vendor/common/observation.ts +52 -0
- package/src/vendor/common/runtime-config.js +246 -0
- package/src/vendor/common/secrets.js +374 -0
- package/src/vendor/common/state-machine.d.ts +1 -0
- package/src/vendor/common/state-machine.js +1 -0
- package/src/vendor/common/state-transitions.js +85 -0
- package/src/vendor/common/tunnel-command.js +59 -0
- package/src/vendor/common/upi-providers.js +389 -0
- package/src/vendor/common/upi-providers.ts +400 -0
- package/src/vendor/common/upi.js +69 -0
- package/src/vendor/common/upi.ts +61 -0
- package/src/vendor/core/__tests__/core.test.js +683 -0
- package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
- package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
- package/src/vendor/core/attribution-engine.js +855 -0
- package/src/vendor/core/bank-email-parser.js +105 -0
- package/src/vendor/core/bank-sms-parser.js +183 -0
- package/src/vendor/core/device-security.js +506 -0
- package/src/vendor/core/index.js +13 -0
- package/src/vendor/core/normalizer.js +165 -0
- package/src/vendor/core/qr-lib.js +8 -0
- package/src/vendor/core/qr.js +62 -0
- package/src/vendor/core/queue-engine.js +153 -0
- package/src/vendor/core/recovery-engine.js +159 -0
- package/src/vendor/core/state-machine.js +42 -0
- package/src/vendor/core/timing-engine.js +272 -0
- package/src/vendor/core/verification-pipeline.js +1164 -0
- package/src/vendor/core/webhook-engine.js +280 -0
- package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
- package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
- package/src/vendor/db/__tests__/db.test.js +265 -0
- package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
- package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
- package/src/vendor/db/database.js +181 -0
- package/src/vendor/db/index.js +12 -0
- package/src/vendor/db/migrate.js +210 -0
- package/src/vendor/db/repositories/audit-log.repository.js +68 -0
- package/src/vendor/db/repositories/customer-session.repository.js +181 -0
- package/src/vendor/db/repositories/customer.repository.js +636 -0
- package/src/vendor/db/repositories/device.repository.js +285 -0
- package/src/vendor/db/repositories/merchant.repository.js +158 -0
- package/src/vendor/db/repositories/observation.repository.js +124 -0
- package/src/vendor/db/repositories/payment-session.repository.js +618 -0
- package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
- package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
- package/src/vendor/db/schema.sql +266 -0
- package/src/vendor/db/sqlite-schema.sql +270 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
safeEqual,
|
|
4
|
+
issueDeviceCredential,
|
|
5
|
+
verifyDeviceCredential,
|
|
6
|
+
createDeviceChallenge,
|
|
7
|
+
consumeDeviceChallenge
|
|
8
|
+
} from '../common/index.js';
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
issueDeviceCredential,
|
|
12
|
+
verifyDeviceCredential,
|
|
13
|
+
createDeviceChallenge,
|
|
14
|
+
consumeDeviceChallenge
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalizes privacy-conscious browser/device signals into a canonical dictionary.
|
|
19
|
+
* @param {Record<string, any>} signals
|
|
20
|
+
*/
|
|
21
|
+
export function normalizeDeviceSignals(signals = {}) {
|
|
22
|
+
const ua = (signals.userAgent || '').toString().trim().replace(/\s+/g, ' ');
|
|
23
|
+
const platform = (signals.platform || '').toString().trim().toLowerCase();
|
|
24
|
+
const language = (signals.language || '').toString().trim().toLowerCase();
|
|
25
|
+
const timezone = (signals.timezone || '').toString().trim();
|
|
26
|
+
|
|
27
|
+
let screenSig = signals.screenSignature || '';
|
|
28
|
+
if (!screenSig && signals.screenWidth && signals.screenHeight) {
|
|
29
|
+
screenSig = `${signals.screenWidth}x${signals.screenHeight}x${signals.screenColorDepth || 24}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const hardwareConcurrency = Math.max(0, parseInt(signals.hardwareConcurrency, 10) || 0);
|
|
33
|
+
const deviceMemory = Math.max(0, parseFloat(signals.deviceMemory) || 0);
|
|
34
|
+
const webglRenderer = (signals.webglRenderer || signals.webgl || '').toString().trim().toLowerCase();
|
|
35
|
+
const canvasSignature = (signals.canvasSignature || '').toString().trim();
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
canvasSignature,
|
|
39
|
+
deviceMemory,
|
|
40
|
+
hardwareConcurrency,
|
|
41
|
+
language,
|
|
42
|
+
platform,
|
|
43
|
+
screenSignature: screenSig,
|
|
44
|
+
timezone,
|
|
45
|
+
userAgent: ua,
|
|
46
|
+
webglRenderer
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generates a stable SHA-256 hash of normalized browser/device signals.
|
|
52
|
+
* @param {Record<string, any>} signals
|
|
53
|
+
* @returns {string} SHA-256 hexadecimal hash
|
|
54
|
+
*/
|
|
55
|
+
export function generateFingerprintHash(signals = {}) {
|
|
56
|
+
const normalized = normalizeDeviceSignals(signals);
|
|
57
|
+
// Canonical serialization: keys sorted deterministically
|
|
58
|
+
const canonicalString = JSON.stringify(normalized, Object.keys(normalized).sort());
|
|
59
|
+
return crypto.createHash('sha256').update(canonicalString).digest('hex');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Imports a public key from base64 SPKI, DER buffer, or JWK.
|
|
64
|
+
* @param {string|object} publicKeyInput
|
|
65
|
+
* @returns {crypto.KeyObject}
|
|
66
|
+
*/
|
|
67
|
+
export function importDevicePublicKey(publicKeyInput) {
|
|
68
|
+
if (!publicKeyInput) {
|
|
69
|
+
throw new Error('Public key is required');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof publicKeyInput === 'object' && publicKeyInput.kty) {
|
|
73
|
+
return crypto.createPublicKey({
|
|
74
|
+
key: publicKeyInput,
|
|
75
|
+
format: 'jwk'
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const str = String(publicKeyInput).trim();
|
|
80
|
+
|
|
81
|
+
// If PEM format
|
|
82
|
+
if (str.includes('BEGIN PUBLIC KEY')) {
|
|
83
|
+
return crypto.createPublicKey(str);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// If JSON JWK string
|
|
87
|
+
if (str.startsWith('{') && str.endsWith('}')) {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(str);
|
|
90
|
+
return crypto.createPublicKey({ key: parsed, format: 'jwk' });
|
|
91
|
+
} catch {}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Otherwise assume base64 SPKI (DER)
|
|
95
|
+
const derBuffer = Buffer.from(str, 'base64');
|
|
96
|
+
return crypto.createPublicKey({
|
|
97
|
+
key: derBuffer,
|
|
98
|
+
format: 'der',
|
|
99
|
+
type: 'spki'
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const DEFAULT_SIGNATURE_MAX_AGE_MS = 5 * 60 * 1000;
|
|
104
|
+
const DEFAULT_SIGNATURE_CLOCK_SKEW_MS = 60 * 1000;
|
|
105
|
+
const usedSignatureNonces = new Map();
|
|
106
|
+
|
|
107
|
+
function pruneSignatureNonces(now = Date.now()) {
|
|
108
|
+
if (usedSignatureNonces.size < 256) return;
|
|
109
|
+
for (const [k, exp] of usedSignatureNonces.entries()) {
|
|
110
|
+
if (exp <= now) usedSignatureNonces.delete(k);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Extracts and validates the anti-replay `{ nonce, timestamp }` metadata from a signed payload.
|
|
116
|
+
* Payloads must be a JSON string/object `{ nonce, timestamp, ... }` or `<nonce>:<timestamp>[:...]`.
|
|
117
|
+
* @param {string|Buffer|object} dataToVerify
|
|
118
|
+
* @param {{ maxAgeMs?: number, maxClockSkewMs?: number, expectedDeviceId?: string, expectedChallenge?: string }} [options]
|
|
119
|
+
* @returns {{ canonicalBuffer: Buffer, nonce: string, timestamp: number } | null}
|
|
120
|
+
*/
|
|
121
|
+
export function parseSignedDevicePayload(dataToVerify, options = {}) {
|
|
122
|
+
if (dataToVerify === null || dataToVerify === undefined) return null;
|
|
123
|
+
|
|
124
|
+
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : DEFAULT_SIGNATURE_MAX_AGE_MS;
|
|
125
|
+
const maxClockSkewMs = Number.isFinite(options.maxClockSkewMs) ? options.maxClockSkewMs : DEFAULT_SIGNATURE_CLOCK_SKEW_MS;
|
|
126
|
+
|
|
127
|
+
let rawString;
|
|
128
|
+
let parsedObj = null;
|
|
129
|
+
|
|
130
|
+
if (Buffer.isBuffer(dataToVerify)) {
|
|
131
|
+
rawString = dataToVerify.toString('utf8');
|
|
132
|
+
} else if (typeof dataToVerify === 'object') {
|
|
133
|
+
parsedObj = dataToVerify;
|
|
134
|
+
rawString = JSON.stringify(dataToVerify);
|
|
135
|
+
} else if (typeof dataToVerify === 'string') {
|
|
136
|
+
rawString = dataToVerify;
|
|
137
|
+
} else {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!parsedObj && rawString.trim().startsWith('{') && rawString.trim().endsWith('}')) {
|
|
142
|
+
try {
|
|
143
|
+
parsedObj = JSON.parse(rawString);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let nonce = null;
|
|
150
|
+
let rawTs = null;
|
|
151
|
+
|
|
152
|
+
if (parsedObj && typeof parsedObj === 'object') {
|
|
153
|
+
nonce = typeof parsedObj.nonce === 'string' ? parsedObj.nonce.trim() : null;
|
|
154
|
+
rawTs = parsedObj.timestamp ?? parsedObj.ts ?? parsedObj.issuedAt;
|
|
155
|
+
if (options.expectedDeviceId && parsedObj.deviceId && parsedObj.deviceId !== options.expectedDeviceId) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
if (options.expectedChallenge && parsedObj.challenge !== options.expectedChallenge) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
// Support colon-separated `<nonce>:<timestamp>[:<challenge>...]`
|
|
163
|
+
const parts = rawString.split(':');
|
|
164
|
+
if (parts.length < 2) return null;
|
|
165
|
+
nonce = parts[0].trim();
|
|
166
|
+
rawTs = parts[1].trim();
|
|
167
|
+
if (options.expectedChallenge && !parts.slice(2).includes(options.expectedChallenge)) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!nonce || nonce.length < 8 || nonce.length > 256) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let timestampMs = NaN;
|
|
177
|
+
if (typeof rawTs === 'number' && Number.isFinite(rawTs)) {
|
|
178
|
+
timestampMs = rawTs >= 1e9 && rawTs < 1e11 ? rawTs * 1000 : rawTs;
|
|
179
|
+
} else if (typeof rawTs === 'string' && rawTs.trim()) {
|
|
180
|
+
const num = Number(rawTs.trim());
|
|
181
|
+
if (Number.isFinite(num)) {
|
|
182
|
+
timestampMs = num >= 1e9 && num < 1e11 ? num * 1000 : num;
|
|
183
|
+
} else {
|
|
184
|
+
timestampMs = Date.parse(rawTs.trim());
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!Number.isFinite(timestampMs) || timestampMs <= 0) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
if (now - timestampMs > maxAgeMs) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
if (timestampMs - now > maxClockSkewMs) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const canonicalBuffer = Buffer.isBuffer(dataToVerify)
|
|
201
|
+
? dataToVerify
|
|
202
|
+
: Buffer.from(rawString, 'utf8');
|
|
203
|
+
|
|
204
|
+
return { canonicalBuffer, nonce, timestamp: timestampMs };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Verifies an ECDSA P-256 signature generated by browser Web Crypto.
|
|
209
|
+
* Requires a fresh timestamp and single-use nonce in the signed payload.
|
|
210
|
+
* @param {string|object} publicKey
|
|
211
|
+
* @param {string|Buffer|object} dataToVerify
|
|
212
|
+
* @param {string} signatureBase64
|
|
213
|
+
* @param {{ maxAgeMs?: number, maxClockSkewMs?: number, expectedDeviceId?: string, expectedChallenge?: string }} [options]
|
|
214
|
+
* @returns {boolean}
|
|
215
|
+
*/
|
|
216
|
+
export function verifyDeviceSignature(publicKey, dataToVerify, signatureBase64, options = {}) {
|
|
217
|
+
try {
|
|
218
|
+
if (!publicKey || !signatureBase64) return false;
|
|
219
|
+
|
|
220
|
+
const parsed = parseSignedDevicePayload(dataToVerify, options);
|
|
221
|
+
if (!parsed) return false;
|
|
222
|
+
|
|
223
|
+
const keyObject = importDevicePublicKey(publicKey);
|
|
224
|
+
const dataBuffer = parsed.canonicalBuffer;
|
|
225
|
+
const sigStr = String(signatureBase64).trim();
|
|
226
|
+
if (!sigStr) return false;
|
|
227
|
+
const sigBuffer = Buffer.from(sigStr, 'base64');
|
|
228
|
+
if (sigBuffer.length === 0) return false;
|
|
229
|
+
|
|
230
|
+
const keyDer = keyObject.export({ format: 'der', type: 'spki' });
|
|
231
|
+
const keyFp = crypto.createHash('sha256').update(keyDer).digest('hex').slice(0, 32);
|
|
232
|
+
const nonceKey = `${keyFp}:${parsed.nonce}`;
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
pruneSignatureNonces(now);
|
|
235
|
+
|
|
236
|
+
const existingExp = usedSignatureNonces.get(nonceKey);
|
|
237
|
+
if (existingExp && existingExp > now) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let verified = false;
|
|
242
|
+
|
|
243
|
+
// Try ieee-p1363 (standard Web Crypto ECDSA output: r || s, 64 bytes)
|
|
244
|
+
try {
|
|
245
|
+
verified = crypto.verify(
|
|
246
|
+
'SHA256',
|
|
247
|
+
dataBuffer,
|
|
248
|
+
{ key: keyObject, dsaEncoding: 'ieee-p1363' },
|
|
249
|
+
sigBuffer
|
|
250
|
+
);
|
|
251
|
+
} catch {}
|
|
252
|
+
|
|
253
|
+
// Fallback to standard DER encoding
|
|
254
|
+
if (!verified) {
|
|
255
|
+
try {
|
|
256
|
+
verified = crypto.verify(
|
|
257
|
+
'SHA256',
|
|
258
|
+
dataBuffer,
|
|
259
|
+
keyObject,
|
|
260
|
+
sigBuffer
|
|
261
|
+
);
|
|
262
|
+
} catch {}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!verified) return false;
|
|
266
|
+
|
|
267
|
+
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : DEFAULT_SIGNATURE_MAX_AGE_MS;
|
|
268
|
+
usedSignatureNonces.set(nonceKey, now + maxAgeMs);
|
|
269
|
+
return true;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.warn('[DeviceSecurity] Signature verification error:', err.message);
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Verifies that a device exists, is ACTIVE, is not an adhoc placeholder, and that the
|
|
278
|
+
* caller presented a valid cryptographic credential (token or ECDSA signature).
|
|
279
|
+
* @param {{
|
|
280
|
+
* deviceId: string,
|
|
281
|
+
* deviceToken?: string|null,
|
|
282
|
+
* deviceSignature?: string|null,
|
|
283
|
+
* signaturePayload?: string|object|null,
|
|
284
|
+
* deviceRepo: any
|
|
285
|
+
* }} params
|
|
286
|
+
* @returns {{ valid: boolean, error?: string, message?: string, device?: any }}
|
|
287
|
+
*/
|
|
288
|
+
export function verifyRegisteredDeviceCredentials({
|
|
289
|
+
deviceId,
|
|
290
|
+
deviceToken,
|
|
291
|
+
deviceSignature,
|
|
292
|
+
signaturePayload,
|
|
293
|
+
deviceRepo
|
|
294
|
+
}) {
|
|
295
|
+
if (!deviceId || typeof deviceId !== 'string') {
|
|
296
|
+
return {
|
|
297
|
+
valid: false,
|
|
298
|
+
error: 'DEVICE_ID_REQUIRED',
|
|
299
|
+
message: 'A registered deviceId is required.'
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (!deviceRepo) {
|
|
303
|
+
return {
|
|
304
|
+
valid: false,
|
|
305
|
+
error: 'DEVICE_REPO_UNAVAILABLE',
|
|
306
|
+
message: 'Device repository is required for device verification.'
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const dev = deviceRepo.findById(deviceId);
|
|
311
|
+
if (!dev || dev.status !== 'ACTIVE' || (typeof dev.publicKey === 'string' && dev.publicKey.startsWith('adhoc_'))) {
|
|
312
|
+
return {
|
|
313
|
+
valid: false,
|
|
314
|
+
error: 'DEVICE_REVOKED',
|
|
315
|
+
message: 'The device is unregistered, revoked, or invalid.'
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const hasToken = typeof deviceToken === 'string' && deviceToken.trim().length > 0;
|
|
320
|
+
const hasSig = typeof deviceSignature === 'string' && deviceSignature.trim().length > 0;
|
|
321
|
+
const hasPayload = signaturePayload !== undefined && signaturePayload !== null && String(signaturePayload).trim().length > 0;
|
|
322
|
+
|
|
323
|
+
if (!hasToken && !hasSig) {
|
|
324
|
+
return {
|
|
325
|
+
valid: false,
|
|
326
|
+
error: 'DEVICE_CREDENTIALS_REQUIRED',
|
|
327
|
+
message: 'Device authentication credential (token or cryptographic signature) is required.'
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if ((hasSig && !hasPayload) || (!hasSig && hasPayload)) {
|
|
332
|
+
return {
|
|
333
|
+
valid: false,
|
|
334
|
+
error: 'DEVICE_SIGNATURE_INVALID',
|
|
335
|
+
message: 'Both device signature and signed payload are required for signature verification.'
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (hasToken) {
|
|
340
|
+
let tokenOk = false;
|
|
341
|
+
if (typeof deviceRepo.verifyToken === 'function') {
|
|
342
|
+
tokenOk = deviceRepo.verifyToken(deviceId, deviceToken.trim());
|
|
343
|
+
} else if (dev.deviceToken) {
|
|
344
|
+
tokenOk = safeEqual(dev.deviceToken, deviceToken.trim()) && verifyDeviceCredential(deviceId, deviceToken.trim());
|
|
345
|
+
} else {
|
|
346
|
+
tokenOk = verifyDeviceCredential(deviceId, deviceToken.trim());
|
|
347
|
+
}
|
|
348
|
+
if (!tokenOk) {
|
|
349
|
+
return {
|
|
350
|
+
valid: false,
|
|
351
|
+
error: 'DEVICE_TOKEN_INVALID',
|
|
352
|
+
message: 'Device token failed verification.'
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (hasSig) {
|
|
358
|
+
const sigOk = verifyDeviceSignature(dev.publicKey, signaturePayload, deviceSignature, {
|
|
359
|
+
expectedDeviceId: deviceId
|
|
360
|
+
});
|
|
361
|
+
if (!sigOk) {
|
|
362
|
+
return {
|
|
363
|
+
valid: false,
|
|
364
|
+
error: 'DEVICE_SIGNATURE_INVALID',
|
|
365
|
+
message: 'Device cryptographic signature failed verification.'
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return { valid: true, device: dev };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Validates whether the requesting device matches the payment session's bound device.
|
|
375
|
+
* @param {{
|
|
376
|
+
* session: any,
|
|
377
|
+
* requestingDeviceId?: string|null,
|
|
378
|
+
* deviceToken?: string|null,
|
|
379
|
+
* deviceSignature?: string|null,
|
|
380
|
+
* signaturePayload?: string|null,
|
|
381
|
+
* deviceRepo?: any
|
|
382
|
+
* }} options
|
|
383
|
+
* @returns {{
|
|
384
|
+
* allowed: boolean,
|
|
385
|
+
* shouldBind?: boolean,
|
|
386
|
+
* error?: string,
|
|
387
|
+
* session_id?: string,
|
|
388
|
+
* action?: string,
|
|
389
|
+
* message?: string,
|
|
390
|
+
* boundDeviceId?: string|null,
|
|
391
|
+
* requestingDeviceId?: string|null
|
|
392
|
+
* }}
|
|
393
|
+
*/
|
|
394
|
+
export function validateSessionDeviceAccess(options) {
|
|
395
|
+
const { session, requestingDeviceId, deviceToken, deviceSignature, signaturePayload, deviceRepo } = options || {};
|
|
396
|
+
|
|
397
|
+
if (!session) {
|
|
398
|
+
return {
|
|
399
|
+
allowed: false,
|
|
400
|
+
error: 'SESSION_NOT_FOUND',
|
|
401
|
+
message: 'Payment session not found'
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const boundDeviceId = session.deviceId;
|
|
406
|
+
|
|
407
|
+
// If session is not yet bound to any device:
|
|
408
|
+
// Only allow binding if the caller presents a registered, cryptographically verified device.
|
|
409
|
+
if (!boundDeviceId) {
|
|
410
|
+
if (!requestingDeviceId) {
|
|
411
|
+
return {
|
|
412
|
+
allowed: false,
|
|
413
|
+
error: 'DEVICE_ID_REQUIRED',
|
|
414
|
+
session_id: session.id,
|
|
415
|
+
action: 'REGISTER_DEVICE',
|
|
416
|
+
message: 'A registered device identity is required to access or bind this checkout session.',
|
|
417
|
+
boundDeviceId: null,
|
|
418
|
+
requestingDeviceId: null
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const credCheck = verifyRegisteredDeviceCredentials({
|
|
423
|
+
deviceId: requestingDeviceId,
|
|
424
|
+
deviceToken,
|
|
425
|
+
deviceSignature,
|
|
426
|
+
signaturePayload,
|
|
427
|
+
deviceRepo
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
if (!credCheck.valid) {
|
|
431
|
+
return {
|
|
432
|
+
allowed: false,
|
|
433
|
+
error: credCheck.error === 'DEVICE_SIGNATURE_INVALID' ? 'DEVICE_SIGNATURE_INVALID' : 'DUPLICATE_SESSION_DETECTED',
|
|
434
|
+
session_id: session.id,
|
|
435
|
+
action: 'END_SESSION_AND_CREATE_NEW',
|
|
436
|
+
message: credCheck.message || 'Valid registered device credentials are required.',
|
|
437
|
+
boundDeviceId: null,
|
|
438
|
+
requestingDeviceId
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
allowed: true,
|
|
444
|
+
shouldBind: true,
|
|
445
|
+
requestingDeviceId
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// If requesting device ID is missing or does not match bound device
|
|
450
|
+
if (!requestingDeviceId || requestingDeviceId !== boundDeviceId) {
|
|
451
|
+
return {
|
|
452
|
+
allowed: false,
|
|
453
|
+
error: 'DUPLICATE_SESSION_DETECTED',
|
|
454
|
+
session_id: session.id,
|
|
455
|
+
action: 'END_SESSION_AND_CREATE_NEW',
|
|
456
|
+
message: 'This payment session was created on another device. For security reasons, this session cannot be continued from this device.',
|
|
457
|
+
boundDeviceId,
|
|
458
|
+
requestingDeviceId: requestingDeviceId || null
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Verify the bound device is registered, active, and that the caller supplied a valid token or signature
|
|
463
|
+
const credCheck = verifyRegisteredDeviceCredentials({
|
|
464
|
+
deviceId: boundDeviceId,
|
|
465
|
+
deviceToken,
|
|
466
|
+
deviceSignature,
|
|
467
|
+
signaturePayload,
|
|
468
|
+
deviceRepo
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
if (!credCheck.valid) {
|
|
472
|
+
if (credCheck.error === 'DEVICE_REVOKED') {
|
|
473
|
+
return {
|
|
474
|
+
allowed: false,
|
|
475
|
+
error: 'DEVICE_REVOKED',
|
|
476
|
+
session_id: session.id,
|
|
477
|
+
action: 'END_SESSION_AND_CREATE_NEW',
|
|
478
|
+
message: 'The device registered for this payment session has been revoked or is invalid.'
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
if (credCheck.error === 'DEVICE_SIGNATURE_INVALID') {
|
|
482
|
+
return {
|
|
483
|
+
allowed: false,
|
|
484
|
+
error: 'DEVICE_SIGNATURE_INVALID',
|
|
485
|
+
session_id: session.id,
|
|
486
|
+
action: 'END_SESSION_AND_CREATE_NEW',
|
|
487
|
+
message: 'Device cryptographic signature failed verification.'
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
allowed: false,
|
|
492
|
+
error: 'DUPLICATE_SESSION_DETECTED',
|
|
493
|
+
session_id: session.id,
|
|
494
|
+
action: 'END_SESSION_AND_CREATE_NEW',
|
|
495
|
+
message: 'This payment session was created on another device. For security reasons, this session cannot be continued from this device.',
|
|
496
|
+
boundDeviceId,
|
|
497
|
+
requestingDeviceId
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
allowed: true,
|
|
503
|
+
boundDeviceId,
|
|
504
|
+
requestingDeviceId
|
|
505
|
+
};
|
|
506
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './state-machine.js';
|
|
2
|
+
export * from './normalizer.js';
|
|
3
|
+
export * from './attribution-engine.js';
|
|
4
|
+
export * from './queue-engine.js';
|
|
5
|
+
export * from './timing-engine.js';
|
|
6
|
+
export * from './recovery-engine.js';
|
|
7
|
+
export * from './webhook-engine.js';
|
|
8
|
+
export * from './bank-sms-parser.js';
|
|
9
|
+
export * from './bank-email-parser.js';
|
|
10
|
+
export * from './qr.js';
|
|
11
|
+
export * from './verification-pipeline.js';
|
|
12
|
+
export * from './device-security.js';
|
|
13
|
+
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { ObserverSource, ObservationAttributionStatus, toPaise, calculateObservationFingerprint } from '../common/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Intelligent Monetary Amount Extractor.
|
|
5
|
+
* Extracts amounts with prefix (Rs. 20.00, INR 500), suffix (20.00 Rs),
|
|
6
|
+
* proximity words ("20.00 received", "received 20.00", "credited 20"),
|
|
7
|
+
* or clean standalone decimal amounts ("20.00").
|
|
8
|
+
* @param {string} text
|
|
9
|
+
* @returns {bigint | null}
|
|
10
|
+
*/
|
|
11
|
+
export function extractIntelligentPaise(text) {
|
|
12
|
+
if (!text || typeof text !== 'string') return null;
|
|
13
|
+
|
|
14
|
+
// Pattern 1: Standard currency prefix (e.g. Rs. 20.00, Rs 200, INR 500.00, ₹1,200.50, ₹20)
|
|
15
|
+
const prefixMatch = text.match(/(?:rs\.?|inr|₹)\s*([\d,]+(?:\.\d{1,2})?)/i);
|
|
16
|
+
if (prefixMatch && prefixMatch[1]) {
|
|
17
|
+
const clean = prefixMatch[1].replace(/,/g, '');
|
|
18
|
+
try { return toPaise(clean); } catch {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Pattern 2: Standard currency suffix (e.g. 20.00 Rs, 20.00 INR, 20rs, 500 inr)
|
|
22
|
+
const suffixMatch = text.match(/([\d,]+(?:\.\d{1,2})?)\s*(?:rs\.?|inr|₹)/i);
|
|
23
|
+
if (suffixMatch && suffixMatch[1]) {
|
|
24
|
+
const clean = suffixMatch[1].replace(/,/g, '');
|
|
25
|
+
try { return toPaise(clean); } catch {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Pattern 3: Number in proximity to credit keywords (e.g. "20.00 received", "received 20.00", "credited 20", "20.00 credited")
|
|
29
|
+
const creditProximityMatch = text.match(/(?:credited|received|deposit|payment|paid|transferred|upi|cr)[\s\w:]*?([\d,]+(?:\.\d{1,2})?)|([\d,]+(?:\.\d{1,2})?)[\s\w:]*?(?:credited|received|deposit|payment|paid|transferred|upi|cr)/i);
|
|
30
|
+
if (creditProximityMatch) {
|
|
31
|
+
const rawVal = creditProximityMatch[1] || creditProximityMatch[2];
|
|
32
|
+
if (rawVal) {
|
|
33
|
+
const clean = rawVal.replace(/,/g, '');
|
|
34
|
+
if (/^\d+(\.\d{1,2})?$/.test(clean) && Number(clean) < 10000000 && Number(clean) > 0) {
|
|
35
|
+
try { return toPaise(clean); } catch {}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pattern 4: Standalone decimal currency number (e.g. 20.00, 150.50, 449.00)
|
|
41
|
+
const standaloneDecimal = text.match(/\b(\d{1,6}\.\d{2})\b/);
|
|
42
|
+
if (standaloneDecimal && standaloneDecimal[1]) {
|
|
43
|
+
try { return toPaise(standaloneDecimal[1]); } catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Regex for extracting bank UTR / Reference numbers.
|
|
51
|
+
* Matches: UTR: 123456789012, Ref 123456789012, Txn: ABC123XYZ, RRN: 987654
|
|
52
|
+
*/
|
|
53
|
+
const REFERENCE_REGEX = /(?:utr|ref(?:\s*no\.?)?|txn(?:\s*(?:id|#))?|transaction\s*id|rrn|reference(?:\s*no\.?)?|upi(?:\s*(?:ref|reference|txn))?)[:\s#]+([a-zA-Z0-9\-_]{6,35})/i;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Regex for extracting sender VPA if present in notification text.
|
|
57
|
+
* Matches: from user@upi, from 9876543210@paytm, from Rahul (UPI ID: rahul@fam), etc.
|
|
58
|
+
*/
|
|
59
|
+
const SENDER_VPA_REGEX = /(?:from|by|vpa|upi\s*id)[\s:]+(?:[a-zA-Z\s]+[\(\[])?([a-zA-Z0-9.\-_]{2,100}@[a-zA-Z0-9.\-_]{2,64})/i;
|
|
60
|
+
|
|
61
|
+
export class ObservationNormalizer {
|
|
62
|
+
/**
|
|
63
|
+
* Normalizes raw incoming data from any observation source into a canonical observation record.
|
|
64
|
+
* @param {{
|
|
65
|
+
* source: string,
|
|
66
|
+
* receivingAccountId: string,
|
|
67
|
+
* rawPayload: Record<string, any>,
|
|
68
|
+
* amountPaise?: bigint | number | string,
|
|
69
|
+
* amount?: string | number,
|
|
70
|
+
* observedAt?: Date | string,
|
|
71
|
+
* senderVpa?: string,
|
|
72
|
+
* bankReferenceNumber?: string
|
|
73
|
+
* }} input
|
|
74
|
+
* @returns {{
|
|
75
|
+
* receivingAccountId: string,
|
|
76
|
+
* source: string,
|
|
77
|
+
* amountPaise: bigint,
|
|
78
|
+
* observedAt: Date,
|
|
79
|
+
* senderVpa?: string,
|
|
80
|
+
* bankReferenceNumber?: string,
|
|
81
|
+
* fingerprintHash: string,
|
|
82
|
+
* attributionStatus: string,
|
|
83
|
+
* rawPayload: Record<string, any>
|
|
84
|
+
* }}
|
|
85
|
+
*/
|
|
86
|
+
static normalize(input) {
|
|
87
|
+
if (!input.receivingAccountId) {
|
|
88
|
+
throw new Error('Observation normalization failed: receivingAccountId is required.');
|
|
89
|
+
}
|
|
90
|
+
if (!input.source || !Object.values(ObserverSource).includes(input.source)) {
|
|
91
|
+
throw new Error(`Observation normalization failed: invalid or unknown source '${input.source}'.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const payload = input.rawPayload || {};
|
|
95
|
+
const textContent = (payload.text || payload.body || payload.notificationText || payload.snippet || '').toString();
|
|
96
|
+
|
|
97
|
+
// 1. Resolve Amount using priority resolution: explicit paise -> explicit amount -> intelligent multi-pattern extraction
|
|
98
|
+
let finalAmountPaise;
|
|
99
|
+
if (input.amountPaise !== undefined && input.amountPaise !== null && input.amountPaise !== '') {
|
|
100
|
+
finalAmountPaise = BigInt(input.amountPaise);
|
|
101
|
+
} else if (input.amount !== undefined && input.amount !== null && input.amount !== '') {
|
|
102
|
+
finalAmountPaise = toPaise(input.amount);
|
|
103
|
+
} else {
|
|
104
|
+
const extracted = extractIntelligentPaise(textContent);
|
|
105
|
+
if (!extracted) {
|
|
106
|
+
throw new Error(`Observation normalization failed: could not extract valid monetary amount from payload text: "${textContent}"`);
|
|
107
|
+
}
|
|
108
|
+
finalAmountPaise = extracted;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. Resolve Observed Timestamp
|
|
112
|
+
let observedAtDate;
|
|
113
|
+
if (input.observedAt) {
|
|
114
|
+
observedAtDate = input.observedAt instanceof Date ? input.observedAt : new Date(input.observedAt);
|
|
115
|
+
} else if (payload.timestamp || payload.postTime || payload.date) {
|
|
116
|
+
const rawTs = payload.timestamp || payload.postTime || payload.date;
|
|
117
|
+
observedAtDate = new Date(typeof rawTs === 'number' ? rawTs : rawTs.toString());
|
|
118
|
+
} else {
|
|
119
|
+
observedAtDate = new Date();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isNaN(observedAtDate.getTime())) {
|
|
123
|
+
observedAtDate = new Date();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 3. Resolve Reference Number / UTR
|
|
127
|
+
let bankRef = input.bankReferenceNumber;
|
|
128
|
+
if (!bankRef && textContent) {
|
|
129
|
+
const refMatch = textContent.match(REFERENCE_REGEX);
|
|
130
|
+
if (refMatch && refMatch[1]) {
|
|
131
|
+
bankRef = refMatch[1].trim();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 4. Resolve Sender VPA
|
|
136
|
+
let senderVpa = input.senderVpa;
|
|
137
|
+
if (!senderVpa && textContent) {
|
|
138
|
+
const vpaMatch = textContent.match(SENDER_VPA_REGEX);
|
|
139
|
+
if (vpaMatch && vpaMatch[1]) {
|
|
140
|
+
senderVpa = vpaMatch[1].trim().replace(/[.,;]+$/, '');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 5. Compute Deterministic Fingerprint
|
|
145
|
+
const fingerprintHash = calculateObservationFingerprint({
|
|
146
|
+
receivingAccountId: input.receivingAccountId,
|
|
147
|
+
amountPaise: finalAmountPaise,
|
|
148
|
+
source: input.source,
|
|
149
|
+
bankReferenceNumber: bankRef,
|
|
150
|
+
observedAt: observedAtDate
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
receivingAccountId: input.receivingAccountId,
|
|
155
|
+
source: input.source,
|
|
156
|
+
amountPaise: finalAmountPaise,
|
|
157
|
+
observedAt: observedAtDate,
|
|
158
|
+
senderVpa: senderVpa || undefined,
|
|
159
|
+
bankReferenceNumber: bankRef || undefined,
|
|
160
|
+
fingerprintHash,
|
|
161
|
+
attributionStatus: ObservationAttributionStatus.UNATTRIBUTED,
|
|
162
|
+
rawPayload: payload
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|