EncryptionController.java

/*
 * Copyright 2013-2019 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.cloud.config.server.encryption;

import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.cloud.context.encrypt.KeyFormatException;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.encrypt.TextEncryptor;
import org.springframework.security.rsa.crypto.RsaKeyHolder;
import org.springframework.security.rsa.crypto.RsaSecretEncryptor;
import org.springframework.util.Base64Utils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author Dave Syer
 * @author Tim Ysewyn
 *
 */
@RestController
@RequestMapping(path = "${spring.cloud.config.server.prefix:}")
public class EncryptionController {

	private static Log logger = LogFactory.getLog(EncryptionController.class);

	volatile private TextEncryptorLocator encryptorLocator;

	private EnvironmentPrefixHelper helper = new EnvironmentPrefixHelper();

	private String defaultApplicationName = "application";

	private String defaultProfile = "default";

	public EncryptionController(TextEncryptorLocator encryptorLocator) {
		this.encryptorLocator = encryptorLocator;
	}

	public void setDefaultApplicationName(String defaultApplicationName) {
		this.defaultApplicationName = defaultApplicationName;
	}

	public void setDefaultProfile(String defaultProfile) {
		this.defaultProfile = defaultProfile;
	}

	@GetMapping("/key")
	public String getPublicKey() {
		return getPublicKey(defaultApplicationName, defaultProfile);
	}

	@GetMapping("/key/{name}/{profiles}")
	public String getPublicKey(@PathVariable String name, @PathVariable String profiles) {
		TextEncryptor encryptor = getEncryptor(name, profiles, "");
		if (!(encryptor instanceof RsaKeyHolder)) {
			throw new KeyNotAvailableException();
		}
		return ((RsaKeyHolder) encryptor).getPublicKey();
	}

	@GetMapping("encrypt/status")
	public Map<String, Object> status() {
		TextEncryptor encryptor = getEncryptor(defaultApplicationName, defaultProfile, "");
		validateEncryptionWeakness(encryptor);
		return Collections.singletonMap("status", "OK");
	}

	@PostMapping("encrypt")
	public String encrypt(@RequestBody String data, @RequestHeader("Content-Type") MediaType type) {
		return encrypt(defaultApplicationName, defaultProfile, data, type);
	}

	@PostMapping("/encrypt/{name}/{profiles}")
	public String encrypt(@PathVariable String name, @PathVariable String profiles, @RequestBody String data,
			@RequestHeader("Content-Type") MediaType type) {
		String input = stripFormData(data, type, false);
		TextEncryptor encryptor = getEncryptor(name, profiles, input);
		validateEncryptionWeakness(encryptor);
		Map<String, String> keys = helper.getEncryptorKeys(name, profiles, input);
		String textToEncrypt = helper.stripPrefix(input);
		String encrypted = helper.addPrefix(keys, encryptor.encrypt(textToEncrypt));
		if (logger.isInfoEnabled()) {
			logger.info("Encrypted data");
		}
		return encrypted;
	}

	@PostMapping("decrypt")
	public String decrypt(@RequestBody String data, @RequestHeader("Content-Type") MediaType type) {
		return decrypt(defaultApplicationName, defaultProfile, data, type);
	}

	@PostMapping("/decrypt/{name}/{profiles}")
	public String decrypt(@PathVariable String name, @PathVariable String profiles, @RequestBody String data,
			@RequestHeader("Content-Type") MediaType type) {
		try {
			TextEncryptor encryptor = getEncryptor(name, profiles, data);
			checkDecryptionPossible(encryptor);
			validateEncryptionWeakness(encryptor);
			String input = stripFormData(helper.stripPrefix(data), type, true);
			String decrypted = encryptor.decrypt(input);
			if (logger.isInfoEnabled()) {
				logger.info("Decrypted cipher data");
			}
			return decrypted;
		}
		catch (IllegalArgumentException | IllegalStateException e) {
			if (logger.isErrorEnabled()) {
				logger.error("Cannot decrypt key:" + name + ", value:" + data, e);
			}
			throw new InvalidCipherException();
		}
	}

	private TextEncryptor getEncryptor(String name, String profiles, String data) {
		if (encryptorLocator == null) {
			if (logger.isDebugEnabled()) {
				logger.debug("Text encryptorLocator is null.");
			}
			throw new KeyNotInstalledException();
		}
		TextEncryptor encryptor = encryptorLocator.locate(helper.getEncryptorKeys(name, profiles, data));
		if (encryptor == null) {
			if (logger.isDebugEnabled()) {
				logger.debug("TextEncryptor is null.");
			}
			throw new KeyNotInstalledException();
		}
		return encryptor;
	}

	private void validateEncryptionWeakness(TextEncryptor textEncryptor) {
		if (textEncryptor.encrypt("FOO").equals("FOO")) {
			throw new EncryptionTooWeakException();
		}
	}

	private void checkDecryptionPossible(TextEncryptor textEncryptor) {
		if (textEncryptor instanceof RsaSecretEncryptor && !((RsaSecretEncryptor) textEncryptor).canDecrypt()) {
			throw new DecryptionNotSupportedException();
		}
	}

	private String stripFormData(String data, MediaType type, boolean cipher) {

		if (data.endsWith("=") && !type.equals(MediaType.TEXT_PLAIN)) {
			try {
				data = URLDecoder.decode(data, "UTF-8");
				if (cipher) {
					data = data.replace(" ", "+");
				}
			}
			catch (UnsupportedEncodingException e) {
				// Really?
			}
			String candidate = data.substring(0, data.length() - 1);
			if (cipher) {
				if (data.endsWith("=")) {
					if (data.length() / 2 != (data.length() + 1) / 2) {
						try {
							Hex.decode(candidate);
							return candidate;
						}
						catch (IllegalArgumentException e) {
							try {
								Base64Utils.decode(candidate.getBytes());
								return candidate;
							}
							catch (IllegalArgumentException ex) {
							}
						}
					}
				}
				return data;
			}
			// User posted data with content type form but meant it to be text/plain
			data = candidate;
		}

		return data;

	}

	@ExceptionHandler(KeyFormatException.class)
	public ResponseEntity<Map<String, Object>> keyFormat() {
		Map<String, Object> body = new HashMap<>();
		body.put("status", "BAD_REQUEST");
		body.put("description", "Key data not in correct format (PEM or jks keystore)");
		return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
	}

	@ExceptionHandler(KeyNotAvailableException.class)
	public ResponseEntity<Map<String, Object>> keyUnavailable() {
		Map<String, Object> body = new HashMap<>();
		body.put("status", "NOT_FOUND");
		body.put("description", "No public key available");
		return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
	}

	@ExceptionHandler(DecryptionNotSupportedException.class)
	public ResponseEntity<Map<String, Object>> decryptionDisabled() {
		Map<String, Object> body = new HashMap<>();
		body.put("status", "BAD_REQUEST");
		body.put("description", "Server-side decryption is not supported");
		return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
	}

	@ExceptionHandler(KeyNotInstalledException.class)
	public ResponseEntity<Map<String, Object>> notInstalled() {
		Map<String, Object> body = new HashMap<>();
		body.put("status", "NO_KEY");
		body.put("description", "No key was installed for encryption service");
		return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
	}

	@ExceptionHandler(EncryptionTooWeakException.class)
	public ResponseEntity<Map<String, Object>> encryptionTooWeak() {
		Map<String, Object> body = new HashMap<>();
		body.put("status", "INVALID");
		body.put("description", "The encryption algorithm is not strong enough");
		return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
	}

	@ExceptionHandler(InvalidCipherException.class)
	public ResponseEntity<Map<String, Object>> invalidCipher() {
		Map<String, Object> body = new HashMap<>();
		body.put("status", "INVALID");
		body.put("description", "Text not encrypted with this key");
		return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
	}

}

@SuppressWarnings("serial")
class KeyNotInstalledException extends RuntimeException {

}

@SuppressWarnings("serial")
class KeyNotAvailableException extends RuntimeException {

}

@SuppressWarnings("serial")
class EncryptionTooWeakException extends RuntimeException {

}

@SuppressWarnings("serial")
class InvalidCipherException extends RuntimeException {

}

@SuppressWarnings("serial")
class DecryptionNotSupportedException extends RuntimeException {

}