创建加密日志文件

10

我正在创建一个客户端应用程序,需要创建用户活动日志,但由于各种原因,此日志不能为人类可读。

目前,在我的开发过程中,我正在创建一个纯文本日志,看起来像这样:

12/03/2009 08:34:21 -> User 'Bob' logged in
12/03/2009 08:34:28 -> Navigated to config page
12/03/2009 08:34:32 -> Option x changed to y

当我部署我的应用程序时,日志文件不应该是明文的,所以所有文本都必须加密。由于需要在每次添加条目时动态更新日志文件,因此这似乎并不容易实现。

我考虑的方法是创建一个二进制文件,在独立的情况下对每个日志条目进行加密,然后在每个条目之间使用适当的分隔符将其附加到二进制文件中。

有没有人知道解决这个问题的常用方法?我相信一定有更好的解决方案!


你有特定的环境想法吗?一般来说,我会使用数据库并在数据库层面应用安全措施,但如果你不使用数据库或需要轻松导出日志,则无法使用该方法。 - Richard Slater
10个回答

13
不要像其他帖子建议的那样单独加密每个日志条目并将其写入文件,因为攻击者很容易能够在日志文件中识别出模式。请参阅块密码模式维基百科条目以了解更多相关问题。

Original Encrypted using ECB mode Encrypted using other modes

相反,确保日志条目的加密取决于先前的日志条目。尽管这有一些缺点(您无法解密单个日志条目,因为您始终需要解密整个文件),但它使加密更加强大。对于我们自己的记录库SmartInspect,我们使用AES加密和CBC模式来避免模式问题。如果商业解决方案合适,可以随时尝试SmartInspect


虽然我喜欢使用日志条目A中的信息对日志条目B进行加密的想法,但我不确定在这里插入你自己的产品是否真的合适...(如果被认为是合适的话,我将谦卑地成为第一个删除此评论的人;-)) - wzzrd
4
我不确定wzzrd。如果我正在寻找解决问题的方法,而已经有一个可以满足我的需求的工具,我会很高兴如果有人指出它(即使是商业工具)。此外,我希望即使您不对我们的工具感兴趣,我的答案也能对您有所帮助。 - Dennis G.
从开头开始读取一个庞大的日志文件以查找特定时间戳并不高效。如果这是一种使用情况,有更好的加密模式可供使用。 - erickson
5
我不赞同这个回答,但我同意Dennis的观点:如果某个商业产品(即使是你自己的产品)能够回答问题,那么进行“插销”是可以接受的。 - Dscoduc
3
你的样例图片是ECB模式,没有理智的人会使用它。如果你对单个日志条目使用CBC模式(或GCM),并为每个条目使用不同的初始向量(通过行号递增),那么就不会有"shining through"的问题。但是,仍可能存在每个日志条目末尾的填充问题,因此如果你关心数据安全性,流密码可能更好。而且不要忘记完整性保护(例如滚动哈希)。 - eckes

6
这不是我的专业领域,我必须承认这一点,但您可以将每个条目单独加密,然后将其附加到日志文件中。如果您避免加密时间戳,则可以轻松查找所需的条目,并在需要时解密它们。
我的主要观点是,将单独加密的条目附加到文件中不一定需要将二进制条目附加到二进制文件中。例如使用gpg进行加密将产生可以附加到ASCII文件中的ASCII乱码。这样能解决您的问题吗?

1
你能给我们提供一个在互联网上的示例链接吗? - shareef

6

值得一提的是,我需要一个加密记录器时,出于性能原因,我使用了对称密钥来加密实际的日志条目。

对称的“日志文件密钥”然后被公钥加密并存储在日志文件的开头,而单独的日志读取器使用私钥解密“日志文件密钥”并读取条目。

整个过程使用log4j和XML日志文件格式实现(以使读者更容易解析),每次日志文件滚动时都会生成一个新的“日志文件密钥”。


除了一件事:如果你想加密/解密某些内容,对称密钥必须以明文形式存储在某个地方。这个事实削弱了非对称加密的所有安全优势。 - bytefu
@bytefu:不是的 - 对称密钥是即时生成的,并且加密存储在日志文件中的公钥下面。 - tonys
@tonys:即时生成密码只会稍微增加其检索难度,但并不是非常困难。如果密码存储在内存中,您可以使用调试器或注入动态库到您的应用程序中 - 安全性就被破坏了。另一方面,如果您仅使用非对称加密,除了分解RSA密钥之类的技术外,没有任何单一的恶意技术可以破坏安全性。 - bytefu

4
假设你正在使用某种日志框架,例如log4j等,那么你应该能够创建一个自定义的Appender(或类似的)实现,对每个条目进行加密,就像@wzzrd建议的那样。

3

这是一个非常古老的问题,我相信技术世界已经取得了很大进展,但值得一提的是,Bruce Schneier和John Kelsey撰写了一篇关于如何实现这一点的论文:https://www.schneier.com/paper-auditlogs.html

这个问题不仅涉及安全性,还包括防止日志/审计文件所在系统被攻击后现有日志文件数据的损坏或更改。


3

我不清楚你所关心的是安全性还是实现问题。

一个简单的实现方法是使用流加密器。流加密器维护自己的状态并能够实现即时加密。

StreamEncryptor<AES_128> encryptor;
encryptor.connectSink(new std::ofstream("app.log"));
encryptor.write(line);
encryptor.write(line2);
...

1

我想知道你写的是哪种应用程序。病毒还是特洛伊木马?不管怎样...

单独加密每个条目,将其转换为某些字符串(例如Base64),然后将该字符串记录为“消息”。

这样可以保持文件的部分可读性,仅加密重要部分。

请注意,这个问题还有另一面:如果你创建了一个完全加密的文件并索要用户提供,她就无法知道你从文件中获得了哪些信息。因此,你应该尽可能地少加密(密码、IP地址、客户数据),以便法律部门验证哪些数据正在离开。

更好的方法是使用日志文件混淆器。它只需用“XXX”替换某些模式即可。你仍然可以看到发生了什么,当你需要特定的数据时,你可以请求它。

[编辑] 这个故事有更多的含义,不只是表面看起来那样。这实际上意味着用户无法看到文件中的内容。 "用户" 不一定包括 "破解者"。破解者会集中精力攻击加密文件(因为它们可能更重要)。这就是古话所说的原因:一旦有人获得了机器的访问权限,就没有办法阻止他在上面做任何事情。或者换句话说:仅仅因为你不知道如何做,并不意味着别人也不知道。如果你认为自己没有什么可隐藏的,那你还没有考虑过自己。

此外,还有责任问题。比如,在你获取日志副本后,某些数据泄露到互联网上。由于用户不知道日志文件中有什么,你怎么能在法庭上证明你不是泄漏者呢?老板们可以要求获取日志文件以监控他们的下属,要求对其进行编码,以便平民百姓看不出来并抱怨(或者起诉,这些卑鄙的家伙!)。

或者从完全不同的角度来看待它:如果没有日志文件,就没有人可以滥用它。在紧急情况下启用调试如何?我已经配置了log4j以保留最后200条日志消息。如果记录了错误,我会将这200条消息转储到日志中。理由是:我真的不关心白天发生了什么。我只关心错误。使用JMX,可以简单地将调试级别设置为ERROR,并在需要更多详细信息时在运行时远程降低它。

很遗憾,我既没有时间也没有能力去创建病毒/木马。加密的日志文件主要是为了防止他人获取机器并检查用户所做的事情(即保护用户的隐私)。 - JamieH
JamieH:我想知道一个病毒/特洛伊木马编写者会怎么回答;但问题仍然存在:谁保护用户免受你的侵害?或者换句话说,当用户因为隐私侵犯而起诉你时,你将如何保护自己? - Aaron Digulla
与问题提出者的问题略有关联,但我真的很喜欢一个事件缓冲区的想法,只有当发生错误时才会刷新到文件中。 - erickson

1

对每个日志条目进行单独加密会大大降低您的密文安全性,特别是因为您正在使用非常可预测的明文。

以下是您可以采取的措施:

  1. 使用对称加密(最好是AES)
  2. 选择一个随机的主密钥
  3. 选择一个安全窗口(5分钟、10分钟等)

然后,在每个窗口的开始处(每5分钟、每10分钟等)选择一个随机的临时密钥。

使用临时密钥分别加密每个日志项并附加到临时日志文件中。

当窗口关闭时(预定时间到了),使用临时密钥解密每个元素,使用主密钥解密主日志文件,合并文件并使用主密钥加密。

然后,选择一个新的临时密钥并继续。

此外,每次轮换主日志文件时(每天、每周等),更改主密钥。

这应该提供足够的安全性。


你能提供更多关于为什么推荐这个解决方案的信息吗?具体来说,为什么使用对称密钥,为什么临时密钥可以增加安全性,以及为什么每次轮换都要更改主密钥? - sazary
如果性能不是问题,那么为什么要使用临时密钥呢?而如果性能是问题,那么使用主密钥来解密整个文件并重新加密它仍然是一个问题,尽管在每个窗口的末尾。 - sazary

0

我和你有完全相同的需求。一个叫做'maybeWeCouldStealAVa'的人在如何向AES加密文件追加内容中写了一个很好的实现,但是它没有被刷新 - 每次刷新消息时,您都必须关闭并重新打开文件,以确保不会丢失任何东西。

所以我写了自己的类来完成这个任务:

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();
    }
}

这是一个使用它的例子:

import org.junit.Test;
import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.*;
import java.io.BufferedWriter;



public class TestFlushableCipher {
    private static byte[] keyBytes = new byte[] {
            // Change these numbers, lest other StackOverflow readers can decrypt your files.
            -53, 93, 59, 108, -34, 17, -72, -33, 126, 93, -62, -50, 106, -44, 17, 55
    };
    private static SecretKeySpec key = new SecretKeySpec(keyBytes,"AES");
    private static int HEADER_LENGTH = 16;


    private static BufferedWriter flushableEncryptedBufferedWriter(File file, boolean append) throws Exception
    {
        FlushableCipherOutputStream fcos = new FlushableCipherOutputStream(file, key, append, false);
        return new BufferedWriter(new OutputStreamWriter(fcos, "UTF-8"));
    }

    private static InputStream readerEncryptedByteStream(File file) throws Exception
    {
        FileInputStream fin = new FileInputStream(file);
        byte[] iv = new byte[16];
        byte[] headerBytes = new byte[HEADER_LENGTH];
        if (fin.read(headerBytes) < HEADER_LENGTH)
            throw new IllegalArgumentException("Invalid file length (failed to read file header)");
        if (headerBytes[0] != 100)
            throw new IllegalArgumentException("The file header does not conform to our encrypted format.");
        if (fin.read(iv) < 16) {
            throw new IllegalArgumentException("Invalid file length (needs a full block for iv)");
        }
        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv));
        return new CipherInputStream(fin,cipher);
    }

    private static BufferedReader readerEncrypted(File file) throws Exception
    {
        InputStream cis = readerEncryptedByteStream(file);
        return new BufferedReader(new InputStreamReader(cis));
    }

    @Test
    public void test() throws Exception {
        File zfilename = new File("c:\\WebEdvalData\\log.x");

        BufferedWriter cos = flushableEncryptedBufferedWriter(zfilename, false);
        cos.append("Sunny ");
        cos.append("and green.  \n");
        cos.close();

        int spaces=0;
        for (int i = 0; i<10; i++) {
            cos = flushableEncryptedBufferedWriter(zfilename, true);
            for (int j=0; j < 2; j++) {
                cos.append("Karelia and Tapiola" + i);
                for (int k=0; k < spaces; k++)
                    cos.append(" ");
                spaces++;
                cos.append("and other nice things.  \n");
                cos.flush();
                tail(zfilename);
            }
            cos.close();
        }

        BufferedReader cis = readerEncrypted(zfilename);
        String msg;
        while ((msg=cis.readLine()) != null) {
            System.out.println(msg);
        }
        cis.close();
    }

    private void tail(File filename) throws Exception
    {
        BufferedReader infile = readerEncrypted(filename);
        String last = null, secondLast = null;
        do {
            String msg = infile.readLine();
            if (msg == null)
                break;
            if (! msg.startsWith("}")) {
                secondLast = last;
                last = msg;
            }
        } while (true);
        if (secondLast != null)
            System.out.println(secondLast);
        System.out.println(last);
        System.out.println();
    }
}

0

对于 .Net,请参阅 Microsoft 应用程序块以获取日志和加密功能: http://msdn.microsoft.com/en-us/library/dd203099.aspx

我会将加密的日志条目附加到一个平面文本文件中,使用适当的分隔符来区分每个条目以使解密工作。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接