UNPKG

@accounter/scraper-app

Version:
235 lines (207 loc) 7.29 kB
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import { z, ZodError } from 'zod'; export function getVaultPath(): string { return process.env['VAULT_PATH'] ?? '.vault'; } const ALGORITHM = 'aes-256-gcm'; const KEY_LEN = 32; const SALT_LEN = 32; const IV_LEN = 12; const TAG_LEN = 16; // Scrypt cost parameters stored in the blob header so decryption is independent // of whatever the current code constant says (enables future param upgrades). const SCRYPT_N = 65_536; const SCRYPT_R = 8; const SCRYPT_P = 1; // Node default maxmem is 32 MB; N=65536 r=8 requires ~64 MB. Compute with 2× headroom. const SCRYPT_MAXMEM = 128 * SCRYPT_N * SCRYPT_R * 2; // Blob header: N as uint32BE (4 bytes) + r as uint8 (1 byte) + p as uint8 (1 byte) const HEADER_LEN = 6; export const PoalimAccountSchema = z.object({ id: z.string(), nickname: z.string().optional(), userCode: z.string(), password: z.string(), options: z .object({ isBusinessAccount: z.boolean().optional(), acceptedAccountNumbers: z.array(z.string()).optional(), acceptedBranchNumbers: z.array(z.string()).optional(), ignoredAccountNumbers: z.array(z.string()).optional(), ignoredBranchNumbers: z.array(z.string()).optional(), }) .optional(), }); export const DiscountAccountSchema = z.object({ id: z.string(), ID: z.string(), password: z.string(), code: z.string().optional(), nickname: z.string().optional(), }); export const IsracardAmexAccountSchema = z.object({ id: z.string(), nickname: z.string().optional(), ownerId: z.string(), password: z.string(), last6Digits: z.string(), options: z .object({ acceptedCardNumbers: z.array(z.string()).optional(), ignoredCardNumbers: z.array(z.string()).optional(), cardNumberMapping: z.record(z.string(), z.string()).optional(), }) .optional(), }); export const CalAccountSchema = z.object({ id: z.string(), nickname: z.string().optional(), username: z.string(), password: z.string(), last4Digits: z.string(), options: z .object({ acceptedCardNumbers: z.array(z.string()).optional(), ignoredCardNumbers: z.array(z.string()).optional(), }) .optional(), }); export const MaxAccountSchema = z.object({ id: z.string(), nickname: z.string().optional(), username: z.string(), password: z.string(), options: z .object({ acceptedCardNumbers: z.array(z.string()).optional(), ignoredCardNumbers: z.array(z.string()).optional(), }) .optional(), }); export const OtsarHahayalAccountSchema = z.object({ id: z.string(), nickname: z.string().optional(), userCode: z.string(), password: z.string(), }); export const SettingsSchema = z.object({ showBrowser: z.boolean().default(false), fetchBankOfIsraelRates: z.boolean().default(true), concurrentScraping: z.boolean().default(false), defaultDateRangeMonths: z.number().int().positive().default(3), historyFilePath: z.string().default('./history.json'), saveHistory: z.boolean().default(true), serverUrl: z.string().optional(), apiKey: z.string().optional(), vaultPath: z.string().trim().min(1).optional(), }); export type Settings = z.infer<typeof SettingsSchema>; export const AccountRecordSchema = z.object({ id: z.string(), sourceId: z.string(), sourceType: z.enum([ 'poalim', 'discount', 'isracard', 'amex', 'cal', 'max', 'otsar-hahayal', 'otsar-hahayal-credit-card', ]), accountNumber: z.string(), branchNumber: z.string().optional(), status: z.enum(['accepted', 'ignored', 'pending']).default('pending'), }); export type AccountRecord = z.infer<typeof AccountRecordSchema>; export const VaultSchema = z.object({ poalimAccounts: z.array(PoalimAccountSchema).default([]), discountAccounts: z.array(DiscountAccountSchema).default([]), isracardAccounts: z.array(IsracardAmexAccountSchema).default([]), amexAccounts: z.array(IsracardAmexAccountSchema).default([]), calAccounts: z.array(CalAccountSchema).default([]), maxAccounts: z.array(MaxAccountSchema).default([]), otsarHahayalAccounts: z.array(OtsarHahayalAccountSchema).default([]), accountRecords: z.array(AccountRecordSchema).default([]), settings: SettingsSchema.default({ showBrowser: false, fetchBankOfIsraelRates: true, concurrentScraping: false, defaultDateRangeMonths: 3, historyFilePath: './history.json', saveHistory: true, }), }); export type Vault = z.infer<typeof VaultSchema>; function deriveKey( password: string, salt: Buffer, options: { N: number; r: number; p: number; maxmem: number }, ): Promise<Buffer> { return new Promise((resolve, reject) => { scrypt(password, salt, KEY_LEN, options, (err, key) => { if (err) reject(err); else resolve(key); }); }); } export async function encryptVault(vault: Vault, password: string): Promise<string> { const salt = randomBytes(SALT_LEN); const iv = randomBytes(IV_LEN); const key = await deriveKey(password, salt, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM, }); const cipher = createCipheriv(ALGORITHM, key, iv); const encrypted = Buffer.concat([cipher.update(JSON.stringify(vault), 'utf8'), cipher.final()]); const authTag = cipher.getAuthTag(); const header = Buffer.alloc(HEADER_LEN); header.writeUInt32BE(SCRYPT_N, 0); header.writeUInt8(SCRYPT_R, 4); header.writeUInt8(SCRYPT_P, 5); // Layout: [N(4)] [r(1)] [p(1)] [salt(32)] [iv(12)] [authTag(16)] [ciphertext] return Buffer.concat([header, salt, iv, authTag, encrypted]).toString('base64'); } export async function decryptVault(blob: string, password: string): Promise<Vault | null> { try { const buf = Buffer.from(blob, 'base64'); const N = buf.readUInt32BE(0); const r = buf.readUInt8(4); const p = buf.readUInt8(5); const salt = buf.subarray(HEADER_LEN, HEADER_LEN + SALT_LEN); const iv = buf.subarray(HEADER_LEN + SALT_LEN, HEADER_LEN + SALT_LEN + IV_LEN); const authTag = buf.subarray( HEADER_LEN + SALT_LEN + IV_LEN, HEADER_LEN + SALT_LEN + IV_LEN + TAG_LEN, ); const body = buf.subarray(HEADER_LEN + SALT_LEN + IV_LEN + TAG_LEN); const key = await deriveKey(password, salt, { N, r, p, maxmem: 128 * N * r * 2 }); const decipher = createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); const plaintext = Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8'); return VaultSchema.parse(JSON.parse(plaintext)); } catch (err) { if (err instanceof ZodError) { console.error('[vault] schema validation failed after decryption:', err.issues); } return null; } } export function defaultVault(): Vault { return VaultSchema.parse({ settings: {} }); } export async function loadVaultFile(filePath: string, password: string): Promise<Vault | null> { const blob = await readFile(filePath, 'utf8'); return decryptVault(blob.trim(), password); } export async function saveVaultFile( filePath: string, vault: Vault, password: string, ): Promise<void> { const data = await encryptVault(vault, password); await writeFile(filePath, data, 'utf8'); }