Repository navigation
[#336] Tamper-evident CSV audit: write and read the CSV files in UTF-8 on every path - #348
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
praise: Every reader and writer of the audit files now agrees on UTF-8, and the tests prove it under a non-UTF-8 default charset.
CsvSecureVerifier.newBufferedCsvMapReaderdecodes withStandardCharsets.UTF_8.newDecoder(), so non-UTF-8 bytes can no longer pass for a logged U+FFFD. The plain and the.gzbranches share that one reader.- The surefire
JAVA_TOOL_OPTIONS=-Dfile.encoding=windows-1252fork makes the new cases bite on every CI JDK, 27 included. Each change reverted on its own fails at least one new test:TextWriter.Stream, the verifier's charset, the strict decoder,createCsvMapReaderand theStandardCsvWriterheader check. Measured on JDK 11 and 17: HEAD green 5/5, all four files at BASE red 5/5.
question (non-blocking): Are the U+FFFD read-back of pre-upgrade plain CSV rows and the failed resume of a non-ASCII column name accepted?
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvAuditEventHandler.java:348, StandardCsvWriter.java:60, :69-71
The setup is a Windows JDK 11–17 host (or -Dfile.encoding) with a plain topic and rotation disabled, the default. The old version wrote "Grüße" as FC DF. readEvent/queryEvents now read that file with FileReader(auditFile, UTF_8), which replaces bad bytes, so the old row comes back as "Gr��e" with no error, and an eq query on it stops matching. The header check also reads in UTF-8, so a non-ASCII column name the old version wrote now throws "Resuming an existing CSV file but the headers do not match." where BASE resumed the file. The Upgrade note says only that such a file "is resumed as it is". If both effects are accepted, this is a Minor: say both in the Upgrade note (rotate or move such a file before upgrading), and pin the HEAD outcome with a test that writes a header and a "Grüße" row in windows-1252, resumes the file with StandardCsvWriter and asserts what readEvent returns. If "resumed as it is" was meant to promise intact read-back, this is a Major: the resume needs a strict UTF-8 check that moves a non-UTF-8 file aside.
suggestion (non-blocking): The Upgrade note should say that, at this head, the documented failure stops the whole audit service's startup, not one topic.
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/SecureCsvWriter.java:133-135, :176-177, CsvAuditEventHandler.java:223, commons/audit/core/src/main/java/org/forgerock/audit/AuditServiceImpl.java:399-400
The resume throws IOException("The CSV file was tampered: The file is not valid UTF-8") inside the constructor's outer try, and the constructor rethrows it as RuntimeException("Error when initializing a secure CSV writer"). CsvAuditEventHandler.startup() catches only IOException per topic, and AuditServiceImpl.startup() catches only ResourceException. So on an affected host the RuntimeException escapes both, and the audit service never reaches RUNNING. A really tampered file already took this path. This PR sends untampered legacy files down it too. #341 makes the failure per topic, so either say this in the note or merge after #341.
suggestion (non-blocking): The Upgrade note should also cover the downgrade direction.
commons/audit/core/src/main/java/org/forgerock/audit/events/handlers/writers/TextWriter.java:73
The previous version's verifier reads with new InputStreamReader(in), the platform charset, and signs the UTF-8 bytes of what it decoded (CsvSecureUtils.java:87). After a rollback on a Windows JDK 11–17 host, a rotation-disabled secure file this version wrote with "ü" (C3 BC) decodes as "ü". The previous version then refuses the live file ("The CSV file was tampered: The HMac at row N is not correct."), and its CLI fails the archives written after the upgrade. One line would cover it: before rolling back on such a host, run the rotate action, and verify the archives written after the upgrade with the new CLI.
suggestion (non-blocking): Name the charset change of the public TextWriter.Stream in audit-core's release notes.
commons/audit/core/src/main/java/org/forgerock/audit/events/handlers/writers/TextWriter.java:73
audit-core is published, and its MANIFEST exports org.forgerock.audit.events.handlers.writers. When the default charset is not UTF-8, outside callers of new TextWriter.Stream(out) (OpenIDM, OpenAM, custom handlers) now get 63 61 66 c3 a9 0a for "café\n" instead of 63 61 66 e9 0a. getBytesWritten() returns 6 instead of 5, a default-charset reader gets "café", and appending to an older file mixes two encodings. Under a UTF-8 default the output is unchanged (probed on JDK 11/17/21/26). No code change is needed.
suggestion (non-blocking): Nothing asserts that the handler-csv fork really runs under a non-UTF-8 default charset.
commons/audit/handler-csv/pom.xml:120, commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/CsvWriterTest.java:183
Under a UTF-8 default, BASE's reader and writer agree with each other. That happens in an IDE run that does not apply surefire's environmentVariables, or after a pom edit that drops the block. Then shouldWriteAndVerifyNonAsciiValuesInUtf8[false,true], shouldResumeCsvFileWithNonAsciiHeader and testReadingAuditLogEntryWithNonAsciiValue pass against the unfixed code, and only shouldNotVerifyFileNotInUtf8 stays red.
@Test
public void shouldRunUnderDefaultCharsetOtherThanUtf8() {
// surefire sets JAVA_TOOL_OPTIONS=-Dfile.encoding=windows-1252; under a UTF-8 default charset the non-ASCII
// tests pass against a reader or a writer that uses the default charset
assertThat(java.nio.charset.Charset.defaultCharset()).isNotEqualTo(UTF_8);
}Pin: this test turns red as soon as the pom block is gone or not applied. That includes an IDE run, which is intended.
suggestion (non-blocking): No test pins the documented refusal to resume a pre-upgrade live file that is not UTF-8.
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/SecureCsvWriter.java:133-135, CsvWriterTest.java:193
shouldNotVerifyFileNotInUtf8 calls the verifier directly. No test drives SecureCsvWriter's resume with such a file, and that resume is the behaviour the Upgrade note warns operators about. The constructor wraps the failure in a RuntimeException, so the pin asserts the root cause (add import static org.assertj.core.api.Assertions.assertThatThrownBy;):
@Test
public void shouldNotResumeFileNotInUtf8() throws Exception {
final File csvFile = writeSecureCsvFile(false, "Grüße");
// the live file as the writer wrote it with a windows-1252 default charset, before it wrote in UTF-8
final String content = new String(Files.readAllBytes(csvFile.toPath()), UTF_8);
Files.write(csvFile.toPath(), content.getBytes("windows-1252"));
final CsvSecurity csvSecurity = new CsvSecurity();
csvSecurity.setEnabled(true);
csvSecurity.setSignatureInterval("5 minutes");
final CsvAuditEventHandlerConfiguration configuration = new CsvAuditEventHandlerConfiguration();
configuration.setSecurity(csvSecurity);
configuration.setRotationRetentionCheckInterval("5 minutes");
assertThatThrownBy(() -> new SecureCsvWriter(csvFile, new String[] { "child1", "child2", "child3" },
CsvPreference.EXCEL_PREFERENCE, configuration, keyStoreHandler, random))
.hasRootCauseMessage("The CSV file was tampered: The file is not valid UTF-8");
}Pin: if the writer resumes the file instead of verifying it, or verification passes, this test turns red. Not compiled or run by this review.
suggestion (non-blocking): "The file is not valid UTF-8" names no row, and the decoder's read-ahead can mask an earlier row's HMAC failure.
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureVerifier.java:152-154
InputStreamReader decodes ahead about 8 KB at a time. In a small file with a wrong HMAC at row 2 and a 0xFC byte in row 4, the exception surfaces first, and the result says "not valid UTF-8" instead of "The HMac at row 2 is not correct.". Verification still fails; the operator just can't locate the bad row. Optionally, track the last row that verified and say "… not valid UTF-8 after row R". csvReader.getRowNumber() at the throw does not give the malformed row, because of the read-ahead.
nitpick (non-blocking): The pom comment should say that this setting replaces an inherited JAVA_TOOL_OPTIONS.
commons/audit/handler-csv/pom.xml:117-121
A developer or CI image that sets JAVA_TOOL_OPTIONS (proxy, truststore, -Xmx, an agent) loses it in handler-csv's test forks only. The project's CI sets none. Moving the setting to argLine does not work as a drop-in: the root argLine is a property of the jdk16.options profile only (pom.xml:1468-1476), so ${argLine} in this pom would stay literal on JDK 11.
nitpick (non-blocking): The Upgrade note gives one failure message, but a legacy file can also fail with "The HMac at row N is not correct.".
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureVerifier.java:152-154
The strict decoder rejects only malformed bytes. Some platform-charset bytes are valid UTF-8: windows-1252 "é" is C3 A9, which decodes to "é", and multi-byte charsets such as MS932 or GBK hit this more often. The HMAC was computed over the UTF-8 bytes of "é" (CsvSecureUtils.java:87), so such a file fails with a message that reads as tampering.
… yet (#354) Fixes #346. `SecureCsvWriter` writes the CSV header with the first event, so the audit file of a tamper-evident topic is empty after startup, and after the `rotate` action of a topic without file rotation, until an event is published. `CsvAuditEventHandler.getEntries()` passed the `null` header of such a file to `convertDotNotationToSlashes()`: - `readEvent()` threw the `NullPointerException` out of the call instead of answering a promise; - `queryEvents()` answered `BadRequestException`. `getEntries()` now returns no entry for a file without a header: `readEvent()` answers `NotFoundException`, and `queryEvents()` answers an empty result. This also covers the empty file that a rotation before the first signature leaves with #342. ## Tests `CsvAuditEventHandlerTest.testReadingTamperEvidentAuditLogWithNoEventYet` and `testQueryOnTamperEvidentAuditLogWithNoEventYet`, after startup and after the `rotate` action. Each case first checks that the audit file is empty, and fails without the fix as the issue describes. `audit/core` and `audit/handler-csv` pass locally on JDK 11 and 27. The change applies cleanly on #341, #342, #347 and #348.
1fcc8da to
b51fdf0
Compare
|
Addressed in b51fdf0, and the description is updated. The branch is rebased onto the current
|
maximthomas
left a comment
There was a problem hiding this comment.
praise: the charset is fixed at the readers and writers themselves, and a test run that cannot show the bug fails instead of passing.
CsvSecureVerifier.java:192decodes withStandardCharsets.UTF_8.newDecoder(), so a file that is not UTF-8 fails as such (:152-154) instead of passing for a U+FFFD that the writer logged.TextWriter.java:73writes UTF-8, asRotatableWriter.java:353does: rotation-disabled and rotated files now have one encoding.CsvWriterTest.shouldRunWithDefaultCharsetOtherThanUtf8(CsvWriterTest.java:182) makes a run without the windows-1252 fork fail. In a UTF-8 run of this head it is the only failure.
issue (blocking): The Upgrade note's workaround (rotate before the upgrade, then verify the archives with the previous CLI) fails on every release: their rotate action leaves the archive without its keystore.
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/CsvSecureVerifier.java:152-154
The keystore rename on the rotate action is #332 (0d6ddfc), and no release contains it: git tag --contains 0d6ddfccf prints nothing, and the newest tag is 3.2.1. In 3.2.1, the rotate action with rotation disabled renames only tamper-evident-<topic>.csv (CsvAuditEventHandler.java:439, :460-473 at 3.2.1) and reopens the writer on the same .keystore. The 3.2.1 CLI then opens <archive>.keystore (CsvSecureArchiveVerifier.java:80 at 3.2.1), which does not exist, so the archive fails with "Expecting to find an initial key into the keystore." This version's CLI fails it with "The file is not valid UTF-8". #332's own comment adds that the reopened file overwrites the archive's keys in the keystore left behind. An operator who follows the note loses the verifiability that skipping it would have kept. I traced this by reading the code; I did not run it end to end against a 3.2.1 build.
On such a host, before the upgrade, stop the service and move the files of each topic aside. For a
tamper-evident topic, move `tamper-evident-<topic>.csv` together with
`tamper-evident-<topic>.csv.keystore`, renamed `<archive>` and `<archive>.keystore`, and verify them
with the CLI of the previous version. Do not use the `rotate` action of a version up to 3.2.1 for a
tamper-evident topic with rotation disabled: it renames only the CSV file, and the new file
overwrites the keys of the archive in the keystore. For a plain CSV topic, the `rotate` action is
enough.Or: merge after a release that carries #332, and name that release as the minimum for the rotate advice. Not checked by this review: which <archive> names the 3.2.1 CLI lists. That depends on the TimestampFilenameFilter of TimeStampFileNamingPolicy and on the CLI's prefix and suffix options, so take the names from that code.
issue (non-blocking): A pre-upgrade live file that fails the new check leaves the topic's FileOutputStream open.
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/SecureCsvWriter.java:104, :131-135, :176-177, :215
The constructor opens the file (:104 → :215) before verify() (:131-135), and the catch at :176-177 wraps the failure without closing anything. The base resumed an untampered windows-1252 live file and kept the stream as the live writer. This head throws "The file is not valid UTF-8" and leaves the stream open until GC; on Windows that handle has no share-delete. CsvWriterTest.shouldNotResumeFileNotInUtf8 (CsvWriterTest.java:215) leaks it the same way. This is #340's mechanism and #341 removes it, so there is nothing to change here, only the merge order with #341 to keep in mind.
suggestion (non-blocking): The Rollback paragraph covers only tamper-evident files.
commons/audit/handler-csv/src/main/java/org/forgerock/audit/handlers/csv/StandardCsvWriter.java:60, commons/audit/core/src/main/java/org/forgerock/audit/events/handlers/writers/TextWriter.java:73
The previous version reads the header and rows of a plain rotation-disabled file in the platform charset (StandardCsvWriter.java:58 at the base). On windows-1252, suppose a file that this version created has a custom column "Grüße". The previous version's resume fails with "Resuming an existing CSV file but the headers do not match." I measured this with this head's writer and the base's header read: shouldResumeCsvFileWithNonAsciiHeader:272 fails with that message. Non-ASCII values written after the upgrade also read back as "Grüße" through readEvent and queryEvents.
A plain CSV topic that this version wrote with rotation disabled is in UTF-8: the previous version
reads the rows written after the upgrade garbled, and does not resume a file whose header holds a
non-ASCII column name. Run the `rotate` action before the rollback.suggestion (non-blocking): The Upgrade section does not tell readers outside the product that rotation-disabled files are now UTF-8.
commons/audit/core/src/main/java/org/forgerock/audit/events/handlers/writers/TextWriter.java:73
Rotated files were in UTF-8 already (RotatableWriter.java:353). Rotation-disabled files move from the platform charset (TextWriter.java:71 at the base) to UTF-8. A log shipper or script that decodes them in windows-1252 reads "café" in a row written after the upgrade as "café" (probed on JDK 11, 17 and 21), and a resumed file mixes both encodings. The description names the mixed encoding only for callers of new TextWriter.Stream(out).
Files written with rotation disabled are now in UTF-8, as rotated files already were: a tool that
reads them must decode them as UTF-8, and a file resumed across the upgrade mixes both encodings.nitpick (non-blocking): "four of the new cases pass against the unfixed code" under a UTF-8 default charset: six of them do.
commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/CsvWriterTest.java:182
With the base's main code, this head's tests and -Dfile.encoding=UTF-8 (JDK 17.0.11), only shouldNotVerifyFileNotInUtf8, shouldNotResumeFileNotInUtf8 and the guard fail, 3 of 9. testReadingAuditLogEntryNotInUtf8 and shouldNotResumeCsvFileWithNonAsciiHeaderNotInUtf8 pass there too. No coverage is lost, because the guard fails any UTF-8 run.
nitpick (non-blocking): "Each new case of the charset fails without its fix" is not true of shouldNotResumeCsvFileWithNonAsciiHeaderNotInUtf8 against the whole unfixed code.
commons/audit/handler-csv/src/test/java/org/forgerock/audit/handlers/csv/CsvWriterTest.java:277
In the windows-1252 fork with the base's main code, 7 of 9 cases fail and this one passes. reencodeInWindows1252 decodes the base's windows-1252 header as UTF-8 and writes U+FFFD back as ?, so the header mismatches anyway. The case fails only when StandardCsvWriter.java:60 is reverted alone (:286). Write "each fails when its change is reverted alone", or name this case as the exception.
|
Addressed in c0105c9, and the description is updated. The branch is on the current
|
…#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.
maximthomas
left a comment
There was a problem hiding this comment.
praise: The new head closes the last gap between the tests and the description, and the upgrade advice now holds against the released CLI.
CsvWriterTest.shouldNotResumeCsvFileWithNonAsciiHeaderNotInUtf8now writes the header bytes itself, withCsvFormatter.formatHeaderandFiles.writein windows-1252. Run against the unfixed main code in the windows-1252 fork, it now fails at:288("Expecting code to raise a throwable."); before, it passed. Every new case exceptshouldRunWithDefaultCharsetOtherThanUtf8now fails against the unfixed code, as the Tests paragraph says (JDK 17, 8 of 9).- The 3.2.1 CLI handles the archive naming that the Upgrade section asks for:
TimestampFilenameFilter.acceptliststamper-evident-<topic>.csv-yyyy.MM.dd-HH.mm.ssand skips its.keystore, andCsvSecureArchiveVerifieropens<archive>.keystore.
…rotation (#342) Fixes #339. Fixes #372. Fixes #373. ## Resuming a file with no event since the last rotation After a rotation, the live file holds the header and the signature chained from the archive. `CsvSecureVerifier` kept that signature for the chain but did not count it as a final signature, so the file failed with `Missing final signature`, and `SecureCsvWriter` could not resume it after a restart. The signature chained into row 2 now counts as the final one until an event follows. A file truncated to these two rows after later events is still rejected, as the current key in its keystore no longer matches. ## Signatures around a rotation `SecureCsvWriter` now tracks whether events were written since the last signature, and signs only then: - A rotation signs them whether or not the scheduled signature could be cancelled. When the event that rotated the file was the first one since a periodic signature, no signature was scheduled yet, and the archive ended with that event unsigned: `Final HMAC key doesn't match expected value` (#372). - No signature is written or scheduled with no event to cover. After a rotation on write, `writeEvent()` scheduled a signature that `close()` wrote into the new file over the last HMAC of the previous file, which the new file does not hold, so it failed at row 3. This is the failure that `shouldRotateCsvAndKeyStoreFile` asserted for its last archive (CAUD-225), and such a live file could not be resumed either. - A rotation before any signature, by the `rotate` action or by a time-based rotation of a topic with no event since startup, leaves the new file empty instead of writing an empty chained signature (`,,`), on which the verifier threw a `NullPointerException` when the writer resumed the file. - The header goes in the same write as the first event. Written on its own, it could rotate the file when a time-based rotation was due, and that rotation had no event to sign: the archive held only the header and failed with `Missing final signature`, and the event went into the new file without a header. This is likely after a restart over the empty live file that the rotation above leaves: its modification time counts as the last rotation, and the first event can come before the first rotation check. A second `close()` does nothing. As `close()` now always runs the signature step, which flushes the writer, a second call with rotation threw `IOException: Stream closed`. ## An event written while the periodic signature completes `writeEvent()` scheduled a signature only when the scheduled one was done. The periodic task releases `signatureLock` and flushes the writer before its `FutureTask` is done, so an event written in this window scheduled none, and stayed unsigned until the next event, a rotation or `close()` (#373). A process killed meanwhile left a live file that failed verification at the next start. The writer now keeps the scheduled task until it starts signing, in `pendingSignature`. `writeEvent()` schedules a signature for unsigned events when no task is pending, and a task clears `pendingSignature` once it holds the lock, before it signs: an event written after that comes after its signature, and schedules the next one, whether the task is done or not. A rotation or `close()` that cancels the scheduled task clears it too. A task clears `pendingSignature` only if it is that task. `cancel(false)` returns `true` for a task that has started and waits for the lock, as a `FutureTask` stays `NEW` until its `run()` returns, and the task still runs. With a plain flag, this gives the interleaving that #373 notes: an event schedules a second task, the first one clears the flag when it gets the lock, and the next event schedules a third; `close()` cancels only the last one, and waits for the second up to the signature interval. The signature task is now one `SignatureTask` per schedule instead of one shared `Runnable`, so that a task can tell whether it is the pending one. ## Verifier `CsvSecureVerifier` also: - accepts an empty file whose keys match, as the rotation above archives one; This replaces the rule of #332, which rejected every file without a header: a file truncated to nothing after events still fails, as the current key of its keystore moved on. `CsvSecureVerifierTest.shouldNotVerifyEmptyFile` now asserts that failure reason. - rejects a row 2 signature that is not base64; - rejects a signature row that holds other values: the data and HMAC columns of signature rows were not checked, so an event could be written into them; - reports a row with neither an HMAC nor a signature as an incorrect HMAC instead of throwing. ## Tests - `SecureCsvWriterTest`: resuming after a forced rotation and after a rotation on write, an event that rotates the file after a periodic signature, a rotation before the first event, a file truncated to the chained signature, a malformed or empty chained signature, signature rows that hold other values, a first event whose write rotates the file, a second `close()`. `shouldRotateCsvAndKeyStoreFile` now expects every archive and the live file to pass, with the plain and the `.gz` suffix; it has 5 archives instead of 6, as the 6th came from the redundant signature of `close()`. - `SecureCsvWriterTest.shouldGenerateHMACColumn` has a 60 s time-out. It fails when `unsignedEvents` is cleared after the signature row is written instead of before: that row then schedules another signature, and `close()` waits for it for the whole signature interval. - `SecureCsvWriterTest`, for #373: - `shouldSignAnEventWrittenWhileThePeriodicSignatureCompletes`: the writer's `flush()`, which `writeSignature()` calls once the signature row is written, writes a second event the first time it runs. A signature must follow that event, and the live file must pass verification while the writer is still open. With the `isDone()` condition it fails: no second signature within 10 s. - `shouldSignPeriodicallyAnEventWrittenAfterARotationCancelledTheScheduledSignature`: a forced rotation cancels the scheduled signature, and the next event must get a periodic one. It fails when the cancel leaves `pendingSignature` set. - `shouldScheduleNoOtherSignatureWhenARotationSignsWhileTheScheduledOneWaitsForTheLock`: the test holds the lock until the scheduled signature waits for it, rotates and writes events, and `close()` must return in less than half the 3 s interval. When a task clears `pendingSignature` whatever it holds, `close()` takes 2.7 s. - `CsvWriterTest.shouldCreateNewAuditFileAfterRotation` verifies the live file, in place of a commented-out TODO. - The temporary directories of the `SecureCsvWriterTest` tests this PR adds or changes are under `target`. Each fix was disabled in turn, and its test failed. `audit/core` and `audit/handler-csv` pass locally, and `SecureCsvWriterTest` passed 5 runs in a row. ## Known limits - The signature chained into row 2 is not authenticated: a live file with no event since the rotation passes with any base64 value there. No tool checks the link between files (`CsvSecureArchiveVerifier` verifies each file on its own), the row can hold no event, and a truncation is caught by the keys. - Files written by earlier versions with a signature over the HMAC of the previous file at row 3, or with an empty chained signature, still fail verification: the first needs the HMAC of the previous archive, the second holds nothing to verify. To resume such a live file, move it and its `.keystore` aside before the restart. Merging with #347 or #348 does not conflict, and #362 does not touch these files. #355 conflicts with `master` in the constructor of `SecureCsvWriter`, and with this PR in no other place.
#347) Fixes #334. Fixes #350. Fixes #349. Fixes #365. Fixes #363. Fixes #364. Fixes #351. ## The race `CsvAuditEventHandler` looked up the writer of a topic without a lock; only the reset after a failed write and the rotations took the handler monitor. A publisher that had the writer when the `rotate` action closed it wrote into the closed writer. With rotation and buffering disabled, `TextWriter.Stream` wrote through a `PrintWriter`, which swallows the `IOException`, so the retry never ran: the event was lost and `publishEvent()` reported success. A stale `SecureCsvWriter` also stored its keystore over the one of the new file. With rotation enabled ([comment](#334 (comment))), the `rotate` action looked up the writer without the monitor too, and could call `forceRotation()` on a writer that a concurrent reset had closed. ## The fix The issue offers two ways out: make a write to a closed writer fail, so that the retry opens the current writer, or make `rotate` wait for the publishers that are already writing. This PR does the latter. A closed writer that throws would not cover the path with rotation enabled, and would leave the gap between the final signature and the close of the stream in `SecureCsvWriter.close()` that #337 left to this issue. A write to a closed writer now fails as well (#349, below), but only the lock covers both paths. A `ReentrantReadWriteLock` per topic guards the writer of the topic, and replaces the handler monitor: - a publisher looks up the writer and writes the event under the read lock. If the topic has no writer, or the write fails, it retries under the write lock, with a new writer unless another thread has replaced the writer meanwhile; - the `rotate` action with rotation enabled holds the read lock during `forceRotation()`: publishers keep writing, and a reset cannot close the writer meanwhile; - the default rotation, `startup()` and `shutdown()` take the write lock, so they wait for the events being written. The rotated file is still compressed after the lock is released (#332). With a lock per topic, the other topics keep publishing while the writer of one topic is replaced. With a single lock they would not: new readers of a `ReentrantReadWriteLock` also queue behind a waiting writer, so one event being written plus a waiting rotation would stop every topic. The retry now also opens a writer when a failed reset of another thread has left the topic without one, where it threw a `NullPointerException` before. ## Events after `shutdown()` (#350) `shutdown()` closed the writers but left them in the map, and nothing marked the handler as shut down. An event published afterwards went to the closed writer of its topic. With rotation disabled, it was lost while `publishEvent()` reported success, and a tamper-evident writer still stored its next key, so the file failed verification and could not be resumed. With rotation enabled, the closed `RotatableWriter` threw, and the retry opened a new writer that nothing closed. The `rotate` action after `shutdown()` also rotated the file and opened a new writer, in both modes. `shutdown()` now marks the handler as shut down before it takes the first write lock, and removes each writer it closes from the map. A topic without a writer then gets no new one: - the retry under the write lock fails with a `ServiceUnavailableException`, so the event is reported as lost, and is written neither to a closed writer nor to a new one. A topic that has a writer still resets it after a failed write, as `startup()` may have reopened it already; - the `rotate` action finds no writer, and fails with a `ServiceUnavailableException`, as a publish does (#364, below). An event that has the read lock of its topic when `shutdown()` gets there is still written, and `shutdown()` waits for it. `startup()` clears the mark once it has opened the writers, also when a writer throws a `RuntimeException` (#340), so that a restarted handler opens a writer again, at the next event, for a topic whose file it could not open. Until `startup()` returns, an event or a `rotate` action on a topic that it has not opened yet fails with a `ServiceUnavailableException`, as the Javadoc of `startup()` now states. Before, such an event went to the closed writer of its topic, as above, and with rotation enabled `startup()` then replaced the writer that the retry had opened, without closing it. `startup()` now closes a writer that an event opened before it, or after a `startup()` that threw, before it opens a new one. The retry under the write lock also rethrows a `ResourceException` as is. `ResourceException` is an `IOException`, so the `ServiceUnavailableException` would have become an `InternalServerErrorException`, and the `InternalServerErrorException` of a failed reset would have been wrapped into another one, since the reset moved into that `try`. ## Write failures (#349) With rotation disabled, `StandardCsvWriter` and `SecureCsvWriter` write through `TextWriter.Stream`, which wrote through a `PrintWriter`. A `PrintWriter` never throws: it sets an error flag, which nothing read. A write that failed, on a full disk, on an I/O error or on a closed stream, was lost while `publishEvent()` reported success, the retry never ran, and a tamper-evident writer still stored the next key after the lost row, so the file failed verification and could not be resumed. `TextWriter.Stream` now writes through a `BufferedWriter` over an `OutputStreamWriter`, the two writers that the `PrintWriter` wrapped, so the buffering and the charset are the same, and `write()` and `flush()` throw the `IOException` of the stream with its message: - the retry of the handler opens a new writer for the event, as it does with rotation enabled, where `RotatableWriter` already threw; - a tamper-evident writer stores the next key only after the row is flushed, so it no longer stores the key of a row it could not write; - with buffering enabled, `AsynchronousTextWriter` logs the failure, which was silent before; - a failure to close the stream is logged, as `RotatableWriter` does. After a failure, the buffers of these writers are unusable: the encoder of the `OutputStreamWriter` is left with its buffer flipped, so the next flush writes nothing and returns, and a close writes a part of a failed text longer than the buffers. Several publishers write to the same `StandardCsvWriter` under the read lock, so the flush of the next one would report success for an event that the failure dropped. `TextWriter.Stream` therefore fails every write and flush after the first failure, with that failure as the cause, and `shutdown()` then closes the stream without writing what the buffers hold. A write or a flush checks for a failure, and records its own, under the lock of the writer: a flush that waits for a flush that fails would otherwise find the buffers empty, and return. Every publisher whose event was in the buffers gets the `IOException`, and the handler retries the event with a new writer. A close that fails records its failure as well, and closes the stream, which the close of the buffers leaves open on JDK 8 and 11, and on older updates of 17 and 21. A caller that logs or throws only the next failure, such as the flush in the `finally` block of `SecureCsvWriter.writeSignature()`, still gives the first one, such as a full disk. `StandardCsvWriter` writes the header of a new file in its constructor, which can now throw; it then closes the file, and the rotator thread with rotation, as the caller gets no writer to close. `write()` and `flush()` of `TextWriter.Stream` now declare `IOException`. In this repository only the two CSV writers use the class, through the `TextWriter` interface, which declared it already; code that calls them on a `TextWriter.Stream` reference has to handle it. `write(null)` throws a `NullPointerException`, where `PrintWriter.print(null)` wrote `null`. ## `RotatableWriter` after a failure (#365) `RotatableWriter`, which writes when rotation is enabled, had the same buffers and the same flaw: with two publishers, the flush of the second one returned after the flush of the first one failed; `close()`, which the reset calls, wrote 8192 bytes of a failed event into the file; and a rotation went on with a new writer, on which the flush of an event that the failure dropped succeeded. `TextWriter.Stream` and `RotatableWriter` now share `FailFastWriter`, a package-private `Writer` that holds the logic above: it fails every write and flush after the first write, flush or close that fails, with that failure as the cause, and closes the stream without writing what the buffers hold. `RotatableWriter` does not rotate a file after a failure: the rotation checks log the failure, and `forceRotation()` throws it, until the writer is replaced. It keeps the writer of the failure until then, so its later flushes fail too. `RotatableWriter` gets a package-private constructor that opens the file through a `FileStreamOpener`, so that the tests can open a stream that fails. `RotationContext.getWriter()` now returns the `FailFastWriter`, which is not a `BufferedWriter`. Its declared type is `Writer`, which the hooks of this repository use; a `RotationHooks` that casts it to `BufferedWriter` gets a `ClassCastException`. ## Buffered writes (#363) With buffering enabled, `AsynchronousTextWriter` writes the records on its own thread, and only logged a failure of the wrapped writer: the handler never learned of it, never replaced the writer, and each record logged a stack trace while the failure lasted. `AsynchronousTextWriter` now keeps the first failure of the wrapped writer, and fails the next `write()` and `flush()` with it as the cause, so that the retry of the handler opens a new writer. It logs the first failure with its stack trace, the next ones at debug level, and, at shutdown, the number of records that could not be written. `flush()` now throws the failure of the wrapped writer instead of logging it, and declares `IOException`: code that calls it on an `AsynchronousTextWriter` reference has to handle it. The first failure is kept for the life of the writer, as the class Javadoc now states: the caller replaces the writer after a failure, as the handler does. The writers of this package that it wraps fail their later writes after a failure too, so a writer that went on would not write again either. `shutdown()` of a buffered handler now fails with an `InternalServerErrorException` when the wrapped writer has failed already, or fails to flush the records handed to it, as the handler does without buffering when the flush fails. The records still queued are written while the writer closes, after that flush: a failure there is only logged, with their number. Every writer is still closed. ## Server errors (#364) `CsvAuditEventHandler` reported an `IOException` of the audit file as a `BadRequestException` (400): a failed write after the reset, a writer that could not be opened, a file that could not be read or queried, and a rotation that failed. These are failures of the server, which a client may retry. They are now `InternalServerErrorException` (500), and `queryEvents()` adapts its failure with `ResourceExceptionsUtil.adapt()`, which keeps a `ResourceException` and turns a `JsonValueException` into a `BadRequestException`. The `rotate` action after `shutdown()` fails with a `ServiceUnavailableException` (503), as a publish does. `BadRequestException` stays for an action without a topic, an unknown action and an unknown topic. The `rotate` action throws the exception of a rotation that does not happen after the `try` of `forceRotation()`: a `ResourceException` is an `IOException`, so its `catch` wrapped the exception into another one, whatever its status. ## Failures after the event is written (#351) The handler takes an `IOException` of a write for an event that is not in the file: it closes the writer, opens a new one and writes the event again. Two writers threw after the event was written: - `SecureCsvWriter.writeEvent()` writes and flushes the row, then stores the next HMAC key in the keystore of the file. When the key could not be stored, the reset closed a writer whose file was intact, and the new writer verified the file against the keystore, which still held the previous key: it failed with "Final HMAC key doesn't match expected value", and so did every later event of the topic, also once the keystore could be written again. The writer now logs the failure and goes on: the next row stores the key, and the event is reported as published, as it is in the file. - `RotatableWriter.write()` applies the rotation policies after it writes the text, so a rotation that failed threw after the event was written, and the handler wrote the event again to a new writer. With tamper-evident logging, the rotation signs the file first, and a keystore that cannot be stored failed it, so the reset broke the topic as above. `write()` now logs the failure of the rotation, as the scheduled checks do. A rotation that fails because the file cannot be written, also when the file fails to close, leaves the writer failed (#365): the flush that follows the write then fails, and the handler writes the event again, which the failure dropped. `CsvWriter.writeEvent()` now states that it does not throw once the row is written, which the retry of the handler relies on. ## Tests - `CsvAuditEventHandlerTest.testActionToRotateFileKeepsEventBeingPublished`, standard and tamper-evident: a publisher stops while it serializes a field of its event, after it has the writer and before it writes to it, and the `rotate` action runs meanwhile. Every event is then written once, to the archive or to the new file, and with tamper-evident logging both files pass verification with their own keystores. Without the fix, the event being published is lost in both variants. - `CsvAuditEventHandlerTest.testActionToRotateFileLetsOtherTopicsPublish`: an event of `activity` is written while the `rotate` action of `access` waits for an event being written. With one lock for all topics, it times out. - `testActionToRotateFileCompressesOutsideHandlerMonitor` is renamed to `testActionToRotateFileCompressesOutsideWriteLock`, as there is no monitor any more. - `CsvAuditEventHandlerTest.testPublishEventAfterShutdownFails`, standard and tamper-evident, each with rotation disabled and enabled: an event published after `shutdown()` fails with a `ServiceUnavailableException` and is not in the file, and the tamper-evident file passes verification. Without the fix, all four variants report success. - `CsvAuditEventHandlerTest.testActionToRotateFileAfterShutdownFails`, with rotation enabled and disabled: the `rotate` action after `shutdown()` fails and leaves the file as it is. Without the fix, it rotates the file in both modes. - `CsvAuditEventHandlerTest.testPublishEventAfterShutdownAndStartup`: after `shutdown()` and `startup()`, a topic whose file could not be opened at startup opens it at its next event. - `TextWriterTest` (new, `audit/core`): a failure of the output stream is thrown by `flush()` with its message; a write after `shutdown()` throws; after a failed flush of two events, the next flush and write throw too; the next failures have the first one as their cause; after a failed write of a text longer than the buffers, `shutdown()` writes nothing of it; a text written and flushed reaches the stream with its byte count. Without the fix, all but the last fail: with the plain `BufferedWriter`, the next flush returns, and `shutdown()` writes 8192 bytes of the failed text. - `TextWriterTest.testStreamFailsTheFlushThatWaitsForAFailingFlush`: two publishers flush at the same time, and the write of the first flush to the stream fails while the second flush waits for it. Both flushes fail, and nothing reaches the stream. Without the check under the lock, the second flush returns. - `TextWriterTest.testStreamFailsTheWriteThatWaitsForAFailingFlush`: a publisher writes while the write of a flush to the stream fails. Both fail, and nothing reaches the stream. Without the lock around the write, the write returns, and puts its event into the buffers that the failure left unusable. - `TextWriterTest.testStreamClosesTheOutputStreamWhenTheShutdownFails`: the close that writes the buffers fails, and the stream is closed. Without the fix, the stream stays open on JDK 11. A probe of `BufferedWriter.close()` over a stream that fails leaves the stream open on 1.8.0_402 and 11.0.32 too, and closes it on 17.0.20, 21.0.12 and 27. - `RotatableWriterTest.testFailsEveryWriteAndFlushAfterAFailure`, `testDoesNotWriteTheTextOfAFailedWriteWhenClosed` and `testDoesNotRotateAfterAFailure`, through a stream whose first write fails. Without the fix, the second flush returns, `close()` writes 8192 bytes of the failed event, and `forceRotation()` rotates the file. - `RotatableWriterTest.testDoesNotRotateAfterTheRotationFailsToCloseTheFile`: the close of a size rotation that `write()` triggers fails to write the text, and the disk works again before the flush of the text. `forceRotation()` and the flush fail, the file holds only the text written before, and there is no archive. Without the fix, `forceRotation()` rotates the file, and the flush succeeds on the new file for the text that the failed close dropped. - `AsynchronousTextWriterTest` (new, `audit/core`): after the wrapped writer fails a write, a later `write()` fails with that failure as the cause; `flush()` throws the failure of the wrapped writer, and the next `write()` fails. Without the fix, no write fails, and `flush()` returns. - `CsvAuditEventHandlerTest.testFailuresOfAuditFileAreServerErrors`: with a directory in place of the audit file, a publish, a read, a query and the `rotate` action fail with an `InternalServerErrorException`. Without the fix, all four fail with a `BadRequestException`. `testActionToRotateFileAfterShutdownFails` now expects a `ServiceUnavailableException`, and `testActionToRotateTamperEvidentFileKeepsFileWhenKeyStoreCannotBeRenamed` an `InternalServerErrorException`. - `CsvAuditEventHandlerTest.testActionsThatTheRequestGetsWrongAreBadRequests`: an action without a topic, an unknown action, and the `rotate` action of an unknown topic fail with a `BadRequestException`. - `CsvAuditEventHandlerTest.testActionToRotateFileThatFailsIsServerError`: with rotation enabled, the `rotate` action fails with an `InternalServerErrorException` for a writer whose `forceRotation()` throws an `IOException`, and for one whose `forceRotation()` returns `false`, with the message of a rotation that does not happen. The writers are put into the handler by reflection. - `CsvAuditEventHandlerTest.testPublishEventOpensWriterWhenTopicIsLeftWithoutOne`: the writer of the topic removes itself from the handler, as a failed reset of another publisher does, and fails the write. The event is written once, to a new writer. Without the `newCsvWriter == null` check, the publish fails with a `NullPointerException`. - `CsvAuditEventHandlerTest.testPublishEventDuringStartupResetsWriterOfReopenedTopic`: after `shutdown()`, a writer is put into the handler by reflection, as when `startup()` has reopened the topic and not returned yet, and fails the write. The event is written once, to a new writer. Without the fix, it fails with a `ServiceUnavailableException`. - `CsvAuditEventHandlerTest.testStartupClosesTheWriterThatItReplaces`: a writer put into the handler by reflection, as one that an event opened after a `startup()` that threw, is closed by the next `startup()`. Without the fix, `startup()` replaces it and leaves it open. - `CsvAuditEventHandlerTest.testRestartLetsOtherTopicsPublishWhenTamperEvidentKeyStoreCannotBeStored`: after `shutdown()`, the file of the topic that `startup()` opens first is deleted and its keystore made read-only, so the writer loads the keystore but cannot store the first keys of the new file, and `startup()` throws (#340). An event of the other topic is then published. Without the fix, it fails with a `ServiceUnavailableException`. The test fails if `startup()` does not throw, so that it does not pass without the fix, and is skipped where the keystore cannot be made read-only, as for root. - `CsvWriterTest.shouldFailToWriteEventToClosedStandardCsvWriter` and `shouldKeepSecureCsvFileVerifiableWhenWriteToClosedWriterFails`: an event written to a closed writer fails with an `IOException`, and the tamper-evident file still passes verification with its keystore. Without the fix, both writes report success. - `CsvAuditEventHandlerTest.testPublishEventKeepsTamperEvidentWriterWhenKeyStoreCannotBeStored`, with rotation enabled and disabled: a directory replaces the keystore during one event, then the keystore is restored. Every event is published and written once, and the file passes verification with its keystore. Without the fix, that event fails with `RuntimeException: Error when initializing a secure CSV writer` (#340), from the reset. - `CsvAuditEventHandlerTest.testPublishEventKeepsTamperEvidentWriterWhenRotationAfterItFails`: with a size-based rotation, a directory replaces the keystore during the event whose write rotates the file, so the signature of the rotation fails. Every event is published and written once, to the archive that the next event rotates, and the archive passes verification with its keystore. Without the fix, the event fails the same way. - `RotatableWriterTest.testDoesNotFailTheWriteOfATextWhenTheRotationAfterItFails`: a rotation hook fails at the write that follows a file of the maximum size; the write and the flush return, and the text is in the file once. Without the fix, the write throws the failure of the hook. The default rotation under the read lock is caught by the first test, and the compression under the write lock by the third. For #350, keeping the closed writers in the map fails the last three tests, dropping the check under the write lock fails the four variants of `testPublishEventAfterShutdownFails`, and a `startup()` that does not clear the mark fails the last test. For #349, storing the key before the row is written fails `shouldKeepSecureCsvFileVerifiableWhenWriteToClosedWriterFails` with "Final HMAC key doesn't match expected value". For #351, throwing the failure to store the key fails both variants of `testPublishEventKeepsTamperEvidentWriterWhenKeyStoreCannotBeStored` and `testPublishEventKeepsTamperEvidentWriterWhenRotationAfterItFails`, and throwing the failure of the rotation from `write()` fails `testPublishEventKeepsTamperEvidentWriterWhenRotationAfterItFails` and `testDoesNotFailTheWriteOfATextWhenTheRotationAfterItFails`. A rotation that logs the failed close of the file and goes on fails `testDoesNotRotateAfterTheRotationFailsToCloseTheFile`. A `BadRequestException` for the `IOException` of `forceRotation()`, or for a rotation that does not happen, fails `testActionToRotateFileThatFailsIsServerError`, and an `InternalServerErrorException` for an unknown topic fails `testActionsThatTheRequestGetsWrongAreBadRequests`. Throwing the exception of a rotation that does not happen inside the `try` fails `testActionToRotateFileThatFailsIsServerError` on its message. A retry under the write lock that fails for any topic once the handler is shut down fails `testPublishEventDuringStartupResetsWriterOfReopenedTopic`, a `startup()` that does not close the writer it replaces fails `testStartupClosesTheWriterThatItReplaces`, and a `FailFastWriter.write()` outside the lock fails `testStreamFailsTheWriteThatWaitsForAFailingFlush`. `audit/core`, `audit/handler-csv`, `audit/handler-json` and `audit/handler-syslog` pass locally on JDK 11 and 27, and the new tests of #334 passed 20 runs in a row. One full run on JDK 11 failed `CsvWriterTest.shouldCreateNewAuditFileAfterRotation`, on the archive of an event that rotated the file after a periodic signature, which #342 fixes. ## Not covered by a test - The `rotate` action with rotation enabled against a concurrent reset: the reset needs a write that fails with an `IOException`, which a test cannot cause with rotation enabled. The same lock covers that path, by reasoning only. - The retry of the handler after a write failure with rotation disabled (#349): a test cannot make the file stream of a writer fail without a full disk, so the writer tests close the stream instead, and `TextWriterTest` uses a stream that fails. - The close in the `StandardCsvWriter` constructor when the header cannot be written, for the same reason. - The retry of the handler after a failure with buffering enabled (#363), for the same reason, and the log of `AsynchronousTextWriter`: the tests of `audit/core` log through `slf4j-simple`, which a test cannot read. - The rotation checks after a failure (#365) run on a timer; they go through the same `rotate()` as `forceRotation()`, which the test covers. ## Known limits - A write that fails after part of its bytes reached the file leaves a partial row, which the retry follows with the whole event. - A tamper-evident writer computes the HMAC of a row, and moves to the next key, before it writes the row. After a failed write, the reset closes it: when a signature is due, the final signature covers the HMAC of the row that is not in the file, and its write fails as well. The file then ends with events that no signature covers, and the new writer cannot resume it: the topic fails loudly, where it lost the row and stored a key that broke the file silently before. - With buffering enabled, the records queued before the failure shows are reported as published, and are lost: their number is logged when the writer is replaced. `shutdown()` does not fail for the records still queued when it is called: their failure is only logged. - Until the writer is replaced, the rotation checks log the failure at each check interval. - Until the keystore can be stored again, it holds the key of an earlier row: the file fails verification meanwhile, and a handler stopped before the next row cannot resume it. Each row logs the failure. - A rotation that fails after it has renamed the file leaves the writer half rotated. When the new file cannot be created or opened, the writer keeps the closed one: the flush that follows the write fails, and the handler writes the event again to a new file, while it is in the archive already, as it did before. When a rotation hook fails after the rename, the writer goes on with the new file, which may lack its header or, with tamper-evident logging, the keys of its keystore; before, the write threw, and the reset made the topic fail. The scheduled checks leave the writer in the same states. ## Merging - #348 makes `TextWriter.Stream` write in UTF-8. The two conflict in the imports and the constructor of `TextWriter.Stream`; the merge is `new FailFastWriter(stream, StandardCharsets.UTF_8)`. - #341 and #353, merged meanwhile, add tests that expect a `BadRequestException` for a topic whose file cannot be opened or resumed (`testStartupOpensOtherTopicsWhenTamperEvidentFileCannotBeResumed`, `testStartupWithAuditFileOfMoreColumnsThanTopicSchema`). With #364 the handler answers an `InternalServerErrorException` there, and since the rebase onto them they expect it. With #341, a file that cannot be resumed fails with an `IOException`, and `startup()` opens the other topics, so the restart test now fails the writer with a keystore that cannot be stored, which is still a `RuntimeException` (`testRestartLetsOtherTopicsPublishWhenTamperEvidentKeyStoreCannotBeStored`). - #342 changes the condition that schedules a signature in `SecureCsvWriter.writeEvent()`, two lines below the key store that this PR stops throwing for. The two merge without a conflict, and so do this PR and #355. ## Not in this PR Found in the review of this change, older than it, and filed separately: - #340, fixed by #341: a `RuntimeException` from `createCsvWriter()` escapes `startup()`, `publishEvent()` and `handleAction()`.
…he CSV files in UTF-8 on every path RotatableWriter writes in UTF-8, and CsvSecureUtils.dataToSign() computes each HMAC over the UTF-8 bytes of the values, but TextWriter.Stream (rotation disabled) wrote, and CsvSecureVerifier read, in the platform default charset. With a default charset other than UTF-8 (JDK 11-17 on Windows, -Dfile.encoding), an untampered file with a non-ASCII value failed verification with "The HMac at row N is not correct", and with rotation disabled a character that the platform charset cannot encode was written as '?'. - TextWriter.Stream writes in UTF-8. - CsvSecureVerifier reads in UTF-8 and reports malformed input: a file that is not UTF-8 fails with "The file is not valid UTF-8" instead of an HMAC failure, and bytes that are not UTF-8 no longer decode to U+FFFD. - CsvAuditEventHandler reads the audit file in UTF-8 for read and query, and StandardCsvWriter reads the header of the file it resumes in UTF-8. - The tests of handler-csv run with windows-1252 as the default charset, so that they catch a reader or a writer that uses the default charset on every platform. A tamper-evident file written with rotation disabled under a default charset other than UTF-8, with a non-ASCII value, now fails verification with "The file is not valid UTF-8". Fixes OpenIdentityPlatform#336
… check the default charset of the tests - CsvWriterTest.shouldNotResumeFileNotInUtf8: SecureCsvWriter does not resume a tamper-evident file that the previous version wrote in windows-1252. - CsvAuditEventHandlerTest.testReadingAuditLogEntryNotInUtf8 and CsvWriterTest.shouldNotResumeCsvFileWithNonAsciiHeaderNotInUtf8: a plain CSV file in windows-1252 is resumed as it is, and its old rows read back with U+FFFD; one with a non-ASCII column name is not resumed. - CsvWriterTest.shouldRunWithDefaultCharsetOtherThanUtf8 fails when the tests run with a UTF-8 default charset, under which four of the new cases pass against the unfixed code. - The comment of the surefire setting says that it replaces a JAVA_TOOL_OPTIONS of the environment, and why it is not in argLine.
…52 in the header resume test CsvWriterTest.shouldNotResumeCsvFileWithNonAsciiHeaderNotInUtf8 re-encoded the file of the writer under test from UTF-8 to windows-1252. Against the unfixed writer, which wrote it in windows-1252 already, the re-encoding replaced the column name with '?', and the header mismatched whatever charset it was read in, so the case passed against the unfixed code. It now writes the header in windows-1252 itself, as the writer wrote it before, and fails against the unfixed code too.
c0105c9 to
5a2de8b
Compare
Fixes #336.
Charset
RotatableWriterwrites in UTF-8, andCsvSecureUtils.dataToSign()computes each HMAC over the UTF-8 bytes of the values.TextWriter.Stream, which writes when rotation is disabled, andCsvSecureVerifierused the platform default charset. With a default charset other than UTF-8 (JDK 11–17 on Windows, or-Dfile.encoding), an untampered file with a non-ASCII value failed verification with "The HMac at row N is not correct", the live file too, so the writer could not resume it. With rotation disabled, a character that the platform charset cannot encode, such as a Cyrillic letter in windows-1252, was written as?.Every reader and writer of the audit files now uses UTF-8:
TextWriter.Streamwrites in UTF-8, asRotatableWriterdoes. In this repository only the two CSV writers use it.CsvSecureVerifierreads in UTF-8.CsvAuditEventHandlerreads the audit file in UTF-8 forreadEventandqueryEvents, which returned non-ASCII values garbled.StandardCsvWriterreads the header of the file it resumes in UTF-8. A non-ASCII column name failed with "Resuming an existing CSV file but the headers do not match."Malformed input
CsvSecureVerifierdecodes withUTF_8.newDecoder(), which reports malformed input instead of replacing it with U+FFFD. A file that is not UTF-8 fails with "The file is not valid UTF-8" instead of an HMAC failure, and a byte sequence that is not UTF-8 can no longer pass for a U+FFFD that the writer logged.Upgrade
On a host whose default charset is not UTF-8 (Windows, JDK 11–17, or
-Dfile.encoding), the files that the previous version wrote with rotation disabled are in that charset:üoréin windows-1252) passed verification before and now fails with "The file is not valid UTF-8". When its bytes happen to be valid UTF-8, as some sequences of a multi-byte charset such as MS932 or GBK are, it fails with "The HMac at row N is not correct." instead. This includes the live file, which the writer verifies before it resumes it. Until [#340] Tamper-evident CSV audit: close the file of a writer that fails to resume it #341, this failure stops the startup of the whole audit service, not only of the topic:SecureCsvWriterwraps it in aRuntimeException, which neitherCsvAuditEventHandler.startup()norAuditServiceImpl.startup()catches. It also leaves the live file open (Tamper-evident CSV audit: a writer that fails to resume its file leaks it, and stops the audit service from starting #340).readEventandqueryEventsread the rows written before with U+FFFD in place of the bytes that are not UTF-8, and a query no longer matches such a value. A file whose header holds a non-ASCII column name, which only a custom topic schema has, is not resumed: the topic fails with "Resuming an existing CSV file but the headers do not match."On such a host, before the upgrade, stop the service and move aside the files of each topic written with rotation disabled. For a tamper-evident topic, rename
tamper-evident-<topic>.csvandtamper-evident-<topic>.csv.keystoretotamper-evident-<topic>.csv<timestamp>andtamper-evident-<topic>.csv<timestamp>.keystore, with<timestamp>in the format of the CLI's--suffixwithout.gz(-yyyy.MM.dd-HH.mm.ssby default, as intamper-evident-access.csv-2026.10.19-12.00.00): the file is not compressed. Verify them on that host with the CLI of the previous version, which reads in the platform charset. Do not use therotateaction of a version up to 3.2.1 for such a topic: with rotation disabled, it renames only the CSV file, under a.gzname although it does not compress it, and the writer of the new file overwrites the keys of the archive in the keystore that it leaves; #332 fixes this in this version. For a plain CSV topic, therotateaction is enough.Files written with rotation enabled were in UTF-8 already: they failed verification on such a host, and now pass. On such a host, the files written with rotation disabled are now in UTF-8 too: a tool that reads them must decode them as UTF-8.
Rolling back on such a host: the previous version reads in the platform charset, so it fails the tamper-evident files that this version wrote with rotation disabled and a non-ASCII value, the live file included. It reads the non-ASCII values of a plain CSV file that this version wrote with rotation disabled garbled, and does not resume such a file whose header holds a non-ASCII column name. Run the
rotateaction of this version before the rollback, and verify the archives written after the upgrade with the CLI of this version.TextWriter.Streamis public: audit-core exportsorg.forgerock.audit.events.handlers.writers. With a default charset other than UTF-8, a caller ofnew TextWriter.Stream(out)outside this repository now gets the UTF-8 bytes of a non-ASCII character, andgetBytesWritten()counts them; a file that such a caller appends to then mixes two encodings. With a UTF-8 default charset, the output is unchanged.Tests
CsvWriterTest.shouldWriteAndVerifyNonAsciiValuesInUtf8, with rotation disabled and enabled: the file is in UTF-8 and passes verification.CsvWriterTest.shouldNotVerifyFileNotInUtf8: a file re-encoded in windows-1252 fails with "The file is not valid UTF-8".CsvWriterTest.shouldNotResumeFileNotInUtf8:SecureCsvWriterdoes not resume such a file.CsvWriterTest.shouldResumeCsvFileWithNonAsciiHeader.CsvAuditEventHandlerTest.testReadingAuditLogEntryWithNonAsciiValue.CsvAuditEventHandlerTest.testReadingAuditLogEntryNotInUtf8resumes a file re-encoded in windows-1252, reads its old row back with U+FFFD and the row appended after the resume as written;CsvWriterTest.shouldNotResumeCsvFileWithNonAsciiHeaderNotInUtf8does not resume a file whose header, with a non-ASCII column name, it writes in windows-1252.The bug shows only with a default charset other than UTF-8, so surefire runs the tests of
handler-csvwithJAVA_TOOL_OPTIONS=-Dfile.encoding=windows-1252, on every platform and JDK. This replaces aJAVA_TOOL_OPTIONSof the environment in these test JVMs.CsvWriterTest.shouldRunWithDefaultCharsetOtherThanUtf8fails when the tests run with a UTF-8 default charset, as in a run that does not apply this setting: there, six of the new cases pass against the unfixed code. With windows-1252, every new case but that check fails against the unfixed code, and each of the five changes is caught when reverted alone: the writer, the verifier, the strict decoder, the reader of the handler and the header check.audit/coreandaudit/handler-csvpass locally on JDK 11 and 27.Not in this PR
Found by reading the code in the review of this change, older than it, and not reproduced; filed separately:
StandardCsvWriterthrowsArrayIndexOutOfBoundsExceptionwhen it resumes a file with more columns than the topic schema.NullPointerException. Fixed since by [#346] Tamper-evident CSV audit: read and query a topic with no event yet #354.TextWriter.Streamstill swallows write failures throughPrintWriter; that is #349.