diff --git a/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureUtils.java b/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureUtils.java index 98597406e..ebc9df1d4 100644 --- a/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureUtils.java +++ b/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureUtils.java @@ -17,10 +17,13 @@ package org.forgerock.audit.handlers.csv; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Map; +import java.util.regex.Pattern; import org.forgerock.util.encode.Base64; import org.slf4j.Logger; @@ -30,6 +33,19 @@ */ final class CsvSecureUtils { + /** + * The first byte of the data that the HMAC of a row covers. UTF-8 never holds it, so these data never equal + * {@link #concatenatedValues(Map, String...)}, the UTF-8 bytes of a row that a previous version signed: an HMAC + * verifies a row in one format only. + */ + private static final int DATA_TO_SIGN_MARKER = 0xFF; + + /** + * A line break that the CSV writer writes as its end of line symbols: a CR, a CRLF, or a LF, which the CSV reader + * reads back as a LF. The writer writes a CR followed by a LF as one line break. + */ + private static final Pattern LINE_BREAK = Pattern.compile("\r\n?"); + /** * Obtain the data to sign. * @param lastSignature The last signature. @@ -71,12 +87,49 @@ private static byte[] concat(byte[]... arrays) { } /** - * Get the data to sign. + * Get the data to sign: the byte 0xFF, then the name and the value of each column, as the CSV reader reads them + * back, in UTF-8, each one after the number of its bytes as a big-endian int. + *

+ * The CSV reader reads an empty name or value as {@code null}, so both are signed as empty, and a line break + * as a LF, so a CR or a CRLF is signed as a LF. * @param logger the value of logger * @param values the value of values * @param nameMapping the value of nameMapping */ static byte[] dataToSign(Logger logger, Map values, String... nameMapping) { + ByteArrayOutputStream data = new ByteArrayOutputStream(); + data.write(DATA_TO_SIGN_MARKER); + for (String h : nameMapping) { + final Object value = values.get(h); + writeLengthAndBytes(data, readBack(h)); + writeLengthAndBytes(data, readBack(value != null ? value.toString() : null)); + } + return data.toByteArray(); + } + + private static void writeLengthAndBytes(ByteArrayOutputStream data, String field) { + final byte[] bytes = field.getBytes(StandardCharsets.UTF_8); + data.write(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array(), 0, Integer.BYTES); + data.write(bytes, 0, bytes.length); + } + + /** + * Returns a name or a value as the CSV reader reads it back once written: empty if it is {@code null} or empty, + * with each line break as a LF. + * @param field the name or the value, may be {@code null} + * @return the name or the value as the CSV reader reads it back, empty for {@code null} + */ + static String readBack(String field) { + return field != null ? LINE_BREAK.matcher(field).replaceAll("\n") : ""; + } + + /** + * Get the data that the HMAC of a row covered before {@link #dataToSign(Logger, Map, String...)}: the values + * concatenated, which do not tell where a value ends, nor its column. + * @param values the value of values + * @param nameMapping the value of nameMapping + */ + static byte[] concatenatedValues(Map values, String... nameMapping) { StringBuilder tmp = new StringBuilder(); for (String h : nameMapping) { final Object value = values.get(h); diff --git a/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureVerifier.java b/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureVerifier.java index 4041ab0c0..3908b4b53 100644 --- a/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureVerifier.java +++ b/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureVerifier.java @@ -196,9 +196,11 @@ private VerificationResult newVerificationSuccessResult() { private boolean verifyHMAC(Map 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); + String[] columns = dropExtraHeaders(header); + // A row that a previous version wrote has the HMAC of its values concatenated. + if (!hmacCalculator.verify(actualHMAC, dataToSign(logger, values, columns), + concatenatedValues(values, columns))) { + logger.trace("The HMAC is not valid. Found : {}", actualHMAC); return false; } else { lastHMAC = actualHMAC; diff --git a/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/HmacCalculator.java b/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/HmacCalculator.java index db7b1e38a..de748cc97 100644 --- a/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/HmacCalculator.java +++ b/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/HmacCalculator.java @@ -65,6 +65,37 @@ public void setCurrentKey(byte[] bytes) { * @throws SignatureException if the HMAC cannot be generated. */ public String calculate(byte[] data) throws SignatureException { + String result = hmac(data); + + // Compute the next key's iteration + computeNextKeyIteration(); + + return result; + } + + /** + * Tells whether the HMAC of one of the given data is the expected one. With the current key, computes the HMAC + * of each data in turn until one is the expected HMAC, then computes the next key's iteration once, as + * {@link #calculate(byte[])} does. + * + * @param expectedHmac the expected HMAC, base64 encoded + * @param data the data that the expected HMAC may have been calculated for, in the order they are tried + * @return {@code true} if the HMAC of one of the data is the expected one. + * @throws SignatureException if the HMAC cannot be generated. + */ + public boolean verify(String expectedHmac, byte[]... data) throws SignatureException { + boolean verified = false; + for (int i = 0; i < data.length && !verified; i++) { + verified = hmac(data[i]).equals(expectedHmac); + } + + // Compute the next key's iteration + computeNextKeyIteration(); + + return verified; + } + + private String hmac(byte[] data) throws SignatureException { try { mac.reset(); mac.init(currentKey); @@ -73,12 +104,7 @@ public String calculate(byte[] data) throws SignatureException { byte[] rawHmac = mac.doFinal(data); // base64-encode the hmac - String result = Base64.encode(rawHmac); - - // Compute the next key's iteration - computeNextKeyIteration(); - - return result; + return Base64.encode(rawHmac); } catch (InvalidKeyException | IllegalStateException e) { throw new SignatureException("Failed to generate HMAC : " + e.getMessage()); } diff --git a/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/SecureCsvWriter.java b/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/SecureCsvWriter.java index 0e57b3a2b..8e4c01626 100644 --- a/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/SecureCsvWriter.java +++ b/commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/SecureCsvWriter.java @@ -23,6 +23,7 @@ import static org.forgerock.audit.handlers.csv.CsvSecureConstants.HEADER_SIGNATURE; import static org.forgerock.audit.handlers.csv.CsvSecureConstants.SIGNATURE_ALGORITHM; import static org.forgerock.audit.handlers.csv.CsvSecureUtils.dataToSign; +import static org.forgerock.audit.handlers.csv.CsvSecureUtils.readBack; import static org.forgerock.util.Reject.checkNotNull; import java.io.File; @@ -169,15 +170,14 @@ class SecureCsvWriter implements CsvWriter, RolloverLifecycleHook { throw new IOException("The CSV file was tampered: " + verificationResult.getFailureReason()); } - // Assert that the 2 headers are equal. + // Assert that the 2 headers are equal, as the CSV reader reads them back. final String[] actualHeaders = verifier.getHeaders(); if (actualHeaders != null) { if (actualHeaders.length != headers.length) { throw new IOException("Resuming an existing CSV file but the headers do not match."); } for (int idx = 0; idx < actualHeaders.length; idx++) { - // Super CSV reads an empty column name as null, the expected headers hold none - if (!headers[idx].equals(actualHeaders[idx])) { + if (!readBack(actualHeaders[idx]).equals(readBack(headers[idx]))) { throw new IOException("Resuming an existing CSV file but the headers do not match."); } } diff --git a/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/CsvSecureUtilsTest.java b/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/CsvSecureUtilsTest.java new file mode 100644 index 000000000..0719d1a28 --- /dev/null +++ b/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/CsvSecureUtilsTest.java @@ -0,0 +1,69 @@ +/* + * 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 2026 3A Systems, LLC. + */ +package org.forgerock.audit.handlers.csv; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testng.annotations.Test; + +@SuppressWarnings("javadoc") +public class CsvSecureUtilsTest { + + private static final Logger logger = LoggerFactory.getLogger(CsvSecureUtilsTest.class); + + @Test + public void shouldConcatenateNonNullValuesInHeaderOrder() { + // the data that the HMAC of a row covered before, which the files written then still need + Map values = new HashMap<>(); + values.put("a", "x"); + values.put("b", null); + values.put("c", "z"); + + assertThat(CsvSecureUtils.concatenatedValues(values, "c", "b", "a")) + .isEqualTo("zx".getBytes(StandardCharsets.UTF_8)); + } + + @Test + public void shouldSignUtf8LengthsAndEmptyNamesAndValues() { + Map values = new HashMap<>(); + values.put("a", "é"); + + assertThat(CsvSecureUtils.dataToSign(logger, values, "a", "b", null)) + .isEqualTo(new byte[] { + (byte) 0xFF, + 0, 0, 0, 1, 'a', 0, 0, 0, 2, (byte) 0xC3, (byte) 0xA9, + 0, 0, 0, 1, 'b', 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0 }); + } + + @Test + public void shouldSignLineBreaksAsLf() { + Map values = new HashMap<>(); + values.put("a\rb", "c\r\nd\r\r\ne\nf"); + + assertThat(CsvSecureUtils.dataToSign(logger, values, "a\rb")) + .isEqualTo(new byte[] { + (byte) 0xFF, + 0, 0, 0, 3, 'a', '\n', 'b', + 0, 0, 0, 8, 'c', '\n', 'd', '\n', '\n', 'e', '\n', 'f' }); + } +} diff --git a/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/CsvSecureVerifierTest.java b/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/CsvSecureVerifierTest.java index 071cbe03b..68d1efdb9 100644 --- a/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/CsvSecureVerifierTest.java +++ b/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/CsvSecureVerifierTest.java @@ -16,14 +16,22 @@ */ package org.forgerock.audit.handlers.csv; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; +import static org.forgerock.audit.handlers.csv.SecureCsvWriterTest.KEYSTORE_FILENAME; +import static org.forgerock.audit.handlers.csv.SecureCsvWriterTest.KEYSTORE_PASSWORD; import java.io.File; import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import org.forgerock.audit.handlers.csv.CsvSecureVerifier.VerificationResult; import org.forgerock.audit.secure.JcaKeyStoreHandler; import org.forgerock.audit.secure.KeyStoreHandlerDecorator; import org.forgerock.audit.secure.KeyStoreSecureStorage; +import org.forgerock.util.encode.Base64; import org.supercsv.prefs.CsvPreference; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; @@ -45,6 +53,96 @@ public void shouldVerifyValidFile() throws Exception { assertThat(csvVerifier.verify().hasPassedVerification()).isTrue(); } + @Test + public void shouldVerifyFileWithHmacOfConcatenatedValues() throws Exception { + // written before the HMAC of a row covered the column names and where each value ends + File csvFile = new File("src/test/resources/secureCsvLegacyHmac.csv"); + KeyStoreHandlerDecorator keyStoreHandler = new KeyStoreHandlerDecorator( + new JcaKeyStoreHandler(CsvSecureConstants.KEYSTORE_TYPE, TRUSTSTORE_FILENAME, TRUSTSTORE_PASSWORD)); + CsvSecureVerifier csvVerifier = new CsvSecureVerifier(csvFile, CsvPreference.EXCEL_PREFERENCE, + new KeyStoreSecureStorage(keyStoreHandler, + keyStoreHandler.readPublicKeyFromKeyStore(KeyStoreSecureStorage.ENTRY_SIGNATURE))); + VerificationResult verificationResult = csvVerifier.verify(); + assertThat(verificationResult.hasPassedVerification()).as(verificationResult.getFailureReason()).isTrue(); + } + + @DataProvider + public Object[][] editsOfSignedData() { + return new Object[][] { + // Edits that keep the values concatenated, which the HMAC of a row covered before: + // characters moved across a column boundary + { "\nbob,x,", "\nbo,bx,", "The HMac at row 2 is not correct." }, + // a value moved into the empty neighbouring column + { "\n,bob,", "\nbob,,", "The HMac at row 3 is not correct." }, + // the column names swapped in the header + { "first,second,", "second,first,", "The HMac at row 2 is not correct." }, + // The row as one value that holds the data its HMAC covers, but the leading byte: the values concatenated + // are these data, which the verifier would accept if the data did not start with a byte UTF-8 never holds. + { "\nbob,x,", "\n\u0000\u0000\u0000\u0005first\u0000\u0000\u0000\u0003bob" + + "\u0000\u0000\u0000\u0006second\u0000\u0000\u0000\u0001x,,", + "The HMac at row 2 is not correct." }, + // Characters moved across a column boundary, next to a value that holds the next column name after its + // length: the names alone would tell where a value ends, but for such a value. + { "\na\u0000\u0000\u0000\u0006secondb,c,", "\na,b\u0000\u0000\u0000\u0006secondc,", + "The HMac at row 4 is not correct." }, + // a column name removed from the header, which the CSV reader reads as null + { "first,second,", ",second,", "The HMac at row 2 is not correct." } + }; + } + + @Test(dataProvider = "editsOfSignedData") + public void shouldNotVerifyEditOfSignedData(String original, String edited, String failureReason) + throws Exception { + final File csvFile = writeSecureCsvFile(new String[] { "bob", "x" }, new String[] { null, "bob" }, + new String[] { "a\u0000\u0000\u0000\u0006secondb", "c" }); + VerificationResult verificationResult = verifySecureCsvFile(csvFile); + assertThat(verificationResult.hasPassedVerification()).as(verificationResult.getFailureReason()).isTrue(); + + final String content = new String(Files.readAllBytes(csvFile.toPath()), UTF_8); + assertThat(content.indexOf(original)).as(original).isNotNegative().isEqualTo(content.lastIndexOf(original)); + Files.write(csvFile.toPath(), content.replace(original, edited).getBytes(UTF_8)); + + verificationResult = verifySecureCsvFile(csvFile); + assertThat(verificationResult.hasPassedVerification()).isFalse(); + assertThat(verificationResult.getFailureReason()).isEqualTo(failureReason); + } + + @Test + public void shouldNotVerifyRowWithoutHmac() throws Exception { + final File csvFile = writeSecureCsvFile(new String[] { "bob", "x" }); + final String content = new String(Files.readAllBytes(csvFile.toPath()), UTF_8); + final String edited = content.replaceFirst("\nbob,x,[^,\r\n]+,", "\nbob,x,,"); + assertThat(edited).isNotEqualTo(content); + Files.write(csvFile.toPath(), edited.getBytes(UTF_8)); + + final VerificationResult verificationResult = verifySecureCsvFile(csvFile); + assertThat(verificationResult.hasPassedVerification()).isFalse(); + assertThat(verificationResult.getFailureReason()).isEqualTo("The HMac at row 2 is not correct."); + } + + @Test + public void shouldVerifyLineBreaksInValues() throws Exception { + // The CSV writer writes a CR, a CRLF and a LF in a value as its end of line symbols, read back as LF. + final File csvFile = writeSecureCsvFile(new String[] { "a\rb", "c\r\nd" }, new String[] { "e\nf", "g\r\r\nh" }); + + final VerificationResult verificationResult = verifySecureCsvFile(csvFile); + assertThat(verificationResult.hasPassedVerification()).as(verificationResult.getFailureReason()).isTrue(); + } + + @Test + public void shouldVerifyAndResumeFileWithLineBreaksInColumnNames() throws Exception { + final String[] header = { "first\rname", "second\r\nname" }; + final File csvFile = newSecureCsvFile(); + writeSecureCsvFile(csvFile, header, new String[] { "a", "b" }); + VerificationResult verificationResult = verifySecureCsvFile(csvFile); + assertThat(verificationResult.hasPassedVerification()).as(verificationResult.getFailureReason()).isTrue(); + + // the writer compares the names it reads back from the file with the names it writes + writeSecureCsvFile(csvFile, header, new String[] { "c", "d" }); + verificationResult = verifySecureCsvFile(csvFile); + assertThat(verificationResult.hasPassedVerification()).as(verificationResult.getFailureReason()).isTrue(); + } + @Test(dataProvider = "invalidContent") public void shouldNotVerify(String filename) throws Exception { File csvFile = new File(filename); @@ -84,4 +182,53 @@ public Object[][] invalidContent() { { "src/test/resources/secureCsvInvalidMissingFinalSignature.csv" } }; } + + /** Writes a tamper evident file with the columns first and second, one row per array of values. */ + private static File writeSecureCsvFile(String[]... rows) throws Exception { + final File csvFile = newSecureCsvFile(); + writeSecureCsvFile(csvFile, new String[] { "first", "second" }, rows); + return csvFile; + } + + private static File newSecureCsvFile() throws Exception { + final File logDirectory = Files.createTempDirectory("CsvSecureVerifierTest").toFile(); + return new File(logDirectory, "tamper-evident.csv"); + } + + /** Writes one row per array of values to a tamper evident file, which the writer resumes if it exists. */ + private static void writeSecureCsvFile(File csvFile, String[] header, String[]... rows) throws Exception { + final CsvAuditEventHandlerConfiguration config = new CsvAuditEventHandlerConfiguration(); + config.getSecurity().setEnabled(true); + // leave the final signature to close() + config.getSecurity().setSignatureInterval("5 minutes"); + try (SecureCsvWriter writer = new SecureCsvWriter(csvFile, header, CsvPreference.EXCEL_PREFERENCE, config, + signatureKeyStore(), new Random(42))) { + for (String[] row : rows) { + final Map values = new HashMap<>(); + for (int i = 0; i < header.length; i++) { + if (row[i] != null) { + values.put(header[i], row[i]); + } + } + writer.writeEvent(values); + } + } + } + + /** Verifies a file that {@link #writeSecureCsvFile} wrote, with its own keystore. */ + private static VerificationResult verifySecureCsvFile(File csvFile) throws Exception { + final KeyStoreHandlerDecorator signatureKeyStore = signatureKeyStore(); + final String password = Base64.encode( + signatureKeyStore.readSecretKeyFromKeyStore(CsvSecureConstants.ENTRY_PASSWORD).getEncoded()); + final KeyStoreSecureStorage secureStorage = new KeyStoreSecureStorage( + new JcaKeyStoreHandler(CsvSecureConstants.KEYSTORE_TYPE, + CsvSecureUtils.keyStoreFile(csvFile).getPath(), password), + signatureKeyStore.readPublicKeyFromKeyStore(KeyStoreSecureStorage.ENTRY_SIGNATURE)); + return new CsvSecureVerifier(csvFile, CsvPreference.EXCEL_PREFERENCE, secureStorage).verify(); + } + + private static KeyStoreHandlerDecorator signatureKeyStore() throws Exception { + return new KeyStoreHandlerDecorator( + new JcaKeyStoreHandler(CsvSecureConstants.KEYSTORE_TYPE, KEYSTORE_FILENAME, KEYSTORE_PASSWORD)); + } } diff --git a/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/HmacCalculatorTest.java b/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/HmacCalculatorTest.java index 1d1d65d9a..9e124eab7 100644 --- a/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/HmacCalculatorTest.java +++ b/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/HmacCalculatorTest.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2015 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.audit.handlers.csv; @@ -58,4 +59,20 @@ public void shouldCalculateHMAC() throws Exception { assertThat(base64HMAC).isEqualTo("xDSrLEfpe3V1t5Y9y2I4bCIUIdWcQKum9HiVDHXfHFQ="); } + @Test + public void shouldVerifyHMACOfOneOfTheDataWithOneKey() throws Exception { + HmacCalculator hmacCalculator = new HmacCalculator("HmacSHA256"); + hmacCalculator.setCurrentKey("forgerock".getBytes(StandardCharsets.UTF_8)); + + byte[] other = "rock".getBytes(StandardCharsets.UTF_8); + byte[] data = "rockstar".getBytes(StandardCharsets.UTF_8); + + // The HMACs of data with the first, second and fourth keys, the first two as in shouldCalculateHMAC. + // A match on the second data, a match on the first data and a mismatch each update the key once. + assertThat(hmacCalculator.verify("m9Yir8xEvAS4leA6IZGJQ++OGkyMMHnf334N94AlVIs=", other, data)).isTrue(); + assertThat(hmacCalculator.verify("xDSrLEfpe3V1t5Y9y2I4bCIUIdWcQKum9HiVDHXfHFQ=", data, other)).isTrue(); + assertThat(hmacCalculator.verify(null, data, other)).isFalse(); + assertThat(hmacCalculator.verify("vDQb23X5yYDWoL7NcWmSGzrHBBYNWwtvA9w6oGOq58o=", data)).isTrue(); + } + } diff --git a/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/SecureCsvWriterTest.java b/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/SecureCsvWriterTest.java index 4b2ef8bdb..124f528f0 100644 --- a/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/SecureCsvWriterTest.java +++ b/commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/SecureCsvWriterTest.java @@ -37,6 +37,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.security.KeyStore; import java.security.PublicKey; @@ -231,6 +232,36 @@ public void shouldGeneratePeriodicallySignature() throws Exception { new File("target/test-classes/shouldGeneratePeriodicallySignature-expected.txt"))); } + @Test + public void shouldResumeFileWithHmacOfConcatenatedValues() throws Exception { + final Path logDirectory = Files.createTempDirectory("SecureCsvWriterTest"); + final File actual = new File(logDirectory.toFile(), "tamper-evident-test.csv"); + // written before the HMAC of a row covered the column names and where each value ends + Files.copy(Paths.get("target/test-classes/secureCsvLegacyHmac.csv"), actual.toPath()); + // the keystore of the file holds the keys that the truststore of CsvSecureVerifierTest holds + final KeyStoreHandlerDecorator truststore = new KeyStoreHandlerDecorator(new JcaKeyStoreHandler(KEYSTORE_TYPE, + CsvSecureVerifierTest.TRUSTSTORE_FILENAME, CsvSecureVerifierTest.TRUSTSTORE_PASSWORD)); + final String keystorePassword = + Base64.encode(keyStoreHandler.readSecretKeyFromKeyStore(ENTRY_PASSWORD).getEncoded()); + final PublicKey publicKey = keyStoreHandler.readPublicKeyFromKeyStore(KeyStoreSecureStorage.ENTRY_SIGNATURE); + final SecureStorage fileSecureStorage = new KeyStoreSecureStorage( + new JcaKeyStoreHandler(KEYSTORE_TYPE, actual.getPath() + ".keystore", keystorePassword), publicKey); + fileSecureStorage.writeInitialKey(truststore.readSecretKeyFromKeyStore(ENTRY_INITIAL_KEY)); + fileSecureStorage.writeCurrentKey(truststore.readSecretKeyFromKeyStore(ENTRY_CURRENT_KEY)); + + try (SecureCsvWriter secureCsvWriter = new SecureCsvWriter( + actual, new String[]{"FOO"}, CsvPreference.EXCEL_PREFERENCE, + createBasicSecureConfig("5 minutes"), keyStoreHandler, random)) { + secureCsvWriter.writeEvent(singletonMap("FOO", "resumed")); + } + + final VerificationResult verificationResult = new CsvSecureVerifier(actual, CsvPreference.EXCEL_PREFERENCE, + new KeyStoreSecureStorage(new JcaKeyStoreHandler(KEYSTORE_TYPE, actual.getPath() + ".keystore", + keystorePassword), publicKey)).verify(); + assertThat(verificationResult.hasPassedVerification()).as(verificationResult.getFailureReason()).isTrue(); + assertThat(contentOf(actual)).containsPattern("(?m)^quix,").containsPattern("(?m)^resumed,"); + } + @Test public void shouldStopSchedulerAndCloseFileWhenFinalSignatureFails() throws Exception { final Path logDirectory = Files.createTempDirectory("SecureCsvWriterTest"); diff --git a/commons/audit/handler-csv/src/test/resources/secureCsvLegacyHmac.csv b/commons/audit/handler-csv/src/test/resources/secureCsvLegacyHmac.csv new file mode 100644 index 000000000..605283f0d --- /dev/null +++ b/commons/audit/handler-csv/src/test/resources/secureCsvLegacyHmac.csv @@ -0,0 +1,5 @@ +FOO,HMAC,SIGNATURE +bar,uLkBIiPY0+yyseXNACJX5SBwqV4RDSN8Ab8Jz7c3cYI=, +,,BZJxI9CrZhjPkuOZNFv+qBR1+PYk9y/sZMAZcvuhfXkbYCZ8Nu6g6OHxWxaPdEJn3aiRGR7e0IVD9DfeGUrI02wSpLCkOZCNv1KdsEHadO4NbTQvnOyN16RqX2C85aHjTgVBbpLr88ZZAxEU99bE+jocHNci7n3dXSoS2RnC1lZyqqiiyTlSh9J3OjpizfBxhc+R5B+JKKnGee2gArgF8PHgqcRV2cYL1r5lmEnwk4i0vcHadp1gNfH3UiWsa8u28xBHtecOm2+49l+t+HbIfTssSJVxv35ISNgOUJfofddq30ECTCVhk9j277e3F/F4sjNeMP7iJtDGMUmUolp6Gg== +quix,taHEQat9MgErQvhG1iRGSJQPfe1SzIemjSyBtB5+hfU=, +,,Q7/AxkQixVpyRfDnCv8Sj36Tn55cWfjNHZ7+qjX+BlQSmNf5B4M1tB2EOu+B+pPccOaB2fvooDtpAMo7E9Qo0tDJtX0xiV2D/sGY5gHNS/UD8gX0gaFafJsP9bBcE/Zt1u1zGPKlqlot4V8+ke6clW8FrjdC9AQGW9nRiAIKHEGDjv8+g0vvMy4PrczmrIaDLskkFcU+4uPqsAJMev6mg/JA+3Dwd5Htfh0c53OZe6lxI+MYm2nFnF81U/3Nr2/mT/rJDkFBJEGkLcUgjXhckXX4WVuhzS92bYJfrE3JmKcgYSrmu5ajtNiDAh1/RjHGS8cGpKLnZ4ekQDr4u253AQ== diff --git a/commons/audit/handler-csv/src/test/resources/shouldGenerateHMACColumn-expected.txt b/commons/audit/handler-csv/src/test/resources/shouldGenerateHMACColumn-expected.txt index 55790108f..85b16ab05 100644 --- a/commons/audit/handler-csv/src/test/resources/shouldGenerateHMACColumn-expected.txt +++ b/commons/audit/handler-csv/src/test/resources/shouldGenerateHMACColumn-expected.txt @@ -1,4 +1,4 @@ FOO,HMAC,SIGNATURE -bar,uLkBIiPY0+yyseXNACJX5SBwqV4RDSN8Ab8Jz7c3cYI=, -quix,taHEQat9MgErQvhG1iRGSJQPfe1SzIemjSyBtB5+hfU=, -,,g0dSW8i7orW7Mb4voYoAGI+v1Lqp0zsEZGBZ6CWSpcjeswHGgXho9HggqTQgT3nnezj/hl6djnePg2LrIBCCkDGpuWAIXerMpyJO0eySSWi8cdNckyMnZZNU3YgaTmmg16mcwn1CQ9O8XO4a9suAovvAmq+6iVMgjK05wKAZORLq01MqIPCxsH9xaoUgBIVDXJcmgu5UlYAYPjcc4tgcAFx9Sph3M697VyI2iyZuD5fvsho7kJZTAqVF4ijRlQ0WHQGWJXKLcQ6OJeBARRPSP9KXODTRMmtJHCMcz6eTclAR5zkGxso+nDXO3dlzwSruEoZxNcV4llAzLH7gvgbfiQ== +bar,L1QgQJehhDbP8DQWdHh2l4czH9EamrzjzZ7MHjFxyAY=, +quix,96AsMYOd6jt5bRo2UtX1GKN7891ZFFe1iuz4chWK7B4=, +,,UAx5ihDeksfv+nbLB7F6xHQUDCyzMemGrXR8huJuh0IPqKKt6mu9NdkeRYgbzeRQkUL6m1WKGl+TvDYa8oaz5WoM6JyrqGapV9lKOQ3VdfoKGmmIbkhatYPcSMyY8kXlUw/ous7VIEZ+lY+i1IgHiA+fSdGHZJSYDjdl1kq9OL+BbYlZEur8Olhu10GEim/otwG18OQl6ZRBWeXz/atKqw7GRKAVa2RDlI2I/16/6FLkxyAr+nXk4hOSGRwJZK1IEcEgOQyfo0MeeidhLZCkRrcK9BpLeLWrSod9nURaMoZTKFMaUthOCtQr/dLMSXUOv8IloHkBkQrAtjnYb2ILjg== diff --git a/commons/audit/handler-csv/src/test/resources/shouldGeneratePeriodicallySignature-expected.txt b/commons/audit/handler-csv/src/test/resources/shouldGeneratePeriodicallySignature-expected.txt index 605283f0d..b4965204f 100644 --- a/commons/audit/handler-csv/src/test/resources/shouldGeneratePeriodicallySignature-expected.txt +++ b/commons/audit/handler-csv/src/test/resources/shouldGeneratePeriodicallySignature-expected.txt @@ -1,5 +1,5 @@ FOO,HMAC,SIGNATURE -bar,uLkBIiPY0+yyseXNACJX5SBwqV4RDSN8Ab8Jz7c3cYI=, -,,BZJxI9CrZhjPkuOZNFv+qBR1+PYk9y/sZMAZcvuhfXkbYCZ8Nu6g6OHxWxaPdEJn3aiRGR7e0IVD9DfeGUrI02wSpLCkOZCNv1KdsEHadO4NbTQvnOyN16RqX2C85aHjTgVBbpLr88ZZAxEU99bE+jocHNci7n3dXSoS2RnC1lZyqqiiyTlSh9J3OjpizfBxhc+R5B+JKKnGee2gArgF8PHgqcRV2cYL1r5lmEnwk4i0vcHadp1gNfH3UiWsa8u28xBHtecOm2+49l+t+HbIfTssSJVxv35ISNgOUJfofddq30ECTCVhk9j277e3F/F4sjNeMP7iJtDGMUmUolp6Gg== -quix,taHEQat9MgErQvhG1iRGSJQPfe1SzIemjSyBtB5+hfU=, -,,Q7/AxkQixVpyRfDnCv8Sj36Tn55cWfjNHZ7+qjX+BlQSmNf5B4M1tB2EOu+B+pPccOaB2fvooDtpAMo7E9Qo0tDJtX0xiV2D/sGY5gHNS/UD8gX0gaFafJsP9bBcE/Zt1u1zGPKlqlot4V8+ke6clW8FrjdC9AQGW9nRiAIKHEGDjv8+g0vvMy4PrczmrIaDLskkFcU+4uPqsAJMev6mg/JA+3Dwd5Htfh0c53OZe6lxI+MYm2nFnF81U/3Nr2/mT/rJDkFBJEGkLcUgjXhckXX4WVuhzS92bYJfrE3JmKcgYSrmu5ajtNiDAh1/RjHGS8cGpKLnZ4ekQDr4u253AQ== +bar,L1QgQJehhDbP8DQWdHh2l4czH9EamrzjzZ7MHjFxyAY=, +,,IU9/MzHdMKqAb2bbhA552djuHapXO3SElBXffucIbiN6qeUYwCCi/YKK7vKmk51WBeKKEw9zZ9kf8188ie2wubYX/sWtAFclTOpbUmLNHoiOzyxrQ3vI3tT+lAKEriDBRzPk+EyS1NLfs5E7oyxMWd1I1WYftjNZFugFszRXtVZ94PO6QwiaUpZh1pRZbZKo0YXFUTzXQTINoqXryGBdxT03xlD2DQHdnHnLG4+9ncBjXGhtXdWVwyjcjl5jDQVR7SIhBt5O8Uj7k0lITTyc6oPbzhoFIfMH37pyoDYi0Y852PCkqPB/2JB2wKBYDRfoXNOCWD+DTM8lDjd3f2FJAA== +quix,96AsMYOd6jt5bRo2UtX1GKN7891ZFFe1iuz4chWK7B4=, +,,LpFoIXPF209t/+r8f7jzWIy5/h684rn9G9StDlh5oNcxdR7yBbccwhLpIbEtc4Hi0jLdnLkW9aTbwDUY0oJZtuEChCQc6mRFrucYFQ38z6iWr9W+R+Igqm0HJwiIqqB3JOsTkeGivDL873nM1JBtLQxHWvW2UeuJsF/X88MxBksAPzGrkXMRB5KcayoOfaN8UfGdqDBP1eptGrO+tBzuRSPmn/oYFlMbSnoQ7qQRgIL3WReZn2KmbzJ1SOCWJCT09B/alFEGppKPBe1CryvDJLTp11stNCZFJVI/x9wCJ14ek1wmba9AjZdYQLxaGN+DzBQeoa/h4M5v3TvKxnO0eA== diff --git a/commons/audit/handler-csv/src/test/resources/shouldGeneratePeriodicallySignature-partial.txt b/commons/audit/handler-csv/src/test/resources/shouldGeneratePeriodicallySignature-partial.txt index 3e4fe8672..5d8911319 100644 --- a/commons/audit/handler-csv/src/test/resources/shouldGeneratePeriodicallySignature-partial.txt +++ b/commons/audit/handler-csv/src/test/resources/shouldGeneratePeriodicallySignature-partial.txt @@ -1,3 +1,3 @@ FOO,HMAC,SIGNATURE -bar,uLkBIiPY0+yyseXNACJX5SBwqV4RDSN8Ab8Jz7c3cYI=, -,,BZJxI9CrZhjPkuOZNFv+qBR1+PYk9y/sZMAZcvuhfXkbYCZ8Nu6g6OHxWxaPdEJn3aiRGR7e0IVD9DfeGUrI02wSpLCkOZCNv1KdsEHadO4NbTQvnOyN16RqX2C85aHjTgVBbpLr88ZZAxEU99bE+jocHNci7n3dXSoS2RnC1lZyqqiiyTlSh9J3OjpizfBxhc+R5B+JKKnGee2gArgF8PHgqcRV2cYL1r5lmEnwk4i0vcHadp1gNfH3UiWsa8u28xBHtecOm2+49l+t+HbIfTssSJVxv35ISNgOUJfofddq30ECTCVhk9j277e3F/F4sjNeMP7iJtDGMUmUolp6Gg== +bar,L1QgQJehhDbP8DQWdHh2l4czH9EamrzjzZ7MHjFxyAY=, +,,IU9/MzHdMKqAb2bbhA552djuHapXO3SElBXffucIbiN6qeUYwCCi/YKK7vKmk51WBeKKEw9zZ9kf8188ie2wubYX/sWtAFclTOpbUmLNHoiOzyxrQ3vI3tT+lAKEriDBRzPk+EyS1NLfs5E7oyxMWd1I1WYftjNZFugFszRXtVZ94PO6QwiaUpZh1pRZbZKo0YXFUTzXQTINoqXryGBdxT03xlD2DQHdnHnLG4+9ncBjXGhtXdWVwyjcjl5jDQVR7SIhBt5O8Uj7k0lITTyc6oPbzhoFIfMH37pyoDYi0Y852PCkqPB/2JB2wKBYDRfoXNOCWD+DTM8lDjd3f2FJAA==