PatEncryptionService.java

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
Location : <init>
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:key_material_that_is_not_32_bytes_is_rejected()]
negated conditional → KILLED

49

1.1
Location : <init>
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:previous_key_without_a_current_key_is_rejected()]
negated conditional → KILLED

59

1.1
Location : <init>
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:configured_service_reports_isConfigured_and_current_version()]
negated conditional → KILLED

61

1.1
Location : <init>
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:current_and_previous_keys_with_the_same_version_are_rejected()]
negated conditional → KILLED

72

1.1
Location : isConfigured
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:unconfigured_service_boots_but_refuses_to_encrypt()]
negated conditional → KILLED

2.2
Location : isConfigured
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:unconfigured_service_boots_but_refuses_to_encrypt()]
replaced boolean return with true for edu/ucsb/cs/scaffold/services/PatEncryptionService::isConfigured → KILLED

77

1.1
Location : currentKeyVersion
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:unconfigured_service_boots_but_refuses_to_encrypt()]
removed call to edu/ucsb/cs/scaffold/services/PatEncryptionService::requireConfigured → KILLED

78

1.1
Location : currentKeyVersion
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:configured_service_reports_isConfigured_and_current_version()]
replaced int return with 0 for edu/ucsb/cs/scaffold/services/PatEncryptionService::currentKeyVersion → KILLED

83

1.1
Location : encrypt
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:unconfigured_service_boots_but_refuses_to_encrypt()]
removed call to edu/ucsb/cs/scaffold/services/PatEncryptionService::requireConfigured → KILLED

88

1.1
Location : encrypt
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:encrypt_then_decrypt_round_trips()]
replaced return value with null for edu/ucsb/cs/scaffold/services/PatEncryptionService::encrypt → KILLED

99

1.1
Location : decrypt
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:unconfigured_service_boots_but_refuses_to_encrypt()]
negated conditional → KILLED

103

1.1
Location : decrypt
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:encrypt_then_decrypt_round_trips()]
replaced return value with "" for edu/ucsb/cs/scaffold/services/PatEncryptionService::decrypt → KILLED

107

1.1
Location : requireConfigured
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:unconfigured_service_boots_but_refuses_to_encrypt()]
negated conditional → KILLED

115

1.1
Location : parseVersionedKey
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:key_material_that_is_not_32_bytes_is_rejected()]
negated conditional → KILLED

2.2
Location : parseVersionedKey
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:key_starting_with_a_colon_is_rejected_as_a_bad_version_prefix()]
changed conditional boundary → KILLED

126

1.1
Location : parseVersionedKey
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:key_material_that_is_not_32_bytes_is_rejected()]
changed conditional boundary → KILLED

2.2
Location : parseVersionedKey
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:key_material_that_is_not_32_bytes_is_rejected()]
negated conditional → KILLED

131

1.1
Location : parseVersionedKey
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:key_material_that_is_not_32_bytes_is_rejected()]
Replaced integer addition with subtraction → KILLED

135

1.1
Location : parseVersionedKey
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:key_material_that_is_not_32_bytes_is_rejected()]
negated conditional → KILLED

141

1.1
Location : parseVersionedKey
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:configured_service_reports_isConfigured_and_current_version()]
replaced return value with null for edu/ucsb/cs/scaffold/services/PatEncryptionService::parseVersionedKey → KILLED

145

1.1
Location : encryptorFor
Killed by : edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.PatEncryptionServiceTests]/[method:encrypt_then_decrypt_round_trips()]
replaced return value with null for edu/ucsb/cs/scaffold/services/PatEncryptionService::encryptorFor → KILLED

Active mutators

Tests examined


Report generated by PIT 1.17.0