1. 程式人生 > >利用 AES 對 log4j 日誌檔案加密

利用 AES 對 log4j 日誌檔案加密

總覽

本文簡要介紹了 AES 演算法加密的方式,以及如何利用 AES 對 log4j 輸出的日誌進行加密。

背景

在網際網路時代下,JAVA 大多用來做後端開發,由於後端的程式大多都部署在自己的伺服器上,客戶接觸不到程式的日誌檔案,因此,多數情況下,日誌是沒有加密的必要,log4j 本身也沒有提供加密的方法。但有些客戶端軟體仍然是用 java 編寫,客戶端安裝在客戶的 PC上,我們想要了解軟體的執行狀態以及出錯原因,就必須記下日誌,這些日誌可能包含有一些敏感的資訊,我們不希望使用者能直接看到,因此對日誌加密是很有必要的。

AES 加密

既然要進行加密,那麼首先得選擇一個可靠的加密演算法,網上搜索了下,大概有這三種:DES、AES、RSA。,其中 RSA 的解密是基於大數的因式分解,雖然安全性極高,但解密效率比其它兩種低得多,不太適合。 DES是美國聯邦政府採用過的一種加密方式,由於它的金鑰只有56位,因此演算法的理論安全強度是

256,但隨著計算機的飛速發展,每秒能處理的金鑰數越來越多,DES將不能夠提供足夠的安全性,因此沒過國家標準技術研究所開始徵集了 AES 用以取代 DES,研究所對 AES的要求是速度快(比三重 DES 速度快)、安全性高(至少與三重 DES一樣安全),最終 Rijndael 演算法脫穎而出。因此對日誌的加密解密選用 AES 比較適合。

JAVA 中的 AES

Java 早已提供標準的 AES 演算法供大家使用,這裡我提供一個簡單的 AES 加密工具類(需要引入 apache 的 codec)

import java.nio.charset.Charset;
import java.security.GeneralSecurityException;

import
javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import org.apache.commons.codec.binary.Base64; /** * @Title: AESUtil.java * @Description: AES 加密解密工具類 * @author: weekdragon * @date: 2018年7月18日 下午4:58:22 * @version V1.0 */ public class AESUtil
{
private static final String KEY_ALGORITHM = "ThisIsASecretKey"; private static final String DEFAULT_CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";// 預設的加密演算法 private static Cipher cipher = null; static { try { cipher = Cipher.getInstance(DEFAULT_CIPHER_ALGORITHM); } catch (Exception e) {} } /** * AES 加密操作 * * @param content * 待加密內容 * @param key * 加密密碼 * @return 返回Base64轉碼後的加密資料 */ public static String encrypt(String content, String key) throws GeneralSecurityException { if(cipher == null)return content; byte[] raw = key.getBytes(Charset.forName("UTF-8")); if (raw.length != 16) { throw new IllegalArgumentException("Invalid key size."); } SecretKeySpec skeySpec = new SecretKeySpec(raw, "AES"); cipher.init(Cipher.ENCRYPT_MODE, skeySpec, new IvParameterSpec(new byte[16])); byte[] doFinal = cipher.doFinal(content.getBytes(Charset.forName("UTF-8"))); return Base64.encodeBase64String(doFinal); } /** * AES 解密操作 * * @param content * @param key * @return */ public static String decrypt(String content, String key) throws GeneralSecurityException { byte[] encrypted = Base64.decodeBase64(content); byte[] raw = key.getBytes(Charset.forName("UTF-8")); if (raw.length != 16) { throw new IllegalArgumentException("Invalid key size."); } SecretKeySpec skeySpec = new SecretKeySpec(raw, "AES"); Cipher cipher = Cipher.getInstance(DEFAULT_CIPHER_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, skeySpec, new IvParameterSpec(new byte[16])); byte[] original = cipher.doFinal(encrypted); return new String(original, Charset.forName("UTF-8")); } }

對於 AES 加密,可以選擇不同的金鑰長度(16的整數倍),還可以選擇不同的補齊方法(加密資料不足16位時的補位策略),這裡只做了個最簡單的實現。

對日誌加密

log4j 本身沒有提供日誌加密的方法,但是使用者可以自定義日誌的 Appender,這個 Appender 就是負責日誌輸出的東西,目前我研究的加密方式有兩種:
第一種是對整個輸出流加密,自定義一個加密的輸出流

import javax.crypto.*;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.*;
import java.security.*;


public class FlushableCipherOutputStream extends OutputStream
{
    private static int HEADER_LENGTH = 16;


    private SecretKeySpec key;
    private RandomAccessFile seekableFile;
    private boolean flushGoesStraightToDisk;
    private Cipher cipher;
    private boolean needToRestoreCipherState;

    /** the buffer holding one byte of incoming data */
    private byte[] ibuffer = new byte[1];

    /** the buffer holding data ready to be written out */
    private byte[] obuffer;



    /** Each time you call 'flush()', the data will be written to the operating system level, immediately available
     * for other processes to read. However this is not the same as writing to disk, which might save you some
     * data if there's a sudden loss of power to the computer. To protect against that, set 'flushGoesStraightToDisk=true'.
     * Most people set that to 'false'. */
    public FlushableCipherOutputStream(String fnm, SecretKeySpec _key, boolean append, boolean _flushGoesStraightToDisk)
            throws IOException
    {
        this(new File(fnm), _key, append,_flushGoesStraightToDisk);
    }

    public FlushableCipherOutputStream(File file, SecretKeySpec _key, boolean append, boolean _flushGoesStraightToDisk)
            throws IOException
    {
        super();

        if (! append)
            file.delete();
        seekableFile = new RandomAccessFile(file,"rw");
        flushGoesStraightToDisk = _flushGoesStraightToDisk;
        key = _key;

        try {
            cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");

            byte[] iv = new byte[16];
            byte[] headerBytes = new byte[HEADER_LENGTH];
            long fileLen = seekableFile.length();
            if (fileLen % 16L != 0L) {
                throw new IllegalArgumentException("Invalid file length (not a multiple of block size)");
            } else if (fileLen == 0L) {
                // new file

                // You can write a 16 byte file header here, including some file format number to represent the
                // encryption format, in case you need to change the key or algorithm. E.g. "100" = v1.0.0
                headerBytes[0] = 100;
                seekableFile.write(headerBytes);

                // Now appending the first IV
                SecureRandom sr = new SecureRandom();
                sr.nextBytes(iv);
                seekableFile.write(iv);
                cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));
            } else if (fileLen <= 16 + HEADER_LENGTH) {
                throw new IllegalArgumentException("Invalid file length (need 2 blocks for iv and data)");
            } else {
                // file length is at least 2 blocks
                needToRestoreCipherState = true;
            }
        } catch (InvalidKeyException e) {
            throw new IOException(e.getMessage());
        } catch (NoSuchAlgorithmException e) {
            throw new IOException(e.getMessage());
        } catch (NoSuchPaddingException e) {
            throw new IOException(e.getMessage());
        } catch (InvalidAlgorithmParameterException e) {
            throw new IOException(e.getMessage());
        }
    }


    /**
     * Writes one _byte_ to this output stream.
     */
    public void write(int b) throws IOException {
        if (needToRestoreCipherState)
            restoreStateOfCipher();
        ibuffer[0] = (byte) b;
        obuffer = cipher.update(ibuffer, 0, 1);
        if (obuffer != null) {
            seekableFile.write(obuffer);
            obuffer = null;
        }
    }

    /** Writes a byte array to this output stream. */
    public void write(byte data[]) throws IOException {
        write(data, 0, data.length);
    }

    /**
     * Writes <code>len</code> bytes from the specified byte array
     * starting at offset <code>off</code> to this output stream.
     *
     * @param      data     the data.
     * @param      off   the start offset in the data.
     * @param      len   the number of bytes to write.
     */
    public void write(byte data[], int off, int len) throws IOException
    {
        if (needToRestoreCipherState)
            restoreStateOfCipher();
        obuffer = cipher.update(data, off, len);
        if (obuffer != null) {
            seekableFile.write(obuffer);
            obuffer = null;
        }
    }


    /** The tricky stuff happens here. We finalise the cipher, write it out, but then rewind the
     * stream so that we can add more bytes without padding. */
    public void flush() throws IOException
    {
        try {
            if (needToRestoreCipherState)
                return; // It must have already been flushed.
            byte[] obuffer = cipher.doFinal();
            if (obuffer != null) {
                seekableFile.write(obuffer);
                if (flushGoesStraightToDisk)
                    seekableFile.getFD().sync();
                needToRestoreCipherState = true;
            }
        } catch (IllegalBlockSizeException e) {
            throw new IOException("Illegal block");
        } catch (BadPaddingException e) {
            throw new IOException("Bad padding");
        }
    }

    private void restoreStateOfCipher() throws IOException
    {
        try {
            // I wish there was a more direct way to snapshot a Cipher object, but it seems there's not.
            needToRestoreCipherState = false;
            byte[] iv = cipher.getIV(); // To help avoid garbage, re-use the old one if present.
            if (iv == null)
                iv = new byte[16];
            seekableFile.seek(seekableFile.length() - 32);
            seekableFile.read(iv);
            byte[] lastBlockEnc = new byte[16];
            seekableFile.read(lastBlockEnc);
            cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
            byte[] lastBlock = cipher.doFinal(lastBlockEnc);
            seekableFile.seek(seekableFile.length() - 16);
            cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));
            byte[] out = cipher.update(lastBlock);
            assert out == null || out.length == 0;
        } catch (Exception e) {
            throw new IOException("Unable to restore cipher state");
        }
    }

    public void close() throws IOException
    {
        flush();
        seekableFile.close();
    }
}

然後自定義一個 Appender ,重寫 setFile 方法

public class AESRollingFileAppender extends RollingFileAppender {
    public final static byte[] keyBytes = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 };
    public final static SecretKeySpec key = new SecretKeySpec(keyBytes, "AES");
    public final static String cipherKey[] = new String[] { "AES/CBC/PKCS5Padding", "AES/CFB8/NoPadding" };
    public final static String Encoding = "UTF-8";

    private Writer fw;

    @Override
    public synchronized void setFile(String fileName, boolean append,
            boolean bufferedIO, int bufferSize) throws IOException {
        LogLog.debug("setFile called: " + fileName + ", " + append);

        // It does not make sense to have immediate flush and bufferedIO.
        if (bufferedIO) {
            setImmediateFlush(false);
        }

        reset();
        FlushableCipherOutputStream cstream = null;
        try {
            cstream = new FlushableCipherOutputStream(fileName, key, true, false);
        } catch (Exception ex) {
            LogLog.error("setFile error", ex);
            ex.printStackTrace();
        }

        setEncoding(Encoding);
        fw = createWriter(cstream);
        if (bufferedIO) {
            fw = new BufferedWriter(fw, bufferSize);
        }
        this.setQWForFiles(fw);
        this.fileName = fileName;
        this.fileAppend = append;
        this.bufferedIO = bufferedIO;
        this.bufferSize = bufferSize;
        writeHeader();
        LogLog.debug("setFile ended");

        if (append) {
            File f = new File(fileName);
            ((CountingQuietWriter) qw).setCount(f.length());
        }
    }
}

第二種是對單條日誌加密,直接自定義 Appender ,重寫 subAppend 方法

import org.apache.log4j.Layout;
import org.apache.log4j.RollingFileAppender;
import org.apache.log4j.spi.LoggingEvent;

/**
 * @Title: AARollingFileAppender.java 
 * @Description: TODO(用一句話描述該檔案做什麼)
 * @author: weekdragon
 * @date: 2018年7月18日 下午3:41:21 
 * @version V1.0
 */
public class AESRollingFileAppender extends RollingFileAppender {

    public static final String AES_KEY="ThisIsASecretKey";
    //這裡一行一行的加密
    @Override
    protected void subAppend(LoggingEvent event) {
        this.qw.write(encrypt(this.layout.format(event)));
        if (layout.ignoresThrowable()) {
            String[] s = event.getThrowableStrRep();
            if (s != null) {
                int len = s.length;
                for (int i = 0; i < len; i++) {
                    this.qw.write(encrypt(s[i]));
                    this.qw.write(encrypt(Layout.LINE_SEP));
                }
            }
        }

        if (shouldFlush(event)) {
            this.qw.flush();
        }
    }

    private String encrypt(String content) {
        try {
           return AESUtil.encrypt(content, AES_KEY)+"\n";
        } catch (Exception e) {
            e.printStackTrace();
            return content;
        }
    }
}

在 log4j 配置檔案裡替換掉預設的 Appender 即可

log4j.appender.AppenderName=package.AESRollingFileAppender

這種方式,日誌是按行加密,每一條日誌加密一行,相比前一種,加密時間有所增加,大概每 10w 條日誌增加幾秒鐘的樣子,對於客戶端程式來說,日誌記錄得不會非常密集,併發量不會很高,完全可以滿足要求,而且可以應對客戶端在強退、斷電等異常情況,即使日誌記錄不完整,也只會損壞單條,而不會影響全部。