Files
HotLunches/square/lib/Secret.php
T
2026-10-19 15:22:52 -07:00

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;
}
}