View Javadoc
1   /*
2    * The contents of this file are subject to the terms of the Common Development and
3    * Distribution License (the License). You may not use this file except in compliance with the
4    * License.
5    *
6    * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
7    * specific language governing permission and limitations under the License.
8    *
9    * When distributing Covered Software, include this CDDL Header Notice in each file and include
10   * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
11   * Header, with the fields enclosed by brackets [] replaced by your own identifying
12   * information: "Portions copyright [year] [name of copyright owner]".
13   *
14   * Copyright 2015-2016 ForgeRock AS.
15   */
16  package org.forgerock.audit.handlers.csv;
17  
18  import static java.lang.String.format;
19  import static java.util.Collections.singletonMap;
20  import static java.util.concurrent.TimeUnit.MILLISECONDS;
21  import static org.forgerock.audit.handlers.csv.CsvSecureConstants.HEADER_HMAC;
22  import static org.forgerock.audit.handlers.csv.CsvSecureConstants.HEADER_SIGNATURE;
23  import static org.forgerock.audit.handlers.csv.CsvSecureConstants.SIGNATURE_ALGORITHM;
24  import static org.forgerock.audit.handlers.csv.CsvSecureUtils.dataToSign;
25  import static org.forgerock.util.Reject.checkNotNull;
26  
27  import java.io.File;
28  import java.io.FileOutputStream;
29  import java.io.IOException;
30  import java.io.Writer;
31  import java.security.PrivateKey;
32  import java.security.PublicKey;
33  import java.security.SignatureException;
34  import java.util.HashMap;
35  import java.util.Map;
36  import java.util.Random;
37  import java.util.concurrent.Executors;
38  import java.util.concurrent.RejectedExecutionException;
39  import java.util.concurrent.ScheduledExecutorService;
40  import java.util.concurrent.ScheduledFuture;
41  import java.util.concurrent.locks.ReentrantLock;
42  
43  import javax.crypto.SecretKey;
44  import javax.crypto.spec.SecretKeySpec;
45  
46  import org.forgerock.audit.events.handlers.writers.RotatableWriter;
47  import org.forgerock.audit.events.handlers.writers.TextWriter;
48  import org.forgerock.audit.events.handlers.writers.TextWriterAdapter;
49  import org.forgerock.audit.events.handlers.writers.RotatableWriter.RolloverLifecycleHook;
50  import org.forgerock.audit.rotation.RotationContext;
51  import org.forgerock.audit.rotation.RotationHooks;
52  import org.forgerock.audit.secure.JcaKeyStoreHandler;
53  import org.forgerock.audit.secure.KeyStoreHandler;
54  import org.forgerock.audit.secure.KeyStoreHandlerDecorator;
55  import org.forgerock.audit.secure.KeyStoreSecureStorage;
56  import org.forgerock.audit.secure.SecureStorageException;
57  import org.forgerock.util.Reject;
58  import org.forgerock.util.annotations.VisibleForTesting;
59  import org.forgerock.util.encode.Base64;
60  import org.forgerock.util.time.Duration;
61  import org.slf4j.Logger;
62  import org.slf4j.LoggerFactory;
63  import org.supercsv.prefs.CsvPreference;
64  
65  /**
66   * Responsible for writing to a CSV file; silently adds 2 last columns : HMAC and SIGNATURE.
67   * The column HMAC is filled with the HMAC calculation of the current row and a key.
68   * The column SIGNATURE is filled with the signature calculation of the last HMAC and the last signature if any.
69   */
70  class SecureCsvWriter implements CsvWriter, RolloverLifecycleHook {
71  
72      private static final Logger logger = LoggerFactory.getLogger(SecureCsvWriter.class);
73  
74      private final CsvFormatter csvFormatter;
75      private final String[] headers;
76      private Writer csvWriter;
77      private RotatableWriter rotatableWriter;
78  
79      private HmacCalculator hmacCalculator;
80      private final ScheduledExecutorService scheduler;
81      private final ReentrantLock signatureLock = new ReentrantLock();
82      private final Runnable signatureTask;
83      private KeyStoreSecureStorage secureStorage;
84      private final Duration signatureInterval;
85      private ScheduledFuture<?> scheduledSignature;
86  
87      private String lastHMAC;
88      private byte[] lastSignature;
89      private boolean headerWritten = false;
90      private final Random random;
91      private File keyStoreFile;
92      private String keyStorePassword;
93  
94      SecureCsvWriter(File csvFile, String[] headers, CsvPreference csvPreference,
95              CsvAuditEventHandlerConfiguration config, KeyStoreHandler keyStoreHandler, Random random)
96              throws IOException {
97          Reject.ifFalse(config.getSecurity().isEnabled(), "SecureCsvWriter should only be used if security is enabled");
98          final boolean fileAlreadyInitialized = csvFile.exists() && csvFile.length() > 0;
99          this.random = random;
100         this.keyStoreFile = new File(csvFile.getPath() + ".keystore");
101         this.headers = checkNotNull(headers, "The headers can't be null.");
102         this.csvFormatter = new CsvFormatter(csvPreference);
103         this.csvWriter = constructWriter(csvFile, fileAlreadyInitialized, config);
104         this.hmacCalculator = new HmacCalculator(CsvSecureConstants.HMAC_ALGORITHM);
105 
106         try {
107             KeyStoreHandlerDecorator keyStoreHandlerDecorated = new KeyStoreHandlerDecorator(keyStoreHandler);
108             SecretKey password = keyStoreHandlerDecorated.readSecretKeyFromKeyStore(CsvSecureConstants.ENTRY_PASSWORD);
109             if (password == null) {
110                 throw new IllegalArgumentException(format(
111                         "No '%s' symmetric key found in the provided keystore: %s. This key must be provided.",
112                         CsvSecureConstants.ENTRY_PASSWORD, keyStoreHandlerDecorated.getLocation()));
113             }
114             this.keyStorePassword = Base64.encode(password.getEncoded());
115             KeyStoreHandler hmacKeyStoreHandler =
116                     new JcaKeyStoreHandler(CsvSecureConstants.KEYSTORE_TYPE, keyStoreFile.getPath(), keyStorePassword);
117             PublicKey publicSignatureKey =
118                     keyStoreHandlerDecorated.readPublicKeyFromKeyStore(CsvSecureConstants.ENTRY_SIGNATURE);
119             PrivateKey privateSignatureKey =
120                     keyStoreHandlerDecorated.readPrivateKeyFromKeyStore(CsvSecureConstants.ENTRY_SIGNATURE);
121             if (publicSignatureKey == null || privateSignatureKey == null) {
122                 throw new IllegalArgumentException(format(
123                         "No '%s' signing key found in the provided keystore: %s. This key must be provided.",
124                         CsvSecureConstants.ENTRY_SIGNATURE, keyStoreHandlerDecorated.getLocation()));
125             }
126             this.secureStorage = new KeyStoreSecureStorage(hmacKeyStoreHandler, publicSignatureKey,
127                     privateSignatureKey);
128             final CsvAuditEventHandlerConfiguration.CsvSecurity securityConfiguration = config.getSecurity();
129             if (fileAlreadyInitialized) {
130                 // Run the CsvVerifier to check that the file was not tampered.
131                 CsvSecureVerifier verifier = new CsvSecureVerifier(csvFile, csvPreference, secureStorage);
132                 CsvSecureVerifier.VerificationResult verificationResult = verifier.verify();
133                 if (!verificationResult.hasPassedVerification()) {
134                     throw new IOException("The CSV file was tampered: " + verificationResult.getFailureReason());
135                 }
136 
137                 // Assert that the 2 headers are equal.
138                 final String[] actualHeaders = verifier.getHeaders();
139                 if (actualHeaders != null) {
140                     if (actualHeaders.length != headers.length) {
141                         throw new IOException("Resuming an existing CSV file but the headers do not match.");
142                     }
143                     for (int idx = 0; idx < actualHeaders.length; idx++) {
144                         if (!actualHeaders[idx].equals(headers[idx])) {
145                             throw new IOException("Resuming an existing CSV file but the headers do not match.");
146                         }
147                     }
148                 }
149 
150                 SecretKey currentKey = secureStorage.readCurrentKey();
151                 if (currentKey == null) {
152                     throw new IllegalStateException("We are supposed to resume but there is not entry for CurrentKey.");
153                 }
154                 this.hmacCalculator.setCurrentKey(currentKey.getEncoded());
155 
156                 setLastHMAC(verifier.getLastHMAC());
157                 setLastSignature(verifier.getLastSignature());
158                 this.headerWritten = true;
159             } else {
160                 initHmacCalculatorWithRandomData();
161             }
162 
163             this.signatureInterval = securityConfiguration.getSignatureIntervalDuration();
164             this.scheduler = Executors.newScheduledThreadPool(1);
165             this.signatureTask = new Runnable() {
166                 @Override
167                 public void run() {
168                     try {
169                         writeSignature(csvWriter);
170                     } catch (Exception ex) {
171                         logger.error("An error occurred while writing the signature", ex);
172                     }
173                 }
174             };
175         } catch (Exception e) {
176             throw new RuntimeException("Error when initializing a secure CSV writer", e);
177         }
178     }
179 
180     @Override
181     public void beforeRollingOver() {
182         // Prevent deadlock in case rotation/retention is enabled.
183         // Rotation will trigger pre and post rotation actions which write to the file,
184         // so no concurrent write must be performed during this time.
185         signatureLock.lock();
186     }
187 
188     @Override
189     public void afterRollingOver() {
190         signatureLock.unlock();
191     }
192 
193     private void initHmacCalculatorWithRandomData() throws SecureStorageException {
194         this.hmacCalculator.setCurrentKey(getRandomBytes());
195         // As we start to work, store the key as the initial one and the current one too
196         secureStorage.writeInitialKey(hmacCalculator.getCurrentKey());
197         secureStorage.writeCurrentKey(hmacCalculator.getCurrentKey());
198     }
199 
200     private byte[] getRandomBytes() {
201         byte[] randomBytes = new byte[32];
202         this.random.nextBytes(randomBytes);
203         return randomBytes;
204     }
205 
206     private Writer constructWriter(File csvFile, boolean append, CsvAuditEventHandlerConfiguration config)
207             throws IOException {
208         TextWriter textWriter;
209         if (config.getFileRotation().isRotationEnabled()) {
210             rotatableWriter = new RotatableWriter(csvFile, config, append, this);
211             rotatableWriter.registerRotationHooks(new SecureCsvWriterRotationHooks());
212             textWriter = rotatableWriter;
213         } else {
214             textWriter = new TextWriter.Stream(new FileOutputStream(csvFile, append));
215         }
216 
217         if (config.getBuffering().isEnabled()) {
218             logger.warn("Secure CSV logging does not support buffering. Buffering config will be ignored.");
219         }
220         return new TextWriterAdapter(textWriter);
221     }
222 
223     @Override
224     public void flush() throws IOException {
225         csvWriter.flush();
226     }
227 
228     @Override
229     public void close() throws IOException {
230         flush();
231         signatureLock.lock();
232         try {
233             forceWriteSignature(csvWriter);
234         } finally {
235             signatureLock.unlock();
236         }
237         scheduler.shutdown();
238         try {
239             while (!scheduler.awaitTermination(500, MILLISECONDS)) {
240                 logger.debug("Waiting to terminate the scheduler.");
241             }
242         } catch (InterruptedException ex) {
243             logger.error("Unable to terminate the scheduler", ex);
244             Thread.currentThread().interrupt();
245         }
246         csvWriter.close();
247     }
248 
249     private void forceWriteSignature(Writer writer) throws IOException {
250         if (scheduledSignature != null && scheduledSignature.cancel(false)) {
251             // We were able to cancel it before it starts, so let's generate the signature now.
252             writeSignature(writer);
253         }
254     }
255 
256     public void writeHeader(String... header) throws IOException {
257         writeHeader(csvWriter, header);
258     }
259 
260     public void writeHeader(Writer writer, String... header) throws IOException {
261         String[] newHeader = addExtraColumns(header);
262         writer.write(csvFormatter.formatHeader(newHeader));
263         logger.trace("Header written to file");
264         headerWritten = true;
265     }
266 
267     @VisibleForTesting
268     void writeSignature(Writer writer) throws IOException {
269         // We have to prevent from writing another line between the signature calculation
270         // and the signature's row write, as the calculation uses the lastHMAC.
271         signatureLock.lock();
272         try {
273             lastSignature = secureStorage.sign(dataToSign(lastSignature, lastHMAC));
274             logger.trace("Calculated new Signature");
275             Map<String, String> values = singletonMap(HEADER_SIGNATURE, Base64.encode(lastSignature));
276             writeEvent(writer, values);
277             logger.trace("Signature written to file");
278 
279             // Store the current signature into the Keystore
280             secureStorage.writeCurrentSignatureKey(new SecretKeySpec(lastSignature, SIGNATURE_ALGORITHM));
281             logger.trace("Signature written to secureStorage");
282         } catch (SecureStorageException ex) {
283             logger.error(ex.getMessage(), ex);
284             throw new IOException(ex);
285         } finally {
286             signatureLock.unlock();
287             flush();
288         }
289     }
290 
291     /**
292      * Forces rotation of the writer.
293      * <p>
294      * Rotation is possible only if file rotation is enabled.
295      *
296      * @return {@code true} if rotation was done, {@code false} otherwise.
297      * @throws IOException
298      *          If an error occurs
299      */
300     @Override
301     public boolean forceRotation() throws IOException {
302         return rotatableWriter != null ? rotatableWriter.forceRotation() : false;
303     }
304 
305     /**
306      * Write a row into the CSV files.
307      * @param values The keys of the {@link Map} have to match the column's header.
308      * @throws IOException
309      */
310     @Override
311     public void writeEvent(Map<String, String> values) throws IOException {
312         writeEvent(csvWriter, values);
313     }
314 
315     /**
316      * Write a row into the CSV files.
317      * @param values The keys of the {@link Map} have to match the column's header.
318      * @throws IOException
319      */
320     public void writeEvent(Writer writer, Map<String, String> values) throws IOException {
321         signatureLock.lock();
322         try {
323             if (!headerWritten) {
324                 writeHeader(headers);
325             }
326             String[] extendedHeaders = addExtraColumns(headers);
327 
328             Map<String, String> extendedValues = new HashMap<>(values);
329             if (!values.containsKey(CsvSecureConstants.HEADER_SIGNATURE)) {
330                 insertHMACSignature(extendedValues, headers);
331             }
332 
333             writer.write(csvFormatter.formatEvent(extendedValues, extendedHeaders));
334             writer.flush();
335             // Store the current key
336             secureStorage.writeCurrentKey(hmacCalculator.getCurrentKey());
337 
338             // Schedule a signature task only if needed.
339             if (!values.containsKey(HEADER_SIGNATURE)
340                     && (scheduledSignature == null || scheduledSignature.isDone())) {
341                 logger.trace("Triggering a new signature task to be executed in {}", signatureInterval);
342                 try {
343                     scheduledSignature = scheduler.schedule(signatureTask, signatureInterval.getValue(),
344                             signatureInterval.getUnit());
345                 } catch (RejectedExecutionException e) {
346                     logger.error(e.getMessage(), e);
347                 }
348             }
349         } catch (SecureStorageException ex) {
350             throw new IOException(ex);
351         } finally {
352             signatureLock.unlock();
353         }
354     }
355 
356     private void insertHMACSignature(Map<String, String> values, String[] nameMapping) throws IOException {
357         try {
358             lastHMAC = hmacCalculator.calculate(dataToSign(logger, values, nameMapping));
359             values.put(CsvSecureConstants.HEADER_HMAC, lastHMAC);
360         } catch (SignatureException ex) {
361             logger.error(ex.getMessage(), ex);
362             throw new IOException(ex);
363         }
364     }
365 
366     private String[] addExtraColumns(String... header) {
367         String[] newHeader = new String[header.length + 2];
368         System.arraycopy(header, 0, newHeader, 0, header.length);
369         newHeader[header.length] = HEADER_HMAC;
370         newHeader[header.length + 1] = HEADER_SIGNATURE;
371         return newHeader;
372     }
373 
374     private void setLastHMAC(String lastHMac) {
375         this.lastHMAC = lastHMac;
376     }
377 
378     private void setLastSignature(byte[] lastSignature) {
379         this.lastSignature = lastSignature;
380     }
381 
382     private void writeLastSignature(Writer writer) throws IOException {
383         // We have to prevent from writing another line between the signature calculation
384         // and the signature's row write, as the calculation uses the lastHMAC.
385         signatureLock.lock();
386         try {
387             Map<String, String> values = singletonMap(HEADER_SIGNATURE, Base64.encode(lastSignature));
388             writeEvent(writer, values);
389             logger.trace("Signature from previous file written to new file");
390         } catch (IOException ex) {
391             logger.error(ex.getMessage(), ex);
392             throw new IOException(ex);
393         } finally {
394             signatureLock.unlock();
395         }
396     }
397 
398     private class SecureCsvWriterRotationHooks implements RotationHooks {
399 
400         @Override
401         public void preRotationAction(RotationContext context) throws IOException {
402             // ensure the final signature is written
403             forceWriteSignature(context.getWriter());
404         }
405 
406         @Override
407         public void postRotationAction(RotationContext context) throws IOException {
408             // Rename the keystore and create a new one.
409             String currentName = keyStoreFile.getName();
410             String nextName = currentName.replaceFirst(context.getInitialFile().getName(),
411                     context.getNextFile().getName());
412             final File nextFile = new File(keyStoreFile.getParent(), nextName);
413             logger.trace("Renaming keystore file {} to {}", currentName, nextName);
414             boolean renamed = keyStoreFile.renameTo(nextFile);
415             if (!renamed) {
416                 logger.error("Unable to rename {} to {}", keyStoreFile.getAbsolutePath(), nextFile.getAbsolutePath());
417             }
418             try {
419                 secureStorage.setKeyStoreHandler(new JcaKeyStoreHandler(CsvSecureConstants.KEYSTORE_TYPE,
420                         keyStoreFile.getPath(), keyStorePassword));
421                 logger.trace("Updated secureStorage to reference new keyStoreFile");
422                 initHmacCalculatorWithRandomData();
423             } catch (Exception ex) {
424                 throw new IOException(ex);
425             }
426 
427             Writer writer = context.getWriter();
428             writeHeader(writer, headers);
429             // ensure the signature chaining along the files
430             writeLastSignature(writer);
431             // In case of low traffic we still want the headers to be written into the file
432             writer.flush();
433         }
434     }
435 }