Skip to content

Commit

Permalink
feat!: support RSA SHA256 signature verification in phylum-init (#165)
Browse files Browse the repository at this point in the history
Actions taken:

* Update the `sig` module
  * Remove all minisign based signature verification
  * Add RSA SHA256 based signature verification
  * Change the function names and docstrings to be more generic
    * This will allow for future signature method implementation changes
* Raise `SystemExit` exceptions everywhere possible
  * Provide a useful error message
  * Exit with a non-zero return code
  * Do not clutter the output with a stack trace when the issue is known
* Add a functional test
* Update the unit tests

Other actions taken:

* Use the long form option for label
  * Starting with CLI v3.12.0, `--label` was added as a long form option to the `analyze` command
  * This new form is preferred since it is more descriptive
  * The minimum supported version constants were updated to reflect this
  * The version is just slightly newer than the one where openssl signatures were introduced (v3.10.0) and so this change is considered relevant and timely
* Update Azure Pipelines docs
  * The example `azure-pipelines.yml` file was missing the section for PR triggers on GitHub hosted repos
  * There was also a typo
* Format and refactor throughout

Closes #141

BREAKING CHANGE: CLI installs prior to v3.12.0 are no longer supported.
BREAKING CHANGE: CLI installs and upgrades can no longer be confirmed
with `.minisig` minisign signatures and must instead use `.signature`
RSA SHA256 based signatures.
  • Loading branch information
maxrake authored Nov 29, 2022
1 parent 35650ea commit 4fad7dd
Show file tree
Hide file tree
Showing 7 changed files with 132 additions and 143 deletions.
4 changes: 3 additions & 1 deletion docs/sync/azure_pipelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The Azure Pipelines environment is primarily supported through the use of a Dock
The pre-requisites for using this image are:

* Access to the [phylumio/phylum-ci Docker image][docker_image]
* Azure DevOps Services is used with a [Azure Repos Git][azure_repos_git] or [GitHub][github_repos] repository type
* Azure DevOps Services is used with an [Azure Repos Git][azure_repos_git] or [GitHub][github_repos] repository type
* Azure DevOps Server versions are not guaranteed to work at this time
* Bitbucket Cloud hosted repositories are not supported at this time
* An [Azure token][azure_auth] with API access, when the build repository is [Azure Repos Git][azure_repos_git]
Expand Down Expand Up @@ -93,6 +93,8 @@ Phylum analysis of dependencies can be added to existing pipelines or on it's ow
```yaml
trigger:
- main
pr:
- main

jobs:
- job: Phylum
Expand Down
4 changes: 2 additions & 2 deletions src/phylum/ci/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def ensure_project(ci_env: CIBase) -> None:
def get_phylum_analysis(ci_env: CIBase) -> dict:
"""Analyze a project lockfile from a given CI environment with the phylum CLI and return the analysis."""
# Build up the analyze command based on the provided inputs
cmd = [str(ci_env.cli_path), "analyze", "-l", ci_env.phylum_label]
cmd = [str(ci_env.cli_path), "analyze", "--label", ci_env.phylum_label]
if ci_env.phylum_project:
cmd.extend(["--project", ci_env.phylum_project])
# A group can not be specified without a project
Expand All @@ -119,7 +119,7 @@ def get_phylum_analysis(ci_env: CIBase) -> dict:
else:
print(f" [!] stdout:\n{err.stdout}")
print(f" [!] stderr:\n{err.stderr}")
raise
raise SystemExit(f" [!] {err}") from err
analysis = json.loads(analysis_result)
return analysis

Expand Down
9 changes: 4 additions & 5 deletions src/phylum/constants.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
"""Provide constants for use throughout the package."""

# This is the minimum CLI version supported for new installs.
# Linux platform support in the CLI was changed from `unknown-linux-musl` to `unknown-linux-gnu` starting with
# v3.8.0-rc2, changing the artifact names available to download and install in a non-backwards compatible manner.
MIN_CLI_VER_FOR_INSTALL = "v3.8.0-rc2"
# `--label` was added as a long form option to the analyze command starting with v3.12.0
MIN_CLI_VER_FOR_INSTALL = "v3.12.0"

# This is the minimum CLI version supported for existing installs.
# The `parse` command was added to the CLI in v3.3.0-rc1 and is relied upon to normalize packages in lockfiles.
MIN_CLI_VER_INSTALLED = "v3.3.0-rc1"
# `--label` was added as a long form option to the analyze command starting with v3.12.0
MIN_CLI_VER_INSTALLED = "v3.12.0"

# Keys are lowercase machine hardware names as returned from `uname -m`.
# Values are the mapped rustc architecture.
Expand Down
19 changes: 10 additions & 9 deletions src/phylum/init/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import requests
from packaging.utils import canonicalize_version
from packaging.version import InvalidVersion, Version
from ruamel.yaml import YAML

from phylum import __version__
from phylum.constants import (
MIN_CLI_VER_FOR_INSTALL,
Expand All @@ -24,8 +26,7 @@
TOKEN_ENVVAR_NAME,
)
from phylum.init import SCRIPT_NAME
from phylum.init.sig import verify_minisig
from ruamel.yaml import YAML
from phylum.init.sig import verify_sig


def get_phylum_settings_path():
Expand Down Expand Up @@ -123,7 +124,7 @@ def is_supported_version(version: str) -> bool:
provided_version = Version(canonicalize_version(version))
min_supported_version = Version(MIN_CLI_VER_FOR_INSTALL)
except InvalidVersion as err:
raise ValueError("An invalid version was provided") from err
raise SystemExit(f" [!] An invalid version was provided: {version}") from err

return provided_version >= min_supported_version

Expand Down Expand Up @@ -187,7 +188,7 @@ def get_target_triple():
return f"{arch}-{plat}"


def save_file_from_url(url, path):
def save_file_from_url(url: str, path: Path) -> None:
"""Save a file from a given URL to a local file path, in binary mode."""
print(f" [*] Getting {url} file ...", end="")
req = requests.get(url, timeout=REQ_TIMEOUT)
Expand Down Expand Up @@ -370,19 +371,19 @@ def main(args=None):
raise SystemExit(f" [!] The identified target triple `{target_triple}` is not supported for release {tag_name}")

archive_name = f"phylum-{target_triple}.zip"
minisig_name = f"{archive_name}.minisig"
sig_name = f"{archive_name}.signature"
archive_url = get_archive_url(tag_name, archive_name)
minisig_url = f"{archive_url}.minisig"
sig_url = f"{archive_url}.signature"

with tempfile.TemporaryDirectory() as temp_dir:
temp_dir_path = pathlib.Path(temp_dir)
archive_path = temp_dir_path / archive_name
minisig_path = temp_dir_path / minisig_name
sig_path = temp_dir_path / sig_name

save_file_from_url(archive_url, archive_path)
save_file_from_url(minisig_url, minisig_path)
save_file_from_url(sig_url, sig_path)

verify_minisig(archive_path, minisig_path)
verify_sig(archive_path, sig_path)

with zipfile.ZipFile(archive_path, mode="r") as zip_file:
if zip_file.testzip() is not None:
Expand Down
167 changes: 66 additions & 101 deletions src/phylum/init/sig.py
Original file line number Diff line number Diff line change
@@ -1,118 +1,83 @@
"""Helper functions for verifying minisign signatures.
"""Verify Phylum generated digital signatures.
This module is meant to be a quick and dirty means of verifying minisign signatures in Python.
There is no readily accessible Python library at this time. The `py-minisign` repository exists
on GitHub to attempt this - https://github.com/x13a/py-minisign - but it does not exist as a
package on PyPI and also does not appear to be actively maintained. There is a `minisign` package
on PyPI - https://pypi.org/project/minisign/ - but it comes from a different repo and has no
functionality at the time of this writing.
This module is meant to be a simple means of verifying RSA signatures in Python. It makes use of the hazardous materials
layer of the `cryptography` library, but does so in a way that closely follows the example documentation:
Short of forking the `py-minisign` repo to maintain it and publish a package from it on PyPI,
the actual format for signatures and public keys is simple and so is verifying signatures.
https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/#verification
Minisign reference: https://jedisct1.github.io/minisign/
There are a number of assumptions:
Even still, this module is NOT meant to be used as a library or general purpose minisign
signature verification. It is purpose written to specifically verify minisign signatures that
were created by Phylum. As such, it makes a number of assumptions:
* The Minisign Public Key for Phylum, Inc. will not change between releases
* The RSA Public Key for Phylum, Inc. will not change between releases
* The RSA signature was created with the SHA256 hash function
* The RSA signature was created with the PKCS1 v1.5 padding scheme
* The files to be verified were created by Phylum, Inc.
* The `.minisig` signature includes a trusted comment and will therefore contain a known number of lines
* The source of the `.minisig` signature is a trusted location, controlled by Phylum, Inc. for it's CLI releases
"""
import base64

from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
from cryptography.hazmat.primitives.asymmetric import ed25519

# This is the Minisign Public Key for Phylum, Inc. The matching private key was used to sign the software releases
PHYLUM_MINISIGN_PUBKEY = "RWT6G44ykbS8GABiLXrJrYsap7FCY77m/Jyi0fgsr/Fsy3oLwU4l0IDf"
* The source of the `.signature` files is a trusted location, controlled by Phylum, Inc. for it's CLI releases
# The format for a minisign public key is:
#
# base64(<signature_algorithm> || <key_id> || <public_key>)
#
# signature_algorithm: `Ed`
# key_id: 8 random bytes
# public_key: Ed25519 public key
PHYLUM_MINISIGN_PUBKEY_SIG_ALGO = base64.b64decode(PHYLUM_MINISIGN_PUBKEY)[:2]
PHYLUM_MINISIGN_PUBKEY_KEY_ID = base64.b64decode(PHYLUM_MINISIGN_PUBKEY)[2:10]
PHYLUM_MINISIGN_PUBKEY_ED25519 = base64.b64decode(PHYLUM_MINISIGN_PUBKEY)[10:]
If these assumptions are not met, the signature verification will fail and the CLI install will exit with a message and
a non-zero return code. The Phylum RSA public key is hard-coded in this module on purpose. It helps to limit network
calls to GitHub, which can be a source of failure. It also has the advantage of "spreading" the public key to multiple
locations so that a change to it (malicious or benign) will require access and coordination to each of those sources.
It is understood that this method is not fool proof but should help the Phylum devs identify failures.
A functional test exists to check that the hard-coded signature matches the one hosted at
https://raw.githubusercontent.com/phylum-dev/cli/main/scripts/signing-key.pub since that is where the quickstart
documentation directs CLI users.
"""
from pathlib import Path
from textwrap import dedent

def verify_minisig(file_path, sig_path):
"""Verify a given file has a valid minisign signature.
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
from cryptography.hazmat.backends.openssl import backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa, types

# This is the RSA Public Key for Phylum, Inc. The matching private key was used to sign the software releases
PHYLUM_RSA_PUBKEY = bytes(
dedent(
"""\
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyGgvuy6CWSgJuhKY8oVz
42udH1F2yIlaBoxAdQFuY2zxPSSpK9zv34B7m0JekuC5WCYfW0gS2Z8Ryu2RVdQh
7DXvQb7qwzZT0H11K9Pw8hIHBvZPM+d61GWgWDc3k/rFwMmqd+kytVZy0mVxNdv4
P2qvy6BNaiUI7yoB1ahR/6klfkPit0X7pkK9sTHwW+/WcYitTQKnEnRzA3q8EmA7
rbU/sFEypzBA3C3qNJZyKSwy47kWXhC4xXUS2NXvew4FoVU6ybMoeDApwsx1AgTu
CPPnPlCwuCIyUPezCP5XYczuHfaWeuwArlwdJFSUpMTc+SqO6REKgL9yvpqsO5Ia
sQIDAQAB
-----END PUBLIC KEY-----
"""
),
encoding="ASCII",
)


def verify_sig(file_path: Path, sig_path: Path) -> None:
"""Verify a given file has a valid signature.
`file_path` is the path to the file data to verify.
`sig_path` is the path to the `.minisig` file containing the minisign signature information.
`sig_path` is the path to the `.signature` file containing the RSA SHA256 signature information.
The public key is an assumed constant, the Minisign Public Key for Phylum, Inc.
The public key is an assumed constant, the RSA Public Key for Phylum, Inc.
"""
try:
phylum_public_key = ed25519.Ed25519PublicKey.from_public_bytes(PHYLUM_MINISIGN_PUBKEY_ED25519)
# The `cryptography` library does not know this is an RSA public key yet...make sure it is
phylum_public_key: types.PUBLIC_KEY_TYPES = serialization.load_pem_public_key(PHYLUM_RSA_PUBKEY)
if isinstance(phylum_public_key, rsa.RSAPublicKey):
phylum_rsa_public_key: rsa.RSAPublicKey = phylum_public_key
else:
raise SystemExit(f" [!] The public key was expected to be RSA but instead got: {type(phylum_public_key)}")
except UnsupportedAlgorithm as err:
raise RuntimeError("Ed25519 algorithm is not supported by the OpenSSL version `cryptography` is using") from err

signature_algorithm, key_id, signature, trusted_comment, global_signature = extract_minisig_elements(sig_path)

if signature_algorithm != b"Ed":
raise RuntimeError("Only the legacy `Ed` signature algorithm is used by Phylum currently")

if key_id != PHYLUM_MINISIGN_PUBKEY_KEY_ID:
raise RuntimeError("The `key_id` from the `.minisig` signature did not match the `key_id` from the public key")
openssl_ver = backend.openssl_version_text()
msg = f" [!] Serialized key type is not supported by the OpenSSL version `cryptography` is using: {openssl_ver}"
raise SystemExit(msg) from err
except ValueError as err:
raise SystemExit(" [!] The PEM data's structure could not be decoded successfully") from err

# Confirm the trusted comment in the sig_path with the `global_signature` there
# Confirm the data from `file_path` with the signature from the `sig_path`
try:
phylum_public_key.verify(global_signature, signature + trusted_comment)
print(f" [*] Verifying {file_path} with signature from {sig_path} ...", end="")
# NOTE: The verify method has no return value, but will raise an exception when the signature does not validate
phylum_rsa_public_key.verify(sig_path.read_bytes(), file_path.read_bytes(), padding.PKCS1v15(), hashes.SHA256())
print("SUCCESS", flush=True)
except InvalidSignature as err:
raise RuntimeError("The signature could not be verified") from err

# Confirm the data from file_path with the signature from the .minisig `sig_path`
with open(file_path, "rb") as f:
file_data = f.read()
try:
phylum_public_key.verify(signature, file_data)
except InvalidSignature as err:
raise RuntimeError("The signature could not be verified") from err


def extract_minisig_elements(sig_path):
"""Extract the elements from a given minisig signature file and return them."""
# The format for a minisign signature is:
#
# untrusted comment: <arbitrary text>
# base64(<signature_algorithm> || <key_id> || <signature>)
# trusted_comment: <arbitrary text>
# base64(<global_signature>)
#
# where each line above represents a line from the `.minisig` file and the elements are defined as:
#
# signature_algorithm: `Ed` (legacy) or `ED` (hashed)
# key_id: 8 random bytes, matching the public key
# signature (legacy): ed25519(<file data>)
# signature (prehashed): ed25519(Blake2b-512(<file data>))
# global_signature: ed25519(<signature> || <trusted_comment>)
trusted_comment_prefix = "trusted comment: "
trusted_comment_prefix_len = len(trusted_comment_prefix)
ed25519_signature_len = 64

with open(sig_path, "rb") as f:
lines = f.read().splitlines()
if len(lines) not in (4, 5):
raise RuntimeError("The .minisig file format expects 4 lines, with an optional blank 5th line")

decoded_sig_line = base64.b64decode(lines[1])
signature_algorithm = decoded_sig_line[:2]
key_id = decoded_sig_line[2:10]
signature = decoded_sig_line[10:]
if len(signature) != ed25519_signature_len:
raise RuntimeError(f"The decoded signature was not {ed25519_signature_len} bytes long")

trusted_comment = lines[2][trusted_comment_prefix_len:]

global_signature = base64.b64decode(lines[3])
if len(global_signature) != ed25519_signature_len:
raise RuntimeError(f"The global signature was not {ed25519_signature_len} bytes long")

return signature_algorithm, key_id, signature, trusted_comment, global_signature
print("FAIL", flush=True)
raise SystemExit(" [!] The signature could not be verified and may be invalid") from err
12 changes: 11 additions & 1 deletion tests/functional/test_init.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
""""Test the phylum-init command line interface (CLI)."""
import subprocess
import sys
from pathlib import Path

from phylum import __version__
from phylum.init import SCRIPT_NAME
from phylum.init import SCRIPT_NAME, sig
from phylum.init.cli import save_file_from_url

from ..constants import PYPROJECT

Expand Down Expand Up @@ -37,3 +39,11 @@ def test_version_option():
assert not ret.stderr, "Nothing should be written to stderr"
assert ret.returncode == 0, "A non-successful return code was provided"
assert ret.stdout == expected_output, "Output did not match expected input"


def test_phylum_pubkey_is_constant(tmp_path):
"""Ensure the RSA public key in use by Phylum has not changed."""
phylum_pubkey_url = "https://raw.githubusercontent.com/phylum-dev/cli/main/scripts/signing-key.pub"
downloaded_key_path: Path = tmp_path / "signing-key.pub"
save_file_from_url(phylum_pubkey_url, downloaded_key_path)
assert downloaded_key_path.read_bytes() == sig.PHYLUM_RSA_PUBKEY, "The key should not be changing"
60 changes: 36 additions & 24 deletions tests/unit/test_sig.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,40 @@
"""Test the minisign signature verification module."""
from phylum.init import sig


def test_phylum_minisign_pubkey():
"""Ensure the minisign public key in use by Phylum has not changed."""
expected_key = "RWT6G44ykbS8GABiLXrJrYsap7FCY77m/Jyi0fgsr/Fsy3oLwU4l0IDf"
assert sig.PHYLUM_MINISIGN_PUBKEY == expected_key, "The key should not be changing"
"""Test the signature verification module."""
from textwrap import dedent

from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import load_pem_public_key

def test_phylum_pubkey_sig_algo():
"""Ensure the Phylum minisign public key signature algorithm is `Ed` (legacy)."""
assert isinstance(sig.PHYLUM_MINISIGN_PUBKEY_SIG_ALGO, bytes)
assert sig.PHYLUM_MINISIGN_PUBKEY_SIG_ALGO == b"Ed", "Only the legacy `Ed` signature is used by Phylum currently"


def test_phylum_pubkey_key_id():
"""Ensure the Phylum minisign public key `key_id` has not changed."""
expected_key_id = b"\xfa\x1b\x8e2\x91\xb4\xbc\x18"
assert isinstance(sig.PHYLUM_MINISIGN_PUBKEY_KEY_ID, bytes)
assert sig.PHYLUM_MINISIGN_PUBKEY_KEY_ID == expected_key_id, "The key ID should not be changing"
from phylum.init import sig


def test_phylum_ed25519_pubkey():
"""Ensure the Phylum minisign Ed25519 public key has not changed."""
expected_key = b"\x00b-z\xc9\xad\x8b\x1a\xa7\xb1Bc\xbe\xe6\xfc\x9c\xa2\xd1\xf8,\xaf\xf1l\xcbz\x0b\xc1N%\xd0\x80\xdf"
assert isinstance(sig.PHYLUM_MINISIGN_PUBKEY_ED25519, bytes)
assert sig.PHYLUM_MINISIGN_PUBKEY_ED25519 == expected_key, "The Ed25519 public key should not be changing"
def test_phylum_pubkey_is_bytes():
"""Ensure the RSA public key in use for the rest of these tests is a bytes object."""
assert isinstance(sig.PHYLUM_RSA_PUBKEY, bytes), "The RSA public key should be in bytes format"


def test_phylum_pubkey_is_constant():
"""Ensure the RSA public key in use by Phylum has not changed."""
expected_key = bytes(
dedent(
"""\
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyGgvuy6CWSgJuhKY8oVz
42udH1F2yIlaBoxAdQFuY2zxPSSpK9zv34B7m0JekuC5WCYfW0gS2Z8Ryu2RVdQh
7DXvQb7qwzZT0H11K9Pw8hIHBvZPM+d61GWgWDc3k/rFwMmqd+kytVZy0mVxNdv4
P2qvy6BNaiUI7yoB1ahR/6klfkPit0X7pkK9sTHwW+/WcYitTQKnEnRzA3q8EmA7
rbU/sFEypzBA3C3qNJZyKSwy47kWXhC4xXUS2NXvew4FoVU6ybMoeDApwsx1AgTu
CPPnPlCwuCIyUPezCP5XYczuHfaWeuwArlwdJFSUpMTc+SqO6REKgL9yvpqsO5Ia
sQIDAQAB
-----END PUBLIC KEY-----
"""
),
encoding="ASCII",
)
assert sig.PHYLUM_RSA_PUBKEY == expected_key, "The key should not be changing"


def test_phylum_pubkey_is_rsa():
"""Ensure the public key in use by Phylum is in fact a 2048 bit RSA key."""
key = load_pem_public_key(sig.PHYLUM_RSA_PUBKEY)
assert isinstance(key, rsa.RSAPublicKey)
assert key.key_size == 2048

0 comments on commit 4fad7dd

Please sign in to comment.