Skip to content

Commit

Permalink
Add HSMSigner and HSMKey
Browse files Browse the repository at this point in the history
Adds basic Signer and Key implementations to generate signatures on
a hardware security module (HSMSigner) and to export the corresponding
public key (HSMKey).

Supported keys are ecdsa on SECG curves secp256r1 (NIST P-256) or
secp384r1 (NIST P-384), which correspond to securesystemslib
signing schemes "ecdsa-sha2-nistp256" and "ecdsa-sha2-nistp384".

Tests are performed on SoftHSM (virtual hsm).

**Caveat**
HSMSigner and HSMKey use the token from a passed PyKCS11 session.
This means that users must identify the correct slot, token and key,
open a session, optionally log in (for signing), and also log out
and close the session afterwards.

This is not user-friendly. Ideally, the user only identifies the
correct slot, token and key out-of-band (e.g. with pkcs11-tool,
yubico-piv-tool or ykman) and then passes a stable identifier to
HSMSigner. Maybe labels? Slot id is not stable.

**Other ideas**
- HSMKey is an SSlibKey with an *import key from HSM* method.
  The method could be moved to different import API (see secure-systems-lab#466),
  and HSMKey could be removed.
- HSMSigner could live in a dedicated _hsm_signer.py, this would
  better hide the conditional imports.

Signed-off-by: Lukas Puehringer <lukas.puehringer@nyu.edu>
  • Loading branch information
lukpueh committed Dec 1, 2022
1 parent 14ee264 commit 35c224d
Show file tree
Hide file tree
Showing 10 changed files with 487 additions and 3 deletions.
59 changes: 59 additions & 0 deletions .github/workflows/hsm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Run Securesystemslib HSM tests

on:
push:
branches:
- master
pull_request:
workflow_dispatch:

permissions: {}

jobs:
build:
strategy:
fail-fast: false
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
os: [ubuntu-latest, macos-latest, windows-latest]

runs-on: ${{ matrix.os }}

steps:
- name: Checkout securesystemslib
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@13ae5bb136fac2878aff31522b9efb785519f984
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: "requirements*.txt"

- name: Install system dependencies
shell: bash
run: |
if [ "$RUNNER_OS" == "Linux" ]; then
sudo apt-get install -y softhsm2
echo "PYKCS11LIB=/usr/lib/softhsm/libsofthsm2.so" >> $GITHUB_ENV
elif [ "$RUNNER_OS" == "macOS" ]; then
brew install softhsm
echo "PYKCS11LIB=$(brew --prefix softhsm)/lib/softhsm/libsofthsm2.so" >> $GITHUB_ENV
elif [ "$RUNNER_OS" == "Windows" ]; then
choco install softhsm.install
echo "PYKCS11LIB=C:\SoftHSM2\lib\softhsm2-x64.dll" >> $GITHUB_ENV
else
echo "$RUNNER_OS not supported"
exit 1
fi
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install --upgrade tox
- name: Run tox
run: tox -e hsm
1 change: 1 addition & 0 deletions pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ check-quote-consistency=yes

[TYPECHECK]
generated-members=shake_128s.*
ignored-modules=PyKCS11
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Issues = "https://github.com/secure-systems-lab/securesystemslib/issues"
crypto = ["cryptography>=37.0.0"]
pynacl = ["pynacl>1.2.0"]
PySPX = ["PySPX==0.5.0"]
hsm = ["asn1crypto", "cryptography", "PyKCS11"]

[tool.setuptools]
include-package-data = true
Expand Down
16 changes: 16 additions & 0 deletions requirements-hsm-pinned.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#
# This file is autogenerated by pip-compile with python 3.8
# To update, run:
#
# pip-compile --output-file=requirements-hsm-pinned.txt requirements-hsm.txt
#
asn1crypto==1.5.1
# via -r requirements-hsm.txt
cffi==1.15.1
# via cryptography
cryptography==38.0.4
# via -r requirements-hsm.txt
pycparser==2.21
# via cffi
pykcs11==1.5.11
# via -r requirements-hsm.txt
3 changes: 3 additions & 0 deletions requirements-hsm.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
asn1crypto
cryptography
PyKCS11
8 changes: 7 additions & 1 deletion securesystemslib/signer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
This module provides extensible interfaces for public keys and signers:
Some implementations are provided by default but more can be added by users.
"""
from securesystemslib.signer._key import KEY_FOR_TYPE_AND_SCHEME, Key, SSlibKey
from securesystemslib.signer._key import (
KEY_FOR_TYPE_AND_SCHEME,
HSMKey,
Key,
SSlibKey,
)
from securesystemslib.signer._signature import GPGSignature, Signature
from securesystemslib.signer._signer import (
SIGNER_FOR_URI_SCHEME,
GPGSigner,
HSMSigner,
SecretsHandler,
Signer,
SSlibSigner,
Expand Down
120 changes: 119 additions & 1 deletion securesystemslib/signer/_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,39 @@
from typing import Any, Dict, Optional, Tuple, Type

import securesystemslib.keys as sslib_keys
from securesystemslib import exceptions
from securesystemslib import KEY_TYPE_ECDSA, exceptions
from securesystemslib.signer._signature import Signature

# pylint: disable=wrong-import-position
CRYPTO_IMPORT_ERROR = None
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ec import (
SECP256R1,
SECP384R1,
EllipticCurvePublicKey,
ObjectIdentifier,
get_curve_for_oid,
)

except ImportError: # pragma: no cover
CRYPTO_IMPORT_ERROR = "'cryptography' required"

PYKCS11_IMPORT_ERROR = None
try:
from PyKCS11 import PyKCS11

except ImportError: # pragma: no cover
PYKCS11_IMPORT_ERROR = "'PyKCS11' required"

ASN1CRYPTO_IMPORT_ERROR = None
try:
from asn1crypto.keys import ECDomainParameters, ECPoint

except ImportError: # pragma: no cover
ASN1CRYPTO_IMPORT_ERROR = "'asn1crypto' required"
# pylint: enable=wrong-import-position

logger = logging.getLogger(__name__)

# NOTE dict for Key dispatch defined here, but filled at end of file when
Expand Down Expand Up @@ -183,6 +213,94 @@ def verify_signature(self, signature: Signature, data: bytes) -> None:
) from e


class HSMKey(SSlibKey):
"""Hardware Security Module (HSM) Key
HSMKey is a regular SSlibKey with an additional `from_hsm` method to
export public keys from hardware security modules.
"""

@classmethod
def from_hsm(
cls,
hsm_session: "PyKCS11.Session",
hsm_keyid: Tuple[int, ...],
keyid: str,
):
"""Export public key from HSM
Supports ecdsa on SECG curves secp256r1 (NIST P-256) or secp384r1 (NIST P-384).
Arguments:
hsm_session: An open ``PyKCS11.Session`` to the token with the public key.
hsm_keyid: Key identifier on the token.
keyid: Key identifier that is unique within the metadata it is used in.
Raises:
ValueError: No compatible key for ``hsm_keyid`` found on HSM.
PyKCS11.PyKCS11Error: Various HSM communication errors.
"""
if CRYPTO_IMPORT_ERROR:
raise exceptions.UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)

if PYKCS11_IMPORT_ERROR:
raise exceptions.UnsupportedLibraryError(PYKCS11_IMPORT_ERROR)

# Search for ecdsa public keys with passed keyid on HSM
keys = hsm_session.findObjects(
[
(PyKCS11.CKA_CLASS, PyKCS11.CKO_PUBLIC_KEY),
(PyKCS11.CKA_KEY_TYPE, PyKCS11.CKK_ECDSA),
(PyKCS11.CKA_ID, hsm_keyid),
]
)

if len(keys) != 1:
raise ValueError(
f"hsm_keyid must identify one {KEY_TYPE_ECDSA} key, found {len(keys)}"
)

# Extract public key domain parameters and point from HSM
hsm_params, hsm_point = hsm_session.getAttributeValue(
keys[0], [PyKCS11.CKA_EC_PARAMS, PyKCS11.CKA_EC_POINT]
)

params = ECDomainParameters.load(bytes(hsm_params))

# TODO: Define as module level constant and don't hardcode scheme strings
scheme_for_curve = {
SECP256R1: "ecdsa-sha2-nistp256",
SECP384R1: "ecdsa-sha2-nistp384",
}
curve_names = [curve.name for curve in scheme_for_curve]

if params.chosen.native not in curve_names:
raise ValueError(
f"found key on {params.chosen.native}, should be on one of {curve_names}"
)

# Create PEM from key
curve = get_curve_for_oid(ObjectIdentifier(params.chosen.dotted))
public_pem = (
EllipticCurvePublicKey.from_encoded_point(
curve(), ECPoint().load(bytes(hsm_point)).native
)
.public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode()
)

return HSMKey(
keyid,
KEY_TYPE_ECDSA,
scheme_for_curve[curve],
{"public": public_pem},
)


# Supported key types and schemes, and the Keys implementing them
KEY_FOR_TYPE_AND_SCHEME = {
("ecdsa", "ecdsa-sha2-nistp256"): SSlibKey,
Expand Down
118 changes: 117 additions & 1 deletion securesystemslib/signer/_signer.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
"""Signer interface and the default implementations"""

import abc
import binascii
import logging
import os
from typing import Callable, Dict, Optional, Type
from typing import Callable, Dict, Optional, Tuple, Type
from urllib import parse

# pylint: disable=wrong-import-position
CRYPTO_IMPORT_ERROR = None
try:
from cryptography.hazmat.primitives.asymmetric.utils import (
encode_dss_signature,
)
except ImportError: # pragma: no cover
CRYPTO_IMPORT_ERROR = "'cryptography' required"

PYKCS11_IMPORT_ERROR = None
try:
from PyKCS11 import PyKCS11

except ImportError: # pragma: no cover
PYKCS11_IMPORT_ERROR = "'PyKCS11' required"
# pylint: enable=wrong-import-position

import securesystemslib.gpg.functions as gpg
import securesystemslib.keys as sslib_keys
from securesystemslib.exceptions import UnsupportedLibraryError
from securesystemslib.signer._key import Key, SSlibKey
from securesystemslib.signer._signature import GPGSignature, Signature

Expand Down Expand Up @@ -275,6 +294,103 @@ def sign(self, payload: bytes) -> GPGSignature:
return GPGSignature(**sig_dict)


class HSMSigner(Signer):
"""Hardware Security Module (HSM) Signer.
HSMSigner uses the PKCS#11/Cryptoki API to sign on an HSM (e.g. YubiKey). It
supports ecdsa on SECG curves secp256r1 (NIST P-256) or secp384r1 (NIST P-384).
Arguments:
hsm_session: An open and logged-in ``PyKCS11.Session`` to the token with the
private key.
hsm_keyid: Key identifier on the token.
public_key: The related public key instance.
Raises:
UnsupportedLibraryError: ``PyKCS11`` and ``cryptography`` libraries not found.
ValueError: ``public_key.scheme`` not supported.
"""

def __init__(
self,
hsm_session: "PyKCS11.Session",
hsm_keyid: Tuple[int, ...],
public_key: Key,
):
if CRYPTO_IMPORT_ERROR:
raise UnsupportedLibraryError(CRYPTO_IMPORT_ERROR)

if PYKCS11_IMPORT_ERROR:
raise UnsupportedLibraryError(PYKCS11_IMPORT_ERROR)

# TODO: Define as module level constant and don't hardcode scheme strings
supported_schemes = {
"ecdsa-sha2-nistp256": PyKCS11.Mechanism(PyKCS11.CKM_ECDSA_SHA256),
"ecdsa-sha2-nistp384": PyKCS11.Mechanism(PyKCS11.CKM_ECDSA_SHA384),
}

if public_key.scheme not in supported_schemes:
raise ValueError(f"unsupported scheme {public_key.scheme}")

self._mechanism = supported_schemes[public_key.scheme]
self.hsm_session = hsm_session
self.hsm_keyid = hsm_keyid
self.public_key = public_key

@classmethod
def from_priv_key_uri(
cls,
priv_key_uri: str,
public_key: Key,
secrets_handler: Optional[SecretsHandler] = None,
) -> "HSMSigner":
raise NotImplementedError("Incompatible with private key URIs")

def sign(self, payload: bytes) -> Signature:
"""Signs payload with Hardware Security Module (HSM).
Arguments:
payload: bytes to be signed.
Raises:
ValueError: No compatible key for ``hsm_keyid`` found on HSM.
PyKCS11.PyKCS11Error: Various HSM communication errors.
Returns:
Signature.
"""

# Search for ecdsa public keys with passed keyid on HSM
keys = self.hsm_session.findObjects(
[
(PyKCS11.CKA_CLASS, PyKCS11.CKO_PRIVATE_KEY),
(PyKCS11.CKA_KEY_TYPE, PyKCS11.CKK_ECDSA),
(PyKCS11.CKA_ID, self.hsm_keyid),
]
)
if len(keys) != 1:
raise ValueError(
f"hsm_keyid must identify one {KEY_TYPE_ECDSA} key, found {len(keys)}"
)

signature = self.hsm_session.sign(keys[0], payload, self._mechanism)

# The PKCS11 signature octets correspond to the concatenation of the ECDSA
# values r and s, both represented as an octet string of equal length of at
# most nLen with the most significant byte first (i.e. big endian)
# https://docs.oasis-open.org/pkcs11/pkcs11-curr/v3.0/cs01/pkcs11-curr-v3.0-cs01.html#_Toc30061178
r_s_len = int(len(signature) / 2)
r = int.from_bytes(signature[:r_s_len], byteorder="big")
s = int.from_bytes(signature[r_s_len:], byteorder="big")

# Create an ASN.1 encoded Dss-Sig-Value to be used with pyca/cryptography
dss_sig_value = binascii.hexlify(encode_dss_signature(r, s)).decode(
"ascii"
)

return Signature(self.public_key.keyid, dss_sig_value)


# Supported private key uri schemes and the Signers implementing them
SIGNER_FOR_URI_SCHEME = {
SSlibSigner.ENVVAR_URI_SCHEME: SSlibSigner,
Expand Down
Loading

0 comments on commit 35c224d

Please sign in to comment.