Skip to content

Commit

Permalink
Add VaultSigner and tests
Browse files Browse the repository at this point in the history
Adds fully functional VaultSigner and basic "import_ -> from_priv_key_uri
-> sign -> verify" test loop using a local vault server.

Tests run on CI and can be run locally with tox, if vault is installed.

Note also that vault supports all key types and signing schemes supported by
SSlibKey. The here added VaultSigner, however, only supports `ed25519`
keys.

Other key types and schemes may be added in follow-up PRs.

Signed-off-by: Lukas Puehringer <[email protected]>
  • Loading branch information
lukpueh committed May 2, 2024
1 parent 557378e commit acae70a
Show file tree
Hide file tree
Showing 10 changed files with 263 additions and 0 deletions.
44 changes: 44 additions & 0 deletions .github/workflows/test-vault.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Run HashiCorp Vault tests

on:
push:
pull_request:

jobs:
local-vault:
runs-on: ubuntu-latest
steps:
- name: Checkout securesystemslib
uses: actions/checkout@1d96c772d19495a3b5c517cd2bc0cb401ea0529f

- name: Set up Python
uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d
with:
python-version: '3.x'
cache: 'pip'
cache-dependency-path: 'requirements*.txt'

- name: Install system dependencies
shell: bash
run: |
sudo apt update && sudo apt install -y gpg wget
wget -O- https://apt.releases.hashicorp.com/gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
gpg --no-default-keyring --fingerprint \
--keyring /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install -y vault
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install --upgrade tox
- name: Run tests
run: tox -e local-vault
3 changes: 3 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ ignore_missing_imports = True

[mypy-botocore.*]
ignore_missing_imports = True

[mypy-hvac.*]
ignore_missing_imports = True
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ awskms = ["boto3", "botocore", "cryptography>=40.0.0"]
hsm = ["asn1crypto", "cryptography>=40.0.0", "PyKCS11"]
PySPX = ["PySPX>=0.5.0"]
sigstore = ["sigstore~=2.0"]
vault = ["hvac", "cryptography>=40.0.0"]

[tool.hatch.version]
path = "securesystemslib/__init__.py"
Expand Down
1 change: 1 addition & 0 deletions requirements-vault.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hvac==2.1.0
2 changes: 2 additions & 0 deletions securesystemslib/signer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
SpxSigner,
generate_spx_key_pair,
)
from securesystemslib.signer._vault_signer import VaultSigner

# Register supported private key uri schemes and the Signers implementing them
SIGNER_FOR_URI_SCHEME.update(
Expand All @@ -34,6 +35,7 @@
GPGSigner.SCHEME: GPGSigner,
AzureSigner.SCHEME: AzureSigner,
AWSSigner.SCHEME: AWSSigner,
VaultSigner.SCHEME: VaultSigner,
}
)

Expand Down
135 changes: 135 additions & 0 deletions securesystemslib/signer/_vault_signer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Signer implementation for HashiCorp Vault (Transit secrets engine)"""

from base64 import b64decode, b64encode
from typing import Optional, Tuple
from urllib import parse

from securesystemslib.exceptions import UnsupportedLibraryError
from securesystemslib.signer._key import Key, SSlibKey
from securesystemslib.signer._signer import SecretsHandler, Signature, Signer

VAULT_IMPORT_ERROR = None
try:
import hvac
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PublicKey,
)

except ImportError:
VAULT_IMPORT_ERROR = (
"Signing with HashiCorp Vault requires hvac and cryptography."
)


class VaultSigner(Signer):
"""Signer for HashiCorp Vault Transit secrets engine
The signer uses "ambient" credentials to connect to vault, most notably
the environment variables ``VAULT_ADDR`` and ``VAULT_TOKEN`` must be set:
https://developer.hashicorp.com/vault/docs/commands#environment-variables
Priv key uri format is: ``hv:<KEY NAME>/<KEY VERSION>``.
Arguments:
hv_key_name: Name of vault key used for signing.
public_key: Related public key instance.
hv_key_version: Version of vault key used for signing.
Raises:
UnsupportedLibraryError: hvac or cryptography are not installed.
"""

SCHEME = "hv"

def __init__(self, hv_key_name: str, public_key: Key, hv_key_version: int):
if VAULT_IMPORT_ERROR:
raise UnsupportedLibraryError(VAULT_IMPORT_ERROR)

self.hv_key_name = hv_key_name
self._public_key = public_key
self.hv_key_version = hv_key_version

# Client caches ambient settings in __init__. This means settings are
# stable for subsequent calls to sign, also if the environment changes.
self._client = hvac.Client()

def sign(self, payload: bytes) -> Signature:
"""Signs payload with HashiCorp Vault Transit secrets engine.
Arguments:
payload: bytes to be signed.
Raises:
Various errors from hvac.
Returns:
Signature.
"""
resp = self._client.secrets.transit.sign_data(
self.hv_key_name,
hash_input=b64encode(payload).decode(),
key_version=self.hv_key_version,
)

sig_b64 = resp["data"]["signature"].split(":")[2]
sig = b64decode(sig_b64).hex()

return Signature(self.public_key.keyid, sig)

@property
def public_key(self) -> Key:
return self._public_key

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

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

name, version = uri.path.split("/")

return cls(name, public_key, int(version))

@classmethod
def import_(cls, hv_key_name: str) -> Tuple[str, Key]:
"""Load key and signer details from HashiCorp Vault.
If multiple keys exist in the vault under the passed name, only the
newest key is returned. Supported key type is: ed25519
See class documentation for details about settings and uri format.
Arguments:
hv_key_name: Name of vault key to import.
Raises:
UnsupportedLibraryError: hvac or cryptography are not installed.
Various errors from hvac.
Returns:
Private key uri and public key.
"""
if VAULT_IMPORT_ERROR:
raise UnsupportedLibraryError(VAULT_IMPORT_ERROR)

client = hvac.Client()
resp = client.secrets.transit.read_key(hv_key_name)

# Pick key with highest version number
version, key_info = sorted(resp["data"]["keys"].items())[-1]

crypto_key = Ed25519PublicKey.from_public_bytes(
b64decode(key_info["public_key"])
)

key = SSlibKey.from_crypto(crypto_key)
uri = f"{VaultSigner.SCHEME}:{hv_key_name}/{version}"

return uri, key
38 changes: 38 additions & 0 deletions tests/check_vault_signer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Test VaultSigner
"""

import unittest

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


class TestVaultSigner(unittest.TestCase):
"""Test VaultSigner"""

def test_vault_import_sign_verify(self):
# Test full signer flow with vault
# - see tests/scripts/init-vault.sh for how keys are created
# - see tox.ini for how credentials etc. are passed via env vars
keys_and_schemes = [("test-key-ed25519", 1, "ed25519")]
for name, version, scheme in keys_and_schemes:
# Test import
uri, public_key = VaultSigner.import_(name)

self.assertEqual(uri, f"{VaultSigner.SCHEME}:{name}/{version}")
self.assertEqual(public_key.scheme, scheme)

# Test load
signer = Signer.from_priv_key_uri(uri, public_key)
self.assertIsInstance(signer, VaultSigner)

# Test sign and verify
signature = signer.sign(b"DATA")
self.assertIsNone(public_key.verify_signature(signature, b"DATA"))
with self.assertRaises(UnverifiedSignatureError):
public_key.verify_signature(signature, b"NOT DATA")


if __name__ == "__main__":
unittest.main(verbosity=1)
12 changes: 12 additions & 0 deletions tests/scripts/init-vault.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env bash

vault server -dev -dev-root-token-id="${VAULT_TOKEN}" &

until vault status
do
sleep 0.1
done

vault secrets enable transit

vault write -force transit/keys/test-key-ed25519 type=ed25519
3 changes: 3 additions & 0 deletions tests/scripts/stop-vault.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash

pkill -f "vault server -dev"
24 changes: 24 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,27 @@ commands =
commands_post =
# Stop virtual AWS KMS
localstack stop


# Requires `vault`
# https://developer.hashicorp.com/vault/tutorials/getting-started/getting-started-install
[testenv:local-vault]
deps =
-r{toxinidir}/requirements-pinned.txt
-r{toxinidir}/requirements-vault.txt

allowlist_externals =
bash

setenv =
VAULT_ADDR = http://localhost:8200
VAULT_TOKEN = test-root-token

commands_pre =
bash {toxinidir}/tests/scripts/init-vault.sh

commands =
python -m tests.check_vault_signer

commands_post =
bash {toxinidir}/tests/scripts/stop-vault.sh

0 comments on commit acae70a

Please sign in to comment.