.". The associated * data binds a ciphertext to its owning row (e.g. "connection::"), * so copying ciphertext between rows makes decryption fail. */ final class Vault { private const PREFIX = 'aw1'; public function __construct(private KeyRing $keys) { } public function encrypt(string $plaintext, string $context): string { $version = $this->keys->currentVersion(); $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); $ct = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, $this->aad($version, $context), $nonce, $this->keys->key($version)); return self::PREFIX . '.' . $version . '.' . base64_encode($nonce . $ct); } public function decrypt(string $envelope, string $context): string { [$version, $nonce, $ct] = $this->parse($envelope); $plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ct, $this->aad($version, $context), $nonce, $this->keys->key($version)); if ($plain === false) { throw new DecryptionException('Stored credential could not be decrypted. It may have been moved or the key changed.'); } return $plain; } public function keyVersionOf(string $envelope): int { return $this->parse($envelope)[0]; } public function needsRotation(string $envelope): bool { return $this->keyVersionOf($envelope) !== $this->keys->currentVersion(); } /** Keyed, non-reversible fingerprint for display and change detection. */ public function fingerprint(string $plaintext): string { $key = sodium_crypto_generichash('aiwend-fingerprint', $this->keys->key($this->keys->currentVersion()), 32); return substr(bin2hex(sodium_crypto_generichash($plaintext, $key, 16)), 0, 16); } /** @return array{0:int,1:string,2:string} */ private function parse(string $envelope): array { $parts = explode('.', $envelope, 3); if (count($parts) !== 3 || $parts[0] !== self::PREFIX || !ctype_digit($parts[1])) { throw new DecryptionException('Unrecognized credential envelope.'); } $raw = base64_decode($parts[2], true); $n = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES; if ($raw === false || strlen($raw) <= $n) { throw new DecryptionException('Malformed credential envelope.'); } return [(int) $parts[1], substr($raw, 0, $n), substr($raw, $n)]; } private function aad(int $version, string $context): string { return self::PREFIX . '|' . $version . '|' . $context; } }