Repository navigation
[#344] Tamper-evident CSV audit: sign the column names and the value lengths in the row HMAC - #369
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
praise: The format fix sits where the bug is, and old files stay verifiable without weakening the new format.
CsvSecureUtils.DATA_TO_SIGN_MARKER(0xFF) keeps the two formats disjoint, so theconcatenatedValuesfallback cannot accept an edited new-format row.HmacCalculator.verifyadvances the key once per row whichever format matches, so a mixed file keeps its chain aligned.- The new cases run on all nine CI rows: handler-csv goes from 38 to 48 tests, all green.
issue (non-blocking): A non-ASCII column name fails archive verification when the JVM's default charset is not UTF-8.
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureVerifier.java:185, :93, commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureUtils.java:96
RotatableWriter writes the file in UTF-8 (commons/audit/core/src/main/java/org/forgerock/audit/events/handlers/writers/RotatableWriter.java:353) and the HMAC covers UTF-8 bytes, but the verifier decodes the file with new InputStreamReader(in). Now that names are signed, a field name such as näme, read back on JDK 11 or 17 with a windows-1252 or US-ASCII default (Windows, Linux under the POSIX locale), re-signs to different bytes: every new-format row fails and an untampered archive reports FAIL. Probed on 11.0.25 and 17.0.11. JDK 18+ defaults to UTF-8 (JEP 400): checked on macOS JDK 21 only, not on Windows. Non-ASCII values already failed this way before this PR, and a non-ASCII name already failed the resume's header check (SecureCsvWriter.java:139-146); only the archive path is new. Reading as UTF-8 fixes all three:
import java.nio.charset.StandardCharsets;
return new CsvMapReader(new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)), csvPreference);CsvAuditEventHandler.java:347 and StandardCsvWriter.java:58 also read with the default charset (FileReader). They sign nothing and are left out.
suggestion (non-blocking): The fallback that keeps pre-upgrade files verifiable is pinned only by a one-column fixture with no empty cell.
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureUtils.java:114, commons/audit/handler-csv/src/test/resources/secureCsvLegacyHmac.csv
secureCsvLegacyHmac.csv (header FOO,HMAC,SIGNATURE, values bar and quix) is the only legacy input. Three concatenatedValues mutants pass all 48 handler-csv tests: "null" appended for a missing cell, "|" between values, and columns in reverse order. Real pre-upgrade files have many columns and empty cells. Under any of these mutants they stop verifying and the writer refuses to resume. This case passes at the head and fails under each of the three mutants:
@Test
public void shouldConcatenateNonNullValuesInHeaderOrder() {
Map<String, String> 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));
}Pin: a new CsvSecureUtilsTest in org.forgerock.audit.handlers.csv, shared with the next comment.
suggestion (non-blocking): The new-format bytes for an absent value, an empty column name and a non-ASCII field are not pinned.
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureUtils.java:91-106
The writer and the verifier share dataToSign, so round trips cannot detect a format change, and the expected files hold one ASCII column that is never empty. Three mutants pass all 48 tests: an absent value signed as "null", an empty name signed as "null", and the length written as the UTF-16 char count instead of the UTF-8 byte count that the Javadoc states. Files that this version writes would stop verifying after such a change. This case passes at the head and fails under each of the three mutants:
@Test
public void shouldSignUtf8LengthsAndEmptyNamesAndValues() {
Map<String, String> values = new HashMap<>();
values.put("a", "é");
assertThat(CsvSecureUtils.dataToSign(LoggerFactory.getLogger(CsvSecureUtilsTest.class), 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 });
}Imports for both cases: java.nio.charset.StandardCharsets, java.util.HashMap, java.util.Map, org.slf4j.LoggerFactory, org.testng.annotations.Test, static org.assertj.core.api.Assertions.assertThat. Measured with JDK 11 against the head's CsvSecureUtils, TestNG 6.9.8 and AssertJ 3.27.7, outside the Maven build: the head passes 2/2, and each of the six mutants fails.
suggestion (non-blocking): A column name that holds a CR fails archive verification.
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureUtils.java:96, commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureVerifier.java:93
The writer signs the configured name, and the verifier signs the name that Super CSV 2.4.0 reads back. A CR inside a quoted header reads back as LF under every end-of-line setting: a\rb becomes a\nb. Quote, delimiter, spaces, tab and LF all round-trip (probed). A schema property key that holds \r therefore fails an untampered archive at row 2. The live-file resume already failed on such a name ("headers do not match"). No code change is needed. At most, add one sentence to the dataToSign Javadoc saying that a name must read back unchanged, which a name with a CR does not.
nitpick (non-blocking): The HmacCalculator.verify Javadoc says the HMAC of each data is computed, but the loop stops at the first match.
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/HmacCalculator.java:77-78, :87
* 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.09e7475 to
5c3c9dc
Compare
|
@maximthomas, the review round is in 5c3c9dc, with the branch rebased on issue (charset): Tracked as #336, and fixed on every path by #348. Reading as UTF-8 alone would break the other path: with rotation disabled, suggestion (legacy fallback): Added as suggestion (new-format bytes): Added as suggestion (CR in a column name): Values had the same problem before this PR: Super CSV reads nitpick: Reworded as proposed. Each of the six mutants from the review fails one of the two proposed cases, and signing the line breaks unchanged fails |
maximthomas
left a comment
There was a problem hiding this comment.
praise: The #370 fix signs what the CSV reader reads back, so a CR or a CRLF in a name or a value now verifies and resumes.
CsvSecureUtils.readBackserves bothdataToSignand the resume's name comparison (SecureCsvWriter.java:146), so the writer, the verifier and the resume agree on one form.shouldVerifyLineBreaksInValuesandshouldVerifyAndResumeFileWithLineBreaksInColumnNamesfail with "The HMac at row 2 is not correct." when the main hunks of 5c3c9dc are reverted, as the description says.- handler-csv runs 57 tests at the head on the ubuntu JDK 27 and windows JDK 11 rows, 15 more than master, and all nine CI rows are green.
…#353) Fixes #345. `StandardCsvWriter` compared the header of the CSV file it resumes with the headers of the topic schema in a loop over the header of the file; the length check before the loop was commented out. - A file with more columns than the schema, whose first columns match it (after the last field was removed from the topic schema), threw `ArrayIndexOutOfBoundsException`. `CsvAuditEventHandler.startup()` logs an `IOException` and goes on with the next topic, but this exception escaped it, so the handler failed to start. - A file with fewer columns than the schema (after a field was added to the end of the topic schema) was resumed, and the rows appended to it had more cells than its header, which Super CSV rejects when the topic is read or queried. - A header with as many columns as the schema but an empty column name threw `NullPointerException`, which escaped `startup()` too: Super CSV reads an empty column name as `null`, and the loop called `equals()` on the header of the file. - `null` headers threw a `NullPointerException` without the message of `checkNotNull`, which ran after the comparison. `StandardCsvWriter` now compares the lengths and throws "Resuming an existing CSV file but the headers do not match." when they differ, as `SecureCsvWriter` does, calls `equals()` on the headers of the topic schema, which hold no `null`, and checks the headers for `null` before it reads the file. `SecureCsvWriter` had the same loop. A tamper-evident file passes the verification with an emptied column name when that column is empty in every row, since the row HMAC covers the values only; it now gets the same `IOException` instead of the `NullPointerException`, which the constructor wraps in its `RuntimeException` until #341. ## Behavior change A plain CSV file with fewer columns than the topic schema is no longer resumed. As with a file whose columns differ in name or order, the handler logs the error at startup and rejects the events of the topic with `BadRequestException` until the file is moved away; the writer then starts a new file with the current header. ## Tests - `CsvWriterTest.shouldNotResumeCsvFileWithHeadersThatDoNotMatch`, with one column more and one column less than the schema, and with an empty column name in the middle and at the end (`child1,,child3`, `child1,child2,`): the writer throws the `IOException` and leaves the file as it was. - `CsvWriterTest.shouldRejectNullHeadersWhenResumingCsvFile`. - `CsvWriterTest.shouldNotResumeCsvSecureFileWithEmptyColumnName`: a tamper-evident file whose header lost the name of a column that is empty in every row. The test accepts the `IOException` of the header check or of the verification: once #369 covers the column names in the row HMAC, the same file fails the verification. - `CsvAuditEventHandlerTest.testStartupWithAuditFileOfMoreColumnsThanTopicSchema`: the handler starts, and the events of the topic are rejected with `BadRequestException`. Each case fails without the fix. `audit/core` and `audit/handler-csv` pass locally. #341, #347, #348 and #369 change the same files; each merges with this one without conflicts.
…names and the value lengths in the row HMAC The HMAC of a row covered its values concatenated, so an edit that moved characters across a column boundary, or a value into an empty neighbouring column, kept it, and swapping two column names in the header kept every HMAC of the file. The HMAC now covers the byte 0xFF, then the name and the value of each column, each after the number of its UTF-8 bytes. The verifier still accepts a row signed in the previous format, with the same key, so the files written before are verified and resumed as before. UTF-8 never holds the leading byte, so a row of the new format cannot pass as a row of the previous one. A row with an empty HMAC fails verification instead of throwing NullPointerException.
…nd the values as the CSV reader reads them back Super CSV writes a CR, a CRLF or a LF in a quoted name or value as the end of line symbols and reads each line break back as a LF, so a row with a CR failed verification and its file could not be resumed. The row HMAC now signs each CR and CRLF as a LF, and the resume compares the column names the same way. Pin the signed data in CsvSecureUtilsTest, and reword the Javadoc of HmacCalculator.verify.
5c3c9dc to
5b56c73
Compare
…the resumed file is verified (#355) Fixes #343. ## Construction order `SecureCsvWriter` constructed its `RotatableWriter` first, and the `RotatableWriter` constructor schedules the rotation and retention checks. Only then did the constructor read the keys and verify the file it resumes. A check due on the resumed file, by `maxFileSize` or by `rotationInterval` from its `lastModified()`, could run during the construction: it renamed the file and its keystore, and `postRotationAction` wrote new keys into a new keystore, through the `secureStorage` that the verifier reads. A valid file, already archived, then failed verification, and the topic did not start. The writer is now constructed last, once the keys are read and the file it resumes is verified. The first check runs one check interval after the construction, on a writer that holds the keys and the last signature of the file. ## A file that cannot be opened The file is still opened, and closed at once, before the keys. A file that cannot be opened, as one in a directory that cannot be created, fails with an `IOException`, which `CsvAuditEventHandler.startup()` logs for the topic. Without that, a new file would fail first when its keystore is stored, with a `SecureStorageException` that the constructor wraps in a `RuntimeException`, and the startup would fail for every topic. Opening for append leaves the file and its `lastModified()` as they are. ## A writer whose check interval is invalid Found in the review. The `RotatableWriter` constructor opened its file before it rejected an unlimited or zero rotation and retention check interval, and the caller of a constructor that throws gets no writer to close: the file stayed open until its stream was collected. An interval under one millisecond passed that check, and failed after the open with the `IllegalArgumentException` of `scheduleAtFixedRate`, which `CsvAuditEventHandler.startup()` does not catch for the topic. The constructor now builds the policies and checks the interval before it opens the file, and rejects an interval under one millisecond with the same `IOException`. That includes a negative one: `Duration` adds the parts of a composite without an overflow check, and `9223372036854775807 ms, 1 ms` parses to a negative interval. ## Tests - `SecureCsvWriterTest.shouldNotRotateResumedFileWhileVerifyingIt`: a written file is resumed with rotation and a 50 ms check interval, with a rotation due at the first check: its size at `maxFileSize`, or its `lastModified()` two hours back with a 1 hour `rotationInterval`. A `CommentMatcher` in the `CsvPreference` holds the verification for 500 ms on the first line. The construction succeeds, and the first check after it rotates the file. The archive passes verification, and so does the live file, with one event after the rotation. On Windows a file that the verifier holds open cannot be renamed, and a rotation that fails to rename it still resets the time of the last rotation: there, only the `rotationInterval` row is sure to fail with the writer constructed first. - `SecureCsvWriterTest.shouldFailWithIOExceptionWhenFileCannotBeOpened`: a file under a regular file fails with an `IOException`. - `SecureCsvWriterTest.shouldFailWithIOExceptionWhenCheckIntervalIsInvalidForNewFile`: a new file with rotation and an `unlimited` check interval fails with the `IOException` of the `RotatableWriter`. - `RotatableWriterTest.testRejectsInvalidRotationRetentionCheckIntervalBeforeOpeningTheFile`: an `unlimited`, `zero`, `500 microseconds` or overflowed `9223372036854775807 ms, 1 ms` check interval fails with an `IOException`, and the file is not created. `testRejectsInvalidRotationRetentionCheckIntervalWithRetentionOnlyBeforeOpeningTheFile` does the same with a retention policy alone. - `RotatableWriterTest.testAcceptsAnyRotationRetentionCheckIntervalWithoutPolicy`: with no policy, no check is scheduled, and the same intervals are accepted. `testAcceptsOneMillisecondRotationRetentionCheckInterval`: one millisecond is accepted. Measured on JDK 27: - With the `SecureCsvWriter` of `master`, both rows fail with "The CSV file was tampered: The file contains no CSV header": with a 50 ms interval, the first check runs while the keys are read, and renames the file before the verifier opens it. With a 1 s interval and a 3 s hold, both rows fail with the reason given in the issue, "Final HMAC key doesn't match expected value". - Without the open and close before the keys, `shouldFailWithIOExceptionWhenFileCannotBeOpened` fails with the `RuntimeException`, and so does `CsvAuditEventHandlerTest.canConfigureCsvHandlerFromJsonAndRegisterWithAuditService`, whose log directory `/path/to/audit/files/` cannot be created. - With the writer constructed in the `try` block, `shouldFailWithIOExceptionWhenCheckIntervalIsInvalidForNewFile` fails with "RuntimeException: Error when initializing a secure CSV writer". - With the `RotatableWriter` of `master`, both `testRejectsInvalidRotationRetentionCheckInterval…` tests fail in their four rows: the file exists for `unlimited` and `zero`, and `500 microseconds` and the overflowed interval throw an `IllegalArgumentException`. With a guard that rejects only zero milliseconds, the overflowed row fails in both with the `IllegalArgumentException`. - Three mutants of the guard each fail only their own test: without the retention clause, the retention-only test in its four rows; without the policy clause, `testAcceptsAnyRotationRetentionCheckIntervalWithoutPolicy` in its four rows; with `<= 1`, `testAcceptsOneMillisecondRotationRetentionCheckInterval`. - `audit/core` and `audit/handler-csv` pass locally, and the new tests passed 3 runs in a row. ## Merging - With #341: a conflict in the constructor. A failure in its `try` block now comes before the writer is constructed, so no file or rotator thread is left open (#340), and the close that #341 adds in the `catch` block would find `csvWriter` still `null`: the close goes, and the rest of #341 applies as is. Its tests that the rotator thread terminates still hold, as the rotator does not start. - With #342, #347 and #369: no conflict. ## Not in this PR - `RotatableWriter` schedules its checks in its constructor, and `registerRotationHooks()` sets the hooks after it, in a field that is not volatile. The first check runs one check interval later. Not observed; it follows from the memory model only. - A new secure topic whose check interval is invalid stores new keys for its empty file on each publish attempt, and a resumed one verifies its file, before the writer fails. `master` leaked a `FileOutputStream` on each attempt instead.
Fixes #344. Fixes #370.
Signed data
CsvSecureUtils.dataToSign()concatenated the values of a row, so the HMAC did not tell where a value ends, nor which column it is in.bob,xedited tobo,bx, or,bobtobob,, kept the HMAC of the row, andCsvSecureVerifieraccepted the edited file.The HMAC of a row now covers the byte
0xFF, then, for each column in header order, the column name and the value as the CSV reader reads them back, in UTF-8, each after the number of its bytes as a big-endian int.userId,trackingIdtotrackingId,userId) kept every HMAC, and the verifier accepted a file whose values had silently changed columns. It now fails at the first row.null, quoted or not, so a file cannot hold the difference betweennulland"", and both are signed as an empty value. Signed differently, a""handed to the writer would fail verification once read back asnull. An empty column name, which is read asnulltoo, is signed as an empty name.Line breaks
Super CSV writes a CR, a CRLF or a LF in a name or a value as the end of line symbols, inside quotes, and reads every line break inside quotes back as a LF. The writer signed the value it was given, so an event with a CR in any field (
x\ryorx\r\ny, read back asx\ny) made the file fail verification at its row, and the writer did not resume it (#370).Files signed before
CsvSecureVerifierchecks the HMAC of a row in the new format, then in the previous one, with the same key of the chain:UTF-8 never holds the leading
0xFF, so the concatenated values of a row never equal the signed data of a row in the new format, and the fallback cannot accept an edited row of the new format. Without that byte, a row rewritten as one value that holds the encoding of the original row would pass the check in the previous format.Downgrade
The previous version does not verify a row in the new format ("The HMac at row N is not correct."), so its writer does not resume a live file that holds such rows. Going back to it needs the live file and its keystore set aside, and the files written in the new format verified with the new version.
Row without HMAC
A row with an empty
HMACand an emptySIGNATUREthrewNullPointerExceptionfromCsvSecureVerifier.verify(). It now fails with "The HMac at row N is not correct.".Tests
CsvSecureVerifierTest.shouldNotVerifyEditOfSignedData, on a file that the writer writes and that passes verification before the edit:The first three passed verification before this change. Each part of the format is caught when removed alone: the leading byte by 4, the names by 3, the value lengths by 5.
CsvSecureVerifierTest.shouldVerifyFileWithHmacOfConcatenatedValues:secureCsvLegacyHmac.csv, the file thatSecureCsvWriterTestexpected before this change, passes verification.SecureCsvWriterTest.shouldResumeFileWithHmacOfConcatenatedValues: the writer resumes that file and appends a row, and the mixed file passes verification.CsvSecureVerifierTest.shouldVerifyLineBreaksInValues: values that hold a CR, a CRLF, a LF and a CR before a CRLF pass verification. It failed at row 2 before this change.CsvSecureVerifierTest.shouldVerifyAndResumeFileWithLineBreaksInColumnNames: a file whose column names hold a CR and a CRLF passes verification, the writer resumes it, and the resumed file passes verification. Signing the line breaks as a LF alone leaves the resume failing on the names.CsvSecureUtilsTestpins the signed data, which the writer and the verifier share, so that a round trip cannot catch a change of them: the values concatenated in the previous format (nullskipped, header order, no separator), the lengths in UTF-8 bytes, an empty name and an absent value, and the line breaks as a LF.HmacCalculatorTest.shouldVerifyHMACOfOneOfTheDataWithOneKey: a match on the first data, a match on the second one and a mismatch each move to the next key once, checked against HMACs computed separately.CsvSecureVerifierTest.shouldNotVerifyRowWithoutHmac.The expected files of
SecureCsvWriterTesthold the new HMACs, which match HMACs computed separately from the format above.CsvSecureVerifierTest.shouldVerifyValidFileverifies the new expected file with the truststore.Without the fallback to the previous format, both tests of that format fail. Each case of
CsvSecureUtilsTestfails when the part of the format it pins is changed: a"null"or a"|"in the values concatenated, or their order reversed; a"null"for an absent value or an empty name, or the length in UTF-16 chars; the line breaks signed unchanged.audit/coreandaudit/handler-csvpass locally on JDK 11.