skip to Main Content

Greetings fellow devs.

Please note that some information has to be withheld due to privacy and NDA.

We have a client that uses a PGP Public Key that I shared with them to encrypt GDPR sensitive data and in return, they send us the encrypted file. The idea is to allow our direct reports to upload the encrypted file via an HTML/AJAX/PHP SPA that does various validation checks then decrypts the file and finally allows the user to download the decrypted file.

As expected, decrypting the file via terminal works flawlessly with a command such as:

gpg --decrypt encrypted_file.ext > decrypted_file.ext

As you’re all well aware, it’s at this point you’d be prompted for the passphrase which, once entered, would output the decrypted file flawlessly.

Now, I created an SPA web interface that allows the encrypted file to be uploaded, undergoes decryption, and upon the AJAX response, an object is returned containing the path to a file named download.php which receives a URL parameter which is an encoded string containing both encrypted and decrypted filenames.

No matter what I have tried, the common error is that the decrypted version of the file has a zero length and obviously means the decryption process failed.

I have tried various approaches with my latest being using inotify-tools to watch the upload target directory to do the following:

  • chown the encrypted file from www-data to the system user used to create the keys;

  • run the following command to decrypt the uploaded file:

    "echo 'passphrase' | gpg --batch --quiet --yes --passphrase-fd 0 --decrypt $encryptedFilename > " . $decryptedFilename;

  • then chown the decrypted file from system user to www-data for safe download, and finally;

  • the download.php would make use of the Content-Disposition header to force the decrypted file download;

The decrypted file always has zero length as I mentioned before meaning the decryption failed. Unfortunately, there are no errors to speak of and hence, my reaching out for any suggestions.

Here is my upload validation class to manage the usual upload irritations:

<?php

class UploadValidationProd
{
    protected $uploadedForm;
    protected $buttonName;
    protected $uploadedFile;
    protected $inputFileName;
    protected $maximumFileSize;
    protected $validated;

    /**
     * @param $post
     * @param $buttonName
     * @param $files
     * @param $inputFileName
     */
    public function __construct($post, $buttonName, $files, $inputFileName)
    {
        $this->uploadedForm = $post;
        $this->buttonName = $buttonName;
        $this->uploadedFile = $files;
        $this->inputFileName = $inputFileName;
        $this->maximumFileSize = 20000000;
        $this->validated = FALSE;
    }

    /**
     * @return bool
     */
    public function CheckFormPosted(): bool
    {
        if (isset($this->uploadedForm[$this->buttonName])) {
            return TRUE;
        }
        return FALSE;
    }

    /**
     * @return bool
     */
    public function CheckFilePosted(): bool
    {
        if (!empty($this->uploadedFile[$this->inputFileName]['name'])) {
            return TRUE;
        }
        return FALSE;
    }

    /**
     * @return bool
     */
    public function CheckFileTypeAllowed(): bool
    {
        $allowedTypes = [
            'csv',
            'xls',
            'xlsx'
        ];
        $fileType = strtolower(pathinfo($this->uploadedFile[$this->inputFileName]['name'], PATHINFO_EXTENSION));
        if (!in_array($fileType, $allowedTypes)) {
            return FALSE;
        }
        return TRUE;
    }

    /**
     * @return bool
     */
    public function CheckFileSize(): bool
    {
        if ($this->uploadedFile[$this->inputFileName]['size'] > $this->maximumFileSize) {
            return FALSE;
        }
        return TRUE;
    }

    /**
     * @return array
     */
    public function DoValidation(): array
    {
        $checkFormPosted = $this->CheckFormPosted();
        if ($checkFormPosted) {
            $checkFilePosted = $this->CheckFilePosted();
            $checkFileTypeAllowed = $this->CheckFileTypeAllowed();
            $checkFileSize = $this->CheckFileSize();
            if (!$checkFilePosted) {
                return [
                    'result' => 'error',
                    'message' => 'No file was selected'
                ];
            }

            if (!$checkFileTypeAllowed) {
                return [
                    'result' => 'error',
                    'message' => 'The selected file type is not allowed'
                ];
            }

            if (!$checkFileSize) {
                return [
                    'result' => 'error',
                    'message' => 'The selected file size exceeds the maximum allowed size (' . $this->maximumFileSize / 1000000 . 'MB)'
                ];
            }

            if (move_uploaded_file($this->uploadedFile[$this->inputFileName]['tmp_name'], $this->uploadedFile[$this->inputFileName]['name'])) {
                return [
                    'result' => 'success',
                    'message' => 'The selected file was successfully uploaded'
                ];
            }
        }
        return [
            'result' => NULL,
            'message' => NULL
        ];
    }
}

Here is the decryption class:

<?php

class UploadDecryptionProd
{
    protected $encryptedFile;
    protected $decryptedFile;
    protected $pgpPassphrase;

    /**
     * @param $encryptedFile
     */
    public function __construct($encryptedFile)
    {
        $this->encryptedFile = $encryptedFile;
        $this->decryptedFile = 'decrypted_' . $encryptedFile;
        $this->pgpPassphrase = 'theOriginalPassphrase';
    }

    /**
     * @return array
     */
    public function Decrypt(): array
    {
        $cmd = "echo '$this->pgpPassphrase' | gpg --batch --quiet --yes --passphrase-fd 0 --decrypt $this->encryptedFile > " . $this->decryptedFile;
        $result = 0;
        system($cmd, $result);
        if ($result == '0') {
            return [
                'result' => 'success',
                'message' => $this->decryptedFile
            ];
        } else {
            return [
                'result' => 'error',
                'message' => $this->encryptedFile . ' could not be decrypted'
            ];
        }
    }
}

If anyone can point me in the right direction, a helpful hint or maybe even a "Oh hey – you just need to add a couple of eggs and sing to the sun", I would be most obliged.

Thanks very much everyone 🙂

2

Answers


  1. Chosen as BEST ANSWER

    I have finally found a solution that works.

    My OS is Raspbian Bullseye (Debian Bullseye basically)

    I copied the default user's keyring to Apache's www-data directory:

    cp -a ~/.gnupg/. /var/www/.gnupg/

    Gave ownership of the copied keyring to www-data:

    sudo chown -R www-data:www-data

    Changed permissions on the copied keyring:

    sudo find /var/www/.gnupg -type f -exec chmod 600 {} ;

    sudo find /var/www/.gnupg -type d -exec chmod 700 {} ;

    Then finally, this is the PHP code:

    <?php
    putenv('GNUPGHOME=/var/www/.gnupg');
    $passphrase = 'passphrase';
    $pgpDir = '/var/www/pgp/';
    $encryptedFile = 'encrypted_20220624.csv';
    $decryptedFile = 'decrypted_' . $encryptedFile;
    $descriptorspec = array(
        0 => array("pipe", "r"),
        1 => array("pipe", "w"),
        2 => array("pipe", "w"),
        3 => array("pipe", "r"),
    );
    
    $pipes = false;
    $command = "echo '$passphrase' | gpg --batch --quiet --yes --pinentry-mode loopback --passphrase-fd 0 --decrypt " . $pgpDir . $encryptedFile . " > " . $pgpDir . $decryptedFile;
    $process = proc_open($command, $descriptorspec, $pipes);
    
    if(is_resource($process)) {
        fwrite($pipes[3], $passphrase);
        fclose($pipes[3]);
    
        fwrite($pipes[0], $decryptedFile);
        fclose($pipes[0]);
    
        $output = stream_get_contents($pipes[1]);
        $stderr = stream_get_contents($pipes[2]);
    
        fclose($pipes[1]);
        fclose($pipes[2]);
    
        $retval = proc_close($process);
    
        echo "retval = $retvaln";
        echo "output= $outputn";
        echo "err= $stderrn";
    }
    

    And the output on the web page:

    retval = 0
    output= 
    err= gpg: Signature made Wed Jun 22 19:12:12 2022 SAST
    gpg:                using RSA key XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
    gpg: Good signature from "William Irwin <[email protected]>" [ultimate]
    

    I hope this helps someone.


  2. I’m not sure but it looks like a bug:

    "echo '$this->pgpPassphrase' | gpg --batch --quiet --yes --passphrase-fd 0 --decrypt $this->encryptedFile > " . $this->decryptedFile
    

    Maybe

    "echo '".$this->pgpPassphrase."' | gpg --batch --quiet --yes --passphrase-fd 0 --decrypt ".$this->encryptedFile." > ".$this->decryptedFile
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search