From cc21067b8377da9074b8dce9d1e71775652ea9c8 Mon Sep 17 00:00:00 2001 From: Zsolt Parragi Date: Wed, 15 Jul 2026 20:53:54 +0000 Subject: [PATCH vnocfbot] wal_pagelevel: page-level AES-256-CTR WAL encryption prototype Throwaway perf prototype. Encrypt WAL pages at the disk-write boundary only; in-memory WAL stays plaintext. Per-page IV is the page-start LSN. Encrypt hooks: XLogWrite, BootStrapXLOG, XLogWalRcvWrite, basebackup ProcessWALDataMsg, pg_resetwal WriteEmptyXLOG. Decrypt hooks: XLogPageRead, WALRead, pg_rewind SimpleXLogPageRead, pg_waldump search_directory/archive reader. Core in src/common/wal_pagelevel.{c,h} + hardcoded key. TAP test wal_pagelevel_check asserts on-disk WAL is ciphertext and round-trips through recovery. NOT for production: hardcoded key, no auth, no plugin API, no key management, no SGML docs. --- src/backend/access/transam/xlog.c | 27 +- src/backend/access/transam/xloginsert.c | 9 + src/backend/access/transam/xlogreader.c | 34 ++ src/backend/access/transam/xlogrecovery.c | 13 + src/backend/postmaster/postmaster.c | 2 + src/backend/replication/walreceiver.c | 29 +- src/bin/pg_basebackup/receivelog.c | 46 ++- src/bin/pg_resetwal/pg_resetwal.c | 11 + src/bin/pg_rewind/parsexlog.c | 8 + src/bin/pg_waldump/archive_waldump.c | 104 +++++- src/bin/pg_waldump/pg_waldump.c | 34 +- src/common/Makefile | 2 + src/common/meson.build | 2 + src/common/wal_pagelevel.c | 305 ++++++++++++++++++ src/common/wal_pagelevel_key.c | 16 + src/include/common/wal_pagelevel.h | 70 ++++ src/test/modules/Makefile | 1 + src/test/modules/meson.build | 1 + src/test/modules/wal_pagelevel_check/Makefile | 18 ++ .../modules/wal_pagelevel_check/meson.build | 15 + .../t/001_wal_is_encrypted.pl | 43 +++ 21 files changed, 768 insertions(+), 22 deletions(-) create mode 100644 src/common/wal_pagelevel.c create mode 100644 src/common/wal_pagelevel_key.c create mode 100644 src/include/common/wal_pagelevel.h create mode 100644 src/test/modules/wal_pagelevel_check/Makefile create mode 100644 src/test/modules/wal_pagelevel_check/meson.build create mode 100644 src/test/modules/wal_pagelevel_check/t/001_wal_is_encrypted.pl diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c index f8b939853e9..bfe107da186 100644 --- a/src/backend/access/transam/xlog.c +++ b/src/backend/access/transam/xlog.c @@ -69,6 +69,7 @@ #include "catalog/pg_database.h" #include "common/controldata_utils.h" #include "common/file_utils.h" +#include "common/wal_pagelevel.h" #include "executor/instrument.h" #include "miscadmin.h" #include "pg_trace.h" @@ -2432,16 +2433,28 @@ XLogWrite(XLogwrtRqst WriteRqst, TimeLineID tli, bool flexible) curridx == XLogCtl->XLogCacheBlck || finishing_seg) { + char *from_plain; char *from; Size nbytes; Size nleft; ssize_t written; instr_time start; + Size seg_offset_start = startoffset; /* OK to write the page(s) */ - from = XLogCtl->pages + startidx * (Size) XLOG_BLCKSZ; + from_plain = XLogCtl->pages + startidx * (Size) XLOG_BLCKSZ; nbytes = npages * (Size) XLOG_BLCKSZ; nleft = nbytes; + + /* + * Encrypt all pages we are about to write into a per-backend + * scratch buffer. XLogCtl->pages stays plaintext. startoffset + * is XLOG_BLCKSZ-aligned here (npages is in pages), so we never + * need an intra-block-offset discard path. + */ + Assert((startoffset % XLOG_BLCKSZ) == 0); + from = WalPagelevelEncryptForWrite(from_plain, nbytes, + openLogSegNo, seg_offset_start); do { errno = 0; @@ -5566,6 +5579,18 @@ BootStrapXLOG(uint32 data_checksum_version) /* Write the first page with the initial record */ errno = 0; pgstat_report_wait_start(WAIT_EVENT_WAL_BOOTSTRAP_WRITE); + + /* + * The bootstrap page bypasses XLogWrite and so does not go through the + * page-level encrypt path there. Encrypt it in place before writing, + * using IV = page-start LSN of the first usable WAL page (segno=1, off=0). + */ + { + XLogRecPtr page_start_lsn = (XLogRecPtr) 1 * wal_segment_size; + WalPagelevelInit(); + WalPagelevelEncryptPage((char *) &buffer, page_start_lsn); + } + if (write(openLogFile, &buffer, XLOG_BLCKSZ) != XLOG_BLCKSZ) { /* if write didn't set errno, assume problem is no disk space */ diff --git a/src/backend/access/transam/xloginsert.c b/src/backend/access/transam/xloginsert.c index f2e10b82b7d..f849f933555 100644 --- a/src/backend/access/transam/xloginsert.c +++ b/src/backend/access/transam/xloginsert.c @@ -33,6 +33,7 @@ #include "access/xloginsert.h" #include "catalog/pg_control.h" #include "common/pg_lzcompress.h" +#include "common/wal_pagelevel.h" #include "executor/instrument.h" #include "miscadmin.h" #include "pg_trace.h" @@ -1438,4 +1439,12 @@ InitXLogInsert(void) if (hdr_scratch == NULL) hdr_scratch = MemoryContextAllocZero(xloginsert_cxt, HEADER_SCRATCH_SIZE); + + /* + * Pre-warm wal_pagelevel.c so its per-backend MemoryContext exists + * before XLogFlush enters a critical section. Without this, the lazy + * WalPagelevelInit() called from XLogWrite's encrypt path trips the + * MemoryContextCreate Assert(CritSectionCount == 0). + */ + WalPagelevelInit(); } diff --git a/src/backend/access/transam/xlogreader.c b/src/backend/access/transam/xlogreader.c index 946907a2507..b292918b91e 100644 --- a/src/backend/access/transam/xlogreader.c +++ b/src/backend/access/transam/xlogreader.c @@ -31,6 +31,7 @@ #include "access/xlogrecord.h" #include "catalog/pg_control.h" #include "common/pg_lzcompress.h" +#include "common/wal_pagelevel.h" #include "replication/origin.h" #ifndef FRONTEND @@ -1614,6 +1615,39 @@ WALRead(XLogReaderState *state, io_start, 1, readbytes); #endif + { + /* + * Decrypt page-by-page. startoff is the in-segment byte offset of + * `p`; readbytes is how many bytes pg_pread returned. Range may + * begin and/or end mid-page. + */ + Size pos = 0; + + while (pos < (Size) readbytes) + { + Size off_in_seg = (Size) startoff + pos; + Size page_idx = off_in_seg / XLOG_BLCKSZ; + Size off_in_page = off_in_seg % XLOG_BLCKSZ; + Size page_remaining = XLOG_BLCKSZ - off_in_page; + Size chunk = Min(page_remaining, (Size) readbytes - pos); + XLogRecPtr page_start_lsn; + + page_start_lsn = (XLogRecPtr) state->seg.ws_segno * + state->segcxt.ws_segsize + + (XLogRecPtr) page_idx * XLOG_BLCKSZ; + + /* + * AES-CTR can decrypt any byte-aligned slice within a page. + * Page-aligned reads (XLogPageRead path) hit off_in_page == 0; + * walsender resume can land on any 8-byte-aligned offset, which + * WalPagelevelDecryptRange handles. + */ + WalPagelevelDecryptRange(p + pos, chunk, + page_start_lsn, off_in_page); + pos += chunk; + } + } + /* Update state for read */ recptr += readbytes; nbytes -= readbytes; diff --git a/src/backend/access/transam/xlogrecovery.c b/src/backend/access/transam/xlogrecovery.c index a9ebac2d0ef..ea82f1eddb0 100644 --- a/src/backend/access/transam/xlogrecovery.c +++ b/src/backend/access/transam/xlogrecovery.c @@ -44,6 +44,7 @@ #include "catalog/pg_control.h" #include "commands/tablespace.h" #include "common/file_utils.h" +#include "common/wal_pagelevel.h" #include "miscadmin.h" #include "nodes/miscnodes.h" #include "pgstat.h" @@ -3424,6 +3425,18 @@ retry: xlogreader->seg.ws_tli = curFileTLI; + /* + * Decrypt the page in place before any header inspection. IV is the + * page-start LSN derived from (readSegNo, readOff); decrypt before the + * existing xlp_magic / xlp_pageaddr validation so it runs against + * plaintext. + */ + { + XLogRecPtr page_start_lsn = (XLogRecPtr) readSegNo * wal_segment_size + + (XLogRecPtr) readOff; + WalPagelevelDecryptPage(readBuf, page_start_lsn); + } + /* * Check the page header immediately, so that we can retry immediately if * it's not valid. This may seem unnecessary, because ReadPageInternal() diff --git a/src/backend/postmaster/postmaster.c b/src/backend/postmaster/postmaster.c index 90c7c4528e8..8a43d538073 100644 --- a/src/backend/postmaster/postmaster.c +++ b/src/backend/postmaster/postmaster.c @@ -94,6 +94,7 @@ #include "access/xlogrecovery.h" #include "common/file_perm.h" #include "common/pg_prng.h" +#include "common/wal_pagelevel.h" #include "lib/ilist.h" #include "libpq/libpq.h" #include "libpq/pqsignal.h" @@ -1116,6 +1117,7 @@ PostmasterMain(int argc, char *argv[]) */ ereport(LOG, (errmsg("starting %s", PG_VERSION_STR))); + WalPagelevelLogAtStartup(); /* * Establish input sockets. diff --git a/src/backend/replication/walreceiver.c b/src/backend/replication/walreceiver.c index 429a1b2d96d..7d4f512552d 100644 --- a/src/backend/replication/walreceiver.c +++ b/src/backend/replication/walreceiver.c @@ -59,6 +59,7 @@ #include "access/xlogrecovery.h" #include "access/xlogwait.h" #include "catalog/pg_authid.h" +#include "common/wal_pagelevel.h" #include "funcapi.h" #include "libpq/pqformat.h" #include "libpq/pqsignal.h" @@ -951,7 +952,33 @@ XLogWalRcvWrite(char *buf, Size nbytes, XLogRecPtr recptr, TimeLineID tli) start = pgstat_prepare_io_time(track_wal_io_timing); pgstat_report_wait_start(WAIT_EVENT_WAL_WRITE); - byteswritten = pg_pwrite(recvFile, buf, segbytes, (pgoff_t) startoff); + { + char *scratch; + Size pos = 0; + + WalPagelevelInit(); + scratch = WalPagelevelGetScratch(segbytes); + memcpy(scratch, buf, segbytes); + + while (pos < (Size) segbytes) + { + Size off_in_seg = (Size) startoff + pos; + Size page_idx = off_in_seg / XLOG_BLCKSZ; + Size off_in_page = off_in_seg % XLOG_BLCKSZ; + Size page_remaining = XLOG_BLCKSZ - off_in_page; + Size chunk = Min(page_remaining, (Size) segbytes - pos); + XLogRecPtr page_start_lsn; + + page_start_lsn = (XLogRecPtr) recvSegNo * wal_segment_size + + (XLogRecPtr) page_idx * XLOG_BLCKSZ; + WalPagelevelEncryptRange(scratch + pos, chunk, + page_start_lsn, off_in_page); + pos += chunk; + } + + byteswritten = pg_pwrite(recvFile, scratch, segbytes, + (pgoff_t) startoff); + } pgstat_report_wait_end(); if (byteswritten <= 0) diff --git a/src/bin/pg_basebackup/receivelog.c b/src/bin/pg_basebackup/receivelog.c index faa60711b1b..f094b65ab0f 100644 --- a/src/bin/pg_basebackup/receivelog.c +++ b/src/bin/pg_basebackup/receivelog.c @@ -20,6 +20,7 @@ #include "access/xlog_internal.h" #include "common/logging.h" +#include "common/wal_pagelevel.h" #include "libpq-fe.h" #include "libpq/protocol.h" #include "receivelog.h" @@ -1135,14 +1136,45 @@ ProcessWALDataMsg(PGconn *conn, StreamCtl *stream, char *copybuf, int len, } } - if (stream->walmethod->ops->write(walfile, - copybuf + hdr_len + bytes_written, - bytes_to_write) != bytes_to_write) { - pg_log_error("could not write %d bytes to WAL file \"%s\": %s", - bytes_to_write, walfile->pathname, - GetLastWalMethodError(stream->walmethod)); - return false; + /* + * Encrypt the streamed plaintext page-by-page before writing. + * The wire delivers plaintext (walsender decrypted via WALRead); + * the standby's on-disk WAL must be encrypted so its own + * XLogPageRead/WALRead paths can decrypt it. Encrypt in place + * into copybuf (caller frees it after this call). + */ + char *segdata = copybuf + hdr_len + bytes_written; + Size pos = 0; + XLogSegNo recvSegNo = *blockpos / WalSegSz; + Size startoff_in_seg = xlogoff; + + WalPagelevelInit(); + + while (pos < (Size) bytes_to_write) + { + Size off_in_seg = startoff_in_seg + pos; + Size page_idx = off_in_seg / XLOG_BLCKSZ; + Size off_in_page = off_in_seg % XLOG_BLCKSZ; + Size page_remaining = XLOG_BLCKSZ - off_in_page; + Size chunk = Min(page_remaining, (Size) bytes_to_write - pos); + XLogRecPtr page_start_lsn; + + page_start_lsn = (XLogRecPtr) recvSegNo * WalSegSz + + (XLogRecPtr) page_idx * XLOG_BLCKSZ; + WalPagelevelEncryptRange(segdata + pos, chunk, + page_start_lsn, off_in_page); + pos += chunk; + } + + if (stream->walmethod->ops->write(walfile, segdata, + bytes_to_write) != bytes_to_write) + { + pg_log_error("could not write %d bytes to WAL file \"%s\": %s", + bytes_to_write, walfile->pathname, + GetLastWalMethodError(stream->walmethod)); + return false; + } } /* Write was successful, advance our position */ diff --git a/src/bin/pg_resetwal/pg_resetwal.c b/src/bin/pg_resetwal/pg_resetwal.c index d072e7c2ea4..feea8abfa1b 100644 --- a/src/bin/pg_resetwal/pg_resetwal.c +++ b/src/bin/pg_resetwal/pg_resetwal.c @@ -54,6 +54,7 @@ #include "common/logging.h" #include "common/restricted_token.h" #include "common/string.h" +#include "common/wal_pagelevel.h" #include "fe_utils/option_utils.h" #include "fe_utils/version.h" #include "getopt_long.h" @@ -1181,6 +1182,16 @@ WriteEmptyXLOG(void) if (fd < 0) pg_fatal("could not open file \"%s\": %m", path); + /* + * Encrypt the synthetic checkpoint page so the cluster can read it via + * the page-level WAL CTR decrypt path on next start. + * IV = page-start LSN of segment newXlogSegNo at offset 0. + */ + { + XLogRecPtr page_start_lsn = (XLogRecPtr) newXlogSegNo * WalSegSz; + WalPagelevelEncryptPage(buffer.data, page_start_lsn); + } + errno = 0; if (write(fd, buffer.data, XLOG_BLCKSZ) != XLOG_BLCKSZ) { diff --git a/src/bin/pg_rewind/parsexlog.c b/src/bin/pg_rewind/parsexlog.c index 023e23b063c..e08e310fae4 100644 --- a/src/bin/pg_rewind/parsexlog.c +++ b/src/bin/pg_rewind/parsexlog.c @@ -20,6 +20,7 @@ #include "catalog/pg_control.h" #include "catalog/storage_xlog.h" #include "commands/dbcommands_xlog.h" +#include "common/wal_pagelevel.h" #include "fe_utils/archive.h" #include "filemap.h" #include "pg_rewind.h" @@ -377,6 +378,13 @@ SimpleXLogPageRead(XLogReaderState *xlogreader, XLogRecPtr targetPagePtr, Assert(targetSegNo == xlogreadsegno); + /* + * On-disk WAL is encrypted; decrypt the page before XLogReader inspects + * it. IV is the page-start LSN, here equal to targetPagePtr (which is + * always page-aligned). + */ + WalPagelevelDecryptPage(readBuf, targetPagePtr); + xlogreader->seg.ws_tli = targetHistory[private->tliIndex].tli; return XLOG_BLCKSZ; } diff --git a/src/bin/pg_waldump/archive_waldump.c b/src/bin/pg_waldump/archive_waldump.c index e0c3aa4d255..cd29c5830c1 100644 --- a/src/bin/pg_waldump/archive_waldump.c +++ b/src/bin/pg_waldump/archive_waldump.c @@ -20,6 +20,7 @@ #include "common/file_perm.h" #include "common/hashfn.h" #include "common/logging.h" +#include "common/wal_pagelevel.h" #include "fe_utils/simple_list.h" #include "pg_waldump.h" @@ -72,6 +73,9 @@ typedef struct ArchivedWALFile int read_len; /* total bytes received from archive for this * segment (same as buf->len, unless we have * spilled the data to a temp file) */ + uint64 segsize; /* WAL segment size (== tar member size). Used + * to derive the page-start LSN for decryption + * before privateInfo->segsize is known. */ } ArchivedWALFile; static uint32 hash_string_pointer(const char *s); @@ -112,6 +116,7 @@ static void astreamer_waldump_free(astreamer *streamer); static bool member_is_wal_file(astreamer_waldump *mystreamer, astreamer_member *member, char **fname); +static XLogRecPtr archive_seg_start_lsn(const char *fname, uint64 segsize); static const astreamer_ops astreamer_waldump_ops = { .content = astreamer_waldump_content, @@ -197,20 +202,41 @@ init_archive_reader(XLogDumpPrivate *privateInfo, } } - /* Extract the WAL segment size from the long page header */ - longhdr = (XLogLongPageHeader) entry->buf->data; - - if (!IsValidWalSegSize(longhdr->xlp_seg_size)) + /* + * Extract the WAL segment size from the long page header. + * + * The bytes in entry->buf are page-level AES-CTR ciphertext. Decrypt a + * local copy of the long header so we can read xlp_seg_size; we leave + * entry->buf as ciphertext because subsequent reads go through + * read_archive_wal_page() (which decrypts into the caller's buffer) or + * via the spill-to-tmpfile path, which is read back through WALRead() + * and decrypted there. + * + * The page-start LSN of the segment's first page is derived from the + * filename plus entry->segsize (== tar member size == WAL segment size). + */ { - pg_log_error(ngettext("invalid WAL segment size in WAL file from archive \"%s\" (%u byte)", - "invalid WAL segment size in WAL file from archive \"%s\" (%u bytes)", - longhdr->xlp_seg_size), - privateInfo->archive_name, longhdr->xlp_seg_size); - pg_log_error_detail("The WAL segment size must be a power of two between 1 MB and 1 GB."); - exit(1); - } + char hdrbuf[sizeof(XLogLongPageHeaderData)]; + XLogRecPtr page_start_lsn; - privateInfo->segsize = longhdr->xlp_seg_size; + memcpy(hdrbuf, entry->buf->data, sizeof(XLogLongPageHeaderData)); + page_start_lsn = archive_seg_start_lsn(entry->fname, entry->segsize); + WalPagelevelDecryptRange(hdrbuf, sizeof(XLogLongPageHeaderData), + page_start_lsn, 0); + longhdr = (XLogLongPageHeader) hdrbuf; + + if (!IsValidWalSegSize(longhdr->xlp_seg_size)) + { + pg_log_error(ngettext("invalid WAL segment size in WAL file from archive \"%s\" (%u byte)", + "invalid WAL segment size in WAL file from archive \"%s\" (%u bytes)", + longhdr->xlp_seg_size), + privateInfo->archive_name, longhdr->xlp_seg_size); + pg_log_error_detail("The WAL segment size must be a power of two between 1 MB and 1 GB."); + exit(1); + } + + privateInfo->segsize = longhdr->xlp_seg_size; + } /* * With the WAL segment size available, we can now initialize the @@ -350,6 +376,31 @@ read_archive_wal_page(XLogDumpPrivate *privateInfo, XLogRecPtr targetPagePtr, copyBytes = Min(nbytes, bufLen - offset); memcpy(p, buf + offset, copyBytes); + /* + * The bytes in entry->buf are page-level AES-CTR ciphertext (the + * archive streamer hands us the bytes exactly as they appear in + * the source segment file). Decrypt the copy now, page by page, + * so the caller sees plaintext just as it would from a regular + * on-disk WAL read. + */ + { + int pos = 0; + + while (pos < copyBytes) + { + XLogRecPtr lsn = recptr + pos; + Size off_in_page = lsn % XLOG_BLCKSZ; + Size page_remaining = XLOG_BLCKSZ - off_in_page; + Size chunk = Min((Size) (copyBytes - pos), + page_remaining); + XLogRecPtr page_start_lsn = lsn - off_in_page; + + WalPagelevelDecryptRange(p + pos, chunk, + page_start_lsn, off_in_page); + pos += chunk; + } + } + /* Update state for read */ recptr += copyBytes; nbytes -= copyBytes; @@ -749,6 +800,7 @@ astreamer_waldump_content(astreamer *streamer, astreamer_member *member, entry->buf = makeStringInfo(); entry->spilled = false; entry->read_len = 0; + entry->segsize = (uint64) member->size; privateInfo->cur_file = entry; } break; @@ -844,6 +896,34 @@ member_is_wal_file(astreamer_waldump *mystreamer, astreamer_member *member, return true; } +/* + * Compute the page-start LSN of the first page of the WAL segment whose + * filename is `fname` and whose segment size is `segsize`. + * + * The XLogFileName encoding is "TLI(8) xlog_id(8) segno_in_xlog(8)", and: + * + * segment_start_lsn = segno * segsize + * = (xlog_id << 32) + segno_in_xlog * segsize + * + * Add an in-segment byte offset to get an arbitrary page-start LSN within the + * segment. Used to derive the IV for AES-CTR decryption of WAL pages read + * out of a tar archive, where we don't have an open segment file to fstat. + */ +static XLogRecPtr +archive_seg_start_lsn(const char *fname, uint64 segsize) +{ + uint32 tli_hex; + uint32 xlogid_hex; + uint32 segno_in_xlog_hex; + + if (sscanf(fname, "%8X%8X%8X", + &tli_hex, &xlogid_hex, &segno_in_xlog_hex) != 3) + pg_fatal("could not parse WAL segment filename \"%s\"", fname); + + return ((XLogRecPtr) xlogid_hex << 32) + + (XLogRecPtr) segno_in_xlog_hex * segsize; +} + /* * Helper function for WAL file hash table. */ diff --git a/src/bin/pg_waldump/pg_waldump.c b/src/bin/pg_waldump/pg_waldump.c index cf760d8b236..82df5fc2558 100644 --- a/src/bin/pg_waldump/pg_waldump.c +++ b/src/bin/pg_waldump/pg_waldump.c @@ -28,6 +28,7 @@ #include "common/file_utils.h" #include "common/logging.h" #include "common/relpath.h" +#include "common/wal_pagelevel.h" #include "getopt_long.h" #include "pg_waldump.h" #include "rmgrdesc.h" @@ -241,7 +242,38 @@ search_directory(const char *directory, const char *fname, int *WalSegSz) r = read(fd, buf.data, XLOG_BLCKSZ); if (r == XLOG_BLCKSZ) { - XLogLongPageHeader longhdr = (XLogLongPageHeader) buf.data; + XLogLongPageHeader longhdr; + struct stat statbuf; + uint32 tli_hex, + xlogid_hex, + segno_in_xlog_hex; + uint64 segsize; + XLogRecPtr page_start_lsn; + + /* + * Decrypt the long header before validating it. We don't yet + * know WalSegSz (it is in the encrypted header), but we can + * derive the page-start LSN of this segment from the filename + * plus the segment file's on-disk size: + * + * page_start_lsn = (xlog_id << 32) + segno_in_xlog * segsize + * + * which equals segno * segsize without explicitly computing + * segno. + */ + if (sscanf(fname, "%8X%8X%8X", + &tli_hex, &xlogid_hex, &segno_in_xlog_hex) != 3) + pg_fatal("could not parse WAL segment filename \"%s\"", fname); + + if (fstat(fd, &statbuf) != 0) + pg_fatal("could not stat file \"%s\": %m", fname); + segsize = (uint64) statbuf.st_size; + + page_start_lsn = ((XLogRecPtr) xlogid_hex << 32) + + (XLogRecPtr) segno_in_xlog_hex * segsize; + WalPagelevelDecryptPage(buf.data, page_start_lsn); + + longhdr = (XLogLongPageHeader) buf.data; if (!IsValidWalSegSize(longhdr->xlp_seg_size)) { diff --git a/src/common/Makefile b/src/common/Makefile index 1a2fbbe887f..feec392c647 100644 --- a/src/common/Makefile +++ b/src/common/Makefile @@ -84,6 +84,8 @@ OBJS_COMMON = \ unicode_norm.o \ username.o \ wait_error.o \ + wal_pagelevel.o \ + wal_pagelevel_key.o \ wchar.o ifeq ($(with_ssl),openssl) diff --git a/src/common/meson.build b/src/common/meson.build index 9bd55cda95b..050275424dd 100644 --- a/src/common/meson.build +++ b/src/common/meson.build @@ -38,6 +38,8 @@ common_sources = files( 'unicode_norm.c', 'username.c', 'wait_error.c', + 'wal_pagelevel.c', + 'wal_pagelevel_key.c', 'wchar.c', ) diff --git a/src/common/wal_pagelevel.c b/src/common/wal_pagelevel.c new file mode 100644 index 00000000000..22441d254c9 --- /dev/null +++ b/src/common/wal_pagelevel.c @@ -0,0 +1,305 @@ +#include "postgres.h" + +#include + +#include "common/wal_pagelevel.h" + +#ifndef FRONTEND +#include "access/xlog.h" /* wal_segment_size */ +#include "miscadmin.h" +#include "storage/ipc.h" +#include "utils/memutils.h" +#else +#include "common/logging.h" +#endif + +#ifndef FRONTEND +#define WAL_PAGELEVEL_FAIL(msg) elog(ERROR, msg) +#else +#define WAL_PAGELEVEL_FAIL(msg) pg_fatal(msg) +#endif + +/* Per-process lazy-allocated EVP contexts (backend + frontend). */ +static EVP_CIPHER_CTX *wal_pagelevel_enc_ctx = NULL; +static EVP_CIPHER_CTX *wal_pagelevel_dec_ctx = NULL; + +#ifndef FRONTEND +static MemoryContext wal_pagelevel_cxt = NULL; +static char *wal_pagelevel_cipher_buf = NULL; +static Size wal_pagelevel_cipher_buf_size = 0; +static bool wal_pagelevel_atexit_registered = false; +#endif + +#ifndef FRONTEND +static void +wal_pagelevel_atexit(int code, Datum arg) +{ + if (wal_pagelevel_enc_ctx) + { + EVP_CIPHER_CTX_free(wal_pagelevel_enc_ctx); + wal_pagelevel_enc_ctx = NULL; + } + if (wal_pagelevel_dec_ctx) + { + EVP_CIPHER_CTX_free(wal_pagelevel_dec_ctx); + wal_pagelevel_dec_ctx = NULL; + } + /* wal_pagelevel_cipher_buf lives in wal_pagelevel_cxt, freed by it. */ +} +#endif + +void +WalPagelevelInit(void) +{ +#ifndef FRONTEND + if (wal_pagelevel_cxt != NULL) + return; /* already done in this backend */ + + wal_pagelevel_cxt = AllocSetContextCreate(TopMemoryContext, + "WAL pagelevel prototype", + ALLOCSET_DEFAULT_SIZES); + MemoryContextAllowInCriticalSection(wal_pagelevel_cxt, true); + + if (!wal_pagelevel_atexit_registered) + { + before_shmem_exit(wal_pagelevel_atexit, 0); + wal_pagelevel_atexit_registered = true; + } +#endif +} + +void +WalPagelevelLogAtStartup(void) +{ +#ifndef FRONTEND + ereport(LOG, + (errmsg("WAL page-level encryption: AES-256-CTR, IV=page-start-LSN"))); +#endif +} + +/* + * Build the AES-CTR initial counter block for the 16-byte block containing + * (page_start_lsn + offset_in_page). + * + * IV layout: + * bytes 0..7: page_start_lsn, big-endian + * bytes 8..15: zero + * + * Advanced by (offset_in_page / 16) blocks. Any sub-block remainder + * (offset_in_page % 16) is consumed by the caller's leading-discard update. + */ +static void +wal_pagelevel_build_iv(unsigned char iv[WAL_PAGELEVEL_IV_LEN], + XLogRecPtr page_start_lsn, + Size offset_in_page) +{ + uint64 blocks; + int i; + uint64 carry; + + /* Big-endian LSN in bytes 0..7. */ + for (i = 0; i < 8; i++) + iv[i] = (unsigned char) (page_start_lsn >> (56 - 8 * i)); + for (i = 8; i < 16; i++) + iv[i] = 0; + + /* Advance by offset_in_page / 16 blocks via big-endian 128-bit add. */ + blocks = offset_in_page / WAL_PAGELEVEL_IV_LEN; + carry = blocks; + for (i = WAL_PAGELEVEL_IV_LEN - 1; i >= 0 && carry; i--) + { + uint64 sum = (uint64) iv[i] + (carry & 0xff); + + iv[i] = (unsigned char) (sum & 0xff); + carry = (carry >> 8) + (sum >> 8); + } +} + +/* + * Per-process EVP context for one direction. Context allocation, cipher + * fetch and the AES key schedule happen once per process here; per-range + * calls only reset the IV, keeping the cached key schedule. + */ +static EVP_CIPHER_CTX * +wal_pagelevel_get_ctx(bool for_encrypt) +{ + EVP_CIPHER_CTX **ctxp; + int rc; + + ctxp = for_encrypt ? &wal_pagelevel_enc_ctx : &wal_pagelevel_dec_ctx; + if (*ctxp != NULL) + return *ctxp; + + *ctxp = EVP_CIPHER_CTX_new(); + if (*ctxp == NULL) + WAL_PAGELEVEL_FAIL("EVP_CIPHER_CTX_new failed"); + + if (for_encrypt) + rc = EVP_EncryptInit_ex2(*ctxp, EVP_aes_256_ctr(), + WalPagelevelKey, NULL, NULL); + else + rc = EVP_DecryptInit_ex2(*ctxp, EVP_aes_256_ctr(), + WalPagelevelKey, NULL, NULL); + if (rc != 1) + WAL_PAGELEVEL_FAIL("EVP cipher init failed"); + + return *ctxp; +} + +/* + * Shared encrypt/decrypt core. dst may equal src (in place) or point to a + * separate output buffer, fusing the copy into the cipher pass. + * + * No EVP_*Final_ex call: CTR is a stream cipher, Update produced every output + * byte, and the context is re-initialized by the next call's IV-only init. + */ +static void +wal_pagelevel_cipher_range(bool for_encrypt, char *dst, const char *src, + Size len, XLogRecPtr page_start_lsn, + Size offset_in_page) +{ + unsigned char iv[WAL_PAGELEVEL_IV_LEN]; + Size leading_discard; + Size aligned_offset; + int outlen; + EVP_CIPHER_CTX *ctx; + int rc; + + if (len == 0) + return; + + Assert(offset_in_page + len <= XLOG_BLCKSZ); + + WalPagelevelInit(); + + leading_discard = offset_in_page % WAL_PAGELEVEL_IV_LEN; + aligned_offset = offset_in_page - leading_discard; + + wal_pagelevel_build_iv(iv, page_start_lsn, aligned_offset); + + ctx = wal_pagelevel_get_ctx(for_encrypt); + + /* IV-only re-init: NULL cipher and key keep the cached key schedule. */ + if (for_encrypt) + rc = EVP_EncryptInit_ex2(ctx, NULL, NULL, iv, NULL); + else + rc = EVP_DecryptInit_ex2(ctx, NULL, NULL, iv, NULL); + if (rc != 1) + WAL_PAGELEVEL_FAIL("EVP IV init failed"); + + /* + * If the start byte is mid-block, run leading_discard zero bytes through + * EVP_*Update to advance the CTR keystream position past them. The output + * is thrown away; only the keystream advance matters. + */ + if (leading_discard > 0) + { + unsigned char discard_in[WAL_PAGELEVEL_IV_LEN] = {0}; + unsigned char discard_out[WAL_PAGELEVEL_IV_LEN]; + + if (for_encrypt) + rc = EVP_EncryptUpdate(ctx, discard_out, &outlen, + discard_in, (int) leading_discard); + else + rc = EVP_DecryptUpdate(ctx, discard_out, &outlen, + discard_in, (int) leading_discard); + if (rc != 1) + WAL_PAGELEVEL_FAIL("EVP discard update failed"); + } + + if (for_encrypt) + rc = EVP_EncryptUpdate(ctx, (unsigned char *) dst, &outlen, + (const unsigned char *) src, (int) len); + else + rc = EVP_DecryptUpdate(ctx, (unsigned char *) dst, &outlen, + (const unsigned char *) src, (int) len); + if (rc != 1 || outlen != (int) len) + WAL_PAGELEVEL_FAIL("EVP update failed"); +} + +void +WalPagelevelEncryptRange(char *buf, Size len, + XLogRecPtr page_start_lsn, + Size offset_in_page) +{ + wal_pagelevel_cipher_range(true, buf, buf, len, + page_start_lsn, offset_in_page); +} + +void +WalPagelevelDecryptRange(char *buf, Size len, + XLogRecPtr page_start_lsn, + Size offset_in_page) +{ + wal_pagelevel_cipher_range(false, buf, buf, len, + page_start_lsn, offset_in_page); +} + +void +WalPagelevelEncryptPage(char *page, XLogRecPtr page_start_lsn) +{ + WalPagelevelEncryptRange(page, XLOG_BLCKSZ, page_start_lsn, 0); +} + +void +WalPagelevelDecryptPage(char *page, XLogRecPtr page_start_lsn) +{ + WalPagelevelDecryptRange(page, XLOG_BLCKSZ, page_start_lsn, 0); +} + +#ifndef FRONTEND +/* + * Ensure the per-backend scratch ciphertext buffer is at least `need` bytes. + * Grown high-water-mark. Caller must have called WalPagelevelInit() first. + */ +static char * +wal_pagelevel_ensure_buf(Size need) +{ + MemoryContext old; + + if (wal_pagelevel_cipher_buf_size >= need) + return wal_pagelevel_cipher_buf; + + old = MemoryContextSwitchTo(wal_pagelevel_cxt); + if (wal_pagelevel_cipher_buf == NULL) + wal_pagelevel_cipher_buf = palloc(need); + else + wal_pagelevel_cipher_buf = repalloc(wal_pagelevel_cipher_buf, need); + wal_pagelevel_cipher_buf_size = need; + MemoryContextSwitchTo(old); + return wal_pagelevel_cipher_buf; +} + +char * +WalPagelevelGetScratch(Size need) +{ + WalPagelevelInit(); + return wal_pagelevel_ensure_buf(need); +} + +char * +WalPagelevelEncryptForWrite(const char *plain, Size len, + XLogSegNo segno, Size seg_offset) +{ + char *scratch; + Size i; + + Assert((seg_offset % XLOG_BLCKSZ) == 0); + Assert((len % XLOG_BLCKSZ) == 0); + + scratch = WalPagelevelGetScratch(len); + + /* Encrypt plaintext straight into scratch, no separate memcpy pass. */ + for (i = 0; i < len; i += XLOG_BLCKSZ) + { + XLogRecPtr page_start_lsn; + + page_start_lsn = (XLogRecPtr) segno * wal_segment_size + + seg_offset + i; + wal_pagelevel_cipher_range(true, scratch + i, plain + i, + XLOG_BLCKSZ, page_start_lsn, 0); + } + + return scratch; +} +#endif diff --git a/src/common/wal_pagelevel_key.c b/src/common/wal_pagelevel_key.c new file mode 100644 index 00000000000..cde31652597 --- /dev/null +++ b/src/common/wal_pagelevel_key.c @@ -0,0 +1,16 @@ +/*------------------------------------------------------------------------- + * + * wal_pagelevel_key.c + * Hardcoded AES-256 key for the page-level WAL CTR perf prototype. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" +#include "common/wal_pagelevel.h" + +const unsigned char WalPagelevelKey[WAL_PAGELEVEL_KEY_LEN] = { + 0x70, 0x67, 0x6c, 0x76, 0x6c, 0x77, 0x61, 0x6c, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x74, 0x79, 0x70, + 0x65, 0x6b, 0x65, 0x79, 0x6e, 0x6f, 0x74, 0x66, + 0x6f, 0x72, 0x70, 0x72, 0x6f, 0x64, 0x21, 0x21, +}; diff --git a/src/include/common/wal_pagelevel.h b/src/include/common/wal_pagelevel.h new file mode 100644 index 00000000000..b8b54524327 --- /dev/null +++ b/src/include/common/wal_pagelevel.h @@ -0,0 +1,70 @@ +/*------------------------------------------------------------------------- + * + * wal_pagelevel.h + * Throwaway page-level WAL AES-256-CTR encryption prototype. + * + * Hardcoded key. No plugin API. No key management. No authentication. + * In-memory WAL buffer stays plaintext; encryption happens only at the + * disk-write boundary. IV is the page-start LSN. + * + *------------------------------------------------------------------------- + */ +#ifndef WAL_PAGELEVEL_H +#define WAL_PAGELEVEL_H + +#include "access/xlogdefs.h" + +#define WAL_PAGELEVEL_KEY_LEN 32 /* AES-256 */ +#define WAL_PAGELEVEL_IV_LEN 16 /* AES block size */ + +extern const unsigned char WalPagelevelKey[WAL_PAGELEVEL_KEY_LEN]; + +/* + * Encrypt `len` bytes in place using AES-256-CTR. The plaintext range + * starts at byte offset `page_start_lsn + offset_in_page` of the WAL + * stream; offset_in_page must be a multiple of 16 (page-aligned writes + * are the only call pattern this prototype supports). + * + * page_start_lsn is the WAL LSN of byte 0 of the page containing the + * range; the IV is derived from it directly. + */ +extern void WalPagelevelEncryptRange(char *buf, + Size len, + XLogRecPtr page_start_lsn, + Size offset_in_page); + +/* Symmetric. out-of-place not supported; decrypts in place. */ +extern void WalPagelevelDecryptRange(char *buf, + Size len, + XLogRecPtr page_start_lsn, + Size offset_in_page); + +/* + * Convenience: encrypt/decrypt one full XLOG_BLCKSZ page in place. + */ +extern void WalPagelevelEncryptPage(char *page, XLogRecPtr page_start_lsn); +extern void WalPagelevelDecryptPage(char *page, XLogRecPtr page_start_lsn); + +/* Lifecycle. */ +extern void WalPagelevelInit(void); +extern void WalPagelevelLogAtStartup(void); + +#ifndef FRONTEND +extern char *WalPagelevelGetScratch(Size need); +#endif + +#ifndef FRONTEND +/* + * Encrypt `len` bytes of WAL data starting at `seg_offset` within segment + * `segno`, copying plaintext from `plain` into a per-backend scratch + * ciphertext buffer. Returns the scratch pointer; the caller pg_pwrite's + * from there. Caller must ensure seg_offset and seg_offset+len are both + * XLOG_BLCKSZ-aligned (page-aligned writes only). + */ +extern char *WalPagelevelEncryptForWrite(const char *plain, + Size len, + XLogSegNo segno, + Size seg_offset); +#endif + +#endif /* WAL_PAGELEVEL_H */ diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile index 0a74ab5c86f..74a7f734779 100644 --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -54,6 +54,7 @@ SUBDIRS = \ test_slru \ test_tidstore \ unsafe_tests \ + wal_pagelevel_check \ worker_spi \ xid_wraparound diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build index 4bca42bb370..299a96e4169 100644 --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -56,5 +56,6 @@ subdir('test_slru') subdir('test_tidstore') subdir('typcache') subdir('unsafe_tests') +subdir('wal_pagelevel_check') subdir('worker_spi') subdir('xid_wraparound') diff --git a/src/test/modules/wal_pagelevel_check/Makefile b/src/test/modules/wal_pagelevel_check/Makefile new file mode 100644 index 00000000000..423811e8e62 --- /dev/null +++ b/src/test/modules/wal_pagelevel_check/Makefile @@ -0,0 +1,18 @@ +# src/test/modules/wal_pagelevel_check/Makefile + +TAP_TESTS = 1 + +# Requires a cluster built with the page-level CTR prototype, +# which is a compile-time choice, so skip during installcheck. +NO_INSTALLCHECK = 1 + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/wal_pagelevel_check +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/wal_pagelevel_check/meson.build b/src/test/modules/wal_pagelevel_check/meson.build new file mode 100644 index 00000000000..3381ae77552 --- /dev/null +++ b/src/test/modules/wal_pagelevel_check/meson.build @@ -0,0 +1,15 @@ +# Copyright (c) 2022-2026, PostgreSQL Global Development Group + +tests += { + 'name': 'wal_pagelevel_check', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'tap': { + 'tests': [ + 't/001_wal_is_encrypted.pl', + ], + # Requires a cluster built with the page-level CTR prototype, + # which is a compile-time choice, so skip during installcheck. + 'runningcheck': false, + }, +} diff --git a/src/test/modules/wal_pagelevel_check/t/001_wal_is_encrypted.pl b/src/test/modules/wal_pagelevel_check/t/001_wal_is_encrypted.pl new file mode 100644 index 00000000000..0f9a554226a --- /dev/null +++ b/src/test/modules/wal_pagelevel_check/t/001_wal_is_encrypted.pl @@ -0,0 +1,43 @@ +# Verify on-disk WAL contains no plaintext under the page-level CTR prototype. + +use strict; +use warnings; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $canary = 'THE_CANARY_STRING_THAT_SHOULD_NEVER_APPEAR_PLAINTEXT'; + +my $node = PostgreSQL::Test::Cluster->new('canary'); +$node->init; +$node->start; + +$node->safe_psql('postgres', + "CREATE TABLE t (c text); INSERT INTO t VALUES ('$canary');"); +$node->safe_psql('postgres', 'CHECKPOINT'); +$node->safe_psql('postgres', 'SELECT pg_switch_wal()'); + +$node->stop; + +my $pg_wal = $node->data_dir . '/pg_wal'; +my @wal = glob "$pg_wal/*"; +my $found = 0; +for my $f (@wal) { + open(my $fh, '<:raw', $f) or die "open $f: $!"; + local $/; + my $bytes = <$fh>; + close $fh; + $found++ while $bytes =~ /\Q$canary\E/g; +} +is($found, 0, 'canary never appears in any pg_wal segment'); + +$node->start; +my $count = $node->safe_psql('postgres', + "SELECT count(*) FROM t WHERE c = '$canary'"); +is($count, '1', + 'row containing canary reads back after restart (decrypt round-trip)'); + +$node->stop; + +done_testing(); -- 2.54.0