From cc21067b8377da9074b8dce9d1e71775652ea9c8 Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
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 <openssl/evp.h>
+
+#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

