Created
September 5, 2025 16:07
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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 & 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