CsvSecureVerifier.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.handlers.csv.CsvSecureConstants.*;
import static org.forgerock.audit.handlers.csv.CsvSecureUtils.*;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.security.SignatureException;
import java.util.Arrays;
import java.util.Map;

import javax.crypto.SecretKey;

import org.forgerock.audit.secure.SecureStorage;
import org.forgerock.audit.secure.SecureStorageException;
import org.forgerock.util.encode.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.supercsv.io.CsvMapReader;
import org.supercsv.io.ICsvMapReader;
import org.supercsv.prefs.CsvPreference;

/**
 * This class aims to verify a secure CSV file.
 */
class CsvSecureVerifier {

    private static final Logger logger = LoggerFactory.getLogger(CsvSecureVerifier.class);

    private File csvFile;
    private final CsvPreference csvPreference;
    private final HmacCalculator hmacCalculator;
    private final SecureStorage secureStorage;
    private String lastHMAC;
    private byte[] lastSignature;
    private String[] headers;

    /**
     * Constructs a new verifier.
     *
     * @param csvFile
     *            the CSV file to verify
     * @param csvPreference
     *            the CSV preference to use
     * @param secureStorage
     *            the secure storage containing keys
     */
    public CsvSecureVerifier(File csvFile, CsvPreference csvPreference, SecureStorage secureStorage) {
        this.csvFile = csvFile;
        this.csvPreference = csvPreference;
        this.secureStorage = secureStorage;

        try {
            SecretKey initialKey = secureStorage.readInitialKey();
            if (initialKey == null) {
                throw new IllegalStateException("Expecting to find an initial key into the keystore.");
            }

            this.hmacCalculator = new HmacCalculator(HMAC_ALGORITHM);
            this.hmacCalculator.setCurrentKey(initialKey.getEncoded());
        } catch (SecureStorageException e) {
            throw new IllegalStateException(e);
        }
    }

    public VerificationResult verify() throws IOException {
        boolean lastRowWasSigned = false;
        try (ICsvMapReader csvReader = newBufferedCsvMapReader()) {
            final String[] header = csvReader.getHeader(true);

            // Ensure header contains HEADER_HMAC and HEADER_SIGNATURE
            int checkCount = 0;
            for (String string : header) {
                if (HEADER_HMAC.equals(string) || HEADER_SIGNATURE.equals(string)) {
                    checkCount++;
                }
            }

            if (!(HEADER_HMAC.equals(header[header.length - 2])
                    && HEADER_SIGNATURE.equals(header[header.length - 1]))) {
                String msg = "Found only " + checkCount + " checked headers from : " + Arrays.toString(header);
                logger.debug(msg);
                return newVerificationFailureResult(msg);
            }
            this.headers = new String[header.length - 2];
            System.arraycopy(header, 0, this.headers, 0, this.headers.length);

            // Check the row one after the other
            Map<String, String> values;
            while ((values = csvReader.read(header)) != null) {
                logger.trace("Verifying row {}", csvReader.getRowNumber());
                lastRowWasSigned = false;
                final String encodedSign = values.get(HEADER_SIGNATURE);
                // The field HEADER_SIGNATURE is filled so let's check that special row
                if (encodedSign != null) {
                    if (csvReader.getRowNumber() == 2) {
                        // Special case : this is a rotated file, do not verify the signature but store it.
                        lastSignature = Base64.decode(encodedSign);
                    } else if (!verifySignature(encodedSign)) {
                        String msg = "The signature at row " + csvReader.getRowNumber() + " is not correct.";
                        logger.trace(msg);
                        return newVerificationFailureResult(msg);
                    } else {
                        logger.trace("The signature at row {} is correct.", csvReader.getRowNumber());
                        lastRowWasSigned = true;
                        // The signature is OK : let's continue to the next row
                        continue;
                    }
                } else {
                    // Otherwise every row must contain a valid HEADER_HMAC
                    if (!verifyHMAC(values, header)) {
                        String msg = "The HMac at row " + csvReader.getRowNumber() + " is not correct.";
                        logger.trace(msg);
                        return newVerificationFailureResult(msg);
                    } else {
                        logger.trace("The HMac at row {} is correct.", csvReader.getRowNumber());
                        // The HMAC is OK : let's continue to the next row
                        continue;
                    }
                }
            }
        }

        try {
            SecretKey currentKey = secureStorage.readCurrentKey();
            if (currentKey != null) {
                boolean keysMatch = Arrays.equals(hmacCalculator.getCurrentKey().getEncoded(), currentKey.getEncoded());
                logger.trace("keysMatch={}, lastRowWasSigned={}", keysMatch, lastRowWasSigned);
                if (!keysMatch) {
                    return newVerificationFailureResult("Final HMAC key doesn't match expected value");
                } else if (!lastRowWasSigned) {
                    return newVerificationFailureResult("Missing final signature");
                } else {
                    return newVerificationSuccessResult();
                }
            } else {
                logger.trace("currentKey is null");
                return newVerificationFailureResult("Final HMAC key is null");
            }
        } catch (SecureStorageException ex) {
            throw new IOException(ex);
        }
    }

    private CsvMapReader newBufferedCsvMapReader() throws FileNotFoundException {
        return new CsvMapReader(new BufferedReader(new FileReader(csvFile)), csvPreference);
    }

    private VerificationResult newVerificationFailureResult(String msg) {
        return new VerificationResult(csvFile, false, msg);
    }

    private VerificationResult newVerificationSuccessResult() {
        return new VerificationResult(csvFile, true, "");
    }

    private boolean verifyHMAC(Map<String, String> values, String[] header) throws IOException {
        try {
            String actualHMAC = values.get(HEADER_HMAC);
            String expectedHMAC = hmacCalculator.calculate(dataToSign(logger, values, dropExtraHeaders(header)));
            if (!actualHMAC.equals(expectedHMAC)) {
                logger.trace("The HMAC is not valid. Expected : {} Found : {}", expectedHMAC, actualHMAC);
                return false;
            } else {
                lastHMAC = actualHMAC;
                return true;
            }
        } catch (SignatureException ex) {
            logger.error(ex.getMessage(), ex);
            throw new IOException(ex);
        }
    }

    private boolean verifySignature(final String encodedSign) throws IOException {
        try {
            byte[] signature = Base64.decode(encodedSign);
            boolean verify = secureStorage.verify(dataToSign(lastSignature, lastHMAC), signature);
            if (!verify) {
                logger.trace("The signature does not match the expecting one.");
                return false;
            } else {
                lastSignature = signature;
                return true;
            }
        } catch (SecureStorageException ex) {
            logger.error(ex.getMessage(), ex);
            throw new IOException(ex);
        }
    }

    private String[] dropExtraHeaders(String... header) {
        // Drop the 2 last headers : HEADER_HMAC and HEADER_SIGNATURE
        return Arrays.copyOf(header, header.length - 2);
    }

    /**
     * Returns the headers of the underlying CSV.
     *
     * @return the headers of the underlying CSV
     */
    public String[] getHeaders() {
        return headers;
    }

    /**
     * Returns the latest read and validated HMAC.
     *
     * @return the latest read and validated HMAC
     */
    public String getLastHMAC() {
        return lastHMAC;
    }

    /**
     * Returns the latest read and validated signature.
     *
     * @return the latest read and validated signature
     */
    public byte[] getLastSignature() {
        return lastSignature;
    }

    static final class VerificationResult {

        private final File archiveFile;
        private final boolean passedVerification;
        private final String failureReason;

        VerificationResult(final File archiveFile, final boolean passedVerification, final String message) {
            this.archiveFile = archiveFile;
            this.passedVerification = passedVerification;
            this.failureReason = message;
        }

        public File getArchiveFile() {
            return archiveFile;
        }

        public boolean hasPassedVerification() {
            return passedVerification;
        }

        public String getFailureReason() {
            return failureReason;
        }
    }
}