PHAR signing best practices¶
There is two idiomatic ways to secure a PHAR:
- Using the built-in PHAR signing API (not recommended; read Why it is bad for the why).
- Signing the PHAR as any other generic binary (see Sign your PHAR).
This doc entry goal is to show why the first method is to be avoided and how to do it "the right way".
Built-in PHAR API¶
How to sign your PHAR¶
This is how a PHAR can be signed:
// See https://www.php.net/manual/en/phar.setsignaturealgorithm.php
$phar->setSignatureAlgorithm($algo, $privateKey);
There is various algorithm available. The most "secure" one would be Phar::OPENSSL
with an
OpenSSL private key. For instance:
openssl genrsa -des3 -out acme-phar-private.pem 4096
// E.g. $privateKeyPath = 'acme-phar-private.pem' with the example above
$privateKey = file_get_contents($privateKeyPath);
$resource = openssl_pkey_get_private($key, $privateKeyPassword);
openssl_pkey_export($resource, $private);
$details = openssl_pkey_get_details($resource);
$phar->setSignatureAlgorithm(Phar::OPENSSL, $private);
file_put_contents(
$phar->getPath().'.pubkey',
$details['key'],
);
With the example above, you will end up with two files: your PHAR (e.g. bin/command.phar
) and its
public key (e.g. bin/command.phar.pubkey
).
How it works¶
To give more background on how PHAR archives are constructed, they are PHP files that contain a mixture of code and other binary data. It is analogous to the JAR files, and will require PHP to execute the PHARs. The content of a PHAR is as follows:
- A stub: a piece of code that handles the extraction of resources.
- A binary manifest which allows the interpreter to understand the file structure of the embedded contents that follow.
- The actual content of the archive.
- The signature makes up the last section of the file. It is a 4-byte signature flag which tells what signature type was used and then another 4-byte constant marks the file as having a signature.
When PHP later reads the archive, it can determine the signature and type by reading the end of the archive. This way, if the content of the PHAR has been tempered with, e.g. code was injected in the archive, PHP will see that the content of the archive does not match the signature of the PHAR and will bail out.
Why it is bad¶
There is a few downsides from this signing mechanisms:
- You cannot run the PHAR without its associated public key file laying right next to it. As a result, if you were to
move your PHAR under
/usr/local/bin
, the PHAR would no longer work due to the missing public key file. - OpenSSL keys do not contain any identity information. So unless cleanly separated at distribution time, nobody knows where the pub key came from or who generated it. Which (almost) kills the very idea of signing things.
The real problem is the signature check itself. If the PHAR gets corrupted, maybe the signature got corrupted too. So there is ways to void the signature:
- Injects code before the stub, then this code will be executed before the signature check. The signature check can still fail if the signature was not adjusted, but this might be too late.
- Replace the signature used. An OpenSSL one will only make it slightly harder as this requires to change an external file (the public key), but in the context the attacker could inject code to the PHAR this is unlikely to be a real prevention measure.
- The entire signature check can be disabled via the PHP ini setting
phar.require_hash
.
So to conclude, this security mechanism CANNOT prevent modifications of the archive itself. It is NOT a reliable protection measure. It is merely a measure to prevent accidentally running a corrupted PHAR.
The good news, there is a solution.
How to (properly) sign your PHAR¶
Create a new GPG-key¶
The first step is to create a new GPG-key. You can either do that via a GUI or via the CLI like this:
gpg --gen-key
It will ask for some questions. It is recommended to use a passphrase (ideally generated and managed by a reputable password manager). In the end, you will end up with something like this:
# $ gpg --gen-key output
pub ed25519 2023-10-21 [SC] [expires: 2026-10-20]
96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08
uid Théo Fidry <theo.fidry+phar-signing-example@example.com>
sub cv25519 2023-10-21 [E] [expires: 2026-10-20]
In this case the interesting part is 96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08
which is the key ID. You can also check
the list of your GPG keys like so:
gpg --list-secret-keys --keyid-format=long
#
# Other keys displayed too
#
sec ed25519/03B2F4DF7A20DF08 2023-10-21 [SC] [expires: 2026-10-20]
96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08
uid [ultimate] Théo Fidry <theo.fidry+phar-signing-example@example.com>
ssb cv25519/765C0E3CCBC7D7D3 2023-10-21 [E] [expires: 2026-10-20]
Like above, you see the key ID 96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08
.
To make the key accessible for others we should now send it to a keyserver1.
gpg --keyserver keys.openpgp.org --send-key 96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08
You can also already generate a revocation certificate for the key. Should the key be compromised you can then send the revocation certificate to the keyserver to invalidate the signing key.
gpg --output revoke-96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08.asc --gen-revoke 96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08
This will leave you with a revocation certificate in the file revoke-96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08.asc
which can be added to your password manager.
Manually signing¶
For manually signing your PHAR (or any file actually), you will need to have an key containing both your public and private GPG key.
Generate the encryption key¶
In order to use the key to encrypt files, you need to first export it:
gpg --export --armor 96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08 > keys.asc
gpg --export-secret-key --armor 96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08 >> keys.asc
Warning
That will leave the public and private key in a single file. Anyone that has that file can sign on your behalf! So keep that file secure at all times and make sure it never accidentally shows up in your git repository.
Secure your encryption key¶
If your goal is to save this encryption key somewhere, for example your repository, you should first encrypt it:
gpg --symmetric keys.asc
This will ask for a second passphrase. It is recommended to pick a different passphrase than for the key itself and ideally one generated and managed by a password manager.
This leaves you with a file keys.asc.gpg
. You can add this one to the repository and at this point you are probably
better off deleting the keys.asc
file. In order to do the actual signing, you will have to decrypt it again, but
it is better to not keep that decrypted key around.
Sign your PHAR¶
You first need to encrypt keys.asc.gpg
into keys.asc
:
# If you are locally:
gpg keys.asc.gpg
# In another environment: CI or other. You should use an environment variable
# or a temporary file to avoid printing the password in clear text.
echo $DECRYPT_KEY_PASSPHRASE | gpg --passphrase-fd 0 keys.asc.gpg
# or:
cat $(.decrypt-key-passphrase) | gpg --passphrase-fd 0 keys.asc.gpg
Import the decrypted key if it is not already present on the machine:
gpg --batch --yes --import keys.asc
Sign your file:
gpg \
--batch \
--passphrase="$GPG_KEY_96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08_PASSPHRASE" \
--local-user 96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08 \
--armor \
--detach-sign \
bin/command.phar
# Do not forget to remove keys.asc afterwards!
You will now have a file bin/command.phar.asc
.
When publishing your archive, you should publish both bin/command.phar
and bin/command.phar.asc
.
Verifying the PHAR signature¶
First you should check the issuer's identity, usually it is provided from where you download it as part of the documentation:
# If you are on the same machine as where you created the key, then this step is unnecessary.
# You will need this however for when verifying a different key that you do not know of yet.
gpg --keyserver hkps://keys.openpgp.org --recv-keys 96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08
However not everyone exposes what is their GPG key ID. So sometimes to avoid bad surprises, you
can look up for similar issuers to the key ID given by the .asc
:
# Verify the signature
gpg --verify bin/command.phar.asc bin/command.phar
# Example of output:
gpg: Signature made Sat 21 Oct 16:58:05 2023 CEST
gpg: using EDDSA key 96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08
gpg: Good signature from "Théo Fidry <theo.fidry+phar-signing-example@example.com>" [ultimate]
If the key ID was not provided before, you can try to look it up to check it was properly registered to a keyserver:
gpg --keyserver https://keys.openpgp.org --search-keys "theo.fidry+phar-signing-example@example.com"
Info
Also note that when dealing with PHARs, the above steps are automatically done for you by PHIVE.
Automatically sign in GitHub Actions¶
The first step is to add environment secrets to your repository:
gpg --export-secret-key --armor 96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08
# Paste the content into a secret environment variable
GPG_KEY_96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08
# Add the corresponding passphrase enviroment variable:
GPG_KEY_96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08_PASSPHRASE
Then you need to:
- Build your PHAR
- Import the GPG key
- Sign your PHAR
- Publish your PHAR
I highly recommend to build your PHAR as part of your regular workflows. Then the other steps can be enable on release only. The following is an example of GitHub workflow:
# .github/workflows/release.yaml
name: Release
on:
push:
branches: [ main ]
pull_request: ~
schedule:
# Do not make it the first of the month and/or midnight since it is a very busy time
- cron: "* 10 6 * *"
release:
types: [ created ]
# See https://stackoverflow.com/a/72408109
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-phar:
runs-on: ubuntu-latest
name: Build PHAR
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
ini-values: phar.readonly=0
tools: composer
coverage: none
- name: Install Composer dependencies
uses: ramsey/composer-install@v2
- name: Build PHAR
run: ...
# Smoke test.
# It is recommended ot have some sorts of tests for your PHAR.
- name: Ensure the PHAR works
run: bin/command.phar --version
# The following section is done only for releases
- name: Import GPG key
if: github.event_name == 'release'
uses: crazy-max/ghaction-import-gpg@v5
with:
gpg_private_key: ${{ secrets.GPG_KEY_96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08 }}
passphrase: ${{ secrets.GPG_KEY_96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08_PASSPHRASE }}
- name: Sign the PHAR
if: github.event_name == 'release'
run: |
gpg --local-user 96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08 \
--batch \
--yes \
--passphrase="${{ secrets.GPG_KEY_96C8013A3CC293C465EE3FBB03B2F4DF7A20DF08_PASSPHRASE }}" \
--detach-sign \
--output bin/command.phar.asc \
bin/command.phar
- name: Upload PHAR to the release
uses: softprops/action-gh-release@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
files: |
box.phar
box.phar.asc
A more complete real-life example can be found in the Box release workflow.
« Reproducible build • FAQ »
Credits:
- Andreas Heigl, January 19, 2017, Encrypt a build-result – automaticaly
- Arne Blankerts
- Jeff Channell, July 13, 2017, Code Injection in Signed PHP Archives (Phar)
-
There is several OpenPGP Keyservers. It is recommended to push your keys to keys.openpgp.org at least, but you can also push it to other servers if you wish to. ↩