paramiko-cloud 1.3.0__tar.gz → 1.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- paramiko_cloud-1.3.2/PKG-INFO +59 -0
- paramiko_cloud-1.3.2/README.md +33 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/aws/keys.py +29 -17
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/aws/test_keys.py +25 -19
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/azure/keys.py +59 -26
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/azure/test_keys.py +63 -28
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/base.py +28 -12
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/dummy/keys.py +6 -2
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/dummy/test_keys.py +10 -8
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/gcp/keys.py +29 -8
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/gcp/test_keys.py +26 -21
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/grpc_server.py +17 -5
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/pki.py +82 -34
- paramiko_cloud-1.3.2/paramiko_cloud/protobuf/__init__.py +3 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/protobuf/csr_pb2.py +29 -18
- paramiko_cloud-1.3.2/paramiko_cloud/protobuf/csr_pb2.pyi +72 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/protobuf/rpc_pb2.py +14 -4
- paramiko_cloud-1.3.2/paramiko_cloud/protobuf/rpc_pb2.pyi +51 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/protobuf/rpc_pb2_grpc.py +50 -9
- paramiko_cloud-1.3.2/paramiko_cloud/py.typed +1 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/test_grpc_server.py +9 -5
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/test_helpers.py +11 -9
- paramiko_cloud-1.3.2/paramiko_cloud/test_pki.py +61 -0
- paramiko_cloud-1.3.2/paramiko_cloud.egg-info/PKG-INFO +59 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud.egg-info/SOURCES.txt +4 -1
- paramiko_cloud-1.3.2/pyproject.toml +77 -0
- paramiko-cloud-1.3.0/PKG-INFO +0 -28
- paramiko-cloud-1.3.0/README.md +0 -14
- paramiko-cloud-1.3.0/paramiko_cloud/protobuf/__init__.py +0 -0
- paramiko-cloud-1.3.0/paramiko_cloud/test_pki.py +0 -24
- paramiko-cloud-1.3.0/paramiko_cloud.egg-info/PKG-INFO +0 -28
- paramiko-cloud-1.3.0/setup.py +0 -128
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/__init__.py +0 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/aws/__init__.py +0 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/azure/__init__.py +0 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/dummy/__init__.py +0 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud/gcp/__init__.py +0 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud.egg-info/dependency_links.txt +0 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud.egg-info/requires.txt +0 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/paramiko_cloud.egg-info/top_level.txt +0 -0
- {paramiko-cloud-1.3.0 → paramiko_cloud-1.3.2}/setup.cfg +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paramiko-cloud
|
|
3
|
+
Version: 1.3.2
|
|
4
|
+
Summary: Use cloud-managed keys to sign SSH certificates
|
|
5
|
+
Author-email: Jason Rigby <hello@jasonrig.by>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jasonrig/paramiko-cloud/
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: paramiko
|
|
11
|
+
Requires-Dist: cryptography
|
|
12
|
+
Requires-Dist: protobuf
|
|
13
|
+
Requires-Dist: grpcio-tools
|
|
14
|
+
Provides-Extra: aws
|
|
15
|
+
Requires-Dist: boto3; extra == "aws"
|
|
16
|
+
Provides-Extra: gcp
|
|
17
|
+
Requires-Dist: google-cloud-kms; extra == "gcp"
|
|
18
|
+
Provides-Extra: azure
|
|
19
|
+
Requires-Dist: azure-keyvault-keys; extra == "azure"
|
|
20
|
+
Requires-Dist: azure-identity; extra == "azure"
|
|
21
|
+
Provides-Extra: all
|
|
22
|
+
Requires-Dist: boto3; extra == "all"
|
|
23
|
+
Requires-Dist: google-cloud-kms; extra == "all"
|
|
24
|
+
Requires-Dist: azure-keyvault-keys; extra == "all"
|
|
25
|
+
Requires-Dist: azure-identity; extra == "all"
|
|
26
|
+
|
|
27
|
+
# Paramiko-Cloud
|
|
28
|
+
[](https://sonarcloud.io/dashboard?id=jasonrig_paramiko-cloud) [](https://sonarcloud.io/dashboard?id=jasonrig_paramiko-cloud) [](https://sonarcloud.io/dashboard?id=jasonrig_paramiko-cloud)
|
|
29
|
+
|
|
30
|
+
Paramiko-Cloud is an extension to Paramiko that provides ECDSA SSH keys managed by
|
|
31
|
+
cloud-based key management services. As well as enabling Paramiko to perform SSH
|
|
32
|
+
operations using cloud-managed keys, it also provides certificate signing functions,
|
|
33
|
+
simplifying the implementation of an SSH certificate authority.
|
|
34
|
+
|
|
35
|
+
Paramiko-Cloud supports:
|
|
36
|
+
* [Amazon Web Services - Key Management Service](https://aws.amazon.com/kms/)
|
|
37
|
+
* [Google Cloud Platform - Cloud Key Management Service](https://cloud.google.com/security-key-management)
|
|
38
|
+
* [Microsoft Azure - Key Vault](https://azure.microsoft.com/en-us/services/key-vault/)
|
|
39
|
+
|
|
40
|
+
Read the docs here: https://paramiko-cloud.readthedocs.io/en/latest/
|
|
41
|
+
|
|
42
|
+
## Development with uv
|
|
43
|
+
|
|
44
|
+
```shell
|
|
45
|
+
# Sync runtime + optional cloud provider dependencies + test tooling
|
|
46
|
+
uv sync --extra all --group dev
|
|
47
|
+
|
|
48
|
+
# Generate protobuf / gRPC Python modules
|
|
49
|
+
uv run python scripts/build_proto.py
|
|
50
|
+
|
|
51
|
+
# Run tests
|
|
52
|
+
uv run pytest --cov=./ --cov-report=xml
|
|
53
|
+
|
|
54
|
+
# Static checks (requires generated protobuf modules first)
|
|
55
|
+
uv run python scripts/build_proto.py
|
|
56
|
+
uv run ruff check .
|
|
57
|
+
uv run ruff format . --check
|
|
58
|
+
uv run mypy paramiko_cloud
|
|
59
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Paramiko-Cloud
|
|
2
|
+
[](https://sonarcloud.io/dashboard?id=jasonrig_paramiko-cloud) [](https://sonarcloud.io/dashboard?id=jasonrig_paramiko-cloud) [](https://sonarcloud.io/dashboard?id=jasonrig_paramiko-cloud)
|
|
3
|
+
|
|
4
|
+
Paramiko-Cloud is an extension to Paramiko that provides ECDSA SSH keys managed by
|
|
5
|
+
cloud-based key management services. As well as enabling Paramiko to perform SSH
|
|
6
|
+
operations using cloud-managed keys, it also provides certificate signing functions,
|
|
7
|
+
simplifying the implementation of an SSH certificate authority.
|
|
8
|
+
|
|
9
|
+
Paramiko-Cloud supports:
|
|
10
|
+
* [Amazon Web Services - Key Management Service](https://aws.amazon.com/kms/)
|
|
11
|
+
* [Google Cloud Platform - Cloud Key Management Service](https://cloud.google.com/security-key-management)
|
|
12
|
+
* [Microsoft Azure - Key Vault](https://azure.microsoft.com/en-us/services/key-vault/)
|
|
13
|
+
|
|
14
|
+
Read the docs here: https://paramiko-cloud.readthedocs.io/en/latest/
|
|
15
|
+
|
|
16
|
+
## Development with uv
|
|
17
|
+
|
|
18
|
+
```shell
|
|
19
|
+
# Sync runtime + optional cloud provider dependencies + test tooling
|
|
20
|
+
uv sync --extra all --group dev
|
|
21
|
+
|
|
22
|
+
# Generate protobuf / gRPC Python modules
|
|
23
|
+
uv run python scripts/build_proto.py
|
|
24
|
+
|
|
25
|
+
# Run tests
|
|
26
|
+
uv run pytest --cov=./ --cov-report=xml
|
|
27
|
+
|
|
28
|
+
# Static checks (requires generated protobuf modules first)
|
|
29
|
+
uv run python scripts/build_proto.py
|
|
30
|
+
uv run ruff check .
|
|
31
|
+
uv run ruff format . --check
|
|
32
|
+
uv run mypy paramiko_cloud
|
|
33
|
+
```
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
from typing import Set
|
|
1
|
+
from typing import Any, Set, cast
|
|
2
2
|
|
|
3
3
|
import boto3
|
|
4
|
-
from cryptography.hazmat.primitives.asymmetric.ec import
|
|
4
|
+
from cryptography.hazmat.primitives.asymmetric.ec import (
|
|
5
|
+
ECDSA,
|
|
6
|
+
EllipticCurve,
|
|
7
|
+
EllipticCurvePublicKey,
|
|
8
|
+
)
|
|
5
9
|
from cryptography.hazmat.primitives.serialization import load_der_public_key
|
|
6
10
|
|
|
7
11
|
from paramiko_cloud.base import BaseKeyECDSA, CloudSigningKey
|
|
@@ -12,7 +16,9 @@ class _AWSSigningKey(CloudSigningKey):
|
|
|
12
16
|
Provides signing operations to Paramiko for the AWS KMS-backed key
|
|
13
17
|
"""
|
|
14
18
|
|
|
15
|
-
def __init__(
|
|
19
|
+
def __init__(
|
|
20
|
+
self, client: Any, key_id: str, signing_algo: str, curve: EllipticCurve
|
|
21
|
+
) -> None:
|
|
16
22
|
"""
|
|
17
23
|
Constructor
|
|
18
24
|
|
|
@@ -46,9 +52,9 @@ class _AWSSigningKey(CloudSigningKey):
|
|
|
46
52
|
Message=hash_,
|
|
47
53
|
MessageType="DIGEST",
|
|
48
54
|
GrantTokens=[
|
|
49
|
-
|
|
55
|
+
"string",
|
|
50
56
|
],
|
|
51
|
-
SigningAlgorithm=self.signing_algo
|
|
57
|
+
SigningAlgorithm=self.signing_algo,
|
|
52
58
|
)
|
|
53
59
|
return signing_response["Signature"]
|
|
54
60
|
|
|
@@ -71,31 +77,35 @@ class ECDSAKey(BaseKeyECDSA):
|
|
|
71
77
|
("ECC_NIST_P521", "ECDSA_SHA_512"),
|
|
72
78
|
)
|
|
73
79
|
|
|
74
|
-
def __init__(self, key_id: str, **kwargs):
|
|
75
|
-
client = boto3.client(
|
|
80
|
+
def __init__(self, key_id: str, **kwargs: Any) -> None:
|
|
81
|
+
client = boto3.client("kms", **kwargs)
|
|
76
82
|
pub_key = client.get_public_key(
|
|
77
83
|
KeyId=key_id,
|
|
78
84
|
GrantTokens=[
|
|
79
|
-
|
|
80
|
-
]
|
|
85
|
+
"string",
|
|
86
|
+
],
|
|
81
87
|
)
|
|
82
88
|
|
|
83
89
|
assert any(
|
|
84
|
-
signing_algo in pub_key["SigningAlgorithms"]
|
|
85
|
-
key_spec == pub_key["CustomerMasterKeySpec"]
|
|
86
|
-
for key_spec, signing_algo in self._ALLOWED_ALGOS
|
|
87
|
-
|
|
90
|
+
signing_algo in pub_key["SigningAlgorithms"]
|
|
91
|
+
and key_spec == pub_key["CustomerMasterKeySpec"]
|
|
92
|
+
for key_spec, signing_algo in self._ALLOWED_ALGOS
|
|
93
|
+
), "No supported key/algorithm pair found."
|
|
88
94
|
assert pub_key["KeyUsage"] == "SIGN_VERIFY", "Key does not support signing."
|
|
89
95
|
|
|
90
|
-
verifying_key =
|
|
96
|
+
verifying_key = cast(
|
|
97
|
+
EllipticCurvePublicKey, load_der_public_key(pub_key["PublicKey"])
|
|
98
|
+
)
|
|
91
99
|
|
|
92
100
|
super().__init__(
|
|
93
101
|
(
|
|
94
102
|
_AWSSigningKey(
|
|
95
103
|
client,
|
|
96
104
|
key_id,
|
|
97
|
-
self._choose_signing_algo(set(pub_key["SigningAlgorithms"])),
|
|
98
|
-
|
|
105
|
+
self._choose_signing_algo(set(pub_key["SigningAlgorithms"])),
|
|
106
|
+
verifying_key.curve,
|
|
107
|
+
),
|
|
108
|
+
verifying_key,
|
|
99
109
|
)
|
|
100
110
|
)
|
|
101
111
|
|
|
@@ -110,4 +120,6 @@ class ECDSAKey(BaseKeyECDSA):
|
|
|
110
120
|
The selected signing algorithm
|
|
111
121
|
"""
|
|
112
122
|
|
|
113
|
-
return
|
|
123
|
+
return {algo for _, algo in self._ALLOWED_ALGOS}.intersection(
|
|
124
|
+
supported_algos
|
|
125
|
+
).pop()
|
|
@@ -16,7 +16,7 @@ def set_up_mocks(boto3_mock: Mock, curve: EllipticCurve, hash_algo: HashAlgorith
|
|
|
16
16
|
priv_key = ec.generate_private_key(curve)
|
|
17
17
|
pem_key = priv_key.public_key().public_bytes(
|
|
18
18
|
encoding=serialization.Encoding.DER,
|
|
19
|
-
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
|
19
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
20
20
|
)
|
|
21
21
|
|
|
22
22
|
signing_algorithm_name = "ECDSA_SHA_{}".format(hash_algo.name[-3:])
|
|
@@ -27,17 +27,12 @@ def set_up_mocks(boto3_mock: Mock, curve: EllipticCurve, hash_algo: HashAlgorith
|
|
|
27
27
|
"SigningAlgorithms": [signing_algorithm_name],
|
|
28
28
|
"CustomerMasterKeySpec": key_spec_name,
|
|
29
29
|
"KeyUsage": "SIGN_VERIFY",
|
|
30
|
-
"PublicKey": pem_key
|
|
30
|
+
"PublicKey": pem_key,
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
def sign(KeyId, Message, MessageType, GrantTokens, SigningAlgorithm):
|
|
34
34
|
assert SigningAlgorithm == signing_algorithm_name
|
|
35
|
-
return {
|
|
36
|
-
"Signature": priv_key.sign(
|
|
37
|
-
Message,
|
|
38
|
-
ec.ECDSA(Prehashed(hash_algo))
|
|
39
|
-
)
|
|
40
|
-
}
|
|
35
|
+
return {"Signature": priv_key.sign(Message, ec.ECDSA(Prehashed(hash_algo)))}
|
|
41
36
|
|
|
42
37
|
boto3_instance.sign.side_effect = sign
|
|
43
38
|
|
|
@@ -46,44 +41,55 @@ class TestECDSAKey(TestCase):
|
|
|
46
41
|
ALL_SUPPORTED_ALGOS: Tuple[Tuple[EllipticCurve, HashAlgorithm]] = (
|
|
47
42
|
(ec.SECP256R1(), hashes.SHA256()),
|
|
48
43
|
(ec.SECP384R1(), hashes.SHA384()),
|
|
49
|
-
(ec.SECP521R1(), hashes.SHA512())
|
|
44
|
+
(ec.SECP521R1(), hashes.SHA512()),
|
|
50
45
|
)
|
|
51
46
|
|
|
52
47
|
@patch("boto3.client")
|
|
53
48
|
def test_key_from_cloud_can_sign(self, boto3_mock: Mock):
|
|
54
49
|
from paramiko_cloud.aws.keys import ECDSAKey
|
|
50
|
+
|
|
55
51
|
for curve, hash_ in self.ALL_SUPPORTED_ALGOS:
|
|
56
|
-
with self.subTest(
|
|
52
|
+
with self.subTest(
|
|
53
|
+
"Using curve {} and hash {}".format(curve.name, hash_.name)
|
|
54
|
+
):
|
|
57
55
|
set_up_mocks(boto3_mock, curve, hash_)
|
|
58
56
|
key = ECDSAKey("test_key", region_name="ap-northeast-1")
|
|
59
57
|
signature = key.sign_ssh_data(b"hello world")
|
|
60
58
|
signature.rewind()
|
|
61
|
-
self.assertTrue(
|
|
59
|
+
self.assertTrue(
|
|
60
|
+
key.verify_ssh_sig(b"hello world", signature),
|
|
61
|
+
"Signature is invalid",
|
|
62
|
+
)
|
|
62
63
|
|
|
63
64
|
@patch("boto3.client")
|
|
64
65
|
def test_key_from_cloud_can_produce_valid_certificate(self, boto3_mock: Mock):
|
|
65
66
|
from paramiko_cloud.aws.keys import ECDSAKey
|
|
67
|
+
|
|
66
68
|
for curve, hash_ in self.ALL_SUPPORTED_ALGOS:
|
|
67
|
-
with self.subTest(
|
|
69
|
+
with self.subTest(
|
|
70
|
+
"Using curve {} and hash {}".format(curve.name, hash_.name)
|
|
71
|
+
):
|
|
68
72
|
set_up_mocks(boto3_mock, curve, hash_)
|
|
69
73
|
ca_key = ECDSAKey("test_key", region_name="ap-northeast-1")
|
|
70
74
|
client_key = RSAKey.generate(1024)
|
|
71
|
-
cert_string = ca_key.sign_certificate(
|
|
75
|
+
cert_string = ca_key.sign_certificate(
|
|
76
|
+
client_key, ["test.user"]
|
|
77
|
+
).cert_string()
|
|
72
78
|
exit_code, cert_details = parse_certificate(cert_string)
|
|
73
79
|
self.assertEqual(
|
|
74
80
|
cert_details.public_key,
|
|
75
|
-
"RSA-CERT SHA256:{}".format(sha256_fingerprint(client_key))
|
|
81
|
+
"RSA-CERT SHA256:{}".format(sha256_fingerprint(client_key)),
|
|
76
82
|
)
|
|
77
83
|
self.assertEqual(
|
|
78
84
|
cert_details.signing_ca,
|
|
79
85
|
"ECDSA SHA256:{} (using ecdsa-sha2-nistp{})".format(
|
|
80
|
-
sha256_fingerprint(ca_key),
|
|
81
|
-
|
|
82
|
-
)
|
|
86
|
+
sha256_fingerprint(ca_key), ca_key.ecdsa_curve.key_length
|
|
87
|
+
),
|
|
83
88
|
)
|
|
84
89
|
self.assertEqual(
|
|
85
|
-
exit_code,
|
|
90
|
+
exit_code,
|
|
91
|
+
0,
|
|
86
92
|
"Could not parse generated certificate with ssh-keygen, exit code {}".format(
|
|
87
93
|
exit_code
|
|
88
|
-
)
|
|
94
|
+
),
|
|
89
95
|
)
|
|
@@ -1,12 +1,28 @@
|
|
|
1
|
-
from typing import Union
|
|
2
|
-
|
|
3
|
-
from azure.identity import
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
from typing import Union, cast
|
|
2
|
+
|
|
3
|
+
from azure.identity import (
|
|
4
|
+
DefaultAzureCredential,
|
|
5
|
+
AzurePowerShellCredential,
|
|
6
|
+
InteractiveBrowserCredential,
|
|
7
|
+
ChainedTokenCredential,
|
|
8
|
+
EnvironmentCredential,
|
|
9
|
+
ManagedIdentityCredential,
|
|
10
|
+
SharedTokenCacheCredential,
|
|
11
|
+
AzureCliCredential,
|
|
12
|
+
VisualStudioCodeCredential,
|
|
13
|
+
)
|
|
6
14
|
from azure.keyvault.keys import KeyClient
|
|
7
15
|
from azure.keyvault.keys.crypto import CryptographyClient, SignatureAlgorithm
|
|
8
|
-
from cryptography.hazmat.primitives.asymmetric.ec import
|
|
9
|
-
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric.ec import (
|
|
17
|
+
ECDSA,
|
|
18
|
+
EllipticCurvePublicNumbers,
|
|
19
|
+
SECP256R1,
|
|
20
|
+
SECP384R1,
|
|
21
|
+
SECP521R1,
|
|
22
|
+
SECP224R1,
|
|
23
|
+
SECP192R1,
|
|
24
|
+
EllipticCurve,
|
|
25
|
+
)
|
|
10
26
|
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
|
11
27
|
|
|
12
28
|
from paramiko_cloud.base import BaseKeyECDSA, CloudSigningKey
|
|
@@ -65,8 +81,8 @@ class _AzureSigningKey(CloudSigningKey):
|
|
|
65
81
|
digest = self.digest(data, signature_algorithm)
|
|
66
82
|
signing_response = self.crypto_client.sign(self._signaure_algorithm(), digest)
|
|
67
83
|
raw_signature = signing_response.signature
|
|
68
|
-
r = int.from_bytes(raw_signature[:len(raw_signature) // 2], "big")
|
|
69
|
-
s = int.from_bytes(raw_signature[len(raw_signature) // 2:], "big")
|
|
84
|
+
r = int.from_bytes(raw_signature[: len(raw_signature) // 2], "big")
|
|
85
|
+
s = int.from_bytes(raw_signature[len(raw_signature) // 2 :], "big")
|
|
70
86
|
return encode_dss_signature(r, s)
|
|
71
87
|
|
|
72
88
|
|
|
@@ -83,32 +99,49 @@ class ECDSAKey(BaseKeyECDSA):
|
|
|
83
99
|
https://docs.microsoft.com/en-us/azure/developer/python/azure-sdk-authenticate
|
|
84
100
|
"""
|
|
85
101
|
|
|
86
|
-
_ALLOWED_ALGOS = (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
_ALLOWED_ALGOS = ("EC",)
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
credential: Union[
|
|
107
|
+
DefaultAzureCredential,
|
|
108
|
+
AzurePowerShellCredential,
|
|
109
|
+
InteractiveBrowserCredential,
|
|
110
|
+
ChainedTokenCredential,
|
|
111
|
+
EnvironmentCredential,
|
|
112
|
+
ManagedIdentityCredential,
|
|
113
|
+
SharedTokenCacheCredential,
|
|
114
|
+
AzureCliCredential,
|
|
115
|
+
VisualStudioCodeCredential,
|
|
116
|
+
],
|
|
117
|
+
vault_url: str,
|
|
118
|
+
key_name: str,
|
|
119
|
+
):
|
|
95
120
|
vault_client = KeyClient(vault_url, credential=credential)
|
|
96
121
|
pub_key = vault_client.get_key(key_name)
|
|
97
|
-
assert pub_key.key_type in self._ALLOWED_ALGOS,
|
|
122
|
+
assert pub_key.key_type in self._ALLOWED_ALGOS, (
|
|
123
|
+
"Unsupported signing algorithm: {}".format(pub_key.key_type)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
jwk = pub_key.key
|
|
127
|
+
assert jwk is not None, "Missing key material from Azure Key Vault."
|
|
128
|
+
curve_name = cast(str, getattr(jwk, "crv"))
|
|
98
129
|
|
|
99
|
-
assert
|
|
130
|
+
assert curve_name in _CURVES, "Unsupported curve: {}".format(curve_name)
|
|
100
131
|
|
|
101
|
-
curve = _CURVES[
|
|
132
|
+
curve = _CURVES[curve_name]() # type: ignore[abstract]
|
|
102
133
|
|
|
103
134
|
verifying_key = EllipticCurvePublicNumbers(
|
|
104
|
-
int.from_bytes(
|
|
105
|
-
int.from_bytes(
|
|
106
|
-
curve
|
|
135
|
+
int.from_bytes(cast(bytes, getattr(jwk, "x")), "big"),
|
|
136
|
+
int.from_bytes(cast(bytes, getattr(jwk, "y")), "big"),
|
|
137
|
+
curve,
|
|
107
138
|
).public_key()
|
|
108
139
|
|
|
109
140
|
super().__init__(
|
|
110
141
|
(
|
|
111
|
-
_AzureSigningKey(
|
|
112
|
-
|
|
142
|
+
_AzureSigningKey(
|
|
143
|
+
CryptographyClient(pub_key, credential), verifying_key.curve
|
|
144
|
+
),
|
|
145
|
+
verifying_key,
|
|
113
146
|
)
|
|
114
147
|
)
|
|
@@ -6,7 +6,10 @@ from azure.keyvault.keys import KeyVaultKey
|
|
|
6
6
|
from cryptography.hazmat.primitives import hashes
|
|
7
7
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
8
8
|
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurve
|
|
9
|
-
from cryptography.hazmat.primitives.asymmetric.utils import
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.utils import (
|
|
10
|
+
Prehashed,
|
|
11
|
+
decode_dss_signature,
|
|
12
|
+
)
|
|
10
13
|
from cryptography.hazmat.primitives.hashes import HashAlgorithm
|
|
11
14
|
from paramiko.rsakey import RSAKey
|
|
12
15
|
|
|
@@ -18,77 +21,109 @@ class _SigningResponse:
|
|
|
18
21
|
self.signature = signature
|
|
19
22
|
|
|
20
23
|
|
|
21
|
-
def set_up_mocks(
|
|
22
|
-
|
|
24
|
+
def set_up_mocks(
|
|
25
|
+
key_client_mock: Mock,
|
|
26
|
+
crypto_client_mock: Mock,
|
|
27
|
+
curve: EllipticCurve,
|
|
28
|
+
hash_algo: HashAlgorithm,
|
|
29
|
+
key_name: str,
|
|
30
|
+
):
|
|
23
31
|
priv_key = ec.generate_private_key(curve)
|
|
24
32
|
pub_key = priv_key.public_key().public_numbers()
|
|
25
33
|
|
|
26
34
|
crypto_client_instance = crypto_client_mock.return_value
|
|
27
35
|
|
|
28
36
|
def sign(_, digest):
|
|
29
|
-
signature = priv_key.sign(
|
|
30
|
-
digest,
|
|
31
|
-
ec.ECDSA(Prehashed(hash_algo))
|
|
32
|
-
)
|
|
37
|
+
signature = priv_key.sign(digest, ec.ECDSA(Prehashed(hash_algo)))
|
|
33
38
|
r, s = decode_dss_signature(signature)
|
|
34
39
|
return _SigningResponse(r.to_bytes(128, "big") + s.to_bytes(128, "big"))
|
|
35
40
|
|
|
36
41
|
crypto_client_instance.sign.side_effect = sign
|
|
37
42
|
key_client_instance = key_client_mock.return_value
|
|
38
|
-
key_client_instance.get_key.return_value = KeyVaultKey(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
key_client_instance.get_key.return_value = KeyVaultKey(
|
|
44
|
+
key_name,
|
|
45
|
+
jwk={
|
|
46
|
+
"kty": "EC",
|
|
47
|
+
"crv": "P-{}".format(curve.key_size),
|
|
48
|
+
"x": int.to_bytes(pub_key.x, 128, "big"),
|
|
49
|
+
"y": int.to_bytes(pub_key.y, 128, "big"),
|
|
50
|
+
},
|
|
51
|
+
)
|
|
44
52
|
|
|
45
53
|
|
|
46
54
|
class TestECDSAKey(TestCase):
|
|
47
55
|
ALL_SUPPORTED_ALGOS: Tuple[Tuple[EllipticCurve, HashAlgorithm]] = (
|
|
48
56
|
(ec.SECP256R1(), hashes.SHA256()),
|
|
49
57
|
(ec.SECP384R1(), hashes.SHA384()),
|
|
50
|
-
(ec.SECP521R1(), hashes.SHA512())
|
|
58
|
+
(ec.SECP521R1(), hashes.SHA512()),
|
|
51
59
|
)
|
|
52
60
|
|
|
53
61
|
TEST_KEY_NAME = "https://test-vault.vault.azure.net/keys/abc/1"
|
|
54
62
|
|
|
55
63
|
@patch("azure.keyvault.keys.crypto.CryptographyClient")
|
|
56
64
|
@patch("azure.keyvault.keys.KeyClient")
|
|
57
|
-
def test_key_from_cloud_can_sign(
|
|
65
|
+
def test_key_from_cloud_can_sign(
|
|
66
|
+
self, key_client_mock: Mock, crypto_client_mock: Mock
|
|
67
|
+
):
|
|
58
68
|
from paramiko_cloud.azure.keys import ECDSAKey
|
|
69
|
+
|
|
59
70
|
for curve, hash_ in self.ALL_SUPPORTED_ALGOS:
|
|
60
|
-
with self.subTest(
|
|
61
|
-
|
|
71
|
+
with self.subTest(
|
|
72
|
+
"Using curve {} and hash {}".format(curve.name, hash_.name)
|
|
73
|
+
):
|
|
74
|
+
set_up_mocks(
|
|
75
|
+
key_client_mock,
|
|
76
|
+
crypto_client_mock,
|
|
77
|
+
curve,
|
|
78
|
+
hash_,
|
|
79
|
+
self.TEST_KEY_NAME,
|
|
80
|
+
)
|
|
62
81
|
key = ECDSAKey(None, None, "test_key")
|
|
63
82
|
signature = key.sign_ssh_data(b"hello world")
|
|
64
83
|
signature.rewind()
|
|
65
|
-
self.assertTrue(
|
|
84
|
+
self.assertTrue(
|
|
85
|
+
key.verify_ssh_sig(b"hello world", signature),
|
|
86
|
+
"Signature is invalid",
|
|
87
|
+
)
|
|
66
88
|
|
|
67
89
|
@patch("azure.keyvault.keys.crypto.CryptographyClient")
|
|
68
90
|
@patch("azure.keyvault.keys.KeyClient")
|
|
69
|
-
def test_key_from_cloud_can_produce_valid_certificate(
|
|
91
|
+
def test_key_from_cloud_can_produce_valid_certificate(
|
|
92
|
+
self, key_client_mock: Mock, crypto_client_mock: Mock
|
|
93
|
+
):
|
|
70
94
|
from paramiko_cloud.azure.keys import ECDSAKey
|
|
95
|
+
|
|
71
96
|
for curve, hash_ in self.ALL_SUPPORTED_ALGOS:
|
|
72
|
-
with self.subTest(
|
|
73
|
-
|
|
97
|
+
with self.subTest(
|
|
98
|
+
"Using curve {} and hash {}".format(curve.name, hash_.name)
|
|
99
|
+
):
|
|
100
|
+
set_up_mocks(
|
|
101
|
+
key_client_mock,
|
|
102
|
+
crypto_client_mock,
|
|
103
|
+
curve,
|
|
104
|
+
hash_,
|
|
105
|
+
self.TEST_KEY_NAME,
|
|
106
|
+
)
|
|
74
107
|
ca_key = ECDSAKey(None, None, "test_key")
|
|
75
108
|
client_key = RSAKey.generate(1024)
|
|
76
|
-
cert_string = ca_key.sign_certificate(
|
|
109
|
+
cert_string = ca_key.sign_certificate(
|
|
110
|
+
client_key, ["test.user"]
|
|
111
|
+
).cert_string()
|
|
77
112
|
exit_code, cert_details = parse_certificate(cert_string)
|
|
78
113
|
self.assertEqual(
|
|
79
114
|
cert_details.public_key,
|
|
80
|
-
"RSA-CERT SHA256:{}".format(sha256_fingerprint(client_key))
|
|
115
|
+
"RSA-CERT SHA256:{}".format(sha256_fingerprint(client_key)),
|
|
81
116
|
)
|
|
82
117
|
self.assertEqual(
|
|
83
118
|
cert_details.signing_ca,
|
|
84
119
|
"ECDSA SHA256:{} (using ecdsa-sha2-nistp{})".format(
|
|
85
|
-
sha256_fingerprint(ca_key),
|
|
86
|
-
|
|
87
|
-
)
|
|
120
|
+
sha256_fingerprint(ca_key), ca_key.ecdsa_curve.key_length
|
|
121
|
+
),
|
|
88
122
|
)
|
|
89
123
|
self.assertEqual(
|
|
90
|
-
exit_code,
|
|
124
|
+
exit_code,
|
|
125
|
+
0,
|
|
91
126
|
"Could not parse generated certificate with ssh-keygen, exit code {}".format(
|
|
92
127
|
exit_code
|
|
93
|
-
)
|
|
128
|
+
),
|
|
94
129
|
)
|
|
@@ -2,9 +2,14 @@ import abc
|
|
|
2
2
|
import base64
|
|
3
3
|
import hashlib
|
|
4
4
|
from datetime import datetime
|
|
5
|
-
from typing import
|
|
6
|
-
|
|
7
|
-
from cryptography.hazmat.primitives.asymmetric.ec import
|
|
5
|
+
from typing import Any, Callable, IO, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric.ec import (
|
|
8
|
+
ECDSA,
|
|
9
|
+
EllipticCurvePublicKey,
|
|
10
|
+
EllipticCurve,
|
|
11
|
+
)
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric.utils import Prehashed
|
|
8
13
|
from paramiko import ECDSAKey, Message
|
|
9
14
|
|
|
10
15
|
from paramiko_cloud.pki import CertificateSigningKeyMixin
|
|
@@ -37,7 +42,10 @@ class CloudSigningKey(abc.ABC):
|
|
|
37
42
|
Returns:
|
|
38
43
|
The hash of the data
|
|
39
44
|
"""
|
|
40
|
-
|
|
45
|
+
algorithm = signature_algorithm.algorithm
|
|
46
|
+
if isinstance(algorithm, Prehashed):
|
|
47
|
+
algorithm = algorithm._algorithm
|
|
48
|
+
return getattr(hashlib, algorithm.name)(data).digest()
|
|
41
49
|
|
|
42
50
|
def sign(self, data: bytes, signature_algorithm: ECDSA) -> bytes:
|
|
43
51
|
"""
|
|
@@ -68,20 +76,28 @@ class BaseKeyECDSA(ECDSAKey, CertificateSigningKeyMixin):
|
|
|
68
76
|
"""
|
|
69
77
|
super().__init__(vals=vals)
|
|
70
78
|
|
|
71
|
-
def write_private_key_file(
|
|
79
|
+
def write_private_key_file(
|
|
80
|
+
self, filename: str, password: Optional[str] = None
|
|
81
|
+
) -> None:
|
|
72
82
|
raise RuntimeError("Private key managed externally, cannot export")
|
|
73
83
|
|
|
74
|
-
def write_private_key(
|
|
84
|
+
def write_private_key(
|
|
85
|
+
self, file_obj: IO[str], password: Optional[str] = None
|
|
86
|
+
) -> None:
|
|
75
87
|
raise RuntimeError("Private key managed externally, cannot export")
|
|
76
88
|
|
|
77
89
|
@classmethod
|
|
78
90
|
def generate(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
91
|
+
cls,
|
|
92
|
+
curve: Optional[EllipticCurve] = None,
|
|
93
|
+
progress_func: Optional[Callable[..., Any]] = None,
|
|
94
|
+
bits: Optional[int] = None,
|
|
95
|
+
) -> "BaseKeyECDSA":
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
"Create new signing keys using the KMS client for your cloud provider"
|
|
98
|
+
)
|
|
83
99
|
|
|
84
|
-
def pubkey_string(self, comment=None) -> str:
|
|
100
|
+
def pubkey_string(self, comment: Optional[str] = None) -> str:
|
|
85
101
|
"""
|
|
86
102
|
Render a string suitable for OpenSSH authorized_keys files
|
|
87
103
|
|
|
@@ -97,5 +113,5 @@ class BaseKeyECDSA(ECDSAKey, CertificateSigningKeyMixin):
|
|
|
97
113
|
return "{key_type} {pubkey_string} {comment}".format(
|
|
98
114
|
key_type=key_type,
|
|
99
115
|
pubkey_string=base64.standard_b64encode(key_bytes).decode(),
|
|
100
|
-
comment=comment or datetime.now().isoformat()
|
|
116
|
+
comment=comment or datetime.now().isoformat(),
|
|
101
117
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Optional
|
|
1
|
+
from typing import Optional, cast
|
|
2
2
|
|
|
3
3
|
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey, ECDSA
|
|
4
4
|
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
|
@@ -10,6 +10,7 @@ class _LocalSigningKey(CloudSigningKey):
|
|
|
10
10
|
"""
|
|
11
11
|
A dummy signing key
|
|
12
12
|
"""
|
|
13
|
+
|
|
13
14
|
def __init__(self, key: EllipticCurvePrivateKey):
|
|
14
15
|
super().__init__(key.curve)
|
|
15
16
|
self.key = key
|
|
@@ -26,7 +27,10 @@ class ECDSAKey(BaseKeyECDSA):
|
|
|
26
27
|
pem_private_key: A PEM-formatted private key
|
|
27
28
|
password: An optional password to decrypt the private key
|
|
28
29
|
"""
|
|
30
|
+
|
|
29
31
|
def __init__(self, pem_private_key: bytes, password: Optional[bytes] = None):
|
|
30
|
-
private_key
|
|
32
|
+
private_key = cast(
|
|
33
|
+
EllipticCurvePrivateKey, load_pem_private_key(pem_private_key, password)
|
|
34
|
+
)
|
|
31
35
|
public_key = private_key.public_key()
|
|
32
36
|
super().__init__((_LocalSigningKey(private_key), public_key))
|