89 lines
3.3 KiB
PHP
89 lines
3.3 KiB
PHP
<?php
|
|
|
|
/**
|
|
* At-rest encryption for seller OAuth tokens.
|
|
*
|
|
* Square access/refresh tokens let anyone holding them move money on the
|
|
* seller's behalf, so — unlike the PayPal merchant IDs we stored in plaintext —
|
|
* they must not sit readable on disk or in the database. We use AES-256-GCM
|
|
* (authenticated encryption) with a 32-byte key from config.
|
|
*
|
|
* Format of seal(): base64( iv[12] || tag[16] || ciphertext ), prefixed with
|
|
* "enc:v1:" so we can tell encrypted values from any legacy plaintext and can
|
|
* evolve the scheme later.
|
|
*/
|
|
class Secret
|
|
{
|
|
private const PREFIX = 'enc:v1:';
|
|
private const CIPHER = 'aes-256-gcm';
|
|
|
|
/**
|
|
* Resolve the 32-byte raw key from the configured 64-hex-char string.
|
|
* Returns null when no (valid) key is configured.
|
|
*/
|
|
private static function key(?string $configKey): ?string
|
|
{
|
|
if (!$configKey) return null;
|
|
$configKey = trim($configKey);
|
|
// Accept a 64-char hex string (preferred) or a raw 32-byte key.
|
|
if (preg_match('/^[0-9a-fA-F]{64}$/', $configKey)) {
|
|
return hex2bin($configKey);
|
|
}
|
|
if (strlen($configKey) === 32) {
|
|
return $configKey;
|
|
}
|
|
// Anything else is misconfigured; treat as "no key" so the caller can warn.
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Encrypt a token for storage. If no usable key is configured, returns the
|
|
* plaintext unchanged and logs a warning (so dev still works, but production
|
|
* never silently ships unencrypted tokens without a trace).
|
|
*/
|
|
public static function seal(string $plaintext, ?string $configKey): string
|
|
{
|
|
$key = self::key($configKey);
|
|
if ($key === null) {
|
|
if (function_exists('writeLog')) {
|
|
writeLog('Secret::seal WARNING — no token_encryption_key set; storing token in PLAINTEXT.');
|
|
}
|
|
return $plaintext;
|
|
}
|
|
$iv = random_bytes(12);
|
|
$tag = '';
|
|
$ct = openssl_encrypt($plaintext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag);
|
|
if ($ct === false) {
|
|
throw new RuntimeException('Secret::seal — encryption failed');
|
|
}
|
|
return self::PREFIX . base64_encode($iv . $tag . $ct);
|
|
}
|
|
|
|
/**
|
|
* Decrypt a stored token. Plaintext (un-prefixed) values are returned as-is
|
|
* for backward compatibility with anything stored before a key was set.
|
|
*/
|
|
public static function open(string $stored, ?string $configKey): string
|
|
{
|
|
if (strncmp($stored, self::PREFIX, strlen(self::PREFIX)) !== 0) {
|
|
return $stored; // legacy / plaintext
|
|
}
|
|
$key = self::key($configKey);
|
|
if ($key === null) {
|
|
throw new RuntimeException('Secret::open — value is encrypted but no key is configured');
|
|
}
|
|
$raw = base64_decode(substr($stored, strlen(self::PREFIX)), true);
|
|
if ($raw === false || strlen($raw) < 28) {
|
|
throw new RuntimeException('Secret::open — malformed ciphertext');
|
|
}
|
|
$iv = substr($raw, 0, 12);
|
|
$tag = substr($raw, 12, 16);
|
|
$ct = substr($raw, 28);
|
|
$pt = openssl_decrypt($ct, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag);
|
|
if ($pt === false) {
|
|
throw new RuntimeException('Secret::open — decryption/auth failed (wrong key or tampered data)');
|
|
}
|
|
return $pt;
|
|
}
|
|
}
|