Step-by-Step Guide to building a custom secure file encryption vault block for Gutenberg using REST API custom routes
I. Architectural Overview: Secure File Vault with Gutenberg Integration
This document details the construction of a custom Gutenberg block for WordPress, acting as a secure file encryption vault. The architecture leverages WordPress’s REST API with custom routes for secure file uploads, encryption, decryption, and retrieval. This approach ensures sensitive data remains encrypted at rest and in transit, accessible only through authenticated user sessions and a robust encryption mechanism. The primary goal is to provide a secure, user-friendly interface within the WordPress editor for managing encrypted files.
Key components include:
- Gutenberg Block: A React-based component for the WordPress editor UI.
- WordPress REST API: Extends WordPress with custom endpoints for file operations.
- Encryption Library: PHP-based library (e.g., OpenSSL via `openssl_encrypt`/`openssl_decrypt`) for symmetric encryption.
- Secure Storage: Files are stored encrypted in the WordPress uploads directory or a designated secure location.
- Authentication & Authorization: Leverages WordPress’s built-in user roles and nonce verification for API access.
II. Setting Up Custom REST API Routes
We’ll define custom routes within a WordPress plugin to handle file uploads, encryption, and retrieval. This involves registering new endpoints under `/wp-json/my-secure-vault/v1/`. The following PHP code snippet demonstrates how to register these routes in your plugin’s main file or an included file.
A. Plugin Initialization and Route Registration
Create a basic plugin structure. For instance, a file named my-secure-vault.php in wp-content/plugins/.
<?php
/**
* Plugin Name: My Secure File Vault
* Description: A custom Gutenberg block for secure file encryption.
* Version: 1.0
* Author: Your Name
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// Define plugin constants
define( 'MY_SECURE_VAULT_PATH', plugin_dir_path( __FILE__ ) );
define( 'MY_SECURE_VAULT_URL', plugin_dir_url( __FILE__ ) );
// Include necessary files
require_once MY_SECURE_VAULT_PATH . 'includes/class-msv-rest-api.php';
require_once MY_SECURE_VAULT_PATH . 'includes/class-msv-encryption.php';
require_once MY_SECURE_VAULT_PATH . 'blocks/build/index.asset.php'; // For block assets
// Initialize REST API routes
add_action( 'rest_api_init', array( 'MSV_REST_API', 'register_routes' ) );
// Enqueue block assets
function my_secure_vault_enqueue_block_assets() {
wp_enqueue_script(
'my-secure-vault-block-editor-script',
MY_SECURE_VAULT_URL . 'blocks/build/index.js',
MY_SECURE_VAULT_BLOCK_EDITOR_SCRIPT_DEPENDENCIES,
MY_SECURE_VAULT_BLOCK_EDITOR_SCRIPT_VERSION
);
wp_enqueue_style(
'my-secure-vault-block-editor-style',
MY_SECURE_VAULT_URL . 'blocks/build/index.css',
array( 'wp-edit-blocks' ),
MY_SECURE_VAULT_BLOCK_EDITOR_SCRIPT_VERSION
);
// Localize script for API endpoints and encryption keys (handle key management securely)
wp_localize_script( 'my-secure-vault-block-editor-script', 'mySecureVaultSettings', array(
'restUrl' => esc_url_raw( rest_url() ),
'nonce' => wp_create_nonce( 'wp_rest' ),
// IMPORTANT: Do NOT expose sensitive encryption keys directly here.
// Key management should be handled server-side or via secure, short-lived tokens.
// For demonstration, we'll assume a server-side key retrieval mechanism.
) );
}
add_action( 'enqueue_block_editor_assets', 'my_secure_vault_enqueue_block_assets' );
// Activation hook for initial setup (optional)
register_activation_hook( __FILE__, array( 'MSV_Encryption', 'generate_encryption_key' ) );
?>
B. REST API Controller Class
Create a file includes/class-msv-rest-api.php. This class will define the endpoints and their callback functions.
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class MSV_REST_API {
/**
* Register the custom REST API routes.
*/
public static function register_routes() {
// Route for file upload and encryption
register_rest_route( 'my-secure-vault/v1', '/upload', array(
'methods' => WP_REST_Server::CREATABLE, // POST method
'callback' => array( __CLASS__, 'handle_upload' ),
'permission_callback' => array( __CLASS__, 'check_permission' ),
'args' => array(
'file' => array(
'required' => true,
'type' => 'string', // Base64 encoded file content
'sanitize_callback' => 'base64_decode',
'validate_callback' => function( $param, $request, $key ) {
return is_string( $param ); // Ensure it's a string after potential decoding
},
),
'filename' => array(
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_file_name',
),
'mime_type' => array(
'required' => false,
'type' => 'string',
'sanitize_callback' => 'sanitize_mime_type',
),
),
) );
// Route for retrieving encrypted file metadata (e.g., list of files)
register_rest_route( 'my-secure-vault/v1', '/files', array(
'methods' => WP_REST_Server::READABLE, // GET method
'callback' => array( __CLASS__, 'handle_get_files' ),
'permission_callback' => array( __CLASS__, 'check_permission' ),
) );
// Route for downloading an encrypted file
register_rest_route( 'my-secure-vault/v1', '/download/(?P<id>\d+)', array(
'methods' => WP_REST_Server::READABLE, // GET method
'callback' => array( __CLASS__, 'handle_download' ),
'permission_callback' => array( __CLASS__, 'check_permission' ),
'args' => array(
'id' => array(
'validate_callback' => function( $param, $request, $key ) {
return is_numeric( $param );
},
),
),
) );
// Route for deleting a file
register_rest_route( 'my-secure-vault/v1', '/delete/(?P<id>\d+)', array(
'methods' => WP_REST_Server::DELETABLE, // DELETE method
'callback' => array( __CLASS__, 'handle_delete' ),
'permission_callback' => array( __CLASS__, 'check_permission' ),
'args' => array(
'id' => array(
'validate_callback' => function( $param, $request, $key ) {
return is_numeric( $param );
},
),
),
) );
}
/**
* Check user permissions for accessing the API.
*
* @param WP_REST_Request $request Full data about the request.
* @return bool|WP_Error True if the request has permission, WP_Error object otherwise.
*/
public static function check_permission( WP_REST_Request $request ) {
// Ensure user is logged in and has a capability to manage media or a custom capability.
// For simplicity, we'll use 'upload_files'. Adjust as needed for your security model.
if ( ! current_user_can( 'upload_files' ) ) {
return new WP_Error( 'rest_forbidden', esc_html__( 'You do not have permissions to perform this action.', 'my-secure-vault' ), array( 'status' => 401 ) );
}
return true;
}
/**
* Handles file upload and encryption.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public static function handle_upload( WP_REST_Request $request ) {
$file_content_base64 = $request->get_param( 'file' );
$filename = $request->get_param( 'filename' );
$mime_type = $request->get_param( 'mime_type' );
if ( ! $file_content_base64 || ! $filename ) {
return new WP_Error( 'rest_invalid_param', esc_html__( 'Missing required parameters.', 'my-secure-vault' ), array( 'status' => 400 ) );
}
$file_content = base64_decode( $file_content_base64 );
if ( $file_content === false ) {
return new WP_Error( 'rest_base64_decode_failed', esc_html__( 'Failed to decode file content.', 'my-secure-vault' ), array( 'status' => 400 ) );
}
$encryption_handler = new MSV_Encryption();
$encrypted_content = $encryption_handler->encrypt( $file_content );
if ( $encrypted_content === false ) {
return new WP_Error( 'encryption_failed', esc_html__( 'File encryption failed.', 'my-secure-vault' ), array( 'status' => 500 ) );
}
// Prepare for WordPress file upload
$upload_dir = wp_upload_dir();
$upload_path = trailingslashit( $upload_dir['basedir'] ) . 'secure-vault/';
if ( ! file_exists( $upload_path ) ) {
wp_mkdir_p( $upload_path );
}
// Generate a unique filename for the encrypted file
$encrypted_filename = sanitize_title( $filename ) . '_' . md5( uniqid( wp_rand(), true ) ) . '.enc';
$file_path = $upload_path . $encrypted_filename;
// Save the encrypted file
if ( ! file_put_contents( $file_path, $encrypted_content ) ) {
return new WP_Error( 'file_save_failed', esc_html__( 'Failed to save encrypted file.', 'my-secure-vault' ), array( 'status' => 500 ) );
}
// Store metadata in the database (e.g., custom post type or options table)
// For simplicity, we'll use the options table. A custom post type is recommended for scalability.
$file_metadata = array(
'original_filename' => $filename,
'encrypted_filename' => $encrypted_filename,
'filepath' => $file_path,
'mime_type' => $mime_type ?: 'application/octet-stream',
'uploaded_by' => get_current_user_id(),
'upload_time' => current_time( 'mysql' ),
);
$files = get_option( 'msv_secure_files', array() );
$new_id = count( $files ) + 1; // Simple ID generation
$files[$new_id] = $file_metadata;
update_option( 'msv_secure_files', $files );
return new WP_REST_Response( array(
'success' => true,
'message' => esc_html__( 'File uploaded and encrypted successfully.', 'my-secure-vault' ),
'file_id' => $new_id,
'filename' => $filename,
), 201 );
}
/**
* Handles retrieving a list of encrypted files.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public static function handle_get_files( WP_REST_Request $request ) {
$files = get_option( 'msv_secure_files', array() );
$file_list = array();
foreach ( $files as $id => $metadata ) {
// Sanitize output for security
$file_list[] = array(
'id' => $id,
'original_filename' => sanitize_text_field( $metadata['original_filename'] ),
'mime_type' => sanitize_mime_type( $metadata['mime_type'] ),
'upload_time' => sanitize_text_field( $metadata['upload_time'] ),
'uploaded_by' => absint( $metadata['uploaded_by'] ),
);
}
return new WP_REST_Response( array(
'success' => true,
'files' => $file_list,
), 200 );
}
/**
* Handles downloading an encrypted file.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public static function handle_download( WP_REST_Request $request ) {
$file_id = $request->get_param( 'id' );
$files = get_option( 'msv_secure_files', array() );
if ( ! isset( $files[$file_id] ) ) {
return new WP_Error( 'file_not_found', esc_html__( 'File not found.', 'my-secure-vault' ), array( 'status' => 404 ) );
}
$metadata = $files[$file_id];
$file_path = $metadata['filepath'];
$original_filename = $metadata['original_filename'];
$mime_type = $metadata['mime_type'];
if ( ! file_exists( $file_path ) ) {
// Clean up metadata if file is missing
unset( $files[$file_id] );
update_option( 'msv_secure_files', $files );
return new WP_Error( 'file_missing', esc_html__( 'Encrypted file is missing on the server.', 'my-secure-vault' ), array( 'status' => 404 ) );
}
$encryption_handler = new MSV_Encryption();
$encrypted_content = file_get_contents( $file_path );
if ( $encrypted_content === false ) {
return new WP_Error( 'file_read_failed', esc_html__( 'Failed to read encrypted file.', 'my-secure-vault' ), array( 'status' => 500 ) );
}
$decrypted_content = $encryption_handler->decrypt( $encrypted_content );
if ( $decrypted_content === false ) {
return new WP_Error( 'decryption_failed', esc_html__( 'File decryption failed. The file may be corrupted or the key is invalid.', 'my-secure-vault' ), array( 'status' => 500 ) );
}
// Prepare response for download
$response = new WP_REST_Response( $decrypted_content );
$response->set_content_type( $mime_type );
$response->add_header( 'Content-Disposition', 'attachment; filename="' . rawurlencode( $original_filename ) . '"' );
$response->add_header( 'Content-Length', strlen( $decrypted_content ) );
$response->add_header( 'Cache-Control', 'no-cache, no-store, must-revalidate' );
$response->add_header( 'Pragma', 'no-cache' );
$response->add_header( 'Expires', '0' );
return $response;
}
/**
* Handles deleting an encrypted file.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public static function handle_delete( WP_REST_Request $request ) {
$file_id = $request->get_param( 'id' );
$files = get_option( 'msv_secure_files', array() );
if ( ! isset( $files[$file_id] ) ) {
return new WP_Error( 'file_not_found', esc_html__( 'File not found.', 'my-secure-vault' ), array( 'status' => 404 ) );
}
$metadata = $files[$file_id];
$file_path = $metadata['filepath'];
// Delete the physical file
if ( file_exists( $file_path ) ) {
if ( ! wp_delete_file( $file_path ) ) {
// Log this error, but proceed to remove metadata
error_log( "My Secure Vault: Failed to delete physical file: " . $file_path );
}
}
// Remove metadata from options
unset( $files[$file_id] );
update_option( 'msv_secure_files', $files );
return new WP_REST_Response( array(
'success' => true,
'message' => esc_html__( 'File deleted successfully.', 'my-secure-vault' ),
), 200 );
}
}
?>
III. Implementing the Encryption Logic
A dedicated class for encryption ensures modularity and security. We’ll use PHP’s OpenSSL extension for robust AES-256 encryption. The encryption key must be managed securely.
A. Encryption Handler Class
Create a file includes/class-msv-encryption.php.
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
class MSV_Encryption {
private $cipher = 'aes-256-cbc';
private $key;
private $iv_length;
public function __construct() {
// Retrieve or generate the encryption key.
// IMPORTANT: This key MUST be stored securely.
// For production, consider using environment variables, a secrets manager,
// or a dedicated WordPress option with strict access controls.
// NEVER hardcode keys directly in the plugin file.
$this->key = $this->get_encryption_key();
if ( ! $this->key ) {
// Fallback or error handling if key is not set.
// In a real-world scenario, this should trigger an alert or prevent operations.
error_log( 'My Secure Vault: Encryption key not found. Please generate or set it.' );
// For demonstration, we'll generate one if missing, but this is NOT secure for production.
$this->key = $this->generate_encryption_key();
}
$this->iv_length = openssl_cipher_iv_length( $this->cipher );
if ( $this->iv_length === false ) {
// Handle error if cipher is not supported or invalid
throw new Exception( 'Unsupported cipher or invalid OpenSSL configuration.' );
}
}
/**
* Gets the encryption key.
* In a production environment, this should fetch from a secure source.
*
* @return string|false The encryption key or false if not found.
*/
private function get_encryption_key() {
// Example: Fetching from a WordPress option.
// Ensure this option is protected and not easily accessible.
$key = get_option( 'msv_encryption_key' );
// IMPORTANT SECURITY NOTE:
// The key should be a strong, random string (e.g., 32 bytes for AES-256).
// It should be generated ONCE during plugin activation and stored securely.
// For this example, we'll return a placeholder if not found, but the activation hook handles generation.
if ( $key && strlen( $key ) === 32 ) { // AES-256 requires a 32-byte key
return $key;
}
return false;
}
/**
* Generates and stores a new encryption key.
* Should be called once during plugin activation.
*
* @return string|false The generated key or false on failure.
*/
public static function generate_encryption_key() {
$key_length = 32; // 32 bytes for AES-256
$key = openssl_random_pseudo_bytes( $key_length );
if ( $key === false ) {
error_log( 'My Secure Vault: Failed to generate encryption key.' );
return false;
}
$key_hex = bin2hex( $key ); // Store as hex for easier handling in options, or directly as binary if preferred.
// Store the key securely. Using 'site_transient' or a custom table with restricted access is better.
// For simplicity, we use 'option', but ensure it's not exposed.
update_option( 'msv_encryption_key', $key_hex );
return $key_hex;
}
/**
* Encrypts data.
*
* @param string $plaintext The data to encrypt.
* @return string|false The encrypted data (base64 encoded) or false on failure.
*/
public function encrypt( $plaintext ) {
if ( ! $this->key ) {
error_log( 'My Secure Vault: Encryption key is missing during encryption.' );
return false;
}
// Generate a unique IV for each encryption
$iv = openssl_random_pseudo_bytes( $this->iv_length );
if ( $iv === false ) {
error_log( 'My Secure Vault: Failed to generate IV for encryption.' );
return false;
}
$ciphertext = openssl_encrypt( $plaintext, $this->cipher, hex2bin( $this->key ), OPENSSL_RAW_DATA, $iv );
if ( $ciphertext === false ) {
error_log( 'My Secure Vault: openssl_encrypt failed: ' . openssl_error_string() );
return false;
}
// Prepend the IV to the ciphertext. The IV is needed for decryption.
// Base64 encode the combined IV and ciphertext for storage/transmission.
return base64_encode( $iv . $ciphertext );
}
/**
* Decrypts data.
*
* @param string $base64_encrypted_data The base64 encoded encrypted data (including IV).
* @return string|false The decrypted data or false on failure.
*/
public function decrypt( $base64_encrypted_data ) {
if ( ! $this->key ) {
error_log( 'My Secure Vault: Encryption key is missing during decryption.' );
return false;
}
$encrypted_data = base64_decode( $base64_encrypted_data );
if ( $encrypted_data === false ) {
error_log( 'My Secure Vault: Failed to base64 decode encrypted data.' );
return false;
}
if ( strlen( $encrypted_data ) <= $this->iv_length ) {
error_log( 'My Secure Vault: Encrypted data too short to contain IV.' );
return false;
}
// Extract the IV and ciphertext
$iv = substr( $encrypted_data, 0, $this->iv_length );
$ciphertext = substr( $encrypted_data, $this->iv_length );
$plaintext = openssl_decrypt( $ciphertext, $this->cipher, hex2bin( $this->key ), OPENSSL_RAW_DATA, $iv );
if ( $plaintext === false ) {
error_log( 'My Secure Vault: openssl_decrypt failed: ' . openssl_error_string() );
return false;
}
return $plaintext;
}
}
?>
IV. Building the Gutenberg Block (React Frontend)
This section outlines the structure of the Gutenberg block. It involves creating a React component that interacts with the custom REST API endpoints. We’ll use the WordPress `@wordpress/scripts` package for building. Ensure you have Node.js and npm/yarn installed.
A. Project Setup and Dependencies
Navigate to your plugin’s directory and create a blocks subfolder. Inside blocks, run:
cd wp-content/plugins/my-secure-vault mkdir blocks cd blocks npm init -y npm install @wordpress/scripts @wordpress/components @wordpress/element @wordpress/i18n @wordpress/block-editor @wordpress/api-fetch --save
Add a build script to your blocks/package.json:
{
"name": "my-secure-vault-block",
"version": "1.0.0",
"description": "Gutenberg block for secure file vault.",
"main": "build/index.js",
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start"
},
"keywords": ["wordpress", "gutenberg", "block"],
"author": "Your Name",
"license": "GPL-2.0-or-later",
"dependencies": {
"@wordpress/api-fetch": "^6.0.0",
"@wordpress/block-editor": "^11.0.0",
"@wordpress/components": "^25.0.0",
"@wordpress/element": "^5.0.0",
"@wordpress/i18n": "^4.0.0"
},
"devDependencies": {
"@wordpress/scripts": "^26.0.0"
}
}
Create a src folder inside blocks. This will contain your React component and registration logic.
B. Block Registration and Main Component
Create blocks/src/index.js:
/**
* WordPress dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import Edit from './edit';
import save from './save';
import metadata from '../block.json'; // Assuming block.json exists
// Register the block
registerBlockType( metadata.name, {
edit: Edit,
save: save,
} );
Create blocks/block.json for block metadata:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "my-secure-vault/file-vault",
"version": "0.1.0",
"title": "Secure File Vault",
"category": "media",
"icon": "lock",
"description": "Upload, manage, and download encrypted files securely.",
"keywords": ["secure", "file", "encryption", "vault", "upload"],
"attributes": {
"files": {
"type": "array",
"default": []
}
},
"supports": {
"html": false
},
"textdomain": "my-secure-vault",
"editorScript": "file:./build/index.js",
"editorStyle": "file:./build/index.css",
"style": "file:./build/style-index.css"
}
C. The Edit Component (React)
Create blocks/src/edit.js. This component handles the UI within the Gutenberg editor.
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import {
PanelBody,
Button,
FileChooser,
Spinner,
Notice,
Table,
TableCaption,
TableCell,
TableRow,
TableBody,
TableHeader,
TableHead,
Icon,
} from '@wordpress/components';
import { Fragment, useState, useEffect } from '@wordpress/element';
import { MediaUpload, MediaUploadCheck } from '@wordpress/block-editor';
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
// No internal imports for this basic example
const Edit = ( { attributes, setAttributes } ) => {
const [ files, setFiles ] = useState( attributes.files || [] );
const [ isLoading, setIsLoading ] = useState( false );
const [ error, setError ] = useState( null );
const [ uploadProgress, setUploadProgress ] = useState( {} ); // For future enhancement
const restUrl = window.mySecureVaultSettings.restUrl;
const nonce = window.mySecureVaultSettings.nonce;
// Fetch existing files on component mount
useEffect( () => {
fetchFiles();
}, [] );
const fetchFiles = async () => {
setIsLoading( true );
setError( null );
try {
const response = await apiFetch( {
path: restUrl + 'my-secure-vault/v1/files',
method: 'GET',
} );
if ( response.success ) {
setFiles( response.files );
setAttributes( { files: response.files } ); // Update block attributes
} else {
setError( response.message || __( 'Failed to fetch files.', 'my-secure-vault' ) );
}
} catch ( e ) {
setError( e.message || __( 'An error occurred while fetching files.', 'my-secure-vault' ) );
} finally {
setIsLoading( false );
}
};
const handleFileSelect = async ( selectedFile ) => {
if ( ! selectedFile ) return;
setIsLoading( true );
setError( null );
setUploadProgress( {} ); // Reset progress
const reader = new FileReader();
reader.onload = async ( event ) => {
const fileContentBase64 = event.target.result.split( ',' )[1]; // Get base64 part
const filename = selectedFile.name;
const mimeType = selectedFile.type;
try {
const response = await apiFetch( {
path: restUrl + 'my-secure-vault/v1/upload',
method: 'POST',
headers: {
'X-WP-Nonce': nonce,