From 982b3b3c900fe1c1dc11bfcedbce6d9b1649f435 Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
Date: Wed, 15 Jul 2026 20:54:44 +0000
Subject: [PATCH vnocfbot] wal_ctr: per-record AES-256-CTR WAL encryption
 prototype

Throwaway perf prototype. Encrypt every WAL record body (everything
after XLogRecord) with AES-256-CTR at XLogInsert time, decrypt at
XLogReadRecord time. New info bit XLR_ENCRYPTED marks encrypted records;
16-byte IV appended after ciphertext, xl_tot_len grown by 16. Zero-body
records bypass encryption. Bootstrap and pg_resetwal synthetic
checkpoint records encrypted the same way.

Per-backend EVP ctx, key loaded once; per-record hot path only resets
the IV. Body encrypted straight from the rdata chain. CRC computed over
ciphertext. Core in src/common/wal_ctr.{c,h} + hardcoded key. TAP test
wal_ctr_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. Requires OpenSSL 3.0+.
---
 src/backend/access/transam/xlog.c             |  22 ++
 src/backend/access/transam/xloginsert.c       | 175 ++++++++++++-
 src/backend/access/transam/xlogreader.c       |  75 ++++++
 src/backend/postmaster/postmaster.c           |   2 +
 src/bin/pg_resetwal/pg_resetwal.c             |  25 ++
 src/common/Makefile                           |   2 +
 src/common/meson.build                        |   2 +
 src/common/wal_ctr.c                          | 231 ++++++++++++++++++
 src/common/wal_ctr_key.c                      |  27 ++
 src/include/access/xlogrecord.h               |   9 +
 src/include/common/wal_ctr.h                  |  61 +++++
 src/test/modules/Makefile                     |   1 +
 src/test/modules/meson.build                  |   1 +
 src/test/modules/wal_ctr_check/Makefile       |  18 ++
 src/test/modules/wal_ctr_check/meson.build    |  15 ++
 .../wal_ctr_check/t/001_wal_is_encrypted.pl   |  50 ++++
 16 files changed, 712 insertions(+), 4 deletions(-)
 create mode 100644 src/common/wal_ctr.c
 create mode 100644 src/common/wal_ctr_key.c
 create mode 100644 src/include/common/wal_ctr.h
 create mode 100644 src/test/modules/wal_ctr_check/Makefile
 create mode 100644 src/test/modules/wal_ctr_check/meson.build
 create mode 100644 src/test/modules/wal_ctr_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..2c8875618c6 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_ctr.h"
 #include "executor/instrument.h"
 #include "miscadmin.h"
 #include "pg_trace.h"
@@ -5548,6 +5549,27 @@ BootStrapXLOG(uint32 data_checksum_version)
 	recptr += sizeof(checkPoint);
 	Assert(recptr - (char *) record == record->xl_tot_len);
 
+	/*
+	 * The bootstrap checkpoint record bypasses XLogInsert and so does not
+	 * hit XLogEncryptRecordBody.  Encrypt it here so the decrypt path in
+	 * xlogreader.c can verify it like any other WAL record.
+	 */
+	{
+		char	   *body = ((char *) record) + SizeOfXLogRecord;
+		Size		body_len = record->xl_tot_len - SizeOfXLogRecord;
+		char	   *iv = body + body_len;	/* appended in-place */
+
+		if (body_len > 0)
+		{
+			WalCtrInit();
+			WalCtrEncryptRecord(body, body, body_len, iv);
+
+			record->xl_tot_len += (uint32) WAL_CTR_OVERHEAD;
+			Assert(record->xl_tot_len <= XLogRecordMaxSize);
+			record->xl_info |= XLR_ENCRYPTED;
+		}
+	}
+
 	INIT_CRC32C(crc);
 	COMP_CRC32C(crc, ((char *) record) + SizeOfXLogRecord, record->xl_tot_len - SizeOfXLogRecord);
 	COMP_CRC32C(crc, (char *) record, offsetof(XLogRecord, xl_crc));
diff --git a/src/backend/access/transam/xloginsert.c b/src/backend/access/transam/xloginsert.c
index f2e10b82b7d..c28e97794ee 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_ctr.h"
 #include "executor/instrument.h"
 #include "miscadmin.h"
 #include "pg_trace.h"
@@ -116,6 +117,17 @@ static uint8 curinsert_flags = 0;
 static XLogRecData hdr_rdt;
 static char *hdr_scratch = NULL;
 
+/*
+ * Buffers and rdata chain used to encrypt the WAL record body before
+ * insertion.  All live in wal_ctr_enc_cxt (allowInCritSection).
+ * Populated whenever this backend inserts a WAL record with a non-empty
+ * body; encryption is unconditional in the prototype.
+ */
+static MemoryContext wal_ctr_enc_cxt = NULL;
+static char *wal_ctr_enc_cipher_buf = NULL;
+static Size wal_ctr_enc_cipher_bufsz = 0;
+static XLogRecData wal_ctr_enc_chain[2];
+
 #define SizeOfXlogOrigin	(sizeof(ReplOriginId) + sizeof(char))
 #define SizeOfXLogTransactionId	(sizeof(TransactionId) + sizeof(char))
 
@@ -137,6 +149,7 @@ static bool begininsert_called = false;
 /* Memory context to hold the registered buffer and data references. */
 static MemoryContext xloginsert_cxt;
 
+static XLogRecData *XLogEncryptRecordBody(XLogRecData *rdt);
 static XLogRecData *XLogRecordAssemble(RmgrId rmid, uint8 info,
 									   XLogRecPtr RedoRecPtr, bool doPageWrites,
 									   XLogRecPtr *fpw_lsn, int *num_fpi,
@@ -530,6 +543,18 @@ XLogInsert(RmgrId rmid, uint8 info)
 								 &fpw_lsn, &num_fpi, &fpi_bytes,
 								 &topxid_included);
 
+		/*
+		 * Encrypt the record body in place via the WAL CTR prototype before
+		 * handing the chain to XLogInsertRecord().  Done outside the WAL
+		 * insert lock so concurrent inserters encrypt in parallel and so
+		 * per-record IVs do not serialise on the lock.  Retries call
+		 * XLogRecordAssemble() again from scratch, so re-encryption with a
+		 * fresh IV is safe.  Zero-body records (XLOG_SWITCH etc.) bypass
+		 * encryption entirely so CopyXLogRecordToWAL's xlog-switch assert
+		 * does not fire.
+		 */
+		rdt = XLogEncryptRecordBody(rdt);
+
 		EndPos = XLogInsertRecord(rdt, fpw_lsn, curinsert_flags, num_fpi,
 								  fpi_bytes, topxid_included);
 	} while (!XLogRecPtrIsValid(EndPos));
@@ -623,7 +648,6 @@ XLogRecordAssemble(RmgrId rmid, uint8 info,
 				   XLogRecPtr *fpw_lsn, int *num_fpi, uint64 *fpi_bytes,
 				   bool *topxid_included)
 {
-	XLogRecData *rdt;
 	uint64		total_len = 0;
 	int			block_id;
 	pg_crc32c	rdata_crc;
@@ -976,11 +1000,15 @@ XLogRecordAssemble(RmgrId rmid, uint8 info,
 	 * don't know the prev-link yet.  Thus, the CRC will represent the CRC of
 	 * the whole record in the order: rdata, then backup blocks, then record
 	 * header.
+	 *
+	 * WAL CTR: the body is encrypted after assembly, and XLogEncryptRecordBody
+	 * computes the CRC over the resulting ciphertext.  Folding the plaintext
+	 * body into the CRC here would be a wasted second pass over every record,
+	 * so we leave it out.  Body-less records (XLOG_SWITCH etc.) have no rdata
+	 * to add and bypass encryption, so the bare INIT value is their correct
+	 * partial CRC.
 	 */
 	INIT_CRC32C(rdata_crc);
-	COMP_CRC32C(rdata_crc, hdr_scratch + SizeOfXLogRecord, hdr_rdt.len - SizeOfXLogRecord);
-	for (rdt = hdr_rdt.next; rdt != NULL; rdt = rdt->next)
-		COMP_CRC32C(rdata_crc, rdt->data, rdt->len);
 
 	/*
 	 * Ensure that the XLogRecord is not too large.
@@ -1010,6 +1038,125 @@ XLogRecordAssemble(RmgrId rmid, uint8 info,
 	return &hdr_rdt;
 }
 
+/*
+ * Ensure the lazily-allocated WAL encryption buffer at *buf has room for at
+ * least `needed` bytes, allocating or growing from wal_ctr_enc_cxt as
+ * required.  Safe to call inside a critical section because wal_ctr_enc_cxt
+ * is marked allowInCritSection.
+ */
+static inline void
+ensure_wal_ctr_enc_buf(char **buf, Size *cur_sz, Size needed)
+{
+	if (needed <= *cur_sz)
+		return;
+	if (*buf == NULL)
+		*buf = MemoryContextAlloc(wal_ctr_enc_cxt, needed);
+	else
+		*buf = repalloc(*buf, needed);
+	*cur_sz = needed;
+}
+
+/*
+ * Encrypt the body of the XLogRecord returned by XLogRecordAssemble() with
+ * AES-256-CTR and return a fresh two-entry rdata chain pointing at the
+ * plaintext header followed by the ciphertext body with the IV tail
+ * appended.
+ *
+ * Zero-body records (XLOG_SWITCH etc.) are returned unchanged - they
+ * carry no encryption and no XLR_ENCRYPTED flag.
+ */
+static XLogRecData *
+XLogEncryptRecordBody(XLogRecData *rdt)
+{
+	XLogRecord *rechdr;
+	Size		body_len;
+	Size		cipher_len;
+	Size		off;
+	pg_crc32c	crc;
+	XLogRecData *cur;
+
+	Assert(rdt != NULL && rdt->len >= SizeOfXLogRecord);
+	rechdr = (XLogRecord *) rdt->data;
+
+	body_len = rechdr->xl_tot_len - SizeOfXLogRecord;
+
+	/*
+	 * Skip encryption for body-less records (e.g. XLOG_SWITCH).  Downstream
+	 * CopyXLogRecordToWAL asserts xl_tot_len == SizeOfXLogRecord exactly for
+	 * such records.  Decrypt side passes them through when XLR_ENCRYPTED is
+	 * absent and body_len == 0.
+	 */
+	if (body_len == 0)
+		return rdt;
+
+	/*
+	 * wal_ctr_enc_cxt is created eagerly in InitXLogInsert so we never
+	 * AllocSetContextCreate inside a critical section.
+	 */
+	Assert(wal_ctr_enc_cxt != NULL);
+
+	cipher_len = body_len + WAL_CTR_OVERHEAD;
+
+	ensure_wal_ctr_enc_buf(&wal_ctr_enc_cipher_buf, &wal_ctr_enc_cipher_bufsz,
+						   cipher_len);
+
+	/*
+	 * Mutate the header BEFORE the encrypt call. This is not a strict
+	 * requirement for CTR, but it is for GCM.
+	 */
+	rechdr->xl_tot_len = (uint32) (SizeOfXLogRecord + cipher_len);
+	Assert(rechdr->xl_tot_len <= XLogRecordMaxSize);
+	rechdr->xl_info |= XLR_ENCRYPTED;
+
+	/*
+	 * Encrypt the body segments straight from the rdata chain into
+	 * wal_ctr_enc_cipher_buf, advancing the CTR keystream across the
+	 * EVP_EncryptUpdate calls.  This avoids flattening the chain into a
+	 * separate buffer first.  Ciphertext lives in [0..body_len); the IV /
+	 * counter block is written into [body_len..body_len+16) by
+	 * WalCtrEncryptBegin.
+	 */
+	WalCtrEncryptBegin(wal_ctr_enc_cipher_buf + body_len);
+	off = 0;
+	if (rdt->len > SizeOfXLogRecord)
+	{
+		Size		first_tail = rdt->len - SizeOfXLogRecord;
+
+		WalCtrEncryptUpdate((const char *) rdt->data + SizeOfXLogRecord,
+							wal_ctr_enc_cipher_buf, first_tail);
+		off = first_tail;
+	}
+	for (cur = rdt->next; cur != NULL; cur = cur->next)
+	{
+		if (cur->len == 0)
+			continue;
+		WalCtrEncryptUpdate(cur->data, wal_ctr_enc_cipher_buf + off, cur->len);
+		off += cur->len;
+	}
+	Assert(off == body_len);
+	WalCtrEncryptFinal();
+
+	/*
+	 * Recompute the partial CRC over the ciphertext + IV tail (matching
+	 * XLogRecordAssemble's INIT/COMP-without-FIN convention).
+	 * XLogInsertRecord finishes the CRC over the header once xl_prev is
+	 * assigned.
+	 */
+	INIT_CRC32C(crc);
+	Assert(cipher_len >= WAL_CTR_OVERHEAD);
+	COMP_CRC32C(crc, wal_ctr_enc_cipher_buf, cipher_len);
+	rechdr->xl_crc = crc;
+
+	wal_ctr_enc_chain[0].data = (char *) rechdr;
+	wal_ctr_enc_chain[0].len = SizeOfXLogRecord;
+	wal_ctr_enc_chain[0].next = &wal_ctr_enc_chain[1];
+	wal_ctr_enc_chain[1].data = wal_ctr_enc_cipher_buf;
+	wal_ctr_enc_chain[1].len = cipher_len;
+	wal_ctr_enc_chain[1].next = NULL;
+
+	return &wal_ctr_enc_chain[0];
+}
+
 /*
  * Create a compressed version of a backup block image.
  *
@@ -1438,4 +1585,24 @@ InitXLogInsert(void)
 	if (hdr_scratch == NULL)
 		hdr_scratch = MemoryContextAllocZero(xloginsert_cxt,
 											 HEADER_SCRATCH_SIZE);
+
+	/*
+	 * Working context for the WAL CTR cipher buffer.  Marked
+	 * allowInCritSection because XLogEncryptRecordBody (re)allocs while
+	 * running under a START_CRIT_SECTION from XLogInsert callers.
+	 */
+	if (wal_ctr_enc_cxt == NULL)
+	{
+		wal_ctr_enc_cxt = AllocSetContextCreate(TopMemoryContext,
+												"WAL CTR encryption",
+												ALLOCSET_DEFAULT_SIZES);
+		MemoryContextAllowInCriticalSection(wal_ctr_enc_cxt, true);
+	}
+
+	/*
+	 * Pre-warm the wal_ctr.c side too: allocate the EVP contexts and load the
+	 * AES key now, outside any critical section, so the WAL hot path never
+	 * calls into OpenSSL allocation on first insert.
+	 */
+	WalCtrInit();
 }
diff --git a/src/backend/access/transam/xlogreader.c b/src/backend/access/transam/xlogreader.c
index 946907a2507..669608ae5ee 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_ctr.h"
 #include "replication/origin.h"
 
 #ifndef FRONTEND
@@ -882,6 +883,80 @@ restart:
 		state->NextRecPtr -= XLogSegmentOffset(state->NextRecPtr, state->segcxt.ws_segsize);
 	}
 
+	/*
+	 * Per-record WAL AES-256-CTR decrypt prototype.  CRC was computed over
+	 * the on-disk ciphertext above, so it has already passed.  Decrypt
+	 * shrinks the record by WAL_CTR_OVERHEAD bytes (the trailing IV) and
+	 * clears XLR_ENCRYPTED so DecodeXLogRecord and downstream consumers see
+	 * a normal record.
+	 *
+	 * Body layout on disk:
+	 *   body[0 .. plain_len)                    ciphertext
+	 *   body[plain_len .. plain_len+16)         IV (16 bytes)
+	 *
+	 * state->NextRecPtr was computed above from the on-disk ciphertext
+	 * total_len, so chaining is unaffected by the in-place shrink.
+	 *
+	 * Body-less records (e.g. XLOG_SWITCH) are not encrypted on the insert
+	 * side, so they reach us without XLR_ENCRYPTED.  Any other record
+	 * missing the flag indicates corruption.
+	 */
+	if ((record->xl_info & XLR_ENCRYPTED) == 0)
+	{
+		if (record->xl_tot_len != SizeOfXLogRecord)
+		{
+			report_invalid_record(state,
+								  "WAL record at %X/%08X missing XLR_ENCRYPTED flag in encrypted build",
+								  LSN_FORMAT_ARGS(RecPtr));
+			goto err;
+		}
+	}
+	else
+	{
+		size_t		body_len = record->xl_tot_len - SizeOfXLogRecord;
+		size_t		plain_len;
+		char	   *body;
+		const char *iv;
+
+		if (body_len < WAL_CTR_OVERHEAD)
+		{
+			report_invalid_record(state,
+								  "WAL record at %X/%08X body too short for CTR overhead (%zu < %u)",
+								  LSN_FORMAT_ARGS(RecPtr),
+								  body_len, (unsigned) WAL_CTR_OVERHEAD);
+			goto err;
+		}
+
+		plain_len = body_len - WAL_CTR_OVERHEAD;
+
+		/*
+		 * Single-page records point into state->readBuf, a page-sized cache
+		 * that ReadPageInternal reuses for subsequent reads of the same LSN
+		 * (e.g. recovery re-reads the checkpoint record while computing the
+		 * end of WAL).  Decrypting in place there would leave plaintext in
+		 * the cache; the second read would then fail ValidXLogRecord's CRC
+		 * check (CRC was computed over on-disk ciphertext).  Stage the
+		 * record into state->readRecordBuf (a per-record private buffer
+		 * rebuilt on every read) and decrypt there.  Multi-page records
+		 * already live in readRecordBuf.
+		 */
+		if (!assembled)
+		{
+			if (state->readRecordBufSize < record->xl_tot_len)
+				allocate_recordbuf(state, record->xl_tot_len);
+			memcpy(state->readRecordBuf, record, record->xl_tot_len);
+			record = (XLogRecord *) state->readRecordBuf;
+		}
+
+		body = ((char *) record) + SizeOfXLogRecord;
+		iv = body + plain_len;
+
+		WalCtrDecryptRecord(body, plain_len, iv);
+
+		record->xl_tot_len = (uint32) (SizeOfXLogRecord + plain_len);
+		record->xl_info &= ~XLR_ENCRYPTED;
+	}
+
 	/*
 	 * If we got here without a DecodedXLogRecord, it means we needed to
 	 * validate total_len before trusting it, but by now we've done that.
diff --git a/src/backend/postmaster/postmaster.c b/src/backend/postmaster/postmaster.c
index 90c7c4528e8..53796461b78 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_ctr.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)));
+	WalCtrLogVariantAtStartup();
 
 	/*
 	 * Establish input sockets.
diff --git a/src/bin/pg_resetwal/pg_resetwal.c b/src/bin/pg_resetwal/pg_resetwal.c
index d072e7c2ea4..80a1f7672d9 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_ctr.h"
 #include "fe_utils/option_utils.h"
 #include "fe_utils/version.h"
 #include "getopt_long.h"
@@ -1164,6 +1165,30 @@ WriteEmptyXLOG(void)
 	memcpy(recptr, &ControlFile.checkPointCopy,
 		   sizeof(CheckPoint));
 
+	/*
+	 * The synthetic shutdown-checkpoint record bypasses XLogInsert and so
+	 * does not hit XLogEncryptRecordBody.  Encrypt it here so the decrypt
+	 * path in xlogreader.c can verify it like any other WAL record.
+	 *
+	 * Body-less records (e.g. XLOG_SWITCH) skip encryption, mirroring the
+	 * logic in XLogEncryptRecordBody.  The synthetic checkpoint always has a
+	 * body (the CheckPoint struct), so the branch is defensive.
+	 */
+	{
+		char	   *body = ((char *) record) + SizeOfXLogRecord;
+		Size		body_len = record->xl_tot_len - SizeOfXLogRecord;
+		char	   *iv = body + body_len;	/* appended in-place */
+
+		if (body_len > 0)
+		{
+			WalCtrInit();
+			WalCtrEncryptRecord(body, body, body_len, iv);
+			record->xl_tot_len += (uint32) WAL_CTR_OVERHEAD;
+			Assert(record->xl_tot_len <= XLogRecordMaxSize);
+			record->xl_info |= XLR_ENCRYPTED;
+		}
+	}
+
 	INIT_CRC32C(crc);
 	COMP_CRC32C(crc, ((char *) record) + SizeOfXLogRecord, record->xl_tot_len - SizeOfXLogRecord);
 	COMP_CRC32C(crc, (char *) record, offsetof(XLogRecord, xl_crc));
diff --git a/src/common/Makefile b/src/common/Makefile
index 1a2fbbe887f..6cc65cad7c3 100644
--- a/src/common/Makefile
+++ b/src/common/Makefile
@@ -84,6 +84,8 @@ OBJS_COMMON = \
 	unicode_norm.o \
 	username.o \
 	wait_error.o \
+	wal_ctr.o \
+	wal_ctr_key.o \
 	wchar.o
 
 ifeq ($(with_ssl),openssl)
diff --git a/src/common/meson.build b/src/common/meson.build
index 9bd55cda95b..7b5d56b6dde 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_ctr.c',
+  'wal_ctr_key.c',
   'wchar.c',
 )
 
diff --git a/src/common/wal_ctr.c b/src/common/wal_ctr.c
new file mode 100644
index 00000000000..107f6cebf9f
--- /dev/null
+++ b/src/common/wal_ctr.c
@@ -0,0 +1,231 @@
+/*-------------------------------------------------------------------------
+ *
+ * wal_ctr.c
+ *	  Direct OpenSSL EVP per-record WAL encryption (AES-256-CTR).
+ *
+ *	  The AES key is loaded into the EVP contexts once per backend; the
+ *	  per-record hot path only resets the IV, so the key schedule is never
+ *	  rebuilt.  Per-record IVs come from a per-backend counter seeded once
+ *	  with RAND_bytes and advanced by the number of 16-byte blocks each
+ *	  record consumes, avoiding a CSPRNG draw per record.
+ *
+ * IDENTIFICATION
+ *	  src/common/wal_ctr.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FRONTEND
+#include "postgres.h"
+#else
+#include "postgres_fe.h"
+#endif
+
+#include <openssl/evp.h>
+#include <openssl/rand.h>
+
+#include "common/wal_ctr.h"
+
+#ifndef FRONTEND
+#include "miscadmin.h"
+#include "storage/ipc.h"
+#include "utils/memutils.h"
+#else
+#include "common/logging.h"
+#endif
+
+#ifndef FRONTEND
+#define WAL_CTR_FATAL(...) elog(ERROR, __VA_ARGS__)
+#else
+#define WAL_CTR_FATAL(...) pg_fatal(__VA_ARGS__)
+#endif
+
+/* Per-backend lazy-allocated state.  Only touched by WAL hot paths. */
+static EVP_CIPHER_CTX *wal_ctr_enc_ctx = NULL;
+static EVP_CIPHER_CTX *wal_ctr_dec_ctx = NULL;
+
+/* Per-backend IV counter: seeded once, advanced per record. */
+static unsigned char wal_ctr_counter[WAL_CTR_IV_LEN];
+static bool			 wal_ctr_counter_seeded = false;
+static Size			 wal_ctr_enc_bytes = 0;	/* body bytes in current record */
+
+#ifndef FRONTEND
+static bool wal_ctr_atexit_registered = false;
+#endif
+
+/*
+ * Big-endian 128-bit counter add.  Advances wal_ctr_counter past the
+ * keystream a record consumed.  Matches the increment scheme that OpenSSL's
+ * EVP_aes_256_ctr() uses on its 16-byte IV.
+ */
+static inline void
+wal_ctr_counter_add(unsigned char ctr[WAL_CTR_IV_LEN], uint64 nblocks)
+{
+	int		i;
+	uint64	carry = nblocks;
+
+	for (i = WAL_CTR_IV_LEN - 1; i >= 0 && carry; i--)
+	{
+		uint64 sum = (uint64) ctr[i] + (carry & 0xff);
+
+		ctr[i] = (unsigned char) (sum & 0xff);
+		carry = (carry >> 8) + (sum >> 8);
+	}
+}
+
+#ifndef FRONTEND
+static void
+wal_ctr_atexit(int code, Datum arg)
+{
+	if (wal_ctr_enc_ctx)
+	{
+		EVP_CIPHER_CTX_free(wal_ctr_enc_ctx);
+		wal_ctr_enc_ctx = NULL;
+	}
+	if (wal_ctr_dec_ctx)
+	{
+		EVP_CIPHER_CTX_free(wal_ctr_dec_ctx);
+		wal_ctr_dec_ctx = NULL;
+	}
+	wal_ctr_counter_seeded = false;
+}
+#endif
+
+/*
+ * Allocate both EVP contexts and load the AES key once.  Idempotent and safe
+ * to call from every WAL hot path.  Loading the key here (with a NULL IV)
+ * means the per-record Begin/Decrypt calls only reset the IV and never rerun
+ * the AES-256 key schedule.
+ */
+void
+WalCtrInit(void)
+{
+	if (wal_ctr_enc_ctx == NULL)
+	{
+		wal_ctr_enc_ctx = EVP_CIPHER_CTX_new();
+		if (wal_ctr_enc_ctx == NULL)
+			WAL_CTR_FATAL("EVP_CIPHER_CTX_new failed");
+		if (EVP_EncryptInit_ex2(wal_ctr_enc_ctx, EVP_aes_256_ctr(),
+								WalCtrKey, NULL, NULL) != 1)
+			WAL_CTR_FATAL("EVP_EncryptInit_ex2 failed (key load)");
+	}
+
+	if (wal_ctr_dec_ctx == NULL)
+	{
+		wal_ctr_dec_ctx = EVP_CIPHER_CTX_new();
+		if (wal_ctr_dec_ctx == NULL)
+			WAL_CTR_FATAL("EVP_CIPHER_CTX_new failed");
+		if (EVP_DecryptInit_ex2(wal_ctr_dec_ctx, EVP_aes_256_ctr(),
+								WalCtrKey, NULL, NULL) != 1)
+			WAL_CTR_FATAL("EVP_DecryptInit_ex2 failed (key load)");
+	}
+
+#ifndef FRONTEND
+	if (!wal_ctr_atexit_registered)
+	{
+		before_shmem_exit(wal_ctr_atexit, 0);
+		wal_ctr_atexit_registered = true;
+	}
+#endif
+}
+
+void
+WalCtrLogVariantAtStartup(void)
+{
+#ifndef FRONTEND
+	ereport(LOG,
+			(errmsg("WAL CTR prototype: AES-256-CTR, per-record counter IV")));
+#endif
+}
+
+void
+WalCtrEncryptBegin(char *iv_out)
+{
+	WalCtrInit();
+
+	if (!wal_ctr_counter_seeded)
+	{
+		if (RAND_bytes(wal_ctr_counter, WAL_CTR_IV_LEN) != 1)
+			WAL_CTR_FATAL("RAND_bytes failed for WAL CTR seed");
+		wal_ctr_counter_seeded = true;
+	}
+
+	/* Record the starting counter block; decrypt is self-contained from it. */
+	memcpy(iv_out, wal_ctr_counter, WAL_CTR_IV_LEN);
+
+	/* IV-only reset: key stays loaded, no AES key schedule rebuild. */
+	if (EVP_EncryptInit_ex2(wal_ctr_enc_ctx, NULL, NULL,
+							wal_ctr_counter, NULL) != 1)
+		WAL_CTR_FATAL("EVP_EncryptInit_ex2 failed (iv reset)");
+
+	wal_ctr_enc_bytes = 0;
+}
+
+void
+WalCtrEncryptUpdate(const char *in, char *out, Size len)
+{
+	int		outlen;
+
+	if (len == 0)
+		return;
+
+	if (EVP_EncryptUpdate(wal_ctr_enc_ctx,
+						  (unsigned char *) out, &outlen,
+						  (const unsigned char *) in, (int) len) != 1)
+		WAL_CTR_FATAL("EVP_EncryptUpdate failed");
+
+	wal_ctr_enc_bytes += len;
+}
+
+void
+WalCtrEncryptFinal(void)
+{
+	unsigned char	tmp[WAL_CTR_IV_LEN];
+	int				outlen;
+
+	/* CTR produces no trailing bytes, but the API requires the call. */
+	if (EVP_EncryptFinal_ex(wal_ctr_enc_ctx, tmp, &outlen) != 1)
+		WAL_CTR_FATAL("EVP_EncryptFinal_ex failed");
+
+	/* Advance the counter past every 16-byte block this record consumed. */
+	wal_ctr_counter_add(wal_ctr_counter, (wal_ctr_enc_bytes + 15) / 16);
+}
+
+void
+WalCtrEncryptRecord(const char *plain,
+					char *cipher,
+					Size body_len,
+					char *iv_out)
+{
+	Assert(body_len > 0);
+
+	WalCtrEncryptBegin(iv_out);
+	WalCtrEncryptUpdate(plain, cipher, body_len);
+	WalCtrEncryptFinal();
+}
+
+void
+WalCtrDecryptRecord(char *cipher_in_plain_out,
+					Size body_len,
+					const char *iv)
+{
+	int		outlen;
+
+	WalCtrInit();
+
+	/* IV-only reset: key stays loaded, no AES key schedule rebuild. */
+	if (EVP_DecryptInit_ex2(wal_ctr_dec_ctx, NULL, NULL,
+							(const unsigned char *) iv, NULL) != 1)
+		WAL_CTR_FATAL("EVP_DecryptInit_ex2 failed");
+
+	if (EVP_DecryptUpdate(wal_ctr_dec_ctx,
+						  (unsigned char *) cipher_in_plain_out, &outlen,
+						  (const unsigned char *) cipher_in_plain_out,
+						  (int) body_len) != 1)
+		WAL_CTR_FATAL("EVP_DecryptUpdate failed");
+
+	if (EVP_DecryptFinal_ex(wal_ctr_dec_ctx,
+							(unsigned char *) cipher_in_plain_out + outlen,
+							&outlen) != 1)
+		WAL_CTR_FATAL("EVP_DecryptFinal_ex failed");
+}
diff --git a/src/common/wal_ctr_key.c b/src/common/wal_ctr_key.c
new file mode 100644
index 00000000000..c8ebee8c4be
--- /dev/null
+++ b/src/common/wal_ctr_key.c
@@ -0,0 +1,27 @@
+/*-------------------------------------------------------------------------
+ *
+ * wal_ctr_key.c
+ *	  Hardcoded AES-256 key for the WAL CTR perf prototype.
+ *
+ * NEVER ship this.  Throwaway perf prototype only.
+ *
+ * IDENTIFICATION
+ *	  src/common/wal_ctr_key.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FRONTEND
+#include "postgres.h"
+#else
+#include "postgres_fe.h"
+#endif
+
+#include "common/wal_ctr.h"
+
+const unsigned char WalCtrKey[WAL_CTR_KEY_LEN] = {
+	0x43, 0x54, 0x52, 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, 0x75, 0x73, 0x65, 0x21,
+};
diff --git a/src/include/access/xlogrecord.h b/src/include/access/xlogrecord.h
index e8999d3fe91..6dbe6622e1a 100644
--- a/src/include/access/xlogrecord.h
+++ b/src/include/access/xlogrecord.h
@@ -90,6 +90,15 @@ typedef struct XLogRecord
  */
 #define XLR_CHECK_CONSISTENCY	0x02
 
+/*
+ * Marks a WAL record whose body has been encrypted by the per-record WAL
+ * CTR prototype.  Set by XLogEncryptRecordBody and the bootstrap/pg_resetwal
+ * inline encrypt blocks when the body is non-empty.  Recovery decrypts the
+ * body in XLogReadRecord before rmgr redo only when this bit is set.  Lives
+ * in the 0x0F window (XLR_INFO_MASK) reserved for XLogInsert-internal flags.
+ */
+#define XLR_ENCRYPTED			0x04
+
 /*
  * Header info for block data appended to an XLOG record.
  *
diff --git a/src/include/common/wal_ctr.h b/src/include/common/wal_ctr.h
new file mode 100644
index 00000000000..2fb29dabeee
--- /dev/null
+++ b/src/include/common/wal_ctr.h
@@ -0,0 +1,61 @@
+/*-------------------------------------------------------------------------
+ *
+ * wal_ctr.h
+ *	  Throwaway per-record WAL AES-256-CTR encryption prototype.
+ *
+ * Per-record IVs are drawn from a per-backend counter: one RAND_bytes seed
+ * per backend, advanced by the number of 16-byte blocks each record
+ * consumes.  The starting counter block is stored on disk with the record,
+ * so every record decrypts independently.
+ *-------------------------------------------------------------------------
+ */
+#ifndef WAL_CTR_H
+#define WAL_CTR_H
+
+#include "access/xlogrecord.h"
+
+#define WAL_CTR_KEY_LEN  32			/* AES-256 */
+#define WAL_CTR_IV_LEN   16			/* AES block size */
+#define WAL_CTR_OVERHEAD WAL_CTR_IV_LEN
+
+extern const unsigned char WalCtrKey[WAL_CTR_KEY_LEN];
+
+/*
+ * Streaming encrypt API.  WalCtrEncryptBegin() captures the IV / counter
+ * block for the record into `iv_out` and resets the (key-loaded) EVP context
+ * to that IV.  WalCtrEncryptUpdate() may be called any number of times to
+ * encrypt consecutive body segments (in == out is allowed); the CTR keystream
+ * advances across calls.  WalCtrEncryptFinal() closes the record and advances
+ * the per-backend counter past the keystream just consumed.
+ */
+extern void WalCtrEncryptBegin(char *iv_out);
+extern void WalCtrEncryptUpdate(const char *in, char *out, Size len);
+extern void WalCtrEncryptFinal(void);
+
+/*
+ * Convenience wrapper for callers with a single contiguous body buffer
+ * (in == out allowed).  Equivalent to Begin + one Update + Final.  Caller is
+ * responsible for skipping the call when body_len == 0.
+ */
+extern void WalCtrEncryptRecord(const char *plain,
+								char *cipher,
+								Size body_len,
+								char *iv_out);
+
+/*
+ * Decrypt `body_len` ciphertext bytes in place (out == in allowed) using the
+ * 16-byte `iv` as the AES-CTR initial counter block.
+ */
+extern void WalCtrDecryptRecord(char *cipher_in_plain_out,
+								Size body_len,
+								const char *iv);
+
+/*
+ * Lifecycle.  WalCtrInit() is idempotent; safe to call from every WAL hot
+ * path.  An internal atexit handler is registered via before_shmem_exit
+ * on first init to free per-backend EVP contexts at shutdown.
+ */
+extern void WalCtrInit(void);
+extern void WalCtrLogVariantAtStartup(void);
+
+#endif							/* WAL_CTR_H */
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 0a74ab5c86f..59bbe50d16c 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -54,6 +54,7 @@ SUBDIRS = \
 		  test_slru \
 		  test_tidstore \
 		  unsafe_tests \
+		  wal_ctr_check \
 		  worker_spi \
 		  xid_wraparound
 
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..c92c7ee3bd6 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_ctr_check')
 subdir('worker_spi')
 subdir('xid_wraparound')
diff --git a/src/test/modules/wal_ctr_check/Makefile b/src/test/modules/wal_ctr_check/Makefile
new file mode 100644
index 00000000000..07f0d7f2ca7
--- /dev/null
+++ b/src/test/modules/wal_ctr_check/Makefile
@@ -0,0 +1,18 @@
+# src/test/modules/wal_ctr_check/Makefile
+
+TAP_TESTS = 1
+
+# Requires a cluster built with one of the per-record CTR variants,
+# 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_ctr_check
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/wal_ctr_check/meson.build b/src/test/modules/wal_ctr_check/meson.build
new file mode 100644
index 00000000000..8d78334dcde
--- /dev/null
+++ b/src/test/modules/wal_ctr_check/meson.build
@@ -0,0 +1,15 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+tests += {
+  'name': 'wal_ctr_check',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_wal_is_encrypted.pl',
+    ],
+    # Requires a cluster built with one of the per-record CTR variants,
+    # which is a compile-time choice, so skip during installcheck.
+    'runningcheck': false,
+  },
+}
diff --git a/src/test/modules/wal_ctr_check/t/001_wal_is_encrypted.pl b/src/test/modules/wal_ctr_check/t/001_wal_is_encrypted.pl
new file mode 100644
index 00000000000..63fb2eb2021
--- /dev/null
+++ b/src/test/modules/wal_ctr_check/t/001_wal_is_encrypted.pl
@@ -0,0 +1,50 @@
+# Verify on-disk WAL contains no plaintext for the WAL CTR perf
+# prototype.  Inserts a 50-byte ASCII canary, flushes + switches
+# WAL, stops the server, greps every pg_wal/* segment for the
+# canary (must find zero occurrences), then restarts and confirms
+# the row reads back (i.e. the decrypt path round-trips through
+# crash recovery).
+
+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)
+{
+    next unless -f $f;
+    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

