Skip to content

Commit

Permalink
Merge pull request #800 from lukpueh/vault-signer
Browse files Browse the repository at this point in the history
Add VaultSigner and tests
  • Loading branch information
lukpueh authored May 2, 2024
2 parents 66a56cb + acae70a commit 5789578
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 5789578

Please sign in to comment.