CanvasApiTokenSecurityService.java
package edu.ucsb.cs156.frontiers.services;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class CanvasApiTokenSecurityService {
private static final String ENCRYPTION_PREFIX = "enc:v1:";
private static final String CIPHER_ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_TAG_LENGTH_BITS = 128;
private static final int GCM_IV_LENGTH_BYTES = 12;
private final String encodedEncryptionKey;
private final SecureRandom secureRandom = new SecureRandom();
public CanvasApiTokenSecurityService(
@Value("${app.canvas.api-token-encryption-key:}") String encodedEncryptionKey) {
this.encodedEncryptionKey = encodedEncryptionKey;
}
public String encrypt(String canvasApiToken) {
if (canvasApiToken == null || canvasApiToken.isEmpty()) {
return canvasApiToken;
}
if (canvasApiToken.startsWith(ENCRYPTION_PREFIX)) {
return canvasApiToken;
}
try {
Cipher cipher = getCipher();
byte[] iv = new byte[GCM_IV_LENGTH_BYTES];
secureRandom.nextBytes(iv);
cipher.init(
Cipher.ENCRYPT_MODE, getSecretKey(), new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
byte[] ciphertext = cipher.doFinal(canvasApiToken.getBytes(StandardCharsets.UTF_8));
byte[] payload = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, payload, 0, iv.length);
System.arraycopy(ciphertext, 0, payload, iv.length, ciphertext.length);
return ENCRYPTION_PREFIX + Base64.getEncoder().encodeToString(payload);
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Failed to encrypt Canvas API token", e);
}
}
public String decrypt(String storedCanvasApiToken) {
if (storedCanvasApiToken == null || storedCanvasApiToken.isEmpty()) {
return storedCanvasApiToken;
}
if (!storedCanvasApiToken.startsWith(ENCRYPTION_PREFIX)) {
return storedCanvasApiToken;
}
try {
byte[] payload =
Base64.getDecoder().decode(storedCanvasApiToken.substring(ENCRYPTION_PREFIX.length()));
if (payload.length <= GCM_IV_LENGTH_BYTES) {
throw new IllegalStateException("Encrypted Canvas API token is malformed");
}
byte[] iv = Arrays.copyOfRange(payload, 0, GCM_IV_LENGTH_BYTES);
byte[] ciphertext = Arrays.copyOfRange(payload, GCM_IV_LENGTH_BYTES, payload.length);
Cipher cipher = getCipher();
cipher.init(
Cipher.DECRYPT_MODE, getSecretKey(), new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
byte[] plaintext = cipher.doFinal(ciphertext);
return new String(plaintext, StandardCharsets.UTF_8);
} catch (GeneralSecurityException | IllegalArgumentException e) {
throw new IllegalStateException("Failed to decrypt Canvas API token", e);
}
}
protected Cipher getCipher() throws GeneralSecurityException {
return Cipher.getInstance(CIPHER_ALGORITHM);
}
private SecretKeySpec getSecretKey() {
if (encodedEncryptionKey == null || encodedEncryptionKey.isBlank()) {
throw new IllegalStateException(
"Canvas API token encryption key is not configured; use 'openssl rand -base64 32' to generate one.");
}
byte[] decodedKey;
try {
decodedKey = Base64.getDecoder().decode(encodedEncryptionKey);
} catch (IllegalArgumentException e) {
throw new IllegalStateException("Canvas API token encryption key must be Base64 encoded", e);
}
if (decodedKey.length != 16 && decodedKey.length != 24 && decodedKey.length != 32) {
throw new IllegalStateException(
"Canvas API token encryption key must decode to 16, 24, or 32 bytes");
}
return new SecretKeySpec(decodedKey, "AES");
}
}