Skip to content

Commit 0aaf60d

Browse files
committed
Initial GCP Signer implementation
A bare bones Signer for Google Cloud KMS: Private keys live in KMS, signing happens in KMS (although payload hashing happens in Signer). Key creation or import is not supported at this point. A test is added with a few caveats: * dependencies are not added to requirements.txt: this would more than triple the size of requirements-pinned.txt... There is a separate requirements file: this is not ideal but best I could come up with. * Test only works on GitHub (because of the GCP authentication), and only on branches within the upstream repo: not on PRs from forks * Test is run only once: it's a smoke test, not an exhaustive matrix test.
1 parent 16734bb commit 0aaf60d

File tree

8 files changed

+241
-0
lines changed

8 files changed

+241
-0
lines changed

.github/workflows/test-kms.yml

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Run KMS tests
2+
3+
on:
4+
push:
5+
workflow_dispatch:
6+
7+
permissions: {}
8+
9+
jobs:
10+
test-kms:
11+
runs-on: ubuntu-latest
12+
13+
permissions:
14+
id-token: 'write' # for OIDC auth for GCP authentication
15+
16+
steps:
17+
- name: Checkout securesystemslib
18+
uses: actions/checkout@v2
19+
20+
- name: Set up Python
21+
uses: actions/setup-python@v2
22+
with:
23+
cache: 'pip'
24+
cache-dependency-path: 'requirements*.txt'
25+
26+
- name: Install dependencies
27+
run: |
28+
python -m pip install --upgrade pip
29+
pip install --upgrade tox
30+
31+
- name: Authenticate to Google Cloud
32+
uses: google-github-actions/auth@c4799db9111fba4461e9f9da8732e5057b394f72
33+
with:
34+
token_format: access_token
35+
workload_identity_provider: projects/843741030650/locations/global/workloadIdentityPools/securesystemslib-tests/providers/securesystemslib-tests
36+
service_account: [email protected]
37+
38+
- run: tox -e kms

mypy.ini

+4
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ files =
99
# Supress error messages until enough modules
1010
# are type annotated
1111
follow_imports = silent
12+
13+
# let's not install typeshed annotations for GCPSigner
14+
[mypy-google.*]
15+
ignore_missing_imports = True

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Issues = "https://github.com/secure-systems-lab/securesystemslib/issues"
4444

4545
[project.optional-dependencies]
4646
crypto = ["cryptography>=37.0.0"]
47+
gcpkms = ["google-cloud-kms"]
4748
pynacl = ["pynacl>1.2.0"]
4849
PySPX = ["PySPX==0.5.0"]
4950

requirements-kms.txt

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
google-cloud-kms

securesystemslib/signer/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
This module provides extensible interfaces for public keys and signers:
55
Some implementations are provided by default but more can be added by users.
66
"""
7+
from securesystemslib.signer._gcp_signer import GCPSigner
78
from securesystemslib.signer._key import KEY_FOR_TYPE_AND_SCHEME, Key, SSlibKey
89
from securesystemslib.signer._signature import GPGSignature, Signature
910
from securesystemslib.signer._signer import (
@@ -19,6 +20,7 @@
1920
{
2021
SSlibSigner.ENVVAR_URI_SCHEME: SSlibSigner,
2122
SSlibSigner.FILE_URI_SCHEME: SSlibSigner,
23+
GCPSigner.SCHEME: GCPSigner,
2224
}
2325
)
2426

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Signer interface and example interface implementations.
2+
3+
The goal of this module is to provide a signing interface supporting multiple
4+
signing implementations and a couple of example implementations.
5+
6+
"""
7+
8+
import logging
9+
from typing import Optional
10+
from urllib import parse
11+
12+
import securesystemslib.hash as sslib_hash
13+
from securesystemslib import exceptions
14+
from securesystemslib.signer._key import Key
15+
from securesystemslib.signer._signer import SecretsHandler, Signature, Signer
16+
17+
logger = logging.getLogger(__name__)
18+
19+
GCP_IMPORT_ERROR = None
20+
try:
21+
from google.cloud import kms
22+
except ImportError:
23+
GCP_IMPORT_ERROR = (
24+
"google-cloud-kms library required to sign with Google Cloud keys."
25+
)
26+
27+
28+
class GCPSigner(Signer):
29+
"""Google Cloud KMS Signer
30+
31+
This Signer uses Google Cloud KMS to sign: the payload is hashed locally,
32+
but the signature is created on the KMS.
33+
34+
The signer uses "ambient" credentials: typically environment var
35+
GOOGLE_APPLICATION_CREDENTIALS that points to a file with valid
36+
credentials. These will be found by google.cloud.kms, see
37+
https://cloud.google.com/docs/authentication/getting-started
38+
(and https://github.com/google-github-actions/auth for the relevant
39+
GitHub action).
40+
41+
Arguments:
42+
gcp_keyid: Fully qualified GCP KMS key name, like
43+
projects/python-tuf-kms/locations/global/keyRings/securesystemslib-tests/cryptoKeys/ecdsa-sha2-nistp256/cryptoKeyVersions/1
44+
public_key: The related public key instance
45+
46+
Raises:
47+
UnsupportedAlgorithmError: The payload hash algorithm is unsupported.
48+
UnsupportedLibraryError: google.cloud.kms was not found
49+
Various errors from google.cloud modules: e.g.
50+
google.auth.exceptions.DefaultCredentialsError if ambient
51+
credentials are not found
52+
"""
53+
54+
SCHEME = "gcpkms"
55+
56+
def __init__(self, gcp_keyid: str, public_key: Key):
57+
if GCP_IMPORT_ERROR:
58+
raise exceptions.UnsupportedLibraryError(GCP_IMPORT_ERROR)
59+
60+
self.hash_algorithm = self._get_hash_algorithm(public_key)
61+
self.gcp_keyid = gcp_keyid
62+
self.public_key = public_key
63+
self.client = kms.KeyManagementServiceClient()
64+
65+
@classmethod
66+
def from_priv_key_uri(
67+
cls,
68+
priv_key_uri: str,
69+
public_key: Key,
70+
secrets_handler: Optional[SecretsHandler] = None,
71+
) -> "GCPSigner":
72+
uri = parse.urlparse(priv_key_uri)
73+
74+
if uri.scheme != cls.SCHEME:
75+
raise ValueError(f"GCPSigner does not support {priv_key_uri}")
76+
77+
return cls(uri.path, public_key)
78+
79+
@staticmethod
80+
def _get_hash_algorithm(public_key: Key) -> str:
81+
"""Helper function to return payload hash algorithm used for this key"""
82+
83+
# TODO: This could be a public abstract method on Key so that GCPSigner
84+
# would not be tied to a specific Key implementation -- not all keys
85+
# have a pre hash algorithm though.
86+
if public_key.keytype == "rsa":
87+
# hash algorithm is encoded as last scheme portion
88+
algo = public_key.scheme.split("-")[-1]
89+
if public_key.keytype in [
90+
"ecdsa",
91+
"ecdsa-sha2-nistp256",
92+
"ecdsa-sha2-nistp384",
93+
]:
94+
# nistp256 uses sha-256, nistp384 uses sha-384
95+
bits = public_key.scheme.split("-nistp")[-1]
96+
algo = f"sha{bits}"
97+
98+
# trigger UnsupportedAlgorithm if appropriate
99+
_ = sslib_hash.digest(algo)
100+
return algo
101+
102+
def sign(self, payload: bytes) -> Signature:
103+
"""Signs payload with Google Cloud KMS.
104+
105+
Arguments:
106+
payload: bytes to be signed.
107+
108+
Raises:
109+
Various errors from google.cloud modules.
110+
111+
Returns:
112+
Signature.
113+
"""
114+
# NOTE: request and response can contain CRC32C of the digest/sig:
115+
# Verifying could be useful but would require another dependency...
116+
117+
hasher = sslib_hash.digest(self.hash_algorithm)
118+
hasher.update(payload)
119+
digest = {self.hash_algorithm: hasher.digest()}
120+
request = {"name": self.gcp_keyid, "digest": digest}
121+
122+
logger.debug("signing request %s", request)
123+
response = self.client.asymmetric_sign(request)
124+
logger.debug("signing response %s", response)
125+
126+
return Signature(self.public_key.keyid, response.signature.hex())

tests/check_kms_signers.py

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/usr/bin/env python
2+
3+
"""
4+
This module confirms that signing using KMS keys works.
5+
6+
The purpose is to do a smoke test, not to exhaustively test every possible
7+
key and environment combination.
8+
9+
For Google Cloud (GCP), the requirements to successfully test are:
10+
* Google Cloud authentication details have to be available in the environment
11+
* The key defined in the test has to be available to the authenticated user
12+
13+
NOTE: the filename is purposefully check_ rather than test_ so that tests are
14+
only run when explicitly invoked: The tests can only pass on Securesystemslib
15+
GitHub Action environment because of the above requirements.
16+
"""
17+
18+
import unittest
19+
20+
from securesystemslib.exceptions import UnverifiedSignatureError
21+
from securesystemslib.signer import Key, Signer
22+
23+
24+
class TestKMSKeys(unittest.TestCase):
25+
"""Test that KMS keys can be used to sign."""
26+
27+
def test_gcp(self):
28+
"""Test that GCP KMS key works for signing
29+
30+
NOTE: The KMS account is setup to only accept requests from the
31+
Securesystemslib GitHub Action environment: test cannot pass elsewhere.
32+
33+
In case of problems with KMS account, please file an issue and
34+
assign @jku.
35+
"""
36+
37+
data = "data".encode("utf-8")
38+
pubkey = Key.from_dict(
39+
"abcd",
40+
{
41+
"keyid": "abcd",
42+
"keytype": "ecdsa",
43+
"scheme": "ecdsa-sha2-nistp256",
44+
"keyval": {
45+
"public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE/ptvrXYuUc2ZaKssHhtg/IKNbO1X\ncDWlbKqLNpaK62MKdOwDz1qlp5AGHZkTY9tO09iq1F16SvVot1BQ9FJ2dw==\n-----END PUBLIC KEY-----\n"
46+
},
47+
},
48+
)
49+
gcp_id = "projects/python-tuf-kms/locations/global/keyRings/securesystemslib-tests/cryptoKeys/ecdsa-sha2-nistp256/cryptoKeyVersions/1"
50+
51+
signer = Signer.from_priv_key_uri(f"gcpkms:{gcp_id}", pubkey)
52+
sig = signer.sign(data)
53+
54+
pubkey.verify_signature(sig, data)
55+
with self.assertRaises(UnverifiedSignatureError):
56+
pubkey.verify_signature(sig, b"NOT DATA")
57+
58+
59+
if __name__ == "__main__":
60+
unittest.main(verbosity=1, buffer=True)

tox.ini

+9
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ setenv =
3333
commands =
3434
python -m tests.check_public_interfaces_gpg
3535

36+
[testenv:kms]
37+
deps =
38+
-r{toxinidir}/requirements-pinned.txt
39+
-r{toxinidir}/requirements-kms.txt
40+
passenv =
41+
GOOGLE_APPLICATION_CREDENTIALS
42+
commands =
43+
python -m tests.check_kms_signers
44+
3645
# This checks that importing securesystemslib.gpg.constants doesn't shell out on
3746
# import.
3847
[testenv:py311-test-gpg-fails]

0 commit comments

Comments
 (0)