D
Engineering

RSA Handshake

Step-by-step RSA 2048-bit handshake antara dazo-whitelist (client) dan dazo-whitelist-api (server) — generate signature via openssl_sign, verify via openssl_verify di ApplicationController::token, issue Passport token (2 jam TTL), auto-refresh 401 retry loop via MyApi.

Handshake antara dazo-whitelist (client) dan dazo-whitelist-api (server) memakai RSA 2048-bit signature dengan SHA-256. Signature diverifikasi di ApplicationController::token (server), bukan di middleware. Setelah verifikasi sukses, server issue Passport Personal Access Token (2 jam TTL) dan client menyimpannya di tabel configs.

Prasyarat

  • storage/rsa_keys/private_key.pem dan public_key.pem ada di dazo-whitelist
  • Record di tabel applications (dazo-whitelist-api) berisi id, secret, private_key, public_key yang cocok
  • .env dazo-whitelist berisi API_KEY, API_SECRET, API_PRIVATE, API_PUBLIC (otomatis terisi via app:register-client)

Flow handshake lengkap

text
┌────────────────────────────────────────────────────────────────────┐
│                     1. PERSIAPAN (sekali saat setup)                 │
└────────────────────────────────────────────────────────────────────┘

dazo-whitelist (client)                  dazo-whitelist-api (server)
──────────────────────                   ─────────────────────────
php artisan app:register-client
  │
  ├─ Generate RSA 2048-bit key pair
  │   openssl_pkey_new(["private_key_bits" => 2048])
  │   openssl_pkey_export($res, $privateKey)
  │   $publicKey = openssl_pkey_get_details($res)["key"]
  │
  ├─ Simpan ke storage/rsa_keys/
  │   private_key.pem
  │   public_key.pem
  │
  ├─ Create record Application (via HTTP ke API engine atau langsung DB)
  │   id         = Str::uuid()
  │   secret     = bin2hex(openssl_random_pseudo_bytes(15))
  │   private_key = <isi private_key.pem>
  │   public_key  = <isi public_key.pem>
  │
  └─ Update .env dazo-whitelist
      API_KEY    = Application::id
      API_SECRET = Application::secret
      API_PRIVATE= Application::private_key
      API_PUBLIC = <isi public_key.pem>


┌────────────────────────────────────────────────────────────────────┐
│                  2. HANDSHAKE (saat token 401/expired)               │
└────────────────────────────────────────────────────────────────────┘

MyApi::get_token()                       ApplicationController::token()
─────────────────────                    ──────────────────────────────
1. Baca config:
   $client_key    = config('app.api_key')         // .env API_KEY
   $client_secret = config('app.api_secret')      // .env API_SECRET
   $private_key   = config('app.api_private')     // .env API_PRIVATE

2. Cek lengkap:
   if (!$client_key || !$client_secret || !$private_key)
       return ['error' => 'Invalid credential'];

3. Generate timestamp ISO8601:
   $timestamp = now()->format('Y-m-d\TH:i:sP')
   // e.g. "2026-08-21T10:26:52+07:00"

4. Load private key dari filesystem:
   $privateKeyPath = storage_path('rsa_keys/private_key.pem')
   if (!File::exists($privateKeyPath))
       return ['error' => 'Key tidak ditemukan!'];

5. Build payload (PENTING — urutan & elemen):
   $data = implode('|', [
       $client_key,     // elemen 1: id (UUID)
       $private_key,    // elemen 2: private_key string (BUKAN public_key!)
       $client_secret,  // elemen 3: secret
       $timestamp,      // elemen 4: ISO8601 timestamp
   ]);

6. Sign dengan RSA + SHA-256:
   openssl_sign($data, $signature, File::get($privateKeyPath),
                OPENSSL_ALGO_SHA256);
   $signature_b64 = base64_encode($signature);

7. POST /api/application/token  ─────────────►  8. Receive request
   Headers:                                          $data = [
     x-client-key: $client_key                         'x-client-key' => header('x-client-key'),
     x-timestamp: $timestamp                           'x-signature'  => header('x-signature'),
     x-signature: $signature_b64                       'x-timestamp'  => header('x-timestamp'),
                                                     ];
                                                   ─────────────────────────
                                                   9. Validate input:
                                                      'x-client-key'   => 'required|uuid'
                                                      'x-signature'    => 'required'
                                                      'x-timestamp'    => 'required|date_format:Y-m-d\TH:i:sP'

                                                   10. Parse & cek timestamp:
                                                       $timestamp = Carbon::createFromFormat('Y-m-d\TH:i:sP', ...)
                                                       if (!$timestamp->between(now-10min, now))
                                                           return 400 'Invalid Timestamp'

                                                   11. Lookup Application:
                                                       $e = Application::where('id', $x-client-key)->first()
                                                       if (!$e || !$e->public_key)
                                                           return 404 'Invalid app'

                                                   12. Rebuild payload (server-side):
                                                       $sign = implode('|', [
                                                           $e->id,           // dari DB
                                                           $e->private_key,  // dari DB
                                                           $e->secret,       // dari DB
                                                           $data['x-timestamp']
                                                       ]);

                                                   13. Verify signature:
                                                       $verified = openssl_verify(
                                                           $sign,
                                                           base64_decode($x-signature),
                                                           $e->public_key,
                                                           OPENSSL_ALGO_SHA256
                                                       );
                                                       if ($verified !== 1)
                                                           return 404 'Invalid Signature'

                                                   14. Issue Passport token:
                                                       Passport::personalAccessTokensExpireIn(now()->addHours(2));
                                                       $e->tokens()->delete();  // revoke old
                                                       $token = $e->createToken('...');

                ◄──────────────────────────────  15. Return:
                                                   &#123; "data": &#123; "access_token": "..." &#125; &#125;

16. Simpan token ke tabel configs:
    Config::updateOrCreate(
        ['name' => 'api_token'],
        ['value' => $token]
    );

17. Return:
    ['type' => 'Bearer', 'token' => $token]

Payload signature (KRITIS)

Payload signature harus identik antara client & server:

php
// Client (MyApi::get_token)
$data = implode('|', [$client_key, $private_key, $client_secret, $timestamp]);

// Server (ApplicationController::token)
$sign = implode('|', [$e->id, $e->private_key, $e->secret, $data['x-timestamp']]);
PosisiClient sideServer sideSumber aktual
1$client_key$e->idApplication.id (UUID)
2$private_key$e->private_keyApplication.private_key — sama dengan API_PRIVATE di .env client
3$client_secret$e->secretApplication.secret — sama dengan API_SECRET di .env client
4$timestamp$data['x-timestamp']Header request x-timestamp

Verifikasi di controller, bukan middleware

LayerClassFungsi
Middleware auth:apiLaravel Passport defaultValidasi Bearer token di header Authorization
Middleware myapiapp/Http/Middleware/MyApi.phpCek header public-key == auth()->user()->public_key — equality check sederhana
Controller ApplicationController::tokenapp/Http/Controllers/ApplicationController.php:62-110RSA verify + timestamp check + issue Passport token
Controller ApplicationController::checkline 18-60RSA verify untuk endpoint info aplikasi

Timestamp toleransi

Server menerima request dengan timestamp dalam rentang:

php
$timestamp->between($now->copy()->subMinute(10), $now)
  • Toleransi: 10 menit ke belakang (tidak menerima timestamp di masa depan)
  • Tujuan: mencegah replay attack — signature yang di-intercept tidak bisa dipakai ulang setelah 10 menit
  • Format: ISO8601 dengan timezone — Y-m-d\TH:i:sP (e.g. 2026-08-21T10:26:52+07:00)

Passport token lifecycle

AspekKeterangan
Grant typePersonal Access Token (bukan password grant, bukan client_credentials)
TTL2 jam (Passport::personalAccessTokensExpireIn(now()->addHours(2)))
RevocationToken lama dihapus sebelum issue baru: $e->tokens()->delete()
Storage clientTabel configs (kolom api_token) di dazo-whitelist
Storage serverTabel oauth_access_tokens (default Passport) di dazo-whitelist-api

401 auto-refresh retry loop

MyApi::request() menangkap response 401 dan otomatis refresh + retry sekali:

php
static function request($path, $params, $method = 'GET', $loop = 0) &#123;
    // ... kirim request ...

    // Catch 401 — hanya refresh sekali ($loop === 0)
    if ($response->getStatusCode() == 401 && !$loop && isset($params['headers']['Authorization'])) &#123;
        $token = static::get_token();           // handshake ulang
        if ($token['error'] ?? null) return $token;
        $params['headers']['Authorization'] = 'Bearer ' . $token['token'];
        return static::request($path, $params, $method, 1);   // retry dengan $loop=1
    &#125;
&#125;

Juga menangkap RequestException dengan code 401:

php
&#125; catch (RequestException $e) &#123;
    if ($e->hasResponse()) &#123;
        $code = $e->getCode();
        if (!$loop && $code == 401 && isset($params['headers']['Authorization'])) &#123;
            $token = static::get_token();
            if ($token['error'] ?? null) return $token;
            $params['headers']['Authorization'] = 'Bearer ' . $token['token'];
            $responseRetry = static::request($path, $params, $method, 1);
            return $responseRetry;
        &#125;
    &#125;
&#125;

Header public-key (di request normal)

Setelah handshake, setiap request normal memakai MyApi::params():

php
static function params($params) &#123;
    $params['headers']['Authorization'] = 'Bearer ' . Config::get_val('api_token');
    $params['headers']['public-key']    = config('app.api_public');
    $params['headers']['Accept']        = 'application/json';
    return $params;
&#125;
HeaderNilaiDivalidasi oleh
AuthorizationBearer &#123;token&#125; dari tabel configsMiddleware auth:api (Passport)
public-keyAPI_PUBLIC dari .envMiddleware myapi — cek auth()->user()->public_key == $header
Acceptapplication/json—

Regenerate RSA keys

Jika private key bocor atau perlu rotasi:

bash
# Di dazo-whitelist
php artisan rsa_key                    # generate key pair baru
php artisan app:register-client --name=konco-main  # re-register ke API engine

Troubleshooting

GejalaPenyebabSolusi
401 Unauthorized di semua request ke API enginePublic key di tabel applications tidak cocok private_key.pemphp artisan app:register-client
Invalid TimestampClock drift antara server client & server API engine > 10 menitSinkronkan NTP kedua server
Invalid SignaturePayload client tidak identik server (e.g. .env tidak ter-update saat register-client)Cek .env API_KEY/API_SECRET/API_PRIVATE cocok record applications
Key tidak ditemukan!storage/rsa_keys/private_key.pem belum adaphp artisan rsa_key atau app:register-client
Loop 401 terus-menerusApplication record tidak punya public_keyphp artisan app:register-client
404 Invalid appAPI_KEY di .env tidak cocok record applications di API engineapp:register-client atau update .env manual

Langkah berikutnya