- Add Tahesab/Zarrin accounting sync module with admin panel UI - Add SMS relay driver as default provider, refactor SMS into driver pattern - Update customer services, orders, products, and catalog queries - Rebuild user-app and user-app-v2 dist assets - Add Prisma migration for accounting tables - Update docker-compose and env examples - Update parity docs (DECISIONS, ISSUES, PROGRESS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
57 lines
2.3 KiB
TypeScript
57 lines
2.3 KiB
TypeScript
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
|
|
import { envOrNull } from '../../common/env';
|
|
|
|
/**
|
|
* Encrypts the accounting-software token at rest (D27).
|
|
*
|
|
* The token sits in the Setting table, so a database dump or a backup would
|
|
* otherwise hand out a live credential to the shop's books. AES-256-GCM with a
|
|
* key derived from `ACCOUNTING_SECRET_KEY`.
|
|
*
|
|
* When that variable is unset the key falls back to one derived from
|
|
* `JWT_SECRET`, with a loud warning at boot — the same "warn, don't refuse"
|
|
* trade-off D23 made — so an admin can configure the integration before ops
|
|
* add the variable. The cost: rotating JWT_SECRET then makes the stored token
|
|
* unreadable and the admin has to enter it again (the screen says so).
|
|
*/
|
|
|
|
const PREFIX = 'enc:v1:';
|
|
|
|
export function secretKeySource(): 'ACCOUNTING_SECRET_KEY' | 'JWT_SECRET' | null {
|
|
if (envOrNull('ACCOUNTING_SECRET_KEY')) return 'ACCOUNTING_SECRET_KEY';
|
|
if (envOrNull('JWT_SECRET')) return 'JWT_SECRET';
|
|
return null;
|
|
}
|
|
|
|
function key(): Buffer {
|
|
const material = envOrNull('ACCOUNTING_SECRET_KEY') ?? envOrNull('JWT_SECRET');
|
|
if (!material) {
|
|
throw new Error('ACCOUNTING_SECRET_KEY تنظیم نشده است؛ ذخیرهی توکن حسابداری ممکن نیست.');
|
|
}
|
|
return createHash('sha256').update(`goldbank-accounting:${material}`).digest();
|
|
}
|
|
|
|
export function sealSecret(plain: string): string {
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
|
const body = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
return PREFIX + Buffer.concat([iv, tag, body]).toString('base64');
|
|
}
|
|
|
|
/** Null when the value cannot be decrypted (wrong key, tampered, malformed). */
|
|
export function openSecret(sealed: string | null | undefined): string | null {
|
|
if (!sealed || !sealed.startsWith(PREFIX)) return null;
|
|
try {
|
|
const raw = Buffer.from(sealed.slice(PREFIX.length), 'base64');
|
|
const iv = raw.subarray(0, 12);
|
|
const tag = raw.subarray(12, 28);
|
|
const body = raw.subarray(28);
|
|
const decipher = createDecipheriv('aes-256-gcm', key(), iv);
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|