Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Google Cloud KMS signing capability #442

Merged
merged 6 commits into from
Dec 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/test-kms.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Run KMS tests

on:
push:
workflow_dispatch:

permissions: {}

jobs:
test-kms:
runs-on: ubuntu-latest

permissions:
id-token: 'write' # for OIDC auth for GCP authentication
issues: 'write' # for filing an issue on failure

steps:
- name: Checkout securesystemslib
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8

- name: Set up Python
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984
jku marked this conversation as resolved.
Show resolved Hide resolved
with:
python-version: '3.x'
cache: 'pip'
cache-dependency-path: 'requirements*.txt'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install --upgrade tox

- name: Authenticate to Google Cloud
uses: google-github-actions/auth@c4799db9111fba4461e9f9da8732e5057b394f72
with:
token_format: access_token
workload_identity_provider: projects/843741030650/locations/global/workloadIdentityPools/securesystemslib-tests/providers/securesystemslib-tests
jku marked this conversation as resolved.
Show resolved Hide resolved
service_account: securesystemslib@python-tuf-kms.iam.gserviceaccount.com

- run: tox -e kms

- name: File an issue on failure
if: ${{ failure() }}
uses: actions/github-script@d556feaca394842dc55e4734bf3bb9f685482fa0
with:
script: |
const repo = context.repo.owner + "/" + context.repo.repo
const issues = await github.rest.search.issuesAndPullRequests({
q: "KMS+tests+failed+in:title+state:open+type:issue+repo:" + repo,
})
if (issues.data.total_count > 0) {
console.log("Issue open already, not creating.")
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: "KMS tests failed",
lukpueh marked this conversation as resolved.
Show resolved Hide resolved
body: "Hey, it seems KMS tests have failed, please see - [workflow run](" +
"https://github.com/" + repo + "/actions/runs/" + context.runId + ")"
})
console.log("New issue created.")
}
4 changes: 4 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ files =
# Supress error messages until enough modules
# are type annotated
follow_imports = silent

# let's not install typeshed annotations for GCPSigner
[mypy-google.*]
ignore_missing_imports = True
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Issues = "https://github.com/secure-systems-lab/securesystemslib/issues"

[project.optional-dependencies]
crypto = ["cryptography>=37.0.0"]
gcpkms = ["google-cloud-kms"]
pynacl = ["pynacl>1.2.0"]
PySPX = ["PySPX==0.5.0"]

Expand Down
1 change: 1 addition & 0 deletions requirements-kms.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
google-cloud-kms
34 changes: 34 additions & 0 deletions securesystemslib/signer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
This module provides extensible interfaces for public keys and signers:
Some implementations are provided by default but more can be added by users.
"""
from securesystemslib.signer._gcp_signer import GCPSigner
from securesystemslib.signer._key import KEY_FOR_TYPE_AND_SCHEME, Key, SSlibKey
from securesystemslib.signer._signature import GPGSignature, Signature
from securesystemslib.signer._signer import (
Expand All @@ -13,3 +14,36 @@
Signer,
SSlibSigner,
)

# Register supported private key uri schemes and the Signers implementing them
SIGNER_FOR_URI_SCHEME.update(
{
SSlibSigner.ENVVAR_URI_SCHEME: SSlibSigner,
SSlibSigner.FILE_URI_SCHEME: SSlibSigner,
GCPSigner.SCHEME: GCPSigner,
}
)

# Register supported key types and schemes, and the Keys implementing them
KEY_FOR_TYPE_AND_SCHEME.update(
{
("ecdsa", "ecdsa-sha2-nistp256"): SSlibKey,
("ecdsa", "ecdsa-sha2-nistp384"): SSlibKey,
("ecdsa-sha2-nistp256", "ecdsa-sha2-nistp256"): SSlibKey,
("ecdsa-sha2-nistp384", "ecdsa-sha2-nistp384"): SSlibKey,
("ed25519", "ed25519"): SSlibKey,
("rsa", "rsassa-pss-md5"): SSlibKey,
("rsa", "rsassa-pss-sha1"): SSlibKey,
("rsa", "rsassa-pss-sha224"): SSlibKey,
("rsa", "rsassa-pss-sha256"): SSlibKey,
("rsa", "rsassa-pss-sha384"): SSlibKey,
("rsa", "rsassa-pss-sha512"): SSlibKey,
("rsa", "rsa-pkcs1v15-md5"): SSlibKey,
("rsa", "rsa-pkcs1v15-sha1"): SSlibKey,
("rsa", "rsa-pkcs1v15-sha224"): SSlibKey,
("rsa", "rsa-pkcs1v15-sha256"): SSlibKey,
("rsa", "rsa-pkcs1v15-sha384"): SSlibKey,
("rsa", "rsa-pkcs1v15-sha512"): SSlibKey,
("sphincs", "sphincs-shake-128s"): SSlibKey,
}
)
121 changes: 121 additions & 0 deletions securesystemslib/signer/_gcp_signer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Signer implementation for Google Cloud KMS"""

import logging
from typing import Optional
from urllib import parse

import securesystemslib.hash as sslib_hash
from securesystemslib import exceptions
from securesystemslib.signer._key import Key
from securesystemslib.signer._signer import SecretsHandler, Signature, Signer

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 GCPSigner(Signer):
"""Google Cloud KMS Signer

This Signer uses Google Cloud KMS to sign: the payload is hashed locally,
but the signature is created on the KMS.

The signer uses "ambient" credentials: typically environment var
GOOGLE_APPLICATION_CREDENTIALS that points to a file with valid
credentials. These will be found by google.cloud.kms, see
https://cloud.google.com/docs/authentication/getting-started
(and https://github.com/google-github-actions/auth for the relevant
GitHub action).

Arguments:
gcp_keyid: Fully qualified GCP KMS key name, like
projects/python-tuf-kms/locations/global/keyRings/securesystemslib-tests/cryptoKeys/ecdsa-sha2-nistp256/cryptoKeyVersions/1
public_key: The related public key instance

Raises:
UnsupportedAlgorithmError: The payload hash algorithm is unsupported.
UnsupportedLibraryError: google.cloud.kms was not found
Various errors from google.cloud modules: e.g.
google.auth.exceptions.DefaultCredentialsError if ambient
credentials are not found
"""

SCHEME = "gcpkms"

def __init__(self, gcp_keyid: str, public_key: Key):
if GCP_IMPORT_ERROR:
raise exceptions.UnsupportedLibraryError(GCP_IMPORT_ERROR)

self.hash_algorithm = self._get_hash_algorithm(public_key)
self.gcp_keyid = gcp_keyid
self.public_key = public_key
self.client = kms.KeyManagementServiceClient()

@classmethod
def from_priv_key_uri(
cls,
priv_key_uri: str,
public_key: Key,
secrets_handler: Optional[SecretsHandler] = None,
) -> "GCPSigner":
uri = parse.urlparse(priv_key_uri)

if uri.scheme != cls.SCHEME:
raise ValueError(f"GCPSigner does not support {priv_key_uri}")

return cls(uri.path, public_key)

@staticmethod
def _get_hash_algorithm(public_key: Key) -> str:
"""Helper function to return payload hash algorithm used for this key"""

# TODO: This could be a public abstract method on Key so that GCPSigner
# would not be tied to a specific Key implementation -- not all keys
# have a pre hash algorithm though.
if public_key.keytype == "rsa":
# hash algorithm is encoded as last scheme portion
algo = public_key.scheme.split("-")[-1]
if public_key.keytype in [
"ecdsa",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
]:
# nistp256 uses sha-256, nistp384 uses sha-384
bits = public_key.scheme.split("-nistp")[-1]
algo = f"sha{bits}"

# trigger UnsupportedAlgorithm if appropriate
_ = sslib_hash.digest(algo)
return algo

def sign(self, payload: bytes) -> Signature:
"""Signs payload with Google Cloud KMS.

Arguments:
payload: bytes to be signed.

Raises:
Various errors from google.cloud modules.

Returns:
Signature.
"""
# NOTE: request and response can contain CRC32C of the digest/sig:
# Verifying could be useful but would require another dependency...

hasher = sslib_hash.digest(self.hash_algorithm)
hasher.update(payload)
digest = {self.hash_algorithm: 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.public_key.keyid, response.signature.hex())
28 changes: 2 additions & 26 deletions securesystemslib/signer/_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@

logger = logging.getLogger(__name__)

# NOTE dict for Key dispatch defined here, but filled at end of file when
# subclass definitions are available. Users can add Key implementations.

# NOTE Key dispatch table is defined here so it's usable by Key,
# but is populated in __init__.py (and can be appended by users).
KEY_FOR_TYPE_AND_SCHEME: Dict[Tuple[str, str], Type] = {}


Expand Down Expand Up @@ -181,26 +180,3 @@ def verify_signature(self, signature: Signature, data: bytes) -> None:
raise exceptions.VerificationError(
f"Unknown failure to verify signature by {self.keyid}"
) from e


# Supported key types and schemes, and the Keys implementing them
KEY_FOR_TYPE_AND_SCHEME = {
("ecdsa", "ecdsa-sha2-nistp256"): SSlibKey,
("ecdsa", "ecdsa-sha2-nistp384"): SSlibKey,
("ecdsa-sha2-nistp256", "ecdsa-sha2-nistp256"): SSlibKey,
("ecdsa-sha2-nistp384", "ecdsa-sha2-nistp384"): SSlibKey,
("ed25519", "ed25519"): SSlibKey,
("rsa", "rsassa-pss-md5"): SSlibKey,
("rsa", "rsassa-pss-sha1"): SSlibKey,
("rsa", "rsassa-pss-sha224"): SSlibKey,
("rsa", "rsassa-pss-sha256"): SSlibKey,
("rsa", "rsassa-pss-sha384"): SSlibKey,
("rsa", "rsassa-pss-sha512"): SSlibKey,
("rsa", "rsa-pkcs1v15-md5"): SSlibKey,
("rsa", "rsa-pkcs1v15-sha1"): SSlibKey,
("rsa", "rsa-pkcs1v15-sha224"): SSlibKey,
("rsa", "rsa-pkcs1v15-sha256"): SSlibKey,
("rsa", "rsa-pkcs1v15-sha384"): SSlibKey,
("rsa", "rsa-pkcs1v15-sha512"): SSlibKey,
("sphincs", "sphincs-shake-128s"): SSlibKey,
}
11 changes: 2 additions & 9 deletions securesystemslib/signer/_signer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@

logger = logging.getLogger(__name__)

# NOTE dict for Signer dispatch defined here, but filled at end of file when
# subclass definitions are available. Users can add Signer implementations.
# NOTE Signer dispatch table is defined here so it's usable by Signer,
# but is populated in __init__.py (and can be appended by users).
SIGNER_FOR_URI_SCHEME: Dict[str, Type] = {}


Expand Down Expand Up @@ -273,10 +273,3 @@ def sign(self, payload: bytes) -> GPGSignature:

sig_dict = gpg.create_signature(payload, self.keyid, self.homedir)
return GPGSignature(**sig_dict)


# Supported private key uri schemes and the Signers implementing them
SIGNER_FOR_URI_SCHEME = {
SSlibSigner.ENVVAR_URI_SCHEME: SSlibSigner,
SSlibSigner.FILE_URI_SCHEME: SSlibSigner,
}
60 changes: 60 additions & 0 deletions tests/check_kms_signers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env python

"""
This module confirms that signing using KMS keys works.

The purpose is to do a smoke test, not to exhaustively test every possible
key and environment combination.

For Google Cloud (GCP), the requirements to successfully test are:
* Google Cloud authentication details have to be available in the environment
* The key defined in the test has to be available to the authenticated user

NOTE: the filename is purposefully check_ rather than test_ so that tests are
only run when explicitly invoked: The tests can only pass on Securesystemslib
GitHub Action environment because of the above requirements.
"""

import unittest

from securesystemslib.exceptions import UnverifiedSignatureError
from securesystemslib.signer import Key, Signer


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

NOTE: The KMS account is setup to only accept requests from the
Securesystemslib GitHub Action environment: test cannot pass elsewhere.

In case of problems with KMS account, please file an issue and
assign @jku.
"""

data = "data".encode("utf-8")
pubkey = Key.from_dict(
"abcd",
{
"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"

signer = Signer.from_priv_key_uri(f"gcpkms:{gcp_id}", pubkey)
sig = signer.sign(data)

pubkey.verify_signature(sig, data)
with self.assertRaises(UnverifiedSignatureError):
pubkey.verify_signature(sig, b"NOT DATA")


if __name__ == "__main__":
unittest.main(verbosity=1, buffer=True)
11 changes: 10 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ deps =
commands =
python -m tests.check_gpg_available
coverage run tests/aggregate_tests.py
coverage report -m --fail-under 97
coverage report -m --fail-under 96

[testenv:purepy311]
deps =
Expand All @@ -33,6 +33,15 @@ setenv =
commands =
python -m tests.check_public_interfaces_gpg

[testenv:kms]
deps =
-r{toxinidir}/requirements-pinned.txt
-r{toxinidir}/requirements-kms.txt
passenv =
GOOGLE_APPLICATION_CREDENTIALS
commands =
python -m tests.check_kms_signers

# This checks that importing securesystemslib.gpg.constants doesn't shell out on
# import.
[testenv:py311-test-gpg-fails]
Expand Down