diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f37681536..b05338d44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: - python-version: 3.8 os: ubuntu-latest toxenv: lint + - python-version: 3.x + os: ubuntu-latest + toxenv: kms runs-on: ${{ matrix.os }} @@ -54,6 +57,15 @@ jobs: # A match with 'restore-keys' is used as fallback restore-keys: ${{ runner.os }}-pip- + - name: 'Authenticate to Google Cloud' + # Authenticate to GCP KMS, but only if we're running KMS tests + if: ${{ matrix.toxenv == 'kms' }} + uses: 'google-github-actions/auth@c4799db9111fba4461e9f9da8732e5057b394f72' + with: + token_format: 'access_token' + workload_identity_provider: 'projects/843741030650/locations/global/workloadIdentityPools/securesystemslib-tests/providers/securesystemslib-tests' + service_account: 'securesystemslib-tests@python-tuf-kms.iam.gserviceaccount.com' + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -61,4 +73,3 @@ jobs: - name: Run tox run: tox -e ${{ matrix.toxenv }} - diff --git a/.github/workflows/test-gcp-kms.yml b/.github/workflows/test-gcp-kms.yml new file mode 100644 index 000000000..ffaa30892 --- /dev/null +++ b/.github/workflows/test-gcp-kms.yml @@ -0,0 +1,29 @@ + +name: Test GCP KMS + +on: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-20.04 + permissions: + id-token: 'write' + steps: + - uses: 'actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8' + - name: 'Authenticate to Google Cloud' + id: 'auth' + uses: 'google-github-actions/auth@c4799db9111fba4461e9f9da8732e5057b394f72' + with: + token_format: 'access_token' + workload_identity_provider: 'projects/843741030650/locations/global/workloadIdentityPools/securesystemslib-tests/providers/securesystemslib-tests' + service_account: 'securesystemslib-tests@python-tuf-kms.iam.gserviceaccount.com' + + - name: 'Install google-cloud-kms' + run: pip install google-cloud-kms + + - name: 'Sign with KMSSigner' + run: | + python3 test-signer.py + + diff --git a/requirements-kms.txt b/requirements-kms.txt new file mode 100644 index 000000000..78d6fb9f0 --- /dev/null +++ b/requirements-kms.txt @@ -0,0 +1 @@ +google-cloud-kms diff --git a/securesystemslib/signer.py b/securesystemslib/signer.py index 4b9e6c680..c1500ceb4 100644 --- a/securesystemslib/signer.py +++ b/securesystemslib/signer.py @@ -6,10 +6,23 @@ """ import abc +import logging from typing import Any, Dict, Mapping, Optional import securesystemslib.gpg.functions as gpg +import securesystemslib.hash as sslib_hash import securesystemslib.keys as sslib_keys +from securesystemslib import exceptions + +logger = logging.getLogger(__name__) + +GCP_IMPORT_ERROR = None +try: + from google.cloud import kms +except ImportError: + GCP_IMPORT_ERROR = ( + "google-cloud-kms library required to sign with Google Cloud keys." + ) class Signature: @@ -266,3 +279,36 @@ def sign(self, payload: bytes) -> GPGSignature: sig_dict = gpg.create_signature(payload, self.keyid, self.homedir) return GPGSignature(**sig_dict) + + +class GCPSigner(Signer): + """Google Cloud KMS Signer + + There is no way to input Google Cloud credentials: assumption is that they will + be found in the runtime environment by google.cloud.kms, see + https://cloud.google.com/docs/authentication/getting-started + """ + + def __init__(self, gcp_keyid: str, hash_algo: str, keyid: str): + if GCP_IMPORT_ERROR: + raise exceptions.UnsupportedLibraryError(GCP_IMPORT_ERROR) + + self.gcp_keyid = gcp_keyid + self.hash_algo = hash_algo + self.keyid = keyid + self.client = kms.KeyManagementServiceClient() + + def sign(self, payload: bytes) -> Signature: + # NOTE: request and response can contain CRC32C of the digest/sig: + # This could be useful but would require another dependency... + + hasher = sslib_hash.digest(self.hash_algo) + hasher.update(payload) + digest = {self.hash_algo: hasher.digest()} + request = {"name": self.gcp_keyid, "digest": digest} + + logger.debug("signing request %s", request) + response = self.client.asymmetric_sign(request) + logger.debug("signing response %s", response) + + return Signature(self.keyid, response.signature.hex()) diff --git a/setup.py b/setup.py index af0fbc0e0..97cbbd978 100644 --- a/setup.py +++ b/setup.py @@ -102,6 +102,7 @@ extras_require={ "crypto": ["cryptography>=37.0.0"], "pynacl": ["pynacl>1.2.0"], + "gcpkms": ["google-cloud-kms"] }, packages=find_packages(exclude=["tests", "debian"]), scripts=[], diff --git a/tests/check_kms_signers.py b/tests/check_kms_signers.py new file mode 100644 index 000000000..f2e6e2ca6 --- /dev/null +++ b/tests/check_kms_signers.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python + +""" +This test confirms that signing using Google Cloud KMS works. + +Requirements to successfully run it: +* Google Cloud authentication details have to be available in the + environment (see https://github.com/googleapis/python-kms) +* The key defined in the test has to be available to the authenticated + user + +Likely the only place where both can be true is the Securesystemslib +GitHub Action environment. + +NOTE: the filename is purposefully check_ rather than test_ so that test +discovery doesn't find this unittest and the tests within are only run +when explicitly invoked. +""" + +import unittest + +from securesystemslib.signer import GCPSigner +from securesystemslib import keys + +class TestKMSKeys(unittest.TestCase): + """Test that KMS keys can be used to sign.""" + + def test_gcp(self): + """Test that GCP KMS key works for signing + + In case of problems with KMS account, please file an issue and + assign @jku + """ + + data = "data".encode("utf-8") + + pubkey = { + "keyid": "abcd", + "keytype": "ecdsa", + "scheme": "ecdsa-sha2-nistp256", + "keyval": { + "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE/ptvrXYuUc2ZaKssHhtg/IKNbO1X\ncDWlbKqLNpaK62MKdOwDz1qlp5AGHZkTY9tO09iq1F16SvVot1BQ9FJ2dw==\n-----END PUBLIC KEY-----\n" + }, + } + + gcp_id = "projects/python-tuf-kms/locations/global/keyRings/securesystemslib-tests/cryptoKeys/ecdsa-sha2-nistp256/cryptoKeyVersions/1" + hash_algo = "sha256" + + signer = GCPSigner(gcp_id, hash_algo, pubkey["keyid"]) + sig = signer.sign(data) + + self.AssertTrue(keys.verify_signature(pubkey, sig.to_dict(), data)) + + +if __name__ == "__main__": + unittest.main(verbosity=1, buffer=True) diff --git a/tox.ini b/tox.ini index 24be3b283..800f5c6d1 100644 --- a/tox.ini +++ b/tox.ini @@ -33,6 +33,13 @@ setenv = commands = python -m tests.check_public_interfaces_gpg +[testenv:kms] +deps = + -r{toxinidir}/requirements-pinned.txt + -r{toxinidir}/requirements-kms.txt +commands = + python -m tests.check_kms_signers + # This checks that importing securesystemslib.gpg.constants doesn't shell out on # import. [testenv:py38-test-gpg-fails]