key = $this->keyFromMaterial($material); $this->information = new TechnicalInformation(); } public function encrypt(string $plaintext): string { if ($plaintext === '') throw new InvalidArgumentException('Credential secret must not be empty.'); $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); $ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, self::ASSOCIATED_DATA, $nonce, $this->key); return self::VERSION . '.' . $this->base64UrlEncode($nonce . $ciphertext); } public function decrypt(string $encoded): string { try { if (!str_starts_with($encoded, self::VERSION . '.')) throw new RuntimeException(); $binary = $this->base64UrlDecode(substr($encoded, strlen(self::VERSION) + 1)); $nonceLength = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES; if (strlen($binary) <= $nonceLength + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES) throw new RuntimeException(); $plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(substr($binary, $nonceLength), self::ASSOCIATED_DATA, substr($binary, 0, $nonceLength), $this->key); if ($plaintext === false) throw new RuntimeException(); return $plaintext; } catch (\Throwable) { throw new RuntimeException('Unable to decrypt credential.'); } } public function mask(string $secret): string { return '••••••••••••••••••••'; } /** @return array */ public function display(array $credential): array { $safe = $this->projectMetadata($credential); if (array_key_exists('secret', $credential) || array_key_exists('secret_ciphertext', $credential)) $safe['secret'] = $this->mask(''); return $safe; } /** @return array */ public function toDisplay(array $credential): array { return $this->display($credential); } /** @return array */ public function encryptCredential(array $credential): array { $validation = $this->information->validate($credential); if (!$validation['valid']) throw new InvalidArgumentException('Invalid credential metadata: ' . implode(' ', $validation['errors'])); $secret = $credential['secret'] ?? null; if (!is_string($secret) || $secret === '') throw new InvalidArgumentException('Credential secret must be a non-empty string.'); $stored = $this->projectMetadata(array_key_exists('id', $credential) ? [...$validation, 'id' => $credential['id']] : $validation); $stored['secret_ciphertext'] = $this->encrypt($secret); return $stored; } /** @return array */ public function decryptCredential(array $credential): array { if (!isset($credential['secret_ciphertext']) || !is_string($credential['secret_ciphertext'])) { throw new InvalidArgumentException('Encrypted credential secret is missing.'); } return [...$this->projectMetadata($credential), 'secret' => $this->decrypt($credential['secret_ciphertext'])]; } /** @return array */ public function projectMetadata(array $credential): array { $normalized = $this->information->normalize($credential); $safe = $this->information->display($normalized); if (array_key_exists('id', $credential)) $safe = ['id' => $credential['id'], ...$safe]; return $safe; } private function keyFromMaterial(string $material): string { if (str_starts_with($material, 'base64:')) { $decoded = base64_decode(substr($material, 7), true); if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) { throw new RuntimeException('APP_KEY base64 material must decode to exactly 32 bytes.'); } return $decoded; } $unprefixedBase64 = base64_decode($material, true); if ($unprefixedBase64 !== false && strlen($unprefixedBase64) === SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES && preg_match('/^[A-Za-z0-9+\/=]+$/', $material) === 1) { return $unprefixedBase64; } if (preg_match('/^[a-f0-9]{64}$/i', $material) === 1) return hex2bin($material); if (strlen($material) < 32) throw new RuntimeException('APP_KEY material must be at least 32 bytes.'); return hash('sha256', $material, true); } private function base64UrlEncode(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); } private function base64UrlDecode(string $value): string { if ($value === '' || preg_match('/^[A-Za-z0-9_-]+$/', $value) !== 1) throw new RuntimeException(); $decoded = base64_decode(strtr($value, '-_', '+/') . str_repeat('=', (4 - strlen($value) % 4) % 4), true); if ($decoded === false) throw new RuntimeException(); return $decoded; } }