package ch.ge.ve.offlineadmin.controller;
/*-
* #%L
* Admin offline
* %%
* Copyright (C) 2015 - 2016 République et Canton de Genève
* %%
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
import ch.ge.ve.commons.crypto.ballot.BallotCipherService;
import ch.ge.ve.commons.crypto.ballot.EncryptedBallotAndWrappedKey;
import ch.ge.ve.commons.crypto.exceptions.PrivateKeyPasswordMismatchException;
import ch.ge.ve.commons.fileutils.OutputFilesPattern;
import ch.ge.ve.commons.fileutils.StreamHasher;
import ch.ge.ve.commons.properties.PropertyConfigurationException;
import ch.ge.ve.commons.properties.PropertyConfigurationService;
import ch.ge.ve.commons.streamutils.SafeObjectReader;
import ch.ge.ve.offlineadmin.exception.MissingKeyFilesException;
import ch.ge.ve.offlineadmin.exception.ProcessInterruptedException;
import ch.ge.ve.offlineadmin.services.BallotCipherServiceFactory;
import ch.ge.ve.offlineadmin.services.DecryptionService;
import ch.ge.ve.offlineadmin.util.FileUtils;
import ch.ge.ve.offlineadmin.util.LogLevel;
import ch.ge.ve.offlineadmin.util.PropertyConfigurationServiceFactory;
import com.google.common.base.Stopwatch;
import com.google.common.collect.ImmutableList;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.scene.layout.BorderPane;
import org.apache.log4j.Logger;
import javax.crypto.SealedObject;
import javax.xml.bind.DatatypeConverter;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.ResourceBundle;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.time.ZonedDateTime;
import static ch.ge.ve.commons.crypto.SensitiveDataCryptoUtilsConfigurationDefaultImpl.COMMON_CRYPTO_STREAM_MAX_BYTES;
import static ch.ge.ve.offlineadmin.util.SecurityConstants.BALLOTS_FILENAME;
import static ch.ge.ve.offlineadmin.util.SecurityConstants.STREAM_MAX_OBJECTS;
/**
* This controller handles the management of the display of the ballot decryption tab, including the decryption process.
*/
public class BallotDecryptionController extends InterruptibleProcessController {
private static final Logger LOGGER = Logger.getLogger(BallotDecryptionController.class);
private final Executor exec = Executors.newCachedThreadPool(runnable -> {
Thread t = new Thread(runnable);
t.setDaemon(true);
return t;
});
@FXML
private ResourceBundle resources;
@FXML
private BorderPane mainPane;
/*
* By appending <tt>Controller</tt> to the <tt>fx:id</tt> of the node, its controller is resolved automatically
*/
@FXML
private ConsoleOutputControl consoleOutputController;
private FileUtils fileUtils;
private StreamHasher streamHasher;
private OutputFilesPattern outputFilesPattern;
private PropertyConfigurationService propertyConfigurationService;
private PasswordDialogController passwordDialogController;
private BallotCipherServiceFactory ballotCipherServiceFactory;
private ZoneId chZoneId = ZoneId.of("Europe/Zurich");
@FXML
public void initialize() throws IOException {
fileUtils = new FileUtils(resources);
propertyConfigurationService = new PropertyConfigurationServiceFactory().propertyConfigurationService();
streamHasher = new StreamHasher(propertyConfigurationService);
outputFilesPattern = new OutputFilesPattern();
passwordDialogController = new PasswordDialogController(resources, consoleOutputController);
ballotCipherServiceFactory = new BallotCipherServiceFactory(propertyConfigurationService);
}
/**
* Perform the decryption of the ballots
*/
public void startDecryption() {
try {
File keyDirectory = fileUtils.promptKeyDirectory();
if (keyDirectory == null) {
throw new ProcessInterruptedException("action cancelled");
}
BallotCipherService ballotCipherService = ballotCipherServiceFactory.decryptionBallotCipherService(keyDirectory);
StringProperty password1 = new SimpleStringProperty();
StringProperty password2 = new SimpleStringProperty();
passwordDialogController.promptForPasswords(password1, password2, false);
decryptBallots(ballotCipherService, password1, password2);
} catch (MissingKeyFilesException e) {
consoleOutputController.logOnScreen(resources.getString("keys_not_found_in_directory"), LogLevel.WARN);
LOGGER.warn(PROCESS_INTERRUPTED_MESSAGE, e);
} catch (PrivateKeyPasswordMismatchException e) {
consoleOutputController.logOnScreen(resources.getString("key_password_mismatch"), LogLevel.WARN);
LOGGER.warn("key password mismatch", e);
} catch (ProcessInterruptedException e) {
consoleOutputController.logOnScreen(resources.getString("ballot_decryption.process_interrupted"), LogLevel.WARN);
LOGGER.warn(PROCESS_INTERRUPTED_MESSAGE, e);
}
}
private void decryptBallots(BallotCipherService ballotCipherService, StringProperty password1, StringProperty password2) throws PrivateKeyPasswordMismatchException, ProcessInterruptedException {
ballotCipherService.loadBallotKeyCipherPrivateKey(password1.getValue() + password2.getValue());
File encryptedBallotsFile = fileUtils.promptEncryptedBallotsFile();
if (encryptedBallotsFile == null) {
throw new ProcessInterruptedException("action cancelled");
}
logEncryptedBallotsFileHash(encryptedBallotsFile);
unserializeAndDecryptEncryptedBallots(encryptedBallotsFile, ballotCipherService);
}
private void logEncryptedBallotsFileHash(File encryptedBallotsFile) throws ProcessInterruptedException {
try (InputStream encBallotsInputStream = Files.newInputStream(encryptedBallotsFile.toPath(), StandardOpenOption.READ)) {
byte[] encBallotsFileHash = streamHasher.threadSafeComputeHash(encBallotsInputStream);
consoleOutputController.logOnScreen(
String.format(resources.getString("ballot_decryption.enc_ballots_file_hash"),
DatatypeConverter.printHexBinary(encBallotsFileHash)));
} catch (IOException e) {
throw new ProcessInterruptedException(resources.getString("ballot_decryption.exception_occurred"), e);
}
}
private void unserializeAndDecryptEncryptedBallots(File encryptedBallotsFile, BallotCipherService ballotCipherService) throws ProcessInterruptedException {
// Using a task here, so as to perform decryption without blocking the UI.
final long maxObjects;
try {
maxObjects = propertyConfigurationService.getConfigValueAsLong(STREAM_MAX_OBJECTS);
} catch (PropertyConfigurationException e) {
throw new ProcessInterruptedException(String.format(resources.getString("ballot_decryption.undefined_property"), STREAM_MAX_OBJECTS), e);
}
final long maxBytes;
try {
maxBytes = propertyConfigurationService.getConfigValueAsLong(COMMON_CRYPTO_STREAM_MAX_BYTES);
} catch (PropertyConfigurationException e) {
throw new ProcessInterruptedException(String.format(resources.getString("ballot_decryption.undefined_property"), COMMON_CRYPTO_STREAM_MAX_BYTES), e);
}
Task<List<EncryptedBallotAndWrappedKey>> unserializeEncryptedBallotsTask = new UnserializeEncryptedBallotsTask(
encryptedBallotsFile,
maxObjects,
maxBytes);
Stopwatch fileOpening = Stopwatch.createStarted();
// Handle success
unserializeEncryptedBallotsTask.setOnSucceeded(event -> {
List<EncryptedBallotAndWrappedKey> encryptedBallots = unserializeEncryptedBallotsTask.getValue();
fileOpening.stop();
consoleOutputController.logOnScreen(
String.format(resources.getString("ballot_decryption.enc_ballots_loaded"),
formatElapsedTime(fileOpening)));
consoleOutputController.logOnScreen(
String.format(resources.getString("ballot_decryption.number_of_ballots"),
encryptedBallots.size())
);
consoleOutputController.setStepCount(encryptedBallots.size() / DecryptionService.STEP_SIZE);
// Once the ballots are deserialized, they can be decrypted
performBallotDecryption(ballotCipherService, encryptedBallots);
});
// Handle failure
unserializeEncryptedBallotsTask.exceptionProperty().addListener((observable, oldValue, newException) -> {
if (newException != null) {
LOGGER.error(resources.getString("ballot_decryption.exception_occurred"), newException);
consoleOutputController.logOnScreen(resources.getString("ballot_decryption.exception_occurred"), LogLevel.ERROR);
}
});
// Start execution
exec.execute(unserializeEncryptedBallotsTask);
}
private void performBallotDecryption(BallotCipherService ballotCipherService, List<EncryptedBallotAndWrappedKey> encryptedBallots) {
DecryptionService decryptionService = new DecryptionService(ballotCipherService, consoleOutputController);
// Using a task here, so as to perform decryption without blocking the UI.
Task<List<String>> ballotDecryptionTask = new BallotDecryptionTask(decryptionService, encryptedBallots);
final Stopwatch ballotDecryption = Stopwatch.createStarted();
// Handle success
ballotDecryptionTask.setOnSucceeded(event -> {
ballotDecryption.stop();
consoleOutputController.logOnScreen(
String.format(resources.getString("ballot_decryption.decryption_finished"),
formatElapsedTime(ballotDecryption)));
consoleOutputController.progressMessage(String.format(resources.getString("ballot_decryption.invalid_ballots_text"), decryptionService.getInvalidCounter())); //"Count of invalid ballots : " +
saveCleartextBallots(ballotDecryptionTask.getValue());
consoleOutputController.incrementStepCount();
});
// Handle failure
ballotDecryptionTask.exceptionProperty().addListener((observable, oldValue, newException) -> {
if (newException != null) {
LOGGER.error(resources.getString("ballot_decryption.exception_occurred"), newException);
consoleOutputController.logOnScreen(resources.getString("ballot_decryption.exception_occurred"), LogLevel.ERROR);
}
});
// Start execution
exec.execute(ballotDecryptionTask);
}
private void saveCleartextBallots(List<String> decryptedBallots) {
try {
File selectedDirectory = selectDirectory();
final String ballotsFilename = propertyConfigurationService.getConfigValue(BALLOTS_FILENAME);
Path cleartextBallotsFilename = Paths.get(selectedDirectory.toString(), outputFilesPattern.injectParams(ballotsFilename, ZonedDateTime.now(chZoneId)));
Files.write(cleartextBallotsFilename, decryptedBallots);
byte[] cleartextBallotsFileHash;
try (InputStream cleartextBallotsInputStream = Files.newInputStream(cleartextBallotsFilename, StandardOpenOption.READ)) {
cleartextBallotsFileHash = streamHasher.threadSafeComputeHash(cleartextBallotsInputStream);
}
consoleOutputController.logOnScreen(
String.format(resources.getString("ballot_decryption.output_file_hash"),
DatatypeConverter.printHexBinary(cleartextBallotsFileHash)));
consoleOutputController.logOnScreen(String.format(resources.getString("ballot_decryption.file_saved"), cleartextBallotsFilename));
} catch (ProcessInterruptedException | IOException | PropertyConfigurationException e) {
consoleOutputController.logOnScreen(resources.getString("ballot_decryption.process_interrupted"), LogLevel.WARN);
LOGGER.warn(PROCESS_INTERRUPTED_MESSAGE, e);
}
}
private File selectDirectory() throws ProcessInterruptedException {
String title = resources.getString("ballot_decryption.dir_chooser.title");
File selectedDirectory = fileUtils.getDirectory(title, fileUtils.getUserHome());
if (selectedDirectory == null) {
throw new ProcessInterruptedException("Directory selection cancelled");
}
return selectedDirectory;
}
private String formatElapsedTime(Stopwatch stopwatch) {
long elapsed = stopwatch.elapsed(TimeUnit.SECONDS);
long hours = elapsed / 3600;
long hourLessElapsed = elapsed % 3600;
long minutes = hourLessElapsed / 60;
long seconds = hourLessElapsed % 60;
return String.format("%d h %02d min %02d sec ", hours, minutes, seconds);
}
@Override
protected ResourceBundle getResourceBundle() {
return resources;
}
void setPasswordDialogController(PasswordDialogController passwordDialogController) {
this.passwordDialogController = passwordDialogController;
}
void setBallotCipherServiceFactory(BallotCipherServiceFactory ballotCipherServiceFactory) {
this.ballotCipherServiceFactory = ballotCipherServiceFactory;
}
void setFileUtils(FileUtils fileUtils) {
this.fileUtils = fileUtils;
}
void setStreamHasher(StreamHasher streamHasher) {
this.streamHasher = streamHasher;
}
private static class UnserializeEncryptedBallotsTask extends Task<List<EncryptedBallotAndWrappedKey>> {
private final File encryptedBallotsFile;
private final long maxObjects;
private final long maxBytes;
public UnserializeEncryptedBallotsTask(File encryptedBallotsFile, long maxObjects, long maxBytes) {
this.encryptedBallotsFile = encryptedBallotsFile;
this.maxObjects = maxObjects;
this.maxBytes = maxBytes;
}
@Override
protected List<EncryptedBallotAndWrappedKey> call() throws Exception {
// Need to create the stream here, so it'll be available to the executor thread
try (InputStream encBallotsInputStream = Files.newInputStream(encryptedBallotsFile.toPath(), StandardOpenOption.READ)) {
return (List<EncryptedBallotAndWrappedKey>) SafeObjectReader.safeReadObject(
ArrayList.class,
Arrays.asList(EncryptedBallotAndWrappedKey.class, SealedObject.class),
maxObjects,
maxBytes,
encBallotsInputStream);
}
}
}
private class BallotDecryptionTask extends Task<List<String>> {
private final DecryptionService decryptionService;
private final List<EncryptedBallotAndWrappedKey> encryptedBallots;
public BallotDecryptionTask(DecryptionService decryptionService, List<EncryptedBallotAndWrappedKey> encryptedBallots) {
this.decryptionService = decryptionService;
this.encryptedBallots = encryptedBallots;
}
@Override
protected List<String> call() throws Exception {
return decryptionService.decrypt(ImmutableList.copyOf(encryptedBallots));
}
}
}