| 1 | package edu.ucsb.cs.scaffold.services; | |
| 2 | ||
| 3 | import edu.ucsb.cs.scaffold.errors.NoSuchKeyVersionException; | |
| 4 | import java.nio.charset.StandardCharsets; | |
| 5 | import java.util.Base64; | |
| 6 | import java.util.HashMap; | |
| 7 | import java.util.Map; | |
| 8 | import javax.crypto.SecretKey; | |
| 9 | import javax.crypto.spec.SecretKeySpec; | |
| 10 | import org.springframework.beans.factory.annotation.Value; | |
| 11 | import org.springframework.security.crypto.encrypt.AesBytesEncryptor; | |
| 12 | import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm; | |
| 13 | import org.springframework.security.crypto.encrypt.BytesEncryptor; | |
| 14 | import org.springframework.security.crypto.keygen.KeyGenerators; | |
| 15 | import org.springframework.stereotype.Service; | |
| 16 | ||
| 17 | /** | |
| 18 | * Encrypts and decrypts users' GitHub personal access tokens (PATs) with AES-256-GCM so the | |
| 19 | * database only ever sees ciphertext. | |
| 20 | * | |
| 21 | * <p>Keys are supplied via environment variables in the form {@code <version>:<base64 of 32 | |
| 22 | * bytes>}; see docs/PAT_ENCRYPTION_KEY_instructions.md. {@code PAT_ENCRYPTION_KEY} is the current | |
| 23 | * key, used for all encryption. During a key rotation, {@code PREVIOUS_PAT_ENCRYPTION_KEY} | |
| 24 | * additionally holds the key being retired, so credentials encrypted under it can still be | |
| 25 | * decrypted until the rotate-keys job re-encrypts them under the current key. | |
| 26 | * | |
| 27 | * <p>When neither variable is set the service is "not configured": the app still boots (so | |
| 28 | * deployments that don't use the PAT feature are unaffected), but encryption requests fail with | |
| 29 | * {@link IllegalStateException}. A malformed key value, by contrast, fails at startup — a typo in | |
| 30 | * key configuration should be loud, not silently treated as "feature off". | |
| 31 | */ | |
| 32 | @Service | |
| 33 | public class PatEncryptionService { | |
| 34 | ||
| 35 | /** Result of encrypting a PAT: the base64 ciphertext and the key version that produced it. */ | |
| 36 | public record EncryptedPat(String ciphertext, int keyVersion) {} | |
| 37 | ||
| 38 | private record VersionedKey(int version, SecretKey key) {} | |
| 39 | ||
| 40 | // 0 = no key configured; real versions are always >= 1 | |
| 41 | private final int currentKeyVersion; | |
| 42 | ||
| 43 | private final Map<Integer, BytesEncryptor> encryptorsByVersion = new HashMap<>(); | |
| 44 | ||
| 45 | public PatEncryptionService( | |
| 46 | @Value("${app.pat.encryption.key:}") String currentKey, | |
| 47 | @Value("${app.pat.encryption.previous-key:}") String previousKey) { | |
| 48 |
1
1. <init> : negated conditional → KILLED |
if (currentKey.isBlank()) { |
| 49 |
1
1. <init> : negated conditional → KILLED |
if (!previousKey.isBlank()) { |
| 50 | throw new IllegalArgumentException( | |
| 51 | "PREVIOUS_PAT_ENCRYPTION_KEY is set but PAT_ENCRYPTION_KEY is not"); | |
| 52 | } | |
| 53 | this.currentKeyVersion = 0; | |
| 54 | return; | |
| 55 | } | |
| 56 | VersionedKey current = parseVersionedKey(currentKey, "PAT_ENCRYPTION_KEY"); | |
| 57 | this.currentKeyVersion = current.version(); | |
| 58 | encryptorsByVersion.put(current.version(), encryptorFor(current.key())); | |
| 59 |
1
1. <init> : negated conditional → KILLED |
if (!previousKey.isBlank()) { |
| 60 | VersionedKey previous = parseVersionedKey(previousKey, "PREVIOUS_PAT_ENCRYPTION_KEY"); | |
| 61 |
1
1. <init> : negated conditional → KILLED |
if (previous.version() == current.version()) { |
| 62 | throw new IllegalArgumentException( | |
| 63 | "PAT_ENCRYPTION_KEY and PREVIOUS_PAT_ENCRYPTION_KEY have the same key version (%d)" | |
| 64 | .formatted(current.version())); | |
| 65 | } | |
| 66 | encryptorsByVersion.put(previous.version(), encryptorFor(previous.key())); | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | /** Whether a current encryption key is configured. */ | |
| 71 | public boolean isConfigured() { | |
| 72 |
2
1. isConfigured : negated conditional → KILLED 2. isConfigured : replaced boolean return with true for edu/ucsb/cs/scaffold/services/PatEncryptionService::isConfigured → KILLED |
return currentKeyVersion != 0; |
| 73 | } | |
| 74 | ||
| 75 | /** The key version new encryptions are performed under (the rotate-keys job's target). */ | |
| 76 | public int currentKeyVersion() { | |
| 77 |
1
1. currentKeyVersion : removed call to edu/ucsb/cs/scaffold/services/PatEncryptionService::requireConfigured → KILLED |
requireConfigured(); |
| 78 |
1
1. currentKeyVersion : replaced int return with 0 for edu/ucsb/cs/scaffold/services/PatEncryptionService::currentKeyVersion → KILLED |
return currentKeyVersion; |
| 79 | } | |
| 80 | ||
| 81 | /** Encrypts a PAT under the current key. */ | |
| 82 | public EncryptedPat encrypt(String plaintext) { | |
| 83 |
1
1. encrypt : removed call to edu/ucsb/cs/scaffold/services/PatEncryptionService::requireConfigured → KILLED |
requireConfigured(); |
| 84 | byte[] ciphertext = | |
| 85 | encryptorsByVersion | |
| 86 | .get(currentKeyVersion) | |
| 87 | .encrypt(plaintext.getBytes(StandardCharsets.UTF_8)); | |
| 88 |
1
1. encrypt : replaced return value with null for edu/ucsb/cs/scaffold/services/PatEncryptionService::encrypt → KILLED |
return new EncryptedPat(Base64.getEncoder().encodeToString(ciphertext), currentKeyVersion); |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Decrypts a stored PAT using the key that encrypted it, identified by the credential's stored | |
| 93 | * key version. | |
| 94 | * | |
| 95 | * @throws NoSuchKeyVersionException if no key is configured for that version | |
| 96 | */ | |
| 97 | public String decrypt(String ciphertext, int keyVersion) { | |
| 98 | BytesEncryptor encryptor = encryptorsByVersion.get(keyVersion); | |
| 99 |
1
1. decrypt : negated conditional → KILLED |
if (encryptor == null) { |
| 100 | throw new NoSuchKeyVersionException(keyVersion); | |
| 101 | } | |
| 102 | byte[] plaintext = encryptor.decrypt(Base64.getDecoder().decode(ciphertext)); | |
| 103 |
1
1. decrypt : replaced return value with "" for edu/ucsb/cs/scaffold/services/PatEncryptionService::decrypt → KILLED |
return new String(plaintext, StandardCharsets.UTF_8); |
| 104 | } | |
| 105 | ||
| 106 | private void requireConfigured() { | |
| 107 |
1
1. requireConfigured : negated conditional → KILLED |
if (currentKeyVersion == 0) { |
| 108 | throw new IllegalStateException( | |
| 109 | "PAT encryption is not configured on this server; set PAT_ENCRYPTION_KEY (see docs/PAT_ENCRYPTION_KEY_instructions.md)"); | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | static VersionedKey parseVersionedKey(String value, String name) { | |
| 114 | int colon = value.indexOf(':'); | |
| 115 |
2
1. parseVersionedKey : negated conditional → KILLED 2. parseVersionedKey : changed conditional boundary → KILLED |
if (colon < 0) { |
| 116 | throw new IllegalArgumentException( | |
| 117 | name | |
| 118 | + " must have the form <version>:<base64-key>; see docs/PAT_ENCRYPTION_KEY_instructions.md"); | |
| 119 | } | |
| 120 | int version; | |
| 121 | try { | |
| 122 | version = Integer.parseInt(value.substring(0, colon)); | |
| 123 | } catch (NumberFormatException e) { | |
| 124 | throw new IllegalArgumentException(name + " version prefix must be an integer"); | |
| 125 | } | |
| 126 |
2
1. parseVersionedKey : changed conditional boundary → KILLED 2. parseVersionedKey : negated conditional → KILLED |
if (version < 1) { |
| 127 | throw new IllegalArgumentException(name + " version must be >= 1, got " + version); | |
| 128 | } | |
| 129 | byte[] keyBytes; | |
| 130 | try { | |
| 131 |
1
1. parseVersionedKey : Replaced integer addition with subtraction → KILLED |
keyBytes = Base64.getDecoder().decode(value.substring(colon + 1)); |
| 132 | } catch (IllegalArgumentException e) { | |
| 133 | throw new IllegalArgumentException(name + " key material is not valid base64"); | |
| 134 | } | |
| 135 |
1
1. parseVersionedKey : negated conditional → KILLED |
if (keyBytes.length != 32) { |
| 136 | throw new IllegalArgumentException( | |
| 137 | name | |
| 138 | + " key material must be exactly 32 bytes (256 bits) after base64 decoding, got " | |
| 139 | + keyBytes.length); | |
| 140 | } | |
| 141 |
1
1. parseVersionedKey : replaced return value with null for edu/ucsb/cs/scaffold/services/PatEncryptionService::parseVersionedKey → KILLED |
return new VersionedKey(version, new SecretKeySpec(keyBytes, "AES")); |
| 142 | } | |
| 143 | ||
| 144 | private static BytesEncryptor encryptorFor(SecretKey key) { | |
| 145 |
1
1. encryptorFor : replaced return value with null for edu/ucsb/cs/scaffold/services/PatEncryptionService::encryptorFor → KILLED |
return new AesBytesEncryptor(key, KeyGenerators.secureRandom(16), CipherAlgorithm.GCM); |
| 146 | } | |
| 147 | } | |
Mutations | ||
| 48 |
1.1 |
|
| 49 |
1.1 |
|
| 59 |
1.1 |
|
| 61 |
1.1 |
|
| 72 |
1.1 2.2 |
|
| 77 |
1.1 |
|
| 78 |
1.1 |
|
| 83 |
1.1 |
|
| 88 |
1.1 |
|
| 99 |
1.1 |
|
| 103 |
1.1 |
|
| 107 |
1.1 |
|
| 115 |
1.1 2.2 |
|
| 126 |
1.1 2.2 |
|
| 131 |
1.1 |
|
| 135 |
1.1 |
|
| 141 |
1.1 |
|
| 145 |
1.1 |