Skip to content

Commit 5789578

Browse files
author
Lukas Pühringer
authored
Merge pull request #800 from lukpueh/vault-signer
Add VaultSigner and tests
2 parents 66a56cb + acae70a commit 5789578

File tree

10 files changed

+263
-0
lines changed

10 files changed

+263
-0
lines changed

.github/workflows/test-vault.yaml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Run HashiCorp Vault tests
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
local-vault:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- name: Checkout securesystemslib
12+
uses: actions/checkout@1d96c772d19495a3b5c517cd2bc0cb401ea0529f
13+
14+
- name: Set up Python
15+
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d
16+
with:
17+
python-version: '3.x'
18+
cache: 'pip'
19+
cache-dependency-path: 'requirements*.txt'
20+
21+
- name: Install system dependencies
22+
shell: bash
23+
run: |
24+
sudo apt update && sudo apt install -y gpg wget
25+
wget -O- https://apt.releases.hashicorp.com/gpg | \
26+
sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
27+
28+
gpg --no-default-keyring --fingerprint \
29+
--keyring /usr/share/keyrings/hashicorp-archive-keyring.gpg
30+
31+
echo "deb [arch=$(dpkg --print-architecture) \
32+
signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
33+
https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
34+
sudo tee /etc/apt/sources.list.d/hashicorp.list
35+
36+
sudo apt update && sudo apt install -y vault
37+
38+
- name: Install dependencies
39+
run: |
40+
python -m pip install --upgrade pip
41+
pip install --upgrade tox
42+
43+
- name: Run tests
44+
run: tox -e local-vault

mypy.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,6 @@ ignore_missing_imports = True
3737

3838
[mypy-botocore.*]
3939
ignore_missing_imports = True
40+
41+
[mypy-hvac.*]
42+
ignore_missing_imports = True

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ awskms = ["boto3", "botocore", "cryptography>=40.0.0"]
5353
hsm = ["asn1crypto", "cryptography>=40.0.0", "PyKCS11"]
5454
PySPX = ["PySPX>=0.5.0"]
5555
sigstore = ["sigstore~=2.0"]
56+
vault = ["hvac", "cryptography>=40.0.0"]
5657

5758
[tool.hatch.version]
5859
path = "securesystemslib/__init__.py"

requirements-vault.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
hvac==2.1.0

securesystemslib/signer/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
SpxSigner,
2525
generate_spx_key_pair,
2626
)
27+
from securesystemslib.signer._vault_signer import VaultSigner
2728

2829
# Register supported private key uri schemes and the Signers implementing them
2930
SIGNER_FOR_URI_SCHEME.update(
@@ -34,6 +35,7 @@
3435
GPGSigner.SCHEME: GPGSigner,
3536
AzureSigner.SCHEME: AzureSigner,
3637
AWSSigner.SCHEME: AWSSigner,
38+
VaultSigner.SCHEME: VaultSigner,
3739
}
3840
)
3941

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Signer implementation for HashiCorp Vault (Transit secrets engine)"""
2+
3+
from base64 import b64decode, b64encode
4+
from typing import Optional, Tuple
5+
from urllib import parse
6+
7+
from securesystemslib.exceptions import UnsupportedLibraryError
8+
from securesystemslib.signer._key import Key, SSlibKey
9+
from securesystemslib.signer._signer import SecretsHandler, Signature, Signer
10+
11+
VAULT_IMPORT_ERROR = None
12+
try:
13+
import hvac
14+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
15+
Ed25519PublicKey,
16+
)
17+
18+
except ImportError:
19+
VAULT_IMPORT_ERROR = (
20+
"Signing with HashiCorp Vault requires hvac and cryptography."
21+
)
22+
23+
24+
class VaultSigner(Signer):
25+
"""Signer for HashiCorp Vault Transit secrets engine
26+
27+
The signer uses "ambient" credentials to connect to vault, most notably
28+
the environment variables ``VAULT_ADDR`` and ``VAULT_TOKEN`` must be set:
29+
https://developer.hashicorp.com/vault/docs/commands#environment-variables
30+
31+
Priv key uri format is: ``hv:<KEY NAME>/<KEY VERSION>``.
32+
33+
Arguments:
34+
hv_key_name: Name of vault key used for signing.
35+
public_key: Related public key instance.
36+
hv_key_version: Version of vault key used for signing.
37+
38+
Raises:
39+
UnsupportedLibraryError: hvac or cryptography are not installed.
40+
"""
41+
42+
SCHEME = "hv"
43+
44+
def __init__(self, hv_key_name: str, public_key: Key, hv_key_version: int):
45+
if VAULT_IMPORT_ERROR:
46+
raise UnsupportedLibraryError(VAULT_IMPORT_ERROR)
47+
48+
self.hv_key_name = hv_key_name
49+
self._public_key = public_key
50+
self.hv_key_version = hv_key_version
51+
52+
# Client caches ambient settings in __init__. This means settings are
53+
# stable for subsequent calls to sign, also if the environment changes.
54+
self._client = hvac.Client()
55+
56+
def sign(self, payload: bytes) -> Signature:
57+
"""Signs payload with HashiCorp Vault Transit secrets engine.
58+
59+
Arguments:
60+
payload: bytes to be signed.
61+
62+
Raises:
63+
Various errors from hvac.
64+
65+
Returns:
66+
Signature.
67+
"""
68+
resp = self._client.secrets.transit.sign_data(
69+
self.hv_key_name,
70+
hash_input=b64encode(payload).decode(),
71+
key_version=self.hv_key_version,
72+
)
73+
74+
sig_b64 = resp["data"]["signature"].split(":")[2]
75+
sig = b64decode(sig_b64).hex()
76+
77+
return Signature(self.public_key.keyid, sig)
78+
79+
@property
80+
def public_key(self) -> Key:
81+
return self._public_key
82+
83+
@classmethod
84+
def from_priv_key_uri(
85+
cls,
86+
priv_key_uri: str,
87+
public_key: Key,
88+
secrets_handler: Optional[SecretsHandler] = None,
89+
) -> "VaultSigner":
90+
uri = parse.urlparse(priv_key_uri)
91+
92+
if uri.scheme != cls.SCHEME:
93+
raise ValueError(f"VaultSigner does not support {priv_key_uri}")
94+
95+
name, version = uri.path.split("/")
96+
97+
return cls(name, public_key, int(version))
98+
99+
@classmethod
100+
def import_(cls, hv_key_name: str) -> Tuple[str, Key]:
101+
"""Load key and signer details from HashiCorp Vault.
102+
103+
If multiple keys exist in the vault under the passed name, only the
104+
newest key is returned. Supported key type is: ed25519
105+
106+
See class documentation for details about settings and uri format.
107+
108+
Arguments:
109+
hv_key_name: Name of vault key to import.
110+
111+
Raises:
112+
UnsupportedLibraryError: hvac or cryptography are not installed.
113+
Various errors from hvac.
114+
115+
Returns:
116+
Private key uri and public key.
117+
118+
"""
119+
if VAULT_IMPORT_ERROR:
120+
raise UnsupportedLibraryError(VAULT_IMPORT_ERROR)
121+
122+
client = hvac.Client()
123+
resp = client.secrets.transit.read_key(hv_key_name)
124+
125+
# Pick key with highest version number
126+
version, key_info = sorted(resp["data"]["keys"].items())[-1]
127+
128+
crypto_key = Ed25519PublicKey.from_public_bytes(
129+
b64decode(key_info["public_key"])
130+
)
131+
132+
key = SSlibKey.from_crypto(crypto_key)
133+
uri = f"{VaultSigner.SCHEME}:{hv_key_name}/{version}"
134+
135+
return uri, key

tests/check_vault_signer.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Test VaultSigner
2+
3+
"""
4+
5+
import unittest
6+
7+
from securesystemslib.exceptions import UnverifiedSignatureError
8+
from securesystemslib.signer import Signer, VaultSigner
9+
10+
11+
class TestVaultSigner(unittest.TestCase):
12+
"""Test VaultSigner"""
13+
14+
def test_vault_import_sign_verify(self):
15+
# Test full signer flow with vault
16+
# - see tests/scripts/init-vault.sh for how keys are created
17+
# - see tox.ini for how credentials etc. are passed via env vars
18+
keys_and_schemes = [("test-key-ed25519", 1, "ed25519")]
19+
for name, version, scheme in keys_and_schemes:
20+
# Test import
21+
uri, public_key = VaultSigner.import_(name)
22+
23+
self.assertEqual(uri, f"{VaultSigner.SCHEME}:{name}/{version}")
24+
self.assertEqual(public_key.scheme, scheme)
25+
26+
# Test load
27+
signer = Signer.from_priv_key_uri(uri, public_key)
28+
self.assertIsInstance(signer, VaultSigner)
29+
30+
# Test sign and verify
31+
signature = signer.sign(b"DATA")
32+
self.assertIsNone(public_key.verify_signature(signature, b"DATA"))
33+
with self.assertRaises(UnverifiedSignatureError):
34+
public_key.verify_signature(signature, b"NOT DATA")
35+
36+
37+
if __name__ == "__main__":
38+
unittest.main(verbosity=1)

tests/scripts/init-vault.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env bash
2+
3+
vault server -dev -dev-root-token-id="${VAULT_TOKEN}" &
4+
5+
until vault status
6+
do
7+
sleep 0.1
8+
done
9+
10+
vault secrets enable transit
11+
12+
vault write -force transit/keys/test-key-ed25519 type=ed25519

tests/scripts/stop-vault.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env bash
2+
3+
pkill -f "vault server -dev"

tox.ini

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,27 @@ commands =
105105
commands_post =
106106
# Stop virtual AWS KMS
107107
localstack stop
108+
109+
110+
# Requires `vault`
111+
# https://developer.hashicorp.com/vault/tutorials/getting-started/getting-started-install
112+
[testenv:local-vault]
113+
deps =
114+
-r{toxinidir}/requirements-pinned.txt
115+
-r{toxinidir}/requirements-vault.txt
116+
117+
allowlist_externals =
118+
bash
119+
120+
setenv =
121+
VAULT_ADDR = http://localhost:8200
122+
VAULT_TOKEN = test-root-token
123+
124+
commands_pre =
125+
bash {toxinidir}/tests/scripts/init-vault.sh
126+
127+
commands =
128+
python -m tests.check_vault_signer
129+
130+
commands_post =
131+
bash {toxinidir}/tests/scripts/stop-vault.sh

0 commit comments

Comments
 (0)