diff --git a/README.md b/README.md index 06e3f98..35fc4dc 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,29 @@ Encrypt and decrypt files with age from PHP. This library is only a wrapper for the the command line tool, it does not implement the C2CP age specification in PHP. ```php +// Encrypt a file with a generated key $age = new FileEncryption("hello.txt"); -$age->encrypt("hello.txt.age", "hello.txt.age.key"); +$keypair = $age->keygen("hello.key")->encrypt("hello.txt.age"); + +// Encrypt a file with a specific public key +$age->public_key("age1mrf8uana2kan6jsrnf04ywxycvl4nnkzzk3et8rdz6fe6vg7upssclnak7")->encrypt("hello.txt.age"); ``` ```php +// Decrypt a file with a key file $age = new FileEncryption("hello.txt.age"); -$age->decrypt("hello.txt.age.key", "hello-decrypted.txt"); +$age->private_key("hello.key")->decrypt("decrypted-hello.txt"); ``` -## Installation +# Installation +This library requires PHP 8.1+ and the [age command line tool](https://github.com/FiloSottile/age). + 1. [Install the age command line tool](https://github.com/FiloSottile/age#installation) 2. Install this library with composer ``` composer require victorwesterlund/php-age ``` -3. Import and use in your project -## How to use +# How to use Import and use the library: ```php require_once "vendor/autoload.php"; @@ -26,27 +32,48 @@ require_once "vendor/autoload.php"; use \Age\FileEncryption; ``` -### Encrypt a file +## Encrypt a file Encrypt a file on disk by passing it to the `FileEncryption` constructor ```php // Relative or absolute path to a file that should be encrypted $age = new FileEncryption("hello.txt"); -// Encrypted file destination and private key destination -// This method also returns the private key as a string -$age->encrypt("hello.txt.age", "hello.txt.age.key"); ``` -You can enable optional PEM encoding by chaining `armor()` before `encrypt()` +> **Note** +> The library will not archive a folder for you. You'll have to `tar` your folder before passing it to `FileEncryption` + +### Generated key pair +You can encrypt a file with a generated key pair (`age-keygen`) by chaining `keygen()` ```php -// Encrypt with PEM encoding -$age->armor()->encrypt("hello.txt.age", "hello.txt.age.key"); +// encrypt() will return the generated keypair as an assoc array +$keypair = $age->keygen()->encrypt("hello.txt.age"); // ["public" => "...", "private" => "..."] ``` -### Decrypt a file +You can also save the generated key file to disk by passing an absolute or relative path to `keygen()` +```php +$keypair = $age->keygen("hello.key)->encrypt("hello.txt.age"); // ["public" => "...", "private" => "..."] +``` + +### Existing public key +Encrypt a file with an existing public key by chaining the `public_key()` method +```php +$keypair = $age->public_key("age1mrf8uana2kan6jsrnf04ywxycvl4nnkzzk3et8rdz6fe6vg7upssclnak7")->encrypt("hello.txt.age"); // ["public" => "age1mrf8uana2kan6jsrnf04ywxycvl4nnkzzk3et8rdz6fe6vg7upssclnak7", "private" => null] +``` + +## Decrypt a file Decrypt a file on disk by passing it to the `FileEncryption` constructor ```php // Relative or absolute path to a file that should be decrypted $age = new FileEncryption("hello.txt.age"); -// Private key file and destination of decrypted file -$age->decrypt("hello.txt.age.key", "hello.txt"); +``` +Chain `private_key()` with an absolute or relative path to the corresponding key file +``` +$age->private_key("hello.key")->decrypt("decrypted-hello.txt"); // true +``` + +## Optional features + +Enable PEM encoding by chaining the optional `armor()` method +``` +$keypair = $age->armor()->keygen()->encrypt("hello.txt.age"); ``` diff --git a/src/FileEncryption.php b/src/FileEncryption.php index c49e467..ddda4ea 100644 --- a/src/FileEncryption.php +++ b/src/FileEncryption.php @@ -10,7 +10,8 @@ class FileEncryption { private string $input; - // Enable or disable --armor flag + private ?string $public_key = null; + private ?string $private_key = null; private bool $armor = false; public function __construct(mixed $input) { @@ -28,10 +29,18 @@ /* ---- */ // Execute a shell command from enum of allowed commands - private static function exec(ShellCommands $cmd, string $args = ""): string|null { + private static function exec(ShellCommands $cmd, string $args = ""): ?string { return shell_exec("{$cmd->value} {$args} 2>&1"); } + // File is readable or throw + private static function is_readable_or_throw(string $path): true { + if (!is_file($path) || !is_readable($path)) { + throw new \Exception("Input file '{$path}' is not readable for current user"); + } + return true; + } + // Parent directory of $output is writeable or throw private static function is_writable_or_throw(string $path): true { if (!is_writable(dirname($path))) { @@ -40,20 +49,23 @@ return true; } - // Generate asymmetric key pair - private static function keygen(): array { - // Generate age key pair - $keygen = explode(PHP_EOL, self::exec(ShellCommands::KEYGEN)); - // Remove first line of output - array_shift($keygen); + // Parse and extract public key from age-keygen output + private static function parse_public_key(string|array $input): string { + // Split age-keygen output by line + $lines = !is_array($input) ? explode(PHP_EOL, $input, 3) : $input; - // Return asymmetric key pair strings as assoc array - return [ - // Extract public key from second line of age-keygen output - "public" => substr($keygen[1], 14), - // Return generated key pair - "private" => implode(PHP_EOL, $keygen) - ]; + // Public key will be on second line if we received an age-keygen string + // Otherwise we will assume we got the public key string directly + $public_key = $lines[count($lines) > 0 ? 1 : 0]; + // Strip prefix if present + return str_replace("# public key: ", "", $public_key); + } + + private function parse_private_key(string|array $input): string { + // Split age-keygen output by line + $lines = !is_array($input) ? explode(PHP_EOL, $input, 3) : $input; + // Private key will be on the 3rd line of an age-keygen output + return $lines[2]; } /* ---- */ @@ -64,17 +76,63 @@ return $this; } - // Decrypt a file using a provided private key string and output file name - public function decrypt(string $key_file, string $output): true { - $this->is_writable_or_throw($output); + public function public_key(string $input): self { + // Get public key from age-keygen file + if (is_file($input)) { + $this->is_readable_or_throw($input); - // Throw if private key file is not readable - if (!is_readable($key_file)) { - throw new \Exception("Failed to open private key file '{$key_file}'"); + $input = self::parse_public_key(file_get_contents($input)); } + + $this->public_key = $input; + return $this; + } + + public function private_key(string $input): self { + $this->is_readable_or_throw($input); + + $this->private_key = $input; + return $this; + } + + // Generate asymmetric key pair and optionally write to file + public function keygen(?string $output = null): self { + // Generate age key pair + $keygen = explode(PHP_EOL, self::exec(ShellCommands::KEYGEN)); + // Remove first line of output + array_shift($keygen); + + // Set global key properties + $this->public_key(self::parse_public_key($keygen)); + $this->private_key = implode(PHP_EOL, $keygen); + + // Write generated key pair to file + if ($output) { + $this->is_writable_or_throw($output); + + file_put_contents($output, implode(PHP_EOL, $keygen)); + } + + return $this; + } + + /* ---- */ + + // Return key pair as assoc array + public function get_keypair(): array { + return [ + "public" => $this->public_key, + "private" => $this->private_key + ]; + } + + // Decrypt a file using a provided private key string and output file name + public function decrypt(string $output): true { + $this->is_writable_or_throw($output); - // Attempt to decrypt file using private key file - $decrypt = $this->exec(ShellCommands::AGE, "--decrypt -i {$key_file} -o {$output} {$this->input}"); + // Decrypt file using private key file + $cmd = "--decrypt -i {$this->private_key} -o {$output} {$this->input}"; + $decrypt = $this->exec(ShellCommands::AGE, $cmd); // Decryption failed if (!is_null($decrypt)) { @@ -85,31 +143,20 @@ } // Encrypt a file and return its private key string - public function encrypt(string $output, string|null $output_key = null): string|false { + public function encrypt(string $output): array { $this->is_writable_or_throw($output); - // Generate asymmetric keypair - $key = $this->keygen(); - // Add --armor string if PEM encoding is enabled + // Add --armor flag if PEM encoding is enabled $armor = $this->armor ? "--armor" : ""; - // Encrypt file to output using age - $encrypt = $this->exec(ShellCommands::AGE, "--encrypt -r {$key["public"]} {$armor} -o {$output} {$this->input}"); + $cmd = "--encrypt -r {$this->public_key} {$armor} -o {$output} {$this->input}"; + $encrypt = $this->exec(ShellCommands::AGE, $cmd); - // Write private key to file - if (is_null($encrypt) && $output_key) { - $this->is_writable_or_throw($output_key); - - // Write private key to file - file_put_contents($output_key, $key["private"]); - - // Throw if private key file could not be created - if (!is_file($output_key)) { - throw new \Exception("Failed to write private key file to '{$output_key}'"); - } + if (!is_null($encrypt)) { + throw new \Exception("Failed to encrypt '{$this->input}' using public key '{$this->public_key}'"); } - // Return private key if $encrypt returns null - return is_null($encrypt) ? $key["private"] : false; + // Return keypair + return $this->get_keypair(); } } \ No newline at end of file