Cryptography · Key Management

Protecting Key Material in Memory and at Rest

What page locking, guard pages, and zeroization actually prevent, why memory safety languages change the calculus, and where stored keys leak.

A key that is used must at some point exist as plaintext in a register or a memory page. Every mechanism for protecting it is therefore a mechanism for shortening that interval, narrowing the set of things that can observe it, or making the residue unusable afterwards. None of them makes the key unreadable to a sufficiently privileged observer on the same machine. The engineering question is which observers the design excludes, and what it does about the copies it cannot prevent.

The copies a key leaves behind

Before a key is used, it travels through several representations: the bytes returned by the generator, the buffer holding them, the parsed structure the library builds, internal state inside the cipher implementation, and possibly a serialized form if the key is stored. Each is a copy, and each must be accounted for.

The operating system adds more. A page holding a key can be swapped to disk, captured in a hibernation image, or written into a core dump when the process crashes. Copy-on-write from fork(2) duplicates the page into the child, where it survives even if the parent zeroes its own copy. A memory allocator may return a freed block to a pool without clearing it, and the next allocation in the same process can read the residue.

Storage adds another layer. Keys at rest are usually encrypted under a master key, which is itself stored, and the chain terminates in something that must be available without a stored secret: a hardware module, a passphrase entered at startup, or an environment variable that is itself a file.

The design has to name the boundary it defends. A process boundary excludes other users but not an administrator or a hypervisor. A hardware boundary excludes the host CPU but not the device firmware. A physical boundary excludes remote attackers but not someone with the disk. Each of these corresponds to a different set of mechanisms, and mixing them produces a defence that is strong in one place and absent in another.

Page-level controls and their gaps

The standard toolkit on a POSIX system is small. mlock(2) pins pages in physical memory so they are not eligible for swapping, and madvise(2) with MADV_DONTDUMP excludes a mapping from core dumps. Both apply to whole pages, and both are subject to limits: the amount of memory a process may lock is bounded by RLIMIT_MEMLOCK, which on many systems defaults to a value far below a megabyte, so a process locking a 64 MiB key buffer will fail unless the limit is raised.

Locking is also not a guarantee of erasure. mlock prevents the page from being written to swap during the life of the mapping, but a hibernation image captures all of physical memory regardless, and a suspend-to-disk cycle writes it to persistent storage. On systems that support it, excluding pages from hibernation is a separate control from excluding them from swap.

Zeroization is the third mechanism, and it is weaker than it appears in source code. A compiler is entitled to eliminate a store to memory that is never read again, and a call to a general-purpose memset on a buffer that goes out of scope immediately afterwards is exactly that pattern. The result is source code that appears to clear a key and object code that does not. The usual fix is a function the compiler cannot treat as removable, either one whose implementation is opaque to the optimizer or one with an explicit barrier. Languages and libraries provide these under names like explicit_bzero on some C libraries and SecureZeroMemory on Windows; where none exists, a volatile write through a pointer the compiler cannot prove is dead achieves the same effect.

Even a correct zeroization only clears the copies the program knows about. The cipher implementation’s internal state, the register contents at the moment of use, and any temporary buffer allocated inside a library remain outside the caller’s reach.

Making the compiler and the allocator cooperate

A workable pattern keeps the key in one allocation whose lifetime is explicit, marks the pages, uses the key only through that allocation, and clears and releases it at a defined point:

#include <sys/mman.h>
#include <string.h>
#include <unistd.h>

/* Page-aligned key storage that is locked and excluded from core dumps. */
static unsigned char *key_alloc(size_t len) {
    long ps = sysconf(_SC_PAGESIZE);
    size_t rounded = (len + (size_t)ps - 1) & ~((size_t)ps - 1);
    unsigned char *p = mmap(NULL, rounded, PROT_READ | PROT_WRITE,
                            MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) return NULL;
    if (mlock(p, rounded) != 0) {          /* may fail on RLIMIT_MEMLOCK */
        munmap(p, rounded);
        return NULL;
    }
    madvise(p, rounded, MADV_DONTDUMP);
    return p;                               /* caller must wipe and munmap */
}

The allocation is page-aligned by construction, so mlock and madvise act on exactly the region. Using mmap rather than malloc avoids the allocator’s reuse of the block for unrelated data and makes the release path explicit. Placing guard pages on either side — mapping them PROT_NONE — turns an off-by-one read of the key buffer into a fault rather than a silent disclosure.

In managed languages the calculus differs. A garbage collector may copy an object during compaction, leaving the original bytes in a page that is later reused; the language cannot promise that a buffer stays at one address. Pinning facilities exist but are limited, and explicit clearing depends on the runtime not optimizing away the write. The practical result is that managed runtimes are a poor place to hold long-lived key material, and the common architecture keeps keys in a separate process or a hardware module and passes only ciphertext through the managed heap.

Keys at rest

The stored form of a key is either wrapped by another key or derived from a secret that is not stored. Anything else is obfuscation. A key written to a configuration file in hexadecimal is a key that any process able to read the file can use, and file permissions are the only control.

Where wrapping is used, the wrapped blob should be bound to its context so that moving it elsewhere fails. Feeding the key identifier, the tenant, and the purpose into the derivation of the wrapping key, or into the associated data of an AEAD, means a blob copied from one record to another does not unwrap. This is the same construction described in envelope encryption, applied to the storage of the keys themselves.

The terminal secret is the hard part. Options are a passphrase supplied at startup, which requires an operator present at every restart; a key held in a hardware module, which survives restarts and bounds extraction; or a key obtained from an external service over an authenticated channel, which makes availability depend on that service. Each choice sets what an attacker with root on the host can do. With a passphrase in memory, root reads it. With a hardware module, root can use the key but not export it, which converts a disclosure into an abuse problem that audit logs can address. With a remote service, root can request unwraps, subject to whatever authorization the service enforces.

That progression is why hardware security modules exist, and why the interesting question about a key store is not how the bytes are encrypted but what operation the boundary permits.

Failure modes worth designing against

The most common failure is a key that appears protected and is not. A wrapper that holds a key in a String because the API took a string has created an immutable, unzeroizable copy in a heap the collector may relocate. A key logged at debug level because a request body was logged has left the process entirely, and the log is replicated. A crash that produces a core dump containing the key has written it to disk in a location that may be readable by more processes than the original.

Lifetime is the second. A key cached to avoid repeated unwraps stays usable after the authorization that justified it has been revoked. Expiring cached keys, and invalidating them on a signal from the key service, bounds that window; the rotation discussion covers the mechanism for telling a process that its cached key is no longer current.

The third is destruction. Overwriting memory and deleting a file do not remove data from a copy-on-write filesystem, a snapshot, a backup, or a solid-state device’s spare blocks. Cryptographic erasure — destroying the key rather than the ciphertext — is the only reliable route on media that cannot be overwritten in place, which is the subject of key destruction and cryptographic erasure.

Finally, the threat model should be written down. Page locking and dump exclusion defend against an attacker who can read files on disk but not memory. They do nothing against an attacker who can read process memory. A design that lists its mechanisms without stating which adversary each addresses will be evaluated against the wrong adversary, and the evaluation will be wrong in the direction of overconfidence.

References

  1. madvise(2) — Linux manual page — Linux man-pages project
  2. mlock(2) — Linux manual page — Linux man-pages project
  3. NIST SP 800-57 Part 1 Rev. 5: Recommendation for Key Management — NIST
  4. OWASP Cryptographic Storage Cheat Sheet — OWASP Foundation