diff --git a/contrib/Makefile b/contrib/Makefile index 7d91fe77db399..46b80071f2acd 100644 --- a/contrib/Makefile +++ b/contrib/Makefile @@ -56,9 +56,17 @@ SUBDIRS = \ vacuumlo ifeq ($(with_ssl),openssl) -SUBDIRS += pgcrypto sslinfo +SUBDIRS += \ + basic_file_encryption \ + pgcrypto \ + sm4_file_encryption \ + sslinfo else -ALWAYS_SUBDIRS += pgcrypto sslinfo +ALWAYS_SUBDIRS += \ + basic_file_encryption \ + pgcrypto \ + sm4_file_encryption \ + sslinfo endif ifneq ($(with_uuid),no) diff --git a/contrib/amcheck/verify_nbtree.c b/contrib/amcheck/verify_nbtree.c index b74ab5f7a057a..cbf843502b432 100644 --- a/contrib/amcheck/verify_nbtree.c +++ b/contrib/amcheck/verify_nbtree.c @@ -1461,7 +1461,7 @@ bt_target_page_check(BtreeCheckState *state) */ lowersizelimit = skey->heapkeyspace && (P_ISLEAF(topaque) || BTreeTupleGetHeapTID(itup) == NULL); - if (tupsize > (lowersizelimit ? BTMaxItemSize : BTMaxItemSizeNoHeapTid)) + if (tupsize > (lowersizelimit ? BTMaxItemSizeForCluster() : BTMaxItemSizeNoHeapTidForCluster())) { ItemPointer tid = BTreeTupleGetPointsToTID(itup); char *itid, diff --git a/contrib/basic_file_encryption/.gitignore b/contrib/basic_file_encryption/.gitignore new file mode 100644 index 0000000000000..5dcb3ff972350 --- /dev/null +++ b/contrib/basic_file_encryption/.gitignore @@ -0,0 +1,4 @@ +# Generated subdirectories +/log/ +/results/ +/tmp_check/ diff --git a/contrib/basic_file_encryption/Makefile b/contrib/basic_file_encryption/Makefile new file mode 100644 index 0000000000000..02b9ee9fcae4d --- /dev/null +++ b/contrib/basic_file_encryption/Makefile @@ -0,0 +1,24 @@ +# contrib/basic_file_encryption/Makefile + +MODULE_big = basic_file_encryption +OBJS = \ + $(WIN32RES) \ + basic_file_encryption.o +PGFILEDESC = "basic_file_encryption - reference file encryption module" + +NO_INSTALLCHECK = 1 +TAP_TESTS = 1 + +# Link against libcrypto for AES-GCM support. +SHLIB_LINK += $(filter -lcrypto, $(LIBS)) + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = contrib/basic_file_encryption +top_builddir = ../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/contrib/basic_file_encryption/basic_file_encryption.c b/contrib/basic_file_encryption/basic_file_encryption.c new file mode 100644 index 0000000000000..3d55181e4c2c7 --- /dev/null +++ b/contrib/basic_file_encryption/basic_file_encryption.c @@ -0,0 +1,972 @@ +/*------------------------------------------------------------------------- + * + * basic_file_encryption.c + * Reference implementation of a file encryption module. + * + * This module is a demonstration and test target, not a production tool. + * It accepts a 256-bit AES key-encryption key (KEK) directly as 64 hex + * characters in the config string, which keeps the test setup trivial but + * is not how a production-grade module should source its keys. Real + * modules would treat the config string as an opaque reference (e.g. a + * KMS URI, a vault path, a file name) and fetch the actual key from an + * external key store at startup_cb time. + * + * The KEK never encrypts user bytes directly; it only wraps data- + * encryption keys (DEKs). + * + * Two encryption flows share the same KEK: + * + * * Record-stream encryption (BufFile, reorderbuffer spill files) uses a + * fresh DEK per call. The DEK is wrapped under the KEK and stored in + * the per-record trailer. Per-call overhead: 96 bytes. + * + * * Per-relation page encryption uses one DEK per RelFileLocator, + * generated at relation-create time and wrapped under the KEK into + * the relation's KEY fork. Each page's trailer holds only the + * per-page IV, tag, and a format marker. Per-page overhead: 32 bytes. + * + * AAD bindings: + * + * * Record-stream encrypt/decrypt binds basename(path) || file_offset(be64). + * Using only the basename keeps CREATE DATABASE FILE_COPY and ALTER + * DATABASE SET TABLESPACE working — both clone files into directories + * whose leading components change for the same on-disk bytes. + * + * * Object-key wrap (DEK ciphertext stored in the KEY fork) binds + * relNumber(be32). Only the relfilenode is bound, so the same + * restrictions on CROSS-database operations apply: a KEY blob is + * valid for any relation with that relfilenode. Within a database + * the relfilenode is unique enough to detect substitution. + * + * * Page encrypt/decrypt binds fork(be32) || blocknum(be32). The + * per-relation DEK already pins which relation we're decrypting; the + * AAD also distinguishes MAIN block N from INIT block N so neither can + * be substituted for the other on disk. On unlogged-relation reset, + * reinit re-encrypts INIT bytes under MAIN's AAD rather than raw-copying + * the ciphertext. + * + * Authentication-tag verification on decrypt detects tampering and key + * mismatch. Errors are surfaced to the host via the *errmsg out-param; + * the module never calls ereport or exit directly, which keeps the same + * .so loadable from both backend and libpgcommon-based frontend tools. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * contrib/basic_file_encryption/basic_file_encryption.c + * + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include +#include + +#include "common/file_encryption_module.h" +#include "fmgr.h" +#include "port.h" + +PG_MODULE_MAGIC; + +/* + * Tiny hex decoder. The backend's utils/adt/encode.c hex_decode lives + * outside libpgcommon, so a dual-loadable .so can't reach it from a + * frontend host. Returns the number of bytes written to dst, or + * (size_t) -1 on a bad input character. + */ +static inline int +bfe_hex_nibble(unsigned char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +static size_t +bfe_hex_decode(const char *src, size_t len, unsigned char *dst) +{ + size_t i; + + if (len % 2 != 0) + return (size_t) -1; + for (i = 0; i < len / 2; i++) + { + int hi = bfe_hex_nibble((unsigned char) src[i * 2]); + int lo = bfe_hex_nibble((unsigned char) src[i * 2 + 1]); + + if (hi < 0 || lo < 0) + return (size_t) -1; + dst[i] = (unsigned char) ((hi << 4) | lo); + } + return len / 2; +} + +#define BFE_KEY_LEN 32 +#define BFE_IV_LEN 12 +#define BFE_TAG_LEN 16 +#define BFE_FORMAT_LEN sizeof(uint32) +#define BFE_FORMAT_MAGIC 0x31454642 /* "BFE1" - record-stream format */ +#define BFE_PAGE_FORMAT_MAGIC 0x50454642 /* "BFEP" - page format */ +#define BFE_OBJ_FORMAT_MAGIC 0x4F454642 /* "BFEO" - object-key wrap format */ + +/* + * Record-stream overhead: per-call DEK wrapped under the KEK alongside the + * record body. IV + tag + wrap IV + wrapped DEK + wrap tag + format + pad. + */ +#define BFE_OVERHEAD_SIZE 96 +#define BFE_PAD_SIZE (BFE_OVERHEAD_SIZE - \ + (2 * BFE_IV_LEN) - \ + (2 * BFE_TAG_LEN) - \ + BFE_KEY_LEN - \ + BFE_FORMAT_LEN) +StaticAssertDecl(BFE_PAD_SIZE == 4, "unexpected basic_file_encryption record padding"); + +/* + * Per-page overhead: the DEK comes from per-relation state, so the trailer + * only needs the per-page IV, tag, and format marker. + */ +#define BFE_PAGE_OVERHEAD_SIZE 32 +#define BFE_PAGE_PAD_SIZE (BFE_PAGE_OVERHEAD_SIZE - BFE_IV_LEN - BFE_TAG_LEN - BFE_FORMAT_LEN) +StaticAssertDecl(BFE_PAGE_PAD_SIZE == 0, "unexpected basic_file_encryption page padding"); + +/* + * Per-relation key-wrap blob written to KEY_FORKNUM block 0: + * + * [ IV 12B ][ wrapped DEK 32B ][ wrap tag 16B ][ format 4B ] + * + * Total 64 bytes. The core wraps this in its FEKeyBlockHeader; the module + * only sees the 64-byte payload. + */ +#define BFE_OBJ_WRAP_SIZE (BFE_IV_LEN + BFE_KEY_LEN + BFE_TAG_LEN + BFE_FORMAT_LEN) + +typedef struct BasicFileEncryptionState +{ + unsigned char kek[BFE_KEY_LEN]; +} BasicFileEncryptionState; + +/* Cached per-relation DEK, opaque to the core. */ +typedef struct BFEObjectState +{ + unsigned char dek[BFE_KEY_LEN]; +} BFEObjectState; + +/* + * Module-static KEK, populated by _PG_file_encryption_module_init from the + * supplied config string. Set once per postmaster (inherited by forked + * children via copy-on-write) and once per frontend tool invocation. + */ +static unsigned char bfe_kek_static[BFE_KEY_LEN]; +static bool bfe_kek_set = false; + +static bool bfe_startup(FileEncryptionModuleState *state, char **errmsg); +static void bfe_shutdown(FileEncryptionModuleState *state); +static bool bfe_encrypt(const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg); +static bool bfe_decrypt(const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg); +static bool bfe_generate_object_key(FileEncryptionModuleState *state, + const RelFileLocator *locator, + char *dst, Size dst_max, + Size *wrapped_len, char **errmsg); +static void *bfe_object_open(FileEncryptionModuleState *state, + const RelFileLocator *locator, + const char *wrapped, Size wrapped_len, + char **errmsg); +static void bfe_object_close(FileEncryptionModuleState *state, + void *object_state); +static bool bfe_encrypt_page(FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, char **errmsg); +static bool bfe_decrypt_page(FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, char **errmsg); + +static BasicFileEncryptionState *bfe_require_kek(const FileEncryptionModuleState *state, + char **errmsg); +static char *bfe_openssl_errstr(const char *op); + +typedef void (*BFEAadFn) (EVP_CIPHER_CTX *ctx, bool encrypting, + void *ctx_data, bool *aad_ok); + +static bool bfe_aes_gcm_encrypt(const unsigned char *key, + const unsigned char *iv, + BFEAadFn aad_fn, void *aad_ctx, + const unsigned char *data, int data_len, + unsigned char *out, + unsigned char *tag, + char **errmsg); +static bool bfe_aes_gcm_decrypt(const unsigned char *key, + const unsigned char *iv, + const unsigned char *tag, + BFEAadFn aad_fn, void *aad_ctx, + const unsigned char *data, int data_len, + unsigned char *out, + char **errmsg); + +static const FileEncryptionCallbacks basic_file_encryption_callbacks = { + PG_FILE_ENCRYPTION_MAGIC, + .overhead_size = BFE_OVERHEAD_SIZE, + .page_overhead_size = BFE_PAGE_OVERHEAD_SIZE, + + .startup_cb = bfe_startup, + .shutdown_cb = bfe_shutdown, + .encrypt_cb = bfe_encrypt, + .decrypt_cb = bfe_decrypt, + + .generate_object_key_cb = bfe_generate_object_key, + .object_open_cb = bfe_object_open, + .object_close_cb = bfe_object_close, + .encrypt_page_cb = bfe_encrypt_page, + .decrypt_page_cb = bfe_decrypt_page, +}; + +/* + * Module entry point. The config string is exactly 64 hex characters + * (the 256-bit KEK). Validate, decode into a module static, and hand + * back the callback table. Same signature for backend and frontend + * hosts. + */ +bool +_PG_file_encryption_module_init(const char *config, + const FileEncryptionCallbacks **callbacks_out, + char **errmsg) +{ + size_t decoded; + + if (config == NULL || config[0] == '\0') + { + *errmsg = pstrdup("basic_file_encryption: 'config' is empty; expected a 64-character hex key"); + return false; + } + + if (strlen(config) != BFE_KEY_LEN * 2) + { + *errmsg = psprintf("basic_file_encryption: 'config' must be %d hex characters (got %zu)", + BFE_KEY_LEN * 2, strlen(config)); + return false; + } + + for (const char *p = config; *p; p++) + { + if (!isxdigit((unsigned char) *p)) + { + *errmsg = pstrdup("basic_file_encryption: 'config' must contain only hexadecimal digits"); + return false; + } + } + + decoded = bfe_hex_decode(config, strlen(config), bfe_kek_static); + if (decoded != BFE_KEY_LEN) + { + *errmsg = psprintf("basic_file_encryption: hex decode of 'config' produced %lu bytes, expected %d", + (unsigned long) decoded, BFE_KEY_LEN); + return false; + } + bfe_kek_set = true; + + *callbacks_out = &basic_file_encryption_callbacks; + return true; +} + +/* + * Copy the module-static KEK into a per-process state struct. The static + * is populated by _PG_file_encryption_module_init; if the host loaded us + * without going through that entry point, fail fast with an errmsg. + */ +static bool +bfe_startup(FileEncryptionModuleState *state, char **errmsg) +{ + BasicFileEncryptionState *priv; + + if (!bfe_kek_set) + { + *errmsg = pstrdup("basic_file_encryption: module was loaded without a valid configuration"); + return false; + } + + priv = palloc0_object(BasicFileEncryptionState); + memcpy(priv->kek, bfe_kek_static, BFE_KEY_LEN); + state->private_data = priv; + return true; +} + +static void +bfe_shutdown(FileEncryptionModuleState *state) +{ + BasicFileEncryptionState *priv = state->private_data; + + if (priv != NULL) + { + explicit_bzero(priv->kek, sizeof(priv->kek)); + pfree(priv); + state->private_data = NULL; + } +} + +/* + * Resolve the per-process key-encryption key. Returns NULL and sets + * *errmsg if the module was loaded without a valid configuration. + */ +static BasicFileEncryptionState * +bfe_require_kek(const FileEncryptionModuleState *state, char **errmsg) +{ + BasicFileEncryptionState *priv = state->private_data; + + if (priv == NULL) + { + *errmsg = pstrdup("basic_file_encryption: module was loaded without a valid configuration"); + return NULL; + } + return priv; +} + +/* + * Format the latest OpenSSL error into a palloc'd string of the form + * " failed: ", suitable for the caller's *errmsg. + */ +static char * +bfe_openssl_errstr(const char *op) +{ + unsigned long e = ERR_get_error(); + char buf[256]; + + if (e == 0) + return psprintf("basic_file_encryption: %s failed", op); + + ERR_error_string_n(e, buf, sizeof(buf)); + return psprintf("basic_file_encryption: %s failed: %s", op, buf); +} + +/* + * AAD-update callbacks. Each flow has its own AAD shape; using a single + * function-pointer entry point keeps bfe_aes_gcm_{encrypt,decrypt} agnostic + * to the caller. *aad_ok is set to false on OpenSSL update failure; the + * caller surfaces the OpenSSL error itself. + */ +typedef struct RecordAadCtx +{ + const char *path; + uint64 file_offset; +} RecordAadCtx; + +typedef struct ObjectAadCtx +{ + const RelFileLocator *locator; +} ObjectAadCtx; + +typedef struct PageAadCtx +{ + ForkNumber fork; + BlockNumber blocknum; +} PageAadCtx; + +/* + * The AAD is basename(path) || file_offset(be64). Using only the basename + * keeps CREATE DATABASE FILE_COPY and ALTER DATABASE SET TABLESPACE + * round-trips working: both clone temp files into directories whose leading + * components differ even though the basename is preserved. + * + * Tradeoff: two record-stream files that share a basename in different + * directories produce AAD-equivalent ciphertexts. An attacker with disk + * write access to both could shuffle records between them undetected at + * decrypt time. Within a single PGDATA the temp file naming (e.g. the + * BufFile per-PID counters in fd.c, the per-XID spill names in + * reorderbuffer.c) makes that hard to engineer in practice, but operators + * who need the stronger guarantee should pick a module that binds the full + * path. + */ +static void +bfe_aad_update_record(EVP_CIPHER_CTX *ctx, bool encrypting, + void *ctx_data, bool *aad_ok) +{ + RecordAadCtx *c = ctx_data; + int outlen; + const char *basename = strrchr(c->path, '/'); + unsigned char offset_be[8]; + int (*update) (EVP_CIPHER_CTX *, unsigned char *, int *, + const unsigned char *, int); + + update = encrypting ? EVP_EncryptUpdate : EVP_DecryptUpdate; + + basename = basename ? basename + 1 : c->path; + if (update(ctx, NULL, &outlen, + (const unsigned char *) basename, (int) strlen(basename)) != 1) + { + *aad_ok = false; + return; + } + + for (int i = 0; i < 8; i++) + offset_be[i] = (unsigned char) (c->file_offset >> ((7 - i) * 8)); + if (update(ctx, NULL, &outlen, offset_be, 8) != 1) + *aad_ok = false; +} + +static void +bfe_aad_update_object(EVP_CIPHER_CTX *ctx, bool encrypting, + void *ctx_data, bool *aad_ok) +{ + ObjectAadCtx *c = ctx_data; + int outlen; + unsigned char buf[4]; + int (*update) (EVP_CIPHER_CTX *, unsigned char *, int *, + const unsigned char *, int); + + update = encrypting ? EVP_EncryptUpdate : EVP_DecryptUpdate; + + for (int i = 0; i < 4; i++) + buf[i] = (unsigned char) (c->locator->relNumber >> ((3 - i) * 8)); + if (update(ctx, NULL, &outlen, buf, 4) != 1) + *aad_ok = false; +} + +/* + * Bind fork(be32) || blocknum(be32). Including the fork distinguishes + * MAIN block N from INIT block N so an attacker with disk write access + * cannot swap one for the other. reinit re-encrypts INIT bytes under + * MAIN's AAD when copying INIT into MAIN on unlogged-relation reset -- + * see ResetUnloggedRelationsInDbspaceDir() in storage/file/reinit.c. + */ +static void +bfe_aad_update_page(EVP_CIPHER_CTX *ctx, bool encrypting, + void *ctx_data, bool *aad_ok) +{ + PageAadCtx *c = ctx_data; + int outlen; + unsigned char buf[8]; + int (*update) (EVP_CIPHER_CTX *, unsigned char *, int *, + const unsigned char *, int); + uint32 fork_be = (uint32) c->fork; + + update = encrypting ? EVP_EncryptUpdate : EVP_DecryptUpdate; + + for (int i = 0; i < 4; i++) + buf[i] = (unsigned char) (fork_be >> ((3 - i) * 8)); + for (int i = 0; i < 4; i++) + buf[4 + i] = (unsigned char) (c->blocknum >> ((3 - i) * 8)); + if (update(ctx, NULL, &outlen, buf, 8) != 1) + *aad_ok = false; +} + +/* + * Encrypt data_len bytes with the supplied AES-256-GCM key and IV. The + * ciphertext is written to 'out' (data_len bytes); 'tag' receives the + * 16-byte authentication tag. Returns true on success; on failure + * cleans up the OpenSSL context, writes an error to *errmsg, and + * returns false. + */ +static bool +bfe_aes_gcm_encrypt(const unsigned char *key, + const unsigned char *iv, + BFEAadFn aad_fn, void *aad_ctx, + const unsigned char *data, int data_len, + unsigned char *out, + unsigned char *tag, + char **errmsg) +{ + EVP_CIPHER_CTX *ctx; + int outlen; + int finallen; + bool aad_ok = true; + + ctx = EVP_CIPHER_CTX_new(); + if (ctx == NULL) + { + *errmsg = bfe_openssl_errstr("EVP_CIPHER_CTX_new"); + return false; + } + +#define FAIL(op) do { \ + *errmsg = bfe_openssl_errstr(op); \ + EVP_CIPHER_CTX_free(ctx); \ + return false; \ + } while (0) + + if (EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL) != 1) + FAIL("EVP_EncryptInit_ex"); + if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, BFE_IV_LEN, NULL) != 1) + FAIL("EVP_CTRL_GCM_SET_IVLEN"); + if (EVP_EncryptInit_ex(ctx, NULL, NULL, key, iv) != 1) + FAIL("EVP_EncryptInit_ex (key/iv)"); + + if (aad_fn != NULL) + { + aad_fn(ctx, true, aad_ctx, &aad_ok); + if (!aad_ok) + FAIL("AAD update"); + } + + if (EVP_EncryptUpdate(ctx, out, &outlen, data, data_len) != 1) + FAIL("EVP_EncryptUpdate"); + if (outlen != data_len) + { + *errmsg = psprintf("basic_file_encryption: EVP_EncryptUpdate produced %d bytes, expected %d", + outlen, data_len); + EVP_CIPHER_CTX_free(ctx); + return false; + } + + if (EVP_EncryptFinal_ex(ctx, out + outlen, &finallen) != 1) + FAIL("EVP_EncryptFinal_ex"); + if (finallen != 0) + { + *errmsg = psprintf("basic_file_encryption: EVP_EncryptFinal_ex produced %d trailing bytes, expected 0", + finallen); + EVP_CIPHER_CTX_free(ctx); + return false; + } + + if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, BFE_TAG_LEN, tag) != 1) + FAIL("EVP_CTRL_GCM_GET_TAG"); + +#undef FAIL + + EVP_CIPHER_CTX_free(ctx); + return true; +} + +/* + * Decrypt data_len bytes with the supplied AES-256-GCM key, IV, and + * expected tag. Plaintext is written to 'out'. Returns false with + * *errmsg set on tag-verification failure or any other error. + */ +static bool +bfe_aes_gcm_decrypt(const unsigned char *key, + const unsigned char *iv, + const unsigned char *tag, + BFEAadFn aad_fn, void *aad_ctx, + const unsigned char *data, int data_len, + unsigned char *out, + char **errmsg) +{ + EVP_CIPHER_CTX *ctx; + int outlen; + int finallen; + bool aad_ok = true; + + ctx = EVP_CIPHER_CTX_new(); + if (ctx == NULL) + { + *errmsg = bfe_openssl_errstr("EVP_CIPHER_CTX_new"); + return false; + } + +#define FAIL(op) do { \ + *errmsg = bfe_openssl_errstr(op); \ + EVP_CIPHER_CTX_free(ctx); \ + return false; \ + } while (0) + + if (EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL) != 1) + FAIL("EVP_DecryptInit_ex"); + if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, BFE_IV_LEN, NULL) != 1) + FAIL("EVP_CTRL_GCM_SET_IVLEN"); + if (EVP_DecryptInit_ex(ctx, NULL, NULL, key, iv) != 1) + FAIL("EVP_DecryptInit_ex (key/iv)"); + + if (aad_fn != NULL) + { + aad_fn(ctx, false, aad_ctx, &aad_ok); + if (!aad_ok) + FAIL("AAD update"); + } + + if (EVP_DecryptUpdate(ctx, out, &outlen, data, data_len) != 1) + FAIL("EVP_DecryptUpdate"); + if (outlen != data_len) + { + *errmsg = psprintf("basic_file_encryption: EVP_DecryptUpdate produced %d bytes, expected %d", + outlen, data_len); + EVP_CIPHER_CTX_free(ctx); + return false; + } + + if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, + BFE_TAG_LEN, (unsigned char *) tag) != 1) + FAIL("EVP_CTRL_GCM_SET_TAG"); + + if (EVP_DecryptFinal_ex(ctx, out + outlen, &finallen) != 1) + { + *errmsg = pstrdup("basic_file_encryption: authentication tag verification failed"); + EVP_CIPHER_CTX_free(ctx); + return false; + } + if (finallen != 0) + { + *errmsg = psprintf("basic_file_encryption: EVP_DecryptFinal_ex produced %d trailing bytes, expected 0", + finallen); + EVP_CIPHER_CTX_free(ctx); + return false; + } + +#undef FAIL + + EVP_CIPHER_CTX_free(ctx); + return true; +} + +/* + * ============================================================ + * Record-stream encryption (BufFile, reorderbuffer spill) + * ============================================================ + * + * Layout (caller plaintext: data_len bytes): + * + * dst[0 .. data_len) ciphertext + * dst[data_len .. data_len+12) data IV + * dst[data_len+12 .. data_len+28) data tag + * dst[data_len+28 .. data_len+40) wrap IV + * dst[data_len+40 .. data_len+72) wrapped data key + * dst[data_len+72 .. data_len+88) wrap tag + * dst[data_len+88 .. data_len+92) format magic + * dst[data_len+92 .. data_len+96) zero padding + */ +static bool +bfe_encrypt(const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg) +{ + BasicFileEncryptionState *priv; + unsigned char data_key[BFE_KEY_LEN]; + unsigned char data_iv[BFE_IV_LEN]; + unsigned char data_tag[BFE_TAG_LEN]; + unsigned char wrap_iv[BFE_IV_LEN]; + unsigned char wrap_tag[BFE_TAG_LEN]; + uint32 format = BFE_FORMAT_MAGIC; + int body_len = (int) data_len; + RecordAadCtx aad = {.path = path,.file_offset = file_offset}; + char *p; + bool ok; + + priv = bfe_require_kek(state, errmsg); + if (priv == NULL) + return false; + + if (!pg_strong_random(data_key, sizeof(data_key)) || + !pg_strong_random(data_iv, sizeof(data_iv)) || + !pg_strong_random(wrap_iv, sizeof(wrap_iv))) + { + *errmsg = pstrdup("basic_file_encryption: could not generate encryption key material"); + return false; + } + + ok = bfe_aes_gcm_encrypt(data_key, data_iv, + bfe_aad_update_record, &aad, + (const unsigned char *) data, body_len, + (unsigned char *) dst, data_tag, + errmsg); + if (!ok) + { + explicit_bzero(data_key, sizeof(data_key)); + return false; + } + + p = dst + body_len; + memcpy(p, data_iv, BFE_IV_LEN); + p += BFE_IV_LEN; + memcpy(p, data_tag, BFE_TAG_LEN); + p += BFE_TAG_LEN; + memcpy(p, wrap_iv, BFE_IV_LEN); + p += BFE_IV_LEN; + + ok = bfe_aes_gcm_encrypt(priv->kek, wrap_iv, + bfe_aad_update_record, &aad, + data_key, BFE_KEY_LEN, + (unsigned char *) p, wrap_tag, + errmsg); + explicit_bzero(data_key, sizeof(data_key)); + if (!ok) + return false; + p += BFE_KEY_LEN; + + memcpy(p, wrap_tag, BFE_TAG_LEN); + p += BFE_TAG_LEN; + memcpy(p, &format, sizeof(format)); + p += sizeof(format); + memset(p, 0, BFE_PAD_SIZE); + + return true; +} + +static bool +bfe_decrypt(const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg) +{ + BasicFileEncryptionState *priv; + const unsigned char *data_iv; + const unsigned char *data_tag; + const unsigned char *wrap_iv; + const unsigned char *wrapped_key; + const unsigned char *wrap_tag; + unsigned char data_key[BFE_KEY_LEN]; + uint32 format; + int body_len; + RecordAadCtx aad = {.path = path,.file_offset = file_offset}; + + priv = bfe_require_kek(state, errmsg); + if (priv == NULL) + return false; + + if (data_len < BFE_OVERHEAD_SIZE) + { + *errmsg = psprintf("basic_file_encryption: encrypted blob is too short (%zu bytes)", + data_len); + return false; + } + + body_len = (int) (data_len - BFE_OVERHEAD_SIZE); + data_iv = (const unsigned char *) data + body_len; + data_tag = data_iv + BFE_IV_LEN; + wrap_iv = data_tag + BFE_TAG_LEN; + wrapped_key = wrap_iv + BFE_IV_LEN; + wrap_tag = wrapped_key + BFE_KEY_LEN; + memcpy(&format, wrap_tag + BFE_TAG_LEN, sizeof(format)); + + if (format != BFE_FORMAT_MAGIC) + { + *errmsg = psprintf("basic_file_encryption: encrypted blob has unrecognized format 0x%08x", + format); + return false; + } + + if (!bfe_aes_gcm_decrypt(priv->kek, wrap_iv, wrap_tag, + bfe_aad_update_record, &aad, + wrapped_key, BFE_KEY_LEN, data_key, + errmsg)) + return false; + + if (!bfe_aes_gcm_decrypt(data_key, data_iv, data_tag, + bfe_aad_update_record, &aad, + (const unsigned char *) data, body_len, + (unsigned char *) dst, + errmsg)) + { + explicit_bzero(data_key, sizeof(data_key)); + return false; + } + explicit_bzero(data_key, sizeof(data_key)); + return true; +} + +/* + * ============================================================ + * Per-relation page encryption + * ============================================================ + * + * Object-key wrap layout (64 bytes, written to KEY fork block payload): + * + * wrapped[0 .. 12) wrap IV + * wrapped[12 .. 44) wrapped DEK + * wrapped[44 .. 60) wrap tag + * wrapped[60 .. 64) format magic + */ +static bool +bfe_generate_object_key(FileEncryptionModuleState *state, + const RelFileLocator *locator, + char *dst, Size dst_max, + Size *wrapped_len, char **errmsg) +{ + BasicFileEncryptionState *priv; + unsigned char dek[BFE_KEY_LEN]; + unsigned char wrap_iv[BFE_IV_LEN]; + unsigned char wrap_tag[BFE_TAG_LEN]; + uint32 format = BFE_OBJ_FORMAT_MAGIC; + ObjectAadCtx aad = {.locator = locator}; + char *p; + bool ok; + + priv = bfe_require_kek(state, errmsg); + if (priv == NULL) + return false; + + if (dst_max < BFE_OBJ_WRAP_SIZE) + { + *errmsg = psprintf("basic_file_encryption: wrapped-key buffer is %zu bytes, need %zu", + dst_max, (Size) BFE_OBJ_WRAP_SIZE); + return false; + } + + if (!pg_strong_random(dek, sizeof(dek)) || + !pg_strong_random(wrap_iv, sizeof(wrap_iv))) + { + *errmsg = pstrdup("basic_file_encryption: could not generate object key material"); + return false; + } + + p = dst; + memcpy(p, wrap_iv, BFE_IV_LEN); + p += BFE_IV_LEN; + + ok = bfe_aes_gcm_encrypt(priv->kek, wrap_iv, + bfe_aad_update_object, &aad, + dek, BFE_KEY_LEN, + (unsigned char *) p, wrap_tag, + errmsg); + explicit_bzero(dek, sizeof(dek)); + if (!ok) + return false; + p += BFE_KEY_LEN; + + memcpy(p, wrap_tag, BFE_TAG_LEN); + p += BFE_TAG_LEN; + memcpy(p, &format, sizeof(format)); + + *wrapped_len = BFE_OBJ_WRAP_SIZE; + return true; +} + +static void * +bfe_object_open(FileEncryptionModuleState *state, + const RelFileLocator *locator, + const char *wrapped, Size wrapped_len, + char **errmsg) +{ + BasicFileEncryptionState *priv; + BFEObjectState *obj; + const unsigned char *wrap_iv; + const unsigned char *wrapped_dek; + const unsigned char *wrap_tag; + uint32 format; + ObjectAadCtx aad = {.locator = locator}; + + priv = bfe_require_kek(state, errmsg); + if (priv == NULL) + return NULL; + + if (wrapped_len != BFE_OBJ_WRAP_SIZE) + { + *errmsg = psprintf("basic_file_encryption: wrapped object key has unexpected length %zu (want %zu)", + wrapped_len, (Size) BFE_OBJ_WRAP_SIZE); + return NULL; + } + + wrap_iv = (const unsigned char *) wrapped; + wrapped_dek = wrap_iv + BFE_IV_LEN; + wrap_tag = wrapped_dek + BFE_KEY_LEN; + memcpy(&format, wrap_tag + BFE_TAG_LEN, sizeof(format)); + + if (format != BFE_OBJ_FORMAT_MAGIC) + { + *errmsg = psprintf("basic_file_encryption: wrapped object key has unrecognized format 0x%08x", + format); + return NULL; + } + + obj = palloc0_object(BFEObjectState); + + if (!bfe_aes_gcm_decrypt(priv->kek, wrap_iv, wrap_tag, + bfe_aad_update_object, &aad, + wrapped_dek, BFE_KEY_LEN, obj->dek, + errmsg)) + { + explicit_bzero(obj->dek, sizeof(obj->dek)); + pfree(obj); + return NULL; + } + + return obj; +} + +static void +bfe_object_close(FileEncryptionModuleState *state, void *object_state) +{ + BFEObjectState *obj = object_state; + + if (obj == NULL) + return; + explicit_bzero(obj->dek, sizeof(obj->dek)); + pfree(obj); +} + +/* + * Page layout (BLCKSZ bytes): + * + * dst[0 .. BLCKSZ - 32) ciphertext (body) + * dst[BLCKSZ - 32 .. BLCKSZ - 20) data IV (12B) + * dst[BLCKSZ - 20 .. BLCKSZ - 4) data tag (16B) + * dst[BLCKSZ - 4 .. BLCKSZ) format magic (4B) + */ +static bool +bfe_encrypt_page(FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, + char **errmsg) +{ + BFEObjectState *obj = object_state; + unsigned char data_iv[BFE_IV_LEN]; + unsigned char data_tag[BFE_TAG_LEN]; + uint32 format = BFE_PAGE_FORMAT_MAGIC; + int body_len = BLCKSZ - BFE_PAGE_OVERHEAD_SIZE; + PageAadCtx aad = {.fork = fork,.blocknum = blocknum}; + + if (!pg_strong_random(data_iv, sizeof(data_iv))) + { + *errmsg = pstrdup("basic_file_encryption: could not generate page IV"); + return false; + } + + if (!bfe_aes_gcm_encrypt(obj->dek, data_iv, + bfe_aad_update_page, &aad, + (const unsigned char *) src, body_len, + (unsigned char *) dst, data_tag, + errmsg)) + return false; + + memcpy(dst + body_len, data_iv, BFE_IV_LEN); + memcpy(dst + body_len + BFE_IV_LEN, data_tag, BFE_TAG_LEN); + memcpy(dst + body_len + BFE_IV_LEN + BFE_TAG_LEN, &format, sizeof(format)); + return true; +} + +static bool +bfe_decrypt_page(FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, + char **errmsg) +{ + BFEObjectState *obj = object_state; + const unsigned char *data_iv; + const unsigned char *data_tag; + uint32 format; + int body_len = BLCKSZ - BFE_PAGE_OVERHEAD_SIZE; + PageAadCtx aad = {.fork = fork,.blocknum = blocknum}; + + data_iv = (const unsigned char *) src + body_len; + data_tag = data_iv + BFE_IV_LEN; + memcpy(&format, data_tag + BFE_TAG_LEN, sizeof(format)); + + if (format != BFE_PAGE_FORMAT_MAGIC) + { + *errmsg = psprintf("basic_file_encryption: encrypted page has unrecognized format 0x%08x", + format); + return false; + } + + if (!bfe_aes_gcm_decrypt(obj->dek, data_iv, data_tag, + bfe_aad_update_page, &aad, + (const unsigned char *) src, body_len, + (unsigned char *) dst, + errmsg)) + return false; + + /* Zero the plaintext trailer so pd_checksum verifies (the writer's + * trailer is zeros per PageInit, and the decrypt output must match). */ + memset(dst + body_len, 0, BFE_PAGE_OVERHEAD_SIZE); + return true; +} diff --git a/contrib/basic_file_encryption/meson.build b/contrib/basic_file_encryption/meson.build new file mode 100644 index 0000000000000..5f8085de1046c --- /dev/null +++ b/contrib/basic_file_encryption/meson.build @@ -0,0 +1,38 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +if not ssl.found() + subdir_done() +endif + +basic_file_encryption_sources = files( + 'basic_file_encryption.c', +) + +if host_system == 'windows' + basic_file_encryption_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'basic_file_encryption', + '--FILEDESC', 'basic_file_encryption - reference file encryption module',]) +endif + +basic_file_encryption = shared_module('basic_file_encryption', + basic_file_encryption_sources, + kwargs: contrib_mod_args + { + 'dependencies': [ssl, contrib_mod_args['dependencies']] + }, +) +contrib_targets += basic_file_encryption + +tests += { + 'name': 'basic_file_encryption', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'tap': { + 'tests': [ + 't/001_synthetic.pl', + 't/002_buffile.pl', + 't/003_logical_decoding.pl', + 't/004_relation_pages.pl', + 't/005_unlogged_and_backup.pl', + ], + }, +} diff --git a/contrib/basic_file_encryption/t/001_synthetic.pl b/contrib/basic_file_encryption/t/001_synthetic.pl new file mode 100644 index 0000000000000..389f15dfa89f4 --- /dev/null +++ b/contrib/basic_file_encryption/t/001_synthetic.pl @@ -0,0 +1,51 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Smoke test for basic_file_encryption: configure the module at initdb +# time, start the cluster, and verify that bootstrap + relation creation +# (which invokes the page-encryption callbacks for every catalog write) +# completes cleanly. Downstream commits add tests that exercise the +# BufFile, reorderbuffer, and relation-page paths against real workloads. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +sub random_key +{ + my @hex; + for (1 .. 64) + { + push @hex, sprintf("%x", int(rand(16))); + } + return join('', @hex); +} + +my $key = random_key(); + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init( + extra => [ + '--file-encryption-library=basic_file_encryption', + "--file-encryption-config=$key" + ]); +$node->start; + +# Cluster started, so initdb's bootstrap + post-bootstrap backends all +# successfully encrypted their writes and decrypted them back on the +# next read. As a tiny additional sanity check, create a table and +# round-trip a value through it -- that exercises the page-encryption +# callbacks against fresh on-disk content. +my $value = $node->safe_psql('postgres', q[ +CREATE TABLE smoke (id int, payload text); +INSERT INTO smoke VALUES (1, 'hello, encrypted world'); +CHECKPOINT; +SELECT payload FROM smoke WHERE id = 1; +]); +is($value, 'hello, encrypted world', + 'plaintext round-trips through page-encrypted heap'); + +$node->stop; +done_testing(); diff --git a/contrib/basic_file_encryption/t/002_buffile.pl b/contrib/basic_file_encryption/t/002_buffile.pl new file mode 100644 index 0000000000000..d7b50fb2b3be9 --- /dev/null +++ b/contrib/basic_file_encryption/t/002_buffile.pl @@ -0,0 +1,86 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Exercise encrypted BufFile via the sort and hash-join paths. We force +# work_mem low enough that a large SELECT must spill to temporary BufFiles, +# then verify that the round-trip through AES-256-GCM faithfully reproduces +# the original rows. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +sub random_key +{ + my @hex; + for (1 .. 64) + { + push @hex, sprintf("%x", int(rand(16))); + } + return join('', @hex); +} + +my $key = random_key(); + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(extra => ['--file-encryption-library=basic_file_encryption', + "--file-encryption-config=$key"]); +$node->append_conf( + 'postgresql.conf', qq( +work_mem = '64kB' +hash_mem_multiplier = 1.0 +)); +$node->start; + +# Create a table whose rows are self-verifying: payload is a deterministic +# function of id. Any byte-level corruption introduced by encryption will +# fail the equality check, regardless of which work_mem path was used. +$node->safe_psql('postgres', q[ +CREATE TABLE t (id int, payload text); +INSERT INTO t +SELECT g, repeat(md5(g::text), 4) +FROM generate_series(1, 50000) g; +]); + +# 1. ORDER BY that spills (work_mem = 64kB, payload is 128 bytes per row). +my $sort_ok = $node->safe_psql('postgres', q[ +WITH ordered AS (SELECT id, payload FROM t ORDER BY id) +SELECT count(*) = 50000 AND + bool_and(payload = repeat(md5(id::text), 4)) AND + (array_agg(id))[1:5] = ARRAY[1, 2, 3, 4, 5] +FROM (SELECT id, payload FROM ordered) s; +]); +is($sort_ok, 't', 'sort spilled and decrypted bytes match originals'); + +# 2. Hash join with low work_mem forces batching. Each batch lives in its +# own BufFile, so this exercises multiple encrypted BufFiles in one +# query. +my $hashjoin_ok = $node->safe_psql('postgres', q[ +SET enable_mergejoin = off; +SET enable_nestloop = off; +SELECT count(*) = 50000 AND + bool_and(a.payload = b.payload AND a.payload = repeat(md5(a.id::text), 4)) +FROM t a JOIN t b USING (id); +]); +is($hashjoin_ok, 't', 'hash join with batching round-tripped through encrypted BufFiles'); + +# 3. Scroll cursor exercises tuplestore + backwards seeks across spilled +# blocks. We jump well past the work_mem threshold, then back, and +# verify the same row reappears with intact bytes. +my $expected_payload = + $node->safe_psql('postgres', "SELECT repeat(md5('25000'), 4);"); +my $cursor_row = $node->safe_psql('postgres', q[ +BEGIN; +DECLARE c SCROLL CURSOR FOR SELECT id, payload FROM t ORDER BY id; +MOVE ABSOLUTE 30000 IN c; +FETCH ABSOLUTE 25000 FROM c; +COMMIT; +]); +is($cursor_row, "25000|$expected_payload", + 'scroll cursor backwards across encrypted BufFile returns matching row') + or note("got: $cursor_row"); + +$node->stop; +done_testing(); diff --git a/contrib/basic_file_encryption/t/003_logical_decoding.pl b/contrib/basic_file_encryption/t/003_logical_decoding.pl new file mode 100644 index 0000000000000..03715a756207d --- /dev/null +++ b/contrib/basic_file_encryption/t/003_logical_decoding.pl @@ -0,0 +1,102 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# End-to-end test of basic_file_encryption: configure a random AES-256 +# key-encryption key, trigger reorderbuffer spilling, and verify that all +# changes round-trip through the encrypt/decrypt callbacks. Also asserts +# that decryption fails loudly when the key changes between the encrypt and +# decrypt sessions (catches accidental key rotation against existing files). + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +# Generate a random 32-byte hex key-encryption key. +sub random_key +{ + my @hex; + for (1 .. 64) + { + push @hex, sprintf("%x", int(rand(16))); + } + return join('', @hex); +} + +my $key = random_key(); + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 'logical', + extra => ['--file-encryption-library=basic_file_encryption', + "--file-encryption-config=$key"]); +$node->append_conf( + 'postgresql.conf', qq( +logical_decoding_work_mem = '64kB' +)); +$node->start; + +$node->safe_psql('postgres', 'CREATE TABLE spill_test(data text);'); +$node->safe_psql('postgres', 'CREATE PUBLICATION pub FOR TABLE spill_test;'); +$node->safe_psql('postgres', + "SELECT pg_create_logical_replication_slot('enc_slot', 'pgoutput');"); + +$node->safe_psql('postgres', q[ +BEGIN; +INSERT INTO spill_test +SELECT 'encrypt-me:' || g.i +FROM generate_series(1, 5000) AS g(i); +COMMIT; +]); + +my $insert_count = $node->safe_psql('postgres', q[ +SELECT count(*) +FROM pg_logical_slot_get_binary_changes('enc_slot', NULL, NULL, + 'proto_version', '4', + 'publication_names', 'pub') +WHERE get_byte(data, 0) = 73; +]); +is($insert_count, '5000', + 'logical decoding round-trips through AES-256-GCM'); + +# Confirm we did spill (otherwise the test would silently bypass the +# encrypt/decrypt code path). +$node->poll_query_until( + 'postgres', q[ +SELECT spill_count > 0 AND spill_bytes > 0 +FROM pg_stat_replication_slots +WHERE slot_name = 'enc_slot'; +]) or die "Timed out while waiting for spill statistics"; + +$node->safe_psql('postgres', "SELECT pg_drop_replication_slot('enc_slot');"); + +# Sanity: a malformed key value is rejected at module load time. The +# postmaster refuses to start when file_encryption_config can't be parsed +# by the configured module, surfacing the module's errmsg in the server +# log. We launch via pg_ctl directly so a failed start doesn't kill the +# test process. +$node->stop; +$node->adjust_conf('postgresql.conf', 'file_encryption_config', + "'not-a-hex-key'"); +my $logfile = $node->logfile; +my $bad_start = system($ENV{'PG_REGRESS_BIN_DIR'} ? "$ENV{PG_REGRESS_BIN_DIR}/pg_ctl" : 'pg_ctl', + '--pgdata' => $node->data_dir, + '--log' => $logfile, + '--options' => '--cluster-name=primary', + '--wait', '--timeout' => 10, 'start'); +isnt($bad_start, 0, + 'postmaster refuses to start with malformed file_encryption_config'); +my $log_after = PostgreSQL::Test::Utils::slurp_file($logfile); +like($log_after, + qr/file encryption module "basic_file_encryption" failed to initialize/, + 'failed start logs the module-init error'); +like($log_after, + qr/basic_file_encryption: 'config' must (be|contain)/, + 'log surfaces module-supplied errmsg detail'); + +# Restore a valid key so the cluster can shut down cleanly when this +# test object is destroyed (PostgreSQL::Test::Cluster::DESTROY tries to +# stop the node and complains if it's not running). +$node->adjust_conf('postgresql.conf', 'file_encryption_config', "'$key'"); + +done_testing(); diff --git a/contrib/basic_file_encryption/t/004_relation_pages.pl b/contrib/basic_file_encryption/t/004_relation_pages.pl new file mode 100644 index 0000000000000..27551e9340577 --- /dev/null +++ b/contrib/basic_file_encryption/t/004_relation_pages.pl @@ -0,0 +1,146 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# End-to-end test of basic_file_encryption page encryption: configure a +# random AES-256 key-encryption key and a 32-byte page-reserved trailer, +# populate a heap+btree, verify round-trip across restart, that the +# on-disk bytes of the heap and index forks are not the plaintext, and +# that the FSM and VM forks are bypass (still plaintext-on-disk). Also +# verify that tampering an encrypted page surfaces a tag-mismatch error. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +sub random_key +{ + my @hex; + for (1 .. 64) + { + push @hex, sprintf("%x", int(rand(16))); + } + return join('', @hex); +} + +my $key = random_key(); + +my $node = PostgreSQL::Test::Cluster->new('primary'); +# initdb runs the bootstrap process to write the initial catalogs; the +# bootstrap process reads postgresql.conf, so the encryption settings have +# to be in place before bootstrap runs (i.e. as -c GUCs to initdb itself, +# not via append_conf afterwards). Otherwise the bootstrap-created pages +# would be plaintext on disk and the postmaster would fail to decrypt them. +$node->init(extra => ['--file-encryption-library=basic_file_encryption', + "--file-encryption-config=$key"]); +$node->start; + +# pg_controldata reports the reserved size and module-load succeeds +my $controldata = $node->safe_psql('postgres', 'SHOW data_directory;'); +my $stdout = `'$ENV{PG_REGRESS_BIN_DIR}/pg_controldata' '$controldata' 2>&1` + if defined $ENV{PG_REGRESS_BIN_DIR}; + +# Populate a heap with a btree index and force material amounts of +# data so we get multiple pages in each fork. +$node->safe_psql('postgres', q[ +CREATE TABLE t (id int PRIMARY KEY, payload text); +INSERT INTO t SELECT g, repeat(md5(g::text), 4) +FROM generate_series(1, 5000) g; +CHECKPOINT; +]); + +# Round-trip read after CHECKPOINT (forces the buffer pool to be +# evicted before the next read). The seqscan exercises mdread of +# every heap page; the index-only count exercises the btree path. +my $count = $node->safe_psql('postgres', 'SELECT count(*) FROM t;'); +is($count, '5000', 'seqscan round-trip through encrypted heap pages'); +my $idx_count = $node->safe_psql('postgres', + 'SELECT count(*) FROM (SELECT id FROM t ORDER BY id) s;'); +is($idx_count, '5000', 'index scan round-trip through encrypted btree pages'); + +# Restart and re-read; this exercises that we successfully decrypt +# pages after a fresh process state (no in-memory plaintext). +$node->restart; +my $restart_count = $node->safe_psql('postgres', 'SELECT count(*) FROM t;'); +is($restart_count, '5000', 'round-trip across restart'); + +my $restart_sample = $node->safe_psql('postgres', + "SELECT payload FROM t WHERE id = 1234;"); +my $expected_sample = $node->safe_psql('postgres', + "SELECT repeat(md5('1234'), 4);"); +is($restart_sample, $expected_sample, 'tuple bytes match across restart'); + +# On-disk verification: the heap fork should NOT contain visible +# plaintext (the payload is a known md5-derived string), the FSM and VM +# forks should be plaintext (per md.c bypass). +my $datadir = $node->data_dir; +my $reloid = $node->safe_psql('postgres', + "SELECT relfilenode FROM pg_class WHERE relname = 't';"); +my $dboid = $node->safe_psql('postgres', + "SELECT oid FROM pg_database WHERE datname = 'postgres';"); + +my $heap_path = "$datadir/base/$dboid/$reloid"; +my $fsm_path = "$heap_path" . "_fsm"; +my $vm_path = "$heap_path" . "_vm"; + +# A known plaintext substring that appears in many tuples +my $marker_query = $node->safe_psql('postgres', + "SELECT substr(repeat(md5('1'), 4), 1, 32);"); + +sub file_contains +{ + my ($path, $needle) = @_; + open my $fh, '<:raw', $path or die "open $path: $!"; + local $/ = undef; + my $content = <$fh>; + close $fh; + return index($content, $needle) >= 0; +} + +ok(-f $heap_path, "heap fork file exists at $heap_path"); +ok(!file_contains($heap_path, $marker_query), + 'heap fork on disk does not contain plaintext payload'); + +# FSM file may or may not exist depending on insert path; skip if not +SKIP: { + skip "no FSM file yet", 1 unless -f $fsm_path; + my $fsm_size = -s $fsm_path; + ok($fsm_size > 0, 'FSM fork has content (and is plaintext on disk)'); +} + +# Force VM creation by vacuuming +$node->safe_psql('postgres', 'VACUUM t;'); +SKIP: { + skip "no VM file yet", 1 unless -f $vm_path; + my $vm_size = -s $vm_path; + ok($vm_size > 0, 'VM fork exists after VACUUM (plaintext on disk)'); +} + +# Tamper detection: corrupt one byte in the heap fork and verify the +# next read surfaces the GCM tag verification error. +$node->stop; + +my $tamper_offset = 100; # inside the page header / payload, well before trailer +open my $fh, '+<:raw', $heap_path or die "open: $!"; +sysseek $fh, $tamper_offset, 0; +my $byte; +sysread $fh, $byte, 1; +$byte = chr((ord($byte) ^ 0xFF) & 0xFF); +sysseek $fh, $tamper_offset, 0; +syswrite $fh, $byte; +close $fh; + +$node->start; + +my ($ret, $tampered_stdout, $tampered_stderr) = $node->psql('postgres', + 'SELECT count(*) FROM t;'); +isnt($ret, 0, 'tampered heap page fails the read'); +like($tampered_stderr, + qr/authentication tag verification failed|could not read|exceeds|invalid|corrupted/, + 'tampered page surfaces an error'); + +# The PANIC during the tampered read takes the postmaster down, so we +# don't call $node->stop here — pg_ctl would Bail on the missing PID +# file. The test framework's END handler will tear down the data dir. +done_testing(); diff --git a/contrib/basic_file_encryption/t/005_unlogged_and_backup.pl b/contrib/basic_file_encryption/t/005_unlogged_and_backup.pl new file mode 100644 index 0000000000000..778352ccfc41d --- /dev/null +++ b/contrib/basic_file_encryption/t/005_unlogged_and_backup.pl @@ -0,0 +1,88 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Verify two cross-cutting paths for page encryption: +# 1) Unlogged tables survive crash reinit -- the KEY fork is preserved +# across the reinit pass so the relation remains decryptable after +# the (empty) reset state is filled with new rows. +# 2) Base backups include the KEY fork (alongside the init fork) for +# unlogged relations, so a restored cluster can read pages written +# after restart. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +sub random_key +{ + my @hex; + for (1 .. 64) + { + push @hex, sprintf("%x", int(rand(16))); + } + return join('', @hex); +} + +my $key = random_key(); + +my $primary = PostgreSQL::Test::Cluster->new('primary'); +$primary->init(extra => ['--file-encryption-library=basic_file_encryption', + "--file-encryption-config=$key"], + allows_streaming => 1); +$primary->start; + +# Create an unlogged relation and populate it. +$primary->safe_psql('postgres', q[ +CREATE UNLOGGED TABLE u (id int PRIMARY KEY, payload text); +INSERT INTO u SELECT g, 'unlogged-' || g FROM generate_series(1, 1000) g; +CHECKPOINT; +]); + +is($primary->safe_psql('postgres', 'SELECT count(*) FROM u;'), '1000', + 'unlogged table populated before crash'); + +# Force a crash so reinit runs on restart. The relation's MAIN fork +# gets wiped and re-seeded from INIT. We need the KEY fork to survive +# so the post-restart writes can encrypt under the same DEK. +$primary->stop('immediate'); +$primary->start; + +# The relation should be empty after reinit, and writable. +is($primary->safe_psql('postgres', 'SELECT count(*) FROM u;'), '0', + 'unlogged table reset to empty after crash'); +$primary->safe_psql('postgres', q[ +INSERT INTO u SELECT g, 'after-reset-' || g FROM generate_series(1, 50) g; +CHECKPOINT; +]); +is($primary->safe_psql('postgres', 'SELECT count(*) FROM u;'), '50', + 'unlogged table writable after reset (DEK survived reinit)'); +my $sample = $primary->safe_psql('postgres', + "SELECT payload FROM u WHERE id = 7;"); +is($sample, 'after-reset-7', 'post-reset tuple round-trips through encryption'); + +# Take a base backup and restore it. The KEY fork of the unlogged +# relation must be included so the restored cluster can encrypt new +# writes under the same DEK that was used to re-seed. +my $backup_path = $primary->backup_dir . '/backup-with-unlogged'; +$primary->backup('backup-with-unlogged'); + +my $restored = PostgreSQL::Test::Cluster->new('restored'); +$restored->init_from_backup($primary, 'backup-with-unlogged'); +$restored->start; + +# The unlogged relation is empty (init fork copied to main on start), +# but writable; encryption is engaged. +is($restored->safe_psql('postgres', 'SELECT count(*) FROM u;'), '0', + 'restored unlogged table is empty'); +$restored->safe_psql('postgres', q[ +INSERT INTO u SELECT g, 'restored-' || g FROM generate_series(1, 25) g; +CHECKPOINT; +]); +is($restored->safe_psql('postgres', 'SELECT count(*) FROM u;'), '25', + 'restored unlogged table writable (KEY fork made it into backup)'); + +$primary->stop; +$restored->stop; +done_testing(); diff --git a/contrib/meson.build b/contrib/meson.build index ebb7f83d8c5ef..9ef90fdb6df9f 100644 --- a/contrib/meson.build +++ b/contrib/meson.build @@ -16,6 +16,7 @@ subdir('amcheck') subdir('auth_delay') subdir('auto_explain') subdir('basic_archive') +subdir('basic_file_encryption') subdir('bloom') subdir('basebackup_to_shell') subdir('bool_plperl') @@ -61,6 +62,7 @@ subdir('pg_walinspect') subdir('postgres_fdw') subdir('seg') subdir('sepgsql') +subdir('sm4_file_encryption') subdir('spi') subdir('sslinfo') # start-scripts doesn't contain build products diff --git a/contrib/pageinspect/brinfuncs.c b/contrib/pageinspect/brinfuncs.c index 309b9522f9022..ccdbc703389b0 100644 --- a/contrib/pageinspect/brinfuncs.c +++ b/contrib/pageinspect/brinfuncs.c @@ -429,7 +429,7 @@ brin_revmap_data(PG_FUNCTION_ARGS) fctx = SRF_PERCALL_SETUP(); state = fctx->user_fctx; - if (state->idx < REVMAP_PAGE_MAXITEMS) + if (state->idx < RevmapPageMaxItemsForCluster()) SRF_RETURN_NEXT(fctx, PointerGetDatum(&state->tids[state->idx++])); SRF_RETURN_DONE(fctx); diff --git a/contrib/pageinspect/expected/page.out b/contrib/pageinspect/expected/page.out index fcf19c5ca5a50..215a33c92c1bf 100644 --- a/contrib/pageinspect/expected/page.out +++ b/contrib/pageinspect/expected/page.out @@ -39,7 +39,7 @@ SELECT octet_length(get_raw_page('xxx', 'main', 0)); ERROR: relation "xxx" does not exist SELECT octet_length(get_raw_page('test1', 'xxx', 0)); ERROR: invalid fork name -HINT: Valid fork names are "main", "fsm", "vm", and "init". +HINT: Valid fork names are "main", "fsm", "vm", "init", and "key". SELECT get_raw_page('test1', 0) = get_raw_page('test1', 'main', 0); ?column? ---------- diff --git a/contrib/sm4_file_encryption/.gitignore b/contrib/sm4_file_encryption/.gitignore new file mode 100644 index 0000000000000..5dcb3ff972350 --- /dev/null +++ b/contrib/sm4_file_encryption/.gitignore @@ -0,0 +1,4 @@ +# Generated subdirectories +/log/ +/results/ +/tmp_check/ diff --git a/contrib/sm4_file_encryption/Makefile b/contrib/sm4_file_encryption/Makefile new file mode 100644 index 0000000000000..30eb4d1cfea2d --- /dev/null +++ b/contrib/sm4_file_encryption/Makefile @@ -0,0 +1,24 @@ +# contrib/sm4_file_encryption/Makefile + +MODULE_big = sm4_file_encryption +OBJS = \ + $(WIN32RES) \ + sm4_file_encryption.o +PGFILEDESC = "sm4_file_encryption - SM4 reference file encryption module" + +NO_INSTALLCHECK = 1 +TAP_TESTS = 1 + +# Link against libcrypto for OpenSSL provider-backed ciphers and HMAC. +SHLIB_LINK += $(filter -lcrypto, $(LIBS)) + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = contrib/sm4_file_encryption +top_builddir = ../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/contrib/sm4_file_encryption/meson.build b/contrib/sm4_file_encryption/meson.build new file mode 100644 index 0000000000000..05ee5ec1dcd09 --- /dev/null +++ b/contrib/sm4_file_encryption/meson.build @@ -0,0 +1,35 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +if not ssl.found() + subdir_done() +endif + +sm4_file_encryption_sources = files( + 'sm4_file_encryption.c', +) + +if host_system == 'windows' + sm4_file_encryption_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'sm4_file_encryption', + '--FILEDESC', 'sm4_file_encryption - SM4 reference file encryption module',]) +endif + +sm4_file_encryption = shared_module('sm4_file_encryption', + sm4_file_encryption_sources, + kwargs: contrib_mod_args + { + 'dependencies': [ssl, contrib_mod_args['dependencies']] + }, +) +contrib_targets += sm4_file_encryption + +tests += { + 'name': 'sm4_file_encryption', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'tap': { + 'tests': [ + 't/001_buffile.pl', + 't/002_relation_pages.pl', + ], + }, +} diff --git a/contrib/sm4_file_encryption/sm4_file_encryption.c b/contrib/sm4_file_encryption/sm4_file_encryption.c new file mode 100644 index 0000000000000..648bb46af3e7f --- /dev/null +++ b/contrib/sm4_file_encryption/sm4_file_encryption.c @@ -0,0 +1,1272 @@ +/*------------------------------------------------------------------------- + * + * sm4_file_encryption.c + * Reference file_encryption_library module using SM4-CTR + SM3 HMAC. + * + * This module is a demonstration and test target, not a production tool. + * It accepts a hex-encoded SM4 key-encryption key (KEK) directly via the + * config blob, which keeps the test setup trivial but is not how a + * production-grade module should source its keys. Real modules would + * treat the config string as an opaque reference (e.g. a KMS URI, a + * vault path, a file name) and fetch the actual key from an external + * key store at startup_cb time. + * + * The KEK never encrypts user bytes directly; it only wraps data- + * encryption keys (DEKs). + * + * Two encryption flows share the same KEK: + * + * * Record-stream encryption (BufFile, reorderbuffer spill files) uses + * a fresh DEK + MAC key per call. Both are wrapped under the KEK and + * stored in the per-record trailer. Per-call overhead: 136 bytes. + * + * * Per-relation page encryption uses one (DEK, MAC key) pair per + * RelFileLocator, generated at relation-create time and wrapped under + * the KEK into the relation's KEY fork. Each page's trailer holds + * only the per-page IV, HMAC tag, and a format marker. Per-page + * overhead: 56 bytes. + * + * AAD bindings: + * + * * Record-stream encrypt/decrypt binds basename(path) || file_offset(be64). + * + * * Object-key wrap (DEK ciphertext stored in the KEY fork) binds + * relNumber(be32). + * + * * Page encrypt/decrypt binds fork(be32) || blocknum(be32). + * + * Defaults: SM4-CTR cipher, SM3 digest. Both are in the OpenSSL default + * provider on modern OpenSSL builds. + * + * Errors are surfaced to the host via the *errmsg out-param on each + * fallible callback; the module never calls ereport or exit directly, + * which keeps the same .so loadable from both backend and libpgcommon- + * based frontend tools. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * contrib/sm4_file_encryption/sm4_file_encryption.c + * + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include +#include +#include +#include + +#if OPENSSL_VERSION_NUMBER >= 0x30000000L +#include +#include +#define SM4_OPENSSL3 1 +#else +#define SM4_OPENSSL3 0 +#endif + +#include "common/file_encryption_module.h" +#include "fmgr.h" +#include "port.h" + +PG_MODULE_MAGIC; + +/* + * Tiny hex decoder. See the matching helper in basic_file_encryption.c + * for why we roll our own. + */ +static inline int +sm4_hex_nibble(unsigned char c) +{ + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + return -1; +} + +static size_t +sm4_hex_decode(const char *src, size_t len, unsigned char *dst) +{ + size_t i; + + if (len % 2 != 0) + return (size_t) -1; + for (i = 0; i < len / 2; i++) + { + int hi = sm4_hex_nibble((unsigned char) src[i * 2]); + int lo = sm4_hex_nibble((unsigned char) src[i * 2 + 1]); + + if (hi < 0 || lo < 0) + return (size_t) -1; + dst[i] = (unsigned char) ((hi << 4) | lo); + } + return len / 2; +} + +#define SM4_KEY_LEN 16 +#define SM4_IV_SLOT_LEN 16 +#define SM4_TAG_LEN 32 +#define SM4_KEY_MATERIAL_LEN (SM4_KEY_LEN * 2) +#define SM4_FORMAT_LEN sizeof(uint32) +#define SM4_FORMAT_MAGIC 0x314d4653 /* "SFM1" in native byte order */ + +/* + * Per-call (record-stream) on-disk overhead: + * [ data IV ][ data tag ] + * [ wrap IV ][ wrapped (DEK || MAC key) ][ wrap tag ] + * [ format ][ pad ] + * + * Sized to 136 bytes (132 used + 4 zero pad), a multiple of MAXIMUM_ALIGNOF. + */ +#define SM4_OVERHEAD_SIZE 136 +#define SM4_PAD_SIZE (SM4_OVERHEAD_SIZE - \ + (2 * SM4_IV_SLOT_LEN) - \ + (2 * SM4_TAG_LEN) - \ + SM4_KEY_MATERIAL_LEN - \ + SM4_FORMAT_LEN) +StaticAssertDecl(SM4_PAD_SIZE >= 0, "invalid sm4_file_encryption overhead"); + +#define SM4_PAGE_OVERHEAD_SIZE 56 +#define SM4_PAGE_PAD_SIZE (SM4_PAGE_OVERHEAD_SIZE - SM4_IV_SLOT_LEN - \ + SM4_TAG_LEN - SM4_FORMAT_LEN) +StaticAssertDecl(SM4_PAGE_PAD_SIZE >= 0, "invalid sm4_file_encryption page overhead"); + +#define SM4_OBJ_WRAP_SIZE (SM4_IV_SLOT_LEN + SM4_KEY_MATERIAL_LEN + \ + SM4_TAG_LEN + SM4_FORMAT_LEN) + +#define SM4_DEFAULT_CIPHER "SM4-CTR" +#define SM4_DEFAULT_DIGEST "SM3" + +typedef struct SM4FileEncryptionState +{ + unsigned char kek[SM4_KEY_LEN]; + const EVP_CIPHER *cipher; + const EVP_MD *digest; +#if SM4_OPENSSL3 + EVP_MAC *hmac; + OSSL_PROVIDER *provider; +#endif + int iv_len; +} SM4FileEncryptionState; + +/* Cached per-relation state, opaque to the core. */ +typedef struct SM4ObjectState +{ + unsigned char data_key[SM4_KEY_LEN]; + unsigned char mac_key[SM4_KEY_LEN]; +} SM4ObjectState; + +static unsigned char sm4_kek_static[SM4_KEY_LEN]; +static bool sm4_kek_set = false; +static char *sm4_provider = NULL; +static char *sm4_cipher = NULL; +static char *sm4_digest = NULL; + +static bool sm4_startup(FileEncryptionModuleState *state, char **errmsg); +static void sm4_shutdown(FileEncryptionModuleState *state); +static bool sm4_encrypt(const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg); +static bool sm4_decrypt(const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg); +static bool sm4_generate_object_key(FileEncryptionModuleState *state, + const RelFileLocator *locator, + char *dst, Size dst_max, + Size *wrapped_len, char **errmsg); +static void *sm4_object_open(FileEncryptionModuleState *state, + const RelFileLocator *locator, + const char *wrapped, Size wrapped_len, + char **errmsg); +static void sm4_object_close(FileEncryptionModuleState *state, + void *object_state); +static bool sm4_encrypt_page(FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, char **errmsg); +static bool sm4_decrypt_page(FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, char **errmsg); + +static SM4FileEncryptionState *sm4_require_state(const FileEncryptionModuleState *state, + char **errmsg); +static char *sm4_openssl_errstr(const char *op); +static char *sm4_missing_algorithm_msg(const char *kind, const char *name); +static bool sm4_load_crypto(SM4FileEncryptionState *priv, char **errmsg); +static void sm4_free_crypto(SM4FileEncryptionState *priv); +static bool sm4_cipher_crypt_raw(const SM4FileEncryptionState *priv, + bool encrypting, + const unsigned char *key, + const unsigned char iv[SM4_IV_SLOT_LEN], + const unsigned char *data, int data_len, + unsigned char *out, char **errmsg); +static bool sm4_hmac_raw(const SM4FileEncryptionState *priv, + const unsigned char *key, int key_len, + const unsigned char *aad, int aad_len, + const unsigned char *data, int data_len, + unsigned char tag[SM4_TAG_LEN], char **errmsg); +static bool sm4_hmac(const SM4FileEncryptionState *priv, + const unsigned char *key, int key_len, + const char *path, uint64 file_offset, + const unsigned char *data, int data_len, + unsigned char tag[SM4_TAG_LEN], char **errmsg); + +static const FileEncryptionCallbacks sm4_file_encryption_callbacks = { + PG_FILE_ENCRYPTION_MAGIC, + .overhead_size = SM4_OVERHEAD_SIZE, + .page_overhead_size = SM4_PAGE_OVERHEAD_SIZE, + + .startup_cb = sm4_startup, + .shutdown_cb = sm4_shutdown, + .encrypt_cb = sm4_encrypt, + .decrypt_cb = sm4_decrypt, + + .generate_object_key_cb = sm4_generate_object_key, + .object_open_cb = sm4_object_open, + .object_close_cb = sm4_object_close, + .encrypt_page_cb = sm4_encrypt_page, + .decrypt_page_cb = sm4_decrypt_page, +}; + +/* + * Trim leading and trailing ASCII whitespace from a NUL-terminated string + * in place. + */ +static char * +sm4_trim(char *s) +{ + char *end; + + while (*s == ' ' || *s == '\t' || *s == '\r') + s++; + end = s + strlen(s); + while (end > s && (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r')) + end--; + *end = '\0'; + return s; +} + +/* + * Parse the module config string. Format: newline-separated "key=value" + * entries. Recognised keys: "key" (required, 64 hex characters), + * "provider", "cipher", "digest". + */ +static bool +sm4_parse_config(const char *config, char **errmsg) +{ + char *buf; + char *saveptr = NULL; + char *line; + bool have_key = false; + + if (config == NULL || config[0] == '\0') + { + *errmsg = pstrdup("sm4_file_encryption: 'config' is empty; expected at least a 'key=' line"); + return false; + } + + if (sm4_provider != NULL) + { + pfree(sm4_provider); + sm4_provider = NULL; + } + if (sm4_cipher != NULL) + { + pfree(sm4_cipher); + sm4_cipher = NULL; + } + if (sm4_digest != NULL) + { + pfree(sm4_digest); + sm4_digest = NULL; + } + + buf = pstrdup(config); + for (line = strtok_r(buf, "\n", &saveptr); + line != NULL; + line = strtok_r(NULL, "\n", &saveptr)) + { + char *trimmed = sm4_trim(line); + char *eq; + char *key; + char *val; + + if (trimmed[0] == '\0' || trimmed[0] == '#') + continue; + + eq = strchr(trimmed, '='); + if (eq == NULL) + { + *errmsg = psprintf("sm4_file_encryption: malformed config line (no '='): \"%s\"", trimmed); + pfree(buf); + return false; + } + + *eq = '\0'; + key = sm4_trim(trimmed); + val = sm4_trim(eq + 1); + + if (strcmp(key, "key") == 0) + { + size_t decoded; + + if (strlen(val) != SM4_KEY_LEN * 2) + { + *errmsg = psprintf("sm4_file_encryption: 'key' must be %d hex characters (got %zu)", + SM4_KEY_LEN * 2, strlen(val)); + pfree(buf); + return false; + } + for (const char *p = val; *p; p++) + { + if (!isxdigit((unsigned char) *p)) + { + *errmsg = pstrdup("sm4_file_encryption: 'key' must contain only hexadecimal digits"); + pfree(buf); + return false; + } + } + decoded = sm4_hex_decode(val, strlen(val), sm4_kek_static); + if (decoded != SM4_KEY_LEN) + { + *errmsg = psprintf("sm4_file_encryption: hex decode of 'key' produced %lu bytes, expected %d", + (unsigned long) decoded, SM4_KEY_LEN); + pfree(buf); + return false; + } + have_key = true; + } + else if (strcmp(key, "provider") == 0) + sm4_provider = pstrdup(val); + else if (strcmp(key, "cipher") == 0) + sm4_cipher = pstrdup(val); + else if (strcmp(key, "digest") == 0) + sm4_digest = pstrdup(val); + else + { + *errmsg = psprintf("sm4_file_encryption: unknown config key \"%s\"", key); + pfree(buf); + return false; + } + } + + pfree(buf); + + if (!have_key) + { + *errmsg = pstrdup("sm4_file_encryption: required config key 'key' is missing"); + return false; + } + + if (sm4_cipher == NULL) + sm4_cipher = pstrdup(SM4_DEFAULT_CIPHER); + if (sm4_digest == NULL) + sm4_digest = pstrdup(SM4_DEFAULT_DIGEST); + + sm4_kek_set = true; + return true; +} + +bool +_PG_file_encryption_module_init(const char *config, + const FileEncryptionCallbacks **callbacks_out, + char **errmsg) +{ + if (!sm4_parse_config(config, errmsg)) + return false; + + *callbacks_out = &sm4_file_encryption_callbacks; + return true; +} + +/* + * Per-process startup. Reports load-time failures (provider not loadable, + * cipher/digest unavailable, HMAC tag length mismatch) up to the host via + * *errmsg. + */ +static bool +sm4_startup(FileEncryptionModuleState *state, char **errmsg) +{ + SM4FileEncryptionState *priv; + + if (!sm4_kek_set) + { + *errmsg = pstrdup("sm4_file_encryption: module was loaded without a valid configuration"); + return false; + } + + priv = palloc0_object(SM4FileEncryptionState); + memcpy(priv->kek, sm4_kek_static, SM4_KEY_LEN); + + if (!sm4_load_crypto(priv, errmsg)) + { + explicit_bzero(priv->kek, sizeof(priv->kek)); + sm4_free_crypto(priv); + pfree(priv); + return false; + } + + state->private_data = priv; + return true; +} + +static void +sm4_shutdown(FileEncryptionModuleState *state) +{ + SM4FileEncryptionState *priv = state->private_data; + + if (priv != NULL) + { + explicit_bzero(priv->kek, sizeof(priv->kek)); + sm4_free_crypto(priv); + pfree(priv); + state->private_data = NULL; + } +} + +static SM4FileEncryptionState * +sm4_require_state(const FileEncryptionModuleState *state, char **errmsg) +{ + SM4FileEncryptionState *priv = state->private_data; + + if (priv == NULL) + { + *errmsg = pstrdup("sm4_file_encryption: module was loaded without a valid configuration"); + return NULL; + } + return priv; +} + +static char * +sm4_openssl_errstr(const char *op) +{ + unsigned long e = ERR_get_error(); + char buf[256]; + + if (e == 0) + return psprintf("sm4_file_encryption: %s failed", op); + + ERR_error_string_n(e, buf, sizeof(buf)); + return psprintf("sm4_file_encryption: %s failed: %s", op, buf); +} + +static char * +sm4_missing_algorithm_msg(const char *kind, const char *name) +{ + return psprintf("sm4_file_encryption: could not fetch OpenSSL %s \"%s\" -- install or activate an OpenSSL provider that implements it", + kind, name); +} + +static int +sm4_cipher_key_length(const EVP_CIPHER *cipher) +{ +#if SM4_OPENSSL3 + return EVP_CIPHER_get_key_length(cipher); +#else + return EVP_CIPHER_key_length(cipher); +#endif +} + +static int +sm4_cipher_iv_length(const EVP_CIPHER *cipher) +{ +#if SM4_OPENSSL3 + return EVP_CIPHER_get_iv_length(cipher); +#else + return EVP_CIPHER_iv_length(cipher); +#endif +} + +static int +sm4_cipher_mode(const EVP_CIPHER *cipher) +{ +#if SM4_OPENSSL3 + return EVP_CIPHER_get_mode(cipher); +#else + return EVP_CIPHER_mode(cipher); +#endif +} + +static bool +sm4_load_crypto(SM4FileEncryptionState *priv, char **errmsg) +{ + int key_len; + int mode; + unsigned char tag[SM4_TAG_LEN]; + char *self_err = NULL; + + if (sm4_cipher == NULL || sm4_cipher[0] == '\0') + { + *errmsg = pstrdup("sm4_file_encryption: 'cipher' must not be empty"); + return false; + } + if (sm4_digest == NULL || sm4_digest[0] == '\0') + { + *errmsg = pstrdup("sm4_file_encryption: 'digest' must not be empty"); + return false; + } + +#if SM4_OPENSSL3 + if (sm4_provider != NULL && sm4_provider[0] != '\0') + { + priv->provider = OSSL_PROVIDER_load(NULL, sm4_provider); + if (priv->provider == NULL) + { + *errmsg = sm4_missing_algorithm_msg("provider", sm4_provider); + return false; + } + } + + priv->cipher = EVP_CIPHER_fetch(NULL, sm4_cipher, NULL); + if (priv->cipher == NULL) + { + *errmsg = sm4_missing_algorithm_msg("cipher", sm4_cipher); + return false; + } + + priv->digest = EVP_MD_fetch(NULL, sm4_digest, NULL); + if (priv->digest == NULL) + { + *errmsg = sm4_missing_algorithm_msg("digest", sm4_digest); + return false; + } + + priv->hmac = EVP_MAC_fetch(NULL, "HMAC", NULL); + if (priv->hmac == NULL) + { + *errmsg = sm4_missing_algorithm_msg("MAC", "HMAC"); + return false; + } +#else + priv->cipher = EVP_get_cipherbyname(sm4_cipher); + if (priv->cipher == NULL) + { + *errmsg = sm4_missing_algorithm_msg("cipher", sm4_cipher); + return false; + } + + priv->digest = EVP_get_digestbyname(sm4_digest); + if (priv->digest == NULL) + { + *errmsg = sm4_missing_algorithm_msg("digest", sm4_digest); + return false; + } +#endif + + key_len = sm4_cipher_key_length(priv->cipher); + if (key_len != SM4_KEY_LEN) + { + *errmsg = psprintf("sm4_file_encryption: OpenSSL cipher \"%s\" has key length %d, expected %d", + sm4_cipher, key_len, SM4_KEY_LEN); + return false; + } + + priv->iv_len = sm4_cipher_iv_length(priv->cipher); + if (priv->iv_len <= 0 || priv->iv_len > SM4_IV_SLOT_LEN) + { + *errmsg = psprintf("sm4_file_encryption: OpenSSL cipher \"%s\" has IV length %d, expected 1..%d", + sm4_cipher, priv->iv_len, SM4_IV_SLOT_LEN); + return false; + } + + mode = sm4_cipher_mode(priv->cipher); + if (mode != EVP_CIPH_CTR_MODE && + mode != EVP_CIPH_CFB_MODE && + mode != EVP_CIPH_OFB_MODE && + mode != EVP_CIPH_STREAM_CIPHER) + { + *errmsg = psprintf("sm4_file_encryption: OpenSSL cipher \"%s\" is not a streaming cipher mode (need CTR/CFB/OFB/stream)", + sm4_cipher); + return false; + } + + /* Sanity check that the digest produces SM4_TAG_LEN-byte HMACs. */ + if (!sm4_hmac_raw(priv, priv->kek, SM4_KEY_LEN, NULL, 0, NULL, 0, tag, &self_err)) + { + *errmsg = self_err; + return false; + } + + return true; +} + +static void +sm4_free_crypto(SM4FileEncryptionState *priv) +{ +#if SM4_OPENSSL3 + if (priv->cipher != NULL) + EVP_CIPHER_free(unconstify(EVP_CIPHER *, priv->cipher)); + if (priv->digest != NULL) + EVP_MD_free(unconstify(EVP_MD *, priv->digest)); + if (priv->hmac != NULL) + EVP_MAC_free(priv->hmac); + if (priv->provider != NULL) + OSSL_PROVIDER_unload(priv->provider); +#endif + priv->cipher = NULL; + priv->digest = NULL; +#if SM4_OPENSSL3 + priv->hmac = NULL; + priv->provider = NULL; +#endif +} + +static const char * +sm4_basename(const char *path) +{ + const char *basename = strrchr(path, '/'); + + return basename ? basename + 1 : path; +} + +static void +sm4_store64_be(unsigned char *dst, uint64 v) +{ + for (int i = 0; i < 8; i++) + dst[i] = (unsigned char) (v >> ((7 - i) * 8)); +} + +static void +sm4_store32_be(unsigned char *dst, uint32 v) +{ + for (int i = 0; i < 4; i++) + dst[i] = (unsigned char) (v >> ((3 - i) * 8)); +} + +/* + * HMAC-of-(AAD || data) under 'key'. Returns false with *errmsg set on + * any OpenSSL failure or unexpected digest output length. + */ +static bool +sm4_hmac_raw(const SM4FileEncryptionState *priv, + const unsigned char *key, int key_len, + const unsigned char *aad, int aad_len, + const unsigned char *data, int data_len, + unsigned char tag[SM4_TAG_LEN], char **errmsg) +{ + unsigned char fulltag[EVP_MAX_MD_SIZE]; + size_t tag_len = 0; + bool ok = true; + +#if SM4_OPENSSL3 + { + EVP_MAC_CTX *ctx; + OSSL_PARAM params[2]; + + ctx = EVP_MAC_CTX_new(priv->hmac); + if (ctx == NULL) + { + *errmsg = sm4_openssl_errstr("EVP_MAC_CTX_new"); + return false; + } + + params[0] = OSSL_PARAM_construct_utf8_string(OSSL_MAC_PARAM_DIGEST, + sm4_digest, 0); + params[1] = OSSL_PARAM_construct_end(); + + if (EVP_MAC_init(ctx, key, key_len, params) != 1 || + (aad_len > 0 && + EVP_MAC_update(ctx, aad, aad_len) != 1) || + (data_len > 0 && + EVP_MAC_update(ctx, data, data_len) != 1) || + EVP_MAC_final(ctx, fulltag, &tag_len, sizeof(fulltag)) != 1) + ok = false; + + EVP_MAC_CTX_free(ctx); + if (!ok) + { + *errmsg = sm4_openssl_errstr("HMAC"); + return false; + } + } +#else + { + HMAC_CTX *ctx; + unsigned int outlen; + + ctx = HMAC_CTX_new(); + if (ctx == NULL) + { + *errmsg = sm4_openssl_errstr("HMAC_CTX_new"); + return false; + } + + if (HMAC_Init_ex(ctx, key, key_len, priv->digest, NULL) != 1 || + (aad_len > 0 && + HMAC_Update(ctx, aad, aad_len) != 1) || + (data_len > 0 && + HMAC_Update(ctx, data, data_len) != 1) || + HMAC_Final(ctx, fulltag, &outlen) != 1) + ok = false; + else + tag_len = outlen; + + HMAC_CTX_free(ctx); + if (!ok) + { + *errmsg = sm4_openssl_errstr("HMAC"); + return false; + } + } +#endif + + if (tag_len != SM4_TAG_LEN) + { + *errmsg = psprintf("sm4_file_encryption: OpenSSL digest \"%s\" produces %zu-byte HMAC tags, expected %d", + sm4_digest, tag_len, SM4_TAG_LEN); + return false; + } + + memcpy(tag, fulltag, SM4_TAG_LEN); + explicit_bzero(fulltag, sizeof(fulltag)); + return true; +} + +/* + * Record-stream HMAC: AAD = basename(path) || file_offset(be64). + */ +static bool +sm4_hmac(const SM4FileEncryptionState *priv, + const unsigned char *key, int key_len, + const char *path, uint64 file_offset, + const unsigned char *data, int data_len, + unsigned char tag[SM4_TAG_LEN], char **errmsg) +{ + const char *basename = sm4_basename(path); + size_t baselen = strlen(basename); + unsigned char *aad; + int aad_len; + bool ok; + + aad_len = (int) baselen + 8; + aad = palloc(aad_len); + memcpy(aad, basename, baselen); + sm4_store64_be(aad + baselen, file_offset); + + ok = sm4_hmac_raw(priv, key, key_len, aad, aad_len, + data, data_len, tag, errmsg); + pfree(aad); + return ok; +} + +static bool +sm4_cipher_crypt_raw(const SM4FileEncryptionState *priv, + bool encrypting, + const unsigned char *key, + const unsigned char iv[SM4_IV_SLOT_LEN], + const unsigned char *data, int data_len, + unsigned char *out, char **errmsg) +{ + EVP_CIPHER_CTX *ctx; + int outlen; + int finallen; + + ctx = EVP_CIPHER_CTX_new(); + if (ctx == NULL) + { + *errmsg = sm4_openssl_errstr("EVP_CIPHER_CTX_new"); + return false; + } + +#define FAIL(op) do { \ + *errmsg = sm4_openssl_errstr(op); \ + EVP_CIPHER_CTX_free(ctx); \ + return false; \ + } while (0) + + if (EVP_CipherInit_ex(ctx, priv->cipher, NULL, NULL, NULL, + encrypting ? 1 : 0) != 1) + FAIL("EVP_CipherInit_ex"); + if (EVP_CIPHER_CTX_set_padding(ctx, 0) != 1) + FAIL("EVP_CIPHER_CTX_set_padding"); + if (EVP_CipherInit_ex(ctx, NULL, NULL, key, iv, + encrypting ? 1 : 0) != 1) + FAIL("EVP_CipherInit_ex (key/iv)"); + + if (EVP_CipherUpdate(ctx, out, &outlen, data, data_len) != 1) + FAIL("EVP_CipherUpdate"); + if (outlen != data_len) + { + *errmsg = psprintf("sm4_file_encryption: OpenSSL cipher \"%s\" produced %d bytes for %d input bytes (need a streaming-mode cipher)", + sm4_cipher, outlen, data_len); + EVP_CIPHER_CTX_free(ctx); + return false; + } + + if (EVP_CipherFinal_ex(ctx, out + outlen, &finallen) != 1) + FAIL("EVP_CipherFinal_ex"); + if (finallen != 0) + { + *errmsg = psprintf("sm4_file_encryption: OpenSSL cipher \"%s\" produced unexpected final output (need a streaming-mode cipher)", + sm4_cipher); + EVP_CIPHER_CTX_free(ctx); + return false; + } + +#undef FAIL + + EVP_CIPHER_CTX_free(ctx); + return true; +} + +/* + * ============================================================ + * Record-stream encryption (BufFile, reorderbuffer spill) + * ============================================================ + */ +static bool +sm4_encrypt(const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg) +{ + SM4FileEncryptionState *priv; + unsigned char data_enc_key[SM4_KEY_LEN]; + unsigned char data_mac_key[SM4_KEY_LEN]; + unsigned char key_material[SM4_KEY_MATERIAL_LEN]; + unsigned char data_iv[SM4_IV_SLOT_LEN] = {0}; + unsigned char wrap_iv[SM4_IV_SLOT_LEN] = {0}; + unsigned char data_tag[SM4_TAG_LEN]; + unsigned char wrap_tag[SM4_TAG_LEN]; + uint32 format = SM4_FORMAT_MAGIC; + int body_len = (int) data_len; + char *p; + bool ok = false; + + priv = sm4_require_state(state, errmsg); + if (priv == NULL) + return false; + + if (!pg_strong_random(data_enc_key, sizeof(data_enc_key)) || + !pg_strong_random(data_mac_key, sizeof(data_mac_key)) || + !pg_strong_random(data_iv, priv->iv_len) || + !pg_strong_random(wrap_iv, priv->iv_len)) + { + *errmsg = pstrdup("sm4_file_encryption: could not generate encryption key material"); + goto out; + } + + memcpy(key_material, data_enc_key, SM4_KEY_LEN); + memcpy(key_material + SM4_KEY_LEN, data_mac_key, SM4_KEY_LEN); + + if (!sm4_cipher_crypt_raw(priv, true, data_enc_key, data_iv, + (const unsigned char *) data, body_len, + (unsigned char *) dst, errmsg)) + goto out; + if (!sm4_hmac(priv, data_mac_key, SM4_KEY_LEN, path, file_offset, + (const unsigned char *) dst, body_len, data_tag, errmsg)) + goto out; + + p = dst + body_len; + memcpy(p, data_iv, SM4_IV_SLOT_LEN); + p += SM4_IV_SLOT_LEN; + memcpy(p, data_tag, SM4_TAG_LEN); + p += SM4_TAG_LEN; + memcpy(p, wrap_iv, SM4_IV_SLOT_LEN); + p += SM4_IV_SLOT_LEN; + + if (!sm4_cipher_crypt_raw(priv, true, priv->kek, wrap_iv, + key_material, SM4_KEY_MATERIAL_LEN, + (unsigned char *) p, errmsg)) + goto out; + if (!sm4_hmac(priv, priv->kek, SM4_KEY_LEN, path, file_offset, + (const unsigned char *) p, SM4_KEY_MATERIAL_LEN, + wrap_tag, errmsg)) + goto out; + p += SM4_KEY_MATERIAL_LEN; + + memcpy(p, wrap_tag, SM4_TAG_LEN); + p += SM4_TAG_LEN; + memcpy(p, &format, sizeof(format)); + p += sizeof(format); + if (SM4_PAD_SIZE > 0) + memset(p, 0, SM4_PAD_SIZE); + ok = true; + +out: + explicit_bzero(data_enc_key, sizeof(data_enc_key)); + explicit_bzero(data_mac_key, sizeof(data_mac_key)); + explicit_bzero(key_material, sizeof(key_material)); + explicit_bzero(data_iv, sizeof(data_iv)); + explicit_bzero(wrap_iv, sizeof(wrap_iv)); + explicit_bzero(data_tag, sizeof(data_tag)); + explicit_bzero(wrap_tag, sizeof(wrap_tag)); + return ok; +} + +static bool +sm4_decrypt(const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg) +{ + SM4FileEncryptionState *priv; + const unsigned char *data_iv; + const unsigned char *data_tag; + const unsigned char *wrap_iv; + const unsigned char *wrapped_keys; + const unsigned char *wrap_tag; + unsigned char expected_tag[SM4_TAG_LEN]; + unsigned char key_material[SM4_KEY_MATERIAL_LEN]; + uint32 format; + int body_len; + bool ok = false; + + priv = sm4_require_state(state, errmsg); + if (priv == NULL) + return false; + + if (data_len < SM4_OVERHEAD_SIZE) + { + *errmsg = psprintf("sm4_file_encryption: encrypted blob is too short (%zu bytes)", + data_len); + return false; + } + + body_len = (int) (data_len - SM4_OVERHEAD_SIZE); + data_iv = (const unsigned char *) data + body_len; + data_tag = data_iv + SM4_IV_SLOT_LEN; + wrap_iv = data_tag + SM4_TAG_LEN; + wrapped_keys = wrap_iv + SM4_IV_SLOT_LEN; + wrap_tag = wrapped_keys + SM4_KEY_MATERIAL_LEN; + memcpy(&format, wrap_tag + SM4_TAG_LEN, sizeof(format)); + + if (format != SM4_FORMAT_MAGIC) + { + *errmsg = pstrdup("sm4_file_encryption: encrypted blob has an unrecognized format"); + return false; + } + + if (!sm4_hmac(priv, priv->kek, SM4_KEY_LEN, path, file_offset, + wrapped_keys, SM4_KEY_MATERIAL_LEN, expected_tag, errmsg)) + goto out; + if (timingsafe_bcmp(expected_tag, wrap_tag, SM4_TAG_LEN) != 0) + { + *errmsg = psprintf("sm4_file_encryption: wrapped key authentication failed for \"%s\" offset %llu", + path, (unsigned long long) file_offset); + goto out; + } + + if (!sm4_cipher_crypt_raw(priv, false, priv->kek, wrap_iv, + wrapped_keys, SM4_KEY_MATERIAL_LEN, + key_material, errmsg)) + goto out; + + if (!sm4_hmac(priv, key_material + SM4_KEY_LEN, SM4_KEY_LEN, + path, file_offset, (const unsigned char *) data, + body_len, expected_tag, errmsg)) + goto out; + if (timingsafe_bcmp(expected_tag, data_tag, SM4_TAG_LEN) != 0) + { + *errmsg = psprintf("sm4_file_encryption: authentication tag verification failed for \"%s\" offset %llu", + path, (unsigned long long) file_offset); + goto out; + } + + if (!sm4_cipher_crypt_raw(priv, false, key_material, data_iv, + (const unsigned char *) data, body_len, + (unsigned char *) dst, errmsg)) + goto out; + ok = true; + +out: + explicit_bzero(expected_tag, sizeof(expected_tag)); + explicit_bzero(key_material, sizeof(key_material)); + return ok; +} + +/* + * ============================================================ + * Per-relation page encryption + * ============================================================ + */ +static bool +sm4_generate_object_key(FileEncryptionModuleState *state, + const RelFileLocator *locator, + char *dst, Size dst_max, + Size *wrapped_len, char **errmsg) +{ + SM4FileEncryptionState *priv; + unsigned char data_key[SM4_KEY_LEN]; + unsigned char mac_key[SM4_KEY_LEN]; + unsigned char key_material[SM4_KEY_MATERIAL_LEN]; + unsigned char wrap_iv[SM4_IV_SLOT_LEN] = {0}; + unsigned char wrap_tag[SM4_TAG_LEN]; + unsigned char aad[4]; + uint32 format = SM4_FORMAT_MAGIC; + char *p; + char *wrapped_keys; + bool ok = false; + + priv = sm4_require_state(state, errmsg); + if (priv == NULL) + return false; + + if (dst_max < SM4_OBJ_WRAP_SIZE) + { + *errmsg = psprintf("sm4_file_encryption: wrapped-key buffer is %zu bytes, need %zu", + dst_max, (Size) SM4_OBJ_WRAP_SIZE); + return false; + } + + if (!pg_strong_random(data_key, sizeof(data_key)) || + !pg_strong_random(mac_key, sizeof(mac_key)) || + !pg_strong_random(wrap_iv, priv->iv_len)) + { + *errmsg = pstrdup("sm4_file_encryption: could not generate object key material"); + goto out; + } + + memcpy(key_material, data_key, SM4_KEY_LEN); + memcpy(key_material + SM4_KEY_LEN, mac_key, SM4_KEY_LEN); + sm4_store32_be(aad, locator->relNumber); + + p = dst; + memcpy(p, wrap_iv, SM4_IV_SLOT_LEN); + p += SM4_IV_SLOT_LEN; + + wrapped_keys = p; + if (!sm4_cipher_crypt_raw(priv, true, priv->kek, wrap_iv, + key_material, SM4_KEY_MATERIAL_LEN, + (unsigned char *) p, errmsg)) + goto out; + p += SM4_KEY_MATERIAL_LEN; + + if (!sm4_hmac_raw(priv, priv->kek, SM4_KEY_LEN, aad, sizeof(aad), + (const unsigned char *) wrapped_keys, + SM4_KEY_MATERIAL_LEN, wrap_tag, errmsg)) + goto out; + + memcpy(p, wrap_tag, SM4_TAG_LEN); + p += SM4_TAG_LEN; + memcpy(p, &format, sizeof(format)); + + *wrapped_len = SM4_OBJ_WRAP_SIZE; + ok = true; + +out: + explicit_bzero(data_key, sizeof(data_key)); + explicit_bzero(mac_key, sizeof(mac_key)); + explicit_bzero(key_material, sizeof(key_material)); + explicit_bzero(wrap_iv, sizeof(wrap_iv)); + explicit_bzero(wrap_tag, sizeof(wrap_tag)); + return ok; +} + +static void * +sm4_object_open(FileEncryptionModuleState *state, + const RelFileLocator *locator, + const char *wrapped, Size wrapped_len, char **errmsg) +{ + SM4FileEncryptionState *priv; + SM4ObjectState *obj; + const unsigned char *wrap_iv; + const unsigned char *wrapped_keys; + const unsigned char *wrap_tag; + unsigned char expected_tag[SM4_TAG_LEN]; + unsigned char key_material[SM4_KEY_MATERIAL_LEN]; + unsigned char aad[4]; + uint32 format; + + priv = sm4_require_state(state, errmsg); + if (priv == NULL) + return NULL; + + if (wrapped_len != SM4_OBJ_WRAP_SIZE) + { + *errmsg = psprintf("sm4_file_encryption: wrapped object key has unexpected length %zu (want %zu)", + wrapped_len, (Size) SM4_OBJ_WRAP_SIZE); + return NULL; + } + + wrap_iv = (const unsigned char *) wrapped; + wrapped_keys = wrap_iv + SM4_IV_SLOT_LEN; + wrap_tag = wrapped_keys + SM4_KEY_MATERIAL_LEN; + memcpy(&format, wrap_tag + SM4_TAG_LEN, sizeof(format)); + + if (format != SM4_FORMAT_MAGIC) + { + *errmsg = psprintf("sm4_file_encryption: wrapped object key has unrecognized format 0x%08x", + format); + return NULL; + } + + sm4_store32_be(aad, locator->relNumber); + + if (!sm4_hmac_raw(priv, priv->kek, SM4_KEY_LEN, aad, sizeof(aad), + wrapped_keys, SM4_KEY_MATERIAL_LEN, expected_tag, + errmsg)) + { + explicit_bzero(expected_tag, sizeof(expected_tag)); + return NULL; + } + if (timingsafe_bcmp(expected_tag, wrap_tag, SM4_TAG_LEN) != 0) + { + *errmsg = pstrdup("sm4_file_encryption: object key authentication failed"); + explicit_bzero(expected_tag, sizeof(expected_tag)); + return NULL; + } + + if (!sm4_cipher_crypt_raw(priv, false, priv->kek, wrap_iv, + wrapped_keys, SM4_KEY_MATERIAL_LEN, + key_material, errmsg)) + { + explicit_bzero(expected_tag, sizeof(expected_tag)); + explicit_bzero(key_material, sizeof(key_material)); + return NULL; + } + + obj = palloc0_object(SM4ObjectState); + memcpy(obj->data_key, key_material, SM4_KEY_LEN); + memcpy(obj->mac_key, key_material + SM4_KEY_LEN, SM4_KEY_LEN); + + explicit_bzero(expected_tag, sizeof(expected_tag)); + explicit_bzero(key_material, sizeof(key_material)); + return obj; +} + +static void +sm4_object_close(FileEncryptionModuleState *state, void *object_state) +{ + SM4ObjectState *obj = object_state; + + if (obj == NULL) + return; + explicit_bzero(obj->data_key, sizeof(obj->data_key)); + explicit_bzero(obj->mac_key, sizeof(obj->mac_key)); + pfree(obj); +} + +/* + * Page layout (BLCKSZ bytes): + * + * dst[0 .. BLCKSZ - 56) ciphertext (body) + * dst[BLCKSZ - 56 .. BLCKSZ - 40) page IV (16B) + * dst[BLCKSZ - 40 .. BLCKSZ - 8) HMAC tag (32B) + * dst[BLCKSZ - 8 .. BLCKSZ - 4) format magic (4B) + * dst[BLCKSZ - 4 .. BLCKSZ) padding (4B) + */ +static bool +sm4_encrypt_page(FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, char **errmsg) +{ + SM4FileEncryptionState *priv; + SM4ObjectState *obj = object_state; + unsigned char data_iv[SM4_IV_SLOT_LEN] = {0}; + unsigned char data_tag[SM4_TAG_LEN]; + unsigned char aad[8]; + uint32 format = SM4_FORMAT_MAGIC; + int body_len = BLCKSZ - SM4_PAGE_OVERHEAD_SIZE; + bool ok = false; + + priv = sm4_require_state(state, errmsg); + if (priv == NULL) + return false; + + if (!pg_strong_random(data_iv, priv->iv_len)) + { + *errmsg = pstrdup("sm4_file_encryption: could not generate page IV"); + goto out; + } + + /* + * AAD = fork(be32) || blocknum(be32). Including the fork + * distinguishes MAIN block N from INIT block N so neither can be + * substituted for the other on disk; reinit re-encrypts INIT bytes + * under MAIN's AAD when copying INIT into MAIN on unlogged-relation + * reset. + */ + sm4_store32_be(aad, (uint32) fork); + sm4_store32_be(aad + 4, blocknum); + + if (!sm4_cipher_crypt_raw(priv, true, obj->data_key, data_iv, + (const unsigned char *) src, body_len, + (unsigned char *) dst, errmsg)) + goto out; + + if (!sm4_hmac_raw(priv, obj->mac_key, SM4_KEY_LEN, aad, sizeof(aad), + (const unsigned char *) dst, body_len, data_tag, + errmsg)) + goto out; + + memcpy(dst + body_len, data_iv, SM4_IV_SLOT_LEN); + memcpy(dst + body_len + SM4_IV_SLOT_LEN, data_tag, SM4_TAG_LEN); + memcpy(dst + body_len + SM4_IV_SLOT_LEN + SM4_TAG_LEN, + &format, sizeof(format)); + if (SM4_PAGE_PAD_SIZE > 0) + memset(dst + body_len + SM4_IV_SLOT_LEN + SM4_TAG_LEN + sizeof(format), + 0, SM4_PAGE_PAD_SIZE); + ok = true; + +out: + explicit_bzero(data_iv, sizeof(data_iv)); + explicit_bzero(data_tag, sizeof(data_tag)); + return ok; +} + +static bool +sm4_decrypt_page(FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, char **errmsg) +{ + SM4FileEncryptionState *priv; + SM4ObjectState *obj = object_state; + const unsigned char *data_iv; + const unsigned char *data_tag; + unsigned char expected_tag[SM4_TAG_LEN]; + unsigned char aad[8]; + uint32 format; + int body_len = BLCKSZ - SM4_PAGE_OVERHEAD_SIZE; + bool ok = false; + + priv = sm4_require_state(state, errmsg); + if (priv == NULL) + return false; + + data_iv = (const unsigned char *) src + body_len; + data_tag = data_iv + SM4_IV_SLOT_LEN; + memcpy(&format, data_tag + SM4_TAG_LEN, sizeof(format)); + + if (format != SM4_FORMAT_MAGIC) + { + *errmsg = psprintf("sm4_file_encryption: encrypted page has unrecognized format 0x%08x", + format); + return false; + } + + sm4_store32_be(aad, (uint32) fork); + sm4_store32_be(aad + 4, blocknum); + + if (!sm4_hmac_raw(priv, obj->mac_key, SM4_KEY_LEN, aad, sizeof(aad), + (const unsigned char *) src, body_len, expected_tag, + errmsg)) + goto out; + if (timingsafe_bcmp(expected_tag, data_tag, SM4_TAG_LEN) != 0) + { + *errmsg = pstrdup("sm4_file_encryption: page authentication failed"); + goto out; + } + + if (!sm4_cipher_crypt_raw(priv, false, obj->data_key, data_iv, + (const unsigned char *) src, body_len, + (unsigned char *) dst, errmsg)) + goto out; + + /* Zero the plaintext trailer so pd_checksum verifies. */ + memset(dst + body_len, 0, SM4_PAGE_OVERHEAD_SIZE); + ok = true; + +out: + explicit_bzero(expected_tag, sizeof(expected_tag)); + return ok; +} diff --git a/contrib/sm4_file_encryption/t/001_buffile.pl b/contrib/sm4_file_encryption/t/001_buffile.pl new file mode 100644 index 0000000000000..be2648b9a3ff0 --- /dev/null +++ b/contrib/sm4_file_encryption/t/001_buffile.pl @@ -0,0 +1,61 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +sub openssl_has_algorithm +{ + my ($option, $name) = @_; + my $output = `openssl list -$option 2>&1`; + return $output =~ /\b\Q$name\E\b/i; +} + +plan skip_all => 'OpenSSL does not expose SM4-CTR' + unless openssl_has_algorithm('cipher-algorithms', 'SM4-CTR'); +plan skip_all => 'OpenSSL does not expose SM3' + unless openssl_has_algorithm('digest-algorithms', 'SM3'); + +sub random_key +{ + my @hex; + for (1 .. 32) + { + push @hex, sprintf("%x", int(rand(16))); + } + return join('', @hex); +} + +my $key = random_key(); + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(extra => ['--file-encryption-library=sm4_file_encryption', + "--file-encryption-config=key=$key"]); +$node->append_conf( + 'postgresql.conf', qq( +work_mem = '64kB' +hash_mem_multiplier = 1.0 +)); +$node->start; + +$node->safe_psql('postgres', q[ +CREATE TABLE t (id int, payload text); +INSERT INTO t +SELECT g, repeat(md5(g::text), 4) +FROM generate_series(1, 50000) g; +]); + +my $sort_ok = $node->safe_psql('postgres', q[ +WITH ordered AS (SELECT id, payload FROM t ORDER BY id) +SELECT count(*) = 50000 AND + bool_and(payload = repeat(md5(id::text), 4)) AND + (array_agg(id))[1:5] = ARRAY[1, 2, 3, 4, 5] +FROM (SELECT id, payload FROM ordered) s; +]); +is($sort_ok, 't', 'sort spilled and round-tripped through SM4 module'); + +$node->stop; +done_testing(); diff --git a/contrib/sm4_file_encryption/t/002_relation_pages.pl b/contrib/sm4_file_encryption/t/002_relation_pages.pl new file mode 100644 index 0000000000000..b55287c5cb03b --- /dev/null +++ b/contrib/sm4_file_encryption/t/002_relation_pages.pl @@ -0,0 +1,80 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Page-encryption test for sm4_file_encryption: per-relation DEK held in +# the relation's KEY fork, page trailer carries IV + HMAC tag + format. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +sub openssl_has_algorithm +{ + my ($option, $name) = @_; + my $output = `openssl list -$option 2>&1`; + return $output =~ /\b\Q$name\E\b/i; +} + +plan skip_all => 'OpenSSL does not expose SM4-CTR' + unless openssl_has_algorithm('cipher-algorithms', 'SM4-CTR'); +plan skip_all => 'OpenSSL does not expose SM3' + unless openssl_has_algorithm('digest-algorithms', 'SM3'); + +sub random_key +{ + my @hex; + for (1 .. 32) + { + push @hex, sprintf("%x", int(rand(16))); + } + return join('', @hex); +} + +my $key = random_key(); + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(extra => ['--file-encryption-library=sm4_file_encryption', + "--file-encryption-config=key=$key"]); +$node->start; + +$node->safe_psql('postgres', q[ +CREATE TABLE t (id int PRIMARY KEY, payload text); +INSERT INTO t SELECT g, repeat(md5(g::text), 4) +FROM generate_series(1, 5000) g; +CHECKPOINT; +]); + +my $count = $node->safe_psql('postgres', 'SELECT count(*) FROM t;'); +is($count, '5000', 'seqscan round-trip through encrypted heap pages'); + +$node->restart; +my $restart_count = $node->safe_psql('postgres', 'SELECT count(*) FROM t;'); +is($restart_count, '5000', 'round-trip across restart'); + +my $restart_sample = $node->safe_psql('postgres', + "SELECT payload FROM t WHERE id = 1234;"); +my $expected_sample = $node->safe_psql('postgres', + "SELECT repeat(md5('1234'), 4);"); +is($restart_sample, $expected_sample, 'tuple bytes match across restart'); + +# On-disk: heap fork shouldn't contain plaintext payload. +my $datadir = $node->data_dir; +my $reloid = $node->safe_psql('postgres', + "SELECT relfilenode FROM pg_class WHERE relname = 't';"); +my $dboid = $node->safe_psql('postgres', + "SELECT oid FROM pg_database WHERE datname = 'postgres';"); +my $heap_path = "$datadir/base/$dboid/$reloid"; + +my $marker = $node->safe_psql('postgres', + "SELECT substr(repeat(md5('1'), 4), 1, 32);"); + +open my $fh, '<:raw', $heap_path or die "open $heap_path: $!"; +local $/ = undef; +my $content = <$fh>; +close $fh; +ok(index($content, $marker) < 0, + 'heap fork on disk does not contain plaintext payload'); + +done_testing(); diff --git a/src/backend/access/brin/brin_pageops.c b/src/backend/access/brin/brin_pageops.c index 7da97bec43b55..3956973aa21e7 100644 --- a/src/backend/access/brin/brin_pageops.c +++ b/src/backend/access/brin/brin_pageops.c @@ -26,7 +26,7 @@ * a single item per page, unlike other index AMs. */ #define BrinMaxItemSize \ - MAXALIGN_DOWN(BLCKSZ - \ + MAXALIGN_DOWN(BLCKSZ - GetPageReservedSize() - \ (MAXALIGN(SizeOfPageHeaderData + \ sizeof(ItemIdData)) + \ MAXALIGN(sizeof(BrinSpecialSpace)))) diff --git a/src/backend/access/brin/brin_revmap.c b/src/backend/access/brin/brin_revmap.c index 233355cb2d5d1..ab074b2e18b0f 100644 --- a/src/backend/access/brin/brin_revmap.c +++ b/src/backend/access/brin/brin_revmap.c @@ -38,9 +38,9 @@ * the given heap block number. */ #define HEAPBLK_TO_REVMAP_BLK(pagesPerRange, heapBlk) \ - ((heapBlk / pagesPerRange) / REVMAP_PAGE_MAXITEMS) + ((heapBlk / pagesPerRange) / RevmapPageMaxItemsForCluster()) #define HEAPBLK_TO_REVMAP_INDEX(pagesPerRange, heapBlk) \ - ((heapBlk / pagesPerRange) % REVMAP_PAGE_MAXITEMS) + ((heapBlk / pagesPerRange) % RevmapPageMaxItemsForCluster()) struct BrinRevmap diff --git a/src/backend/access/common/bufmask.c b/src/backend/access/common/bufmask.c index 5f63d04c9cbf6..9f1c71d5d9f1d 100644 --- a/src/backend/access/common/bufmask.c +++ b/src/backend/access/common/bufmask.c @@ -75,7 +75,8 @@ mask_unused_space(Page page) /* Sanity check */ if (pd_lower > pd_upper || pd_special < pd_upper || - pd_lower < SizeOfPageHeaderData || pd_special > BLCKSZ) + pd_lower < SizeOfPageHeaderData || + pd_special > BLCKSZ - GetPageReservedSize()) { elog(ERROR, "invalid page pd_lower %u pd_upper %u pd_special %u", pd_lower, pd_upper, pd_special); @@ -117,9 +118,11 @@ mask_lp_flags(Page page) void mask_page_content(Page page) { - /* Mask Page Content */ + /* Mask Page Content (excluding the encryption trailer, which is owned + * by the smgr layer and contains module-specific metadata that varies + * between WAL writer and replayer.) */ memset(page + SizeOfPageHeaderData, MASK_MARKER, - BLCKSZ - SizeOfPageHeaderData); + BLCKSZ - GetPageReservedSize() - SizeOfPageHeaderData); /* Mask pd_lower and pd_upper */ memset(&((PageHeader) page)->pd_lower, MASK_MARKER, diff --git a/src/backend/access/gin/gindatapage.c b/src/backend/access/gin/gindatapage.c index c5d7db28077da..fe26c8373b46e 100644 --- a/src/backend/access/gin/gindatapage.c +++ b/src/backend/access/gin/gindatapage.c @@ -535,7 +535,7 @@ dataBeginPlaceToPageLeaf(GinBtree btree, Buffer buf, GinBtreeStack *stack, * a single byte, and we can use all the free space on the old page as * well as the new page. For simplicity, ignore segment overhead etc. */ - maxitems = Min(maxitems, freespace + GinDataPageMaxDataSize); + maxitems = Min(maxitems, freespace + GinDataPageMaxDataSizeForCluster()); } else { @@ -550,7 +550,7 @@ dataBeginPlaceToPageLeaf(GinBtree btree, Buffer buf, GinBtreeStack *stack, int nnewsegments; nnewsegments = freespace / GinPostingListSegmentMaxSize; - nnewsegments += GinDataPageMaxDataSize / GinPostingListSegmentMaxSize; + nnewsegments += GinDataPageMaxDataSizeForCluster() / GinPostingListSegmentMaxSize; maxitems = Min(maxitems, nnewsegments * MinTuplesPerSegment); } @@ -665,8 +665,8 @@ dataBeginPlaceToPageLeaf(GinBtree btree, Buffer buf, GinBtreeStack *stack, leaf->lastleft = dlist_prev_node(&leaf->segments, leaf->lastleft); } } - Assert(leaf->lsize <= GinDataPageMaxDataSize); - Assert(leaf->rsize <= GinDataPageMaxDataSize); + Assert(leaf->lsize <= GinDataPageMaxDataSizeForCluster()); + Assert(leaf->rsize <= GinDataPageMaxDataSizeForCluster()); /* * Fetch the max item in the left page's last segment; it becomes the @@ -758,7 +758,7 @@ ginVacuumPostingTreeLeaf(Relation indexrel, Buffer buffer, GinVacuumState *gvs) if (seginfo->seg) oldsegsize = SizeOfGinPostingList(seginfo->seg); else - oldsegsize = GinDataPageMaxDataSize; + oldsegsize = GinDataPageMaxDataSizeForCluster(); cleaned = ginVacuumItemPointers(gvs, seginfo->items, @@ -1018,7 +1018,7 @@ dataPlaceToPageLeafRecompress(Buffer buf, disassembledLeaf *leaf) } } - Assert(newsize <= GinDataPageMaxDataSize); + Assert(newsize <= GinDataPageMaxDataSizeForCluster()); GinDataPageSetDataSize(page, newsize); } @@ -1690,7 +1690,7 @@ leafRepackItems(disassembledLeaf *leaf, ItemPointer remaining) * copying to the page. Did we exceed the size that fits on one page? */ segsize = SizeOfGinPostingList(seginfo->seg); - if (pgused + segsize > GinDataPageMaxDataSize) + if (pgused + segsize > GinDataPageMaxDataSizeForCluster()) { if (!needsplit) { @@ -1730,8 +1730,8 @@ leafRepackItems(disassembledLeaf *leaf, ItemPointer remaining) else leaf->rsize = pgused; - Assert(leaf->lsize <= GinDataPageMaxDataSize); - Assert(leaf->rsize <= GinDataPageMaxDataSize); + Assert(leaf->lsize <= GinDataPageMaxDataSizeForCluster()); + Assert(leaf->rsize <= GinDataPageMaxDataSizeForCluster()); /* * Make a palloc'd copy of every segment after the first modified one, @@ -1807,7 +1807,7 @@ createPostingTree(Relation index, ItemPointerData *items, uint32 nitems, GinPostingListSegmentMaxSize, &npacked); segsize = SizeOfGinPostingList(segment); - if (rootsize + segsize > GinDataPageMaxDataSize) + if (rootsize + segsize > GinDataPageMaxDataSizeForCluster()) break; memcpy(ptr, segment, segsize); diff --git a/src/backend/access/gin/ginentrypage.c b/src/backend/access/gin/ginentrypage.c index f818132eceba2..9ddcc4374fb26 100644 --- a/src/backend/access/gin/ginentrypage.c +++ b/src/backend/access/gin/ginentrypage.c @@ -101,13 +101,13 @@ GinFormTuple(GinState *ginstate, newsize = MAXALIGN(newsize); - if (newsize > GinMaxItemSize) + if (newsize > GinMaxItemSizeForCluster()) { if (errorTooBig) ereport(ERROR, (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), errmsg("index row size %zu exceeds maximum %zu for index \"%s\"", - (Size) newsize, (Size) GinMaxItemSize, + (Size) newsize, GinMaxItemSizeForCluster(), RelationGetRelationName(ginstate->index)))); pfree(itup); return NULL; diff --git a/src/backend/access/gin/ginfast.c b/src/backend/access/gin/ginfast.c index f50848eb65a81..9672cc01cb1ec 100644 --- a/src/backend/access/gin/ginfast.c +++ b/src/backend/access/gin/ginfast.c @@ -39,7 +39,8 @@ int gin_pending_list_limit = 0; #define GIN_PAGE_FREESIZE \ - ( (Size) BLCKSZ - MAXALIGN(SizeOfPageHeaderData) - MAXALIGN(sizeof(GinPageOpaqueData)) ) + ( (Size) BLCKSZ - GetPageReservedSize() - \ + MAXALIGN(SizeOfPageHeaderData) - MAXALIGN(sizeof(GinPageOpaqueData)) ) typedef struct KeyArray { @@ -183,7 +184,7 @@ makeSublist(Relation index, IndexTuple *tuples, int32 ntuples, tupsize = MAXALIGN(IndexTupleSize(tuples[i])) + sizeof(ItemIdData); - if (size + tupsize > GinListPageSize) + if (size + tupsize > GinListPageSizeForCluster()) { /* won't fit, force a new page and reprocess */ i--; @@ -250,7 +251,7 @@ ginHeapTupleFastInsert(GinState *ginstate, GinTupleCollector *collector) * ready to modify the page. */ - if (collector->sumsize + collector->ntuples * sizeof(ItemIdData) > GinListPageSize) + if (collector->sumsize + collector->ntuples * sizeof(ItemIdData) > GinListPageSizeForCluster()) { /* * Total size is greater than one page => make sublist diff --git a/src/backend/access/gin/gininsert.c b/src/backend/access/gin/gininsert.c index 9d83a4957757b..0c132223c567d 100644 --- a/src/backend/access/gin/gininsert.c +++ b/src/backend/access/gin/gininsert.c @@ -244,7 +244,7 @@ addItemPointersToLeafTuple(GinState *ginstate, /* Compress the posting list, and try to a build tuple with room for it */ res = NULL; - compressedList = ginCompressPostingList(newItems, newNPosting, GinMaxItemSize, &nwritten); + compressedList = ginCompressPostingList(newItems, newNPosting, GinMaxItemSizeForCluster(), &nwritten); if (nwritten == newNPosting) { res = GinFormTuple(ginstate, attnum, key, category, @@ -306,7 +306,7 @@ buildFreshLeafTuple(GinState *ginstate, int nwritten; /* try to build a posting list tuple with all the items */ - compressedList = ginCompressPostingList(items, nitem, GinMaxItemSize, &nwritten); + compressedList = ginCompressPostingList(items, nitem, GinMaxItemSizeForCluster(), &nwritten); if (nwritten == nitem) { res = GinFormTuple(ginstate, attnum, key, category, diff --git a/src/backend/access/gin/ginvacuum.c b/src/backend/access/gin/ginvacuum.c index 840543eb6642b..118ed63432a07 100644 --- a/src/backend/access/gin/ginvacuum.c +++ b/src/backend/access/gin/ginvacuum.c @@ -551,7 +551,7 @@ ginVacuumEntryPage(GinVacuumState *gvs, Buffer buffer, BlockNumber *roots, uint3 if (nitems > 0) { - plist = ginCompressPostingList(items, nitems, GinMaxItemSize, NULL); + plist = ginCompressPostingList(items, nitems, GinMaxItemSizeForCluster(), NULL); plistsize = SizeOfGinPostingList(plist); } else diff --git a/src/backend/access/gist/gist.c b/src/backend/access/gist/gist.c index 8565e225be7fd..5b0f04251a965 100644 --- a/src/backend/access/gist/gist.c +++ b/src/backend/access/gist/gist.c @@ -1473,7 +1473,7 @@ gistSplit(Relation r, ereport(ERROR, (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), errmsg("index row size %zu exceeds maximum %zu for index \"%s\"", - IndexTupleSize(itup[0]), GiSTPageSize, + IndexTupleSize(itup[0]), GiSTPageSizeForCluster(), RelationGetRelationName(r)))); memset(v.spl_lisnull, true, diff --git a/src/backend/access/gist/gistbuild.c b/src/backend/access/gist/gistbuild.c index 7f57c787f4cbf..b1846ab3eb583 100644 --- a/src/backend/access/gist/gistbuild.c +++ b/src/backend/access/gist/gistbuild.c @@ -636,7 +636,8 @@ gistInitBuffering(GISTBuildState *buildstate) int levelStep; /* Calc space of index page which is available for index tuples */ - pageFreeSpace = BLCKSZ - SizeOfPageHeaderData - sizeof(GISTPageOpaqueData) + pageFreeSpace = BLCKSZ - GetPageReservedSize() - SizeOfPageHeaderData + - sizeof(GISTPageOpaqueData) - sizeof(ItemIdData) - buildstate->freespace; @@ -794,7 +795,8 @@ calculatePagesPerBuffer(GISTBuildState *buildstate, int levelStep) Size pageFreeSpace; /* Calc space of index page which is available for index tuples */ - pageFreeSpace = BLCKSZ - SizeOfPageHeaderData - sizeof(GISTPageOpaqueData) + pageFreeSpace = BLCKSZ - GetPageReservedSize() - SizeOfPageHeaderData + - sizeof(GISTPageOpaqueData) - sizeof(ItemIdData) - buildstate->freespace; diff --git a/src/backend/access/gist/gistutil.c b/src/backend/access/gist/gistutil.c index 0f58f61879fb0..26593148b64f5 100644 --- a/src/backend/access/gist/gistutil.c +++ b/src/backend/access/gist/gistutil.c @@ -85,7 +85,7 @@ gistfitpage(IndexTuple *itvec, int len) size += IndexTupleSize(itvec[i]) + sizeof(ItemIdData); /* TODO: Consider fillfactor */ - return (size <= GiSTPageSize); + return (size <= GiSTPageSizeForCluster()); } /* diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c index 2268cc277bce5..85f3728016b11 100644 --- a/src/backend/access/heap/heapam_handler.c +++ b/src/backend/access/heap/heapam_handler.c @@ -567,6 +567,16 @@ heapam_relation_copy_data(Relation rel, const RelFileLocator *newrlocator) for (ForkNumber forkNum = MAIN_FORKNUM + 1; forkNum <= MAX_FORKNUM; forkNum++) { + /* + * KEY_FORKNUM was already created by RelationCreateStorage above + * with a freshly minted destination DEK; do not copy the source's + * wrapped DEK over it. RelationCopyStorage transparently decrypts + * under the source DEK on read and re-encrypts under the + * destination DEK on write for the data forks. + */ + if (forkNum == KEY_FORKNUM) + continue; + if (smgrexists(RelationGetSmgr(rel), forkNum)) { smgrcreate(dstrel, forkNum, false); @@ -2071,7 +2081,7 @@ heapam_relation_toast_am(Relation rel) #define HEAP_OVERHEAD_BYTES_PER_TUPLE \ (MAXALIGN(SizeofHeapTupleHeader) + sizeof(ItemIdData)) #define HEAP_USABLE_BYTES_PER_PAGE \ - (BLCKSZ - SizeOfPageHeaderData) + (BLCKSZ - GetPageReservedSize() - SizeOfPageHeaderData) static void heapam_estimate_rel_size(Relation rel, int32 *attr_widths, diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c index e96e0f77d9264..e3bd4c91134df 100644 --- a/src/backend/access/heap/hio.c +++ b/src/backend/access/heap/hio.c @@ -527,11 +527,11 @@ RelationGetBufferForTuple(Relation relation, Size len, /* * If we're gonna fail for oversize tuple, do it right away */ - if (len > MaxHeapTupleSize) + if (len > MaxHeapTupleSizeForCluster()) ereport(ERROR, (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), errmsg("row is too big: size %zu, maximum size %zu", - len, MaxHeapTupleSize))); + len, MaxHeapTupleSizeForCluster()))); /* Compute desired extra freespace due to fillfactor option */ saveFreeSpace = RelationGetTargetPageFreeSpace(relation, @@ -543,7 +543,7 @@ RelationGetBufferForTuple(Relation relation, Size len, * somewhat arbitrary, but it should prevent most unnecessary relation * extensions while inserting large tuples into low-fillfactor tables. */ - nearlyEmptyFreeSpace = MaxHeapTupleSize - + nearlyEmptyFreeSpace = MaxHeapTupleSizeForCluster() - (MaxHeapTuplesPerPage / 8 * sizeof(ItemIdData)); if (len + saveFreeSpace > nearlyEmptyFreeSpace) targetFreeSpace = Max(len, nearlyEmptyFreeSpace); diff --git a/src/backend/access/heap/rewriteheap.c b/src/backend/access/heap/rewriteheap.c index 5a5398a76ae7d..15383a8945775 100644 --- a/src/backend/access/heap/rewriteheap.c +++ b/src/backend/access/heap/rewriteheap.c @@ -638,11 +638,11 @@ raw_heap_insert(RewriteState state, HeapTuple tup) /* * If we're gonna fail for oversize tuple, do it right away */ - if (len > MaxHeapTupleSize) + if (len > MaxHeapTupleSizeForCluster()) ereport(ERROR, (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), errmsg("row is too big: size %zu, maximum size %zu", - len, MaxHeapTupleSize))); + len, MaxHeapTupleSizeForCluster()))); /* Compute desired extra freespace due to fillfactor option */ saveFreeSpace = RelationGetTargetPageFreeSpace(state->rs_new_rel, diff --git a/src/backend/access/heap/vacuumlazy.c b/src/backend/access/heap/vacuumlazy.c index 39395aed0d592..0e7fb83d50c85 100644 --- a/src/backend/access/heap/vacuumlazy.c +++ b/src/backend/access/heap/vacuumlazy.c @@ -1904,7 +1904,7 @@ lazy_scan_new_or_empty(LVRelState *vacrel, Buffer buf, BlockNumber blkno, if (GetRecordedFreeSpace(vacrel->rel, blkno) == 0) { - freespace = BLCKSZ - SizeOfPageHeaderData; + freespace = BLCKSZ - GetPageReservedSize() - SizeOfPageHeaderData; RecordPageWithFreeSpace(vacrel->rel, blkno, freespace); } diff --git a/src/backend/access/heap/visibilitymap.c b/src/backend/access/heap/visibilitymap.c index 4fd470702aae7..f4de98d7e556c 100644 --- a/src/backend/access/heap/visibilitymap.c +++ b/src/backend/access/heap/visibilitymap.c @@ -115,6 +115,12 @@ * Size of the bitmap on each visibility map page, in bytes. There's no * extra headers, so the whole page minus the standard page header is * used for the bitmap. + * + * Intentionally BLCKSZ-based even when the cluster reserves bytes at + * the tail of every page for file encryption: VM pages are exempt from + * encryption (md.c doesn't route them through encrypt_page_cb / + * decrypt_page_cb), so the bitmap can use the entire page including + * what would otherwise be the encryption trailer. */ #define MAPSIZE (BLCKSZ - MAXALIGN(SizeOfPageHeaderData)) diff --git a/src/backend/access/nbtree/nbtdedup.c b/src/backend/access/nbtree/nbtdedup.c index af7affdf409d3..2c2d29d48ef7d 100644 --- a/src/backend/access/nbtree/nbtdedup.c +++ b/src/backend/access/nbtree/nbtdedup.c @@ -86,7 +86,7 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz, state = palloc_object(BTDedupStateData); state->deduplicate = true; state->nmaxitems = 0; - state->maxpostingsize = Min(BTMaxItemSize / 2, INDEX_SIZE_MASK); + state->maxpostingsize = Min(BTMaxItemSizeForCluster() / 2, INDEX_SIZE_MASK); /* Metadata about base tuple of current pending posting list */ state->base = NULL; state->baseoff = InvalidOffsetNumber; @@ -570,7 +570,7 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state) /* Use original, unchanged base tuple */ tuplesz = IndexTupleSize(state->base); Assert(tuplesz == MAXALIGN(IndexTupleSize(state->base))); - Assert(tuplesz <= BTMaxItemSize); + Assert(tuplesz <= BTMaxItemSizeForCluster()); if (PageAddItem(newpage, state->base, tuplesz, tupoff, false, false) == InvalidOffsetNumber) elog(ERROR, "deduplication failed to add tuple to page"); @@ -589,7 +589,7 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state) state->intervals[state->nintervals].nitems = state->nitems; Assert(tuplesz == MAXALIGN(IndexTupleSize(final))); - Assert(tuplesz <= BTMaxItemSize); + Assert(tuplesz <= BTMaxItemSizeForCluster()); if (PageAddItem(newpage, final, tuplesz, tupoff, false, false) == InvalidOffsetNumber) elog(ERROR, "deduplication failed to add tuple to page"); @@ -825,7 +825,7 @@ _bt_singleval_fillfactor(Page page, BTDedupState state, Size newitemsz) int reduction; /* This calculation needs to match nbtsplitloc.c */ - leftfree = PageGetPageSize(page) - SizeOfPageHeaderData - + leftfree = PageGetUsableSize(page) - SizeOfPageHeaderData - MAXALIGN(sizeof(BTPageOpaqueData)); /* Subtract size of new high key (includes pivot heap TID space) */ leftfree -= newitemsz + MAXALIGN(sizeof(ItemPointerData)); diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c index c8af97dd23dfb..402d21622d492 100644 --- a/src/backend/access/nbtree/nbtinsert.c +++ b/src/backend/access/nbtree/nbtinsert.c @@ -841,7 +841,7 @@ _bt_findinsertloc(Relation rel, opaque = BTPageGetOpaque(page); /* Check 1/3 of a page restriction */ - if (unlikely(insertstate->itemsz > BTMaxItemSize)) + if (unlikely(insertstate->itemsz > BTMaxItemSizeForCluster())) _bt_check_third_page(rel, heapRel, itup_key->heapkeyspace, page, insertstate->itup); diff --git a/src/backend/access/nbtree/nbtsort.c b/src/backend/access/nbtree/nbtsort.c index 756dfa3dcf47e..af74929855bcf 100644 --- a/src/backend/access/nbtree/nbtsort.c +++ b/src/backend/access/nbtree/nbtsort.c @@ -832,7 +832,7 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup, * make use of the reserved space. This should never fail on internal * pages. */ - if (unlikely(itupsz > BTMaxItemSize)) + if (unlikely(itupsz > BTMaxItemSizeForCluster())) _bt_check_third_page(wstate->index, wstate->heap, isleaf, npage, itup); @@ -1306,7 +1306,7 @@ _bt_load(BTWriteState *wstate, BTSpool *btspool, BTSpool *btspool2) */ dstate->maxpostingsize = MAXALIGN_DOWN((BLCKSZ * 10 / 100)) - sizeof(ItemIdData); - Assert(dstate->maxpostingsize <= BTMaxItemSize && + Assert(dstate->maxpostingsize <= BTMaxItemSizeForCluster() && dstate->maxpostingsize <= INDEX_SIZE_MASK); dstate->htids = palloc(dstate->maxpostingsize); diff --git a/src/backend/access/nbtree/nbtsplitloc.c b/src/backend/access/nbtree/nbtsplitloc.c index de9eca3c8b2ea..6f7dbabbdcfdb 100644 --- a/src/backend/access/nbtree/nbtsplitloc.c +++ b/src/backend/access/nbtree/nbtsplitloc.c @@ -157,7 +157,7 @@ _bt_findsplitloc(Relation rel, /* Total free space available on a btree page, after fixed overhead */ leftspace = rightspace = - PageGetPageSize(origpage) - SizeOfPageHeaderData - + PageGetUsableSize(origpage) - SizeOfPageHeaderData - MAXALIGN(sizeof(BTPageOpaqueData)); /* The right page will have the same high key as the old page */ diff --git a/src/backend/access/nbtree/nbtutils.c b/src/backend/access/nbtree/nbtutils.c index 014faa1622fb5..4deed6b5c1ede 100644 --- a/src/backend/access/nbtree/nbtutils.c +++ b/src/backend/access/nbtree/nbtutils.c @@ -1124,7 +1124,7 @@ _bt_check_third_page(Relation rel, Relation heap, bool needheaptidspace, itemsz = MAXALIGN(IndexTupleSize(newtup)); /* Double check item size against limit */ - if (itemsz <= BTMaxItemSize) + if (itemsz <= BTMaxItemSizeForCluster()) return; /* @@ -1132,7 +1132,7 @@ _bt_check_third_page(Relation rel, Relation heap, bool needheaptidspace, * index uses version 2 or version 3, or that page is an internal page, in * which case a slightly higher limit applies. */ - if (!needheaptidspace && itemsz <= BTMaxItemSizeNoHeapTid) + if (!needheaptidspace && itemsz <= BTMaxItemSizeNoHeapTidForCluster()) return; /* @@ -1149,7 +1149,7 @@ _bt_check_third_page(Relation rel, Relation heap, bool needheaptidspace, errmsg("index row size %zu exceeds btree version %u maximum %zu for index \"%s\"", itemsz, needheaptidspace ? BTREE_VERSION : BTREE_NOVAC_VERSION, - needheaptidspace ? BTMaxItemSize : BTMaxItemSizeNoHeapTid, + needheaptidspace ? BTMaxItemSizeForCluster() : BTMaxItemSizeNoHeapTidForCluster(), RelationGetRelationName(rel)), errdetail("Index row references tuple (%u,%u) in relation \"%s\".", ItemPointerGetBlockNumber(BTreeTupleGetHeapTID(newtup)), diff --git a/src/backend/access/rmgrdesc/smgrdesc.c b/src/backend/access/rmgrdesc/smgrdesc.c index aaf1b07999dbc..380c599a43e72 100644 --- a/src/backend/access/rmgrdesc/smgrdesc.c +++ b/src/backend/access/rmgrdesc/smgrdesc.c @@ -38,6 +38,13 @@ smgr_desc(StringInfo buf, XLogReaderState *record) relpathperm(xlrec->rlocator, MAIN_FORKNUM).str, xlrec->blkno, xlrec->flags); } + else if (info == XLOG_SMGR_KEY_FORK_CREATE) + { + xl_smgr_key_fork_create *xlrec = (xl_smgr_key_fork_create *) rec; + + appendStringInfoString(buf, + relpathperm(xlrec->rlocator, KEY_FORKNUM).str); + } } const char * @@ -53,6 +60,9 @@ smgr_identify(uint8 info) case XLOG_SMGR_TRUNCATE: id = "TRUNCATE"; break; + case XLOG_SMGR_KEY_FORK_CREATE: + id = "KEY_FORK_CREATE"; + break; } return id; diff --git a/src/backend/access/spgist/spgdoinsert.c b/src/backend/access/spgist/spgdoinsert.c index 7c7371c69e80e..f5628c4fdcbb5 100644 --- a/src/backend/access/spgist/spgdoinsert.c +++ b/src/backend/access/spgist/spgdoinsert.c @@ -894,7 +894,7 @@ doPickSplit(Relation index, SpGistState *state, * fit on one page. */ allTheSame = checkAllTheSame(&in, &out, - totalLeafSizes > SPGIST_PAGE_CAPACITY, + totalLeafSizes > SpGistPageCapacityForCluster(), &includeNew); /* @@ -1025,7 +1025,7 @@ doPickSplit(Relation index, SpGistState *state, for (i = 0; i < nToInsert; i++) leafPageSelect[i] = 0; /* signifies current page */ } - else if (in.nTuples == 1 && totalLeafSizes > SPGIST_PAGE_CAPACITY) + else if (in.nTuples == 1 && totalLeafSizes > SpGistPageCapacityForCluster()) { /* * We're trying to split up a long value by repeated suffixing, but @@ -1046,7 +1046,7 @@ doPickSplit(Relation index, SpGistState *state, newLeafBuffer = SpGistGetBuffer(index, GBUF_LEAF | (isNulls ? GBUF_NULLS : 0), Min(totalLeafSizes, - SPGIST_PAGE_CAPACITY), + SpGistPageCapacityForCluster()), &xlrec.initDest); /* @@ -1989,13 +1989,13 @@ spgdoinsert(Relation index, SpGistState *state, * If it isn't gonna fit, and the opclass can't reduce the datum size by * suffixing, bail out now rather than doing a lot of useless work. */ - if (leafSize > SPGIST_PAGE_CAPACITY && + if (leafSize > SpGistPageCapacityForCluster() && (isnull || !state->config.longValuesOK)) ereport(ERROR, (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), errmsg("index row size %zu exceeds maximum %zu for index \"%s\"", leafSize - sizeof(ItemIdData), - SPGIST_PAGE_CAPACITY - sizeof(ItemIdData), + SpGistPageCapacityForCluster() - sizeof(ItemIdData), RelationGetRelationName(index)), errhint("Values larger than a buffer page cannot be indexed."))); bestLeafSize = leafSize; @@ -2053,7 +2053,7 @@ spgdoinsert(Relation index, SpGistState *state, current.buffer = SpGistGetBuffer(index, GBUF_LEAF | (isnull ? GBUF_NULLS : 0), - Min(leafSize, SPGIST_PAGE_CAPACITY), + Min(leafSize, SpGistPageCapacityForCluster()), &isNew); current.blkno = BufferGetBlockNumber(current.buffer); } @@ -2116,9 +2116,9 @@ spgdoinsert(Relation index, SpGistState *state, } else if ((sizeToSplit = checkSplitConditions(index, state, ¤t, - &nToSplit)) < SPGIST_PAGE_CAPACITY / 2 && + &nToSplit)) < SpGistPageCapacityForCluster() / 2 && nToSplit < 64 && - leafTuple->size + sizeof(ItemIdData) + sizeToSplit <= SPGIST_PAGE_CAPACITY) + leafTuple->size + sizeof(ItemIdData) + sizeToSplit <= SpGistPageCapacityForCluster()) { /* * the amount of data is pretty small, so just move the whole @@ -2252,7 +2252,7 @@ spgdoinsert(Relation index, SpGistState *state, * than MAXALIGN, to accommodate opclasses that trim one * byte from the leaf datum per pass.) */ - if (leafSize > SPGIST_PAGE_CAPACITY) + if (leafSize > SpGistPageCapacityForCluster()) { bool ok = false; @@ -2272,7 +2272,7 @@ spgdoinsert(Relation index, SpGistState *state, (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), errmsg("index row size %zu exceeds maximum %zu for index \"%s\"", leafSize - sizeof(ItemIdData), - SPGIST_PAGE_CAPACITY - sizeof(ItemIdData), + SpGistPageCapacityForCluster() - sizeof(ItemIdData), RelationGetRelationName(index)), errhint("Values larger than a buffer page cannot be indexed."))); } diff --git a/src/backend/access/spgist/spgutils.c b/src/backend/access/spgist/spgutils.c index f2ee333f60d84..37b986d5d61ac 100644 --- a/src/backend/access/spgist/spgutils.c +++ b/src/backend/access/spgist/spgutils.c @@ -571,7 +571,7 @@ SpGistGetBuffer(Relation index, int flags, int needSpace, bool *isNew) SpGistLastUsedPage *lup; /* Bail out if even an empty page wouldn't meet the demand */ - if (needSpace > SPGIST_PAGE_CAPACITY) + if (needSpace > SpGistPageCapacityForCluster()) elog(ERROR, "desired SPGiST tuple size is too big"); /* @@ -582,7 +582,7 @@ SpGistGetBuffer(Relation index, int flags, int needSpace, bool *isNew) * error for requests that would otherwise be legal. */ needSpace += SpGistGetTargetPageFreeSpace(index); - needSpace = Min(needSpace, SPGIST_PAGE_CAPACITY); + needSpace = Min(needSpace, SpGistPageCapacityForCluster()); /* Get the cache entry for this flags setting */ lup = GET_LUP(cache, flags); @@ -1029,12 +1029,12 @@ spgFormInnerTuple(SpGistState *state, bool hasPrefix, Datum prefix, /* * Inner tuple should be small enough to fit on a page */ - if (size > SPGIST_PAGE_CAPACITY - sizeof(ItemIdData)) + if (size > SpGistPageCapacityForCluster() - sizeof(ItemIdData)) ereport(ERROR, (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), errmsg("SP-GiST inner tuple size %zu exceeds maximum %zu", (Size) size, - SPGIST_PAGE_CAPACITY - sizeof(ItemIdData)), + SpGistPageCapacityForCluster() - sizeof(ItemIdData)), errhint("Values larger than a buffer page cannot be indexed."))); /* diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c index f0434da40c945..ac6d1b2ff0c80 100644 --- a/src/backend/access/transam/xlog.c +++ b/src/backend/access/transam/xlog.c @@ -87,6 +87,7 @@ #include "replication/walsender.h" #include "storage/bufmgr.h" #include "storage/fd.h" +#include "storage/file_encryption.h" #include "storage/ipc.h" #include "storage/large_object.h" #include "storage/latch.h" @@ -4254,6 +4255,7 @@ CleanupBackupHistory(void) static void InitControlFile(uint64 sysidentifier, uint32 data_checksum_version) { + const char *file_encryption_library = FileEncryptionLibraryName(); char mock_auth_nonce[MOCK_AUTH_NONCE_LEN]; /* @@ -4285,6 +4287,28 @@ InitControlFile(uint64 sysidentifier, uint32 data_checksum_version) ControlFile->track_commit_timestamp = track_commit_timestamp; ControlFile->data_checksum_version = data_checksum_version; + /* + * Stamp the encryption library name into pg_control so frontend tools + * (which can't read postgresql.conf reliably across versions, but can + * always read pg_control) know which module to dlopen. The page + * reservation size comes from the module's declared page_overhead_size, + * which bootstrap's process_file_encryption_library() call has already + * resolved by the time we reach this point. Empty library / zero size + * for unencrypted clusters. + */ + if (file_encryption_library != NULL && file_encryption_library[0] != '\0') + { + strlcpy(ControlFile->file_encryption_library, + file_encryption_library, + sizeof(ControlFile->file_encryption_library)); + ControlFile->page_reserved_size = FileEncryptionPageReservedSize(); + } + else + { + ControlFile->file_encryption_library[0] = '\0'; + ControlFile->page_reserved_size = 0; + } + /* * Set the data_checksum_version value into XLogCtl, which is where all * processes get the current value from. @@ -4995,6 +5019,29 @@ GetDefaultCharSignedness(void) return ControlFile->default_char_signedness; } +/* + * Number of bytes reserved at the tail of every relation page for a file + * encryption module's per-page metadata. Set at initdb time and immutable + * afterwards; zero when no encryption is configured. Safe to call after + * LocalProcessControlFile() has run. + */ +uint32 +GetPageReservedSize(void) +{ + return ControlFile->page_reserved_size; +} + +/* + * Name of the file encryption library that was configured at initdb time. + * Empty string when the cluster is unencrypted. Safe to call after + * LocalProcessControlFile() has run. + */ +const char * +GetFileEncryptionLibrary(void) +{ + return ControlFile->file_encryption_library; +} + /* * Returns a fake LSN for unlogged relations. * diff --git a/src/backend/backup/basebackup.c b/src/backend/backup/basebackup.c index 9c79dadaacc55..a1cf38dd9d877 100644 --- a/src/backend/backup/basebackup.c +++ b/src/backend/backup/basebackup.c @@ -1316,8 +1316,16 @@ sendDir(bbsink *sink, const char *path, int basepathlen, bool sizeonly, &relfilenumber, &relForkNum, &segno); - /* Exclude all forks for unlogged tables except the init fork */ - if (isRelationFile && relForkNum != INIT_FORKNUM) + /* + * Exclude all forks for unlogged tables except the init fork and the + * key fork. The key fork carries the relation's wrapped DEK, which + * survives reinit and must be present on the restored cluster for + * the relation's pages to be readable -- including ones written by + * code paths that don't go through the init fork copy (the relation + * itself is empty on restart, but the DEK must still match). + */ + if (isRelationFile && relForkNum != INIT_FORKNUM && + relForkNum != KEY_FORKNUM) { char initForkFile[MAXPGPATH]; diff --git a/src/backend/bootstrap/bootstrap.c b/src/backend/bootstrap/bootstrap.c index b0dcd9876c56f..4ec70c4227e61 100644 --- a/src/backend/bootstrap/bootstrap.c +++ b/src/backend/bootstrap/bootstrap.c @@ -27,6 +27,7 @@ #include "catalog/index.h" #include "catalog/pg_authid.h" #include "catalog/pg_collation.h" +#include "catalog/pg_control.h" #include "catalog/pg_proc.h" #include "catalog/pg_type.h" #include "common/link-canary.h" @@ -37,6 +38,7 @@ #include "storage/bufpage.h" #include "storage/checksum.h" #include "storage/fd.h" +#include "storage/file_encryption.h" #include "storage/ipc.h" #include "storage/proc.h" #include "storage/shmem_internal.h" @@ -242,6 +244,7 @@ BootstrapModeMain(int argc, char *argv[], bool check_only) int flag; char *userDoption = NULL; uint32 bootstrap_data_checksum_version = PG_DATA_CHECKSUM_OFF; + const char *bootstrap_file_encryption_library = NULL; yyscan_t scanner; Assert(!IsUnderPostmaster); @@ -258,7 +261,7 @@ BootstrapModeMain(int argc, char *argv[], bool check_only) argv++; argc--; - pg_getopt_start(&optctx, argc, argv, "B:c:d:D:Fkr:X:-:"); + pg_getopt_start(&optctx, argc, argv, "B:c:d:D:FkL:r:X:-:"); while ((flag = pg_getopt_next(&optctx)) != -1) { switch (flag) @@ -327,6 +330,9 @@ BootstrapModeMain(int argc, char *argv[], bool check_only) case 'k': bootstrap_data_checksum_version = PG_DATA_CHECKSUM_VERSION; break; + case 'L': + bootstrap_file_encryption_library = pstrdup(optctx.optarg); + break; case 'r': strlcpy(OutputFileName, optctx.optarg, MAXPGPATH); break; @@ -405,6 +411,18 @@ BootstrapModeMain(int argc, char *argv[], bool check_only) BaseInit(); bootstrap_signals(); + + /* + * Load the file encryption module BEFORE BootStrapXLOG so that + * InitControlFile can populate page_reserved_size from the module's + * declared page_overhead_size. This is the source of truth for the + * cluster's reserved size; we never let the operator force a + * different value at initdb time. After this call returns + * FileEncryptionPageReservedSize() answers from the loaded module's + * page_overhead_size, and BootStrapXLOG copies that into pg_control. + */ + process_file_encryption_library(bootstrap_file_encryption_library); + BootStrapXLOG(bootstrap_data_checksum_version); /* diff --git a/src/backend/catalog/storage.c b/src/backend/catalog/storage.c index e443a4993c5e6..a0d7133590b88 100644 --- a/src/backend/catalog/storage.c +++ b/src/backend/catalog/storage.c @@ -29,6 +29,7 @@ #include "miscadmin.h" #include "pgstat.h" #include "storage/bulk_write.h" +#include "storage/file_encryption.h" #include "storage/freespace.h" #include "storage/proc.h" #include "storage/smgr.h" @@ -153,6 +154,56 @@ RelationCreateStorage(RelFileLocator rlocator, char relpersistence, if (needs_wal) log_smgrcreate(&srel->smgr_rlocator.locator, MAIN_FORKNUM); + /* + * If page encryption is configured, give the relation a KEY fork holding + * the wrapped per-relation data-encryption key. This must happen at + * relation-create time, while we still know that no encrypted page has + * yet been written for this rlocator and the module can mint a fresh + * DEK. The fork is one BLCKSZ block long, page-formatted by + * FileEncryptionGenerateObjectKey and exempt from md.c-level + * encryption. + * + * Write the block directly and synchronously: encryption-aware writes + * later in this transaction can call FileEncryptionOpenObject, which + * goes through smgrread. Going through the buffer manager would risk + * recursive AIO from inside an in-flight smgr write, so we keep the + * KEY fork strictly disk-resident. smgrimmedsync makes the bytes + * durable before the WAL record below is replayed on a standby. + */ + if (FileEncryptionEnabled()) + { + PGIOAlignedBlock keyblock; + + smgrcreate(srel, KEY_FORKNUM, false); + FileEncryptionGenerateObjectKey(&srel->smgr_rlocator.locator, + keyblock.data); + smgrextend(srel, KEY_FORKNUM, 0, keyblock.data, false); + smgrimmedsync(srel, KEY_FORKNUM); + + /* + * Always WAL-log the KEY fork on non-temp relations, even for + * unlogged ones (where the MAIN fork's content is intentionally + * unWAL'd). The wrapped DEK has to survive crashes so that + * unlogged-relation reinit can read the INIT fork's ciphertext on + * recovery, and standby replicas need it to encrypt new writes + * after promotion. This mirrors how the INIT fork itself is + * WAL-logged for unlogged relations by heapam_handler.c and + * index.c. + * + * Use a dedicated record (XLOG_SMGR_KEY_FORK_CREATE) rather than + * log_smgrcreate + log_newpage: its redo path writes the block + * directly to disk via smgrwrite + smgrimmedsync, so a subsequent + * encrypted write on the standby (typically an FPI for an INIT + * fork the redo applies right after) can always read the wrapped + * DEK back via smgrread. A log_newpage replay would leave the + * content in a dirty buffer that may not have reached disk yet, + * which would make the next encrypted write fail with "bad magic". + */ + if (relpersistence != RELPERSISTENCE_TEMP) + log_smgr_key_fork_create(&srel->smgr_rlocator.locator, + keyblock.data); + } + /* * Add the relation to the list of stuff to delete at abort, if we are * asked to do so. @@ -199,6 +250,26 @@ log_smgrcreate(const RelFileLocator *rlocator, ForkNumber forkNum) XLogInsert(RM_SMGR_ID, XLOG_SMGR_CREATE | XLR_SPECIAL_REL_UPDATE); } +/* + * Emit XLOG_SMGR_KEY_FORK_CREATE for the relation's KEY_FORKNUM creation, + * carrying the BLCKSZ-sized page-formatted wrapped-DEK block inline. The + * redo function reconstructs the file and writes the block synchronously + * without involving the buffer pool. + */ +void +log_smgr_key_fork_create(const RelFileLocator *rlocator, const char *keyblock) +{ + xl_smgr_key_fork_create xlrec; + + xlrec.rlocator = *rlocator; + + XLogBeginInsert(); + XLogRegisterData(&xlrec, sizeof(xlrec)); + XLogRegisterData(keyblock, BLCKSZ); + XLogInsert(RM_SMGR_ID, + XLOG_SMGR_KEY_FORK_CREATE | XLR_SPECIAL_REL_UPDATE); +} + /* * RelationDropStorage * Schedule unlinking of physical storage at transaction commit. @@ -796,6 +867,20 @@ smgrDoPendingSyncs(bool isCommit, bool isParallelWorker) { for (fork = 0; fork <= MAX_FORKNUM; fork++) { + /* + * KEY fork is owned by the file-encryption framework, not + * page-formatted, and already WAL-logged at create time via + * log_newpage in RelationCreateStorage. Exclude it from both + * the size-accounting and (below) the log_newpage_range loop. + * smgrdosyncall picks it up normally if the relation lands in + * the fsync path. + */ + if (fork == KEY_FORKNUM) + { + nblocks[fork] = InvalidBlockNumber; + continue; + } + if (smgrexists(srel, fork)) { BlockNumber n = smgrnblocks(srel, fork); @@ -994,6 +1079,47 @@ smgr_redo(XLogReaderState *record) reln = smgropen(xlrec->rlocator, INVALID_PROC_NUMBER); smgrcreate(reln, xlrec->forkNum, true); } + else if (info == XLOG_SMGR_KEY_FORK_CREATE) + { + xl_smgr_key_fork_create *xlrec; + PGIOAlignedBlock keyblock; + SMgrRelation reln; + + xlrec = (xl_smgr_key_fork_create *) XLogRecGetData(record); + + /* + * The WAL record's payload may sit at any alignment, but the + * smgr write path asserts PG_IO_ALIGN_SIZE alignment on the + * caller's buffer. Copy to a stack-resident PGIOAlignedBlock + * which always satisfies that requirement. + */ + memcpy(keyblock.data, XLogRecGetData(record) + sizeof(*xlrec), BLCKSZ); + + reln = smgropen(xlrec->rlocator, INVALID_PROC_NUMBER); + smgrcreate(reln, KEY_FORKNUM, true); + + /* + * Write the page-formatted KEY fork block straight to disk and + * fsync it. Going through the buffer manager would leave the + * content in a dirty shared buffer; if the next redo step is an + * encrypted-fork FPI whose application triggers a buffer + * eviction, the resulting smgrwrite of the encrypted page would + * call FileEncryptionOpenObject -> smgrread on the KEY fork and + * see the all-zero on-disk remnant of mdzeroextend instead of + * the wrapped DEK. + */ + /* + * Extend on a fresh redo; overwrite if the fork already has block + * 0 (e.g. recovery restarted after a previous crash had already + * applied this record). Either way smgrimmedsync below makes the + * bytes durable before the next redo step runs. + */ + if (smgrnblocks(reln, KEY_FORKNUM) == 0) + smgrextend(reln, KEY_FORKNUM, 0, keyblock.data, false); + else + smgrwrite(reln, KEY_FORKNUM, 0, keyblock.data, false); + smgrimmedsync(reln, KEY_FORKNUM); + } else if (info == XLOG_SMGR_TRUNCATE) { xl_smgr_truncate *xlrec = (xl_smgr_truncate *) XLogRecGetData(record); diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c index 88451c9144811..61ddae34a5252 100644 --- a/src/backend/commands/tablecmds.c +++ b/src/backend/commands/tablecmds.c @@ -17446,6 +17446,16 @@ index_copy_data(Relation rel, RelFileLocator newrlocator) for (ForkNumber forkNum = MAIN_FORKNUM + 1; forkNum <= MAX_FORKNUM; forkNum++) { + /* + * KEY_FORKNUM was already created by RelationCreateStorage above + * with a freshly minted destination DEK; do not copy the source's + * wrapped DEK over it. RelationCopyStorage transparently decrypts + * under the source DEK on read and re-encrypts under the + * destination DEK on write for the data forks. + */ + if (forkNum == KEY_FORKNUM) + continue; + if (smgrexists(RelationGetSmgr(rel), forkNum)) { smgrcreate(dstrel, forkNum, false); diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c index 7c4be1748699d..4f6d17f64d0bb 100644 --- a/src/backend/optimizer/util/plancat.c +++ b/src/backend/optimizer/util/plancat.c @@ -1381,7 +1381,8 @@ estimate_rel_size(Relation rel, int32 *attr_widths, tuple_width += MAXALIGN(SizeofHeapTupleHeader); tuple_width += sizeof(ItemIdData); /* note: integer division is intentional here */ - density = (BLCKSZ - SizeOfPageHeaderData) / tuple_width; + density = (BLCKSZ - GetPageReservedSize() - SizeOfPageHeaderData) / + tuple_width; } *tuples = rint(density * (double) curpages); diff --git a/src/backend/postmaster/datachecksum_state.c b/src/backend/postmaster/datachecksum_state.c index 33430147ff293..49ea891d566b2 100644 --- a/src/backend/postmaster/datachecksum_state.c +++ b/src/backend/postmaster/datachecksum_state.c @@ -770,6 +770,14 @@ ProcessSingleRelationByOid(Oid relationId, BufferAccessStrategy strategy) for (ForkNumber fnum = 0; fnum <= MAX_FORKNUM; fnum++) { + /* + * KEY_FORKNUM stores the wrapped per-relation data-encryption key, + * not a Page-formatted block. It's exempt from page encryption + * (md.c bypasses it) and has no checksum field, so skip it. + */ + if (fnum == KEY_FORKNUM) + continue; + if (smgrexists(rel->rd_smgr, fnum)) { if (!ProcessSingleRelationFork(rel, fnum, strategy)) diff --git a/src/backend/postmaster/launch_backend.c b/src/backend/postmaster/launch_backend.c index 8f3cfea880c3c..906a7b31967f2 100644 --- a/src/backend/postmaster/launch_backend.c +++ b/src/backend/postmaster/launch_backend.c @@ -67,6 +67,7 @@ #include "common/file_utils.h" #include "storage/fd.h" +#include "storage/file_encryption.h" #include "storage/lwlock.h" #include "storage/pmsignal.h" #include "storage/proc.h" @@ -676,6 +677,7 @@ SubPostmasterMain(int argc, char *argv[]) * non-EXEC_BACKEND behavior. */ process_shared_preload_libraries(); + process_file_encryption_library(NULL); /* Restore basic shared memory pointers */ if (UsedShmemSegAddr != NULL) diff --git a/src/backend/postmaster/postmaster.c b/src/backend/postmaster/postmaster.c index 90c7c4528e872..76bf24a693882 100644 --- a/src/backend/postmaster/postmaster.c +++ b/src/backend/postmaster/postmaster.c @@ -111,6 +111,7 @@ #include "replication/walsender.h" #include "storage/aio_subsys.h" #include "storage/fd.h" +#include "storage/file_encryption.h" #include "storage/io_worker.h" #include "storage/ipc.h" #include "storage/pmsignal.h" @@ -935,6 +936,12 @@ PostmasterMain(int argc, char *argv[]) */ process_shared_preload_libraries(); + /* + * Load the file encryption module, if configured, so that its _PG_init + * runs at postmaster start (matching shared_preload_libraries' timing). + */ + process_file_encryption_library(NULL); + /* * Initialize SSL library, if specified. */ diff --git a/src/backend/replication/logical/reorderbuffer.c b/src/backend/replication/logical/reorderbuffer.c index 682d13c9f22f0..eb1284248b12b 100644 --- a/src/backend/replication/logical/reorderbuffer.c +++ b/src/backend/replication/logical/reorderbuffer.c @@ -98,6 +98,7 @@ #include "catalog/catalog.h" #include "common/int.h" #include "lib/binaryheap.h" +#include "lib/stringinfo.h" #include "miscadmin.h" #include "pgstat.h" #include "replication/logical.h" @@ -106,6 +107,7 @@ #include "replication/snapbuild.h" /* just for SnapBuildSnapDecRefcount */ #include "storage/bufmgr.h" #include "storage/fd.h" +#include "storage/file_encryption.h" #include "storage/procarray.h" #include "storage/sinval.h" #include "utils/builtins.h" @@ -195,6 +197,19 @@ typedef struct ReorderBufferDiskChange /* data follows */ } ReorderBufferDiskChange; +/* + * Header written ahead of each ciphertext blob in an encrypted spill file. + * + * Spill files are process-local and transient (recreated on every decoding + * run), so this struct does not need to be portable across platforms. Size + * is used here for consistency with ReorderBufferDiskChange.size. + */ +typedef struct ReorderBufferEncryptedRecord +{ + Size plaintext_size; + Size ciphertext_size; +} ReorderBufferEncryptedRecord; + #define IsSpecInsert(action) \ ( \ ((action) == REORDER_BUFFER_CHANGE_INTERNAL_SPEC_INSERT) \ @@ -266,7 +281,11 @@ static void ReorderBufferExecuteInvalidations(uint32 nmsgs, SharedInvalidationMe static void ReorderBufferCheckMemoryLimit(ReorderBuffer *rb); static void ReorderBufferSerializeTXN(ReorderBuffer *rb, ReorderBufferTXN *txn); static void ReorderBufferSerializeChange(ReorderBuffer *rb, ReorderBufferTXN *txn, - int fd, ReorderBufferChange *change); + const char *path, int fd, + pgoff_t *write_offset, + StringInfo ciphertext, + StringInfo writebuf, + ReorderBufferChange *change); static Size ReorderBufferRestoreChanges(ReorderBuffer *rb, ReorderBufferTXN *txn, TXNEntryFile *file, XLogSegNo *segno); static void ReorderBufferRestoreChange(ReorderBuffer *rb, ReorderBufferTXN *txn, @@ -4000,6 +4019,11 @@ ReorderBufferSerializeTXN(ReorderBuffer *rb, ReorderBufferTXN *txn) XLogSegNo curOpenSegNo = 0; Size spilled = 0; Size size = txn->size; + char path[MAXPGPATH]; + bool encrypted = FileEncryptionEnabled(); + StringInfoData ciphertext; + StringInfoData writebuf; + pgoff_t write_offset = 0; elog(DEBUG2, "spill %u changes in XID %u to disk", (uint32) txn->nentries_mem, txn->xid); @@ -4013,6 +4037,16 @@ ReorderBufferSerializeTXN(ReorderBuffer *rb, ReorderBufferTXN *txn) ReorderBufferSerializeTXN(rb, subtxn); } + /* + * When encryption is enabled, allocate the per-record buffers once and + * reuse them across writes to avoid palloc/pfree churn. + */ + if (encrypted) + { + initStringInfo(&ciphertext); + initStringInfo(&writebuf); + } + /* serialize changestream */ dlist_foreach_modify(change_i, &txn->changes) { @@ -4027,8 +4061,6 @@ ReorderBufferSerializeTXN(ReorderBuffer *rb, ReorderBufferTXN *txn) if (fd == -1 || !XLByteInSeg(change->lsn, curOpenSegNo, wal_segment_size)) { - char path[MAXPGPATH]; - if (fd != -1) CloseTransientFile(fd); @@ -4041,7 +4073,6 @@ ReorderBufferSerializeTXN(ReorderBuffer *rb, ReorderBufferTXN *txn) ReorderBufferSerializedPath(path, MyReplicationSlot, txn->xid, curOpenSegNo); - /* open segment, create it if necessary */ fd = OpenTransientFile(path, O_CREAT | O_WRONLY | O_APPEND | PG_BINARY); @@ -4049,9 +4080,36 @@ ReorderBufferSerializeTXN(ReorderBuffer *rb, ReorderBufferTXN *txn) ereport(ERROR, (errcode_for_file_access(), errmsg("could not open file \"%s\": %m", path))); + + /* + * Encryption modules use the file offset of each record to + * derive a per-record IV. The file is opened O_APPEND, so + * writes always go to the end; learn the current end once + * here, then advance our local cursor with each write. + * + * Safe because spill files are owned by a single decoding + * worker (the slot's): nothing else writes to this fd, so the + * locally tracked write_offset stays in lockstep with the + * kernel's append position. A future caller introducing + * concurrent writers would have to abandon the offset + * tracking here. + */ + if (encrypted) + { + write_offset = lseek(fd, 0, SEEK_END); + if (write_offset < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not determine end of spill file \"%s\": %m", + path))); + } } - ReorderBufferSerializeChange(rb, txn, fd, change); + ReorderBufferSerializeChange(rb, txn, path, fd, + encrypted ? &write_offset : NULL, + encrypted ? &ciphertext : NULL, + encrypted ? &writebuf : NULL, + change); dlist_delete(&change->node); ReorderBufferFreeChange(rb, change, false); @@ -4081,14 +4139,28 @@ ReorderBufferSerializeTXN(ReorderBuffer *rb, ReorderBufferTXN *txn) if (fd != -1) CloseTransientFile(fd); + if (encrypted) + { + pfree(ciphertext.data); + pfree(writebuf.data); + } } /* * Serialize individual change to disk. + * + * If file encryption is enabled, the caller supplies pre-allocated + * "ciphertext" and "writebuf" buffers, and "*write_offset" tracks the + * position at which the next encrypted record will be written. These + * arguments may be NULL when encryption is disabled. */ static void ReorderBufferSerializeChange(ReorderBuffer *rb, ReorderBufferTXN *txn, - int fd, ReorderBufferChange *change) + const char *path, int fd, + pgoff_t *write_offset, + StringInfo ciphertext, + StringInfo writebuf, + ReorderBufferChange *change) { ReorderBufferDiskChange *ondisk; Size sz = sizeof(ReorderBufferDiskChange); @@ -4269,22 +4341,66 @@ ReorderBufferSerializeChange(ReorderBuffer *rb, ReorderBufferTXN *txn, ondisk->size = sz; - errno = 0; - pgstat_report_wait_start(WAIT_EVENT_REORDER_BUFFER_WRITE); - if (write(fd, rb->outbuf, ondisk->size) != ondisk->size) + if (FileEncryptionEnabled()) { - int save_errno = errno; + ReorderBufferEncryptedRecord encrypted_record; + Size ciphertext_size; - CloseTransientFile(fd); + Assert(ciphertext != NULL && writebuf != NULL && write_offset != NULL); - /* if write didn't set errno, assume problem is no disk space */ - errno = save_errno ? save_errno : ENOSPC; - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not write to data file for XID %u: %m", - txn->xid))); + ciphertext_size = ondisk->size + FileEncryptionOverheadSize(); + + encrypted_record.plaintext_size = ondisk->size; + encrypted_record.ciphertext_size = ciphertext_size; + + resetStringInfo(ciphertext); + enlargeStringInfo(ciphertext, (int) ciphertext_size); + FileEncryptionEncrypt(path, *write_offset, + rb->outbuf, ondisk->size, ciphertext->data); + ciphertext->len = (int) ciphertext_size; + + resetStringInfo(writebuf); + appendBinaryStringInfo(writebuf, (char *) &encrypted_record, + sizeof(encrypted_record)); + appendBinaryStringInfo(writebuf, ciphertext->data, ciphertext->len); + + errno = 0; + pgstat_report_wait_start(WAIT_EVENT_REORDER_BUFFER_WRITE); + if (write(fd, writebuf->data, writebuf->len) != writebuf->len) + { + int save_errno = errno; + + CloseTransientFile(fd); + + errno = save_errno ? save_errno : ENOSPC; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write to data file for XID %u: %m", + txn->xid))); + } + pgstat_report_wait_end(); + + *write_offset += writebuf->len; + } + else + { + errno = 0; + pgstat_report_wait_start(WAIT_EVENT_REORDER_BUFFER_WRITE); + if (write(fd, rb->outbuf, ondisk->size) != ondisk->size) + { + int save_errno = errno; + + CloseTransientFile(fd); + + /* if write didn't set errno, assume problem is no disk space */ + errno = save_errno ? save_errno : ENOSPC; + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write to data file for XID %u: %m", + txn->xid))); + } + pgstat_report_wait_end(); } - pgstat_report_wait_end(); /* * Keep the transaction's final_lsn up to date with each change we send to @@ -4535,6 +4651,38 @@ ReorderBufferChangeSize(ReorderBufferChange *change) } +/* + * Read a contiguous chunk from a spill file into "dst". + * + * Updates file->curOffset on success. Returns 0 if EOF is encountered at the + * very start of the read AND eof_ok is true (used for record headers to + * detect end-of-segment); otherwise raises an error on any short read or + * outright failure. + */ +static int +ReorderBufferReadSpill(TXNEntryFile *file, void *dst, Size sz, bool eof_ok) +{ + int readBytes; + + readBytes = FileRead(file->vfd, dst, sz, file->curOffset, + WAIT_EVENT_REORDER_BUFFER_READ); + + if (readBytes == 0 && eof_ok) + return 0; + if (readBytes < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not read from reorderbuffer spill file: %m"))); + if ((Size) readBytes != sz) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not read from reorderbuffer spill file: read %d instead of %u bytes", + readBytes, (uint32) sz))); + + file->curOffset += readBytes; + return readBytes; +} + /* * Restore a number of changes spilled to disk back into memory. */ @@ -4546,10 +4694,20 @@ ReorderBufferRestoreChanges(ReorderBuffer *rb, ReorderBufferTXN *txn, XLogSegNo last_segno; dlist_mutable_iter cleanup_iter; File *fd = &file->vfd; + char path[MAXPGPATH]; + bool encrypted = FileEncryptionEnabled(); + StringInfoData ciphertext; + StringInfoData plaintext; Assert(XLogRecPtrIsValid(txn->first_lsn)); Assert(XLogRecPtrIsValid(txn->final_lsn)); + if (encrypted) + { + initStringInfo(&ciphertext); + initStringInfo(&plaintext); + } + /* free current entries, so we have memory for more */ dlist_foreach_modify(cleanup_iter, &txn->changes) { @@ -4566,28 +4724,30 @@ ReorderBufferRestoreChanges(ReorderBuffer *rb, ReorderBufferTXN *txn, while (restored < max_changes_in_memory && *segno <= last_segno) { - int readBytes; ReorderBufferDiskChange *ondisk; + char *plaintext_data; CHECK_FOR_INTERRUPTS(); - if (*fd == -1) - { - char path[MAXPGPATH]; - - /* first time in */ - if (*segno == 0) - XLByteToSeg(txn->first_lsn, *segno, wal_segment_size); + /* + * Path is needed both for opening a new segment and (when encryption + * is enabled) for the encryption module's per-record IV derivation, + * so derive it once per iteration. *segno always matches the + * currently-open or about-to-be-opened file. + */ + if (*fd == -1 && *segno == 0) + XLByteToSeg(txn->first_lsn, *segno, wal_segment_size); - Assert(*segno != 0 || dlist_is_empty(&txn->changes)); + Assert(*segno != 0 || dlist_is_empty(&txn->changes)); - /* - * No need to care about TLIs here, only used during a single run, - * so each LSN only maps to a specific WAL record. - */ - ReorderBufferSerializedPath(path, MyReplicationSlot, txn->xid, - *segno); + /* + * No need to care about TLIs here, only used during a single run, so + * each LSN only maps to a specific WAL record. + */ + ReorderBufferSerializedPath(path, MyReplicationSlot, txn->xid, *segno); + if (*fd == -1) + { *fd = PathNameOpenFile(path, O_RDONLY | PG_BINARY); /* No harm in resetting the offset even in case of failure */ @@ -4604,6 +4764,7 @@ ReorderBufferRestoreChanges(ReorderBuffer *rb, ReorderBufferTXN *txn, (errcode_for_file_access(), errmsg("could not open file \"%s\": %m", path))); + } /* @@ -4611,65 +4772,99 @@ ReorderBufferRestoreChanges(ReorderBuffer *rb, ReorderBufferTXN *txn, * about the total size. If we couldn't read a record, we're at the * end of this file. */ - ReorderBufferSerializeReserve(rb, sizeof(ReorderBufferDiskChange)); - readBytes = FileRead(file->vfd, rb->outbuf, - sizeof(ReorderBufferDiskChange), - file->curOffset, WAIT_EVENT_REORDER_BUFFER_READ); - - /* eof */ - if (readBytes == 0) + if (encrypted) { - FileClose(*fd); - *fd = -1; - (*segno)++; - continue; - } - else if (readBytes < 0) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not read from reorderbuffer spill file: %m"))); - else if (readBytes != sizeof(ReorderBufferDiskChange)) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not read from reorderbuffer spill file: read %d instead of %u bytes", - readBytes, - (uint32) sizeof(ReorderBufferDiskChange)))); + ReorderBufferEncryptedRecord encrypted_record; + pgoff_t record_offset = file->curOffset; + + if (ReorderBufferReadSpill(file, &encrypted_record, + sizeof(encrypted_record), true) == 0) + { + FileClose(*fd); + *fd = -1; + (*segno)++; + continue; + } - file->curOffset += readBytes; + if (encrypted_record.ciphertext_size > MaxAllocSize || + encrypted_record.plaintext_size > MaxAllocSize) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("reorderbuffer spill record has an invalid size"))); + if (encrypted_record.ciphertext_size != + encrypted_record.plaintext_size + FileEncryptionOverheadSize()) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("reorderbuffer spill record is inconsistent with module overhead"))); + if (encrypted_record.plaintext_size < sizeof(ReorderBufferDiskChange)) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("reorderbuffer spill record is truncated"))); + + resetStringInfo(&ciphertext); + enlargeStringInfo(&ciphertext, (int) encrypted_record.ciphertext_size); + + ReorderBufferReadSpill(file, ciphertext.data, + encrypted_record.ciphertext_size, false); + ciphertext.len = encrypted_record.ciphertext_size; + ciphertext.data[ciphertext.len] = '\0'; + + resetStringInfo(&plaintext); + enlargeStringInfo(&plaintext, (int) encrypted_record.plaintext_size); + FileEncryptionDecrypt(path, record_offset, + ciphertext.data, ciphertext.len, + plaintext.data); + plaintext.len = (int) encrypted_record.plaintext_size; + + ondisk = (ReorderBufferDiskChange *) plaintext.data; + if (ondisk->size != plaintext.len) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("reorderbuffer spill record header is inconsistent with its size"))); - ondisk = (ReorderBufferDiskChange *) rb->outbuf; + plaintext_data = plaintext.data; + } + else + { + ReorderBufferSerializeReserve(rb, sizeof(ReorderBufferDiskChange)); + if (ReorderBufferReadSpill(file, rb->outbuf, + sizeof(ReorderBufferDiskChange), + true) == 0) + { + FileClose(*fd); + *fd = -1; + (*segno)++; + continue; + } - ReorderBufferSerializeReserve(rb, - sizeof(ReorderBufferDiskChange) + ondisk->size); - ondisk = (ReorderBufferDiskChange *) rb->outbuf; + ondisk = (ReorderBufferDiskChange *) rb->outbuf; - readBytes = FileRead(file->vfd, - rb->outbuf + sizeof(ReorderBufferDiskChange), - ondisk->size - sizeof(ReorderBufferDiskChange), - file->curOffset, - WAIT_EVENT_REORDER_BUFFER_READ); + ReorderBufferSerializeReserve(rb, + sizeof(ReorderBufferDiskChange) + ondisk->size); + ondisk = (ReorderBufferDiskChange *) rb->outbuf; - if (readBytes < 0) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not read from reorderbuffer spill file: %m"))); - else if (readBytes != ondisk->size - sizeof(ReorderBufferDiskChange)) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not read from reorderbuffer spill file: read %d instead of %u bytes", - readBytes, - (uint32) (ondisk->size - sizeof(ReorderBufferDiskChange))))); + ReorderBufferReadSpill(file, + rb->outbuf + sizeof(ReorderBufferDiskChange), + ondisk->size - sizeof(ReorderBufferDiskChange), + false); - file->curOffset += readBytes; + plaintext_data = rb->outbuf; + } /* * ok, read a full change from disk, now restore it into proper * in-memory format */ - ReorderBufferRestoreChange(rb, txn, rb->outbuf); + ReorderBufferRestoreChange(rb, txn, plaintext_data); restored++; } + if (encrypted) + { + pfree(ciphertext.data); + pfree(plaintext.data); + } + return restored; } diff --git a/src/backend/storage/aio/aio_io.c b/src/backend/storage/aio/aio_io.c index 72b4c9feb3a69..17fe6edd78433 100644 --- a/src/backend/storage/aio/aio_io.c +++ b/src/backend/storage/aio/aio_io.c @@ -37,11 +37,18 @@ static void pgaio_io_before_start(PgAioHandle *ioh); /* * Scatter/gather IO needs to associate an iovec with the Handle. To support * worker mode this data needs to be in shared memory. + * + * Callable from the issuing backend during IO setup (HANDED_OUT) and from + * shared completion callbacks (COMPLETED_IO / COMPLETED_SHARED), which need + * to inspect the buffer pointers to perform per-block post-processing such + * as decryption. The iovec slot persists until the handle is reused. */ int pgaio_io_get_iovec(PgAioHandle *ioh, struct iovec **iov) { - Assert(ioh->state == PGAIO_HS_HANDED_OUT); + Assert(ioh->state == PGAIO_HS_HANDED_OUT || + ioh->state == PGAIO_HS_COMPLETED_IO || + ioh->state == PGAIO_HS_COMPLETED_SHARED); *iov = &pgaio_ctl->iovecs[ioh->iovec_off]; diff --git a/src/backend/storage/buffer/bufmgr.c b/src/backend/storage/buffer/bufmgr.c index 1878efb4aa998..972f972339bf5 100644 --- a/src/backend/storage/buffer/bufmgr.c +++ b/src/backend/storage/buffer/bufmgr.c @@ -5489,6 +5489,18 @@ CreateAndCopyRelationData(RelFileLocator src_rlocator, for (ForkNumber forkNum = MAIN_FORKNUM + 1; forkNum <= MAX_FORKNUM; forkNum++) { + /* + * KEY_FORKNUM is handled by RelationCreateStorage above: the + * destination already has a freshly minted per-relation DEK, + * distinct from the source's. The buffer-manager-driven copy of + * the MAIN (and INIT) forks below transparently decrypts under + * the source's DEK on read and re-encrypts under the destination's + * DEK on write, so we deliberately do not copy the source KEY + * fork bytes into the destination. + */ + if (forkNum == KEY_FORKNUM) + continue; + if (smgrexists(src_rel, forkNum)) { smgrcreate(dst_rel, forkNum, false); diff --git a/src/backend/storage/file/Makefile b/src/backend/storage/file/Makefile index 660ac51807e79..af071351329af 100644 --- a/src/backend/storage/file/Makefile +++ b/src/backend/storage/file/Makefile @@ -16,6 +16,7 @@ OBJS = \ buffile.o \ copydir.o \ fd.o \ + file_encryption.o \ fileset.o \ reinit.o \ sharedfileset.o diff --git a/src/backend/storage/file/buffile.c b/src/backend/storage/file/buffile.c index c4afe4d368a34..5d53c52001c27 100644 --- a/src/backend/storage/file/buffile.c +++ b/src/backend/storage/file/buffile.c @@ -47,11 +47,14 @@ #include "commands/tablespace.h" #include "executor/instrument.h" +#include "lib/stringinfo.h" #include "miscadmin.h" #include "pgstat.h" #include "storage/buffile.h" #include "storage/bufmgr.h" #include "storage/fd.h" +#include "storage/file_encryption.h" +#include "utils/memutils.h" #include "utils/resowner.h" #include "utils/wait_event.h" @@ -63,6 +66,27 @@ #define MAX_PHYSICAL_FILESIZE 0x40000000 #define BUFFILE_SEG_SIZE (MAX_PHYSICAL_FILESIZE / BLCKSZ) +/* + * When file encryption is enabled, each BLCKSZ plaintext block becomes one + * fixed-size physical block on disk: + * + * [ uint32 plaintext_len ] [ uint32 ciphertext_len ] [ ciphertext... ] + * + * with the rest of the physical block left as unspecified slack. Fixing + * the physical block size lets us seek to logical block N at a known + * physical offset without an offset map. + * + * BUFFILE_ENC_OVERHEAD bounds the room available for the 8-byte header plus + * any per-record overhead the encryption module wants to add (e.g. IVs, + * auth tags, or wrapped data keys). Modules that need more than + * (OVERHEAD - HEADER) bytes of overhead per BLCKSZ plaintext cannot be used + * for BufFile. + */ +#define BUFFILE_ENC_OVERHEAD 256 +#define BUFFILE_ENC_HEADER_SIZE 8 +#define BUFFILE_PHYSICAL_BLOCK_SIZE (BLCKSZ + BUFFILE_ENC_OVERHEAD) +#define BUFFILE_MAX_CIPHERTEXT (BUFFILE_PHYSICAL_BLOCK_SIZE - BUFFILE_ENC_HEADER_SIZE) + /* * This data structure represents a buffered file that consists of one or * more physical files (each accessed through a virtual file descriptor @@ -77,10 +101,31 @@ struct BufFile bool isInterXact; /* keep open over transactions? */ bool dirty; /* does buffer need to be written? */ bool readOnly; /* has the file been set to read only? */ + bool encrypted; /* snapshot of FileEncryptionEnabled() at create */ + + /* + * Encrypted-mode bookkeeping. buffer_from_disk is true when the current + * buffer reflects what's on disk for its block; it goes false after + * BufFileSeek invalidates the buffer. highest_dumped_offset is the + * plaintext-space high-water mark across all physical files, used to + * distinguish fresh appends from writes that must first load and preserve + * existing block contents. + */ + bool buffer_from_disk; + int64 highest_dumped_offset; FileSet *fileset; /* space for fileset based segment files */ const char *name; /* name of fileset based BufFile */ + /* + * Reusable encryption-side buffers; lazily allocated on first encrypted + * I/O so non-encrypted BufFiles pay no allocator cost. Sized to the + * maximum per-block ciphertext / plaintext footprint for the loaded + * module. + */ + char *enc_ciphertext_buf; + char *enc_plaintext_buf; + /* * resowner is the ResourceOwner to use for underlying temp files. (We * don't need to remember the memory context we're using explicitly, @@ -111,6 +156,14 @@ static void BufFileLoadBuffer(BufFile *file); static void BufFileDumpBuffer(BufFile *file); static void BufFileFlush(BufFile *file); static File MakeNewFileSetSegment(BufFile *buffile, int segment); +static int BufFileReadEncryptedBlock(BufFile *file, int fileno, + pgoff_t block_start, bool missing_ok); +static pgoff_t BufFileWriteEncryptedBlock(BufFile *file, int fileno, + pgoff_t block_start, + const char *data, + uint32 plaintext_len); +static bool BufFileLoadEncryptedBlock(BufFile *file, bool for_write); +static void BufFilePrepareEncryptedWrite(BufFile *file); /* * Create BufFile and perform the common initialization. @@ -123,15 +176,400 @@ makeBufFileCommon(int nfiles) file->numFiles = nfiles; file->isInterXact = false; file->dirty = false; + file->encrypted = FileEncryptionEnabled(); + file->buffer_from_disk = false; + file->highest_dumped_offset = 0; file->resowner = CurrentResourceOwner; file->curFile = 0; file->curOffset = 0; file->pos = 0; file->nbytes = 0; + file->enc_ciphertext_buf = NULL; + file->enc_plaintext_buf = NULL; return file; } +/* + * Maximum logical (plaintext) bytes that fit in one physical component file. + * + * For the no-encryption path this is MAX_PHYSICAL_FILESIZE, exactly matching + * upstream so the on-disk layout is unchanged. For encrypted BufFiles each + * physical file holds N fixed-size BUFFILE_PHYSICAL_BLOCK_SIZE slots, so + * the available plaintext space shrinks by any partial trailing block. + */ +static inline pgoff_t +BufFilePlaintextPerFile(const BufFile *file) +{ + if (!file->encrypted) + return (pgoff_t) MAX_PHYSICAL_FILESIZE; + return ((pgoff_t) MAX_PHYSICAL_FILESIZE / + BUFFILE_PHYSICAL_BLOCK_SIZE) * BLCKSZ; +} + +static inline int64 +BufFilePlaintextBlocksPerFile(const BufFile *file) +{ + if (!file->encrypted) + return (int64) BUFFILE_SEG_SIZE; + return ((int64) MAX_PHYSICAL_FILESIZE / BUFFILE_PHYSICAL_BLOCK_SIZE); +} + +/* + * Translate a plaintext offset within a physical file to the physical + * offset where its encrypted representation begins. + */ +static inline pgoff_t +BufFilePhysicalOffset(const BufFile *file, pgoff_t plaintext_offset) +{ + Assert((plaintext_offset % BLCKSZ) == 0); + return (plaintext_offset / BLCKSZ) * BUFFILE_PHYSICAL_BLOCK_SIZE; +} + +/* + * Lazily set up the per-BufFile reusable encryption staging buffers. + * + * The ciphertext buffer needs room for BLCKSZ plaintext plus the loaded + * module's per-record overhead. Enforce that the result still fits in a + * physical BufFile block; modules with very large overhead can't be used + * for BufFile. + */ +static inline void +BufFileEnsureEncBuffers(BufFile *file) +{ + if (file->enc_ciphertext_buf == NULL) + { + MemoryContext oldcontext; + Size max_ciphertext = BLCKSZ + FileEncryptionOverheadSize(); + + if (max_ciphertext > BUFFILE_MAX_CIPHERTEXT) + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("file encryption module overhead %zu exceeds BufFile per-block limit %zu", + FileEncryptionOverheadSize(), + (Size) (BUFFILE_MAX_CIPHERTEXT - BLCKSZ)))); + + oldcontext = MemoryContextSwitchTo(GetMemoryChunkContext(file)); + file->enc_ciphertext_buf = palloc(max_ciphertext); + file->enc_plaintext_buf = palloc(BLCKSZ); + MemoryContextSwitchTo(oldcontext); + } +} + +/* + * Compute the logical (plaintext) size of one physical component file. + * + * For unencrypted BufFiles, this is just FileSize(). For encrypted ones, + * we count full physical blocks and probe the trailing partial block (if + * any) to read its plaintext length out of the on-disk header. + * + * This implicitly assumes that every block before the trailing one has + * plaintext_len == BLCKSZ. BufFileDumpBuffer enforces that invariant when + * dumping (a partial block can only be the file's high-water mark). If a + * future caller violated it — e.g. by flushing a partial block and then + * writing past it without re-loading — this function would understate the + * logical size, since it credits each non-trailing block at full BLCKSZ. + */ +static int64 +BufFileLogicalSize(BufFile *file, int fileno) +{ + int64 phys_size; + int64 full_blocks; + int64 remainder; + char header[BUFFILE_ENC_HEADER_SIZE]; + uint32 plaintext_len; + int hdr_read; + + phys_size = FileSize(file->files[fileno]); + if (phys_size < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not determine size of temporary file \"%s\" from BufFile \"%s\": %m", + FilePathName(file->files[fileno]), + file->name))); + if (!file->encrypted) + return phys_size; + + full_blocks = phys_size / BUFFILE_PHYSICAL_BLOCK_SIZE; + remainder = phys_size - full_blocks * BUFFILE_PHYSICAL_BLOCK_SIZE; + + if (remainder == 0) + return full_blocks * BLCKSZ; + + hdr_read = FileRead(file->files[fileno], header, sizeof(header), + full_blocks * BUFFILE_PHYSICAL_BLOCK_SIZE, + WAIT_EVENT_BUFFILE_READ); + if (hdr_read != sizeof(header)) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("could not read encrypted BufFile header for size of \"%s\"", + FilePathName(file->files[fileno])))); + memcpy(&plaintext_len, header, sizeof(plaintext_len)); + if (plaintext_len > BLCKSZ) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("invalid encrypted BufFile header in \"%s\"", + FilePathName(file->files[fileno])))); + + return full_blocks * BLCKSZ + plaintext_len; +} + +static int +BufFileReadEncryptedBlock(BufFile *file, int fileno, pgoff_t block_start, + bool missing_ok) +{ + File thisfile; + char header[BUFFILE_ENC_HEADER_SIZE]; + uint32 plaintext_len; + uint32 ciphertext_len; + pgoff_t phys_offset; + int hdr_read; + int ct_read; + instr_time io_start; + instr_time io_time; + + Assert(file->encrypted); + Assert((block_start % BLCKSZ) == 0); + + BufFileEnsureEncBuffers(file); + + thisfile = file->files[fileno]; + phys_offset = BufFilePhysicalOffset(file, block_start); + + if (track_io_timing) + INSTR_TIME_SET_CURRENT(io_start); + else + INSTR_TIME_SET_ZERO(io_start); + + hdr_read = FileRead(thisfile, header, sizeof(header), phys_offset, + WAIT_EVENT_BUFFILE_READ); + if (hdr_read == 0 && missing_ok) + { + if (track_io_timing) + { + INSTR_TIME_SET_CURRENT(io_time); + INSTR_TIME_ACCUM_DIFF(pgBufferUsage.temp_blk_read_time, + io_time, io_start); + } + return 0; + } + if (hdr_read < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not read file \"%s\": %m", + FilePathName(thisfile)))); + if (hdr_read != sizeof(header)) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("short read of encrypted BufFile header in \"%s\"", + FilePathName(thisfile)))); + + memcpy(&plaintext_len, header, sizeof(uint32)); + memcpy(&ciphertext_len, header + sizeof(uint32), sizeof(uint32)); + + if (plaintext_len > BLCKSZ || + ciphertext_len > BUFFILE_MAX_CIPHERTEXT) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("invalid encrypted BufFile block header in \"%s\"", + FilePathName(thisfile)))); + + if ((Size) ciphertext_len != (Size) plaintext_len + FileEncryptionOverheadSize()) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("encrypted BufFile block header in \"%s\" is inconsistent with module overhead", + FilePathName(thisfile)))); + + ct_read = FileRead(thisfile, file->enc_ciphertext_buf, + ciphertext_len, phys_offset + sizeof(header), + WAIT_EVENT_BUFFILE_READ); + if (ct_read < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not read file \"%s\": %m", + FilePathName(thisfile)))); + if ((uint32) ct_read != ciphertext_len) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("short read of encrypted BufFile body in \"%s\"", + FilePathName(thisfile)))); + + FileEncryptionDecrypt(FilePathName(thisfile), phys_offset, + file->enc_ciphertext_buf, ciphertext_len, + file->enc_plaintext_buf); + + if (track_io_timing) + { + INSTR_TIME_SET_CURRENT(io_time); + INSTR_TIME_ACCUM_DIFF(pgBufferUsage.temp_blk_read_time, + io_time, io_start); + } + + if (plaintext_len > 0) + pgBufferUsage.temp_blks_read++; + + return plaintext_len; +} + +static pgoff_t +BufFileWriteEncryptedBlock(BufFile *file, int fileno, pgoff_t block_start, + const char *data, uint32 plaintext_len) +{ + File thisfile; + pgoff_t phys_offset; + uint32 ciphertext_len; + char header[BUFFILE_ENC_HEADER_SIZE]; + instr_time io_start; + instr_time io_time; + + Assert(file->encrypted); + Assert((block_start % BLCKSZ) == 0); + Assert(plaintext_len <= BLCKSZ); + + BufFileEnsureEncBuffers(file); + + thisfile = file->files[fileno]; + phys_offset = BufFilePhysicalOffset(file, block_start); + + ciphertext_len = plaintext_len + (uint32) FileEncryptionOverheadSize(); + FileEncryptionEncrypt(FilePathName(thisfile), phys_offset, + data, plaintext_len, + file->enc_ciphertext_buf); + + memcpy(header, &plaintext_len, sizeof(uint32)); + memcpy(header + sizeof(uint32), &ciphertext_len, sizeof(uint32)); + + if (track_io_timing) + INSTR_TIME_SET_CURRENT(io_start); + else + INSTR_TIME_SET_ZERO(io_start); + + if (FileWrite(thisfile, header, sizeof(header), phys_offset, + WAIT_EVENT_BUFFILE_WRITE) != sizeof(header)) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write to file \"%s\": %m", + FilePathName(thisfile)))); + if ((uint32) FileWrite(thisfile, file->enc_ciphertext_buf, + ciphertext_len, + phys_offset + sizeof(header), + WAIT_EVENT_BUFFILE_WRITE) != ciphertext_len) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write to file \"%s\": %m", + FilePathName(thisfile)))); + + if (track_io_timing) + { + INSTR_TIME_SET_CURRENT(io_time); + INSTR_TIME_ACCUM_DIFF(pgBufferUsage.temp_blk_write_time, + io_time, io_start); + } + pgBufferUsage.temp_blks_written++; + + return phys_offset + sizeof(header) + ciphertext_len; +} + +static bool +BufFileLoadEncryptedBlock(BufFile *file, bool for_write) +{ + pgoff_t logical; + pgoff_t block_start; + int intra; + pgoff_t max_per_file; + int plaintext_len; + + Assert(file->encrypted); + Assert(!file->dirty); + + logical = file->curOffset + file->pos; + max_per_file = BufFilePlaintextPerFile(file); + + if (logical >= max_per_file && file->curFile + 1 < file->numFiles) + { + file->curFile++; + logical -= max_per_file; + } + + block_start = (logical / BLCKSZ) * BLCKSZ; + intra = (int) (logical - block_start); + + file->curOffset = block_start; + file->pos = intra; + file->nbytes = 0; + file->buffer_from_disk = false; + + plaintext_len = BufFileReadEncryptedBlock(file, file->curFile, + block_start, true); + if (plaintext_len > 0) + { + memcpy(file->buffer.data, file->enc_plaintext_buf, plaintext_len); + file->nbytes = plaintext_len; + file->buffer_from_disk = true; + } + + if (for_write) + { + if (intra > file->nbytes) + { + MemSet(file->buffer.data + file->nbytes, 0, + intra - file->nbytes); + file->nbytes = intra; + } + } + else if (intra >= file->nbytes) + { + /* + * Preserve the caller's logical position at EOF. If we leave the + * cursor at block_start, a later EOF probe would reload this block + * from the beginning and expose its tuples again. + */ + file->curOffset = logical; + file->nbytes = 0; + file->pos = 0; + } + + return file->nbytes > 0; +} + +static void +BufFilePrepareEncryptedWrite(BufFile *file) +{ + int fileno; + pgoff_t max_per_file; + pgoff_t local_offset; + pgoff_t block_start; + int intra; + int64 block_total; + + Assert(file->encrypted); + Assert(!file->dirty); + + fileno = file->curFile; + max_per_file = BufFilePlaintextPerFile(file); + local_offset = file->curOffset + file->pos; + + if (local_offset >= max_per_file && fileno + 1 < file->numFiles) + { + fileno++; + local_offset -= max_per_file; + } + + block_start = (local_offset / BLCKSZ) * BLCKSZ; + intra = (int) (local_offset - block_start); + block_total = (int64) fileno * max_per_file + block_start; + + if (file->curFile == fileno && + file->curOffset == block_start && + file->pos == intra && + file->buffer_from_disk && + intra <= file->nbytes) + return; + + if (intra != 0 || block_total < file->highest_dumped_offset) + BufFileLoadEncryptedBlock(file, true); +} + /* * Create a BufFile given the first underlying physical file. * NOTE: caller must set isInterXact if appropriate. @@ -346,6 +784,13 @@ BufFileOpenFileSet(FileSet *fileset, const char *name, int mode, file->fileset = fileset; file->name = pstrdup(name); + /* + * Track the existing logical extent so later writes can distinguish + * appending past EOF from overwriting an existing block that must be + * loaded first. + */ + file->highest_dumped_offset = BufFileSize(file); + return file; } @@ -421,6 +866,11 @@ BufFileClose(BufFile *file) FileClose(file->files[i]); /* release the buffer space */ pfree(file->files); + if (file->enc_ciphertext_buf != NULL) + { + pfree(file->enc_ciphertext_buf); + pfree(file->enc_plaintext_buf); + } pfree(file); } @@ -428,8 +878,12 @@ BufFileClose(BufFile *file) * BufFileLoadBuffer * * Load some data into buffer, if possible, starting from curOffset. - * At call, must have dirty = false, pos and nbytes = 0. - * On exit, nbytes is number of bytes loaded. + * At call, must have dirty = false. In the non-encrypted path callers + * additionally guarantee pos and nbytes = 0; the encrypted path tolerates + * any (curOffset + pos) combination and normalizes them to point at the + * start of the loaded block. + * + * On exit, nbytes is the number of bytes loaded into the buffer. */ static void BufFileLoadBuffer(BufFile *file) @@ -438,6 +892,12 @@ BufFileLoadBuffer(BufFile *file) instr_time io_start; instr_time io_time; + if (file->encrypted) + { + (void) BufFileLoadEncryptedBlock(file, false); + return; + } + /* * Advance to next component file if necessary and possible. */ @@ -498,6 +958,86 @@ BufFileDumpBuffer(BufFile *file) int64 bytestowrite; File thisfile; + if (file->encrypted) + { + pgoff_t max_per_file = BufFilePlaintextPerFile(file); + uint32 plaintext_len; + + /* + * Encrypted BufFiles encrypt one BLCKSZ-aligned block at a time. + * BufFileWrite() prepares dirty buffers by loading the current + * block first when needed, so partial writes still preserve any + * existing bytes before or after the write range. + */ + Assert((file->curOffset % BLCKSZ) == 0); + + /* Roll over to next physical file if this block doesn't fit. */ + if (file->curOffset >= max_per_file) + { + while (file->curFile + 1 >= file->numFiles) + extendBufFile(file); + file->curFile++; + file->curOffset = 0; + } + + plaintext_len = (uint32) file->nbytes; + + /* + * Partial blocks are only allowed at the file's high-water mark. + * BufFileLogicalSize and the read path assume every block before + * the trailing one is full BLCKSZ; flushing a partial block in the + * middle would silently misreport the logical size and turn reads + * into early EOFs at the partial block's tail. No current caller + * (logtape, hashjoin, gistbuildbuffers, tuplestore, apply + * streaming) does this, but assert it so a future caller can't + * regress without noticing. + */ + Assert(plaintext_len == BLCKSZ || + (int64) file->curFile * max_per_file + + file->curOffset + plaintext_len >= file->highest_dumped_offset); + + (void) BufFileWriteEncryptedBlock(file, file->curFile, + file->curOffset, + file->buffer.data, + plaintext_len); + + file->dirty = false; + + /* + * Track the new highest plaintext-end so later writes can distinguish + * fresh extension from read-modify-write. + */ + { + int64 dump_end_total = + (int64) file->curFile * max_per_file + + file->curOffset + plaintext_len; + + if (dump_end_total > file->highest_dumped_offset) + file->highest_dumped_offset = dump_end_total; + } + + /* + * Mirror the upstream post-dump bookkeeping: advance curOffset by + * the plaintext we wrote, then back up to the user's logical + * position (curOffset + pos). LoadBuffer handles re-aligning the + * resulting curOffset to a block boundary, so we don't enforce + * alignment here. + */ + file->curOffset += plaintext_len; + file->curOffset -= (file->nbytes - file->pos); + if (file->curOffset < 0) + { + file->curFile--; + Assert(file->curFile >= 0); + file->curOffset += BufFilePlaintextPerFile(file); + } + file->pos = 0; + file->nbytes = 0; + /* Buffer is empty now; further writes need their own load or extend. */ + file->buffer_from_disk = false; + return; + } + /* * Unlike BufFileLoadBuffer, we must dump the whole buffer even if it * crosses a component-file boundary; so we need a loop. @@ -693,9 +1233,13 @@ BufFileWrite(BufFile *file, const void *ptr, size_t size) file->curOffset += file->pos; file->pos = 0; file->nbytes = 0; + file->buffer_from_disk = false; } } + if (file->encrypted && !file->dirty) + BufFilePrepareEncryptedWrite(file); + nthistime = BLCKSZ - file->pos; if (nthistime > size) nthistime = size; @@ -742,6 +1286,7 @@ BufFileSeek(BufFile *file, int fileno, pgoff_t offset, int whence) { int newFile; pgoff_t newOffset; + pgoff_t max_per_file = BufFilePlaintextPerFile(file); switch (whence) { @@ -764,16 +1309,10 @@ BufFileSeek(BufFile *file, int fileno, pgoff_t offset, int whence) /* * The file size of the last file gives us the end offset of that - * file. + * file (in plaintext bytes for encrypted BufFiles). */ newFile = file->numFiles - 1; - newOffset = FileSize(file->files[file->numFiles - 1]); - if (newOffset < 0) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not determine size of temporary file \"%s\" from BufFile \"%s\": %m", - FilePathName(file->files[file->numFiles - 1]), - file->name))); + newOffset = BufFileLogicalSize(file, file->numFiles - 1); break; default: elog(ERROR, "invalid whence: %d", whence); @@ -783,7 +1322,7 @@ BufFileSeek(BufFile *file, int fileno, pgoff_t offset, int whence) { if (--newFile < 0) return EOF; - newOffset += MAX_PHYSICAL_FILESIZE; + newOffset += max_per_file; } if (newFile == file->curFile && newOffset >= file->curOffset && @@ -811,13 +1350,13 @@ BufFileSeek(BufFile *file, int fileno, pgoff_t offset, int whence) if (newFile == file->numFiles && newOffset == 0) { newFile--; - newOffset = MAX_PHYSICAL_FILESIZE; + newOffset = max_per_file; } - while (newOffset > MAX_PHYSICAL_FILESIZE) + while (newOffset > max_per_file) { if (++newFile >= file->numFiles) return EOF; - newOffset -= MAX_PHYSICAL_FILESIZE; + newOffset -= max_per_file; } if (newFile >= file->numFiles) return EOF; @@ -826,6 +1365,7 @@ BufFileSeek(BufFile *file, int fileno, pgoff_t offset, int whence) file->curOffset = newOffset; file->pos = 0; file->nbytes = 0; + file->buffer_from_disk = false; return 0; } @@ -850,9 +1390,11 @@ BufFileTell(BufFile *file, int *fileno, pgoff_t *offset) int BufFileSeekBlock(BufFile *file, int64 blknum) { + int64 blocks_per_file = BufFilePlaintextBlocksPerFile(file); + return BufFileSeek(file, - (int) (blknum / BUFFILE_SEG_SIZE), - (pgoff_t) (blknum % BUFFILE_SEG_SIZE) * BLCKSZ, + (int) (blknum / blocks_per_file), + (pgoff_t) (blknum % blocks_per_file) * BLCKSZ, SEEK_SET); } @@ -865,18 +1407,9 @@ BufFileSeekBlock(BufFile *file, int64 blknum) int64 BufFileSize(BufFile *file) { - int64 lastFileSize; + int64 lastFileSize = BufFileLogicalSize(file, file->numFiles - 1); - /* Get the size of the last physical file. */ - lastFileSize = FileSize(file->files[file->numFiles - 1]); - if (lastFileSize < 0) - ereport(ERROR, - (errcode_for_file_access(), - errmsg("could not determine size of temporary file \"%s\" from BufFile \"%s\": %m", - FilePathName(file->files[file->numFiles - 1]), - file->name))); - - return ((file->numFiles - 1) * (int64) MAX_PHYSICAL_FILESIZE) + + return ((file->numFiles - 1) * (int64) BufFilePlaintextPerFile(file)) + lastFileSize; } @@ -901,12 +1434,14 @@ BufFileSize(BufFile *file) int64 BufFileAppend(BufFile *target, BufFile *source) { - int64 startBlock = (int64) target->numFiles * BUFFILE_SEG_SIZE; + int64 startBlock = (int64) target->numFiles * + BufFilePlaintextBlocksPerFile(target); int newNumFiles = target->numFiles + source->numFiles; int i; Assert(source->readOnly); Assert(!source->dirty); + Assert(target->encrypted == source->encrypted); if (target->resowner != source->resowner) elog(ERROR, "could not append BufFile with non-matching resource owner"); @@ -915,7 +1450,10 @@ BufFileAppend(BufFile *target, BufFile *source) repalloc(target->files, sizeof(File) * newNumFiles); for (i = target->numFiles; i < newNumFiles; i++) target->files[i] = source->files[i - target->numFiles]; + target->numFiles = newNumFiles; + if (target->encrypted) + target->highest_dumped_offset = BufFileSize(target); return startBlock; } @@ -923,6 +1461,10 @@ BufFileAppend(BufFile *target, BufFile *source) /* * Truncate a BufFile created by BufFileCreateFileSet up to the given fileno * and the offset. + * + * In encrypted mode, offset is a logical plaintext offset. When the + * truncation point lands inside an encrypted block, rewrite that block with + * a shorter plaintext payload before truncating the physical file. */ void BufFileTruncateFileSet(BufFile *file, int fileno, pgoff_t offset) @@ -930,8 +1472,36 @@ BufFileTruncateFileSet(BufFile *file, int fileno, pgoff_t offset) int numFiles = file->numFiles; int newFile = fileno; pgoff_t newOffset = file->curOffset; + pgoff_t max_per_file = BufFilePlaintextPerFile(file); char segment_name[MAXPGPATH]; int i; + pgoff_t physical_offset = offset; + + if (file->encrypted) + { + pgoff_t block_start = (offset / BLCKSZ) * BLCKSZ; + int intra = (int) (offset - block_start); + + if (intra == 0) + physical_offset = BufFilePhysicalOffset(file, offset); + else + { + int plaintext_len; + + plaintext_len = BufFileReadEncryptedBlock(file, fileno, + block_start, false); + if (plaintext_len < intra) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("encrypted BufFile block in \"%s\" is shorter than requested truncation offset", + FilePathName(file->files[fileno])))); + + physical_offset = BufFileWriteEncryptedBlock(file, fileno, + block_start, + file->enc_plaintext_buf, + (uint32) intra); + } + } /* * Loop over all the files up to the given fileno and remove the files @@ -951,7 +1521,7 @@ BufFileTruncateFileSet(BufFile *file, int fileno, pgoff_t offset) errmsg("could not delete fileset \"%s\": %m", segment_name))); numFiles--; - newOffset = MAX_PHYSICAL_FILESIZE; + newOffset = max_per_file; /* * This is required to indicate that we have deleted the given @@ -962,7 +1532,7 @@ BufFileTruncateFileSet(BufFile *file, int fileno, pgoff_t offset) } else { - if (FileTruncate(file->files[i], offset, + if (FileTruncate(file->files[i], physical_offset, WAIT_EVENT_BUFFILE_TRUNCATE) < 0) ereport(ERROR, (errcode_for_file_access(), @@ -1013,4 +1583,16 @@ BufFileTruncateFileSet(BufFile *file, int fileno, pgoff_t offset) file->nbytes = 0; } /* Nothing to do, if the truncate point is beyond current file. */ + + /* + * Refresh the highest-dumped tracker so a subsequent write still + * recognizes a fresh extension past the truncation point. + */ + { + int64 truncated_eof = (int64) newFile * + BufFilePlaintextPerFile(file) + newOffset; + + if (truncated_eof < file->highest_dumped_offset) + file->highest_dumped_offset = truncated_eof; + } } diff --git a/src/backend/storage/file/file_encryption.c b/src/backend/storage/file/file_encryption.c new file mode 100644 index 0000000000000..a0d002203a2af --- /dev/null +++ b/src/backend/storage/file/file_encryption.c @@ -0,0 +1,700 @@ +/*------------------------------------------------------------------------- + * + * file_encryption.c + * Support for pluggable file encryption modules. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/backend/storage/file/file_encryption.c + * + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include "access/xlog.h" +#include "common/file_encryption_keyblock.h" +#include "fmgr.h" +#include "miscadmin.h" +#include "storage/bufpage.h" +#include "storage/checksum.h" +#include "storage/file_encryption.h" +#include "storage/ipc.h" +#include "storage/md.h" +#include "storage/smgr.h" +#include "utils/memutils.h" + +/* GUC */ +char *file_encryption_config = NULL; + +/* + * Library name in effect for this process. In bootstrap mode this is set + * by process_file_encryption_library() (which receives the name from the + * bootstrap command line); at runtime it points at the field in + * ControlFile, which postmaster populated via LocalProcessControlFile(). + * The two paths converge on the same loader. + */ +static const char *active_file_encryption_library = NULL; + +/* + * Module-level callbacks. Set during process_file_encryption_library() so + * the postmaster has them at startup; fork()ed children inherit, and + * EXEC_BACKEND children re-establish them via the same call from + * launch_backend.c. + */ +static const FileEncryptionCallbacks *LoadedFileEncryptionCallbacks = NULL; + +/* + * Per-process state. Initialized eagerly from process_file_encryption_library + * (which all top-level startup paths call) and re-initialized after fork(). + * Forked children inherit the postmaster's pointer, but they must not reuse + * it: crash recovery can run the postmaster's shutdown callback and clear the + * module private_data before later backends are forked. + */ +static FileEncryptionModuleState *file_encryption_module_state = NULL; +static int file_encryption_init_pid = 0; + +/* + * Page-level scratch StringInfo buffer. Pages need a BLCKSZ-sized output + * but StringInfo writes a trailing null at data[len], so the backing + * buffer is BLCKSZ + 1. Reused across calls in the current process. + */ +static char *page_scratch_buffer = NULL; + +static inline void +ensure_page_scratch(void) +{ + if (page_scratch_buffer == NULL) + page_scratch_buffer = MemoryContextAlloc(TopMemoryContext, BLCKSZ + 1); +} + +static void load_and_validate_module(const char *libname); +static void ensure_per_process_init(void); +static void file_encryption_shutdown_cb(int code, Datum arg); + +/* + * Returns true if a file encryption module is configured for this cluster. + * At runtime the library name lives in pg_control (stamped there at initdb + * time); during bootstrap it's whatever process_file_encryption_library() + * was handed on the command line. + */ +bool +FileEncryptionEnabled(void) +{ + return active_file_encryption_library != NULL && + active_file_encryption_library[0] != '\0'; +} + +/* + * Name of the module this process is bound to. Same string + * process_file_encryption_library() recorded; NULL/empty when no module is + * configured. + */ +const char * +FileEncryptionLibraryName(void) +{ + return active_file_encryption_library; +} + +/* + * Per-call ciphertext overhead the configured module declares. Returns 0 + * when no module is configured. + */ +Size +FileEncryptionOverheadSize(void) +{ + if (!FileEncryptionEnabled()) + return 0; + if (LoadedFileEncryptionCallbacks == NULL) + return 0; + return LoadedFileEncryptionCallbacks->overhead_size; +} + +/* + * Encrypt data_len plaintext bytes into dst. dst must have + * data_len + overhead_size bytes allocated by the caller; the module + * appends its overhead at the tail. Errors from the module are surfaced + * as ereport(ERROR). + */ +void +FileEncryptionEncrypt(const char *path, uint64 file_offset, + const char *data, Size data_len, char *dst) +{ + char *module_errmsg = NULL; + + if (!FileEncryptionEnabled()) + elog(ERROR, "file encryption module is not configured"); + + ensure_per_process_init(); + if (!LoadedFileEncryptionCallbacks->encrypt_cb(file_encryption_module_state, + path, file_offset, data, data_len, + dst, &module_errmsg)) + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("file encryption module encrypt callback failed"), + module_errmsg ? errdetail("%s", module_errmsg) : 0)); +} + +/* + * Decrypt data_len bytes (which the module produced via encrypt_cb, + * including its trailing overhead) into dst. dst must have + * data_len - overhead_size bytes allocated by the caller. + */ +void +FileEncryptionDecrypt(const char *path, uint64 file_offset, + const char *data, Size data_len, char *dst) +{ + char *module_errmsg = NULL; + + if (!FileEncryptionEnabled()) + elog(ERROR, "file encryption module is not configured"); + + ensure_per_process_init(); + if (!LoadedFileEncryptionCallbacks->decrypt_cb(file_encryption_module_state, + path, file_offset, data, data_len, + dst, &module_errmsg)) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("file encryption module decrypt callback failed"), + module_errmsg ? errdetail("%s", module_errmsg) : 0)); +} + +/* + * Eager wrapper for ensure_per_process_init() and the page-scratch + * allocation. Called from mdinit() in each backend so that the + * per-process module state, shutdown registration, and BLCKSZ-sized + * scratch buffer are all in place before AIO completion callbacks + * (which run inside critical sections and can't allocate) fire. + * + * No-op when the module hasn't been loaded yet — that's the bootstrap + * case, where BaseInit() runs before process_file_encryption_library(), + * and we'd otherwise try to dlopen the module and run its _PG_init too + * early (PGC_POSTMASTER GUCs can't be defined after startup is complete). + * The bootstrap process_file_encryption_library() call will reach back + * via md_init_enc_workspace() to do the eager init once the module IS + * loaded. + */ +void +FileEncryptionEnsureInit(void) +{ + if (!FileEncryptionEnabled()) + return; + if (LoadedFileEncryptionCallbacks == NULL) + return; + ensure_per_process_init(); + ensure_page_scratch(); +} + +/* + * Number of bytes the configured module reserves at the tail of every + * relation page. May be zero — a module that doesn't need a per-page + * trailer (e.g. AES-XTS, or a stream cipher with a deterministic IV + * derived from (relNumber, fork, blocknum)) is fully supported. Returns + * 0 when no module is configured. + */ +Size +FileEncryptionPageReservedSize(void) +{ + if (!FileEncryptionEnabled()) + return 0; + if (LoadedFileEncryptionCallbacks == NULL) + return 0; + return LoadedFileEncryptionCallbacks->page_overhead_size; +} + +/* + * Generate a fresh per-relation wrapped DEK and write it as a BLCKSZ block + * into 'dst'. The block layout is: + * + * [ FEKeyBlockHeader ] [ wrapped DEK ... ] [ zero padding to BLCKSZ ] + * + * Called from storage.c when an encrypted relation is created; the result + * is written to KEY_FORKNUM block 0. The module writes the wrapped DEK + * directly into the payload area of dst and reports how many bytes it + * used; we then frame it with the FEKeyBlockHeader above. + */ +void +FileEncryptionGenerateObjectKey(const RelFileLocator *locator, char *dst) +{ + FEKeyBlockHeader hdr; + Size wrapped_len = 0; + char *module_errmsg = NULL; + + if (!FileEncryptionEnabled()) + elog(ERROR, "file encryption module is not configured"); + + ensure_per_process_init(); + + /* + * Format the block as a PostgreSQL page so it passes PageIsVerified and + * travels through the buffer manager like any other block. PageInit + * zeroes the whole BLCKSZ region (including the cluster-wide + * page-reserved trailer) and sets pd_lower/pd_upper/pd_special such + * that the entire area between the page header and pd_upper is + * available data space; we embed our header and the wrapped DEK there. + * + * We compute pd_checksum eagerly via pg_checksum_page rather than going + * through PageSetChecksum: PageSetChecksum is a no-op while + * data_checksums is "off", but the block must remain verifiable across + * a future data-checksums-on transition (we never let the + * data-checksums worker rewrite the KEY fork — see datachecksum_state). + * Computing the checksum at write time once means the block is + * permanently self-consistent regardless of the cluster's current + * checksum state. + */ + PageInit((Page) dst, BLCKSZ, 0); + + if (!LoadedFileEncryptionCallbacks->generate_object_key_cb(file_encryption_module_state, + locator, + dst + FE_KEY_BLOCK_PAYLOAD_OFFSET, + FE_KEY_BLOCK_MAX_WRAPPED, + &wrapped_len, + &module_errmsg)) + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("file encryption module generate_object_key callback failed"), + module_errmsg ? errdetail("%s", module_errmsg) : 0)); + if (wrapped_len == 0) + elog(ERROR, "file encryption module generated an empty wrapped key"); + if (wrapped_len > FE_KEY_BLOCK_MAX_WRAPPED) + elog(ERROR, + "file encryption module generated %zu bytes of wrapped key, exceeds limit %zu", + wrapped_len, FE_KEY_BLOCK_MAX_WRAPPED); + + hdr.magic = FE_KEY_BLOCK_MAGIC; + hdr.version = FE_KEY_BLOCK_VERSION; + hdr.wrapped_len = (uint32) wrapped_len; + hdr.reserved = 0; + memcpy(dst + FE_KEY_BLOCK_HEADER_OFFSET, &hdr, sizeof(hdr)); + ((PageHeader) dst)->pd_checksum = pg_checksum_page(dst, 0); +} + +/* + * Read the KEY fork block for 'reln' and ask the module to unwrap the + * stored DEK into a per-relation state pointer cached on reln. Idempotent + * — no-op when the state is already populated. + */ +void +FileEncryptionOpenObject(SMgrRelation reln) +{ + PGIOAlignedBlock buf; + FEKeyBlockHeader hdr; + void *object_state; + char *module_errmsg = NULL; + MemoryContext oldcontext; + + if (reln->encryption_object_state != NULL) + return; + if (!FileEncryptionEnabled()) + elog(ERROR, "file encryption module is not configured"); + + ensure_per_process_init(); + ensure_page_scratch(); + + /* + * KEY fork is exempt from encryption (md_fork_is_encrypted returns + * false), so smgrread hands us the raw on-disk bytes. We can read + * directly from disk because the KEY fork's content is forced to disk + * during relation creation -- on the primary by smgrextend + + * smgrimmedsync, on a standby by the dedicated smgr WAL record whose + * redo also goes directly to disk (see XLOG_SMGR_KEY_FORK_CREATE in + * storage.c). This avoids reading through the buffer manager, which + * would risk recursive AIO inside an in-flight smgr write. + */ + smgrread(reln, KEY_FORKNUM, 0, buf.data); + + memcpy(&hdr, buf.data + FE_KEY_BLOCK_HEADER_OFFSET, sizeof(hdr)); + if (hdr.magic != FE_KEY_BLOCK_MAGIC) + ereport(ERROR, + (errmsg("invalid file-encryption key block on relation %u/%u/%u: bad magic 0x%08x", + reln->smgr_rlocator.locator.spcOid, + reln->smgr_rlocator.locator.dbOid, + reln->smgr_rlocator.locator.relNumber, + hdr.magic))); + if (hdr.version != FE_KEY_BLOCK_VERSION) + ereport(ERROR, + (errmsg("unsupported file-encryption key block version %u on relation %u/%u/%u", + hdr.version, + reln->smgr_rlocator.locator.spcOid, + reln->smgr_rlocator.locator.dbOid, + reln->smgr_rlocator.locator.relNumber))); + if (hdr.wrapped_len == 0 || hdr.wrapped_len > FE_KEY_BLOCK_MAX_WRAPPED) + ereport(ERROR, + (errmsg("invalid file-encryption key block on relation %u/%u/%u: wrapped_len %u out of range", + reln->smgr_rlocator.locator.spcOid, + reln->smgr_rlocator.locator.dbOid, + reln->smgr_rlocator.locator.relNumber, + hdr.wrapped_len))); + + /* + * The per-relation state has to outlive every catalog-scoped or + * transaction-scoped memory context that the module's palloc would + * otherwise pick up: it's cached on SMgrRelation and freed only when + * smgrdestroy() calls object_close_cb. Switch to TopMemoryContext so + * the module can just palloc without thinking about lifetimes. + */ + oldcontext = MemoryContextSwitchTo(TopMemoryContext); + object_state = + LoadedFileEncryptionCallbacks->object_open_cb(file_encryption_module_state, + &reln->smgr_rlocator.locator, + buf.data + FE_KEY_BLOCK_PAYLOAD_OFFSET, + hdr.wrapped_len, + &module_errmsg); + MemoryContextSwitchTo(oldcontext); + if (object_state == NULL) + ereport(ERROR, + (errmsg("file encryption module could not open object state for relation %u/%u/%u", + reln->smgr_rlocator.locator.spcOid, + reln->smgr_rlocator.locator.dbOid, + reln->smgr_rlocator.locator.relNumber), + module_errmsg ? errdetail("%s", module_errmsg) : 0)); + + reln->encryption_object_state = object_state; +} + +/* + * Release any per-relation file-encryption state cached on 'reln'. Called + * from smgrdestroy() (and tolerates a NULL state, so it's safe from any + * teardown path). + */ +void +FileEncryptionCloseObject(SMgrRelation reln) +{ + if (reln->encryption_object_state == NULL) + return; + if (LoadedFileEncryptionCallbacks != NULL && + LoadedFileEncryptionCallbacks->object_close_cb != NULL) + LoadedFileEncryptionCallbacks->object_close_cb(file_encryption_module_state, + reln->encryption_object_state); + reln->encryption_object_state = NULL; +} + +/* + * Encrypt a relation page. src and dst are both BLCKSZ-sized buffers; the + * module fills dst with the encrypted page (the trailing page_overhead_size + * bytes are its own metadata). Uses the per-relation DEK cached on + * SMgrRelation, loading it lazily on first call. + */ +void +FileEncryptionEncryptPage(SMgrRelation reln, ForkNumber fork, + BlockNumber blocknum, + const char *src, char *dst) +{ + char *module_errmsg = NULL; + + if (!FileEncryptionEnabled()) + elog(ERROR, "file encryption module is not configured"); + + FileEncryptionOpenObject(reln); + + if (!LoadedFileEncryptionCallbacks->encrypt_page_cb(file_encryption_module_state, + reln->encryption_object_state, + fork, blocknum, src, dst, + &module_errmsg)) + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("file encryption module encrypt_page callback failed for fork %d block %u of relation %u/%u/%u", + fork, blocknum, + reln->smgr_rlocator.locator.spcOid, + reln->smgr_rlocator.locator.dbOid, + reln->smgr_rlocator.locator.relNumber), + module_errmsg ? errdetail("%s", module_errmsg) : 0)); +} + +/* + * Decrypt a relation page. src and dst are both BLCKSZ-sized buffers; the + * module reads the trailing page_overhead_size bytes of src for its own + * per-page metadata (if any) before producing dst. Module contract: the + * trailing page_overhead_size bytes of dst must be zero on return, so + * that pd_checksum (which covers the full BLCKSZ) verifies against the + * writer's plaintext, which also has a zero trailer. + */ +void +FileEncryptionDecryptPage(SMgrRelation reln, ForkNumber fork, + BlockNumber blocknum, + const char *src, char *dst) +{ + char *module_errmsg = NULL; + + if (!FileEncryptionEnabled()) + elog(ERROR, "file encryption module is not configured"); + + FileEncryptionOpenObject(reln); + + if (!LoadedFileEncryptionCallbacks->decrypt_page_cb(file_encryption_module_state, + reln->encryption_object_state, + fork, blocknum, src, dst, + &module_errmsg)) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("file encryption module decrypt_page callback failed for fork %d block %u of relation %u/%u/%u", + fork, blocknum, + reln->smgr_rlocator.locator.spcOid, + reln->smgr_rlocator.locator.dbOid, + reln->smgr_rlocator.locator.relNumber), + module_errmsg ? errdetail("%s", module_errmsg) : 0)); +} + +/* + * Load the configured file encryption library and validate its callbacks. + * + * Called at the same point as process_shared_preload_libraries() so that + * the module's _PG_init runs early enough to define PGC_POSTMASTER GUCs, + * and so misconfigurations surface at server start instead of at the first + * encrypt/decrypt. + */ +void +process_file_encryption_library(const char *libname) +{ + /* + * If the caller didn't pass a libname (the normal runtime case), pull + * it from the control file via the xlog accessor. Bootstrap passes + * the name explicitly because pg_control hasn't been populated yet at + * that point. + */ + if (libname == NULL || libname[0] == '\0') + libname = GetFileEncryptionLibrary(); + + if (libname == NULL || libname[0] == '\0') + { + active_file_encryption_library = NULL; + return; + } + + active_file_encryption_library = libname; + + /* + * The module's only configuration channel is the opaque config string + * passed to _PG_file_encryption_module_init; modules must not define + * GUCs from their entry point. We deliberately do not toggle + * process_shared_preload_libraries_in_progress here. + */ + load_and_validate_module(libname); + + /* + * Eagerly run the per-process startup callback now, while we're still + * outside any critical section. AIO completion callbacks invoke + * encrypt/decrypt from within a critical section and can't tolerate + * the lazy palloc that ensure_per_process_init() would otherwise do + * on first use. For the same reason, ask md.c to allocate its + * page-encryption workspace now: in bootstrap mode, mdinit() ran + * before this function and saw FileEncryptionEnabled() == false, so + * the workspace is still NULL. + */ + ensure_per_process_init(); + md_init_enc_workspace(); +} + +static void +load_and_validate_module(const char *libname) +{ + FileEncryptionModuleInit init; + const FileEncryptionCallbacks *callbacks = NULL; + const char *module_config; + char *init_errmsg = NULL; + MemoryContext oldcontext; + bool init_ok; + + /* + * Idempotent: in fork()ed backends we've already inherited the + * postmaster's callback pointer, and EXEC_BACKEND children only invoke + * this from launch_backend once. + */ + if (LoadedFileEncryptionCallbacks != NULL) + return; + + init = (FileEncryptionModuleInit) + load_external_function(libname, + "_PG_file_encryption_module_init", + false, NULL); + + if (init == NULL) + ereport(ERROR, + (errmsg("file encryption modules have to define the symbol %s", + "_PG_file_encryption_module_init"))); + + /* + * Fall back to the PGFILEENCRYPTIONCONFIG environment variable when the + * GUC is unset. initdb forwards --file-encryption-config to its child + * backends through this env var rather than postgres's command line, so + * an operator-supplied configuration value isn't trivially observable + * via `ps` during initdb. + */ + module_config = file_encryption_config; + if (module_config == NULL || module_config[0] == '\0') + module_config = getenv("PGFILEENCRYPTIONCONFIG"); + + /* + * Modules typically stash parsed configuration into module statics + * via pstrdup/palloc, so any allocation done inside init must come + * from a long-lived context. Switch to TopMemoryContext for the + * duration of the call -- the host has no way to know what the + * caller's current context is otherwise. Frontend tools have only + * one heap and don't need this. + */ + oldcontext = MemoryContextSwitchTo(TopMemoryContext); + init_ok = (*init) (module_config, &callbacks, &init_errmsg); + MemoryContextSwitchTo(oldcontext); + + if (!init_ok) + { + char *detail = init_errmsg ? pstrdup(init_errmsg) : NULL; + + if (init_errmsg) + pfree(init_errmsg); + if (detail != NULL) + ereport(ERROR, + (errmsg("file encryption module \"%s\" failed to initialize", + libname), + errdetail("%s", detail))); + else + ereport(ERROR, + (errmsg("file encryption module \"%s\" failed to initialize", + libname))); + } + + if (callbacks == NULL) + ereport(ERROR, + (errmsg("file encryption module \"%s\" returned no callbacks", + libname))); + + if (callbacks->magic != PG_FILE_ENCRYPTION_MAGIC) + ereport(ERROR, + (errmsg("file encryption module \"%s\" has incompatible ABI", + libname), + errdetail("Server expects %u, module provides %u.", + PG_FILE_ENCRYPTION_MAGIC, + callbacks->magic))); + + /* + * A loaded module must implement every callback: encrypt_cb / decrypt_cb + * for record streams (BufFile, reorderbuffer spill), plus the five + * page-encryption callbacks. Configuring an encryption library is an + * all-or-nothing choice -- there is no partial mode in which only some + * I/O is encrypted. + */ + if (callbacks->encrypt_cb == NULL || + callbacks->decrypt_cb == NULL || + callbacks->generate_object_key_cb == NULL || + callbacks->object_open_cb == NULL || + callbacks->object_close_cb == NULL || + callbacks->encrypt_page_cb == NULL || + callbacks->decrypt_page_cb == NULL) + ereport(ERROR, + (errmsg("file encryption module \"%s\" did not register the full callback set", + libname), + errdetail("All of encrypt_cb, decrypt_cb, generate_object_key_cb, object_open_cb, object_close_cb, encrypt_page_cb, and decrypt_page_cb must be set."))); + + /* + * If the cluster's pg_control already has a page_reserved_size (i.e. we + * are starting up an existing cluster, not running BootStrapXLOG for + * the first time), the module's page_overhead_size must match it + * exactly. A mismatch means the module was changed or replaced after + * initdb, which would silently corrupt every page on the next write. + * + * In bootstrap mode GetPageReservedSize() returns 0 because pg_control + * hasn't been populated yet; we accept the module unconditionally and + * BootStrapXLOG will copy page_overhead_size into pg_control. Note + * that page_overhead_size = 0 is a valid declaration for modules that + * don't need a per-page trailer (e.g. AES-XTS, or a stream cipher with + * a deterministic IV derived from the binding context); it just means + * the cluster uses the full BLCKSZ for AM data. + */ + if (GetPageReservedSize() != 0 && + callbacks->page_overhead_size != GetPageReservedSize()) + ereport(ERROR, + (errmsg("file encryption module \"%s\" declares page_overhead_size %zu, but the cluster was initialized with page_reserved_size %u", + libname, + callbacks->page_overhead_size, + GetPageReservedSize()), + errdetail("The cluster's page_reserved_size is stamped at initdb time from the module's declared per-page overhead; the module appears to have been changed since."))); + + LoadedFileEncryptionCallbacks = callbacks; +} + +/* + * Allocate this process's FileEncryptionModuleState, run the module's + * startup callback, and register the matching shutdown callback. Idempotent. + * + * The state outlives the current memory context (it must survive until + * backend exit, when the shutdown callback runs), so allocate from + * TopMemoryContext. The same context is used for the startup callback so + * that whatever the module stows in private_data is durable. + */ +static void +ensure_per_process_init(void) +{ + /* + * Already initialized for this process? + */ + if (file_encryption_module_state != NULL && + file_encryption_init_pid == MyProcPid) + return; + + /* + * Should already have run from process_file_encryption_library at + * startup; fall back to loading on demand for safety, using whichever + * library name our caller already arranged in active_*. + */ + if (LoadedFileEncryptionCallbacks == NULL) + load_and_validate_module(active_file_encryption_library); + + /* + * Drop any pointer inherited from an ancestor process: the parent's + * per-process struct may have been freed by its shutdown callback + * before we forked, so this process must build its own. + */ + file_encryption_module_state = + MemoryContextAllocZero(TopMemoryContext, + sizeof(FileEncryptionModuleState)); + file_encryption_module_state->sversion = PG_VERSION_NUM; + + if (LoadedFileEncryptionCallbacks->startup_cb != NULL) + { + MemoryContext oldcontext = MemoryContextSwitchTo(TopMemoryContext); + char *module_errmsg = NULL; + bool startup_ok; + + PG_TRY(); + { + startup_ok = LoadedFileEncryptionCallbacks->startup_cb(file_encryption_module_state, + &module_errmsg); + } + PG_CATCH(); + { + MemoryContextSwitchTo(oldcontext); + pfree(file_encryption_module_state); + file_encryption_module_state = NULL; + PG_RE_THROW(); + } + PG_END_TRY(); + + MemoryContextSwitchTo(oldcontext); + + if (!startup_ok) + { + pfree(file_encryption_module_state); + file_encryption_module_state = NULL; + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("file encryption module startup callback failed"), + module_errmsg ? errdetail("%s", module_errmsg) : 0)); + } + } + + file_encryption_init_pid = MyProcPid; + before_shmem_exit(file_encryption_shutdown_cb, 0); +} + +/* + * Call the shutdown callback of the loaded module, if defined. + */ +static void +file_encryption_shutdown_cb(int code, Datum arg) +{ + if (LoadedFileEncryptionCallbacks != NULL && + LoadedFileEncryptionCallbacks->shutdown_cb != NULL) + LoadedFileEncryptionCallbacks->shutdown_cb(file_encryption_module_state); + file_encryption_init_pid = 0; +} diff --git a/src/backend/storage/file/meson.build b/src/backend/storage/file/meson.build index 795402589b0b9..262555eadafa0 100644 --- a/src/backend/storage/file/meson.build +++ b/src/backend/storage/file/meson.build @@ -4,6 +4,7 @@ backend_sources += files( 'buffile.c', 'copydir.c', 'fd.c', + 'file_encryption.c', 'fileset.c', 'reinit.c', 'sharedfileset.c', diff --git a/src/backend/storage/file/reinit.c b/src/backend/storage/file/reinit.c index 25fa215130928..ce7fbd7513a17 100644 --- a/src/backend/storage/file/reinit.c +++ b/src/backend/storage/file/reinit.c @@ -14,20 +14,30 @@ #include "postgres.h" +#include +#include #include +#include "catalog/pg_tablespace_d.h" #include "common/relpath.h" #include "postmaster/startup.h" #include "storage/copydir.h" #include "storage/fd.h" +#include "storage/file_encryption.h" #include "storage/reinit.h" +#include "storage/relfilelocator.h" +#include "storage/smgr.h" #include "utils/hsearch.h" #include "utils/memutils.h" -static void ResetUnloggedRelationsInTablespaceDir(const char *tsdirname, +static void ResetUnloggedRelationsInTablespaceDir(Oid spcOid, + const char *tsdirname, int op); -static void ResetUnloggedRelationsInDbspaceDir(const char *dbspacedirname, +static void ResetUnloggedRelationsInDbspaceDir(Oid spcOid, Oid dbOid, + const char *dbspacedirname, int op); +static void reencrypt_init_segment(RelFileLocator rlocator, unsigned segno, + const char *srcpath, const char *dstpath); typedef struct { @@ -72,7 +82,7 @@ ResetUnloggedRelations(int op) /* * First process unlogged files in pg_default ($PGDATA/base) */ - ResetUnloggedRelationsInTablespaceDir("base", op); + ResetUnloggedRelationsInTablespaceDir(DEFAULTTABLESPACE_OID, "base", op); /* * Cycle through directories for all non-default tablespaces. @@ -81,13 +91,24 @@ ResetUnloggedRelations(int op) while ((spc_de = ReadDir(spc_dir, PG_TBLSPC_DIR)) != NULL) { + Oid spcOid; + char *endp; + if (strcmp(spc_de->d_name, ".") == 0 || strcmp(spc_de->d_name, "..") == 0) continue; + /* + * Each entry under pg_tblspc is a symlink whose name is the + * tablespace OID. Skip anything that doesn't parse as one. + */ + spcOid = strtoul(spc_de->d_name, &endp, 10); + if (*endp != '\0') + continue; + snprintf(temp_path, sizeof(temp_path), "%s/%s/%s", PG_TBLSPC_DIR, spc_de->d_name, TABLESPACE_VERSION_DIRECTORY); - ResetUnloggedRelationsInTablespaceDir(temp_path, op); + ResetUnloggedRelationsInTablespaceDir(spcOid, temp_path, op); } FreeDir(spc_dir); @@ -103,7 +124,8 @@ ResetUnloggedRelations(int op) * Process one tablespace directory for ResetUnloggedRelations */ static void -ResetUnloggedRelationsInTablespaceDir(const char *tsdirname, int op) +ResetUnloggedRelationsInTablespaceDir(Oid spcOid, const char *tsdirname, + int op) { DIR *ts_dir; struct dirent *de; @@ -130,6 +152,9 @@ ResetUnloggedRelationsInTablespaceDir(const char *tsdirname, int op) while ((de = ReadDir(ts_dir, tsdirname)) != NULL) { + Oid dbOid; + char *endp; + /* * We're only interested in the per-database directories, which have * numeric names. Note that this code will also (properly) ignore "." @@ -137,6 +162,8 @@ ResetUnloggedRelationsInTablespaceDir(const char *tsdirname, int op) */ if (strspn(de->d_name, "0123456789") != strlen(de->d_name)) continue; + dbOid = strtoul(de->d_name, &endp, 10); + Assert(*endp == '\0'); snprintf(dbspace_path, sizeof(dbspace_path), "%s/%s", tsdirname, de->d_name); @@ -148,7 +175,7 @@ ResetUnloggedRelationsInTablespaceDir(const char *tsdirname, int op) ereport_startup_progress("resetting unlogged relations (cleanup), elapsed time: %ld.%02d s, current path: %s", dbspace_path); - ResetUnloggedRelationsInDbspaceDir(dbspace_path, op); + ResetUnloggedRelationsInDbspaceDir(spcOid, dbOid, dbspace_path, op); } FreeDir(ts_dir); @@ -158,7 +185,8 @@ ResetUnloggedRelationsInTablespaceDir(const char *tsdirname, int op) * Process one per-dbspace directory for ResetUnloggedRelations */ static void -ResetUnloggedRelationsInDbspaceDir(const char *dbspacedirname, int op) +ResetUnloggedRelationsInDbspaceDir(Oid spcOid, Oid dbOid, + const char *dbspacedirname, int op) { DIR *dbspace_dir; struct dirent *de; @@ -243,8 +271,14 @@ ResetUnloggedRelationsInDbspaceDir(const char *dbspacedirname, int op) &forkNum, &segno)) continue; - /* We never remove the init fork. */ - if (forkNum == INIT_FORKNUM) + /* + * We never remove the init fork. We also keep the key fork: + * its wrapped DEK belongs to the relation as a unit (the data + * we're about to wipe was encrypted under it; new data after + * reset re-encrypts under the same DEK). Re-creating it would + * require module-side wrap, which we don't do during reinit. + */ + if (forkNum == INIT_FORKNUM || forkNum == KEY_FORKNUM) continue; /* @@ -310,9 +344,28 @@ ResetUnloggedRelationsInDbspaceDir(const char *dbspacedirname, int op) snprintf(dstpath, sizeof(dstpath), "%s/%u.%u", dbspacedirname, relNumber, segno); - /* OK, we're ready to perform the actual copy. */ - elog(DEBUG2, "copying %s to %s", srcpath, dstpath); - copy_file(srcpath, dstpath); + /* + * If page encryption is configured, the INIT-fork ciphertext + * was produced under a (fork=INIT_FORKNUM, blocknum) binding + * context and a raw byte copy into the MAIN fork would not + * decrypt later (the read path supplies fork=MAIN_FORKNUM as + * binding context). Decrypt INIT-side and re-encrypt + * MAIN-side instead so the context matches at read time. + */ + if (FileEncryptionEnabled()) + { + RelFileLocator rlocator = {.spcOid = spcOid, + .dbOid = dbOid,.relNumber = relNumber}; + + elog(DEBUG2, "re-encrypting %s into %s", srcpath, dstpath); + reencrypt_init_segment(rlocator, segno, srcpath, dstpath); + } + else + { + /* OK, we're ready to perform the actual copy. */ + elog(DEBUG2, "copying %s to %s", srcpath, dstpath); + copy_file(srcpath, dstpath); + } } FreeDir(dbspace_dir); @@ -366,6 +419,106 @@ ResetUnloggedRelationsInDbspaceDir(const char *dbspacedirname, int op) } } +/* + * INIT-fork-to-MAIN-fork copy for an encrypted unlogged relation. + * + * Each INIT-fork page was encrypted with a binding context that included + * fork=INIT_FORKNUM; a raw byte copy into MAIN would not decrypt afterwards + * because the encryption layer supplies fork=MAIN_FORKNUM on read. Decrypt + * each block of the INIT segment file and re-encrypt under the MAIN context + * using the relation's existing DEK (read from the KEY fork), producing a + * MAIN segment file that the running cluster can read normally. + * + * The plaintext never leaves this process's memory. The DEK is unchanged. + */ +static void +reencrypt_init_segment(RelFileLocator rlocator, unsigned segno, + const char *srcpath, const char *dstpath) +{ + SMgrRelation reln; + int src_fd; + int dst_fd; + struct stat st; + BlockNumber nblocks; + BlockNumber block_in_seg; + PGIOAlignedBlock encrypted; + PGIOAlignedBlock plaintext; + PGIOAlignedBlock reencrypted; + + /* + * smgropen + FileEncryptionOpenObject load the relation's DEK by + * reading the KEY fork. Both palloc; we're outside any critical + * section here. + */ + reln = smgropen(rlocator, INVALID_PROC_NUMBER); + FileEncryptionOpenObject(reln); + + src_fd = OpenTransientFile(srcpath, O_RDONLY | PG_BINARY); + if (src_fd < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not open file \"%s\": %m", srcpath))); + + if (fstat(src_fd, &st) < 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not stat file \"%s\": %m", srcpath))); + if (st.st_size % BLCKSZ != 0) + ereport(ERROR, + (errcode(ERRCODE_DATA_CORRUPTED), + errmsg("INIT fork file \"%s\" has size %lld, not a multiple of BLCKSZ", + srcpath, (long long) st.st_size))); + nblocks = st.st_size / BLCKSZ; + + dst_fd = OpenTransientFile(dstpath, + O_WRONLY | O_CREAT | O_TRUNC | PG_BINARY); + if (dst_fd < 0) + { + CloseTransientFile(src_fd); + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not create file \"%s\": %m", dstpath))); + } + + for (block_in_seg = 0; block_in_seg < nblocks; block_in_seg++) + { + BlockNumber blocknum = segno * RELSEG_SIZE + block_in_seg; + off_t off = (off_t) block_in_seg * BLCKSZ; + + if (pg_pread(src_fd, encrypted.data, BLCKSZ, off) != BLCKSZ) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not read block %u in file \"%s\": %m", + block_in_seg, srcpath))); + + FileEncryptionDecryptPage(reln, INIT_FORKNUM, blocknum, + encrypted.data, plaintext.data); + FileEncryptionEncryptPage(reln, MAIN_FORKNUM, blocknum, + plaintext.data, reencrypted.data); + + if (pg_pwrite(dst_fd, reencrypted.data, BLCKSZ, off) != BLCKSZ) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not write block %u in file \"%s\": %m", + block_in_seg, dstpath))); + } + + if (pg_fsync(dst_fd) != 0) + { + CloseTransientFile(src_fd); + CloseTransientFile(dst_fd); + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not fsync file \"%s\": %m", dstpath))); + } + + CloseTransientFile(src_fd); + if (CloseTransientFile(dst_fd) != 0) + ereport(ERROR, + (errcode_for_file_access(), + errmsg("could not close file \"%s\": %m", dstpath))); +} + /* * Basic parsing of putative relation filenames. * diff --git a/src/backend/storage/page/bufpage.c b/src/backend/storage/page/bufpage.c index 1fdfda59edd08..f53454b62faff 100644 --- a/src/backend/storage/page/bufpage.c +++ b/src/backend/storage/page/bufpage.c @@ -42,19 +42,26 @@ void PageInit(Page page, Size pageSize, Size specialSize) { PageHeader p = (PageHeader) page; + Size usableSize; specialSize = MAXALIGN(specialSize); Assert(pageSize == BLCKSZ); - Assert(pageSize > specialSize + SizeOfPageHeaderData); + usableSize = pageSize - GetPageReservedSize(); + Assert(usableSize > specialSize + SizeOfPageHeaderData); - /* Make sure all fields of page are zero, as well as unused space */ + /* + * Zero the entire page including any encryption trailer. The trailer + * is owned by the smgr/encryption layer and must remain zero in the + * plaintext view so that pd_checksum (which still covers the full page) + * matches between writer and reader. + */ MemSet(p, 0, pageSize); p->pd_flags = 0; p->pd_lower = SizeOfPageHeaderData; - p->pd_upper = pageSize - specialSize; - p->pd_special = pageSize - specialSize; + p->pd_upper = usableSize - specialSize; + p->pd_special = usableSize - specialSize; PageSetPageSizeAndVersion(page, pageSize, PG_PAGE_LAYOUT_VERSION); /* p->pd_prune_xid = InvalidTransactionId; done by above MemSet */ } @@ -137,7 +144,7 @@ PageIsVerified(PageData *page, BlockNumber blkno, int flags, bool *checksum_fail if ((p->pd_flags & ~PD_VALID_FLAG_BITS) == 0 && p->pd_lower <= p->pd_upper && p->pd_upper <= p->pd_special && - p->pd_special <= BLCKSZ && + p->pd_special <= BLCKSZ - GetPageReservedSize() && p->pd_special == MAXALIGN(p->pd_special)) header_sane = true; @@ -220,7 +227,7 @@ PageAddItemExtended(Page page, if (phdr->pd_lower < SizeOfPageHeaderData || phdr->pd_lower > phdr->pd_upper || phdr->pd_upper > phdr->pd_special || - phdr->pd_special > BLCKSZ) + phdr->pd_special > BLCKSZ - GetPageReservedSize()) ereport(PANIC, (errcode(ERRCODE_DATA_CORRUPTED), errmsg("corrupted page pointers: lower = %u, upper = %u, special = %u", @@ -732,7 +739,7 @@ PageRepairFragmentation(Page page) if (pd_lower < SizeOfPageHeaderData || pd_lower > pd_upper || pd_upper > pd_special || - pd_special > BLCKSZ || + pd_special > BLCKSZ - GetPageReservedSize() || pd_special != MAXALIGN(pd_special)) ereport(ERROR, (errcode(ERRCODE_DATA_CORRUPTED), @@ -1075,7 +1082,7 @@ PageIndexTupleDelete(Page page, OffsetNumber offnum) if (phdr->pd_lower < SizeOfPageHeaderData || phdr->pd_lower > phdr->pd_upper || phdr->pd_upper > phdr->pd_special || - phdr->pd_special > BLCKSZ || + phdr->pd_special > BLCKSZ - GetPageReservedSize() || phdr->pd_special != MAXALIGN(phdr->pd_special)) ereport(ERROR, (errcode(ERRCODE_DATA_CORRUPTED), @@ -1210,7 +1217,7 @@ PageIndexMultiDelete(Page page, OffsetNumber *itemnos, int nitems) if (pd_lower < SizeOfPageHeaderData || pd_lower > pd_upper || pd_upper > pd_special || - pd_special > BLCKSZ || + pd_special > BLCKSZ - GetPageReservedSize() || pd_special != MAXALIGN(pd_special)) ereport(ERROR, (errcode(ERRCODE_DATA_CORRUPTED), @@ -1316,7 +1323,7 @@ PageIndexTupleDeleteNoCompact(Page page, OffsetNumber offnum) if (phdr->pd_lower < SizeOfPageHeaderData || phdr->pd_lower > phdr->pd_upper || phdr->pd_upper > phdr->pd_special || - phdr->pd_special > BLCKSZ || + phdr->pd_special > BLCKSZ - GetPageReservedSize() || phdr->pd_special != MAXALIGN(phdr->pd_special)) ereport(ERROR, (errcode(ERRCODE_DATA_CORRUPTED), @@ -1428,7 +1435,7 @@ PageIndexTupleOverwrite(Page page, OffsetNumber offnum, if (phdr->pd_lower < SizeOfPageHeaderData || phdr->pd_lower > phdr->pd_upper || phdr->pd_upper > phdr->pd_special || - phdr->pd_special > BLCKSZ || + phdr->pd_special > BLCKSZ - GetPageReservedSize() || phdr->pd_special != MAXALIGN(phdr->pd_special)) ereport(ERROR, (errcode(ERRCODE_DATA_CORRUPTED), diff --git a/src/backend/storage/smgr/md.c b/src/backend/storage/smgr/md.c index dee29037b1696..f0e998901ebf5 100644 --- a/src/backend/storage/smgr/md.c +++ b/src/backend/storage/smgr/md.c @@ -35,6 +35,7 @@ #include "storage/aio.h" #include "storage/bufmgr.h" #include "storage/fd.h" +#include "storage/file_encryption.h" #include "storage/md.h" #include "storage/relfilelocator.h" #include "storage/smgr.h" @@ -172,6 +173,42 @@ const PgAioHandleCallbacks aio_md_readv_cb = { .report = md_readv_report, }; +/* + * Per-backend workspace for page encryption. Sized to + * MAX_IO_COMBINE_LIMIT * BLCKSZ bytes and pre-allocated in mdinit() when a + * page-encryption module is configured (AIO completion callbacks run in a + * critical section and can't allocate, so we have to do it eagerly). + * Stays NULL when no page-encryption module is loaded. + */ +static char *md_enc_workspace = NULL; + +/* + * Whether this fork's pages are routed through the file-encryption module. + * FSM and VM forks carry only metadata (free-space estimates, all-visible + * bits); leaving them as plaintext keeps fsmpage.c / visibilitymap.c free + * of crypto plumbing while encrypting everything that holds user data. + * The cluster-wide page_reserved_size still applies to FSM/VM pages so + * their on-disk layout stays uniform with the rest of the cluster. + */ +bool +md_fork_is_encrypted(ForkNumber forknum) +{ + if (!FileEncryptionEnabled()) + return false; + return forknum == MAIN_FORKNUM || forknum == INIT_FORKNUM; +} + +static inline bool +md_block_is_zero(const char *block) +{ + const uint64 *p = (const uint64 *) block; + + for (Size i = 0; i < BLCKSZ / sizeof(uint64); i++) + if (p[i] != 0) + return false; + return true; +} + static inline int _mdfd_open_flags(void) @@ -193,6 +230,37 @@ mdinit(void) MdCxt = AllocSetContextCreate(TopMemoryContext, "MdSmgr", ALLOCSET_DEFAULT_SIZES); + + /* + * If a page-encryption module is already loaded, allocate the + * encryption workspace. In bootstrap mode the module hasn't been + * loaded yet at this point, and md_init_enc_workspace() will be + * called from process_file_encryption_library() once it has. + */ + md_init_enc_workspace(); +} + +/* + * Allocate the per-backend encryption workspace if a page-encryption + * module is configured, and force the file-encryption per-process init + * to run. Both must happen from outside any critical section, since AIO + * completion callbacks can't allocate. Idempotent. + */ +void +md_init_enc_workspace(void) +{ + /* + * Even when this fork doesn't carry encrypted pages, the spill-file / + * BufFile encryption paths can still hit a critical section first; + * calling FileEncryptionEnsureInit unconditionally keeps them safe. + */ + FileEncryptionEnsureInit(); + + if (FileEncryptionEnabled() && md_enc_workspace == NULL) + md_enc_workspace = MemoryContextAllocAligned(TopMemoryContext, + (Size) MAX_IO_COMBINE_LIMIT * BLCKSZ, + PG_IO_ALIGN_SIZE, + 0); } /* @@ -380,6 +448,28 @@ mdunlinkfork(RelFileLocatorBackend rlocator, ForkNumber forknum, bool isRedo) path = relpath(rlocator, forknum); + /* + * KEY_FORKNUM: schedule a delayed unlink without truncating the file. + * The wrapped DEK has to stay readable until the relation's drop + * COMMIT is itself replayed: WAL records that touch the dropped + * relation are replayed first and any buffer flush during that + * replay needs the KEY fork to encrypt its bytes. Other forks + * (FSM, VM) get re-created on-demand by their own WAL records, but + * nothing re-creates the KEY fork after RelationCreateStorage. We + * don't truncate the file (unlike MAIN, where the do_truncate + * reclaims disk space proactively): KEY fork is a single BLCKSZ + * block, the space savings are negligible, and a zero-byte file + * would short-read when the next encrypted page tries to unwrap + * the DEK during recovery. + */ + if (!isRedo && !IsBinaryUpgrade && + forknum == KEY_FORKNUM && + !RelFileLocatorBackendIsTemp(rlocator)) + { + register_unlink_segment(rlocator, forknum, 0 /* first seg */ ); + return; + } + /* * Truncate and then unlink the first segment, or just register a request * to unlink it later, as described in the comments for mdunlink(). @@ -520,6 +610,20 @@ mdextend(SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, Assert(seekpos < (pgoff_t) BLCKSZ * RELSEG_SIZE); + /* + * Encrypt into the per-backend workspace, then write the workspace. + * The buffer pool's plaintext page must not be mutated. + */ + if (md_fork_is_encrypted(forknum)) + { + char *workspace = md_enc_workspace; + + Assert(workspace != NULL); + + FileEncryptionEncryptPage(reln, forknum, blocknum, buffer, workspace); + buffer = workspace; + } + if ((nbytes = FileWrite(v->mdfd_vfd, buffer, BLCKSZ, seekpos, WAIT_EVENT_DATA_FILE_EXTEND)) != BLCKSZ) { if (nbytes < 0) @@ -984,6 +1088,31 @@ mdreadv(SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, iovcnt = compute_remaining_iovec(iov, iov, iovcnt, nbytes); } + /* + * Decrypt each block in place, when this fork's pages are routed + * through the encryption module. All-zero pages on disk (e.g. from + * mdzeroextend) are passed through unchanged so PageIsNew can + * recognise them. + */ + if (md_fork_is_encrypted(forknum)) + { + char *workspace = md_enc_workspace; + + Assert(workspace != NULL); + + for (BlockNumber b = 0; b < nblocks_this_segment; b++) + { + char *blk = (char *) buffers[b]; + + if (md_block_is_zero(blk)) + continue; + + memcpy(workspace, blk, BLCKSZ); + FileEncryptionDecryptPage(reln, forknum, blocknum + b, + workspace, blk); + } + } + nblocks -= nblocks_this_segment; buffers += nblocks_this_segment; blocknum += nblocks_this_segment; @@ -1027,6 +1156,15 @@ mdstartreadv(PgAioHandle *ioh, Assert(iovcnt <= nblocks_this_segment); + /* + * Open the per-relation encryption state before the I/O is dispatched. + * The AIO completion callback may run in a critical section where it + * can't tolerate the palloc/smgrread that FileEncryptionOpenObject + * would otherwise do on first call. + */ + if (md_fork_is_encrypted(forknum)) + FileEncryptionOpenObject(reln); + if (!(io_direct_flags & IO_DIRECT_DATA)) pgaio_io_set_flag(ioh, PGAIO_HF_BUFFERED); @@ -1102,7 +1240,36 @@ mdwritev(SMgrRelation reln, ForkNumber forknum, BlockNumber blocknum, if (nblocks_this_segment != nblocks) elog(ERROR, "write crosses segment boundary"); - iovcnt = buffers_to_iovec(iov, (void **) buffers, nblocks_this_segment); + /* + * If this fork is encrypted, build a separate ciphertext copy in the + * per-backend workspace and point a single iovec at the contiguous + * workspace bytes. The buffer pool's plaintext pages must not be + * mutated, and the workspace is one contiguous block, so we don't + * call buffers_to_iovec at all in this path. + */ + if (md_fork_is_encrypted(forknum)) + { + char *workspace = md_enc_workspace; + + Assert(workspace != NULL); + + for (BlockNumber b = 0; b < nblocks_this_segment; b++) + { + char *slot = workspace + (Size) b * BLCKSZ; + + FileEncryptionEncryptPage(reln, forknum, blocknum + b, + buffers[b], slot); + } + + iov[0].iov_base = workspace; + iov[0].iov_len = (Size) nblocks_this_segment * BLCKSZ; + iovcnt = 1; + } + else + { + iovcnt = buffers_to_iovec(iov, (void **) buffers, nblocks_this_segment); + } + size_this_segment = nblocks_this_segment * BLCKSZ; transferred_this_segment = 0; @@ -1958,8 +2125,13 @@ mdunlinkfiletag(const FileTag *ftag, char *path) { RelPathStr p; - /* Compute the path. */ - p = relpathperm(ftag->rlocator, MAIN_FORKNUM); + /* + * Compute the path. Honor the forknum from the tag rather than + * hardcoding MAIN_FORKNUM: a KEY fork can also reach this path via + * mdunlinkfork's deferred-unlink branch, and unlinking MAIN instead + * would silently leak the KEY fork file. + */ + p = relpathperm(ftag->rlocator, ftag->forknum); strlcpy(path, p.str, MAXPGPATH); /* Try to unlink the file. */ @@ -2043,6 +2215,71 @@ md_readv_complete(PgAioHandle *ioh, PgAioResult prior_result, uint8 cb_data) result.id = PGAIO_HCB_MD_READV; } + /* + * Decrypt successfully-read blocks in place. Mirrors the post-read + * loop in mdreadv() for the synchronous path. Only blocks that + * actually came back from disk are decrypted; partial reads leave the + * unread tail untouched (the upper level will retry). All-zero + * ciphertext is passed through unchanged so PageIsNew can recognise + * fresh pages produced by mdzeroextend(). + * + * buffers_to_iovec merges contiguous buffer-pool pages into a single + * iovec entry, so we walk each iovec in BLCKSZ steps rather than + * assuming one iovec per block. + */ + if (md_fork_is_encrypted(td->smgr.forkNum) && result.result > 0) + { + struct iovec *iov; + char *workspace = md_enc_workspace; + uint32 blocks_done = 0; + SMgrRelation reln; + ProcNumber procno; + + Assert(workspace != NULL); + + /* + * Resolve the SMgrRelation so we can reach its cached encryption + * state. smgropen_existing is a pure hash lookup, never allocates, + * and returns NULL if the issuer didn't pre-open the entry -- + * mandatory inside this completion-callback critical section. + * mdstartreadv (in the issuing backend) and smgr_aio_reopen (in IO + * workers) both populate the entry before the IO is dispatched. + * + * We must use the owning backend's procno for temp relations so the + * hash key matches; otherwise the lookup would miss and return NULL. + */ + procno = td->smgr.is_temp ? pgaio_io_get_owner(ioh) : INVALID_PROC_NUMBER; + reln = smgropen_existing(td->smgr.rlocator, procno); + if (reln == NULL) + elog(PANIC, "md_readv_complete: SMgrRelation for %u/%u/%u missing at IO completion", + td->smgr.rlocator.spcOid, + td->smgr.rlocator.dbOid, + td->smgr.rlocator.relNumber); + + (void) pgaio_io_get_iovec(ioh, &iov); + + for (struct iovec *cur = iov; blocks_done < result.result; cur++) + { + Assert(cur->iov_len % BLCKSZ == 0); + for (Size off = 0; off < cur->iov_len; off += BLCKSZ) + { + char *blk = (char *) cur->iov_base + off; + + if (blocks_done >= result.result) + break; + + if (!md_block_is_zero(blk)) + { + memcpy(workspace, blk, BLCKSZ); + FileEncryptionDecryptPage(reln, td->smgr.forkNum, + td->smgr.blockNum + blocks_done, + workspace, blk); + } + blocks_done++; + } + } + } + return result; } diff --git a/src/backend/storage/smgr/smgr.c b/src/backend/storage/smgr/smgr.c index 5391640d8613d..10c23baf52c29 100644 --- a/src/backend/storage/smgr/smgr.c +++ b/src/backend/storage/smgr/smgr.c @@ -68,6 +68,7 @@ #include "miscadmin.h" #include "storage/aio.h" #include "storage/bufmgr.h" +#include "storage/file_encryption.h" #include "storage/ipc.h" #include "storage/md.h" #include "storage/smgr.h" @@ -273,6 +274,7 @@ smgropen(RelFileLocator rlocator, ProcNumber backend) reln->smgr_targblock = InvalidBlockNumber; for (int i = 0; i <= MAX_FORKNUM; ++i) reln->smgr_cached_nblocks[i] = InvalidBlockNumber; + reln->encryption_object_state = NULL; reln->smgr_which = 0; /* we only have md.c at present */ /* it is not pinned yet */ @@ -288,6 +290,34 @@ smgropen(RelFileLocator rlocator, ProcNumber backend) return reln; } +/* + * smgropen_existing() -- find an SMgrRelation that has previously been + * opened in this process. + * + * Returns NULL if no entry for (rlocator, backend) exists. Unlike smgropen, + * never allocates: safe to call from contexts where a palloc would be + * fatal, e.g. AIO completion callbacks that run inside a critical section. + * The expectation is that the caller (typically an AIO completion callback) + * relies on an earlier code path to have populated the entry; a NULL return + * means that contract has been broken. + */ +SMgrRelation +smgropen_existing(RelFileLocator rlocator, ProcNumber backend) +{ + RelFileLocatorBackend brlocator; + SMgrRelation reln; + + if (SMgrRelationHash == NULL) + return NULL; + + brlocator.locator = rlocator; + brlocator.backend = backend; + reln = (SMgrRelation) hash_search(SMgrRelationHash, + &brlocator, + HASH_FIND, NULL); + return reln; +} + /* * smgrpin() -- Prevent an SMgrRelation object from being destroyed at end of * transaction @@ -328,6 +358,13 @@ smgrdestroy(SMgrRelation reln) HOLD_INTERRUPTS(); + /* + * Hand any per-relation file-encryption state back to the loaded module + * before tearing down the SMgrRelation. Idempotent — no-op if no state + * was ever attached. + */ + FileEncryptionCloseObject(reln); + for (forknum = 0; forknum <= MAX_FORKNUM; forknum++) smgrsw[reln->smgr_which].smgr_close(reln, forknum); @@ -1089,6 +1126,21 @@ smgr_aio_reopen(PgAioHandle *ioh) case PGAIO_OP_READV: od->read.fd = smgrfd(reln, sd->smgr.forkNum, sd->smgr.blockNum, &off); Assert(off == od->read.offset); + + /* + * Pre-open the per-relation file-encryption state before the IO + * worker enters the critical section in + * pgaio_io_perform_synchronously(). The completion callback + * (md_readv_complete) decrypts pages from inside that critical + * section and cannot palloc; by populating + * SMgrRelation.encryption_object_state here -- outside the + * critical section -- we ensure the decrypt path is reduced to a + * cache-hot lookup. The backend that issued the IO does the + * equivalent pre-open in mdstartreadv(); this branch covers io + * workers, which have their own SMgrRelation hash. + */ + if (md_fork_is_encrypted(sd->smgr.forkNum)) + FileEncryptionOpenObject(reln); break; case PGAIO_OP_WRITEV: od->write.fd = smgrfd(reln, sd->smgr.forkNum, sd->smgr.blockNum, &off); diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c index dbef734a93f15..5d8d189a317c5 100644 --- a/src/backend/tcop/postgres.c +++ b/src/backend/tcop/postgres.c @@ -66,6 +66,7 @@ #include "storage/bufmgr.h" #include "storage/ipc.h" #include "storage/fd.h" +#include "storage/file_encryption.h" #include "storage/pmsignal.h" #include "storage/proc.h" #include "storage/procsignal.h" @@ -4185,6 +4186,7 @@ PostgresSingleUserMain(int argc, char *argv[], * process any libraries that should be preloaded at postmaster start */ process_shared_preload_libraries(); + process_file_encryption_library(NULL); /* Initialize MaxBackends */ InitializeMaxBackends(); diff --git a/src/backend/utils/adt/dbsize.c b/src/backend/utils/adt/dbsize.c index cccc4a24c8405..51c4d5803247f 100644 --- a/src/backend/utils/adt/dbsize.c +++ b/src/backend/utils/adt/dbsize.c @@ -405,8 +405,12 @@ calculate_toast_table_size(Oid toastrelid) /* toast heap size, including FSM and VM size */ for (forkNum = 0; forkNum <= MAX_FORKNUM; forkNum++) + { + if (forkNum == KEY_FORKNUM) + continue; size += calculate_relation_size(&(toastRel->rd_locator), toastRel->rd_backend, forkNum); + } /* toast index size, including FSM and VM size */ indexlist = RelationGetIndexList(toastRel); @@ -419,8 +423,12 @@ calculate_toast_table_size(Oid toastrelid) toastIdxRel = relation_open(lfirst_oid(lc), AccessShareLock); for (forkNum = 0; forkNum <= MAX_FORKNUM; forkNum++) + { + if (forkNum == KEY_FORKNUM) + continue; size += calculate_relation_size(&(toastIdxRel->rd_locator), toastIdxRel->rd_backend, forkNum); + } relation_close(toastIdxRel, AccessShareLock); } @@ -445,11 +453,18 @@ calculate_table_size(Relation rel) ForkNumber forkNum; /* - * heap size, including FSM and VM + * heap size, including FSM and VM. The KEY fork is file-encryption + * framework metadata, not user data, so leave it out of pg_table_size + * and friends -- otherwise an empty encrypted table would report + * BLCKSZ instead of 0 bytes. */ for (forkNum = 0; forkNum <= MAX_FORKNUM; forkNum++) + { + if (forkNum == KEY_FORKNUM) + continue; size += calculate_relation_size(&(rel->rd_locator), rel->rd_backend, forkNum); + } /* * Size of toast relation @@ -487,9 +502,13 @@ calculate_indexes_size(Relation rel) idxRel = relation_open(idxOid, AccessShareLock); for (forkNum = 0; forkNum <= MAX_FORKNUM; forkNum++) + { + if (forkNum == KEY_FORKNUM) + continue; size += calculate_relation_size(&(idxRel->rd_locator), idxRel->rd_backend, forkNum); + } relation_close(idxRel, AccessShareLock); } diff --git a/src/backend/utils/adt/selfuncs.c b/src/backend/utils/adt/selfuncs.c index f2b58ebfe1ece..8d92919591036 100644 --- a/src/backend/utils/adt/selfuncs.c +++ b/src/backend/utils/adt/selfuncs.c @@ -9079,7 +9079,7 @@ brincostestimate(PlannerInfo *root, IndexPath *path, double loop_count, BRIN_DEFAULT_PAGES_PER_RANGE), 1.0); statsData.pagesPerRange = BRIN_DEFAULT_PAGES_PER_RANGE; - statsData.revmapNumPages = (indexRanges / REVMAP_PAGE_MAXITEMS) + 1; + statsData.revmapNumPages = (indexRanges / RevmapPageMaxItemsForCluster()) + 1; } /* diff --git a/src/backend/utils/misc/guc_parameters.dat b/src/backend/utils/misc/guc_parameters.dat index afaa058b046c9..f0b4ce78c604b 100644 --- a/src/backend/utils/misc/guc_parameters.dat +++ b/src/backend/utils/misc/guc_parameters.dat @@ -1092,6 +1092,14 @@ options => 'file_copy_method_options', }, +{ name => 'file_encryption_config', type => 'string', context => 'PGC_POSTMASTER', group => 'RESOURCES_DISK', + short_desc => 'Module-defined configuration passed verbatim to the file encryption module at load time.', + long_desc => 'The format is defined entirely by the module named in file_encryption_library; the server passes the string through without inspection. Modules must not require key material to be passed here; production-grade modules fetch keys from an external key store at startup using whatever this string identifies.', + flags => 'GUC_SUPERUSER_ONLY | GUC_NOT_IN_SAMPLE', + variable => 'file_encryption_config', + boot_val => '""', +}, + { name => 'file_extend_method', type => 'enum', context => 'PGC_SIGHUP', group => 'RESOURCES_DISK', short_desc => 'Selects the method used for extending data files.', variable => 'file_extend_method', diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c index 290ccbc543e25..d39220c8d4c47 100644 --- a/src/backend/utils/misc/guc_tables.c +++ b/src/backend/utils/misc/guc_tables.c @@ -81,6 +81,7 @@ #include "storage/bufpage.h" #include "storage/copydir.h" #include "storage/fd.h" +#include "storage/file_encryption.h" #include "storage/io_worker.h" #include "storage/large_object.h" #include "storage/pg_shmem.h" diff --git a/src/bin/initdb/initdb.c b/src/bin/initdb/initdb.c index 14cb79c26be04..24165ce653b2e 100644 --- a/src/bin/initdb/initdb.c +++ b/src/bin/initdb/initdb.c @@ -68,6 +68,7 @@ #include "catalog/pg_authid_d.h" #include "catalog/pg_class_d.h" #include "catalog/pg_collation_d.h" +#include "catalog/pg_control.h" #include "catalog/pg_database_d.h" #include "common/file_perm.h" #include "common/file_utils.h" @@ -165,6 +166,8 @@ static bool do_sync = true; static bool sync_only = false; static bool show_setting = false; static bool data_checksums = true; +static char *file_encryption_library = NULL; +static char *file_encryption_config = NULL; static char *xlog_dir = NULL; static int wal_segment_size_mb = (DEFAULT_XLOG_SEG_SIZE) / (1024 * 1024); static DataDirSyncMethod sync_method = DATA_DIR_SYNC_METHOD_FSYNC; @@ -1636,6 +1639,16 @@ bootstrap_template1(void) appendPQExpBuffer(&cmd, " -X %d", wal_segment_size_mb * (1024 * 1024)); if (data_checksums) appendPQExpBufferStr(&cmd, " -k"); + /* + * The library name goes to the bootstrap backend via -L (it lands in + * pg_control there). The corresponding config is forwarded to every + * backend initdb spawns via extra_options, so we don't repeat it here. + */ + if (file_encryption_library != NULL && file_encryption_library[0] != '\0') + { + appendPQExpBufferStr(&cmd, " -L "); + appendShellString(&cmd, file_encryption_library); + } if (debug) appendPQExpBufferStr(&cmd, " -d 5"); @@ -2563,6 +2576,16 @@ usage(const char *progname) printf(_(" --locale-provider={builtin|libc|icu}\n" " set default locale provider for new databases\n")); printf(_(" --no-data-checksums do not use data page checksums\n")); + printf(_(" --file-encryption-library=NAME\n" + " name of the file encryption module to load. The\n" + " cluster's page_reserved_size is set from the\n" + " module's declared per-page overhead.\n")); + printf(_(" --file-encryption-config=STRING\n" + " module-defined configuration string, passed verbatim\n" + " to the module's init function. Written into the new\n" + " cluster's postgresql.conf as file_encryption_config so\n" + " later postmasters use the same value; pass\n" + " --set file_encryption_config=... after it to override.\n")); printf(_(" --pwfile=FILE read password for the new superuser from file\n")); printf(_(" -T, --text-search-config=CFG\n" " default text search configuration\n")); @@ -3223,6 +3246,8 @@ main(int argc, char *argv[]) {"sync-method", required_argument, NULL, 19}, {"no-data-checksums", no_argument, NULL, 20}, {"no-sync-data-files", no_argument, NULL, 21}, + {"file-encryption-library", required_argument, NULL, 22}, + {"file-encryption-config", required_argument, NULL, 23}, {NULL, 0, NULL, 0} }; @@ -3420,6 +3445,40 @@ main(int argc, char *argv[]) case 21: sync_data_files = false; break; + case 22: + file_encryption_library = pg_strdup(optarg); + break; + case 23: + file_encryption_config = pg_strdup(optarg); + + /* + * Default the new cluster's file_encryption_config GUC to + * the same value we were handed. This is what an operator + * almost always wants: the same config initdb used should + * apply to the running cluster. Adding it here uses the + * same channel as --set / -c, so an explicit later "--set + * file_encryption_config=..." still wins via postgresql.conf + * read order, and operators who source the value elsewhere + * (env var, ALTER SYSTEM, include directive) can leave the + * conf line untouched and let it be overridden at startup. + */ + add_stringlist_item(&extra_guc_names, "file_encryption_config"); + add_stringlist_item(&extra_guc_values, optarg); + + /* + * Forward the config to backends initdb spawns via the + * PGFILEENCRYPTIONCONFIG environment variable, which is + * inherited across fork/exec. We deliberately avoid + * inlining the value into postgres's command line so the + * operator-supplied configuration isn't trivially + * observable via `ps` during initdb; file_encryption.c + * consults the same env var when its GUC is unset (which + * is the case in the bootstrap backend, before any + * postgresql.conf exists). + */ + if (setenv("PGFILEENCRYPTIONCONFIG", optarg, 1) != 0) + pg_fatal("could not set environment variable: %m"); + break; default: /* getopt_long already emitted a complaint */ pg_log_error_hint("Try \"%s --help\" for more information.", progname); @@ -3523,6 +3582,10 @@ main(int argc, char *argv[]) else printf(_("Data page checksums are disabled.\n")); + if (file_encryption_library != NULL && file_encryption_library[0] != '\0') + printf(_("File encryption is enabled (module: %s).\n"), + file_encryption_library); + if (pwprompt || pwfilename) get_su_pwd(); diff --git a/src/bin/pg_checksums/meson.build b/src/bin/pg_checksums/meson.build index 7b2401cb31b76..4e7ad77e4d1f2 100644 --- a/src/bin/pg_checksums/meson.build +++ b/src/bin/pg_checksums/meson.build @@ -14,6 +14,12 @@ pg_checksums = executable('pg_checksums', pg_checksums_sources, include_directories: [timezone_inc], dependencies: [frontend_code], + # dlopen'd file_encryption modules resolve libpgcommon/libpgport symbols + # (palloc, pstrdup, pg_strong_random, ...) against pg_checksums itself. + # Force the static archives to contribute all their symbols, and export + # them in the dynamic table so dlopen lookups succeed. + link_whole: [common_static, pgport_static], + export_dynamic: true, kwargs: default_bin_args, ) bin_targets += pg_checksums @@ -26,6 +32,7 @@ tests += { 'tests': [ 't/001_basic.pl', 't/002_actions.pl', + 't/003_encrypted.pl', ], }, } diff --git a/src/bin/pg_checksums/pg_checksums.c b/src/bin/pg_checksums/pg_checksums.c index cfacd1300fc1d..80bf4063bb363 100644 --- a/src/bin/pg_checksums/pg_checksums.c +++ b/src/bin/pg_checksums/pg_checksums.c @@ -20,7 +20,17 @@ #include #include +#ifdef WIN32 +#include "port/win32_port.h" /* for dlclose */ +#else +#include +#endif + +#include "catalog/pg_tablespace_d.h" #include "common/controldata_utils.h" +#include "common/file_encryption_keyblock.h" +#include "common/file_encryption_load.h" +#include "common/file_encryption_module.h" #include "common/file_utils.h" #include "common/logging.h" #include "common/relpath.h" @@ -46,6 +56,33 @@ static bool verbose = false; static bool showprogress = false; static DataDirSyncMethod sync_method = DATA_DIR_SYNC_METHOD_FSYNC; +/* + * File encryption module state. Populated in main() after ControlFile is + * read if the cluster was initdb'd with --file-encryption-library; NULL + * otherwise. scan_file consults fe_callbacks to decide whether to + * decrypt blocks before checksum verification. + */ +static const FileEncryptionCallbacks *fe_callbacks = NULL; +static FileEncryptionModuleState fe_module_state = {0}; +static void *fe_module_handle = NULL; + +/* + * One-entry-per-relation cache of unwrapped DEK state. Built lazily on + * first MAIN/INIT block seen for each relation; freed at exit. A simple + * linked list is sufficient -- pg_checksums walks files sequentially and + * we only revisit a relation across its segment boundary. + */ +typedef struct EncryptedRelEntry +{ + RelFileLocator locator; + void *object_state; + struct EncryptedRelEntry *next; +} EncryptedRelEntry; + +static EncryptedRelEntry *encrypted_rels = NULL; + +static char *file_encryption_config = NULL; + typedef enum { PG_MODE_CHECK, @@ -76,6 +113,10 @@ usage(void) printf(_(" -d, --disable disable data checksums\n")); printf(_(" -e, --enable enable data checksums\n")); printf(_(" -f, --filenode=FILENODE check only relation with specified filenode\n")); + printf(_(" --file-encryption-config=STRING\n" + " configuration blob for the encryption module\n" + " named in the cluster's pg_control; may also be\n" + " supplied via the PGFILEENCRYPTIONCONFIG env var\n")); printf(_(" -N, --no-sync do not wait for changes to be written safely to disk\n")); printf(_(" -P, --progress show progress information\n")); printf(_(" --sync-method=METHOD set method for syncing files to disk\n")); @@ -172,8 +213,143 @@ skipfile(const char *fn) return false; } +/* + * Resolve the per-relation object_state for an encrypted relation, + * loading + caching it on first lookup. Reads the KEY fork from + * '/_key', validates its header, and hands the + * wrapped DEK to the module's object_open_cb. + */ +static void * +get_encryption_object_state(RelFileLocator locator, const char *dirpath) +{ + EncryptedRelEntry *entry; + char keypath[MAXPGPATH]; + int f; + PGIOAlignedBlock keybuf; + FEKeyBlockHeader hdr; + int r; + char *module_errmsg = NULL; + void *obj_state; + + for (entry = encrypted_rels; entry != NULL; entry = entry->next) + { + if (entry->locator.spcOid == locator.spcOid && + entry->locator.dbOid == locator.dbOid && + entry->locator.relNumber == locator.relNumber) + return entry->object_state; + } + + snprintf(keypath, sizeof(keypath), "%s/%u_key", dirpath, locator.relNumber); + f = open(keypath, O_RDONLY | PG_BINARY, 0); + if (f < 0) + pg_fatal("could not open KEY fork \"%s\": %m", keypath); + r = read(f, keybuf.data, BLCKSZ); + if (r != BLCKSZ) + { + if (r < 0) + pg_fatal("could not read KEY fork \"%s\": %m", keypath); + pg_fatal("short read on KEY fork \"%s\": read %d of %d", keypath, r, BLCKSZ); + } + close(f); + + memcpy(&hdr, keybuf.data + FE_KEY_BLOCK_HEADER_OFFSET, sizeof(hdr)); + if (hdr.magic != FE_KEY_BLOCK_MAGIC) + pg_fatal("invalid KEY fork \"%s\": bad magic 0x%08x", keypath, hdr.magic); + if (hdr.version != FE_KEY_BLOCK_VERSION) + pg_fatal("unsupported KEY-fork version %u in \"%s\"", hdr.version, keypath); + if (hdr.wrapped_len == 0 || hdr.wrapped_len > FE_KEY_BLOCK_MAX_WRAPPED) + pg_fatal("invalid KEY fork \"%s\": wrapped_len %u out of range", + keypath, hdr.wrapped_len); + + obj_state = fe_callbacks->object_open_cb(&fe_module_state, &locator, + keybuf.data + FE_KEY_BLOCK_PAYLOAD_OFFSET, + hdr.wrapped_len, &module_errmsg); + if (obj_state == NULL) + pg_fatal("could not unwrap KEY for relation %u/%u/%u: %s", + locator.spcOid, locator.dbOid, locator.relNumber, + module_errmsg ? module_errmsg : "no detail"); + + entry = pg_malloc(sizeof(*entry)); + entry->locator = locator; + entry->object_state = obj_state; + entry->next = encrypted_rels; + encrypted_rels = entry; + return obj_state; +} + +/* + * Decrypt a single block in place using the loaded module. Caller has + * already determined that the relation+fork is encrypted (i.e. fe_callbacks + * is non-NULL and the fork is MAIN or INIT). All-zero blocks are passed + * through unchanged so PageIsNew() still recognises fresh-from-extend pages. + */ static void -scan_file(const char *fn, int segmentno) +decrypt_block_in_place(PGIOAlignedBlock *buf, RelFileLocator locator, + ForkNumber forknum, BlockNumber blocknum, + const char *dirpath) +{ + void *obj_state; + PGIOAlignedBlock plaintext; + char *module_errmsg = NULL; + + /* Pass through fresh pages without trying to decrypt zeros. */ + { + const uint64 *p = (const uint64 *) buf->data; + bool all_zero = true; + + for (Size i = 0; i < BLCKSZ / sizeof(uint64); i++) + if (p[i] != 0) + { + all_zero = false; + break; + } + if (all_zero) + return; + } + + obj_state = get_encryption_object_state(locator, dirpath); + if (!fe_callbacks->decrypt_page_cb(&fe_module_state, obj_state, + forknum, blocknum, + buf->data, plaintext.data, + &module_errmsg)) + pg_fatal("could not decrypt fork %d block %u of relation %u/%u/%u: %s", + forknum, blocknum, + locator.spcOid, locator.dbOid, locator.relNumber, + module_errmsg ? module_errmsg : "no detail"); + + memcpy(buf->data, plaintext.data, BLCKSZ); +} + +/* + * Re-encrypt a plaintext block in place after we've adjusted pd_checksum + * in --enable mode. Mirrors decrypt_block_in_place: same gating, same + * cached object state, just the opposite direction. + */ +static void +encrypt_block_in_place(PGIOAlignedBlock *buf, RelFileLocator locator, + ForkNumber forknum, BlockNumber blocknum, + const char *dirpath) +{ + void *obj_state; + PGIOAlignedBlock ciphertext; + char *module_errmsg = NULL; + + obj_state = get_encryption_object_state(locator, dirpath); + if (!fe_callbacks->encrypt_page_cb(&fe_module_state, obj_state, + forknum, blocknum, + buf->data, ciphertext.data, + &module_errmsg)) + pg_fatal("could not encrypt fork %d block %u of relation %u/%u/%u: %s", + forknum, blocknum, + locator.spcOid, locator.dbOid, locator.relNumber, + module_errmsg ? module_errmsg : "no detail"); + + memcpy(buf->data, ciphertext.data, BLCKSZ); +} + +static void +scan_file(const char *fn, int segmentno, RelFileLocator locator, + ForkNumber forknum, const char *dirpath) { PGIOAlignedBlock buf; PageHeader header = (PageHeader) buf.data; @@ -181,10 +357,19 @@ scan_file(const char *fn, int segmentno) BlockNumber blockno; int flags; int64 blocks_written_in_file = 0; + bool do_decrypt; Assert(mode == PG_MODE_ENABLE || mode == PG_MODE_CHECK); + /* + * MAIN and INIT forks of relations in an encrypted cluster are routed + * through the module on every read/write; FSM, VM, and KEY forks are + * passed through plaintext per md_fork_is_encrypted() in the backend. + */ + do_decrypt = (fe_callbacks != NULL && + (forknum == MAIN_FORKNUM || forknum == INIT_FORKNUM)); + flags = (mode == PG_MODE_ENABLE) ? O_RDWR : O_RDONLY; f = open(fn, PG_BINARY | flags, 0); @@ -196,6 +381,7 @@ scan_file(const char *fn, int segmentno) for (blockno = 0;; blockno++) { uint16 csum; + BlockNumber abs_blocknum = blockno + (BlockNumber) segmentno * RELSEG_SIZE; int r = read(f, buf.data, BLCKSZ); if (r == 0) @@ -219,11 +405,14 @@ scan_file(const char *fn, int segmentno) */ current_size += r; + if (do_decrypt) + decrypt_block_in_place(&buf, locator, forknum, abs_blocknum, dirpath); + /* New pages have no checksum yet */ if (PageIsNew(buf.data)) continue; - csum = pg_checksum_page(buf.data, blockno + segmentno * RELSEG_SIZE); + csum = pg_checksum_page(buf.data, abs_blocknum); if (mode == PG_MODE_CHECK) { if (csum != header->pd_checksum) @@ -250,6 +439,15 @@ scan_file(const char *fn, int segmentno) /* Set checksum in page header */ header->pd_checksum = csum; + /* + * Re-encrypt before writing back if this fork was encrypted on + * read. Both directions use the same object state, so we + * never have to worry about wrap/unwrap mismatches. + */ + if (do_decrypt) + encrypt_block_in_place(&buf, locator, forknum, abs_blocknum, + dirpath); + /* Seek back to beginning of block */ if (lseek(f, -BLCKSZ, SEEK_CUR) < 0) pg_fatal("seek failed for block %u in file \"%s\": %m", blockno, fn); @@ -295,9 +493,18 @@ scan_file(const char *fn, int segmentno) * all the items which have checksums is computed and returned back * to the caller without operating on the files. This is used to compile * the total size of the data directory for progress reports. + * + * spcOid / dbOid are the OIDs implied by the directory path (e.g. when + * scanning base/16384/, dbOid=16384 and spcOid=DEFAULTTABLESPACE_OID). + * spcOid=0 means the OID isn't known yet -- we're either at the top of + * pg_tblspc and the next-level subdir name is the tablespace OID, or + * we're at the version-dir level and the next-level subdir name is the + * database OID. These are passed to scan_file so it can construct the + * RelFileLocator for encrypted relations. */ static int64 -scan_directory(const char *basedir, const char *subdir, bool sizeonly) +scan_directory(const char *basedir, const char *subdir, bool sizeonly, + Oid spcOid, Oid dbOid) { int64 dirsize = 0; char path[MAXPGPATH]; @@ -342,6 +549,8 @@ scan_directory(const char *basedir, const char *subdir, bool sizeonly) char *forkpath, *segmentpath; int segmentno = 0; + ForkNumber forknum = MAIN_FORKNUM; + RelFileLocator locator; if (skipfile(de->d_name)) continue; @@ -365,12 +574,41 @@ scan_directory(const char *basedir, const char *subdir, bool sizeonly) forkpath = strchr(fnonly, '_'); if (forkpath != NULL) + { *forkpath++ = '\0'; + forknum = forkname_to_number(forkpath); + if (forknum == InvalidForkNumber) + { + /* + * Unrecognised fork suffix. Upstream pg_checksums has + * never validated fork names and just feeds whatever it + * finds to scan_file; preserve that tolerance when no + * encryption module is loaded. When one is loaded, the + * fork number drives AAD selection and an out-of-bound + * guess would just produce a confusing tag-mismatch + * error on the first block -- skip the file instead. + */ + if (fe_callbacks != NULL) + continue; + forknum = MAIN_FORKNUM; + } + } if (only_filenode && strcmp(only_filenode, fnonly) != 0) /* filenode not to be included */ continue; + /* + * In --enable mode the KEY fork's checksum was already set + * at relation-create time via pg_checksum_page (see + * FileEncryptionGenerateObjectKey); the backend's data- + * checksum worker skips the KEY fork on enable, and so do we. + * Checksum verification (--check) still runs for the KEY fork + * normally. + */ + if (forknum == KEY_FORKNUM && mode == PG_MODE_ENABLE) + continue; + dirsize += st.st_size; /* @@ -378,10 +616,32 @@ scan_directory(const char *basedir, const char *subdir, bool sizeonly) * the items in the data folder. */ if (!sizeonly) - scan_file(fn, segmentno); + { + locator.spcOid = spcOid; + locator.dbOid = dbOid; + locator.relNumber = (RelFileNumber) strtoul(fnonly, NULL, 10); + scan_file(fn, segmentno, locator, forknum, path); + } } else if (S_ISDIR(st.st_mode) || S_ISLNK(st.st_mode)) { + Oid sub_spc = spcOid; + Oid sub_db = dbOid; + char *endp; + + /* + * Subdirectory names are OIDs at three levels: in base// + * the d_name is the dbOid; at the top of pg_tblspc the d_name + * is the tablespace OID; and inside a tablespace's + * TABLESPACE_VERSION_DIRECTORY the d_name is again the dbOid. + * dbOid==0 (unset) is the "next numeric subdir is the dbOid" + * signal; spcOid==0 is the same signal at the top of pg_tblspc. + */ + if (spcOid == 0) + sub_spc = (Oid) strtoul(de->d_name, &endp, 10); + else if (dbOid == 0 && spcOid != GLOBALTABLESPACE_OID) + sub_db = (Oid) strtoul(de->d_name, &endp, 10); + /* * If going through the entries of pg_tblspc, we assume to operate * on tablespace locations where only TABLESPACE_VERSION_DIRECTORY @@ -417,11 +677,12 @@ scan_directory(const char *basedir, const char *subdir, bool sizeonly) /* Looks like a valid tablespace location */ dirsize += scan_directory(tblspc_path, TABLESPACE_VERSION_DIRECTORY, - sizeonly); + sizeonly, sub_spc, sub_db); } else { - dirsize += scan_directory(path, de->d_name, sizeonly); + dirsize += scan_directory(path, de->d_name, sizeonly, + sub_spc, sub_db); } } } @@ -442,6 +703,7 @@ main(int argc, char *argv[]) {"progress", no_argument, NULL, 'P'}, {"verbose", no_argument, NULL, 'v'}, {"sync-method", required_argument, NULL, 1}, + {"file-encryption-config", required_argument, NULL, 2}, {NULL, 0, NULL, 0} }; @@ -506,6 +768,9 @@ main(int argc, char *argv[]) if (!parse_sync_method(optarg, &sync_method)) exit(1); break; + case 2: + file_encryption_config = pg_strdup(optarg); + break; default: /* getopt_long already emitted a complaint */ pg_log_error_hint("Try \"%s --help\" for more information.", progname); @@ -597,6 +862,53 @@ main(int argc, char *argv[]) mode == PG_MODE_ENABLE) pg_fatal("data checksums are already enabled in cluster"); + /* + * Load the file-encryption module, if the cluster was initialized with + * one. Module errors abort via pg_fatal; on success fe_callbacks is + * non-NULL and scan_file routes MAIN/INIT blocks through it. + */ + if (ControlFile->file_encryption_library[0] != '\0') + { + char *load_errmsg = NULL; + + if (file_encryption_config == NULL) + { + const char *env = getenv("PGFILEENCRYPTIONCONFIG"); + + if (env != NULL) + file_encryption_config = pg_strdup(env); + } + if (file_encryption_config == NULL || file_encryption_config[0] == '\0') + { + pg_log_error("cluster was initialized with file encryption but no configuration was supplied"); + pg_log_error_hint("Use --file-encryption-config=STRING or set the PGFILEENCRYPTIONCONFIG environment variable."); + exit(1); + } + + if (!load_file_encryption_module(argv[0], + ControlFile->file_encryption_library, + file_encryption_config, + &fe_module_handle, + &fe_callbacks, + &load_errmsg)) + pg_fatal("%s", load_errmsg ? load_errmsg : "could not load file encryption module"); + + fe_module_state.sversion = PG_VERSION_NUM; + if (fe_callbacks->startup_cb != NULL) + { + char *startup_errmsg = NULL; + + if (!fe_callbacks->startup_cb(&fe_module_state, &startup_errmsg)) + pg_fatal("%s", + startup_errmsg ? startup_errmsg : + "file encryption module startup callback failed"); + } + if (fe_callbacks->page_overhead_size != ControlFile->page_reserved_size) + pg_fatal("module's page_overhead_size %zu does not match cluster's page_reserved_size %u", + fe_callbacks->page_overhead_size, + ControlFile->page_reserved_size); + } + /* Operate on all files if checking or enabling checksums */ if (mode == PG_MODE_CHECK || mode == PG_MODE_ENABLE) { @@ -607,14 +919,18 @@ main(int argc, char *argv[]) */ if (showprogress) { - total_size = scan_directory(DataDir, "global", true); - total_size += scan_directory(DataDir, "base", true); - total_size += scan_directory(DataDir, PG_TBLSPC_DIR, true); + total_size = scan_directory(DataDir, "global", true, + GLOBALTABLESPACE_OID, 0); + total_size += scan_directory(DataDir, "base", true, + DEFAULTTABLESPACE_OID, 0); + total_size += scan_directory(DataDir, PG_TBLSPC_DIR, true, 0, 0); } - (void) scan_directory(DataDir, "global", false); - (void) scan_directory(DataDir, "base", false); - (void) scan_directory(DataDir, PG_TBLSPC_DIR, false); + (void) scan_directory(DataDir, "global", false, + GLOBALTABLESPACE_OID, 0); + (void) scan_directory(DataDir, "base", false, + DEFAULTTABLESPACE_OID, 0); + (void) scan_directory(DataDir, PG_TBLSPC_DIR, false, 0, 0); if (showprogress) progress_report(true); @@ -664,5 +980,31 @@ main(int argc, char *argv[]) printf(_("Checksums disabled in cluster\n")); } + /* + * Release per-relation encryption state and the module's per-process + * state in module-friendly order. + */ + if (fe_callbacks != NULL) + { + EncryptedRelEntry *entry, + *next; + + for (entry = encrypted_rels; entry != NULL; entry = next) + { + next = entry->next; + if (fe_callbacks->object_close_cb != NULL) + fe_callbacks->object_close_cb(&fe_module_state, + entry->object_state); + pg_free(entry); + } + encrypted_rels = NULL; + + if (fe_callbacks->shutdown_cb != NULL) + fe_callbacks->shutdown_cb(&fe_module_state); + + if (fe_module_handle != NULL) + dlclose(fe_module_handle); + } + return 0; } diff --git a/src/bin/pg_checksums/t/003_encrypted.pl b/src/bin/pg_checksums/t/003_encrypted.pl new file mode 100644 index 0000000000000..6334e5f60c82d --- /dev/null +++ b/src/bin/pg_checksums/t/003_encrypted.pl @@ -0,0 +1,130 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Verify pg_checksums against an encrypted cluster: configure +# basic_file_encryption at initdb time, populate a few relations, stop the +# cluster, then run pg_checksums --check both with and without the +# encryption config supplied. Also exercise --enable on a no-checksums +# encrypted cluster (decrypt -> set pd_checksum -> re-encrypt -> write +# back) and confirm the result verifies. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +sub random_key +{ + my @hex; + for (1 .. 64) + { + push @hex, sprintf("%x", int(rand(16))); + } + return join('', @hex); +} + +my $key = random_key(); + +# +# --- Phase 1: --check on a cluster that initdb'd WITH data-checksums on. +# +my $node = PostgreSQL::Test::Cluster->new('encrypted'); +$node->init( + extra => [ + '--file-encryption-library=basic_file_encryption', + "--file-encryption-config=$key" + ]); +$node->start; + +# Create heap + btree with a deterministic payload; checkpoint forces +# pages to disk via the encrypted mdwritev path. +$node->safe_psql('postgres', q[ +CREATE TABLE t (id int PRIMARY KEY, payload text); +INSERT INTO t SELECT g, repeat(md5(g::text), 4) FROM generate_series(1, 2000) g; +CHECKPOINT; +]); + +$node->stop; + +my $datadir = $node->data_dir; + +# Missing config: pg_checksums --check must refuse with a useful error. +$node->command_checks_all( + [ 'pg_checksums', '--check', '-D', $datadir ], + 1, + [], + [qr/cluster was initialized with file encryption but no configuration was supplied/], + 'pg_checksums --check fails without an encryption config'); + +# With the config supplied, --check succeeds across all forks. +$node->command_checks_all( + [ 'pg_checksums', '--check', + '--file-encryption-config', $key, + '-D', $datadir ], + 0, + [qr/Bad checksums:\s*0/], + [], + 'pg_checksums --check passes for the encrypted cluster'); + +# Same, but via the PGFILEENCRYPTIONCONFIG env var fallback. +local $ENV{PGFILEENCRYPTIONCONFIG} = $key; +$node->command_checks_all( + [ 'pg_checksums', '--check', '-D', $datadir ], + 0, + [qr/Bad checksums:\s*0/], + [], + 'pg_checksums --check reads config from PGFILEENCRYPTIONCONFIG'); +delete $ENV{PGFILEENCRYPTIONCONFIG}; + +# +# --- Phase 2: --enable on a no-checksums encrypted cluster. +# +my $key2 = random_key(); +my $node2 = PostgreSQL::Test::Cluster->new('encrypted_nocsums'); +$node2->init( + extra => [ + '--no-data-checksums', + '--file-encryption-library=basic_file_encryption', + "--file-encryption-config=$key2" + ]); +$node2->start; +$node2->safe_psql('postgres', q[ +CREATE TABLE t (id int PRIMARY KEY, payload text); +INSERT INTO t SELECT g, repeat(md5(g::text), 4) FROM generate_series(1, 2000) g; +CHECKPOINT; +]); +$node2->stop; + +my $datadir2 = $node2->data_dir; + +$node2->command_checks_all( + [ 'pg_checksums', '--enable', + '--file-encryption-config', $key2, + '-D', $datadir2 ], + 0, + [qr/Checksums enabled in cluster/], + [], + 'pg_checksums --enable succeeds on encrypted cluster'); + +# Restart the cluster and round-trip a value to confirm the re-encrypted +# blocks decrypt correctly under the original DEK. +$node2->start; +my $count = $node2->safe_psql('postgres', 'SELECT count(*) FROM t;'); +is($count, '2000', 'all rows readable after --enable round-trip'); +my $sample = $node2->safe_psql('postgres', "SELECT payload FROM t WHERE id = 1234;"); +my $expected = $node2->safe_psql('postgres', "SELECT repeat(md5('1234'), 4);"); +is($sample, $expected, 'payload bytes intact after --enable round-trip'); +$node2->stop; + +# Final --check confirms the rewritten checksums verify. +$node2->command_checks_all( + [ 'pg_checksums', '--check', + '--file-encryption-config', $key2, + '-D', $datadir2 ], + 0, + [qr/Bad checksums:\s*0/], + [], + 'pg_checksums --check passes after --enable'); + +done_testing(); diff --git a/src/bin/pg_controldata/pg_controldata.c b/src/bin/pg_controldata/pg_controldata.c index fe5fc5ec133b7..9ebdb8cb2312e 100644 --- a/src/bin/pg_controldata/pg_controldata.c +++ b/src/bin/pg_controldata/pg_controldata.c @@ -347,6 +347,12 @@ main(int argc, char *argv[]) (ControlFile->float8ByVal ? _("by value") : _("by reference"))); printf(_("Data page checksum version: %u\n"), ControlFile->data_checksum_version); + printf(_("File encryption page-reserved size: %u\n"), + ControlFile->page_reserved_size); + printf(_("File encryption library: %s\n"), + ControlFile->file_encryption_library[0] != '\0' + ? ControlFile->file_encryption_library + : _("(none)")); printf(_("Default char data signedness: %s\n"), (ControlFile->default_char_signedness ? _("signed") : _("unsigned"))); printf(_("Mock authentication nonce: %s\n"), diff --git a/src/bin/pg_upgrade/controldata.c b/src/bin/pg_upgrade/controldata.c index cffcd4b0ebabe..54ca6a2ad9df0 100644 --- a/src/bin/pg_upgrade/controldata.c +++ b/src/bin/pg_upgrade/controldata.c @@ -505,6 +505,16 @@ get_control_data(ClusterInfo *cluster) cluster->controldata.data_checksum_version = str2uint(p); got_data_checksum_version = true; } + else if ((p = strstr(bufin, "File encryption page-reserved size:")) != NULL) + { + p = strchr(p, ':'); + + if (p == NULL || strlen(p) <= 1) + pg_fatal("%d: controldata retrieval problem", __LINE__); + + p++; /* remove ':' char */ + cluster->controldata.page_reserved_size = str2uint(p); + } else if ((p = strstr(bufin, "Default char data signedness:")) != NULL) { p = strchr(p, ':'); @@ -757,6 +767,16 @@ check_control_data(ControlData *oldctrl, pg_fatal("old cluster uses data checksums but the new one does not"); else if (oldctrl->data_checksum_version != newctrl->data_checksum_version) pg_fatal("old and new cluster pg_controldata checksum versions do not match"); + + /* + * Encryption page-reserved sizes have to match exactly: pages on disk in + * the old cluster carry their tail metadata at a fixed offset that the + * new cluster's smgr layer must understand. Cross-encryption upgrades + * require dump+restore. + */ + if (oldctrl->page_reserved_size != newctrl->page_reserved_size) + pg_fatal("old and new cluster file-encryption page-reserved sizes do not match (%u vs %u)", + oldctrl->page_reserved_size, newctrl->page_reserved_size); } diff --git a/src/bin/pg_upgrade/pg_upgrade.h b/src/bin/pg_upgrade/pg_upgrade.h index 1d767bbda2df8..a4b2a47a9a466 100644 --- a/src/bin/pg_upgrade/pg_upgrade.h +++ b/src/bin/pg_upgrade/pg_upgrade.h @@ -257,6 +257,7 @@ typedef struct bool date_is_int; bool float8_pass_by_value; uint32 data_checksum_version; + uint32 page_reserved_size; bool default_char_signedness; } ControlData; diff --git a/src/common/Makefile b/src/common/Makefile index 1a2fbbe887f22..a41e26ddf5d4a 100644 --- a/src/common/Makefile +++ b/src/common/Makefile @@ -56,6 +56,7 @@ OBJS_COMMON = \ encnames.o \ exec.o \ f2s.o \ + file_encryption_load.o \ file_perm.o \ file_utils.o \ hashfn.o \ diff --git a/src/common/file_encryption_load.c b/src/common/file_encryption_load.c new file mode 100644 index 0000000000000..53aa7554113ae --- /dev/null +++ b/src/common/file_encryption_load.c @@ -0,0 +1,116 @@ +/*------------------------------------------------------------------------- + * + * file_encryption_load.c + * Dynamic-load helper for file encryption modules. + * + * Both the backend's loader (process_file_encryption_library) and any + * frontend tool that wants to decrypt/encrypt cluster files use the same + * module ABI defined in common/file_encryption_module.h. The backend + * has its own dlopen plumbing (load_external_function) wired up to + * dynamic_loader.c; this file gives frontend tools an equivalent + * libpgcommon-safe entry point. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/common/file_encryption_load.c + * + *------------------------------------------------------------------------- + */ +#ifndef FRONTEND +#include "postgres.h" +#else +#include "postgres_fe.h" +#endif + +#ifdef WIN32 +#include "port/win32_port.h" /* for dlopen/dlsym/dlerror */ +#else +#include +#endif + +#include "common/file_encryption_module.h" +#include "common/file_encryption_load.h" + +/* + * Resolve $libdir/, dlopen it, look up the module's + * init symbol, and invoke it with the supplied config string. + * + * On success returns true; *handle_out receives the dlopen handle (the + * caller may pass it to dlclose at shutdown), *callbacks_out receives the + * module's callback table. + * + * On failure returns false and sets *errmsg to a pg_malloc()'d / palloc()'d + * error string (the caller frees it with pfree/pg_free). + */ +bool +load_file_encryption_module(const char *my_exec_path, + const char *libname, + const char *config, + void **handle_out, + const FileEncryptionCallbacks **callbacks_out, + char **errmsg) +{ + char libdir[MAXPGPATH]; + char path[MAXPGPATH]; + void *handle; + FileEncryptionModuleInit init; + char *module_errmsg = NULL; + + *handle_out = NULL; + + if (libname == NULL || libname[0] == '\0') + { + *errmsg = pstrdup("file encryption library name is empty"); + return false; + } + + get_pkglib_path(my_exec_path, libdir); + snprintf(path, sizeof(path), "%s/%s%s", libdir, libname, DLSUFFIX); + + handle = dlopen(path, RTLD_NOW | RTLD_LOCAL); + if (handle == NULL) + { + const char *dlerr = dlerror(); + + *errmsg = psprintf("could not load file encryption module \"%s\": %s", + path, dlerr ? dlerr : "(no dlerror)"); + return false; + } + + init = (FileEncryptionModuleInit) + dlsym(handle, "_PG_file_encryption_module_init"); + if (init == NULL) + { + const char *dlerr = dlerror(); + + *errmsg = psprintf("file encryption module \"%s\" lacks symbol _PG_file_encryption_module_init: %s", + path, dlerr ? dlerr : "(no dlerror)"); + dlclose(handle); + return false; + } + + if (!(*init) (config, callbacks_out, &module_errmsg)) + { + *errmsg = psprintf("file encryption module \"%s\" failed to initialize: %s", + libname, + module_errmsg ? module_errmsg : "(no module errmsg)"); + if (module_errmsg) + pfree(module_errmsg); + dlclose(handle); + return false; + } + + if (*callbacks_out == NULL || + (*callbacks_out)->magic != PG_FILE_ENCRYPTION_MAGIC) + { + *errmsg = psprintf("file encryption module \"%s\" returned an incompatible ABI", + libname); + dlclose(handle); + return false; + } + + *handle_out = handle; + return true; +} diff --git a/src/common/meson.build b/src/common/meson.build index 9bd55cda95b10..2686e20b06d78 100644 --- a/src/common/meson.build +++ b/src/common/meson.build @@ -10,6 +10,7 @@ common_sources = files( 'controldata_utils.c', 'encnames.c', 'exec.c', + 'file_encryption_load.c', 'file_perm.c', 'file_utils.c', 'hashfn.c', diff --git a/src/common/relpath.c b/src/common/relpath.c index 8fb3bed7873ab..71b53e587d8f8 100644 --- a/src/common/relpath.c +++ b/src/common/relpath.c @@ -35,6 +35,7 @@ const char *const forkNames[] = { [FSM_FORKNUM] = "fsm", [VISIBILITYMAP_FORKNUM] = "vm", [INIT_FORKNUM] = "init", + [KEY_FORKNUM] = "key", }; StaticAssertDecl(lengthof(forkNames) == (MAX_FORKNUM + 1), @@ -60,7 +61,7 @@ forkname_to_number(const char *forkName) (errcode(ERRCODE_INVALID_PARAMETER_VALUE), errmsg("invalid fork name"), errhint("Valid fork names are \"main\", \"fsm\", " - "\"vm\", and \"init\"."))); + "\"vm\", \"init\", and \"key\"."))); #endif return InvalidForkNumber; diff --git a/src/include/access/brin_page.h b/src/include/access/brin_page.h index 3297934c1779e..fb5cd6ce9b6e2 100644 --- a/src/include/access/brin_page.h +++ b/src/include/access/brin_page.h @@ -18,6 +18,7 @@ #define BRIN_PAGE_H #include "storage/block.h" +#include "storage/bufpage.h" #include "storage/itemptr.h" /* @@ -93,4 +94,22 @@ typedef struct RevmapContents #define REVMAP_PAGE_MAXITEMS \ (REVMAP_CONTENT_SIZE / sizeof(ItemPointerData)) +/* + * Cluster-aware variants. When a file_encryption_library has reserved bytes + * at the tail of every page, the available revmap area is correspondingly + * smaller. Use these at runtime when computing the actual revmap layout + * (mapping a heap block to its revmap block/index, sizing the revmap). + */ +static inline Size +RevmapContentSizeForCluster(void) +{ + return REVMAP_CONTENT_SIZE - GetPageReservedSize(); +} + +static inline Size +RevmapPageMaxItemsForCluster(void) +{ + return RevmapContentSizeForCluster() / sizeof(ItemPointerData); +} + #endif /* BRIN_PAGE_H */ diff --git a/src/include/access/ginblock.h b/src/include/access/ginblock.h index 2d75023179a28..aebf76c2242c2 100644 --- a/src/include/access/ginblock.h +++ b/src/include/access/ginblock.h @@ -10,6 +10,7 @@ #ifndef GINBLOCK_H #define GINBLOCK_H +#include "access/itup.h" #include "access/transam.h" #include "storage/block.h" #include "storage/bufpage.h" @@ -252,6 +253,21 @@ typedef signed char GinNullCategory; MAXALIGN(SizeOfPageHeaderData + 3 * sizeof(ItemIdData)) - \ MAXALIGN(sizeof(GinPageOpaqueData))) / 3))) +/* + * Cluster-aware variant of GinMaxItemSize. When a file_encryption_library + * reserves bytes at the tail of every page, the actual largest entry that + * fits is correspondingly smaller; use this at the runtime fit-check sites. + */ +static inline Size +GinMaxItemSizeForCluster(void) +{ + Size raw = MAXALIGN_DOWN((BLCKSZ - GetPageReservedSize() - + MAXALIGN(SizeOfPageHeaderData + 3 * sizeof(ItemIdData)) - + MAXALIGN(sizeof(GinPageOpaqueData))) / 3); + + return Min((Size) INDEX_SIZE_MASK, raw); +} + /* * Access macros for non-leaf entry tuples */ @@ -309,12 +325,12 @@ typedef signed char GinNullCategory; */ #define GinDataPageSetDataSize(page, size) \ { \ - Assert(size <= GinDataPageMaxDataSize); \ + Assert(size <= GinDataPageMaxDataSizeForCluster()); \ ((PageHeader) page)->pd_lower = (size) + MAXALIGN(SizeOfPageHeaderData) + MAXALIGN(sizeof(ItemPointerData)); \ } #define GinNonLeafDataPageGetFreeSpace(page) \ - (GinDataPageMaxDataSize - \ + (GinDataPageMaxDataSizeForCluster() - \ GinPageGetOpaque(page)->maxoff * sizeof(PostingItem)) #define GinDataPageMaxDataSize \ @@ -326,7 +342,26 @@ typedef signed char GinNullCategory; * List pages */ #define GinListPageSize \ - ( BLCKSZ - SizeOfPageHeaderData - MAXALIGN(sizeof(GinPageOpaqueData)) ) + ( BLCKSZ - SizeOfPageHeaderData - \ + MAXALIGN(sizeof(GinPageOpaqueData)) ) + +/* + * Cluster-aware variants. GinDataPageMaxDataSize and GinListPageSize give + * the BLCKSZ-derived upper bound; the *ForCluster() variants subtract any + * tail-reserved bytes the file_encryption_library has claimed. Page-layout + * arithmetic that operates on real pages must use the cluster-aware form. + */ +static inline Size +GinDataPageMaxDataSizeForCluster(void) +{ + return GinDataPageMaxDataSize - GetPageReservedSize(); +} + +static inline Size +GinListPageSizeForCluster(void) +{ + return GinListPageSize - GetPageReservedSize(); +} /* * A compressed posting list. diff --git a/src/include/access/gist_private.h b/src/include/access/gist_private.h index 44514f1cb8d81..a97b7d42efde9 100644 --- a/src/include/access/gist_private.h +++ b/src/include/access/gist_private.h @@ -474,6 +474,17 @@ extern void gistadjustmembers(Oid opfamilyoid, #define GiSTPageSize \ ( BLCKSZ - SizeOfPageHeaderData - MAXALIGN(sizeof(GISTPageOpaqueData)) ) +/* + * Cluster-aware variant: when a file_encryption_library has reserved bytes + * at the tail of every page, the available per-page tuple space shrinks + * accordingly. Use this at the runtime fit-check sites. + */ +static inline Size +GiSTPageSizeForCluster(void) +{ + return GiSTPageSize - GetPageReservedSize(); +} + #define GIST_MIN_FILLFACTOR 10 #define GIST_DEFAULT_FILLFACTOR 90 diff --git a/src/include/access/hash.h b/src/include/access/hash.h index a8702f0e5ea13..4d27047d6a3af 100644 --- a/src/include/access/hash.h +++ b/src/include/access/hash.h @@ -285,7 +285,7 @@ typedef struct HashOptions * Maximum size of a hash index item (it's okay to have only one per page) */ #define HashMaxItemSize(page) \ - MAXALIGN_DOWN(PageGetPageSize(page) - \ + MAXALIGN_DOWN(PageGetUsableSize(page) - \ SizeOfPageHeaderData - \ sizeof(ItemIdData) - \ MAXALIGN(sizeof(HashPageOpaqueData))) diff --git a/src/include/access/htup_details.h b/src/include/access/htup_details.h index 77a6c48fd711a..511c8656a4259 100644 --- a/src/include/access/htup_details.h +++ b/src/include/access/htup_details.h @@ -601,6 +601,21 @@ BITMAPLEN(int NATTS) #define MaxHeapTupleSize (BLCKSZ - MAXALIGN(SizeOfPageHeaderData + sizeof(ItemIdData))) #define MinHeapTupleSize MAXALIGN(SizeofHeapTupleHeader) +/* + * Cluster-aware version of MaxHeapTupleSize. MaxHeapTupleSize is the + * compile-time upper bound used to size stack buffers and assertions; when + * file_encryption_library reserves bytes at the tail of every page, the + * actual largest tuple a cluster can store is correspondingly smaller. + * Use this at runtime "is this tuple too big?" check sites so the rejection + * happens here with a meaningful error rather than as a later + * "could not fit" failure. + */ +static inline Size +MaxHeapTupleSizeForCluster(void) +{ + return MaxHeapTupleSize - GetPageReservedSize(); +} + /* * MaxHeapTuplesPerPage is an upper bound on the number of tuples that can * fit on one heap page. (Note that indexes could have more, because they diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h index 3097e9bb1af9b..2e22cd05230fe 100644 --- a/src/include/access/nbtree.h +++ b/src/include/access/nbtree.h @@ -172,6 +172,31 @@ typedef struct BTMetaPageData MAXALIGN(SizeOfPageHeaderData + 3*sizeof(ItemIdData)) - \ MAXALIGN(sizeof(BTPageOpaqueData))) / 3) +/* + * Cluster-aware variants. When a file_encryption_library has reserved bytes + * at the tail of every page (cluster-wide page_reserved_size > 0), the + * actual largest item that fits on a btree page is correspondingly smaller. + * Use these at the runtime "does this tuple fit?" sites (insert, dedup, + * sort, amcheck) so the rejection happens with a meaningful error rather + * than later as a "could not fit" failure. + */ +static inline Size +BTMaxItemSizeForCluster(void) +{ + return MAXALIGN_DOWN((BLCKSZ - GetPageReservedSize() - + MAXALIGN(SizeOfPageHeaderData + 3 * sizeof(ItemIdData)) - + MAXALIGN(sizeof(BTPageOpaqueData))) / 3) - + MAXALIGN(sizeof(ItemPointerData)); +} + +static inline Size +BTMaxItemSizeNoHeapTidForCluster(void) +{ + return MAXALIGN_DOWN((BLCKSZ - GetPageReservedSize() - + MAXALIGN(SizeOfPageHeaderData + 3 * sizeof(ItemIdData)) - + MAXALIGN(sizeof(BTPageOpaqueData))) / 3); +} + /* * MaxTIDsPerBTreePage is an upper bound on the number of heap TIDs tuples * that may be stored on a btree leaf page. It is used to size the diff --git a/src/include/access/spgist_private.h b/src/include/access/spgist_private.h index ec6d6f5f74d27..a5ee37380de2c 100644 --- a/src/include/access/spgist_private.h +++ b/src/include/access/spgist_private.h @@ -452,6 +452,19 @@ typedef SpGistDeadTupleData *SpGistDeadTuple; SizeOfPageHeaderData - \ MAXALIGN(sizeof(SpGistPageOpaqueData))) +/* + * Cluster-aware variant: when a file_encryption_library has reserved bytes + * at the tail of every page, the actual page capacity is correspondingly + * smaller. Use this at runtime fit-check / split-decision sites. + */ +static inline Size +SpGistPageCapacityForCluster(void) +{ + return MAXALIGN_DOWN(BLCKSZ - GetPageReservedSize() - + SizeOfPageHeaderData - + MAXALIGN(sizeof(SpGistPageOpaqueData))); +} + /* * Compute free space on page, assuming that up to n placeholders can be * recycled if present (n should be the number of tuples to be inserted) diff --git a/src/include/access/xlog.h b/src/include/access/xlog.h index 4dd986242046f..943ce3e5a562b 100644 --- a/src/include/access/xlog.h +++ b/src/include/access/xlog.h @@ -260,6 +260,7 @@ extern const char *get_checksum_state_string(uint32 state); extern void InitLocalDataChecksumState(void); extern void SetLocalDataChecksumState(uint32 data_checksum_version); extern bool GetDefaultCharSignedness(void); +extern const char *GetFileEncryptionLibrary(void); extern XLogRecPtr GetFakeLSNForUnloggedRel(void); extern void BootStrapXLOG(uint32 data_checksum_version); extern void InitializeWalConsistencyChecking(void); diff --git a/src/include/catalog/pg_control.h b/src/include/catalog/pg_control.h index 80b3a730e035a..76957dc157a9e 100644 --- a/src/include/catalog/pg_control.h +++ b/src/include/catalog/pg_control.h @@ -22,11 +22,19 @@ /* Version identifier for this pg_control format */ -#define PG_CONTROL_VERSION 1902 +#define PG_CONTROL_VERSION 1903 /* Nonce key length, see below */ #define MOCK_AUTH_NONCE_LEN 32 +/* + * Maximum bytes a file encryption module may reserve at the tail of every + * relation page. Plenty for an IV + auth tag + key version, with room for + * future per-page metadata. Kept aligned to MAXIMUM_ALIGNOF so PageInit's + * arithmetic remains aligned. + */ +#define MAX_PAGE_RESERVED_SIZE 256 + /* * Body of CheckPoint XLOG records. This is declared here because we keep * a copy of the latest one in pg_control for possible disaster recovery. @@ -231,6 +239,23 @@ typedef struct ControlFileData /* Are data pages protected by checksums? Zero if no checksum version */ uint32 data_checksum_version; + /* + * Number of bytes reserved at the tail of every relation page, used by a + * file encryption module for per-page metadata (IV, auth tag, ...). Set + * at initdb time and immutable afterwards. Zero when no module is + * configured; pages are byte-identical to upstream in that case. + */ + uint32 page_reserved_size; + + /* + * Name of the file encryption library this cluster was initialized with + * (passed to initdb via --file-encryption-library). Empty string when + * the cluster is not encrypted. Backend startup reads this to know + * which module to dlopen; frontend tools (pg_checksums, pg_basebackup, + * ...) read it for the same reason without needing postgresql.conf. + */ + char file_encryption_library[NAMEDATALEN]; + /* * True if the default signedness of char is "signed" on a platform where * the cluster is initialized. diff --git a/src/include/catalog/storage_xlog.h b/src/include/catalog/storage_xlog.h index c1b2f73666974..34445ee16632f 100644 --- a/src/include/catalog/storage_xlog.h +++ b/src/include/catalog/storage_xlog.h @@ -27,8 +27,9 @@ */ /* XLOG gives us high 4 bits */ -#define XLOG_SMGR_CREATE 0x10 -#define XLOG_SMGR_TRUNCATE 0x20 +#define XLOG_SMGR_CREATE 0x10 +#define XLOG_SMGR_TRUNCATE 0x20 +#define XLOG_SMGR_KEY_FORK_CREATE 0x30 typedef struct xl_smgr_create { @@ -50,7 +51,23 @@ typedef struct xl_smgr_truncate int flags; } xl_smgr_truncate; +/* + * XLOG_SMGR_KEY_FORK_CREATE: create the KEY fork and write its single block + * directly to disk, bypassing the buffer pool. The wrapped-DEK contents + * follow the header in the WAL record and the redo writes them with + * smgrwrite + smgrimmedsync so a subsequent encrypted write on the standby + * can reliably read them back via smgrread, even when the redo loop hasn't + * yet had a chance to flush dirty buffers. + */ +typedef struct xl_smgr_key_fork_create +{ + RelFileLocator rlocator; + /* BLCKSZ bytes of page-formatted KEY fork data follow */ +} xl_smgr_key_fork_create; + extern void log_smgrcreate(const RelFileLocator *rlocator, ForkNumber forkNum); +extern void log_smgr_key_fork_create(const RelFileLocator *rlocator, + const char *keyblock); extern void smgr_redo(XLogReaderState *record); extern void smgr_desc(StringInfo buf, XLogReaderState *record); diff --git a/src/include/common/file_encryption_keyblock.h b/src/include/common/file_encryption_keyblock.h new file mode 100644 index 0000000000000..929ffe016bf12 --- /dev/null +++ b/src/include/common/file_encryption_keyblock.h @@ -0,0 +1,52 @@ +/*------------------------------------------------------------------------- + * + * file_encryption_keyblock.h + * Layout of the KEY-fork block produced by FileEncryptionGenerateObjectKey. + * + * The KEY fork holds one BLCKSZ block per relation, formatted as a normal + * PostgreSQL page (PageInit'd with no special area, pd_checksum populated) + * so it travels through the buffer manager and survives a future data- + * checksums-on transition. The encryption-specific payload lives in the + * otherwise-empty data area between pd_lower and pd_upper: + * + * [ PageHeaderData (SizeOfPageHeaderData bytes) ] + * [ FEKeyBlockHeader { magic, version, wrapped_len, reserved } ] + * [ wrapped DEK ... wrapped_len bytes ... ] + * [ zero padding up to pd_upper ] + * [ encryption trailer (zero; KEY fork is exempt from encryption) ] + * + * The wrapped DEK is opaque to the core; the encryption module owns its + * format and verification. The layout itself is shared between the + * backend (which writes it via FileEncryptionGenerateObjectKey and reads + * it via FileEncryptionOpenObject) and any frontend tool that needs to + * locate the wrapped DEK to hand to a module's object_open_cb. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/common/file_encryption_keyblock.h + * + *------------------------------------------------------------------------- + */ +#ifndef FILE_ENCRYPTION_KEYBLOCK_H +#define FILE_ENCRYPTION_KEYBLOCK_H + +#include "storage/bufpage.h" + +#define FE_KEY_BLOCK_MAGIC 0x46454B42 /* "FEKB" */ +#define FE_KEY_BLOCK_VERSION 1 + +typedef struct FEKeyBlockHeader +{ + uint32 magic; + uint32 version; + uint32 wrapped_len; + uint32 reserved; +} FEKeyBlockHeader; + +#define FE_KEY_BLOCK_HEADER_OFFSET SizeOfPageHeaderData +#define FE_KEY_BLOCK_PAYLOAD_OFFSET (FE_KEY_BLOCK_HEADER_OFFSET + \ + sizeof(FEKeyBlockHeader)) +#define FE_KEY_BLOCK_MAX_WRAPPED (BLCKSZ - FE_KEY_BLOCK_PAYLOAD_OFFSET) + +#endif /* FILE_ENCRYPTION_KEYBLOCK_H */ diff --git a/src/include/common/file_encryption_load.h b/src/include/common/file_encryption_load.h new file mode 100644 index 0000000000000..65e7733615ef3 --- /dev/null +++ b/src/include/common/file_encryption_load.h @@ -0,0 +1,25 @@ +/*------------------------------------------------------------------------- + * + * file_encryption_load.h + * Dynamic-load helper for file encryption modules. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/common/file_encryption_load.h + * + *------------------------------------------------------------------------- + */ +#ifndef FILE_ENCRYPTION_LOAD_H +#define FILE_ENCRYPTION_LOAD_H + +#include "common/file_encryption_module.h" + +extern bool load_file_encryption_module(const char *my_exec_path, + const char *libname, + const char *config, + void **handle_out, + const FileEncryptionCallbacks **callbacks_out, + char **errmsg); + +#endif /* FILE_ENCRYPTION_LOAD_H */ diff --git a/src/include/common/file_encryption_module.h b/src/include/common/file_encryption_module.h new file mode 100644 index 0000000000000..b798608124a94 --- /dev/null +++ b/src/include/common/file_encryption_module.h @@ -0,0 +1,215 @@ +/*------------------------------------------------------------------------- + * + * file_encryption_module.h + * Public ABI for pluggable file encryption modules. + * + * Modules implement a single symbol, _PG_file_encryption_module_init, which + * the host (the backend or a frontend tool such as pg_checksums) calls at + * load time. The host passes an opaque, module-defined configuration + * string; the module parses it, sets up whatever state it needs in *state, + * and returns a pointer to its FileEncryptionCallbacks via *callbacks_out. + * + * The callback signatures and the state struct intentionally use only + * types available to libpgcommon (RelFileLocator, ForkNumber, BlockNumber, + * raw byte pointers). This lets the same compiled .so be loaded by both + * the backend and any libpgcommon-based frontend tool. + * + * Error reporting follows the same return-value-plus-errmsg pattern as + * _PG_file_encryption_module_init: every fallible callback returns false + * (or NULL for object_open_cb) on failure and writes a palloc'd + * description into *errmsg. The host decides how to surface the error + * -- the backend wraps each callback with ereport(ERROR); frontend + * callers print and exit via pg_fatal. Modules never call ereport or + * pg_fatal directly, which keeps the same .so usable from both contexts. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/common/file_encryption_module.h + * + *------------------------------------------------------------------------- + */ +#ifndef FILE_ENCRYPTION_MODULE_H +#define FILE_ENCRYPTION_MODULE_H + +#include "common/relpath.h" +#include "storage/block.h" +#include "storage/relfilelocator.h" + +typedef struct FileEncryptionModuleState +{ + /* Holds the server's PG_VERSION_NUM. Reserved for future extensibility. */ + int sversion; + + /* + * Private data pointer for use by a file encryption module. This can be + * used to store state for the module that will be passed to each callback. + */ + void *private_data; +} FileEncryptionModuleState; + +/* + * Optional per-process lifecycle callbacks. startup_cb runs once when the + * module's per-process state is first needed (eagerly at postmaster startup + * and again in each forked backend); shutdown_cb runs once at process exit. + * Both may be NULL. On failure startup_cb returns false and writes a + * palloc'd description into *errmsg. + */ +typedef bool (*FileEncryptionStartupCB) (FileEncryptionModuleState *state, + char **errmsg); +typedef void (*FileEncryptionShutdownCB) (FileEncryptionModuleState *state); + +/* + * Record-stream encryption callbacks (BufFile, reorderbuffer spill files). + * + * The module encrypts data_len plaintext bytes at "data" into the + * caller-allocated "dst" buffer, producing exactly data_len + overhead_size + * output bytes (with the overhead at the tail of dst). Decrypt is the + * symmetric operation: data_len ciphertext bytes (including trailing + * overhead) produce exactly data_len - overhead_size plaintext bytes. The + * caller sizes dst to the contractual output length; the module never + * shortens or extends it. + * + * The (path, file_offset) pair identifies where the bytes will live on + * disk; modules bind it into their per-call key/IV/MAC derivation however + * they see fit (as AEAD AAD, HMAC input, an XTS-style tweak, ...) so + * substituting one record for another at decrypt time fails. Each call is + * otherwise independent -- modules typically generate a fresh data- + * encryption key per call and wrap it under the module's configured KEK in + * the overhead. + * + * Returns true on success. On failure returns false and writes a palloc'd + * error description into *errmsg; the caller frees it via pfree(). The + * contents of dst on failure are unspecified. + */ +typedef bool (*FileEncryptionEncryptCB) (const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg); +typedef bool (*FileEncryptionDecryptCB) (const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg); + +/* + * Per-relation page-encryption callbacks. See storage/file_encryption.h + * for the backend-side wrappers. Return-value semantics match the + * record-stream callbacks above. + * + * Per-page binding context: the host supplies (fork, blocknum) on every + * encrypt/decrypt call (and the relation's RelFileLocator at object-open + * time); the per-relation DEK is otherwise the same across all pages and + * forks of the relation. Modules use these inputs however they like -- + * as AEAD AAD, HMAC input, an XTS-style tweak -- so an attacker with + * disk write access can't shuffle blocks between forks or block numbers + * within a relation without the swap being detected at decrypt time. + * + * generate_object_key_cb writes its wrapped DEK into dst (caller-owned, + * dst_max bytes) and stores the number of bytes used in *wrapped_len. + * dst_max is the module-side cap on wrapped-DEK size; modules that need + * more must fail with an *errmsg explaining why. object_open_cb returns a + * non-NULL pointer to module-owned state on success and NULL on failure + * (with *errmsg populated). The lifetime of the returned pointer must + * outlive any encrypt/decrypt call referring to the same relation; the + * host arranges for the allocator's lifetime to match. + */ +typedef bool (*FileEncryptionGenerateObjectKeyCB) (FileEncryptionModuleState *state, + const RelFileLocator *locator, + char *dst, Size dst_max, + Size *wrapped_len, + char **errmsg); +typedef void *(*FileEncryptionObjectOpenCB) (FileEncryptionModuleState *state, + const RelFileLocator *locator, + const char *wrapped, Size wrapped_len, + char **errmsg); +typedef void (*FileEncryptionObjectCloseCB) (FileEncryptionModuleState *state, + void *object_state); +typedef bool (*FileEncryptionEncryptPageCB) (FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, + char **errmsg); +typedef bool (*FileEncryptionDecryptPageCB) (FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, + char **errmsg); + +/* + * Identifies the compiled ABI version of the file encryption module. + * + * Bump this whenever FileEncryptionCallbacks or any of the callback + * signatures change in an incompatible way. + */ +#define PG_FILE_ENCRYPTION_MAGIC 0x46454D39 /* "FEM9" */ + +typedef struct FileEncryptionCallbacks +{ + uint32 magic; /* must be set to PG_FILE_ENCRYPTION_MAGIC */ + + /* + * Number of bytes the module appends to every encrypt_cb output for its + * own per-call metadata. The layout is fully module-defined; common + * elements include an IV, an authentication tag, and a per-call wrapped + * key, but a size-preserving mode is free to declare 0. Only relevant + * for record-stream encryption (BufFile, reorderbuffer spill). + */ + Size overhead_size; + + /* + * Number of bytes the module reserves at the tail of every relation + * page for its own per-page metadata. Module-defined layout (typically + * IV plus authentication tag, but length-preserving modes like AES-XTS + * declare 0). Must match the cluster's page_reserved_size for the + * module to load. Smaller than overhead_size in the typical case + * because the per-relation DEK lives in KEY_FORKNUM, not in each + * page's trailer. + */ + Size page_overhead_size; + + /* Per-process lifecycle. */ + FileEncryptionStartupCB startup_cb; + FileEncryptionShutdownCB shutdown_cb; + + /* Record-stream encryption (BufFile, reorderbuffer spill). */ + FileEncryptionEncryptCB encrypt_cb; + FileEncryptionDecryptCB decrypt_cb; + + /* Per-relation page encryption. All five must be set if any is. */ + FileEncryptionGenerateObjectKeyCB generate_object_key_cb; + FileEncryptionObjectOpenCB object_open_cb; + FileEncryptionObjectCloseCB object_close_cb; + FileEncryptionEncryptPageCB encrypt_page_cb; + FileEncryptionDecryptPageCB decrypt_page_cb; +} FileEncryptionCallbacks; + +/* + * Type of the shared library symbol _PG_file_encryption_module_init that + * every file encryption module exports. + * + * 'config' is a module-defined opaque string (passed verbatim from the + * file_encryption_config GUC in the backend, or from --encryption-config / + * env in a frontend tool). May be NULL or empty if the host has nothing + * to provide; modules that need configuration should signal that as an + * error via *errmsg. + * + * On success, the module returns true and populates *callbacks_out with a + * pointer to its (typically static) callback table. Modules that need to + * carry parsed configuration into their callbacks should stash it in module + * statics: init runs once per host process (per postmaster in the backend, + * per tool invocation in the frontend), and fork()ed children inherit the + * statics via copy-on-write. Per-process state proper is built lazily by + * the optional startup_cb. + * + * On failure, the module returns false and sets *errmsg to a host-allocated + * (palloc-compatible) error string describing the problem. The host frees + * the string. *callbacks_out is left untouched on failure. + */ +typedef bool (*FileEncryptionModuleInit) (const char *config, + const FileEncryptionCallbacks **callbacks_out, + char **errmsg); +extern PGDLLEXPORT bool _PG_file_encryption_module_init(const char *config, + const FileEncryptionCallbacks **callbacks_out, + char **errmsg); + +#endif /* FILE_ENCRYPTION_MODULE_H */ diff --git a/src/include/common/relpath.h b/src/include/common/relpath.h index 9772125be7398..e8789afa052d9 100644 --- a/src/include/common/relpath.h +++ b/src/include/common/relpath.h @@ -60,6 +60,7 @@ typedef enum ForkNumber FSM_FORKNUM, VISIBILITYMAP_FORKNUM, INIT_FORKNUM, + KEY_FORKNUM, /* * NOTE: if you add a new fork, change MAX_FORKNUM and possibly @@ -68,7 +69,7 @@ typedef enum ForkNumber */ } ForkNumber; -#define MAX_FORKNUM INIT_FORKNUM +#define MAX_FORKNUM KEY_FORKNUM #define FORKNAMECHARS 4 /* max chars for a fork name */ diff --git a/src/include/storage/bufpage.h b/src/include/storage/bufpage.h index 634e1e49ee52a..4ac4970c5b299 100644 --- a/src/include/storage/bufpage.h +++ b/src/include/storage/bufpage.h @@ -290,6 +290,14 @@ PageGetContents(Page page) * ---------------- */ +/* + * Number of bytes reserved at the tail of every relation page for a file + * encryption module's per-page metadata. Cluster-wide and immutable; set + * at initdb time, stored in pg_control, and exposed here for use in the + * page-layout helpers below. Defined in xlog.c. + */ +extern uint32 GetPageReservedSize(void); + /* * PageGetPageSize * Returns the page size of a page. @@ -304,6 +312,21 @@ PageGetPageSize(const PageData *page) return (Size) (((const PageHeaderData *) page)->pd_pagesize_version & (uint16) 0xFF00); } +/* + * PageGetUsableSize + * Returns the usable page size, i.e. the page size minus the trailing + * bytes reserved by the cluster's file encryption module (if any). + * + * All page-layout arithmetic that needs to know "where do tuples and the + * special area stop" should use this rather than PageGetPageSize. When no + * file encryption module is configured, this equals PageGetPageSize. + */ +static inline Size +PageGetUsableSize(const PageData *page) +{ + return PageGetPageSize(page) - GetPageReservedSize(); +} + /* * PageGetPageLayoutVersion * Returns the page layout version of a page. @@ -341,7 +364,7 @@ PageSetPageSizeAndVersion(Page page, Size size, uint8 version) static inline uint16 PageGetSpecialSize(const PageData *page) { - return (PageGetPageSize(page) - ((const PageHeaderData *) page)->pd_special); + return (PageGetUsableSize(page) - ((const PageHeaderData *) page)->pd_special); } /* @@ -353,7 +376,7 @@ static inline void PageValidateSpecialPointer(const PageData *page) { Assert(page); - Assert(((const PageHeaderData *) page)->pd_special <= BLCKSZ); + Assert(((const PageHeaderData *) page)->pd_special <= PageGetUsableSize(page)); Assert(((const PageHeaderData *) page)->pd_special >= SizeOfPageHeaderData); } diff --git a/src/include/storage/file_encryption.h b/src/include/storage/file_encryption.h new file mode 100644 index 0000000000000..3522064e79366 --- /dev/null +++ b/src/include/storage/file_encryption.h @@ -0,0 +1,113 @@ +/*------------------------------------------------------------------------- + * + * file_encryption.h + * Backend wrappers around the file_encryption module ABI. + * + * The ABI itself lives in common/file_encryption_module.h so that + * frontend tools (pg_checksums, pg_basebackup, ...) can dlopen the same + * shared library and call the same callbacks. This header layers the + * backend-only conveniences (SMgrRelation, GUC, ereport-based wrappers) + * on top. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/storage/file_encryption.h + * + *------------------------------------------------------------------------- + */ +#ifndef FILE_ENCRYPTION_H +#define FILE_ENCRYPTION_H + +#include "common/file_encryption_module.h" + +/* SMgrRelation is forward-declared to avoid pulling in smgr.h here. */ +struct SMgrRelationData; +typedef struct SMgrRelationData *SMgrRelation; + +/* + * GUC. file_encryption_config is the opaque, module-defined configuration + * string passed verbatim to _PG_file_encryption_module_init. The library + * NAME isn't a GUC: it lives in pg_control (set at initdb time and + * immutable afterwards) and is read by the backend at startup. + */ +extern PGDLLIMPORT char *file_encryption_config; + +extern bool FileEncryptionEnabled(void); + +/* + * Name of the module that this process is bound to. In bootstrap mode this + * is whatever -L was passed; at runtime it tracks pg_control. Returns NULL + * (or "") when no module is configured. Distinct from + * GetFileEncryptionLibrary() in xlog.h, which always reads pg_control -- + * useful for code paths (like InitControlFile) that *populate* that field. + */ +extern const char *FileEncryptionLibraryName(void); + +/* Per-call ciphertext overhead size declared by the loaded module. */ +extern Size FileEncryptionOverheadSize(void); + +/* + * Encrypt data_len plaintext bytes into the caller-allocated dst (which + * must hold data_len + FileEncryptionOverheadSize() bytes). Decrypt is + * symmetric: data_len input bytes (including trailing overhead) produce + * exactly data_len - FileEncryptionOverheadSize() plaintext bytes. + */ +extern void FileEncryptionEncrypt(const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst); +extern void FileEncryptionDecrypt(const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst); + +/* + * Page-level encryption is always engaged when a file encryption module is + * configured -- relation pages are routed through the module, with the + * module's declared page_overhead_size carved off the tail of every page + * for its per-page metadata (may be zero for modes like AES-XTS). The + * helpers below wrap encrypt_page_cb / decrypt_page_cb with the BLCKSZ-in / + * BLCKSZ-out contract that md.c needs. + */ +extern Size FileEncryptionPageReservedSize(void); + +/* + * Generate a fresh per-relation wrapped DEK and write it as a BLCKSZ-sized + * page-formatted block into 'dst'. Called once per relation at create time + * by storage.c. + */ +extern void FileEncryptionGenerateObjectKey(const RelFileLocator *locator, + char *dst); + +/* + * Read the relation's KEY fork (block 0) and unwrap the DEK into a + * per-relation state pointer cached on SMgrRelation. Idempotent. Must be + * called before the first FileEncryptionEncryptPage / DecryptPage call for + * this relation; the page helpers below call it lazily on first use. + */ +extern void FileEncryptionOpenObject(SMgrRelation reln); + +/* + * Release per-relation state cached on SMgrRelation. Called from + * smgrclose() when the SMgrRelation is torn down. + */ +extern void FileEncryptionCloseObject(SMgrRelation reln); + +extern void FileEncryptionEncryptPage(SMgrRelation reln, ForkNumber fork, + BlockNumber blocknum, + const char *src, char *dst); +extern void FileEncryptionDecryptPage(SMgrRelation reln, ForkNumber fork, + BlockNumber blocknum, + const char *src, char *dst); + +extern void process_file_encryption_library(const char *libname); + +/* + * Eagerly run the module's per-process startup callback and register its + * shutdown callback for the current process. Must be called outside any + * critical section (the startup callback may palloc) and before any code + * path that touches encryption from within a critical section, such as + * the AIO read/write completion callbacks in md.c. + */ +extern void FileEncryptionEnsureInit(void); + +#endif /* FILE_ENCRYPTION_H */ diff --git a/src/include/storage/fsm_internals.h b/src/include/storage/fsm_internals.h index 9416ec7d4845c..fc3644732dd76 100644 --- a/src/include/storage/fsm_internals.h +++ b/src/include/storage/fsm_internals.h @@ -47,6 +47,14 @@ typedef FSMPageData *FSMPage; /* * Number of non-leaf and leaf nodes, and nodes in total, on an FSM page. * These definitions are internal to fsmpage.c. + * + * Intentionally BLCKSZ-based even when the cluster reserves bytes at the + * tail of every page for file encryption. FSM pages are exempt from + * encryption (md.c doesn't route them through encrypt_page_cb / decrypt_ + * page_cb), so fp_nodes can use the entire page including what would + * otherwise be the encryption trailer. Keeping NodesPerPage constant + * also keeps FSM_TREE_DEPTH (in freespace.c) a compile-time constant, + * which it needs to be for FSM_ROOT_ADDRESS's static initializer. */ #define NodesPerPage (BLCKSZ - MAXALIGN(SizeOfPageHeaderData) - \ offsetof(FSMPageData, fp_nodes)) diff --git a/src/include/storage/md.h b/src/include/storage/md.h index b8d10329eb874..58640ed2c12de 100644 --- a/src/include/storage/md.h +++ b/src/include/storage/md.h @@ -24,6 +24,8 @@ extern PGDLLIMPORT const PgAioHandleCallbacks aio_md_readv_cb; /* md storage manager functionality */ extern void mdinit(void); +extern void md_init_enc_workspace(void); +extern bool md_fork_is_encrypted(ForkNumber forknum); extern void mdopen(SMgrRelation reln); extern void mdclose(SMgrRelation reln, ForkNumber forknum); extern void mdcreate(SMgrRelation reln, ForkNumber forknum, bool isRedo); diff --git a/src/include/storage/smgr.h b/src/include/storage/smgr.h index 09bd42fcf4ba6..40295c832dbfd 100644 --- a/src/include/storage/smgr.h +++ b/src/include/storage/smgr.h @@ -46,6 +46,14 @@ typedef struct SMgrRelationData BlockNumber smgr_targblock; /* current insertion target block */ BlockNumber smgr_cached_nblocks[MAX_FORKNUM + 1]; /* last known size */ + /* + * File encryption per-relation state. Lazily populated by + * FileEncryptionOpenObject() on first page encrypt/decrypt for this + * relation; released by FileEncryptionCloseObject() from smgrclose(). + * Opaque to smgr; the loaded encryption module owns the value. + */ + void *encryption_object_state; + /* additional public fields may someday exist here */ /* @@ -78,6 +86,8 @@ extern PGDLLIMPORT const PgAioTargetInfo aio_smgr_target_info; extern void smgrinit(void); extern SMgrRelation smgropen(RelFileLocator rlocator, ProcNumber backend); +extern SMgrRelation smgropen_existing(RelFileLocator rlocator, + ProcNumber backend); extern bool smgrexists(SMgrRelation reln, ForkNumber forknum); extern void smgrpin(SMgrRelation reln); extern void smgrunpin(SMgrRelation reln); diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile index 0a74ab5c86f51..b446e3ac8e76e 100644 --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -31,6 +31,7 @@ SUBDIRS = \ test_dsm_registry \ test_escape \ test_extensions \ + test_file_encryption \ test_ginpostinglist \ test_int128 \ test_integerset \ diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build index 4bca42bb3706a..ccf229ef412c8 100644 --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -32,6 +32,7 @@ subdir('test_dsa') subdir('test_dsm_registry') subdir('test_escape') subdir('test_extensions') +subdir('test_file_encryption') subdir('test_ginpostinglist') subdir('test_int128') subdir('test_integerset') diff --git a/src/test/modules/test_file_encryption/.gitignore b/src/test/modules/test_file_encryption/.gitignore new file mode 100644 index 0000000000000..5dcb3ff972350 --- /dev/null +++ b/src/test/modules/test_file_encryption/.gitignore @@ -0,0 +1,4 @@ +# Generated subdirectories +/log/ +/results/ +/tmp_check/ diff --git a/src/test/modules/test_file_encryption/Makefile b/src/test/modules/test_file_encryption/Makefile new file mode 100644 index 0000000000000..0b9e2cb455f31 --- /dev/null +++ b/src/test/modules/test_file_encryption/Makefile @@ -0,0 +1,25 @@ +#------------------------------------------------------------------------- +# +# Makefile for src/test/modules/test_file_encryption +# +# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group +# Portions Copyright (c) 1994, Regents of the University of California +# +#------------------------------------------------------------------------- + +MODULES = test_file_encryption +PGFILEDESC = "test_file_encryption - test file encryption module" + +NO_INSTALLCHECK = 1 +TAP_TESTS = 1 + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/test_file_encryption +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/test_file_encryption/meson.build b/src/test/modules/test_file_encryption/meson.build new file mode 100644 index 0000000000000..31506564f37dd --- /dev/null +++ b/src/test/modules/test_file_encryption/meson.build @@ -0,0 +1,30 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +test_file_encryption_sources = files( + 'test_file_encryption.c', +) + +if host_system == 'windows' + test_file_encryption_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'test_file_encryption', + '--FILEDESC', 'test_file_encryption - test file encryption module',]) +endif + +test_file_encryption = shared_module('test_file_encryption', + test_file_encryption_sources, + kwargs: pg_test_mod_args, +) +test_install_libs += test_file_encryption + +tests += { + 'name': 'test_file_encryption', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'tap': { + 'tests': [ + 't/001_buffile.pl', + 't/003_apply_streaming.pl', + 't/004_logical_decoding.pl', + ], + }, +} diff --git a/src/test/modules/test_file_encryption/t/001_buffile.pl b/src/test/modules/test_file_encryption/t/001_buffile.pl new file mode 100644 index 0000000000000..adc6f99984b16 --- /dev/null +++ b/src/test/modules/test_file_encryption/t/001_buffile.pl @@ -0,0 +1,54 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Smoke-test BufFile encryption end-to-end with the test_file_encryption +# module: load the module, force a sort to spill, and check that the +# self-verifying payload column round-trips. Also verify the encrypt_cb +# and decrypt_cb callbacks fire by inspecting the summary line the module +# emits at backend exit. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(extra => ['--file-encryption-library=test_file_encryption']); +$node->append_conf( + 'postgresql.conf', q( +work_mem = '64kB' +hash_mem_multiplier = 1.0 +)); +$node->start; + +$node->safe_psql('postgres', q[ +CREATE TABLE t (id int, payload text); +INSERT INTO t +SELECT g, repeat(md5(g::text), 4) +FROM generate_series(1, 50000) g; +]); + +# ORDER BY with work_mem=64kB and 50k 128-byte payload rows must spill +# to disk; bool_and over the deterministic payload catches any byte-level +# corruption introduced by the encrypt/decrypt round-trip. +my $sort_ok = $node->safe_psql('postgres', q[ +WITH ordered AS (SELECT id, payload FROM t ORDER BY id) +SELECT count(*) = 50000 AND + bool_and(payload = repeat(md5(id::text), 4)) AND + (array_agg(id))[1:5] = ARRAY[1, 2, 3, 4, 5] +FROM (SELECT id, payload FROM ordered) s; +]); +is($sort_ok, 't', 'sort spilled and round-tripped through encrypted BufFile'); + +# Disconnect so the backend's before_shmem_exit callback flushes the +# module's summary, then poll the server log for proof that the +# encrypt/decrypt callbacks fired. +$node->stop; + +ok($node->log_contains( + qr/test_file_encryption: encrypt_calls=[1-9][0-9]* decrypt_calls=[1-9][0-9]*/ + ), + 'encrypt/decrypt callbacks were exercised'); + +done_testing(); diff --git a/src/test/modules/test_file_encryption/t/003_apply_streaming.pl b/src/test/modules/test_file_encryption/t/003_apply_streaming.pl new file mode 100644 index 0000000000000..031ce9ae58e5b --- /dev/null +++ b/src/test/modules/test_file_encryption/t/003_apply_streaming.pl @@ -0,0 +1,100 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Exercise logical replication apply streaming while the subscriber encrypts +# streamed-changes BufFiles. streaming=on serializes streamed changes to a +# BufFile and reopens it for later stream segments; the rollback-to-savepoint +# case also truncates that file at a logical byte offset. + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +my $node_publisher = PostgreSQL::Test::Cluster->new('publisher'); +$node_publisher->init(allows_streaming => 'logical'); +$node_publisher->append_conf( + 'postgresql.conf', qq( +logical_decoding_work_mem = '64kB' +debug_logical_replication_streaming = immediate +)); +$node_publisher->start; + +my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber'); +$node_subscriber->init(extra => ['--file-encryption-library=test_file_encryption']); +$node_subscriber->append_conf( + 'postgresql.conf', qq( +log_min_messages = debug1 +)); +$node_subscriber->start; + +$node_publisher->safe_psql('postgres', + 'CREATE TABLE stream_test (id int primary key, payload text);'); +$node_subscriber->safe_psql('postgres', + 'CREATE TABLE stream_test (id int primary key, payload text);'); + +my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres'; +my $appname = 'tap_sub'; + +$node_publisher->safe_psql('postgres', + 'CREATE PUBLICATION tap_pub FOR TABLE stream_test;'); +$node_subscriber->safe_psql( + 'postgres', + "CREATE SUBSCRIPTION tap_sub CONNECTION '$publisher_connstr application_name=$appname' " + . "PUBLICATION tap_pub WITH (streaming = on, copy_data = false)" +); + +$node_publisher->wait_for_catchup($appname); + +my $log_offset = -s $node_subscriber->logfile; + +$node_publisher->safe_psql( + 'postgres', q[ +BEGIN; +INSERT INTO stream_test +SELECT i, repeat(md5(i::text), 7) +FROM generate_series(1, 20) AS s(i); +SAVEPOINT s1; +INSERT INTO stream_test +SELECT i, repeat(md5(i::text), 7) +FROM generate_series(21, 30) AS s(i); +ROLLBACK TO s1; +INSERT INTO stream_test +SELECT i, repeat(md5(i::text), 7) +FROM generate_series(31, 40) AS s(i); +COMMIT; +]); + +$node_publisher->wait_for_catchup($appname); + +my $result = $node_subscriber->safe_psql('postgres', q[ +SELECT count(*), + min(id), + max(id), + count(*) FILTER (WHERE id BETWEEN 21 AND 30), + bool_and(payload = repeat(md5(id::text), 7)) +FROM stream_test; +]); +is($result, '30|1|40|0|t', + 'streamed transaction with subtransaction abort applied correctly'); + +ok($node_subscriber->log_contains( + qr/opening file "\d+-\d+\.changes" for streamed changes.*opening file "\d+-\d+\.changes" for streamed changes/s, + $log_offset), + 'apply worker reopened encrypted streamed-changes file'); + +ok($node_subscriber->log_contains( + qr/finished processing the STREAM ABORT command/s, + $log_offset), + 'apply worker processed streamed subtransaction abort'); + +$node_subscriber->stop; +$node_publisher->stop; + +ok($node_subscriber->log_contains( + qr/test_file_encryption: encrypt_calls=[1-9][0-9]* decrypt_calls=[1-9][0-9]*/, + 0), + 'file encryption callbacks were used by apply streaming'); + +done_testing(); diff --git a/src/test/modules/test_file_encryption/t/004_logical_decoding.pl b/src/test/modules/test_file_encryption/t/004_logical_decoding.pl new file mode 100644 index 0000000000000..abf46d86a9753 --- /dev/null +++ b/src/test/modules/test_file_encryption/t/004_logical_decoding.pl @@ -0,0 +1,67 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +# Use the smallest WAL segment size so that a single transaction below can +# easily span multiple segments, exercising the per-segment spill file +# handling on both the write and read paths. +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init(allows_streaming => 'logical', + extra => ['--wal-segsize=1', + '--file-encryption-library=test_file_encryption']); +$node->append_conf( + 'postgresql.conf', qq( +logical_decoding_work_mem = '64kB' +)); +$node->start; + +$node->safe_psql('postgres', 'CREATE TABLE spill_test(data text);'); +$node->safe_psql('postgres', 'CREATE PUBLICATION pub FOR TABLE spill_test;'); +$node->safe_psql('postgres', + "SELECT pg_create_logical_replication_slot('enc_slot', 'pgoutput');"); + +# Roughly 5MB of inserts (~5x the 1MB WAL segment size) so the transaction +# spans multiple WAL segments. With logical_decoding_work_mem=64kB this also +# forces spilling, exercising the per-segment spill path on both write and +# read. Spill files are created and consumed inside a single decoding call, +# so we can't observe them between transactions; the round-trip of all 5000 +# rows below is the assertion that the cross-segment path works. +$node->safe_psql('postgres', q[ +BEGIN; +INSERT INTO spill_test +SELECT 'encrypt-me:' || repeat('x', 1000) || ':' || g.i +FROM generate_series(1, 5000) AS g(i); +COMMIT; +]); + +my $insert_count = $node->safe_psql('postgres', q[ +SELECT count(*) +FROM pg_logical_slot_get_binary_changes('enc_slot', NULL, NULL, + 'proto_version', '4', + 'publication_names', 'pub') +WHERE get_byte(data, 0) = 73; +]); +is($insert_count, '5000', + 'logical decoding returns all spilled changes with file encryption enabled'); + +$node->poll_query_until( + 'postgres', q[ +SELECT spill_count > 0 AND spill_bytes > 0 +FROM pg_stat_replication_slots +WHERE slot_name = 'enc_slot'; +]) or die "Timed out while waiting for spill statistics"; + +ok($node->log_contains( + qr/test_file_encryption: encrypt_calls=[1-9][0-9]* decrypt_calls=[1-9][0-9]*/, + 0), + 'file encryption callbacks were used for reorderbuffer spill files'); + +$node->safe_psql('postgres', "SELECT pg_drop_replication_slot('enc_slot');"); +$node->stop; + +done_testing(); diff --git a/src/test/modules/test_file_encryption/test_file_encryption.c b/src/test/modules/test_file_encryption/test_file_encryption.c new file mode 100644 index 0000000000000..a18f2213370e7 --- /dev/null +++ b/src/test/modules/test_file_encryption/test_file_encryption.c @@ -0,0 +1,307 @@ +/*------------------------------------------------------------------------- + * + * test_file_encryption.c + * Test module for file encryption callbacks. + * + * Implements a tiny path-keyed XOR transform — symmetric, so encrypt and + * decrypt are the same code path. No real security; just a way to + * exercise every code path that touches the encryption module. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/test/modules/test_file_encryption/test_file_encryption.c + * + *------------------------------------------------------------------------- + */ + +#include "postgres.h" + +#include "common/file_encryption_module.h" +#include "fmgr.h" +#include "port.h" + +PG_MODULE_MAGIC; + +typedef struct TestFileEncryptionState +{ + uint64 encrypt_calls; + uint64 decrypt_calls; + uint64 encrypt_bytes; + uint64 decrypt_bytes; +} TestFileEncryptionState; + +/* + * Per-relation state: just the relNumber, used as the XOR keystream seed. + * Doubles as a corruption check at object_open time — generate_object_key_cb + * wrote relNumber into the KEY fork, so we expect to see it back. + */ +#define TFE_OBJECT_MAGIC 0x54464530 /* "TFE0" */ +#define TFE_OBJECT_WRAP_LEN (sizeof(uint32) + sizeof(uint32)) + +typedef struct TestFileEncryptionObject +{ + uint32 rel_number; +} TestFileEncryptionObject; + +static bool test_file_encryption_startup(FileEncryptionModuleState *state, + char **errmsg); +static void test_file_encryption_shutdown(FileEncryptionModuleState *state); +static bool test_file_encryption_encrypt(const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg); +static bool test_file_encryption_decrypt(const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg); +static bool test_file_encryption_generate_object_key(FileEncryptionModuleState *state, + const RelFileLocator *locator, + char *dst, Size dst_max, + Size *wrapped_len, + char **errmsg); +static void *test_file_encryption_object_open(FileEncryptionModuleState *state, + const RelFileLocator *locator, + const char *wrapped, Size wrapped_len, + char **errmsg); +static void test_file_encryption_object_close(FileEncryptionModuleState *state, + void *object_state); +static bool test_file_encryption_encrypt_page(FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, + char **errmsg); +static bool test_file_encryption_decrypt_page(FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, + char **errmsg); + +static const FileEncryptionCallbacks test_file_encryption_callbacks = { + PG_FILE_ENCRYPTION_MAGIC, + .overhead_size = 0, /* size-preserving XOR; no per-call overhead */ + .page_overhead_size = 0, /* size-preserving XOR for pages too */ + + .startup_cb = test_file_encryption_startup, + .shutdown_cb = test_file_encryption_shutdown, + .encrypt_cb = test_file_encryption_encrypt, + .decrypt_cb = test_file_encryption_decrypt, + .generate_object_key_cb = test_file_encryption_generate_object_key, + .object_open_cb = test_file_encryption_object_open, + .object_close_cb = test_file_encryption_object_close, + .encrypt_page_cb = test_file_encryption_encrypt_page, + .decrypt_page_cb = test_file_encryption_decrypt_page, +}; + +static uint32 +path_hash(const char *path) +{ + uint32 hash = 5381; + + while (*path) + hash = (hash << 5) + hash + (unsigned char) *path++; + + return hash; +} + +/* + * Symmetric XOR keystream derived from path + file_offset, written into + * dst. Used for both encrypt and decrypt. + */ +static void +xor_transform(const char *path, uint64 file_offset, + const char *data, Size data_len, char *dst) +{ + uint32 hash = path_hash(path); + Size i; + + for (i = 0; i < data_len; i++) + { + uint8 mask = (uint8) (hash + file_offset + i); + + dst[i] = data[i] ^ mask; + } +} + +/* + * Module entry point. This module does no real cryptography and takes no + * configuration; the test scripts inspect the always-emitted shutdown log + * line to confirm that callbacks fired. + */ +bool +_PG_file_encryption_module_init(const char *config, + const FileEncryptionCallbacks **callbacks_out, + char **errmsg) +{ + *callbacks_out = &test_file_encryption_callbacks; + return true; +} + +static bool +test_file_encryption_startup(FileEncryptionModuleState *state, char **errmsg) +{ + TestFileEncryptionState *private_state; + + private_state = palloc0_object(TestFileEncryptionState); + state->private_data = private_state; + return true; +} + +static void +test_file_encryption_shutdown(FileEncryptionModuleState *state) +{ + TestFileEncryptionState *private_state; + + private_state = (TestFileEncryptionState *) state->private_data; + + if (private_state != NULL) + elog(LOG, + "test_file_encryption: encrypt_calls=" UINT64_FORMAT " decrypt_calls=" UINT64_FORMAT + " encrypt_bytes=" UINT64_FORMAT " decrypt_bytes=" UINT64_FORMAT, + private_state->encrypt_calls, + private_state->decrypt_calls, + private_state->encrypt_bytes, + private_state->decrypt_bytes); +} + +static bool +test_file_encryption_encrypt(const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg) +{ + TestFileEncryptionState *private_state; + + private_state = (TestFileEncryptionState *) state->private_data; + xor_transform(path, file_offset, data, data_len, dst); + private_state->encrypt_calls++; + private_state->encrypt_bytes += data_len; + return true; +} + +static bool +test_file_encryption_decrypt(const FileEncryptionModuleState *state, + const char *path, uint64 file_offset, + const char *data, Size data_len, + char *dst, char **errmsg) +{ + TestFileEncryptionState *private_state; + + private_state = (TestFileEncryptionState *) state->private_data; + xor_transform(path, file_offset, data, data_len, dst); + private_state->decrypt_calls++; + private_state->decrypt_bytes += data_len; + return true; +} + +/* + * Per-relation page encryption: zero-overhead XOR. The "wrapped DEK" is + * just a magic + the relNumber, so object_open can sanity-check that the + * KEY fork wasn't shuffled between relations. Real modules would put + * actual key material here. + */ +static bool +test_file_encryption_generate_object_key(FileEncryptionModuleState *state, + const RelFileLocator *locator, + char *dst, Size dst_max, + Size *wrapped_len, + char **errmsg) +{ + uint32 magic = TFE_OBJECT_MAGIC; + uint32 rel = (uint32) locator->relNumber; + + if (dst_max < TFE_OBJECT_WRAP_LEN) + { + *errmsg = psprintf("test_file_encryption: wrapped-key buffer is %zu bytes, need %zu", + dst_max, (Size) TFE_OBJECT_WRAP_LEN); + return false; + } + + memcpy(dst, &magic, sizeof(magic)); + memcpy(dst + sizeof(magic), &rel, sizeof(rel)); + *wrapped_len = TFE_OBJECT_WRAP_LEN; + return true; +} + +static void * +test_file_encryption_object_open(FileEncryptionModuleState *state, + const RelFileLocator *locator, + const char *wrapped, Size wrapped_len, + char **errmsg) +{ + TestFileEncryptionObject *obj; + uint32 magic; + uint32 rel; + + if (wrapped_len != TFE_OBJECT_WRAP_LEN) + { + *errmsg = psprintf("unexpected wrapped object length %zu", wrapped_len); + return NULL; + } + memcpy(&magic, wrapped, sizeof(magic)); + memcpy(&rel, wrapped + sizeof(magic), sizeof(rel)); + if (magic != TFE_OBJECT_MAGIC) + { + *errmsg = psprintf("bad object magic 0x%08x", magic); + return NULL; + } + if (rel != (uint32) locator->relNumber) + { + *errmsg = psprintf("relNumber mismatch on KEY fork"); + return NULL; + } + + obj = palloc0_object(TestFileEncryptionObject); + obj->rel_number = rel; + return obj; +} + +static void +test_file_encryption_object_close(FileEncryptionModuleState *state, + void *object_state) +{ + pfree(object_state); +} + +/* + * Symmetric XOR over the full BLCKSZ. The keystream binds the relation, + * fork, and block number, so MAIN block N and INIT block N produce + * different ciphertexts (matching the binding-context semantics of real + * modules). + */ +static void +test_file_encryption_xor_page(TestFileEncryptionObject *obj, ForkNumber fork, + BlockNumber blocknum, + const char *src, char *dst) +{ + uint32 seed = obj->rel_number * 2654435761u + + (uint32) fork * 16777619u + + blocknum; + + for (Size i = 0; i < BLCKSZ; i++) + dst[i] = src[i] ^ (uint8) (seed + i); +} + +static bool +test_file_encryption_encrypt_page(FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, + char **errmsg) +{ + test_file_encryption_xor_page((TestFileEncryptionObject *) object_state, + fork, blocknum, src, dst); + return true; +} + +static bool +test_file_encryption_decrypt_page(FileEncryptionModuleState *state, + void *object_state, + ForkNumber fork, BlockNumber blocknum, + const char *src, char *dst, + char **errmsg) +{ + test_file_encryption_xor_page((TestFileEncryptionObject *) object_state, + fork, blocknum, src, dst); + return true; +}