PatEncryptionService.java
package edu.ucsb.cs.scaffold.services;
import edu.ucsb.cs.scaffold.errors.NoSuchKeyVersionException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.encrypt.AesBytesEncryptor;
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
import org.springframework.security.crypto.encrypt.BytesEncryptor;
import org.springframework.security.crypto.keygen.KeyGenerators;
import org.springframework.stereotype.Service;
/**
* Encrypts and decrypts users' GitHub personal access tokens (PATs) with AES-256-GCM so the
* database only ever sees ciphertext.
*
* <p>Keys are supplied via environment variables in the form {@code <version>:<base64 of 32
* bytes>}; see docs/PAT_ENCRYPTION_KEY_instructions.md. {@code PAT_ENCRYPTION_KEY} is the current
* key, used for all encryption. During a key rotation, {@code PREVIOUS_PAT_ENCRYPTION_KEY}
* additionally holds the key being retired, so credentials encrypted under it can still be
* decrypted until the rotate-keys job re-encrypts them under the current key.
*
* <p>When neither variable is set the service is "not configured": the app still boots (so
* deployments that don't use the PAT feature are unaffected), but encryption requests fail with
* {@link IllegalStateException}. A malformed key value, by contrast, fails at startup — a typo in
* key configuration should be loud, not silently treated as "feature off".
*/
@Service
public class PatEncryptionService {
/** Result of encrypting a PAT: the base64 ciphertext and the key version that produced it. */
public record EncryptedPat(String ciphertext, int keyVersion) {}
private record VersionedKey(int version, SecretKey key) {}
// 0 = no key configured; real versions are always >= 1
private final int currentKeyVersion;
private final Map<Integer, BytesEncryptor> encryptorsByVersion = new HashMap<>();
public PatEncryptionService(
@Value("${app.pat.encryption.key:}") String currentKey,
@Value("${app.pat.encryption.previous-key:}") String previousKey) {
if (currentKey.isBlank()) {
if (!previousKey.isBlank()) {
throw new IllegalArgumentException(
"PREVIOUS_PAT_ENCRYPTION_KEY is set but PAT_ENCRYPTION_KEY is not");
}
this.currentKeyVersion = 0;
return;
}
VersionedKey current = parseVersionedKey(currentKey, "PAT_ENCRYPTION_KEY");
this.currentKeyVersion = current.version();
encryptorsByVersion.put(current.version(), encryptorFor(current.key()));
if (!previousKey.isBlank()) {
VersionedKey previous = parseVersionedKey(previousKey, "PREVIOUS_PAT_ENCRYPTION_KEY");
if (previous.version() == current.version()) {
throw new IllegalArgumentException(
"PAT_ENCRYPTION_KEY and PREVIOUS_PAT_ENCRYPTION_KEY have the same key version (%d)"
.formatted(current.version()));
}
encryptorsByVersion.put(previous.version(), encryptorFor(previous.key()));
}
}
/** Whether a current encryption key is configured. */
public boolean isConfigured() {
return currentKeyVersion != 0;
}
/** The key version new encryptions are performed under (the rotate-keys job's target). */
public int currentKeyVersion() {
requireConfigured();
return currentKeyVersion;
}
/** Encrypts a PAT under the current key. */
public EncryptedPat encrypt(String plaintext) {
requireConfigured();
byte[] ciphertext =
encryptorsByVersion
.get(currentKeyVersion)
.encrypt(plaintext.getBytes(StandardCharsets.UTF_8));
return new EncryptedPat(Base64.getEncoder().encodeToString(ciphertext), currentKeyVersion);
}
/**
* Decrypts a stored PAT using the key that encrypted it, identified by the credential's stored
* key version.
*
* @throws NoSuchKeyVersionException if no key is configured for that version
*/
public String decrypt(String ciphertext, int keyVersion) {
BytesEncryptor encryptor = encryptorsByVersion.get(keyVersion);
if (encryptor == null) {
throw new NoSuchKeyVersionException(keyVersion);
}
byte[] plaintext = encryptor.decrypt(Base64.getDecoder().decode(ciphertext));
return new String(plaintext, StandardCharsets.UTF_8);
}
private void requireConfigured() {
if (currentKeyVersion == 0) {
throw new IllegalStateException(
"PAT encryption is not configured on this server; set PAT_ENCRYPTION_KEY (see docs/PAT_ENCRYPTION_KEY_instructions.md)");
}
}
static VersionedKey parseVersionedKey(String value, String name) {
int colon = value.indexOf(':');
if (colon < 0) {
throw new IllegalArgumentException(
name
+ " must have the form <version>:<base64-key>; see docs/PAT_ENCRYPTION_KEY_instructions.md");
}
int version;
try {
version = Integer.parseInt(value.substring(0, colon));
} catch (NumberFormatException e) {
throw new IllegalArgumentException(name + " version prefix must be an integer");
}
if (version < 1) {
throw new IllegalArgumentException(name + " version must be >= 1, got " + version);
}
byte[] keyBytes;
try {
keyBytes = Base64.getDecoder().decode(value.substring(colon + 1));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(name + " key material is not valid base64");
}
if (keyBytes.length != 32) {
throw new IllegalArgumentException(
name
+ " key material must be exactly 32 bytes (256 bits) after base64 decoding, got "
+ keyBytes.length);
}
return new VersionedKey(version, new SecretKeySpec(keyBytes, "AES"));
}
private static BytesEncryptor encryptorFor(SecretKey key) {
return new AesBytesEncryptor(key, KeyGenerators.secureRandom(16), CipherAlgorithm.GCM);
}
}