CsvSecureArchiveVerifierCli.java

/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions copyright [year] [name of copyright owner]".
 *
 * Copyright 2015-2016 ForgeRock AS.
 */
package org.forgerock.audit.handlers.csv;

import static org.forgerock.audit.events.handlers.FileBasedEventHandlerConfiguration.FileRotation.DEFAULT_ROTATION_FILE_SUFFIX;
import static org.forgerock.audit.handlers.csv.CsvSecureConstants.KEYSTORE_TYPE;

import java.io.File;
import java.io.PrintStream;
import java.nio.file.Path;
import java.security.PublicKey;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import javax.crypto.SecretKey;

import org.forgerock.audit.handlers.csv.CsvSecureVerifier.VerificationResult;
import org.forgerock.audit.retention.FileNamingPolicy;
import org.forgerock.audit.retention.TimeStampFileNamingPolicy;
import org.forgerock.audit.secure.JcaKeyStoreHandler;
import org.forgerock.audit.secure.KeyStoreHandlerDecorator;
import org.forgerock.audit.secure.KeyStoreSecureStorage;
import org.forgerock.audit.secure.SecureStorageException;
import org.forgerock.util.Option;
import org.forgerock.util.Options;
import org.forgerock.util.annotations.VisibleForTesting;
import org.forgerock.util.encode.Base64;
import org.supercsv.prefs.CsvPreference;

/**
 * Command line interface for verifying an archived set of tamper evident CSV audit log files for a particular topic.
 */
public final class CsvSecureArchiveVerifierCli {

    private static final Option<Path> ARCHIVE_DIRECTORY = Option.of(Path.class, null);
    private static final Option<String> TOPIC = Option.of(String.class, null);
    private static final Option<String> PREFIX = Option.of(String.class, "");
    private static final Option<String> SUFFIX = Option.of(String.class, DEFAULT_ROTATION_FILE_SUFFIX);
    private static final Option<Path> KEYSTORE_FILE = Option.of(Path.class, null);
    private static final Option<String> KEYSTORE_PASSWORD = Option.of(String.class, null);

    @VisibleForTesting
    static PrintStream out = System.out;
    @VisibleForTesting
    static PrintStream err = System.err;
    @VisibleForTesting
    static FileNamingPolicyFactory fileNamingPolicyFactory = new DefaultFileNamingPolicyFactory();

    /**
     * Entry point for CLI.
     *
     * @param args command line arguments.
     */
    public static void main(final String[] args) {

        Options options = new OptionsParser(out, err).parse(args);
        if (options == null) {
            return;
        }

        final Path archiveDirectory = options.get(ARCHIVE_DIRECTORY);
        final String topic = options.get(TOPIC);
        final File liveFile = new File(archiveDirectory.toFile(), topic + ".csv");
        final String prefix = options.get(PREFIX) + CsvAuditEventHandler.SECURE_CSV_FILENAME_PREFIX;
        final String suffix = options.get(SUFFIX);
        final FileNamingPolicy fileNamingPolicy = fileNamingPolicyFactory.newFileNamingPolicy(liveFile, suffix, prefix);
        final Path keystoreFile = options.get(KEYSTORE_FILE);
        final String keystorePassword = options.get(KEYSTORE_PASSWORD);

        final KeyStoreHandlerDecorator keyStoreHandler = getKeyStoreHandlerDecorator(keystoreFile, keystorePassword);
        if (keyStoreHandler == null) {
            return;
        }

        final PublicKey publicKey = getSignaturePublicKey(keyStoreHandler);
        if (publicKey == null) {
            return;
        }

        final String password = getKeystorePassword(keyStoreHandler);
        if (password == null) {
            return;
        }

        final CsvSecureArchiveVerifier archiveVerifier =
                new CsvSecureArchiveVerifier(fileNamingPolicy, password, publicKey, CsvPreference.EXCEL_PREFERENCE);
        final List<CsvSecureVerifier.VerificationResult> verificationResults = archiveVerifier.verify();

        printVerificationResults(verificationResults, out);
    }

    private static KeyStoreHandlerDecorator getKeyStoreHandlerDecorator(
            final Path keystoreFile, final String keystorePassword) {
        try {
            return new KeyStoreHandlerDecorator(
                    new JcaKeyStoreHandler(KEYSTORE_TYPE, keystoreFile.toFile().getAbsolutePath(), keystorePassword));
        } catch (Exception e) {
            err.println("Unable to open keystore");
            return null;
        }
    }

    private static PublicKey getSignaturePublicKey(KeyStoreHandlerDecorator keyStoreHandler) {
        try {
            return keyStoreHandler.readPublicKeyFromKeyStore(KeyStoreSecureStorage.ENTRY_SIGNATURE);
        } catch (SecureStorageException e) {
            err.println("Unable to read " + KeyStoreSecureStorage.ENTRY_SIGNATURE + " public key from keystore");
            return null;
        }
    }

    private static String getKeystorePassword(KeyStoreHandlerDecorator keyStoreHandler) {
        try {
            final SecretKey passwordKey = keyStoreHandler.readSecretKeyFromKeyStore(CsvSecureConstants.ENTRY_PASSWORD);
            return Base64.encode(passwordKey.getEncoded());
        } catch (SecureStorageException e) {
            err.println("Unable to read " + CsvSecureConstants.ENTRY_PASSWORD + " secret key from keystore");
            return null;
        }
    }

    static void printVerificationResults(final List<VerificationResult> verificationResults, final PrintStream out) {
        for (final VerificationResult verificationResult : verificationResults) {
            String filename = verificationResult.getArchiveFile().getName();
            if (verificationResult.hasPassedVerification()) {
                out.println("PASS    " + filename);
            } else {
                out.println("FAIL    " + filename + "    " + verificationResult.getFailureReason());
            }
        }
    }

    static final class OptionsParser {

        static final String FLAG_ARCHIVE_DIRECTORY = "--archive";
        static final String FLAG_TOPIC = "--topic";
        static final String FLAG_PREFIX = "--prefix";
        static final String FLAG_SUFFIX = "--suffix";
        static final String FLAG_KEYSTORE_FILE = "--keystore";
        static final String FLAG_KEYSTORE_PASSWORD = "--password";

        private static final String DESC_ARCHIVE_DIRECTORY = "path to directory containing files to verify";
        private static final String DESC_TOPIC = "name of topic fileset to verify";
        private static final String DESC_PREFIX = "prefix prepended to archive files";
        private static final String DESC_SUFFIX = "format of timestamp suffix appended to archive files";
        private static final String DESC_KEYSTORE_FILE = "path to keystore file";
        private static final String DESC_KEYSTORE_PASSWORD = "keystore file password";

        private final PrintStream out;
        private final PrintStream err;

        OptionsParser(final PrintStream out, final PrintStream err) {
            this.out = out;
            this.err = err;
        }

        Options parse(final String[] args) {
            Options options = Options.defaultOptions();

            if (args.length == 0) {
                printHelp();
                return null;
            }

            Set<String> flagsSeen = new HashSet<>();
            for (int i = 0; i < args.length; i += 2) {
                final boolean isLastArgument = args.length == i + 1;
                final String currentArgument = args[i];
                if (flagsSeen.contains(currentArgument)) {
                    err.println(currentArgument + " should only be provided once");
                    return null;
                }
                flagsSeen.add(currentArgument);
                final String nextArgument = isLastArgument ? null : args[i + 1];
                switch (currentArgument) {
                case FLAG_ARCHIVE_DIRECTORY:
                    options.set(ARCHIVE_DIRECTORY,
                            getPathOption(nextArgument, FLAG_ARCHIVE_DIRECTORY, DESC_ARCHIVE_DIRECTORY));
                    break;
                case FLAG_TOPIC:
                    options.set(TOPIC, getStringOption(nextArgument, FLAG_TOPIC, DESC_TOPIC));
                    break;
                case FLAG_PREFIX:
                    options.set(PREFIX, getStringOption(nextArgument, FLAG_PREFIX, DESC_PREFIX));
                    break;
                case FLAG_SUFFIX:
                    options.set(SUFFIX, getStringOption(nextArgument, FLAG_SUFFIX, DESC_SUFFIX));
                    break;
                case FLAG_KEYSTORE_FILE:
                    options.set(KEYSTORE_FILE, getPathOption(nextArgument, FLAG_KEYSTORE_FILE, DESC_KEYSTORE_FILE));
                    break;
                case FLAG_KEYSTORE_PASSWORD:
                    options.set(KEYSTORE_PASSWORD,
                            getStringOption(nextArgument, FLAG_KEYSTORE_PASSWORD, DESC_KEYSTORE_PASSWORD));
                    break;
                default:
                    err.println("Unknown flag " + currentArgument);
                    return null;
                }
            }

            if (!flagsSeen.contains(FLAG_ARCHIVE_DIRECTORY) && options.get(ARCHIVE_DIRECTORY) == null) {
                err.println(DESC_ARCHIVE_DIRECTORY + " must be specified using flag " + FLAG_ARCHIVE_DIRECTORY);
                return null;
            }
            if (!flagsSeen.contains(FLAG_TOPIC) && options.get(TOPIC) == null) {
                err.println(DESC_TOPIC + " must be specified using flag " + FLAG_TOPIC);
                return null;
            }
            if (!flagsSeen.contains(FLAG_KEYSTORE_FILE) && options.get(KEYSTORE_FILE) == null) {
                err.println(DESC_KEYSTORE_FILE + " must be specified using flag " + FLAG_KEYSTORE_FILE);
                return null;
            }
            if (!flagsSeen.contains(FLAG_KEYSTORE_PASSWORD) && options.get(KEYSTORE_PASSWORD) == null) {
                err.println(DESC_KEYSTORE_PASSWORD + " must be specified using flag " + FLAG_KEYSTORE_PASSWORD);
                return null;
            }

            return options;
        }

        private void printHelp() {
            out.println(String.format("arguments: %s <path> %s <topic> [%s <prefix>] "
                    + "[%s <suffix>] %s <path> %s <password>", FLAG_ARCHIVE_DIRECTORY, FLAG_TOPIC, FLAG_PREFIX,
                    FLAG_SUFFIX, FLAG_KEYSTORE_FILE, FLAG_KEYSTORE_PASSWORD));
            out.println("");
            out.println(String.format("   %-15s %s", FLAG_ARCHIVE_DIRECTORY, DESC_ARCHIVE_DIRECTORY));
            out.println(String.format("   %-15s %s", FLAG_TOPIC, DESC_TOPIC));
            out.println(String.format("   %-15s %s", FLAG_PREFIX, DESC_PREFIX));
            out.println(String.format("   %-15s %s", FLAG_SUFFIX, DESC_SUFFIX));
            out.println(String.format("   %-15s %s", FLAG_KEYSTORE_FILE, DESC_KEYSTORE_FILE));
            out.println(String.format("   %-15s %s", FLAG_KEYSTORE_PASSWORD, DESC_KEYSTORE_PASSWORD));
        }

        private Path getPathOption(String nextArgument, String flag, String description) {
            if (nextArgument == null) {
                err.println(flag + " flag must be followed by " + description);
                return null;
            }
            final File file = new File(nextArgument);
            if (!file.exists()) {
                err.println(file + " not found");
                return null;
            }
            return file.toPath();
        }

        private String getStringOption(String nextArgument, String flag, String description) {
            if (nextArgument == null) {
                err.println(flag + " flag must be followed by " + description);
                return null;
            }
            return nextArgument;
        }

    }

    /**
     * This interface exists solely to allow tests to replace the default FileNamingPolicy used by {@link #main}.
     * <br/>
     * The default policy {@link TimeStampFileNamingPolicy} sorts files by their timestamp meta-data but this is only
     * accurate to the nearest second and therefore doesn't correctly sort the files generated by tests (as multiple
     * files can be created within a single second).
     */
    interface FileNamingPolicyFactory {

        FileNamingPolicy newFileNamingPolicy(File liveFile, String suffix, String prefix);

    }

    static class DefaultFileNamingPolicyFactory implements FileNamingPolicyFactory {

        @Override
        public FileNamingPolicy newFileNamingPolicy(File liveFile, String suffix, String prefix) {
            return new TimeStampFileNamingPolicy(liveFile, suffix, prefix);
        }
    }

    private CsvSecureArchiveVerifierCli() {
        // never created
    }

}