#!/usr/bin/env node /** * Decrypt a JTL POS backup archive (ZIP) to SQLite .db files and images/. * AES-256-ECB, PKCS7 padding, hardcoded key from app. * * Usage: node decrypt_jtl_pos.js [output-dir] */ const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFileSync } = require('child_process'); const KEY = Buffer.from([ 0xCC, 0x50, 0x12, 0x30, 0x31, 0xDC, 0x03, 0xAA, 0x62, 0x0A, 0x1A, 0x97, 0x0B, 0xCA, 0x4B, 0x8F, 0xF6, 0x3D, 0xBA, 0xA8, 0xC4, 0x53, 0x7F, 0xDD, 0x9D, 0x0F, 0x3A, 0x73, 0x57, 0x71, 0x03, 0xB2, ]); const ENCRYPTED_DBS = ['POS_DATABASE_', 'POS_LOG_DATABASE_', 'POS_R_DATABASE_']; const IMAGES_MARKER = `${path.sep}files${path.sep}images${path.sep}`; function usage() { console.error('Usage: node decrypt_jtl_pos.js [output-dir]'); process.exit(1); } function rmDir(dir) { fs.rmSync(dir, { recursive: true, force: true }); } function findFile(dir, basename) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { const found = findFile(full, basename); if (found) return found; } else if (entry.name === basename) { return full; } } return null; } function walkFiles(dir) { const files = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...walkFiles(full)); } else { files.push(full); } } return files; } function decryptBuffer(data) { const decipher = crypto.createDecipheriv('aes-256-ecb', KEY, null); decipher.setAutoPadding(true); return Buffer.concat([decipher.update(data), decipher.final()]); } function md5Hex(buf) { return crypto.createHash('md5').update(buf).digest('hex'); } function imageExtension(buf) { if (buf.length >= 4 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) { return '.png'; } if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) { return '.jpg'; } if (buf.length >= 4 && buf.subarray(0, 4).toString() === 'GIF8') { return '.gif'; } if (buf.length >= 12 && buf.subarray(0, 4).toString() === 'RIFF' && buf.subarray(8, 12).toString() === 'WEBP') { return '.webp'; } return ''; } function imageDestPath(rel, data) { if (path.extname(rel)) return rel; const ext = imageExtension(data); return ext ? rel + ext : rel; } function extractImages(tempDir, outDir) { const imagesDir = path.join(outDir, 'images'); let count = 0; for (const file of walkFiles(tempDir)) { const idx = file.indexOf(IMAGES_MARKER); if (idx === -1) continue; const rel = imageDestPath(file.slice(idx + IMAGES_MARKER.length), fs.readFileSync(file)); const dest = path.join(imagesDir, rel); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(file, dest); count++; } return count; } function unzipBackup(backup, tempDir) { try { execFileSync('unzip', ['-o', backup, '-d', tempDir], { stdio: 'pipe' }); } catch { if (!findFile(tempDir, ENCRYPTED_DBS[0])) { throw new Error('Failed to extract backup (is it a valid ZIP?)'); } } } function decryptBackup(backupPath, outputDir) { const backup = path.resolve(backupPath); if (!fs.existsSync(backup)) { throw new Error(`Backup not found: ${backup}`); } const outDir = path.resolve(outputDir || path.dirname(backup)); fs.mkdirSync(outDir, { recursive: true }); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jtl-pos-decrypt-')); try { unzipBackup(backup, tempDir); for (const encName of ENCRYPTED_DBS) { const encPath = findFile(tempDir, encName); if (!encPath) { throw new Error(`Missing encrypted database in backup: ${encName}`); } const plain = decryptBuffer(fs.readFileSync(encPath)); if (plain.subarray(0, 15).toString() !== 'SQLite format 3') { throw new Error(`${encName}: decrypted data is not SQLite`); } const md5Path = findFile(tempDir, encName.replace(/_$/, '') + '_md5'); if (md5Path) { const expected = fs.readFileSync(md5Path, 'utf8').trim(); const actual = md5Hex(plain); if (actual.toLowerCase() !== expected.toLowerCase()) { throw new Error(`${encName}: MD5 mismatch (expected ${expected}, got ${actual})`); } } const outName = encName.replace(/_$/, '') + '.db'; const outPath = path.join(outDir, outName); fs.writeFileSync(outPath, plain); console.log(`${outPath} (${plain.length} bytes)`); } const imageCount = extractImages(tempDir, outDir); if (imageCount > 0) { console.log(`${path.join(outDir, 'images')}/ (${imageCount} files)`); } } finally { rmDir(tempDir); } } const backupArg = process.argv[2]; if (!backupArg) usage(); try { decryptBackup(backupArg, process.argv[3]); } catch (err) { console.error(err.message); process.exit(1); }