HardТеория11 min

Шифрование

OpenSSL, Sodium, симметричное и асимметричное шифрование, цифровые подписи

Шифрование -- это обратимое преобразование данных для обеспечения конфиденциальности. В отличие от хеширования, зашифрованные данные можно расшифровать обратно при наличии ключа. PHP предоставляет два основных расширения: OpenSSL (широкий набор алгоритмов) и Sodium (современный, безопасный по умолчанию).

Симметричное vs асимметричное шифрование

Симметричное шифрование:
  Один ключ для шифрования и расшифровки
  [Данные] --[ключ]--> [Шифр] --[тот же ключ]--> [Данные]
  Примеры: AES-256-CBC, AES-256-GCM, XSalsa20-Poly1305

Асимметричное шифрование:
  Пара ключей: публичный (шифрует) + приватный (расшифровывает)
  [Данные] --[публичный ключ]--> [Шифр] --[приватный ключ]--> [Данные]
  Примеры: RSA, X25519, Ed25519

Гибридное шифрование (на практике):
  1. Генерируем случайный симметричный ключ (session key)
  2. Шифруем данные симметричным ключом (быстро)
  3. Шифруем симметричный ключ публичным ключом получателя
  4. Отправляем: зашифрованные данные + зашифрованный ключ

Генерация случайных данных (CSPRNG)

<?php
declare(strict_types=1);

// random_bytes() — cryptographically secure random bytes (PHP 7.0+)
$key = random_bytes(32);     // 256-bit key
$iv = random_bytes(16);      // 128-bit IV
$nonce = random_bytes(24);   // 192-bit nonce

// random_int() — cryptographically secure random integer
$otp = random_int(100000, 999999); // 6-digit OTP code

// Random\Randomizer class (PHP 8.2+) — OOP interface
$randomizer = new Random\Randomizer(new Random\Engine\Secure());

$bytes = $randomizer->getBytes(32);
$int = $randomizer->getInt(1, 100);
$shuffled = $randomizer->shuffleString('abcdefgh');

// Generate a random string from specific characters
$randomizer = new Random\Randomizer();
$token = $randomizer->getBytesFromString(
    'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
    32
); // PHP 8.3+

// ❌ NEVER use for cryptography:
// rand(), mt_rand(), array_rand(), uniqid(), microtime()
// These are NOT cryptographically secure!

OpenSSL -- симметричное шифрование

AES-256-CBC

<?php
declare(strict_types=1);

// AES-256-CBC — classic symmetric encryption
// CBC mode requires IV and padding, does NOT provide authentication

$plaintext = 'Sensitive user data';
$key = random_bytes(32); // 256-bit key for AES-256

// Get required IV length for the cipher
$ivLength = openssl_cipher_iv_length('aes-256-cbc');
$iv = openssl_random_pseudo_bytes($ivLength); // 16 bytes for AES

// Encrypt
$ciphertext = openssl_encrypt(
    data: $plaintext,
    cipher_algo: 'aes-256-cbc',
    passphrase: $key,
    options: OPENSSL_RAW_DATA, // Return raw bytes, not base64
    iv: $iv,
);

// Decrypt
$decrypted = openssl_decrypt(
    data: $ciphertext,
    cipher_algo: 'aes-256-cbc',
    passphrase: $key,
    options: OPENSSL_RAW_DATA,
    iv: $iv,
);

echo $decrypted; // 'Sensitive user data'

// ⚠️ CBC mode is NOT authenticated — use GCM instead!
// An attacker can modify ciphertext without detection (padding oracle attack)

// For storage/transmission: combine IV + ciphertext
$stored = base64_encode($iv . $ciphertext);

// For retrieval: split IV and ciphertext
$decoded = base64_decode($stored);
$iv = substr($decoded, 0, $ivLength);
$ciphertext = substr($decoded, $ivLength);

AES-256-GCM (рекомендуется)

<?php
declare(strict_types=1);

// AES-256-GCM — Authenticated Encryption with Associated Data (AEAD)
// Provides both confidentiality AND integrity/authenticity

$plaintext = 'Credit card: 4111-1111-1111-1111';
$key = random_bytes(32);

$ivLength = openssl_cipher_iv_length('aes-256-gcm');
$iv = random_bytes($ivLength); // 12 bytes for GCM

// Additional Authenticated Data — integrity-protected but not encrypted
$aad = 'user_id=42;context=payment';

// Encrypt — $tag will be populated with authentication tag
$tag = '';
$ciphertext = openssl_encrypt(
    data: $plaintext,
    cipher_algo: 'aes-256-gcm',
    passphrase: $key,
    options: OPENSSL_RAW_DATA,
    iv: $iv,
    tag: $tag,          // Output: authentication tag (16 bytes by default)
    aad: $aad,
    tag_length: 16,     // Tag length in bytes
);

// Decrypt — must provide the same IV, tag, and AAD
$decrypted = openssl_decrypt(
    data: $ciphertext,
    cipher_algo: 'aes-256-gcm',
    passphrase: $key,
    options: OPENSSL_RAW_DATA,
    iv: $iv,
    tag: $tag,
    aad: $aad,
);

if ($decrypted === false) {
    throw new RuntimeException('Decryption failed — data tampered or wrong key');
}

echo $decrypted; // 'Credit card: 4111-1111-1111-1111'

// For storage: IV + tag + ciphertext
function encryptAesGcm(string $plaintext, string $key, string $aad = ''): string
{
    $iv = random_bytes(12);
    $tag = '';

    $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, $aad, 16);

    if ($ciphertext === false) {
        throw new RuntimeException('Encryption failed: ' . openssl_error_string());
    }

    // Format: IV (12) + Tag (16) + Ciphertext
    return $iv . $tag . $ciphertext;
}

function decryptAesGcm(string $encrypted, string $key, string $aad = ''): string
{
    $iv = substr($encrypted, 0, 12);
    $tag = substr($encrypted, 12, 16);
    $ciphertext = substr($encrypted, 28);

    $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, $aad);

    if ($plaintext === false) {
        throw new RuntimeException('Decryption failed — data corrupted or wrong key');
    }

    return $plaintext;
}

// Available ciphers
$ciphers = openssl_get_cipher_methods();
// Filter only unique (remove uppercase duplicates)
$ciphers = array_filter($ciphers, fn(string $c) => $c === strtolower($c));

OpenSSL -- асимметричное шифрование (RSA)

<?php
declare(strict_types=1);

// Generate RSA key pair
$config = [
    'private_key_bits' => 4096,      // Key size (minimum 2048!)
    'private_key_type' => OPENSSL_KEYTYPE_RSA,
];

$keyPair = openssl_pkey_new($config);

// Extract private key as PEM string
openssl_pkey_export($keyPair, $privateKeyPem);
echo $privateKeyPem;

// Extract public key
$details = openssl_pkey_get_details($keyPair);
$publicKeyPem = $details['key'];

// Save keys to files
file_put_contents('/secure/path/private.pem', $privateKeyPem);
file_put_contents('/secure/path/public.pem', $publicKeyPem);

// With passphrase protection for private key
openssl_pkey_export($keyPair, $protectedPrivateKey, 'my-passphrase', [
    'encrypt_key_cipher' => OPENSSL_CIPHER_AES_256_CBC,
]);

// Encrypt with public key (anyone can encrypt)
$plaintext = 'Secret message';
openssl_public_encrypt($plaintext, $encrypted, $publicKeyPem);

// Decrypt with private key (only owner can decrypt)
openssl_private_decrypt($encrypted, $decrypted, $privateKeyPem);
echo $decrypted; // 'Secret message'

// ⚠️ RSA can only encrypt data smaller than key size minus padding
// For 4096-bit key with OAEP padding: max ~446 bytes
// For larger data, use hybrid encryption!

// Encrypt with private key (for signing, not confidentiality)
openssl_private_encrypt($plaintext, $signed, $privateKeyPem);
openssl_public_decrypt($signed, $verified, $publicKeyPem);

// Load existing keys from files
$privateKey = openssl_pkey_get_private(
    file_get_contents('/secure/path/private.pem'),
    'passphrase-if-protected'
);
$publicKey = openssl_pkey_get_public(file_get_contents('/secure/path/public.pem'));

OpenSSL -- цифровые подписи

<?php
declare(strict_types=1);

// Digital signatures: prove authenticity and integrity
// Sign with private key, verify with public key

$data = 'Document content to be signed';

// Generate key pair
$keyPair = openssl_pkey_new(['private_key_bits' => 2048]);
openssl_pkey_export($keyPair, $privateKey);
$publicKey = openssl_pkey_get_details($keyPair)['key'];

// Sign data
openssl_sign($data, $signature, $privateKey, OPENSSL_ALGO_SHA256);

// Verify signature (returns 1 = valid, 0 = invalid, false = error)
$result = openssl_verify($data, $signature, $publicKey, OPENSSL_ALGO_SHA256);

match ($result) {
    1     => echo "Signature VALID\n",
    0     => echo "Signature INVALID\n",
    false => echo "Error: " . openssl_error_string() . "\n",
};

// Sign a file
$fileData = file_get_contents('/path/to/document.pdf');
openssl_sign($fileData, $fileSignature, $privateKey, OPENSSL_ALGO_SHA256);
$signatureBase64 = base64_encode($fileSignature);
// Save/transmit $signatureBase64 alongside the file

OpenSSL -- сертификаты

<?php
declare(strict_types=1);

// Create a Certificate Signing Request (CSR)
$dn = [
    'countryName'            => 'US',
    'stateOrProvinceName'    => 'California',
    'localityName'           => 'San Francisco',
    'organizationName'       => 'My Company',
    'organizationalUnitName' => 'Engineering',
    'commonName'             => 'example.com',
    'emailAddress'           => '[email protected]',
];

$privateKey = openssl_pkey_new(['private_key_bits' => 2048]);
$csr = openssl_csr_new($dn, $privateKey);

// Export CSR to PEM
openssl_csr_export($csr, $csrPem);

// Self-sign certificate (for development)
$cert = openssl_csr_sign($csr, null, $privateKey, 365);
openssl_x509_export($cert, $certPem);

// Parse certificate
$certInfo = openssl_x509_parse($certPem);
echo $certInfo['subject']['CN']; // 'example.com'
echo $certInfo['validFrom_time_t']; // Unix timestamp
echo $certInfo['validTo_time_t'];   // Unix timestamp

Sodium -- симметричное шифрование (рекомендуется)

Sodium (libsodium) -- это современная криптографическая библиотека, встроенная в PHP с версии 7.2. Она спроектирована так, чтобы было сложно допустить ошибку.

<?php
declare(strict_types=1);

// === Symmetric encryption: XSalsa20-Poly1305 (secretbox) ===

$message = 'Top secret message';

// Generate key (32 bytes)
$key = sodium_crypto_secretbox_keygen();
// Or: $key = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); // 32

// Generate nonce (24 bytes) — must be unique per message!
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // 24

// Encrypt (automatically authenticated!)
$ciphertext = sodium_crypto_secretbox($message, $nonce, $key);

// Decrypt
$decrypted = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);

if ($decrypted === false) {
    throw new RuntimeException('Decryption failed — tampered or wrong key');
}

echo $decrypted; // 'Top secret message'

// ⚠️ ALWAYS wipe sensitive data from memory!
sodium_memzero($key);
sodium_memzero($decrypted);

// Helper for storage: nonce + ciphertext
function sodiumEncrypt(string $plaintext, string $key): string
{
    $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
    $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $key);

    // Prepend nonce to ciphertext
    return $nonce . $ciphertext;
}

function sodiumDecrypt(string $encrypted, string $key): string
{
    $nonceSize = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;

    $nonce = substr($encrypted, 0, $nonceSize);
    $ciphertext = substr($encrypted, $nonceSize);

    $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);

    if ($plaintext === false) {
        throw new RuntimeException('Decryption failed');
    }

    return $plaintext;
}

AEAD -- XChaCha20-Poly1305

<?php
declare(strict_types=1);

// AEAD — Authenticated Encryption with Associated Data
// XChaCha20-Poly1305-IETF — recommended for new projects

$message = 'Payment data';
$additionalData = 'user_id:42'; // Authenticated but not encrypted

$key = sodium_crypto_aead_xchacha20poly1305_ietf_keygen();
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); // 24 bytes

// Encrypt with AAD
$ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt(
    $message,
    $additionalData,
    $nonce,
    $key,
);

// Decrypt with AAD (must match exactly)
$decrypted = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
    $ciphertext,
    $additionalData,
    $nonce,
    $key,
);

if ($decrypted === false) {
    throw new RuntimeException('Decryption failed — AAD mismatch, tampered, or wrong key');
}

sodium_memzero($key);

Sodium -- асимметричное шифрование

<?php
declare(strict_types=1);

// === Asymmetric encryption: X25519-XSalsa20-Poly1305 (crypto_box) ===

// Generate key pairs for Alice and Bob
$aliceKeypair = sodium_crypto_box_keypair();
$alicePublicKey = sodium_crypto_box_publickey($aliceKeypair);
$aliceSecretKey = sodium_crypto_box_secretkey($aliceKeypair);

$bobKeypair = sodium_crypto_box_keypair();
$bobPublicKey = sodium_crypto_box_publickey($bobKeypair);
$bobSecretKey = sodium_crypto_box_secretkey($bobKeypair);

// Alice sends message to Bob
$message = 'Hello Bob, this is Alice!';
$nonce = random_bytes(SODIUM_CRYPTO_BOX_NONCEBYTES); // 24 bytes

// Create combined keypair: Alice's secret + Bob's public
$encryptionKeypair = sodium_crypto_box_keypair_from_secretkey_and_publickey(
    $aliceSecretKey,
    $bobPublicKey,
);

$ciphertext = sodium_crypto_box($message, $nonce, $encryptionKeypair);

// Bob decrypts using: Bob's secret + Alice's public
$decryptionKeypair = sodium_crypto_box_keypair_from_secretkey_and_publickey(
    $bobSecretKey,
    $alicePublicKey,
);

$decrypted = sodium_crypto_box_open($ciphertext, $nonce, $decryptionKeypair);

echo $decrypted; // 'Hello Bob, this is Alice!'

// Anonymous encryption (sealed box) — sender is anonymous
// Only recipient's public key is needed
$sealed = sodium_crypto_box_seal($message, $bobPublicKey);

// Bob decrypts with his full keypair
$opened = sodium_crypto_box_seal_open($sealed, $bobKeypair);
echo $opened; // 'Hello Bob, this is Alice!'

// Clean up sensitive keys
sodium_memzero($aliceSecretKey);
sodium_memzero($bobSecretKey);

Sodium -- цифровые подписи (Ed25519)

<?php
declare(strict_types=1);

// Ed25519 signatures — fast and secure

// Generate signing key pair
$signingKeypair = sodium_crypto_sign_keypair();
$signingPublicKey = sodium_crypto_sign_publickey($signingKeypair);
$signingSecretKey = sodium_crypto_sign_secretkey($signingKeypair);

$message = 'This document is authentic';

// Sign (combined mode — signature prepended to message)
$signedMessage = sodium_crypto_sign($message, $signingSecretKey);

// Verify and extract original message
$originalMessage = sodium_crypto_sign_open($signedMessage, $signingPublicKey);

if ($originalMessage === false) {
    throw new RuntimeException('Invalid signature!');
}

echo $originalMessage; // 'This document is authentic'

// Detached signature (signature separate from message)
$signature = sodium_crypto_sign_detached($message, $signingSecretKey);

// Verify detached signature
$valid = sodium_crypto_sign_verify_detached($signature, $message, $signingPublicKey);

if (!$valid) {
    throw new RuntimeException('Signature verification failed!');
}

// Key conversion: Ed25519 → X25519 (signing → encryption)
$encryptionPublicKey = sodium_crypto_sign_ed25519_pk_to_curve25519($signingPublicKey);
$encryptionSecretKey = sodium_crypto_sign_ed25519_sk_to_curve25519($signingSecretKey);

sodium_memzero($signingSecretKey);

Sodium -- безопасная работа с памятью

<?php
declare(strict_types=1);

// Wipe sensitive data from memory
$key = sodium_crypto_secretbox_keygen();
// ... use the key ...
sodium_memzero($key); // Overwrite memory with zeros
// $key is now empty string

// Safe encoding/decoding (no side-channel leaks)
$binary = random_bytes(32);

// Constant-time hex encoding (safe against timing attacks)
$hex = sodium_bin2hex($binary);
$back = sodium_hex2bin($hex);

// Constant-time base64 encoding
$b64 = sodium_bin2base64($binary, SODIUM_BASE64_VARIANT_ORIGINAL);
$back = sodium_base64_todec($b64, SODIUM_BASE64_VARIANT_ORIGINAL);

// URL-safe base64
$urlSafe = sodium_bin2base64($binary, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);

// Constant-time comparison
$equal = sodium_compare($str1, $str2); // Returns -1, 0, or 1
// For equality: sodium_memcmp($a, $b) returns 0 if equal

// Increment a nonce (for counter-based nonces)
$nonce = random_bytes(24);
sodium_increment($nonce); // Safely increments as big-endian number

Sodium -- хеширование паролей (Argon2)

<?php
declare(strict_types=1);

// Sodium provides its own password hashing API

$password = 'user-password-here';

// Hash password with Argon2id
$hash = sodium_crypto_pwhash_str(
    $password,
    SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, // CPU cost
    SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,  // Memory cost (64 MB)
);

// Verify password
if (sodium_crypto_pwhash_str_verify($hash, $password)) {
    echo "Password valid!\n";
}

// Check if rehash needed
if (sodium_crypto_pwhash_str_needs_rehash(
    $hash,
    SODIUM_CRYPTO_PWHASH_OPSLIMIT_MODERATE,
    SODIUM_CRYPTO_PWHASH_MEMLIMIT_MODERATE,
)) {
    // Rehash with stronger parameters
    $newHash = sodium_crypto_pwhash_str(
        $password,
        SODIUM_CRYPTO_PWHASH_OPSLIMIT_MODERATE,
        SODIUM_CRYPTO_PWHASH_MEMLIMIT_MODERATE,
    );
}

// Derive encryption key from password
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES); // 16 bytes
$key = sodium_crypto_pwhash(
    SODIUM_CRYPTO_SECRETBOX_KEYBYTES, // Output length (32)
    $password,
    $salt,
    SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
    SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,
    SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13,
);

sodium_memzero($password);

Практический пример: шифрование пользовательских данных

<?php
declare(strict_types=1);

final class DataEncryptor
{
    private string $key;

    public function __construct(string $hexKey)
    {
        $this->key = sodium_hex2bin($hexKey);

        if (strlen($this->key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
            throw new InvalidArgumentException('Key must be 32 bytes');
        }
    }

    public function encrypt(string $plaintext): string
    {
        $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
        $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->key);

        return sodium_bin2base64(
            $nonce . $ciphertext,
            SODIUM_BASE64_VARIANT_ORIGINAL,
        );
    }

    public function decrypt(string $encoded): string
    {
        $decoded = sodium_base2bin(
            $encoded,
            SODIUM_BASE64_VARIANT_ORIGINAL,
        );

        $nonceSize = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
        $nonce = substr($decoded, 0, $nonceSize);
        $ciphertext = substr($decoded, $nonceSize);

        $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key);

        if ($plaintext === false) {
            throw new RuntimeException('Decryption failed');
        }

        return $plaintext;
    }

    public function __destruct()
    {
        sodium_memzero($this->key);
    }
}

// Usage
$key = sodium_bin2hex(sodium_crypto_secretbox_keygen());
// Store $key in environment variable, NOT in code!

$encryptor = new DataEncryptor($key);
$encrypted = $encryptor->encrypt('SSN: 123-45-6789');
$decrypted = $encryptor->decrypt($encrypted);

Практический пример: шифрование файлов

<?php
declare(strict_types=1);

function encryptFile(string $inputPath, string $outputPath, string $key): void
{
    $plaintext = file_get_contents($inputPath);

    if ($plaintext === false) {
        throw new RuntimeException("Cannot read file: $inputPath");
    }

    $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
    $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $key);

    // Write nonce + ciphertext
    file_put_contents($outputPath, $nonce . $ciphertext);

    sodium_memzero($plaintext);
}

function decryptFile(string $inputPath, string $outputPath, string $key): void
{
    $data = file_get_contents($inputPath);

    if ($data === false) {
        throw new RuntimeException("Cannot read file: $inputPath");
    }

    $nonceSize = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
    $nonce = substr($data, 0, $nonceSize);
    $ciphertext = substr($data, $nonceSize);

    $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);

    if ($plaintext === false) {
        throw new RuntimeException('File decryption failed');
    }

    file_put_contents($outputPath, $plaintext);
    sodium_memzero($plaintext);
}

// Usage
$key = sodium_crypto_secretbox_keygen();
encryptFile('/data/sensitive.csv', '/data/sensitive.csv.enc', $key);
decryptFile('/data/sensitive.csv.enc', '/data/sensitive.csv', $key);
sodium_memzero($key);

Best practices

1. Используйте Sodium вместо OpenSSL для новых проектов
2. ВСЕГДА используйте authenticated encryption (GCM, Poly1305)
3. НИКОГДА не используйте ECB режим — он не скрывает паттерны данных
4. IV/nonce должен быть УНИКАЛЬНЫМ для каждого сообщения
5. Храните ключи в env variables или key management service, НЕ в коде
6. Очищайте ключи из памяти после использования (sodium_memzero)
7. Используйте random_bytes() для генерации ключей и IV
8. RSA: минимум 2048 бит, рекомендуется 4096
9. Для паролей: НИКОГДА не шифруйте — только хешируйте (password_hash)
10. Регулярно ротируйте ключи шифрования

Тесты

Проверь себя

5 из 12

Почему ECB режим шифрования небезопасен?

Почему нельзя использовать rand() для генерации криптографических ключей?

Какая константа в Sodium определяет длину nonce для secretbox?

Зачем нужен IV/nonce при симметричном шифровании?

Какой режим AES обеспечивает authenticated encryption?