اتصال PHP به سامانه مودیان (TP Tax) — راهنمای کامل پیاده‌سازی احراز هویت، امضای JWS، رمزنگاری JWE، ارسال صورتحساب و استعلام وضعیت (با نمونه‌کد PHP)

اتصال PHP به سامانه مودیان (TP Tax) — راهنمای کامل پیاده‌سازی احراز هویت، امضای JWS، رمزنگاری JWE، ارسال صورتحساب و استعلام وضعیت (با نمونه‌کد PHP)

ساختار کلی فرایند (خلاصه‌ٔ مراحل)

1- دریافت nonce از سرور (GET /api/v2/nonce).

2- ساختن Payload شامل nonce و clientId.

3- تولید JWS (توکن) با الگوریتم RS256؛ Header باید شامل x5c (زنجیرهٔ گواهی Base64)، sigT (زمان امضا ISO-8601 UTC)، typ: "jose", crit: ["sigT"], و cty: "text/plain". این JWS به‌عنوان توکن احراز هویت در هدر Authorization: Bearer ارسال می‌شود.

4- با توکن فوق، فراخوانی GET /api/v2/server-information برای دریافت publicKeys (کلید عمومی سرور برای رمزنگاری JWE).

5- ساخت صورتحساب JSON (header/body/payments) — امضا به‌صورت JWS (مشابه توکن) — سپس رمزنگاری JWE (alg: RSA-OAEP-256, enc: A256GCM, kid: server key id).

6- ارسال بسته JWE در بدنهٔ POST /api/v2/invoice به شکل آرایه‌ای از InvoicePacket شامل payload (رشتهٔ JWE) و header (requestTraceId و fiscalId).

7- دریافت نتیجه شامل uid و referenceNumber و در ادامه استعلام وضعیت با endpoints مناسب (/inquiry-by-uid, /inquiry-by-reference-id, یا /inquiry). 

پیش‌نیازها و کتابخانه‌ها (PHP)

  • PHP 8+

  • توابع OpenSSL (openssl_* در PHP)

  • composer packageها (مثلاً):

    • firebase/php-jwt — برای ساخت JWS (یا می‌توانید با openssl دستی بسازید).

    • phpseclib/phpseclib — برای کارهای کلید و رمزنگاری در صورت نیاز.

نمونه نصب:

composer require firebase/php-jwt phpseclib/phpseclib

بخش A — توابع کمکی (Base64URL و UUID و بارگذاری گواهی)

<?php
function base64url_encode($data) {
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

function base64url_decode($data) {
    $remainder = strlen($data) % 4;
    if ($remainder) $data .= str_repeat('=', 4 - $remainder);
    return base64_decode(strtr($data, '-_', '+/'));
}

function gen_uuid_v4() {
    $data = random_bytes(16);
    $data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
    $data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
    return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}

بخش B — دریافت Nonce

طبق سند: GET https://tp.tax.gov.ir/requestsmanager/api/v2/nonce?timeToLive=20 خروجی شامل nonce و expDate است.

function get_nonce($ttl = 20) {
    $url = "https://tp.tax.gov.ir/requestsmanager/api/v2/nonce?timeToLive=" . intval($ttl);
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    $resp = curl_exec($ch);
    if ($resp === false) throw new Exception(curl_error($ch));
    $obj = json_decode($resp, true);
    if (!$obj || !isset($obj['nonce'])) throw new Exception("Invalid nonce response");
    return $obj;
}

بخش C — ساخت JWS (برای احراز هویت): تولید JWT امضا شده با RS256 و x5c در هدر

سند بیان می‌کند header باید شامل x5c (certificate chain) و sigT باشد و امضا RS256 باشد.

در PHP دو راه هست: (1) استفاده از firebase/php-jwt و درج هدر سفارشی، یا (2) ساخت دستی بخش‌های Base64URL و امضا با openssl_sign (پیشنهاد می‌کنم برای کنترل کامل، روش دستی را ببینید).

روش دستی (برای شفافیت)

function create_jws_rs256($payload_array, $privateKeyPemPath, $certPemPath) {
    $payload = json_encode($payload_array, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

    // load cert and private key
    $cert = file_get_contents($certPemPath);
    $cert_der = pem_to_der($cert); // تابع کمکی پایین
    $cert_b64 = base64_encode($cert_der);

    $sigT = gmdate("Y-m-d\\TH:i:s\\Z"); // ISO-8601 UTC
    $header = [
        "alg" => "RS256",
        "x5c" => [$cert_b64],
        "sigT" => $sigT,
        "typ" => "jose",
        "crit" => ["sigT"],
        "cty" => "text/plain"
    ];

    $protected = base64url_encode(json_encode($header, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    $b64payload = base64url_encode($payload);
    $signingInput = $protected . "." . $b64payload;

    $privateKey = file_get_contents($privateKeyPemPath);
    $pkeyid = openssl_pkey_get_private($privateKey);
    if (!$pkeyid) throw new Exception("Invalid private key");

    $signature = '';
    $ok = openssl_sign($signingInput, $signature, $pkeyid, OPENSSL_ALGO_SHA256);
    openssl_free_key($pkeyid);
    if (!$ok) throw new Exception("Signing failed");

    $b64sig = base64url_encode($signature);
    return $protected . "." . $b64payload . "." . $b64sig;
}

function pem_to_der($pem) {
    $pem = trim($pem);
    $lines = explode("\n", $pem);
    $data = '';
    foreach ($lines as $line) {
        if (strpos($line, '-----') === 0) continue;
        $data .= trim($line);
    }
    return base64_decode($data);
}

توجه: clientId در payload باید شناسهٔ حافظهٔ مالیاتی (fiscalId / clientId) باشد (مقداری که سازمان به شما تخصیص داده)

بخش D — درخواست به سرور اطلاعات (با Authorization: Bearer )

پس از تولید JWS از بالا، باید آن را در هدر Authorization قرار دهی و اطلاعات سرور (شامل publicKeys) را دریافت کنی. مثال (curl):

$jwt = create_jws_rs256(["nonce"=>$nonceVal,"clientId"=>$clientId], "private_key.pem", "certificate.crt");

$ch = curl_init("https://tp.tax.gov.ir/requestsmanager/api/v2/server-information");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer $jwt",
    "accept: */*"
]);
$resp = curl_exec($ch);
$data = json_decode($resp, true);

پاسخ شامل serverTime و آرایه‌ای publicKeys است که هر عنصر key (Base64)، id, algorithm, purpose دارد. از این key برای رمزنگاری JWE استفاده می‌شود.

بخش E — ساخت JWS برای صورتحساب (Invoice JWS)

صورتحساب باید به‌صورت JSON (مثال قالب در سند) ساخته شود و سپس همان روش JWS برای امضای آن اعمال شود (Header مشابه اما cty ممکن است text/plain و payload = JSON صورتحساب باشد). سند نمونهٔ payload صورتحساب را ارائه داده است.

$invoiceJson = json_encode($invoiceArray, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$jws_invoice = create_jws_rs256(json_decode($invoiceJson, true), "private_key.pem", "certificate.crt");
// note: create_jws_rs256 above expects array; adapt if needed

(در عمل ممکن بخواهید تابعی که payload را از آرایه می‌گیرد مستقیماً استفاده کنید.)

بخش F — تبدیل JWS به JWE (رمزنگاری بستهٔ صورتحساب)

سند شرح می‌دهد که قالب JWE مطابق RFC7516 است و Header JWE باید چیزی شبیه به:

{"alg":"RSA-OAEP-256","enc":"A256GCM","kid":""}

و ساختار رشتهٔ JWE به صورت:

Base64URL(JWE Protected Header) || '.' ||
Base64URL(JWE Encrypted Key) || '.' ||
Base64URL(JWE Initialization Vector) || '.' ||
Base64URL(JWE Ciphertext) || '.' ||
Base64URL(JWE Authentication Tag)
``` :contentReference[oaicite:14]{index=14}

### پیاده‌سازی در PHP — گام به گام
1. تولید یک کلید متقارن CEK (Content Encryption Key) 32 بایت برای AES-256-GCM.  
2. Encrypt کردن CEK با کلید عمومی سرور (از `publicKeys[].key` که Base64 است) با RSA-OAEP-256 (RSAES-OAEP with SHA-256).  
3. تولید IV (96-bit recommended) برای AES-GCM.  
4. محاسبه `aad` = Base64URL(Protected Header).  
5. AES-256-GCM بر روی plaintext = JWS_invoice برای تولید `ciphertext` و `tag`.  
6. ساخت قطعهٔ JWE با Base64URL همهٔ قسمت‌ها.  

**مثال (تابع ساده‌شده):**

```php
function create_jwe_rsa_oaep_a256gcm($jws_plaintext, $serverPublicKeyBase64, $kid) {
    // 1. protected header
    $header = ["alg"=>"RSA-OAEP-256","enc"=>"A256GCM","kid"=>$kid];
    $protected = base64url_encode(json_encode($header, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

    // 2. generate CEK (32 bytes)
    $cek = random_bytes(32);

    // 3. encrypt CEK with server public key (RSA-OAEP with SHA-256)
    $serverPubDer = base64_decode($serverPublicKeyBase64);
    // convert DER to PEM
    $serverPubPem = "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($serverPubDer),64,"\n") . "-----END PUBLIC KEY-----\n";

    $encryptedKey = '';
    $ok = openssl_public_encrypt($cek, $encryptedKey, $serverPubPem, OPENSSL_PKCS1_OAEP_PADDING); 
    // Note: PHP's openssl_public_encrypt uses OAEP with SHA1 by default; RSA-OAEP-256 (SHA-256) is not directly supported by openssl_public_encrypt.
    // For RSA-OAEP-256 you may need ext/openssl 1.1.1+ with openssl_public_encrypt using OPENSSL_PKCS1_OAEP_PADDING and an option for SHA-256 isn't available in PHP < 8.1.
    // A robust solution uses phpseclib for RSA-OAEP-256. (See note below.)
    if (!$ok) throw new Exception("encrypt CEK failed");

    // 4. iv
    $iv = random_bytes(12); // 96-bit

    // 5. AES-256-GCM encrypt
    $ciphertext = openssl_encrypt($jws_plaintext, 'aes-256-gcm', $cek, OPENSSL_RAW_DATA, $iv, $tag, $protected);
    if ($ciphertext === false) throw new Exception("AES-GCM failed");

    return implode('.', [
        $protected,
        base64url_encode($encryptedKey),
        base64url_encode($iv),
        base64url_encode($ciphertext),
        base64url_encode($tag)
    ]);
}

هشدار مهم و نکات عملی:

  • PHP openssl_public_encrypt سنتی تنها RSA-OAEP با SHA-1 را (در نسخه‌های قدیمی) پشتیبانی می‌کند، و برای RSA-OAEP-256 نیاز به استفاده از پیاده‌سازیِ پشتیبانی‌شده (مانند phpseclib یا توابع openssl با گزینه‌های جدید) است. بنابراین در عمل از phpseclib (مثلاً phpseclib3\Crypt\RSA) استفاده کنید تا RSA-OAEP-256 صحیح انجام شود. سند تأکید می‌کند alg برابر RSA-OAEP-256 است.

بخش G — ارسال صورتحساب (POST /api/v2/invoice)

پس از ساخت JWE (رشتهٔ نهایی)، باید آن را به شکل زیر ارسال کنید (می‌توان حداکثر 1000 بسته در یک درخواست فرستاد). هر عنصر آرایه شامل:

{
  "payload": "",
  "header": {
    "requestTraceId": "",
    "fiscalId": ""
  }
}

Response شامل timestamp و result[] با uid و referenceNumber است.

نمونهٔ ارسال:

$invoicePacket = [
    "payload" => $jwe_string,
    "header" => [
        "requestTraceId" => gen_uuid_v4(),
        "fiscalId" => $fiscalId
    ]
];

$body = json_encode([$invoicePacket], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

$ch = curl_init("https://tp.tax.gov.ir/requestsmanager/api/v2/invoice");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "Authorization: Bearer $jwt", // jwt generated earlier for auth
    "Content-Type: application/json",
    "Accept: */*"
]);
$response = curl_exec($ch);
$result = json_decode($response, true);

بخش H — استعلام وضعیت صورتحساب‌ها

پس از ارسال، می‌توان وضعیت را با استفاده از inquiry-by-reference-id یا inquiry-by-uid یا inquiry (بر اساس بازه زمانی) بررسی کرد. نمونهٔ endpointها و پارامترها در سند آمده‌اند.

مثال استعلام با UID:

$url = "https://tp.tax.gov.ir/requestsmanager/api/v2/inquiry-by-uid?uidList=" . urlencode($uid) . "&fiscalId=" . urlencode($fiscalId);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $jwt"]);
$resp = curl_exec($ch);

بخش I — نکات امنیتی، عملی و مسائل رایج

  • کلیدها و گواهی‌ها: کلید خصوصی را همیشه امن نگهدار، دسترسی فایل را محدود کن (مثلاً 600). در سند تاکید شده که امضاکننده باید دارای گواهی معتبر از مراجع مورد قبول باشد.

  • امضای JWT و زمان sigT: sigT باید به فرمت ISO_8601 UTC (yyyy-MM-dd'T'HH:mm:ss'Z') باشد و در هدر JWS ارسال شود. سند این مورد را مشخص کرده است.
  • RSA-OAEP-256: برای رمزنگاری CEK با RSA-OAEP-256 از کتابخانه‌ای که صراحتاً SHA-256 OAEP را پشتیبانی کند استفاده کن (مثلاً phpseclib). استفاده از OAEP-SHA1 اشتباه خواهد بود اگر سرور صراحتاً OAEP-256 بخواهد.
  • تست مرحله‌به‌مرحله: ابتدا فقط احراز هویت (ساخت JWS و GET server-information) را تست کن، سپس امضای صورتحساب (JWS) را تست، و در آخر JWE و ارسال. این تقسیم‌بندی خطاها را کم می‌کند.

پیشنهاد می‌کنم این الگو را به‌عنوان نقطهٔ شروع استفاده کنید؛ در محیط واقعی با مدیریت خطا و لاگ دقیق، بررسی پاسخ گواهی‌ها و OCSP/CRL و مدیریت کلیدها تکمیل کنید.

توجه: به‌دلایل طول متن، کد بصورت خلاصه شده توضیح داده شده و در صورت نیاز، میتوانید با پشتیبانی بودجکس تماس بگیرید.

سخن پایانی: سعی شده با ساده ترین حالت این راهنمای پیاده سازی در بودجکس ارائه شود تا برنامه نویسان و توسعه دهندگان در پروژه های خود استفاده کنند.

همچنین در صورت نیاز به خدمات و طراحی و توسعه اپلیکیشن و نرم افزار و همکاری در پروژه های مشابه میتوانید با ما در تماس باشید.