Skip to content

Instantly share code, notes, and snippets.

@mwender
Created September 5, 2025 16:07
Show Gist options
  • Select an option

  • Save mwender/eaf81c87cbeca240e22f50d50e77fd18 to your computer and use it in GitHub Desktop.

Select an option

Save mwender/eaf81c87cbeca240e22f50d50e77fd18 to your computer and use it in GitHub Desktop.
[GravityForms Secure Comms] A suite of functions for encrypting fields submitted via GravityForms. #php #wordpress
<?php
/**
* Plugin Name: GF – Encrypt & Expire (Form-Specific)
* Description: Encrypts selected Gravity Forms fields at rest and auto-expires entries for a specific form. Requires constants in wp-config.php.
* Author: TheWebist
* Version: 1.1.0
*/
// ==================================================
// REQUIRED CONSTANTS (define these in wp-config.php)
//
// define( 'GF_ENCRYPT_FORM_ID', 17 ); // int
// define( 'GF_ENCRYPT_FIELD_IDS', '1,3,5' ); // comma-separated string OR PHP array [1,3,5]
// define( 'GF_ENCRYPT_EXPIRY_DAYS', 30 ); // int
// define( 'GF_ENCRYPT_DECRYPT_CAP', 'manage_options' ); // capability string for admins who can see plaintext in WP Admin
// define( 'GF_ENCRYPT_SECRET_KEY', 'long-random-secret...'); // long random string kept outside repo
// ==================================================
// ====== CONFIG VALIDATION ======
/**
* Return array of missing or invalid requirements.
*
* @return array
*/
function gf_encrypt_missing_requirements() {
$missing = [];
if ( ! defined( 'GF_ENCRYPT_FORM_ID' ) || ! is_numeric( GF_ENCRYPT_FORM_ID ) ) {
$missing[] = 'GF_ENCRYPT_FORM_ID';
}
if ( ! defined( 'GF_ENCRYPT_FIELD_IDS' ) ) {
$missing[] = 'GF_ENCRYPT_FIELD_IDS';
} else {
$ids = gf_encrypt_field_ids();
if ( empty( $ids ) ) {
$missing[] = 'GF_ENCRYPT_FIELD_IDS (must be non-empty CSV string like "1,3,5" or array [1,3,5])';
}
}
if ( ! defined( 'GF_ENCRYPT_EXPIRY_DAYS' ) || ! is_numeric( GF_ENCRYPT_EXPIRY_DAYS ) || GF_ENCRYPT_EXPIRY_DAYS < 1 ) {
$missing[] = 'GF_ENCRYPT_EXPIRY_DAYS (integer >= 1)';
}
if ( ! defined( 'GF_ENCRYPT_DECRYPT_CAP' ) || ! is_string( GF_ENCRYPT_DECRYPT_CAP ) || '' === GF_ENCRYPT_DECRYPT_CAP ) {
$missing[] = 'GF_ENCRYPT_DECRYPT_CAP';
}
if ( ! defined( 'GF_ENCRYPT_SECRET_KEY' ) || ! is_string( GF_ENCRYPT_SECRET_KEY ) || '' === GF_ENCRYPT_SECRET_KEY ) {
$missing[] = 'GF_ENCRYPT_SECRET_KEY';
}
if ( ! function_exists( 'sodium_crypto_secretbox' ) ) {
$missing[] = 'libsodium PHP extension (sodium)';
}
return $missing;
}
/**
* Get field IDs as an array from GF_ENCRYPT_FIELD_IDS.
*
* Accepts CSV string "1,3,5" or PHP array [1,3,5].
*
* @return int[]
*/
function gf_encrypt_field_ids() {
if ( ! defined( 'GF_ENCRYPT_FIELD_IDS' ) ) {
return [];
}
$raw = GF_ENCRYPT_FIELD_IDS;
if ( is_array( $raw ) ) {
$out = array_map( 'intval', $raw );
return array_values( array_filter( $out, static function( $v ) { return $v > 0; } ) );
}
if ( is_string( $raw ) ) {
$parts = array_map( 'trim', explode( ',', $raw ) );
$out = array_map( 'intval', $parts );
return array_values( array_filter( $out, static function( $v ) { return $v > 0; } ) );
}
return [];
}
/**
* Is config valid?
*
* @return bool
*/
function gf_encrypt_config_is_valid() {
return empty( gf_encrypt_missing_requirements() );
}
/**
* Admin notice for missing config/libsodium.
*/
add_action( 'admin_notices', function() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$missing = gf_encrypt_missing_requirements();
if ( empty( $missing ) ) {
return;
}
$sample = <<<EOT
// Gravity Forms encryption config:
define( 'GF_ENCRYPT_FORM_ID', 17 ); // Protect this form ID.
define( 'GF_ENCRYPT_FIELD_IDS', '1,3,5' ); // Comma list or array of field IDs to encrypt.
define( 'GF_ENCRYPT_EXPIRY_DAYS', 7 ); // Days until entry deletion.
define( 'GF_ENCRYPT_DECRYPT_CAP', 'manage_options' );// Cap required to view plaintext in WP Admin.
define( 'GF_ENCRYPT_SECRET_KEY', 'CHANGE-THIS-TO-A-LONG-RANDOM-SECRET' ); // Keep secret; rotate with care.
EOT;
?>
<div class="notice notice-error">
<p><strong>GF – Encrypt &amp; Expire:</strong> Configuration incomplete.</p>
<p>The following requirements are missing or invalid:</p>
<ul style="list-style: disc; margin-left: 2em;">
<?php foreach ( $missing as $m ) : ?>
<li><?php echo esc_html( $m ); ?></li>
<?php endforeach; ?>
</ul>
<p>Add these to <code>wp-config.php</code> (above “/* That’s all, stop editing! */”):</p>
<pre style="white-space:pre-wrap;"><?php echo esc_html( $sample ); ?></pre>
<p><em>Note:</em> Arrays are supported for <code>GF_ENCRYPT_FIELD_IDS</code>, e.g. <code>define( 'GF_ENCRYPT_FIELD_IDS', [1,3,5] );</code></p>
</div>
<?php
} );
// Early exit if config is invalid (do not hook filters/actions).
if ( ! gf_encrypt_config_is_valid() ) {
return;
}
// ====== HELPERS ======
/**
* Encrypt a string with libsodium (secretbox).
*
* @param string $plain Plaintext.
* @return string Cipher text in format base64(nonce):base64(ciphertext).
*/
function gf_encrypt_encrypt( $plain ) {
if ( ! is_string( $plain ) || '' === $plain ) {
return $plain;
}
$key = sodium_crypto_generichash( GF_ENCRYPT_SECRET_KEY, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
$nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
$ct = sodium_crypto_secretbox( $plain, $nonce, $key );
return base64_encode( $nonce ) . ':' . base64_encode( $ct );
}
/**
* Detect our ciphertext strictly.
*
* Expected format: base64(nonce):base64(ciphertext)
* - nonce must decode to exactly SODIUM_CRYPTO_SECRETBOX_NONCEBYTES (24) bytes
* - ciphertext must be valid base64 and at least MAC size (16) bytes
*
* @param string $value Value to test.
* @return bool
*/
function gf_encrypt_is_ciphertext( $value ) {
if ( ! is_string( $value ) || '' === $value ) {
return false;
}
// Must contain exactly one separator producing two parts.
$parts = explode( ':', $value, 2 );
if ( 2 !== count( $parts ) ) {
return false;
}
// Strict base64 decode.
$nonce = base64_decode( $parts[0], true );
$ct = base64_decode( $parts[1], true );
if ( false === $nonce || false === $ct ) {
return false;
}
// Nonce must be exactly 24 bytes for secretbox.
if ( strlen( $nonce ) !== SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) {
return false;
}
// Ciphertext must be at least MAC length (16) bytes.
if ( strlen( $ct ) < SODIUM_CRYPTO_SECRETBOX_MACBYTES ) {
return false;
}
return true;
}
/**
* Decrypt a ciphertext string.
*
* @param string $cipher Cipher text in format base64(nonce):base64(cipher).
* @return string Plaintext on success, original value on failure.
*/
function gf_encrypt_decrypt( $cipher ) {
if ( ! gf_encrypt_is_ciphertext( $cipher ) ) {
return $cipher;
}
list( $nonce_b64, $ct_b64 ) = explode( ':', $cipher, 2 );
$nonce = base64_decode( $nonce_b64, true );
$ct = base64_decode( $ct_b64, true );
if ( false === $nonce || false === $ct ) {
return $cipher;
}
$key = sodium_crypto_generichash( GF_ENCRYPT_SECRET_KEY, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
$pt = sodium_crypto_secretbox_open( $ct, $nonce, $key );
return false === $pt ? $cipher : $pt;
}
/**
* Check if current user can view decrypted values.
*
* @return bool
*/
function gf_encrypt_user_can_decrypt() {
return is_admin() && current_user_can( GF_ENCRYPT_DECRYPT_CAP );
}
/**
* Encrypt targeted fields at write-time (late priority to win over built-ins).
*
* NOTE: UPDATED to run at priority 100 and catch Website/Paragraph Text edge cases.
*/
add_filter( 'gform_save_field_value', function( $value, $entry, $field, $form, $input_id ) {
// Only our protected form.
if ( (int) $form['id'] !== (int) GF_ENCRYPT_FORM_ID ) {
return $value;
}
// Only targeted field IDs.
$fid = (int) $field->id;
if ( ! in_array( $fid, gf_encrypt_field_ids(), true ) ) {
return $value;
}
// Normalize value to string for encryption.
if ( is_array( $value ) ) {
$value = wp_json_encode( $value );
}
if ( ! is_string( $value ) || '' === $value ) {
return $value;
}
// Skip if already ciphertext (prevents double-encryption on edits/updates).
if ( gf_encrypt_is_ciphertext( $value ) ) {
return $value;
}
return gf_encrypt_encrypt( $value );
}, 100, 5 ); // <-- priority 100 (UPDATED)
// ====== SET EXPIRATION ======
/**
* After submission, set expire_at meta for the entry.
*/
add_action( 'gform_after_submission', function( $entry, $form ) {
if ( (int) $form['id'] !== (int) GF_ENCRYPT_FORM_ID ) {
return;
}
$expire_at = time() + ( DAY_IN_SECONDS * (int) GF_ENCRYPT_EXPIRY_DAYS );
// Prefer official helper if available.
if ( function_exists( 'gform_update_meta' ) ) {
gform_update_meta( (int) $entry['id'], 'expire_at', $expire_at );
return;
}
// Fallback for older GF versions.
if ( class_exists( 'GFFormsModel' ) && method_exists( 'GFFormsModel', 'update_lead_meta' ) ) {
GFFormsModel::update_lead_meta( (int) $entry['id'], 'expire_at', $expire_at );
}
}, 10, 2 );
// ====== ADMIN DECRYPT (LIST, DETAIL, EXPORT) ======
/**
* Decrypt values in Entries list table.
*/
add_filter( 'gform_entries_field_value', function( $value, $form_id, $field_id, $entry ) {
if ( (int) $form_id !== (int) GF_ENCRYPT_FORM_ID ) {
return $value;
}
if ( ! gf_encrypt_user_can_decrypt() ) {
return $value;
}
if ( in_array( (int) $field_id, gf_encrypt_field_ids(), true ) && is_string( $value ) && gf_encrypt_is_ciphertext( $value ) ) {
return gf_encrypt_decrypt( $value );
}
return $value;
}, 10, 4 );
/**
* Decrypt values in Entry detail screen.
*/
add_filter( 'gform_get_input_value', function( $value, $entry, $field, $input_id ) {
if ( (int) $entry['form_id'] !== (int) GF_ENCRYPT_FORM_ID ) {
return $value;
}
if ( ! gf_encrypt_user_can_decrypt() ) {
return $value;
}
$fid = (int) $field->id;
if ( in_array( $fid, gf_encrypt_field_ids(), true ) && is_string( $value ) && gf_encrypt_is_ciphertext( $value ) ) {
return gf_encrypt_decrypt( $value );
}
return $value;
}, 10, 4 );
/**
* Decrypt values in CSV exports.
*/
add_filter( 'gform_export_field_value', function( $value, $form_id, $field_id, $entry ) {
if ( (int) $form_id !== (int) GF_ENCRYPT_FORM_ID ) {
return $value;
}
if ( ! gf_encrypt_user_can_decrypt() ) {
return $value;
}
if ( in_array( (int) $field_id, gf_encrypt_field_ids(), true ) && is_string( $value ) && gf_encrypt_is_ciphertext( $value ) ) {
return gf_encrypt_decrypt( $value );
}
return $value;
}, 10, 4 );
// ====== ENFORCEMENT: POST-SAVE DOUBLE-CHECK (NEW) ======
/**
* After the entry is saved, ensure targeted metas are encrypted.
* This is idempotent and only touches plaintext that may have slipped through.
*
* NOTE: NEW block to handle edge cases where GF writes plaintext after our filter.
*/
add_action( 'gform_entry_post_save', function( $entry, $form ) {
if ( (int) $form['id'] !== (int) GF_ENCRYPT_FORM_ID ) {
return $entry;
}
if ( empty( $entry['id'] ) || ! function_exists( 'gform_get_meta' ) ) {
return $entry;
}
$entry_id = (int) $entry['id'];
$field_ids = gf_encrypt_field_ids();
foreach ( $field_ids as $fid ) {
$key = (string) (int) $fid;
$value = gform_get_meta( $entry_id, $key );
if ( null === $value || '' === $value ) {
continue;
}
// Already ciphertext? leave it.
if ( is_string( $value ) && gf_encrypt_is_ciphertext( $value ) ) {
continue;
}
if ( is_array( $value ) ) {
$value = wp_json_encode( $value );
}
if ( is_string( $value ) && '' !== $value ) {
$cipher = gf_encrypt_encrypt( $value );
if ( function_exists( 'gform_update_meta' ) ) {
gform_update_meta( $entry_id, $key, $cipher );
} elseif ( class_exists( 'GFFormsModel' ) && method_exists( 'GFFormsModel', 'update_lead_meta' ) ) {
GFFormsModel::update_lead_meta( $entry_id, $key, $cipher );
}
}
}
return $entry;
}, 10, 2 );
// ====== CRON: DELETE EXPIRED ENTRIES ======
/**
* Schedule daily task.
*/
add_action( 'wp', function() {
if ( ! wp_next_scheduled( 'gf_encrypt_purge_expired_entries' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'gf_encrypt_purge_expired_entries' );
}
} );
/**
* Purge expired entries for our form.
*/
add_action( 'gf_encrypt_purge_expired_entries', function() {
if ( ! class_exists( 'GFAPI' ) ) {
return;
}
$now = time();
$search_criteria = [
'status' => 'active',
'field_filters' => [
'mode' => 'all',
[
'key' => 'expire_at',
'operator' => '<',
'value' => $now,
],
],
];
$paging = [ 'offset' => 0, 'page_size' => 200 ];
$entries = GFAPI::get_entries( (int) GF_ENCRYPT_FORM_ID, $search_criteria, null, $paging );
if ( is_wp_error( $entries ) || empty( $entries ) ) {
return;
}
foreach ( $entries as $entry ) {
GFAPI::delete_entry( $entry['id'] ); // Move to trash (GF). Use GFAPI::delete_entry( $id ) again to force permanent, if desired.
}
} );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment