diff --git a/CHANGELOG.md b/CHANGELOG.md index 40bc9c60a..b1ae90099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,10 @@ and this project adheres to [Semantic Versioning][semver]. ### Added - +- Add key names from config files to metadata during repository setup, following updates to TUF and securesystemslib [(583)] +- Implement iteration over all inserted YubiKeys during metadata signing [(583)] +- Implement a `PinManager` class to allow secure pin reuse across API functions and eliminated insecure global pin storage [(583)] +- Implement removal of keys [(561)] - Implement removal and rotation of keys [(561)] ### Changed @@ -19,7 +22,7 @@ Transition to the newest version of TUF [(561)] ### Fixed - +[563]: https://github.com/openlawlibrary/taf/pull/563 [561]: https://github.com/openlawlibrary/taf/pull/561 diff --git a/taf/api/api_workflow.py b/taf/api/api_workflow.py index e8073bdeb..4f706f29c 100644 --- a/taf/api/api_workflow.py +++ b/taf/api/api_workflow.py @@ -1,6 +1,6 @@ from contextlib import contextmanager from pathlib import Path -from typing import Dict, List, Optional, Union +from typing import List, Optional, Union from taf.api.utils._conf import find_keystore from taf.auth_repo import AuthenticationRepository @@ -79,13 +79,11 @@ def manage_repo_and_signers( keystore_path = find_keystore(auth_repo.path) else: keystore_path = Path(keystore) - loaded_yubikeys: Dict = {} for role in roles_to_load: if not auth_repo.check_if_keys_loaded(role): keystore_signers, yubikey_signers = load_signers( auth_repo, role, - loaded_yubikeys=loaded_yubikeys, keystore=keystore_path, scheme=scheme, prompt_for_keys=prompt_for_keys, diff --git a/taf/api/dependencies.py b/taf/api/dependencies.py index c5be81827..5095ab13d 100644 --- a/taf/api/dependencies.py +++ b/taf/api/dependencies.py @@ -16,6 +16,7 @@ from taf.log import taf_logger from taf.updater.updater import OperationType, clone_repository import taf.updater.updater as updater +from taf.yubikey.yubikey_manager import PinManager def _add_to_dependencies( @@ -59,6 +60,7 @@ def _add_to_dependencies( @check_if_clean def add_dependency( path: str, + pin_manager: PinManager, dependency_name: str, branch_name: str, out_of_band_commit: str, @@ -107,7 +109,7 @@ def add_dependency( if path is None: raise TAFError("Authentication repository's path not provided") - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) if not auth_repo.is_git_repository_root: taf_logger.error(f"{path} is not a git repository!") return @@ -165,6 +167,7 @@ def add_dependency( commit_msg = git_commit_message("add-dependency", dependency_name=dependency_name) register_target_files( path=path, + pin_manager=pin_manager, keystore=keystore, commit=commit, scheme=scheme, @@ -190,6 +193,7 @@ def add_dependency( @check_if_clean def remove_dependency( path: str, + pin_manager: PinManager, dependency_name: str, keystore: str, scheme: Optional[str] = DEFAULT_RSA_SIGNATURE_SCHEME, @@ -218,7 +222,7 @@ def remove_dependency( if path is None: raise TAFError("Authentication repository's path not provided") - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) if not auth_repo.is_git_repository_root: print(f"{path} is not a git repository!") return @@ -249,6 +253,7 @@ def remove_dependency( register_target_files( path=path, + pin_manager=pin_manager, keystore=keystore, commit=commit, scheme=scheme, diff --git a/taf/api/keystore.py b/taf/api/keystore.py index 077db17af..e7f0422e5 100644 --- a/taf/api/keystore.py +++ b/taf/api/keystore.py @@ -5,7 +5,7 @@ from taf.models.types import RolesKeysData from taf.api.utils._conf import find_taf_directory -from taf.api.roles import _initialize_roles_and_keystore +from taf.api.roles import initialize_roles_and_keystore from taf.keys import get_key_name from taf.log import taf_logger from taf.models.types import RolesIterator @@ -80,7 +80,7 @@ def generate_keys( keystore = "./keystore" taf_logger.log("NOTICE", f"Generating keys in {str(Path(keystore).absolute())}") - roles_key_infos_dict, keystore, _ = _initialize_roles_and_keystore( + roles_key_infos_dict, keystore, _ = initialize_roles_and_keystore( roles_key_infos, str(keystore) ) diff --git a/taf/api/metadata.py b/taf/api/metadata.py index 50de25602..d8090507f 100644 --- a/taf/api/metadata.py +++ b/taf/api/metadata.py @@ -2,6 +2,7 @@ from logging import ERROR from typing import Dict, List, Optional, Tuple from logdecorator import log_on_error +from taf.yubikey.yubikey_manager import PinManager from tuf.api.metadata import Snapshot, Timestamp from taf.api.utils._git import check_if_clean @@ -88,6 +89,7 @@ def print_expiration_dates( @check_if_clean def update_metadata_expiration_date( path: str, + pin_manager: PinManager, roles: List[str], interval: Optional[int] = None, keystore: Optional[str] = None, @@ -126,7 +128,7 @@ def update_metadata_expiration_date( None """ - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) if start_date is None: start_date = datetime.now() @@ -178,6 +180,7 @@ def update_metadata_expiration_date( @check_if_clean def update_snapshot_and_timestamp( path: str, + pin_manager: PinManager, keystore: Optional[str] = None, roles_to_sync: Optional[List[str]] = None, scheme: Optional[str] = DEFAULT_RSA_SIGNATURE_SCHEME, @@ -208,7 +211,7 @@ def update_snapshot_and_timestamp( None """ - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) with manage_repo_and_signers( auth_repo, diff --git a/taf/api/repository.py b/taf/api/repository.py index 8524cfe86..eec9a8a05 100644 --- a/taf/api/repository.py +++ b/taf/api/repository.py @@ -11,7 +11,7 @@ from pathlib import Path from taf.api.roles import ( - _initialize_roles_and_keystore, + initialize_roles_and_keystore, ) from taf.api.targets import list_targets, register_target_files @@ -23,6 +23,7 @@ from taf.tuf.repository import METADATA_DIRECTORY_NAME from taf.utils import ensure_pre_push_hook from taf.log import taf_logger +from taf.yubikey.yubikey_manager import PinManager @log_on_start( @@ -38,6 +39,7 @@ ) def create_repository( path: str, + pin_manager: PinManager, keystore: Optional[str] = None, roles_key_infos: Optional[str] = None, commit: Optional[bool] = False, @@ -68,14 +70,15 @@ def create_repository( keystore_path = find_keystore(path) if keystore_path is not None: keystore = str(keystore_path) - roles_key_infos_dict, keystore, skip_prompt = _initialize_roles_and_keystore( + roles_key_infos_dict, keystore, skip_prompt = initialize_roles_and_keystore( roles_key_infos, keystore ) roles_keys_data = from_dict(roles_key_infos_dict, RolesKeysData) - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) signers, verification_keys = load_sorted_keys_of_new_roles( roles=roles_keys_data.roles, + auth_repo=auth_repo, yubikeys_data=roles_keys_data.yubikeys, keystore=keystore, skip_prompt=skip_prompt, @@ -84,8 +87,7 @@ def create_repository( if signers is None: return - repository = AuthenticationRepository(path=path) - repository.create(roles_keys_data, signers, verification_keys) + auth_repo.create(roles_keys_data, signers, verification_keys) if commit: auth_repo.init_repo() commit_msg = git_commit_message("create-repo") diff --git a/taf/api/roles.py b/taf/api/roles.py index 64b02d858..22afa0444 100644 --- a/taf/api/roles.py +++ b/taf/api/roles.py @@ -30,6 +30,7 @@ from taf.messages import git_commit_message from securesystemslib.signer._key import SSlibKey +from taf.yubikey.yubikey_manager import PinManager @log_on_start(DEBUG, "Adding a new role {role:s}", logger=taf_logger) @@ -44,6 +45,7 @@ @check_if_clean def add_role( path: str, + pin_manager: PinManager, role: str, parent_role: str, paths: list, @@ -87,7 +89,9 @@ def add_role( """ if auth_repo is None: - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) + elif auth_repo.pin_manager is None: + auth_repo.pin_manager = pin_manager if not parent_role: parent_role = "targets" @@ -102,6 +106,27 @@ def add_role( commit_msg = git_commit_message("add-role", role=role) metadata_path = Path(METADATA_DIRECTORY_NAME, f"{role}.json") + targets_parent_role = TargetsRole() + if parent_role != "targets": + targets_parent_role.name = parent_role + targets_parent_role.paths = [] + + new_role = TargetsRole() + new_role.name = role + new_role.parent = targets_parent_role + new_role.paths = paths + new_role.number = keys_number + new_role.threshold = threshold + new_role.yubikey = yubikey + + signers, verification_keys = load_sorted_keys_of_new_roles( + roles=new_role, + auth_repo=auth_repo, + yubikeys_data=None, + keystore=keystore_path, + skip_prompt=skip_prompt, + certs_dir=auth_repo.certs_dir, + ) with manage_repo_and_signers( auth_repo, roles=[parent_role], @@ -115,27 +140,7 @@ def add_role( commit_msg=commit_msg, paths_to_reset_on_error=[metadata_path], ): - targets_parent_role = TargetsRole() - if parent_role != "targets": - targets_parent_role.name = parent_role - targets_parent_role.paths = [] - - new_role = TargetsRole() - new_role.name = role - new_role.parent = targets_parent_role - new_role.paths = paths - new_role.number = keys_number - new_role.threshold = threshold - new_role.yubikey = yubikey - - signers, _ = load_sorted_keys_of_new_roles( - roles=new_role, - yubikeys_data=None, - keystore=keystore_path, - skip_prompt=skip_prompt, - certs_dir=auth_repo.certs_dir, - ) - auth_repo.create_delegated_roles([new_role], signers) + auth_repo.create_delegated_roles([new_role], signers, verification_keys) auth_repo.add_new_roles_to_snapshot([new_role.name]) auth_repo.do_timestamp() @@ -151,6 +156,7 @@ def add_role( ) def add_role_paths( paths: List[str], + pin_manager: PinManager, delegated_role: str, keystore: str, commit: Optional[bool] = True, @@ -181,7 +187,9 @@ def add_role_paths( """ if auth_repo is None: - auth_repo = AuthenticationRepository(path=auth_path) + auth_repo = AuthenticationRepository(path=auth_path, pin_manger=pin_manager) + elif auth_repo.pin_manager is None: + auth_repo.pin_manager = pin_manager parent_role = auth_repo.find_delegated_roles_parent(delegated_role) if all( @@ -221,8 +229,9 @@ def add_role_paths( reraise=True, ) @check_if_clean -def add_multiple_roles( +def add_roles( path: str, + pin_manager: PinManager, keystore: Optional[str] = None, roles_key_infos: Optional[str] = None, scheme: Optional[str] = DEFAULT_RSA_SIGNATURE_SCHEME, @@ -250,11 +259,13 @@ def add_multiple_roles( None """ + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) roles_keys_data_new = _initialize_roles_and_keystore_for_existing_repo( - path, roles_key_infos, keystore + path, + auth_repo, + roles_key_infos, + keystore, ) - - auth_repo = AuthenticationRepository(path=path) roles_data = auth_repo.generate_roles_description() roles_keys_data_current = from_dict(roles_data, RolesKeysData) new_roles_data, _ = compare_roles_data(roles_keys_data_current, roles_keys_data_new) @@ -278,6 +289,20 @@ def add_multiple_roles( ] keystore_path = roles_keys_data_new.keystore + all_signers = {} + all_verification_keys = {} + for role_to_add_data in roles_to_add_data: + signers, verification_keys = load_sorted_keys_of_new_roles( + roles=role_to_add_data, + auth_repo=auth_repo, + yubikeys_data=None, + keystore=keystore_path, + skip_prompt=not prompt_for_keys, + certs_dir=auth_repo.certs_dir, + ) + all_signers.update(signers) + all_verification_keys.update(verification_keys) + with manage_repo_and_signers( auth_repo, roles=roles_to_load, @@ -289,18 +314,10 @@ def add_multiple_roles( commit=commit, push=push, ): - all_signers = {} - for role_to_add_data in roles_to_add_data: - signers, _ = load_sorted_keys_of_new_roles( - roles=role_to_add_data, - yubikeys_data=None, - keystore=keystore_path, - skip_prompt=not prompt_for_keys, - certs_dir=auth_repo.certs_dir, - ) - all_signers.update(signers) - auth_repo.create_delegated_roles(roles_to_add_data, all_signers) + auth_repo.create_delegated_roles( + roles_to_add_data, all_signers, all_verification_keys + ) auth_repo.add_new_roles_to_snapshot(roles_to_add) auth_repo.do_timestamp() @@ -317,6 +334,7 @@ def add_multiple_roles( @check_if_clean def add_signing_key( path: str, + pin_manager: PinManager, roles: List[str], pub_key_path: Optional[str] = None, pub_key: Optional[SSlibKey] = None, @@ -356,7 +374,7 @@ def add_signing_key( roles_keys = {role: [pub_key] for role in roles} - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) with manage_repo_and_signers( auth_repo, @@ -397,6 +415,7 @@ def add_signing_key( @check_if_clean def revoke_signing_key( path: str, + pin_manager: PinManager, key_id: str, roles: Optional[List[str]] = None, keystore: Optional[str] = None, @@ -428,7 +447,7 @@ def revoke_signing_key( None """ - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) roles_to_update = roles or auth_repo.find_keysid_roles([key_id]) @@ -468,6 +487,7 @@ def revoke_signing_key( @check_if_clean def rotate_signing_key( path: str, + pin_manager: PinManager, key_id: str, pub_key_path: Optional[str] = None, roles: Optional[List[str]] = None, @@ -507,12 +527,13 @@ def rotate_signing_key( pub_key = _load_pub_key_from_file( pub_key_path, prompt_for_keys=prompt_for_keys, scheme=scheme ) - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) roles = roles or auth_repo.find_keysid_roles([key_id]) with transactional_execution(auth_repo): revoke_signing_key( path=path, + pin_manager=pin_manager, key_id=key_id, roles=roles, keystore=keystore, @@ -525,6 +546,7 @@ def rotate_signing_key( add_signing_key( path=path, + pin_manager=pin_manager, roles=roles, pub_key=pub_key, keystore=keystore, @@ -688,8 +710,45 @@ def _read_val(input_type, name, param=None, required=False): pass +def _transform_roles_dict(data, auth_repo): + key_names = auth_repo.keys_name_mappings.values() + + transformed_data = data.copy() + + yubikeys_data = transformed_data.get("yubikeys", {}) + for key_name in key_names: + if key_name not in yubikeys_data: + yubikeys_data[key_name] = {} + + transformed_roles = {"root": {}, "snapshot": {}, "timestamp": {}} + + if "roles" in data: + for role_name, role_data in data["roles"].items(): + parent_role = role_data.pop("parent_role") + + if parent_role == "targets": + if "targets" not in transformed_roles: + transformed_roles["targets"] = {"delegations": {}} + transformed_roles["targets"]["delegations"][role_name] = role_data + else: + if "targets" not in transformed_roles: + transformed_roles["targets"] = {"delegations": {}} + if parent_role not in transformed_roles["targets"]["delegations"]: + transformed_roles["targets"]["delegations"][parent_role] = { + "delegations": {} + } + transformed_roles["targets"]["delegations"][parent_role]["delegations"][ + role_name + ] = role_data + + transformed_data["roles"] = transformed_roles + + return transformed_data + + def _initialize_roles_and_keystore_for_existing_repo( path: str, + auth_repo: AuthenticationRepository, roles_key_infos: Optional[str], keystore: Optional[str], enter_info: Optional[bool] = True, @@ -698,6 +757,8 @@ def _initialize_roles_and_keystore_for_existing_repo( if not roles_key_infos_dict and enter_info: roles_key_infos_dict = _enter_roles_infos(None, roles_key_infos) + elif roles_key_infos_dict: + roles_key_infos_dict = _transform_roles_dict(roles_key_infos_dict, auth_repo) roles_keys_data = from_dict(roles_key_infos_dict, RolesKeysData) keystore = keystore or roles_keys_data.keystore if keystore is None and path is not None: @@ -709,7 +770,7 @@ def _initialize_roles_and_keystore_for_existing_repo( return roles_keys_data -def _initialize_roles_and_keystore( +def initialize_roles_and_keystore( roles_key_infos: Optional[str], keystore: Optional[str], enter_info: Optional[bool] = True, @@ -866,6 +927,7 @@ def list_keys_of_role( @check_if_clean def remove_role( path: str, + pin_manager: PinManager, role: str, keystore: str, scheme: Optional[str] = DEFAULT_RSA_SIGNATURE_SCHEME, @@ -997,6 +1059,7 @@ def remove_role( ) def remove_paths( path: str, + pin_manager: PinManager, paths: List[str], keystore: str, scheme: Optional[str] = DEFAULT_RSA_SIGNATURE_SCHEME, @@ -1022,7 +1085,7 @@ def remove_paths( Returns: True if the delegation existed, False otherwise """ - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) paths_to_remove_from_roles = defaultdict(list) for path_to_remove in paths: delegated_role = auth_repo.get_role_from_target_paths([path_to_remove]) diff --git a/taf/api/targets.py b/taf/api/targets.py index 7a30051cd..e3e08a65a 100644 --- a/taf/api/targets.py +++ b/taf/api/targets.py @@ -7,7 +7,7 @@ from logdecorator import log_on_end, log_on_error, log_on_start from taf.api.api_workflow import manage_repo_and_signers from taf.api.roles import ( - _initialize_roles_and_keystore, + initialize_roles_and_keystore, add_role, add_role_paths, remove_paths, @@ -21,6 +21,7 @@ import taf.repositoriesdb as repositoriesdb from taf.log import taf_logger from taf.auth_repo import AuthenticationRepository +from taf.yubikey.yubikey_manager import PinManager @log_on_start(DEBUG, "Adding target repository {target_name:s}", logger=taf_logger) @@ -35,6 +36,7 @@ @check_if_clean def add_target_repo( path: str, + pin_manager: PinManager, target_path: str, target_name: str, role: str, @@ -80,7 +82,7 @@ def add_target_repo( Returns: None """ - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) if library_dir is None: library_dir = str(auth_repo.path.parent.parent) @@ -103,6 +105,7 @@ def add_target_repo( add_role( path=path, + pin_manager=pin_manager, role=role, parent_role=parent_role or "targets", paths=paths, @@ -123,6 +126,7 @@ def add_target_repo( taf_logger.info("Role already exists") add_role_paths( paths=[target_name], + pin_manager=pin_manager, delegated_role=role, keystore=keystore, commit=True, @@ -135,6 +139,7 @@ def add_target_repo( commit_msg = git_commit_message("add-target", target_name=target_name) register_target_files( path=path, + pin_manager=pin_manager, keystore=keystore, commit=commit, scheme=scheme, @@ -318,6 +323,7 @@ def list_targets( ) def register_target_files( path: Union[Path, str], + pin_manager: PinManager, keystore: Optional[str] = None, roles_key_infos: Optional[str] = None, commit: Optional[bool] = True, @@ -357,7 +363,9 @@ def register_target_files( # find files that should be added/modified/removed if auth_repo is None: - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) + elif auth_repo.pin_manager is None: + auth_repo.pin_manager = pin_manager added_targets_data, removed_targets_data = auth_repo.get_all_target_files_state() if not added_targets_data and not removed_targets_data: @@ -375,7 +383,7 @@ def register_target_files( if reset_updated_targets_on_error: paths_to_reset.append(str(Path(TARGETS_DIRECTORY_NAME, path))) - _, keystore, _ = _initialize_roles_and_keystore( + _, keystore, _ = initialize_roles_and_keystore( roles_key_infos, keystore, enter_info=False ) @@ -423,6 +431,7 @@ def register_target_files( @check_if_clean def remove_target_repo( path: str, + pin_manager: PinManager, target_name: str, keystore: str, prompt_for_keys: Optional[bool] = False, @@ -445,7 +454,7 @@ def remove_target_repo( Returns: None """ - auth_repo = AuthenticationRepository(path=path) + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) tarets_updated = _remove_from_repositories_json(auth_repo, target_name) @@ -463,6 +472,7 @@ def remove_target_repo( commit_msg = git_commit_message("remove-target", target_name=target_name) register_target_files( path=path, + pin_manager=pin_manager, keystore=keystore, commit=True, scheme=scheme, @@ -549,6 +559,7 @@ def _save_top_commit_of_repo_to_target( @check_if_clean def update_target_repos_from_repositories_json( path: str, + pin_manager: PinManager, library_dir: str, keystore: str, add_branch: Optional[bool] = True, @@ -590,6 +601,7 @@ def update_target_repos_from_repositories_json( register_target_files( repo_path, + pin_manager, keystore, None, commit, @@ -613,6 +625,7 @@ def update_target_repos_from_repositories_json( @check_if_clean def update_and_sign_targets( path: str, + pin_manager: PinManager, library_dir: Optional[str], target_types: list, keystore: str, @@ -642,7 +655,7 @@ def update_and_sign_targets( None """ repo_path = Path(path).resolve() - auth_repo = AuthenticationRepository(path=repo_path) + auth_repo = AuthenticationRepository(path=repo_path, pin_manager=pin_manager) if library_dir is None: library_dir = str(repo_path.parent.parent) # Ensure this uses the Path object repositoriesdb.load_repositories(auth_repo) @@ -675,6 +688,7 @@ def update_and_sign_targets( register_target_files( repo_path, + pin_manager, keystore, roles_key_infos, commit, diff --git a/taf/api/yubikey.py b/taf/api/yubikey.py index c17b750e0..e78745bc4 100644 --- a/taf/api/yubikey.py +++ b/taf/api/yubikey.py @@ -6,13 +6,14 @@ from logdecorator import log_on_end, log_on_error, log_on_start from taf.auth_repo import AuthenticationRepository from taf.constants import DEFAULT_RSA_SIGNATURE_SCHEME -from taf.exceptions import TAFError +from taf.exceptions import TAFError, YubikeyError # from taf.constants import DEFAULT_RSA_SIGNATURE_SCHEME from taf.log import taf_logger from taf.tuf.keys import get_sslib_key_from_value from taf.tuf.repository import MAIN_ROLES -import taf.yubikey as yk +import taf.yubikey.yubikey as yk +from taf.yubikey.yubikey_manager import PinManager @log_on_start(DEBUG, "Exporting public pem from YubiKey", logger=taf_logger) @@ -24,7 +25,9 @@ on_exceptions=TAFError, reraise=True, ) -def export_yk_public_pem(path: Optional[str] = None) -> None: +def export_yk_public_pem( + path: Optional[str] = None, serial: Optional[str] = None +) -> None: """ Export public key from a YubiKey and save it to a file or print to console. @@ -39,19 +42,27 @@ def export_yk_public_pem(path: Optional[str] = None) -> None: None """ try: - pub_key_pem = yk.export_piv_pub_key().decode("utf-8") + serials = [serial] if serial else yk.get_serial_num() + + if not len(serials): + print("YubiKey not inserted.") + return + + for serial in serials: + pub_key_pem = yk.export_piv_pub_key(serial=serial).decode("utf-8") + if path is None: + print(f"Serial: {serial}") + print(pub_key_pem) + else: + if not path.endswith(".pub"): + path = f"{path}.pub" + pem_path = Path(path) + parent = pem_path.parent + parent.mkdir(parents=True, exist_ok=True) + pem_path.write_text(pub_key_pem) except Exception: print("Could not export the public key. Check if a YubiKey is inserted") return - if path is None: - print(pub_key_pem) - else: - if not path.endswith(".pub"): - path = f"{path}.pub" - pem_path = Path(path) - parent = pem_path.parent - parent.mkdir(parents=True, exist_ok=True) - pem_path.write_text(pub_key_pem) @log_on_start(DEBUG, "Exporting certificate from YubiKey", logger=taf_logger) @@ -63,7 +74,9 @@ def export_yk_public_pem(path: Optional[str] = None) -> None: on_exceptions=TAFError, reraise=True, ) -def export_yk_certificate(path: Optional[str] = None) -> None: +def export_yk_certificate( + path: Optional[str] = None, serial: Optional[str] = None +) -> None: """ Export certificate from the YubiKey. @@ -78,10 +91,16 @@ def export_yk_certificate(path: Optional[str] = None) -> None: None """ try: - pub_key_pem = yk.export_piv_pub_key().decode("utf-8") - scheme = DEFAULT_RSA_SIGNATURE_SCHEME - key = get_sslib_key_from_value(pub_key_pem, scheme) - yk.export_yk_certificate(path, key) + serials = [serial] if serial else yk.get_serial_num() + + if not len(serials): + print("YubiKey not inserted.") + return + for serial in serials: + pub_key_pem = yk.export_piv_pub_key(serial=serial).decode("utf-8") + scheme = DEFAULT_RSA_SIGNATURE_SCHEME + key = get_sslib_key_from_value(pub_key_pem, scheme) + yk.export_yk_certificate(path, key, serial) except Exception as e: print(e) print("Could not export certificate. Check if a YubiKey is inserted") @@ -96,7 +115,7 @@ def export_yk_certificate(path: Optional[str] = None) -> None: on_exceptions=TAFError, reraise=True, ) -def get_yk_roles(path: str) -> Dict: +def get_yk_roles(path: str, serial: Optional[str] = None) -> Dict: """ List all roles that the inserted YubiKey whose metadata files can be signed by this YubiKey. In case of delegated targets roles, include the delegation paths. @@ -109,14 +128,23 @@ def get_yk_roles(path: str) -> Dict: Returns: A dictionary containing roles and delegated paths in case of delegated target roles """ + serials = [serial] if serial else yk.get_serial_num() + roles_per_yubikes: Dict = {} + + if not len(serials): + print("YubiKey not inserted.") + return roles_per_yubikes + auth = AuthenticationRepository(path=path) - pub_key = yk.get_piv_public_key_tuf() - roles = auth.find_associated_roles_of_key(pub_key) - roles_with_paths: Dict = {role: {} for role in roles} - for role in roles: - if role not in MAIN_ROLES: - roles_with_paths[role] = auth.get_role_paths(role) - return roles_with_paths + for serial in serials: + pub_key = yk.get_piv_public_key_tuf(serial=serial) + roles = auth.find_associated_roles_of_key(pub_key) + roles_with_paths: Dict = {role: {} for role in roles} + for role in roles: + if role not in MAIN_ROLES: + roles_with_paths[role] = auth.get_role_paths(role) + roles_per_yubikes[serial] = roles_with_paths + return roles_per_yubikes @log_on_start(DEBUG, "Setting up a new signing YubiKey", logger=taf_logger) @@ -129,7 +157,7 @@ def get_yk_roles(path: str) -> Dict: reraise=True, ) def setup_signing_yubikey( - certs_dir: Optional[str] = None, key_size: int = 2048 + pin_manager: PinManager, certs_dir: Optional[str] = None, key_size: int = 2048 ) -> None: """ Delete everything from the inserted YubiKey, generate a new key and copy it to the YubiKey. @@ -148,15 +176,20 @@ def setup_signing_yubikey( "WARNING - this will delete everything from the inserted key. Proceed?" ): return - _, serial_num = yk.yubikey_prompt( - "new Yubikey", + yubikeys = yk.yubikey_prompt( + ["new Yubikey"], + pin_manager=pin_manager, creating_new_key=True, pin_confirm=True, pin_repeat=True, prompt_message="Please insert the new Yubikey and press ENTER", ) - key = yk.setup_new_yubikey(serial_num, key_size=key_size) - yk.export_yk_certificate(certs_dir, key) + if yubikeys: + _, serial_num, _ = yubikeys[0] + key = yk.setup_new_yubikey(pin_manager, serial_num, key_size=key_size) + yk.export_yk_certificate(certs_dir, key, serial_num) + else: + raise YubikeyError("Could not generate a new key") @log_on_start(DEBUG, "Setting up a new test YubiKey", logger=taf_logger) @@ -166,9 +199,13 @@ def setup_signing_yubikey( "An error occurred while setting up a test YubiKey: {e}", logger=taf_logger, on_exceptions=TAFError, - reraise=True, ) -def setup_test_yubikey(key_path: str, key_size: Optional[int] = 2048) -> None: +def setup_test_yubikey( + pin_manager: PinManager, + key_path: str, + key_size: Optional[int] = 2048, + serial: Optional[str] = None, +) -> None: """ Reset the inserted yubikey, set default pin and copy the specified key to it. @@ -182,15 +219,27 @@ def setup_test_yubikey(key_path: str, key_size: Optional[int] = 2048) -> None: Returns: None """ + if serial is None: + serials = yk.get_serial_num() + if not len(serials): + raise YubikeyError("YubiKey not inserted") + if len(serials) > 1: + raise YubikeyError("Insert only one YubiKey") + if not click.confirm("WARNING - this will reset the inserted key. Proceed?"): return + + serial = serials[0] key_pem_path = Path(key_path) key_pem = key_pem_path.read_bytes() print(f"Importing RSA private key from {key_path} to Yubikey...") pin = yk.DEFAULT_PIN + pin_manager.add_pin(serial, pin) - pub_key = yk.setup(pin, "Test Yubikey", private_key_pem=key_pem, key_size=key_size) + pub_key = yk.setup( + pin, serial, "Test Yubikey", private_key_pem=key_pem, key_size=key_size + ) print("\nPrivate key successfully imported.\n") print("\nPublic key (PEM): \n{}".format(pub_key.decode("utf-8"))) print("Pin: {}\n".format(pin)) diff --git a/taf/auth_repo.py b/taf/auth_repo.py index 72ac73dc0..a0e15f960 100644 --- a/taf/auth_repo.py +++ b/taf/auth_repo.py @@ -16,6 +16,7 @@ get_target_path, ) from taf.constants import INFO_JSON_PATH +from taf.yubikey.yubikey_manager import PinManager class AuthenticationRepository(GitRepository): @@ -40,6 +41,7 @@ def __init__( out_of_band_authentication: Optional[str] = None, path: Optional[Union[Path, str]] = None, alias: Optional[str] = None, + pin_manager: Optional[PinManager] = None, *args, **kwargs, ): @@ -78,7 +80,12 @@ def __init__( self.conf_directory_root = conf_directory_root_path.resolve() self.out_of_band_authentication = out_of_band_authentication self._storage = GitStorageBackend() - self._tuf_repository = TUFRepository(self.path, storage=self._storage) + if pin_manager is None: + pin_manager = PinManager() + self._tuf_repository = TUFRepository( + self.path, storage=self._storage, pin_manager=pin_manager + ) + self.pin_manager = pin_manager def __getattr__(self, item): """Delegate attribute lookup to TUFRepository instance""" diff --git a/taf/keys.py b/taf/keys.py index 68c4862b8..fde1b75b7 100644 --- a/taf/keys.py +++ b/taf/keys.py @@ -1,10 +1,10 @@ -from collections import defaultdict from functools import partial from logging import INFO from typing import Dict, List, Optional, Tuple, Union import click from pathlib import Path from logdecorator import log_on_start +from taf.auth_repo import AuthenticationRepository from taf.log import taf_logger from taf.models.types import Role, RolesIterator from taf.models.models import TAFKey @@ -13,6 +13,7 @@ from taf.api.utils._conf import find_keystore from taf.tuf.keys import ( YkSigner, + _get_legacy_keyid, generate_and_write_rsa_keypair, generate_rsa_keypair, get_sslib_key_from_value, @@ -34,7 +35,7 @@ from securesystemslib.signer._crypto_signer import CryptoSigner try: - import taf.yubikey as yk + import taf.yubikey.yubikey as yk except ImportError: taf_logger.warning( "WARNING: yubikey-manager dependency not installed. You will not be able to use YubiKeys." @@ -42,6 +43,19 @@ yk = YubikeyMissingLibrary() # type: ignore +def _create_signer(auth_repo, public_key, serial_num, key_name): + return YkSigner( + public_key, + serial_num, + partial( + yk.yk_secrets_handler, + pin_manager=auth_repo.pin_manager, + serial_num=serial_num, + ), + key_name=key_name, + ) + + def get_key_name(role_name: str, key_num: int, num_of_keys: int) -> str: """ Return a keystore key's name based on the role's name and total number of signing keys, @@ -90,9 +104,9 @@ def _get_attr(oid): def load_sorted_keys_of_new_roles( roles: Union[MainRoles, TargetsRole], + auth_repo: AuthenticationRepository, yubikeys_data: Optional[Dict[str, UserKeyData]], keystore: Optional[Union[Path, str]], - yubikeys: Optional[Dict[str, Dict]] = None, existing_roles: Optional[List[str]] = None, skip_prompt: Optional[bool] = False, certs_dir: Optional[Union[Path, str]] = None, @@ -110,7 +124,7 @@ def load_sorted_keys_of_new_roles( If additional details contain the public key, a user will not have to insert that YubiKey (provided that it's not necessary given the threshold of signing keys) keystore: keystore path - yubikeys:(optional): A dictionary containing previously loaded YubiKeys used to save already entered pins + pint_manager: Instance of a class for secure pin management existing_roles (optional): A list of roles whose keys were already loaded skip_prompt (optional): A flag defining if the user will be asked if they want to generate new keys or reuse existing ones in case keystore files should be used. New keys will be generated by default. @@ -131,8 +145,6 @@ def _sort_roles(roles): keystore_roles.append(role) return keystore_roles, yubikey_roles - if yubikeys is None: - yubikeys = defaultdict(dict) # load and/or generate all keys first if existing_roles is None: existing_roles = [] @@ -146,6 +158,7 @@ def _sort_roles(roles): continue keystore_signers, _, _ = setup_roles_keys( role, + auth_repo, keystore=keystore, skip_prompt=skip_prompt, ) @@ -157,13 +170,14 @@ def _sort_roles(roles): continue _, yubikey_keys, yubikey_signers = setup_roles_keys( role, + auth_repo, certs_dir=certs_dir, - yubikeys=yubikeys, users_yubikeys_details=yubikeys_data, skip_prompt=skip_prompt, ) verification_keys[role.name] = yubikey_keys signers[role.name] = yubikey_signers + return signers, verification_keys except KeystoreError: raise SigningError("Could not load keys of new roles") @@ -188,11 +202,51 @@ def _load_signer_from_keystore( return None +def _load_yubikeys( + taf_repo, + role, + key_names, + retry_on_failure, + hide_threshold_message, +): + + signers_yubikeys = [] + yubikeys = yk.yubikey_prompt( + key_names=key_names, + pin_manager=taf_repo.pin_manager, + role=role, + taf_repo=taf_repo, + retry_on_failure=retry_on_failure, + hide_already_loaded_message=True, + hide_threshold_message=hide_threshold_message, + ) + + loaded_keyids = [signer.public_key.keyid for signer in signers_yubikeys] + loaded_key_names = [] + for public_key, serial_num, key_name in yubikeys: + if public_key is not None and public_key.keyid not in loaded_keyids: + signer = YkSigner( + public_key, + serial_num, + partial( + yk.yk_secrets_handler, + pin_manager=taf_repo.pin_manager, + serial_num=serial_num, + ), + key_name=key_name, + ) + signers_yubikeys.append(signer) + loaded_keyids.append(public_key.keyid) + loaded_key_names.append(key_name) + taf_logger.info(f"Successfully loaded {key_name} from inserted YubiKey") + + return signers_yubikeys, loaded_key_names + + @log_on_start(INFO, "Loading signing keys of '{role:s}'", logger=taf_logger) def load_signers( taf_repo: TUFRepository, role: str, - loaded_yubikeys: Optional[Dict], keystore: Optional[str] = None, scheme: Optional[str] = DEFAULT_RSA_SIGNATURE_SCHEME, prompt_for_keys: Optional[bool] = False, @@ -206,8 +260,8 @@ def load_signers( signing_keys_num = len(taf_repo.get_role_keys(role)) all_loaded = False num_of_signatures = 0 - signers_keystore = [] - yubikeys = [] + signers_keystore: List = [] + signers_yubikeys: List = [] # first try to sign using yubikey # if that is not possible, try to load key from a keystore file @@ -223,31 +277,16 @@ def load_signers( keystore_path = Path(keystore).expanduser().resolve() if keystore else None - def _load_and_append_yubikeys( - key_name, role, retry_on_failure, hide_already_loaded_message - ): - public_key, serial_num = yk.yubikey_prompt( - key_name, - role, - taf_repo, - loaded_yubikeys=loaded_yubikeys, - retry_on_failure=retry_on_failure, - hide_already_loaded_message=hide_already_loaded_message, - ) - if public_key is not None and public_key not in yubikeys: - signer = YkSigner( - public_key, partial(yk.yk_secrets_handler, serial_num=serial_num) - ) - yubikeys.append(signer) - taf_logger.info(f"Successfully loaded {key_name} from inserted YubiKey") - return True - return False - keystore_files = [] if keystore is not None: keystore_files = get_keystore_keys_of_role(keystore, role) prompt_for_yubikey = True use_yubikey_for_signing_confirmed = False + + key_names = taf_repo.get_key_names_of_role(role) + initial_yk_load_attempt = True + hide_threshold_message = False + while not all_loaded and num_of_signatures < signing_keys_num: # when loading from keystore files @@ -262,30 +301,49 @@ def _load_and_append_yubikeys( num_of_signatures += 1 continue if num_of_signatures >= threshold: - if use_yubikey_for_signing_confirmed: + if ( + use_yubikey_for_signing_confirmed + and not taf_repo.pin_manager.auto_continue + ): if not click.confirm( f"Threshold of {role} keys reached. Do you want to load more {role} keys?" ): break + else: + hide_threshold_message = True else: # loading from keystore files, couldn't load from all of them, but loaded enough + # or auto continue sets break - # try to load from the inserted YubiKey, without asking the user to insert it - key_name = get_key_name(role, num_of_signatures, signing_keys_num) - if _load_and_append_yubikeys(key_name, role, False, True): - num_of_signatures += 1 - continue + # try to load from the inserted YubiKeys, without asking the user to insert it + # in case of yubikeys, instead of asking for a particular key, ask to insert all + # that can be used to sign the current role, but either read the name from the + # metadata, or assign a role + counter name + if initial_yk_load_attempt or use_yubikey_for_signing_confirmed: + loaded_signers, loaded_keys = _load_yubikeys( + taf_repo=taf_repo, + role=role, + key_names=key_names, + retry_on_failure=use_yubikey_for_signing_confirmed, + hide_threshold_message=hide_threshold_message, + ) + signers_yubikeys.extend(loaded_signers) + if loaded_keys: + use_yubikey_for_signing_confirmed = True + num_of_loaded_keys = len(loaded_keys) + for loaded_key in loaded_keys: + key_names.remove(loaded_key) + + if num_of_loaded_keys: + num_of_signatures += num_of_loaded_keys + continue if prompt_for_yubikey: if click.confirm(f"Sign {role} using YubiKey(s)?"): use_yubikey_for_signing_confirmed = True - prompt_for_yubikey = False - - if use_yubikey_for_signing_confirmed: - if _load_and_append_yubikeys(key_name, role, True, False): - num_of_signatures += 1 - continue + prompt_for_yubikey = False + continue if prompt_for_keys and click.confirm(f"Manually enter {role} key?"): keys = [signer.public_key for signer in signers_keystore] @@ -296,14 +354,14 @@ def _load_and_append_yubikeys( else: raise SigningError(f"Cannot load keys of role {role}") - return signers_keystore, yubikeys + return signers_keystore, signers_yubikeys def setup_roles_keys( role: Role, + auth_repo: AuthenticationRepository, certs_dir: Optional[Union[Path, str]] = None, keystore: Optional[Union[Path, str]] = None, - yubikeys: Optional[Dict] = None, users_yubikeys_details: Optional[Dict[str, UserKeyData]] = None, skip_prompt: Optional[bool] = False, key_size: int = 2048, @@ -321,11 +379,25 @@ def setup_roles_keys( if users_yubikeys_details is None: users_yubikeys_details = {} - is_yubikey = bool(yubikey_ids) + if yubikey_ids: + if auth_repo.keys_name_mappings: + # check if some of the listed key names are already defined as signing keys + # in that case, they need to be loaded and verified + existing_key_names = { + existing_key_name: existing_key_id + for existing_key_id, existing_key_name in auth_repo.keys_name_mappings.items() + } + for key_name in yubikey_ids: + if key_name in existing_key_names: + public_key_pem, scheme = auth_repo.get_public_key_of_keyid( + existing_key_names[key_name] + ) + key_data = {"public": public_key_pem, "scheme": scheme} + users_yubikeys_details[key_name] = UserKeyData(**key_data) - if is_yubikey: + if role.is_yubikey: yubikey_keys, yubikey_signers = _setup_yubikey_roles_keys( - yubikey_ids, users_yubikeys_details, yubikeys, role, certs_dir, key_size + auth_repo, yubikey_ids, users_yubikeys_details, role, certs_dir, key_size ) else: if keystore is None: @@ -334,7 +406,7 @@ def setup_roles_keys( default_params = RoleSetupParams() for key_num in range(role.number): key_name = get_key_name(role.name, key_num, role.number) - signer = _setup_keystore_key( + signer, key_id = _setup_keystore_key( keystore, role.name, key_name, @@ -344,81 +416,98 @@ def setup_roles_keys( skip_prompt=skip_prompt, ) keystore_signers.append(signer) + auth_repo.add_key_name(key_name, key_id) + return keystore_signers, yubikey_keys, yubikey_signers def _setup_yubikey_roles_keys( - yubikey_ids, users_yubikeys_details, yubikeys, role, certs_dir, key_size + auth_repo, yubikey_ids, users_yubikeys_details, role, certs_dir, key_size ): loaded_keys_num = 0 yk_with_public_key = {} yubikey_keys = [] signers = [] - for key_id in yubikey_ids: + # if a key was already loaded (while setting up a different role) + # register signers and remove the key id, so that the user is not asked to enter it again + yubikes_to_skip = [] + names_defined = bool(yubikey_ids) + if names_defined: + for key_name in list(yubikey_ids): + key_data = auth_repo.yubikey_store.get_key_data(key_name) + if key_data is not None: + public_key, serial_num = key_data + auth_repo.yubikey_store.add_key_data( + key_name, serial_num, public_key, role.name + ) + yubikey_ids.remove(key_name) + yubikey_keys.append(public_key) + loaded_keys_num += 1 + signer = _create_signer(auth_repo, public_key, serial_num, key_name) + signers.append(signer) + + # if key already loaded while setting up a different role, skip it + # if the current role's yubikey ids are defined + # however, if the current role's yubikey ids are not specified + # it can be possible to reuse a yubikey + for key_name, key_data in auth_repo.yubikey_store.yubikeys_data.items(): + if key_name not in yubikey_ids: + yubikes_to_skip.append(key_data["serial"]) + else: + yubikey_ids = [f"{role.name}{counter}" for counter in range(1, role.number + 1)] + + for key_name in yubikey_ids: public_key_text = None - if key_id in users_yubikeys_details: - public_key_text = users_yubikeys_details[key_id].public + if key_name in users_yubikeys_details: + public_key_text = users_yubikeys_details[key_name].public if public_key_text: - scheme = users_yubikeys_details[key_id].scheme + scheme = users_yubikeys_details[key_name].scheme public_key = get_sslib_key_from_value(public_key_text, scheme) # Check if the signing key is already loaded - if not yk.get_key_serial_by_id(key_id): - yk_with_public_key[key_id] = public_key + if not auth_repo.yubikey_store.is_key_name_loaded(key_name): + yk_with_public_key[key_name] = public_key else: + serial_num = auth_repo.yubikey_store.get_key_data["serial"] + auth_repo.yubikey_store.add_key_data( + key_name, serial_num, public_key, role.name + ) loaded_keys_num += 1 yubikey_keys.append(public_key) else: key_scheme = None - if key_id in users_yubikeys_details: - key_scheme = users_yubikeys_details[key_id].scheme + if key_name in users_yubikeys_details: + key_scheme = users_yubikeys_details[key_name].scheme key_scheme = key_scheme or role.scheme public_key, serial_num = _setup_yubikey( - yubikeys, + auth_repo, role.name, - key_id, - yubikey_keys, + key_name, key_scheme, certs_dir, key_size, + yubikes_to_skip, ) loaded_keys_num += 1 - signer = YkSigner( - public_key, partial(yk.yk_secrets_handler, serial_num=serial_num) - ) + signer = _create_signer(auth_repo, public_key, serial_num, key_name) signers.append(signer) - if loaded_keys_num < role.threshold: - print(f"Threshold of role {role.name} is {role.threshold}") - while loaded_keys_num < role.threshold: - loaded_keys = [] - for key_id, public_key in yk_with_public_key.items(): - if ( - key_id in users_yubikeys_details - and not users_yubikeys_details[key_id].present - ): - continue - serial_num = _load_and_verify_yubikey( - yubikeys, role.name, key_id, public_key - ) - if serial_num: - loaded_keys_num += 1 - loaded_keys.append(key_id) - signer = YkSigner( - public_key, - partial(yk.yk_secrets_handler, serial_num=serial_num), - ) - signers.append(signer) - if loaded_keys_num == role.threshold: - break - if loaded_keys_num < role.threshold: - if not click.confirm( - f"Threshold of signing keys of role {role.name} not reached. Continue?" - ): - raise SigningError("Not enough signing keys") - for key_id in loaded_keys: - yk_with_public_key.pop(key_id) + key_id = _get_legacy_keyid(public_key) + auth_repo.add_key_name(key_name, key_id, overwrite=names_defined) + + if loaded_keys_num < role.number: + if loaded_keys_num < role.threshold: + print(f"Threshold of role {role.name} is {role.threshold}") + _load_remaining_keys_of_role( + auth_repo, + role, + loaded_keys_num, + users_yubikeys_details, + yk_with_public_key, + yubikey_keys, + signers, + ) return yubikey_keys, signers @@ -430,7 +519,7 @@ def _setup_keystore_key( length: int, password: Optional[str], skip_prompt: Optional[bool], -) -> CryptoSigner: +) -> Tuple[CryptoSigner, str]: # if keystore exists, load the keys generate_new_keys = keystore is None signer = None @@ -492,17 +581,19 @@ def _invalid_key_message(key_name, keystore): print(f"{role_name} key:\n\n{private_pem.decode()}\n\n") signer = load_signer_from_pem(private_pem) - return signer + if signer is not None: + return signer, _get_legacy_keyid(signer.public_key) + raise KeystoreError(f"Could not load signer {key_name}") def _setup_yubikey( - yubikeys: Optional[Dict], + auth_repo: AuthenticationRepository, role_name: str, key_name: str, - loaded_keys: List[str], scheme: Optional[str] = DEFAULT_RSA_SIGNATURE_SCHEME, certs_dir: Optional[Union[Path, str]] = None, key_size: int = 2048, + yubikeys_to_skip: Optional[List] = None, ) -> Tuple[Dict, str]: print(f"Registering keys for {key_name}") while True: @@ -514,46 +605,97 @@ def _setup_yubikey( if click.confirm("Cancel?"): raise YubikeyError("Yubikey setup canceled") continue - key, serial_num = yk.yubikey_prompt( - key_name, - role_name, - taf_repo=None, + yubikeys = yk.yubikey_prompt( + [key_name], + pin_manager=auth_repo.pin_manager, + role=role_name, + taf_repo=auth_repo, registering_new_key=True, creating_new_key=not use_existing, - loaded_yubikeys=yubikeys, pin_confirm=True, pin_repeat=True, + yubikeys_to_skip=yubikeys_to_skip, ) - if use_existing and key in loaded_keys: - print("Key already loaded. Please insert a different YubiKey") - else: + if yubikeys is not None: + key, serial_num, key_name = yubikeys[0] if not use_existing: - key = yk.setup_new_yubikey(serial_num, scheme, key_size=key_size) + key = yk.setup_new_yubikey( + auth_repo.pin_manager, serial_num, scheme, key_size=key_size + ) if certs_dir is not None: - yk.export_yk_certificate(certs_dir, key) + # check if already exporeted + if len(auth_repo.yubikey_store.get_roles_of_key(serial_num)) == 1: + # this is the first time that this key is being used (can only be used once per role) + yk.export_yk_certificate(certs_dir, key, serial=serial_num) return key, serial_num +def _load_remaining_keys_of_role( + auth_repo: AuthenticationRepository, + role: Role, + loaded_keys_num: int, + users_yubikeys_details: UserKeyData, + yk_with_public_key: Dict, + yubikey_keys, + signers: List, +): + """ + If a a yubikey's public key was specified, meaning that it can be added as a + verification key without being inserted, but the total number of signing + keys is smaller than the threshold + """ + while loaded_keys_num < role.threshold: + loaded_keys = [] + for key_name, public_key in yk_with_public_key.items(): + serial_num = _load_and_verify_yubikey( + role.name, + key_name, + public_key, + taf_repo=auth_repo, + ) + if serial_num: + loaded_keys_num += 1 + loaded_keys.append(key_name) + signer = _create_signer(auth_repo, public_key, serial_num, key_name) + signers.append(signer) + yubikey_keys.remove(signer.public_key) + + if loaded_keys_num < role.threshold: + if not click.confirm( + f"Threshold of signing keys of role {role.name} not reached. Continue?" + ): + raise SigningError("Not enough signing keys") + for key_name in loaded_keys: + yk_with_public_key.pop(key_name) + + def _load_and_verify_yubikey( - yubikeys: Optional[Dict], role_name: str, key_name: str, public_key + role_name: str, + key_name: str, + public_key, + taf_repo: AuthenticationRepository, ) -> Optional[str]: if not click.confirm(f"Sign using {key_name} Yubikey?"): return None while True: - yk_public_key, _ = yk.yubikey_prompt( - key_name, - role_name, - taf_repo=None, + yubikeys = yk.yubikey_prompt( + [key_name], + role=role_name, + pin_manager=taf_repo.pin_manager, + taf_repo=taf_repo, registering_new_key=True, creating_new_key=False, - loaded_yubikeys=yubikeys, pin_confirm=True, pin_repeat=True, ) - - if yk_public_key["keyid"] != public_key["keyid"]: - print("Public key of the inserted key is not equal to the specified one.") - if not click.confirm("Try again?"): - return None - return yk.get_serial_num() + if yubikeys: + yubikey = yubikeys[0] + yk_pub_key_id = yubikey[0].keyid + if yk_pub_key_id != public_key.keyid: + print( + "Public key of the inserted key is not equal to the specified one." + ) + if not click.confirm("Try again?"): + return None + return yubikey[1] diff --git a/taf/models/types.py b/taf/models/types.py index bb9c9eaa8..2080a94ec 100644 --- a/taf/models/types.py +++ b/taf/models/types.py @@ -59,11 +59,7 @@ def is_yubikey(self): def yubikey_ids(self): if not self.is_yubikey: return None - if self.yubikeys: - return self.yubikeys - if self.number == 1: - return [self.name] - return [f"{self.name}{counter}" for counter in range(1, self.number + 1)] + return self.yubikeys @attrs.define diff --git a/taf/tests/conftest.py b/taf/tests/conftest.py index 3b3cc7bb9..3287b53d9 100644 --- a/taf/tests/conftest.py +++ b/taf/tests/conftest.py @@ -1,3 +1,4 @@ +from taf.yubikey.yubikey_manager import PinManager import pytest import json import re @@ -139,6 +140,11 @@ def output_path(): shutil.rmtree(TEST_OUTPUT_PATH, onerror=on_rm_error) +@pytest.fixture(scope="session") +def pin_manager(): + return PinManager() + + @pytest.fixture(scope="session") def client_dir(): return CLIENT_DIR_PATH diff --git a/taf/tests/data/repository_description_inputs/add_roles_config.json b/taf/tests/data/repository_description_inputs/add_roles_config.json new file mode 100644 index 000000000..23008cb5a --- /dev/null +++ b/taf/tests/data/repository_description_inputs/add_roles_config.json @@ -0,0 +1,9 @@ +{ + "roles": { + "delegated_role": { + "parent_role": "targets", + "paths": ["/delegated_path_inside_targets1", "/delegated_path_inside_targets2"], + "threshold": 1 + } + } + } \ No newline at end of file diff --git a/taf/tests/test_api/conftest.py b/taf/tests/test_api/conftest.py index b3eea6cd0..3b791676d 100644 --- a/taf/tests/test_api/conftest.py +++ b/taf/tests/test_api/conftest.py @@ -13,12 +13,14 @@ from taf.tests.conftest import CLIENT_DIR_PATH, KEYSTORES_PATH, TEST_DATA_PATH from taf.tests.utils import copy_mirrors_json, copy_repositories_json, read_json from taf.utils import on_rm_error +from taf.yubikey.yubikey_manager import PinManager REPOSITORY_DESCRIPTION_INPUT_DIR = TEST_DATA_PATH / "repository_description_inputs" TEST_INIT_DATA_PATH = Path(__file__).parent.parent / "init_data" NO_DELEGATIONS_INPUT = REPOSITORY_DESCRIPTION_INPUT_DIR / "no_delegations.json" NO_YUBIKEYS_INPUT = REPOSITORY_DESCRIPTION_INPUT_DIR / "no_yubikeys.json" WITH_DELEGATIONS_INPUT = REPOSITORY_DESCRIPTION_INPUT_DIR / "with_delegations.json" +ADD_ROLES_CONFIG_INPUT = REPOSITORY_DESCRIPTION_INPUT_DIR / "add_roles_config.json" INVALID_PUBLIC_KEY_INPUT = REPOSITORY_DESCRIPTION_INPUT_DIR / "invalid_public_key.json" WITH_DELEGATIONS_NO_YUBIKEYS_INPUT = ( REPOSITORY_DESCRIPTION_INPUT_DIR / "with_delegations_no_yubikeys.json" @@ -50,6 +52,11 @@ def with_delegations_json_input(): return read_json(WITH_DELEGATIONS_INPUT) +@pytest.fixture(scope="session") +def add_roles_config_json_input(): + return read_json(ADD_ROLES_CONFIG_INPUT) + + @pytest.fixture(scope="session") def invalid_public_key_json_input(): return read_json(INVALID_PUBLIC_KEY_INPUT) @@ -79,10 +86,11 @@ def auth_repo_path(repo_dir): @pytest.fixture -def auth_repo(auth_repo_path, keystore_delegations, no_yubikeys_path): +def auth_repo(auth_repo_path, keystore_delegations, no_yubikeys_path, pin_manager): repo_path = str(auth_repo_path) create_repository( repo_path, + pin_manager, roles_key_infos=no_yubikeys_path, keystore=keystore_delegations, commit=True, @@ -94,11 +102,12 @@ def auth_repo(auth_repo_path, keystore_delegations, no_yubikeys_path): @pytest.fixture def auth_repo_with_delegations( - auth_repo_path, keystore_delegations, with_delegations_no_yubikeys_path + auth_repo_path, keystore_delegations, with_delegations_no_yubikeys_path, pin_manager ): repo_path = str(auth_repo_path) create_repository( repo_path, + pin_manager, roles_key_infos=with_delegations_no_yubikeys_path, keystore=keystore_delegations, commit=True, @@ -144,6 +153,7 @@ def auth_repo_when_add_repositories_json( keystore_delegations: str, repositories_json_template: Dict, mirrors_json_path: Path, + pin_manager: PinManager, ): repo_path = library / "auth" namespace = library.name @@ -151,6 +161,7 @@ def auth_repo_when_add_repositories_json( copy_mirrors_json(mirrors_json_path, repo_path) create_repository( str(repo_path), + pin_manager=pin_manager, roles_key_infos=with_delegations_no_yubikeys_path, keystore=keystore_delegations, commit=True, diff --git a/taf/tests/test_api/dependencies/api/test_dependencies.py b/taf/tests/test_api/dependencies/api/test_dependencies.py index 24c6e0adf..662344b3a 100644 --- a/taf/tests/test_api/dependencies/api/test_dependencies.py +++ b/taf/tests/test_api/dependencies/api/test_dependencies.py @@ -7,6 +7,7 @@ from taf.auth_repo import AuthenticationRepository from taf.api.repository import create_repository from taf.tests.test_api.conftest import DEPENDENCY_NAME +from taf.yubikey.yubikey_manager import PinManager def test_setup_repositories( @@ -14,10 +15,12 @@ def test_setup_repositories( parent_repo_path: Path, with_delegations_no_yubikeys_path: str, keystore_delegations: str, + pin_manager: PinManager, ): for path in (child_repo_path, parent_repo_path): create_repository( str(path), + pin_manager, roles_key_infos=with_delegations_no_yubikeys_path, keystore=keystore_delegations, commit=True, @@ -25,7 +28,10 @@ def test_setup_repositories( def test_add_dependency_when_on_filesystem_invalid_commit( - parent_repo_path, child_repo_path, keystore_delegations + parent_repo_path, + child_repo_path, + keystore_delegations, + pin_manager, ): auth_repo = AuthenticationRepository(path=parent_repo_path) initial_commits_num = len(auth_repo.list_commits()) @@ -34,6 +40,7 @@ def test_add_dependency_when_on_filesystem_invalid_commit( with pytest.raises(TAFError): add_dependency( path=str(parent_repo_path), + pin_manager=pin_manager, dependency_name=child_repository.name, keystore=keystore_delegations, branch_name="main", @@ -46,7 +53,10 @@ def test_add_dependency_when_on_filesystem_invalid_commit( def test_add_dependency_when_on_filesystem( - parent_repo_path, child_repo_path, keystore_delegations + parent_repo_path, + child_repo_path, + keystore_delegations, + pin_manager, ): auth_repo = AuthenticationRepository(path=parent_repo_path) initial_commits_num = len(auth_repo.list_commits()) @@ -54,6 +64,7 @@ def test_add_dependency_when_on_filesystem( add_dependency( path=str(parent_repo_path), + pin_manager=pin_manager, dependency_name=child_repository.name, keystore=keystore_delegations, branch_name=None, @@ -76,13 +87,16 @@ def test_add_dependency_when_on_filesystem( } -def test_add_dependency_when_not_on_filesystem(parent_repo_path, keystore_delegations): +def test_add_dependency_when_not_on_filesystem( + parent_repo_path, keystore_delegations, pin_manager +): auth_repo = AuthenticationRepository(path=parent_repo_path) initial_commits_num = len(auth_repo.list_commits()) branch_name = "main" out_of_band_commit = "66d7f48e972f9fa25196523f469227dfcd85c994" add_dependency( path=str(parent_repo_path), + pin_manager=pin_manager, dependency_name=DEPENDENCY_NAME, keystore=keystore_delegations, branch_name=branch_name, @@ -105,13 +119,16 @@ def test_add_dependency_when_not_on_filesystem(parent_repo_path, keystore_delega } -def test_remove_dependency(parent_repo_path, child_repo_path, keystore_delegations): +def test_remove_dependency( + parent_repo_path, child_repo_path, keystore_delegations, pin_manager +): auth_repo = AuthenticationRepository(path=parent_repo_path) initial_commits_num = len(auth_repo.list_commits()) child_repository = AuthenticationRepository(path=child_repo_path) remove_dependency( path=str(parent_repo_path), + pin_manager=pin_manager, dependency_name=child_repository.name, keystore=keystore_delegations, push=False, diff --git a/taf/tests/test_api/dependencies/cli/test_dependencies_cmds.py b/taf/tests/test_api/dependencies/cli/test_dependencies_cmds.py index dcbebf362..6c15ea593 100644 --- a/taf/tests/test_api/dependencies/cli/test_dependencies_cmds.py +++ b/taf/tests/test_api/dependencies/cli/test_dependencies_cmds.py @@ -6,6 +6,7 @@ from taf.auth_repo import AuthenticationRepository from taf.tests.test_api.conftest import DEPENDENCY_NAME from taf.tools.cli.taf import taf +from taf.yubikey.yubikey_manager import PinManager def test_dependencies_add_cmd_expect_success( @@ -13,10 +14,12 @@ def test_dependencies_add_cmd_expect_success( child_repo_path, with_delegations_no_yubikeys_path, keystore_delegations, + pin_manager: PinManager, ): for path in (child_repo_path, parent_repo_path): create_repository( str(path), + pin_manager, roles_key_infos=with_delegations_no_yubikeys_path, keystore=keystore_delegations, commit=True, diff --git a/taf/tests/test_api/metadata/api/test_metadata.py b/taf/tests/test_api/metadata/api/test_metadata.py index a530c8d02..d5e5c0ce0 100644 --- a/taf/tests/test_api/metadata/api/test_metadata.py +++ b/taf/tests/test_api/metadata/api/test_metadata.py @@ -8,6 +8,7 @@ from taf.api.metadata import check_expiration_dates, update_metadata_expiration_date from tuf.api.metadata import Root, Snapshot, Timestamp, Targets +from taf.yubikey.yubikey_manager import PinManager AUTH_REPO_NAME = "auth" @@ -16,11 +17,15 @@ @pytest.fixture(scope="module") @freeze_time("2021-12-31") def auth_repo_expired( - api_repo_path, keystore_delegations, with_delegations_no_yubikeys_path + api_repo_path, + keystore_delegations, + with_delegations_no_yubikeys_path, + pin_manager, ): repo_path = str(api_repo_path) create_repository( repo_path, + pin_manager, roles_key_infos=str(with_delegations_no_yubikeys_path), keystore=keystore_delegations, commit=True, @@ -52,7 +57,9 @@ def test_check_expiration_date_when_all_expired( @freeze_time("2023-01-01") def test_update_root_metadata( - auth_repo_expired: AuthenticationRepository, keystore_delegations: str + auth_repo_expired: AuthenticationRepository, + keystore_delegations: str, + pin_manager: PinManager, ): # update root metadata, expect snapshot and timestamp to be updated too # targets should not be updated @@ -65,6 +72,7 @@ def test_update_root_metadata( snapshot_version = auth_repo_expired.snapshot().version update_metadata_expiration_date( path=auth_repo_path, + pin_manager=pin_manager, roles=roles, interval=INTERVAL, keystore=keystore_delegations, @@ -112,7 +120,9 @@ def test_check_expiration_date_when_expired_and_will_expire( @freeze_time("2023-01-01") def test_update_snapshot_metadata( - auth_repo_expired: AuthenticationRepository, keystore_delegations: str + auth_repo_expired: AuthenticationRepository, + keystore_delegations: str, + pin_manager: PinManager, ): # update root metadata, expect snapshot and timestamp to be updated too # targets should not be updated @@ -125,6 +135,7 @@ def test_update_snapshot_metadata( snapshot_version = auth_repo_expired.snapshot().version update_metadata_expiration_date( path=auth_repo_path, + pin_manager=pin_manager, roles=roles, interval=INTERVAL, keystore=keystore_delegations, @@ -141,7 +152,9 @@ def test_update_snapshot_metadata( @freeze_time("2023-01-01") def test_update_timestamp_metadata( - auth_repo_expired: AuthenticationRepository, keystore_delegations: str + auth_repo_expired: AuthenticationRepository, + keystore_delegations: str, + pin_manager: PinManager, ): # update root metadata, expect snapshot and timestamp to be updated too # targets should not be updated @@ -154,6 +167,7 @@ def test_update_timestamp_metadata( snapshot_version = auth_repo_expired.snapshot().version update_metadata_expiration_date( path=auth_repo_path, + pin_manager=pin_manager, roles=roles, interval=INTERVAL, keystore=keystore_delegations, @@ -170,7 +184,9 @@ def test_update_timestamp_metadata( @freeze_time("2023-01-01") def test_update_multiple_roles_metadata( - auth_repo_expired: AuthenticationRepository, keystore_delegations: str + auth_repo_expired: AuthenticationRepository, + keystore_delegations: str, + pin_manager: PinManager, ): # update root metadata, expect snapshot and timestamp to be updated too # targets should not be updated @@ -183,6 +199,7 @@ def test_update_multiple_roles_metadata( snapshot_version = auth_repo_expired.snapshot().version update_metadata_expiration_date( path=auth_repo_path, + pin_manager=pin_manager, roles=roles, interval=INTERVAL, keystore=keystore_delegations, diff --git a/taf/tests/test_api/repo/api/test_create_repository.py b/taf/tests/test_api/repo/api/test_create_repository.py index 62b4fda8d..e672efe36 100644 --- a/taf/tests/test_api/repo/api/test_create_repository.py +++ b/taf/tests/test_api/repo/api/test_create_repository.py @@ -10,6 +10,7 @@ ) from taf.tests.utils import copy_mirrors_json, copy_repositories_json from taf.updater.updater import validate_repository +from taf.yubikey.yubikey_manager import PinManager def _check_repo_initialization_successful( @@ -39,10 +40,12 @@ def test_create_repository_when_no_delegations( auth_repo_path: Path, with_delegations_no_yubikeys_path: str, keystore_delegations: str, + pin_manager: PinManager, ): repo_path = str(auth_repo_path) create_repository( repo_path, + pin_manager, roles_key_infos=with_delegations_no_yubikeys_path, keystore=keystore_delegations, commit=True, @@ -57,10 +60,12 @@ def test_create_repository_when_no_delegations_with_test_flag( auth_repo_path: Path, with_delegations_no_yubikeys_path: str, keystore_delegations: str, + pin_manager: PinManager, ): repo_path = str(auth_repo_path) create_repository( repo_path, + pin_manager, roles_key_infos=with_delegations_no_yubikeys_path, keystore=keystore_delegations, commit=True, @@ -77,10 +82,12 @@ def test_create_repository_when_delegations( auth_repo_path: Path, with_delegations_no_yubikeys_path: str, keystore_delegations: str, + pin_manager: PinManager, ): repo_path = str(auth_repo_path) create_repository( str(auth_repo_path), + pin_manager, roles_key_infos=with_delegations_no_yubikeys_path, keystore=keystore_delegations, commit=True, @@ -100,6 +107,7 @@ def test_create_repository_when_add_repositories_json( keystore_delegations: str, repositories_json_template: Dict, mirrors_json_path: Path, + pin_manager: PinManager, ): repo_path = str(auth_repo_path) namespace = auth_repo_path.parent.name @@ -108,6 +116,7 @@ def test_create_repository_when_add_repositories_json( create_repository( repo_path, + pin_manager, roles_key_infos=with_delegations_no_yubikeys_path, keystore=keystore_delegations, commit=True, diff --git a/taf/tests/test_api/roles/api/test_roles.py b/taf/tests/test_api/roles/api/test_roles.py index 13f23b1dc..b520939f7 100644 --- a/taf/tests/test_api/roles/api/test_roles.py +++ b/taf/tests/test_api/roles/api/test_roles.py @@ -2,7 +2,7 @@ from taf.api.roles import ( add_role, add_role_paths, - add_multiple_roles, + add_roles, add_signing_key, list_keys_of_role, remove_paths, @@ -11,10 +11,13 @@ from taf.messages import git_commit_message from taf.auth_repo import AuthenticationRepository from taf.tests.test_api.util import check_new_role +from taf.yubikey.yubikey_manager import PinManager def test_add_role_when_target_is_parent( - auth_repo: AuthenticationRepository, roles_keystore: str + auth_repo: AuthenticationRepository, + roles_keystore: str, + pin_manager: PinManager, ): initial_commits_num = len(auth_repo.list_commits()) ROLE_NAME = "new_role" @@ -22,6 +25,7 @@ def test_add_role_when_target_is_parent( PARENT_NAME = "targets" add_role( path=str(auth_repo.path), + pin_manager=pin_manager, auth_repo=auth_repo, role=ROLE_NAME, parent_role=PARENT_NAME, @@ -40,7 +44,9 @@ def test_add_role_when_target_is_parent( def test_add_role_when_delegated_role_is_parent( - auth_repo_with_delegations: AuthenticationRepository, roles_keystore: str + auth_repo_with_delegations: AuthenticationRepository, + roles_keystore: str, + pin_manager: PinManager, ): initial_commits_num = len(auth_repo_with_delegations.list_commits()) ROLE_NAME = "new_inner_role" @@ -48,6 +54,7 @@ def test_add_role_when_delegated_role_is_parent( PARENT_NAME = "delegated_role" add_role( path=str(auth_repo_with_delegations.path), + pin_manager=pin_manager, auth_repo=auth_repo_with_delegations, role=ROLE_NAME, parent_role=PARENT_NAME, @@ -69,21 +76,23 @@ def test_add_role_when_delegated_role_is_parent( def test_add_multiple_roles( auth_repo: AuthenticationRepository, + pin_manager: PinManager, roles_keystore: str, - with_delegations_no_yubikeys_path: str, + add_roles_config_json_input: str, ): initial_commits_num = len(auth_repo.list_commits()) - add_multiple_roles( + add_roles( path=str(auth_repo.path), + pin_manager=pin_manager, keystore=roles_keystore, - roles_key_infos=with_delegations_no_yubikeys_path, + roles_key_infos=add_roles_config_json_input, push=False, ) # with_delegations_no_yubikeys_path specification contains delegated_role and inner_role # definitions, so these two roles should get added to the repository commits = auth_repo.list_commits() assert len(commits) == initial_commits_num + 1 - new_roles = ["delegated_role", "inner_role"] + new_roles = ["delegated_role"] assert commits[0].message.strip() == git_commit_message( "add-roles", roles=", ".join(new_roles) ) @@ -91,17 +100,19 @@ def test_add_multiple_roles( for role_name in new_roles: assert role_name in target_roles assert auth_repo.find_delegated_roles_parent("delegated_role") == "targets" - assert auth_repo.find_delegated_roles_parent("inner_role") == "delegated_role" def test_add_role_paths( - auth_repo_with_delegations: AuthenticationRepository, roles_keystore: str + auth_repo_with_delegations: AuthenticationRepository, + roles_keystore: str, + pin_manager: PinManager, ): initial_commits_num = len(auth_repo_with_delegations.list_commits()) NEW_PATHS = ["some-path3"] ROLE_NAME = "delegated_role" add_role_paths( auth_repo=auth_repo_with_delegations, + pin_manager=pin_manager, paths=NEW_PATHS, keystore=roles_keystore, delegated_role=ROLE_NAME, @@ -119,13 +130,16 @@ def test_add_role_paths( def test_remove_role_paths( - auth_repo_with_delegations: AuthenticationRepository, roles_keystore: str + auth_repo_with_delegations: AuthenticationRepository, + roles_keystore: str, + pin_manager: PinManager, ): initial_commits_num = len(auth_repo_with_delegations.list_commits()) REMOVED_PATHS = ["dir2/path1"] ROLE_NAME = "delegated_role" remove_paths( path=str(auth_repo_with_delegations.path), + pin_manager=pin_manager, paths=REMOVED_PATHS, keystore=roles_keystore, push=False, @@ -237,7 +251,9 @@ def test_list_keys(auth_repo: AuthenticationRepository): assert len(timestamp_keys_infos) == 1 -def test_add_signing_key(auth_repo: AuthenticationRepository, roles_keystore: str): +def test_add_signing_key( + auth_repo: AuthenticationRepository, roles_keystore: str, pin_manager: PinManager +): auth_repo = AuthenticationRepository(path=auth_repo.path) initial_commits_num = len(auth_repo.list_commits()) # for testing purposes, add targets signing key to timestamp and snapshot roles @@ -245,6 +261,7 @@ def test_add_signing_key(auth_repo: AuthenticationRepository, roles_keystore: st COMMIT_MSG = "Add new timestamp and snapshot signing key" add_signing_key( path=str(auth_repo.path), + pin_manager=pin_manager, pub_key_path=str(pub_key_path), roles=["timestamp", "snapshot"], keystore=roles_keystore, @@ -260,7 +277,9 @@ def test_add_signing_key(auth_repo: AuthenticationRepository, roles_keystore: st assert len(snapshot_keys_infos) == 2 -def test_revoke_signing_key(auth_repo: AuthenticationRepository, roles_keystore: str): +def test_revoke_signing_key( + auth_repo: AuthenticationRepository, roles_keystore: str, pin_manager: PinManager +): auth_repo = AuthenticationRepository(path=auth_repo.path) targest_keyids = auth_repo.get_keyids_of_role("targets") key_to_remove = targest_keyids[-1] @@ -270,6 +289,7 @@ def test_revoke_signing_key(auth_repo: AuthenticationRepository, roles_keystore: COMMIT_MSG = "Revoke a targets key" revoke_signing_key( path=str(auth_repo.path), + pin_manager=pin_manager, key_id=key_to_remove, keystore=roles_keystore, push=False, diff --git a/taf/tests/test_api/roles/cli/test_roles_cmds.py b/taf/tests/test_api/roles/cli/test_roles_cmds.py index 3d5804f00..d8e22d7fa 100644 --- a/taf/tests/test_api/roles/cli/test_roles_cmds.py +++ b/taf/tests/test_api/roles/cli/test_roles_cmds.py @@ -1,81 +1,42 @@ -import json - from pathlib import Path from click.testing import CliRunner from taf.api.roles import list_keys_of_role +from taf.tests.test_api.conftest import ADD_ROLES_CONFIG_INPUT from taf.tests.test_api.util import check_new_role from taf.tools.cli.taf import taf -def test_roles_add_cmd_expect_success(auth_repo_with_delegations, roles_keystore): +def test_roles_add_cmd_expect_success(auth_repo, roles_keystore): runner = CliRunner() with runner.isolated_filesystem(): - # cli expects a config file, so we manually create config pass it to the cli - cwd = Path.cwd() - config = { - "parent_role": "targets", - "delegated_path": [ - "/delegated_path_inside_targets1", - "/delegated_path_inside_targets2", - ], - "keys_number": 2, - "threshold": 1, - "yubikey": False, - "scheme": "rsa-pkcs1v15-sha256", - } - config_file_path = cwd / "config.json" - with open(config_file_path, "w") as f: - json.dump(config, f) runner.invoke( taf, [ "roles", "add", - "new_role", - "--path", - f"{str(auth_repo_with_delegations.path)}", "--config-file", - f"{str(config_file_path)}", - "--keystore", - f"{str(roles_keystore)}", - ], - ) - check_new_role( - auth_repo_with_delegations, - "new_role", - ["/delegated_path_inside_targets1", "/delegated_path_inside_targets2"], - str(roles_keystore), - "targets", - ) - - -def test_roles_add_multiple_cmd_expect_success( - auth_repo, with_delegations_no_yubikeys_path, roles_keystore -): - runner = CliRunner() - - with runner.isolated_filesystem(): - runner.invoke( - taf, - [ - "roles", - "add-multiple", - f"{str(with_delegations_no_yubikeys_path)}", + f"{ADD_ROLES_CONFIG_INPUT}", "--path", f"{str(auth_repo.path)}", "--keystore", f"{str(roles_keystore)}", ], ) - new_roles = ["delegated_role", "inner_role"] + new_roles = ["delegated_role"] target_roles = auth_repo.get_all_targets_roles() for role_name in new_roles: assert role_name in target_roles assert auth_repo.find_delegated_roles_parent("delegated_role") == "targets" - assert auth_repo.find_delegated_roles_parent("inner_role") == "delegated_role" + check_new_role( + auth_repo, + "delegated_role", + ["/delegated_path_inside_targets1", "/delegated_path_inside_targets2"], + str(roles_keystore), + "targets", + ) def test_roles_add_role_paths_cmd_expect_success( diff --git a/taf/tests/test_api/targets/api/test_targets.py b/taf/tests/test_api/targets/api/test_targets.py index 37201825a..540a0bfb1 100644 --- a/taf/tests/test_api/targets/api/test_targets.py +++ b/taf/tests/test_api/targets/api/test_targets.py @@ -14,6 +14,7 @@ check_if_targets_signed, check_target_file, ) +from taf.yubikey.yubikey_manager import PinManager AUTH_REPO_NAME = "auth" @@ -21,6 +22,7 @@ def test_register_targets_when_file_added( auth_repo_when_add_repositories_json: AuthenticationRepository, + pin_manager: PinManager, library: Path, keystore_delegations: str, ): @@ -31,7 +33,11 @@ def test_register_targets_when_file_added( file_path = repo_path / TARGETS_DIRECTORY_NAME / FILENAME file_path.write_text("test") register_target_files( - repo_path, keystore_delegations, update_snapshot_and_timestamp=True, push=False + repo_path, + pin_manager, + keystore_delegations, + update_snapshot_and_timestamp=True, + push=False, ) check_if_targets_signed(auth_repo_when_add_repositories_json, "targets", FILENAME) commits = auth_repo_when_add_repositories_json.list_commits() @@ -41,6 +47,7 @@ def test_register_targets_when_file_added( def test_register_targets_when_file_removed( auth_repo_when_add_repositories_json: AuthenticationRepository, + pin_manager: PinManager, library: Path, keystore_delegations: str, ): @@ -51,11 +58,19 @@ def test_register_targets_when_file_removed( file_path = repo_path / TARGETS_DIRECTORY_NAME / FILENAME file_path.write_text("test") register_target_files( - repo_path, keystore_delegations, update_snapshot_and_timestamp=True, push=False + repo_path, + pin_manager, + keystore_delegations, + update_snapshot_and_timestamp=True, + push=False, ) file_path.unlink() register_target_files( - repo_path, keystore_delegations, update_snapshot_and_timestamp=True, push=False + repo_path, + pin_manager, + keystore_delegations, + update_snapshot_and_timestamp=True, + push=False, ) signed_target_files = auth_repo_when_add_repositories_json.get_signed_target_files() assert FILENAME not in signed_target_files @@ -66,6 +81,7 @@ def test_register_targets_when_file_removed( def test_update_target_repos_from_repositories_json( auth_repo_when_add_repositories_json: AuthenticationRepository, + pin_manager: PinManager, library: Path, keystore_delegations: str, ): @@ -74,6 +90,7 @@ def test_update_target_repos_from_repositories_json( namespace = library.name update_target_repos_from_repositories_json( str(repo_path), + pin_manager, str(library.parent), keystore_delegations, push=False, @@ -92,6 +109,7 @@ def test_update_target_repos_from_repositories_json( def test_add_target_repository_when_not_on_filesystem( auth_repo_when_add_repositories_json: AuthenticationRepository, + pin_manager: PinManager, library: Path, keystore_delegations: str, ): @@ -101,6 +119,7 @@ def test_add_target_repository_when_not_on_filesystem( target_repo_name = f"{namespace}/target4" add_target_repo( str(repo_path), + pin_manager, None, target_repo_name, "delegated_role", @@ -130,6 +149,7 @@ def test_add_target_repository_when_not_on_filesystem( def test_add_target_repository_when_on_filesystem( auth_repo_when_add_repositories_json: AuthenticationRepository, + pin_manager: PinManager, library: Path, keystore_delegations: str, ): @@ -139,6 +159,7 @@ def test_add_target_repository_when_on_filesystem( target_repo_name = f"{namespace}/new_target" add_target_repo( repo_path, + pin_manager, None, target_repo_name, "delegated_role", diff --git a/taf/tests/test_api/targets/cli/test_targets_cmds.py b/taf/tests/test_api/targets/cli/test_targets_cmds.py index 9345a8872..57002e731 100644 --- a/taf/tests/test_api/targets/cli/test_targets_cmds.py +++ b/taf/tests/test_api/targets/cli/test_targets_cmds.py @@ -99,6 +99,7 @@ def test_targets_add_repo_cmd_expect_success( config_file_path = cwd / "config.json" with open(config_file_path, "w") as f: json.dump(config, f) + runner.invoke( taf, [ diff --git a/taf/tests/test_repositoriesdb/conftest.py b/taf/tests/test_repositoriesdb/conftest.py index 762e5c4ff..ddf91bbd4 100644 --- a/taf/tests/test_repositoriesdb/conftest.py +++ b/taf/tests/test_repositoriesdb/conftest.py @@ -12,6 +12,7 @@ from taf.tests.utils import copy_mirrors_json, copy_repositories_json from taf.utils import on_rm_error from contextlib import contextmanager +from taf.yubikey.yubikey_manager import PinManager AUTH_REPO_NAME = "auth" @@ -43,6 +44,7 @@ def auth_repo_with_targets( keystore_delegations: str, repositories_json_template: Dict, mirrors_json_path: Path, + pin_manager: PinManager, ): auth_path = root_dir / AUTH_REPO_NAME auth_path.mkdir(exist_ok=True, parents=True) @@ -51,15 +53,21 @@ def auth_repo_with_targets( copy_mirrors_json(mirrors_json_path, auth_path) create_repository( str(auth_path), + pin_manager, roles_key_infos=with_delegations_no_yubikeys_path, keystore=keystore_delegations, commit=True, ) update_target_repos_from_repositories_json( - str(auth_path), str(root_dir.parent), keystore_delegations, commit=True + str(auth_path), + pin_manager, + str(root_dir.parent), + keystore_delegations, + commit=True, ) update_metadata_expiration_date( path=auth_path, + pin_manager=pin_manager, roles=["targets"], keystore=keystore_delegations, ) diff --git a/taf/tests/test_updater/conftest.py b/taf/tests/test_updater/conftest.py index 982973ee6..0421c33d3 100644 --- a/taf/tests/test_updater/conftest.py +++ b/taf/tests/test_updater/conftest.py @@ -40,6 +40,7 @@ KEYSTORE_PATH, TEST_INIT_DATA_PATH, ) +from taf.yubikey.yubikey_manager import PinManager from tuf.api.metadata import Timestamp @@ -155,6 +156,8 @@ def add_task(self, function, kwargs=None): date = kwargs.pop("date", None) repetitions = kwargs.pop("repetitions", 1) sig = inspect.signature(function) + if "pin_manager" in sig.parameters: + function = partial(function, pin_manager=self.auth_repo.pin_manager) if "auth_repo" in sig.parameters: function = partial(function, auth_repo=self.auth_repo) # Check if the parameter is in the signature @@ -210,7 +213,9 @@ def test_name(request): @pytest.fixture(scope="function") -def origin_auth_repo(request, test_name: str, origin_dir: Path): +def origin_auth_repo( + request, test_name: str, origin_dir: Path, pin_manager: PinManager +): targets_config_list = request.param["targets_config"] is_test_repo = request.param.get("is_test_repo", False) date = request.param.get("data") @@ -228,11 +233,16 @@ def origin_auth_repo(request, test_name: str, origin_dir: Path): if date is not None: with freeze_time(date): auth_repo = _init_auth_repo( - origin_dir, setup_type, repo_name, targets_config, is_test_repo + origin_dir, + setup_type, + repo_name, + targets_config, + is_test_repo, + pin_manager, ) else: auth_repo = _init_auth_repo( - origin_dir, setup_type, repo_name, targets_config, is_test_repo + origin_dir, setup_type, repo_name, targets_config, is_test_repo, pin_manager ) yield auth_repo @@ -293,11 +303,16 @@ def create_mirrors_json(library_dir: Path, repo_name: str): def create_authentication_repository( - library_dir: Path, repo_name: str, keys_description: str, is_test_repo: bool = False + library_dir: Path, + pin_manager: PinManager, + repo_name: str, + keys_description: str, + is_test_repo: bool = False, ): repo_path = Path(library_dir, repo_name) create_repository( str(repo_path), + pin_manager, str(KEYSTORE_PATH), keys_description, commit=True, @@ -305,9 +320,9 @@ def create_authentication_repository( ) -def sign_target_files(library_dir, repo_name, keystore): +def sign_target_files(library_dir, repo_name, keystore, pin_manager): repo_path = Path(library_dir, repo_name) - register_target_files(str(repo_path), keystore) + register_target_files(str(repo_path), pin_manager, keystore) def _init_auth_repo( @@ -316,26 +331,27 @@ def _init_auth_repo( repo_name: str, targets_config: list, is_test_repo: bool, + pin_manager: PinManager, ) -> AuthenticationRepository: if setup_type == SetupState.ALL_FILES_INITIALLY: return setup_repository_all_files_initially( - origin_dir, repo_name, targets_config, is_test_repo + origin_dir, repo_name, targets_config, is_test_repo, pin_manager ) elif setup_type == SetupState.NO_INFO_JSON: return setup_repository_no_info_json( - origin_dir, repo_name, targets_config, is_test_repo + origin_dir, repo_name, targets_config, is_test_repo, pin_manager ) elif setup_type == SetupState.MIRRORS_ADDED_LATER: return setup_repository_mirrors_added_later( - origin_dir, repo_name, targets_config, is_test_repo + origin_dir, repo_name, targets_config, is_test_repo, pin_manager ) elif setup_type == SetupState.MIRRORS_AND_REPOSITOIRES_ADDED_LATER: return setup_repository_repositories_and_mirrors_added_later( - origin_dir, repo_name, targets_config, is_test_repo + origin_dir, repo_name, targets_config, is_test_repo, pin_manager ) elif setup_type == SetupState.NO_TARGET_REPOSITORIES: return setup_repository_no_target_repositories( - origin_dir, repo_name, targets_config, is_test_repo + origin_dir, repo_name, targets_config, is_test_repo, pin_manager ) else: raise ValueError(f"Unsupported setup type: {setup_type}") @@ -369,10 +385,13 @@ def initialize_target_repositories( target_repo.commit("Initial commit") -def sign_target_repositories(library_dir: Path, repo_name: str, keystore: Path): +def sign_target_repositories( + library_dir: Path, repo_name: str, keystore: Path, pin_manager: PinManager +): repo_path = Path(library_dir, repo_name) update_target_repos_from_repositories_json( str(repo_path), + pin_manager, str(library_dir), str(keystore), ) @@ -386,7 +405,11 @@ def generate_repositories_json(targets_data: list): def setup_repository_all_files_initially( - origin_dir: Path, repo_name: str, targets_config: list, is_test_repo: bool + origin_dir: Path, + repo_name: str, + targets_config: list, + is_test_repo: bool, + pin_manager: PinManager, ) -> AuthenticationRepository: # Define the origin path # Execute the tasks directly @@ -395,20 +418,27 @@ def setup_repository_all_files_initially( create_info_json(origin_dir, repo_name) create_authentication_repository( origin_dir, + pin_manager, repo_name, keys_description=KEYS_DESCRIPTION, is_test_repo=is_test_repo, ) if targets_config: initialize_target_repositories(origin_dir, targets_config=targets_config) - sign_target_repositories(origin_dir, repo_name, keystore=KEYSTORE_PATH) + sign_target_repositories( + origin_dir, repo_name, keystore=KEYSTORE_PATH, pin_manager=pin_manager + ) # Yield the authentication repository object return AuthenticationRepository(origin_dir, repo_name) def setup_repository_no_info_json( - origin_dir: Path, repo_name: str, targets_config: list, is_test_repo: bool + origin_dir: Path, + repo_name: str, + targets_config: list, + is_test_repo: bool, + pin_manager: PinManager, ) -> AuthenticationRepository: # Define the origin path @@ -417,64 +447,89 @@ def setup_repository_no_info_json( create_mirrors_json(origin_dir, repo_name) create_authentication_repository( origin_dir, + pin_manager, repo_name, keys_description=KEYS_DESCRIPTION, is_test_repo=is_test_repo, ) if targets_config: initialize_target_repositories(origin_dir, targets_config=targets_config) - sign_target_repositories(origin_dir, repo_name, keystore=KEYSTORE_PATH) + sign_target_repositories( + origin_dir, repo_name, keystore=KEYSTORE_PATH, pin_manager=pin_manager + ) # Yield the authentication repository object return AuthenticationRepository(origin_dir, repo_name) def setup_repository_mirrors_added_later( - origin_dir: Path, repo_name: str, targets_config: list, is_test_repo: bool + origin_dir: Path, + repo_name: str, + targets_config: list, + is_test_repo: bool, + pin_manager: PinManager, ) -> AuthenticationRepository: # Execute the tasks directly create_repositories_json(origin_dir, repo_name, targets_config=targets_config) create_info_json(origin_dir, repo_name) create_authentication_repository( origin_dir, + pin_manager, repo_name, keys_description=KEYS_DESCRIPTION, is_test_repo=is_test_repo, ) if targets_config: initialize_target_repositories(origin_dir, targets_config=targets_config) - sign_target_repositories(origin_dir, repo_name, keystore=KEYSTORE_PATH) + sign_target_repositories( + origin_dir, repo_name, keystore=KEYSTORE_PATH, pin_manager=pin_manager + ) create_mirrors_json(origin_dir, repo_name) - sign_target_files(origin_dir, repo_name, keystore=KEYSTORE_PATH) + sign_target_files( + origin_dir, repo_name, keystore=KEYSTORE_PATH, pin_manager=pin_manager + ) # Yield the authentication repository object return AuthenticationRepository(origin_dir, repo_name) def setup_repository_repositories_and_mirrors_added_later( - origin_dir: Path, repo_name: str, targets_config: list, is_test_repo: bool + origin_dir: Path, + repo_name: str, + targets_config: list, + is_test_repo: bool, + pin_manager: PinManager, ) -> AuthenticationRepository: # Execute the tasks directly create_info_json(origin_dir, repo_name) create_authentication_repository( origin_dir, + pin_manager, repo_name, keys_description=KEYS_DESCRIPTION, is_test_repo=is_test_repo, ) create_repositories_json(origin_dir, repo_name, targets_config=targets_config) create_mirrors_json(origin_dir, repo_name) - sign_target_files(origin_dir, repo_name, keystore=KEYSTORE_PATH) + sign_target_files( + origin_dir, repo_name, keystore=KEYSTORE_PATH, pin_manager=pin_manager + ) if targets_config: initialize_target_repositories(origin_dir, targets_config=targets_config) - sign_target_repositories(origin_dir, repo_name, keystore=KEYSTORE_PATH) + sign_target_repositories( + origin_dir, repo_name, keystore=KEYSTORE_PATH, pin_manager=pin_manager + ) # Yield the authentication repository object return AuthenticationRepository(origin_dir, repo_name) def setup_repository_no_target_repositories( - origin_dir: Path, repo_name: str, targets_config: list, is_test_repo: bool + origin_dir: Path, + repo_name: str, + targets_config: list, + is_test_repo: bool, + pin_manager: PinManager, ) -> AuthenticationRepository: # Execute the tasks directly @@ -483,6 +538,7 @@ def setup_repository_no_target_repositories( create_mirrors_json(origin_dir, repo_name) create_authentication_repository( origin_dir, + pin_manager, repo_name, keys_description=KEYS_DESCRIPTION, is_test_repo=is_test_repo, @@ -503,13 +559,18 @@ def add_file_to_repository( def add_valid_target_commits( - auth_repo: AuthenticationRepository, target_repos: list, add_if_empty: bool = True + auth_repo: AuthenticationRepository, + pin_manager: PinManager, + target_repos: list, + add_if_empty: bool = True, ): for target_repo in target_repos: if not add_if_empty and target_repo.head_commit_sha() is None: continue update_target_repository(target_repo, "Update target files") - sign_target_repositories(TEST_DATA_ORIGIN_PATH, auth_repo.name, KEYSTORE_PATH) + sign_target_repositories( + TEST_DATA_ORIGIN_PATH, auth_repo.name, KEYSTORE_PATH, pin_manager + ) def add_file_to_target_repo_without_committing(target_repos: list, target_name: str): @@ -540,7 +601,10 @@ def add_unauthenticated_commit_to_target_repo(target_repos: list, target_name: s def create_new_target_orphan_branches( - auth_repo: AuthenticationRepository, target_repos: list, branch_name: str + auth_repo: AuthenticationRepository, + target_repos: list, + pin_manager: PinManager, + branch_name: str, ): for target_repo in target_repos: target_repo.checkout_orphan_branch(branch_name) @@ -550,7 +614,9 @@ def create_new_target_orphan_branches( random_text = _generate_random_text() (target_repo.path / f"test{i}.txt").write_text(random_text) target_repo.commit("Initial commit") - sign_target_repositories(TEST_DATA_ORIGIN_PATH, auth_repo.name, KEYSTORE_PATH) + sign_target_repositories( + TEST_DATA_ORIGIN_PATH, auth_repo.name, KEYSTORE_PATH, pin_manager + ) def create_new_target_repo_branch( @@ -638,10 +704,14 @@ def swap_last_two_commits(auth_repo: AuthenticationRepository): def update_expiration_dates( - auth_repo: AuthenticationRepository, roles=["snapshot", "timestamp"], push=True + auth_repo: AuthenticationRepository, + pin_manager: PinManager, + roles=["snapshot", "timestamp"], + push=True, ): update_metadata_expiration_date( str(auth_repo.path), + pin_manager, roles=roles, keystore=KEYSTORE_PATH, interval=None, @@ -650,10 +720,11 @@ def update_expiration_dates( def update_auth_repo_without_committing( - auth_repo: AuthenticationRepository, roles=["snapshot", "timestamp"] + auth_repo: AuthenticationRepository, pin_manager, roles=["snapshot", "timestamp"] ): update_metadata_expiration_date( str(auth_repo.path), + pin_manager, roles=roles, keystore=KEYSTORE_PATH, interval=None, @@ -662,10 +733,11 @@ def update_auth_repo_without_committing( def update_role_metadata_without_signing( - auth_repo: AuthenticationRepository, role: str + auth_repo: AuthenticationRepository, pin_manager, role: str ): update_metadata_expiration_date( path=auth_repo.path, + pin_manager=pin_manager, roles=[role], keystore=KEYSTORE_PATH, prompt_for_keys=False, @@ -678,10 +750,12 @@ def update_role_metadata_without_signing( def update_target_repo_without_committing(target_repos: list, target_name: str): for target_repo in target_repos: if target_name in target_repo.name: - update_target_repository(target_repo) + update_target_repository(target_repo, None) -def update_timestamp_metadata_invalid_signature(auth_repo: AuthenticationRepository): +def update_timestamp_metadata_invalid_signature( + auth_repo: AuthenticationRepository, +): role = Timestamp.type with manage_repo_and_signers( @@ -704,11 +778,12 @@ def update_timestamp_metadata_invalid_signature(auth_repo: AuthenticationReposit def update_and_sign_metadata_without_clean_check( - auth_repo: AuthenticationRepository, roles: list + auth_repo: AuthenticationRepository, pin_manager: PinManager, roles: list ): update_metadata_expiration_date( path=auth_repo.path, + pin_manager=pin_manager, roles=roles, keystore=KEYSTORE_PATH, scheme=DEFAULT_RSA_SIGNATURE_SCHEME, diff --git a/taf/tests/test_updater/test_update_library/conftest.py b/taf/tests/test_updater/test_update_library/conftest.py index b662f3f1c..5c0154d44 100644 --- a/taf/tests/test_updater/test_update_library/conftest.py +++ b/taf/tests/test_updater/test_update_library/conftest.py @@ -39,7 +39,7 @@ def create_and_write_json(template_path, substitutions, output_path): @fixture -def library_with_dependencies(origin_dir, request): +def library_with_dependencies(origin_dir, pin_manager, request): library = {} dependencies_config = request.param["dependencies_config"] initial_commits = {} @@ -50,7 +50,7 @@ def library_with_dependencies(origin_dir, request): RepositoryConfig(target["name"]) for target in dep.get("targets_config", []) ] auth_repo = setup_repository_all_files_initially( - origin_dir, namespace, targets_config, False + origin_dir, namespace, targets_config, False, pin_manager ) target_repos = load_target_repositories(auth_repo).values() library[auth_repo.name] = { @@ -63,7 +63,7 @@ def library_with_dependencies(origin_dir, request): root_repo_name = f"{ROOT_REPO_NAMESPACE}/{AUTH_NAME}" root_auth_repo = setup_repository_all_files_initially( - origin_dir, root_repo_name, [], False + origin_dir, root_repo_name, [], False, pin_manager ) (root_auth_repo.path / TARGETS_DIRECTORY_NAME).mkdir(parents=True, exist_ok=True) @@ -78,7 +78,9 @@ def library_with_dependencies(origin_dir, request): params, root_auth_repo.path / TARGETS_DIRECTORY_NAME / DEPENDENCIES_JSON_NAME, ) - sign_target_files(origin_dir, root_repo_name, keystore=KEYSTORE_PATH) + sign_target_files( + origin_dir, root_repo_name, keystore=KEYSTORE_PATH, pin_manager=pin_manager + ) library[root_auth_repo.name] = {"auth_repo": root_auth_repo, "target_repos": []} yield library diff --git a/taf/tests/tuf/test_keys/test_yk.py b/taf/tests/tuf/test_keys/test_yk.py index 63efcadb5..c301bc95d 100644 --- a/taf/tests/tuf/test_keys/test_yk.py +++ b/taf/tests/tuf/test_keys/test_yk.py @@ -1,7 +1,9 @@ """Test YkSigner""" -from getpass import getpass +from functools import partial import os + +from taf.yubikey.yubikey_manager import PinManager import pytest from securesystemslib.exceptions import UnverifiedSignatureError @@ -38,13 +40,19 @@ def is_yubikey_manager_installed(): ) def test_fake_yk(mocker): """Test public key export and signing with fake Yubikey.""" - mocker.patch("taf.yubikey.export_piv_pub_key", return_value=_PUB) - mocker.patch("taf.yubikey.sign_piv_rsa_pkcs1v15", return_value=_SIG) + mocker.patch("taf.yubikey.yubikey.export_piv_pub_key", return_value=_PUB) + mocker.patch("taf.yubikey.yubikey.sign_piv_rsa_pkcs1v15", return_value=_SIG) + mocker.patch("taf.yubikey.yubikey.verify_yk_inserted", return_value=True) + mocker.patch("taf.yubikey.yubikey.get_serial_num", return_value=["1234"]) from taf.tuf.keys import YkSigner key = YkSigner.import_() - signer = YkSigner(key, lambda sec: None) + + def _secrets_handler(key_name): + return "123456" + + signer = YkSigner(key, "1234", _secrets_handler, "test") sig = signer.sign(_DATA) key.verify_signature(sig, _DATA) @@ -59,13 +67,22 @@ def test_fake_yk(mocker): def test_real_yk(): """Test public key export and signing with real Yubikey.""" - def sec_handler(secret_name: str) -> str: - return getpass(f"Enter {secret_name}: ") - + import taf.yubikey.yubikey as yk from taf.tuf.keys import YkSigner - + from taf.yubikey.yubikey import get_serial_num + + serials = get_serial_num() + serial = serials[0] + pin_manager = PinManager() + pin_manager.add_pin(serial, "123456") + + secrets_handler = partial( + yk.yk_secrets_handler, + pin_manager=pin_manager, + serial_num=serial, + ) key = YkSigner.import_() - signer = YkSigner(key, sec_handler) + signer = YkSigner(key, serial, secrets_handler, "test") sig = signer.sign(_DATA) key.verify_signature(sig, _DATA) diff --git a/taf/tests/tuf/test_query_repo/test_query_repo.py b/taf/tests/tuf/test_query_repo/test_query_repo.py index 749363027..8bf7c768f 100644 --- a/taf/tests/tuf/test_query_repo/test_query_repo.py +++ b/taf/tests/tuf/test_query_repo/test_query_repo.py @@ -219,7 +219,8 @@ def test_get_key_length_and_scheme_from_metadata(tuf_repo_with_delegations): actual = tuf_repo_with_delegations.get_key_length_and_scheme_from_metadata( "root", keyid ) - key, scheme = actual + pem, key, scheme = actual + assert pem is not None assert key is not None assert scheme == "rsa-pkcs1v15-sha256" @@ -264,7 +265,7 @@ def test_generate_roles_description(tuf_repo_with_delegations): def test_sort_roles_targets_for_filenames(tuf_repo_with_delegations): actual = tuf_repo_with_delegations.sort_roles_targets_for_filenames() assert actual["targets"] == ["test1", "test2"] - assert actual["delegated_role"] == ["dir1/path1", "dir2/path1"] + assert set(actual["delegated_role"]) == set(["dir1/path1", "dir2/path1"]) assert actual["inner_role"] == ["dir2/path2"] diff --git a/taf/tools/cli/__init__.py b/taf/tools/cli/__init__.py index 0b7dca9e4..d777a409e 100644 --- a/taf/tools/cli/__init__.py +++ b/taf/tools/cli/__init__.py @@ -32,10 +32,8 @@ def wrapper(*args, **kwargs): successful = True return result except handle as e: - # TODO - # for now - # if print_error: - taf_logger.error(e) + if print_error: + taf_logger.error(e) except Exception as e: if is_run_from_python_executable(): taf_logger.error(f"An error occurred: {e}") diff --git a/taf/tools/dependencies/__init__.py b/taf/tools/dependencies/__init__.py index e51af2188..dc59ba2e0 100644 --- a/taf/tools/dependencies/__init__.py +++ b/taf/tools/dependencies/__init__.py @@ -2,6 +2,7 @@ from taf.api.dependencies import add_dependency, remove_dependency from taf.exceptions import TAFError from taf.tools.cli import catch_cli_exception, find_repository, process_custom_command_line_args +from taf.tools.repo import pin_managed def add_dependency_command(): @@ -49,10 +50,12 @@ def add_dependency_command(): @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") @click.pass_context - def add(ctx, dependency_name, path, branch_name, dependency_url, out_of_band_commit, dependency_path, keystore, prompt_for_keys, no_commit): + @pin_managed + def add(ctx, dependency_name, path, branch_name, dependency_url, out_of_band_commit, dependency_path, keystore, prompt_for_keys, no_commit, pin_manager): custom = process_custom_command_line_args(ctx) add_dependency( path=path, + pin_manager=pin_manager, dependency_name=dependency_name, branch_name=branch_name, dependency_url=dependency_url, @@ -84,9 +87,11 @@ def remove_dependency_command(): @click.option("--keystore", default=None, help="Location of the keystore files") @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") - def remove(dependency_name, path, keystore, prompt_for_keys, no_commit): + @pin_managed + def remove(dependency_name, path, keystore, prompt_for_keys, no_commit, pin_manager): remove_dependency( path=path, + pin_manager=pin_manager, dependency_name=dependency_name, keystore=keystore, prompt_for_keys=prompt_for_keys, diff --git a/taf/tools/metadata/__init__.py b/taf/tools/metadata/__init__.py index 8f851caf9..9e79dddb2 100644 --- a/taf/tools/metadata/__init__.py +++ b/taf/tools/metadata/__init__.py @@ -3,6 +3,7 @@ from taf.constants import DEFAULT_RSA_SIGNATURE_SCHEME from taf.exceptions import TAFError from taf.tools.cli import catch_cli_exception, find_repository +from taf.tools.repo import pin_managed from taf.utils import ISO_DATE_PARAM_TYPE as ISO_DATE import datetime @@ -58,12 +59,14 @@ def update_expiration_dates_command(): @click.option("--start-date", default=datetime.datetime.now(), type=ISO_DATE, help="Date to which the interval is added") @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") - def update_expiration_dates(path, role, interval, keystore, scheme, start_date, no_commit, prompt_for_keys): + @pin_managed + def update_expiration_dates(path, role, interval, keystore, scheme, start_date, no_commit, prompt_for_keys, pin_manager): if not len(role): print("Specify at least one role") return update_metadata_expiration_date( path=path, + pin_manager=pin_manager, roles=role, interval=interval, keystore=keystore, diff --git a/taf/tools/repo/__init__.py b/taf/tools/repo/__init__.py index 6b916f97d..8d656f03c 100644 --- a/taf/tools/repo/__init__.py +++ b/taf/tools/repo/__init__.py @@ -10,6 +10,7 @@ from taf.tools.cli import catch_cli_exception, find_repository from taf.updater.types.update import UpdateType from taf.updater.updater import OperationType, UpdateConfig, clone_repository, update_repository, validate_repository +from taf.yubikey.yubikey_manager import pin_managed def common_update_options(f): @@ -24,7 +25,7 @@ def common_update_options(f): def _call_updater(config, format_output): """ - A helper function which call update or clone repository + A helper function which calls update or clone repository """ try: if config.operation == OperationType.CLONE: @@ -121,9 +122,11 @@ def create_repo_command(): "committed automatically") @click.option("--test", is_flag=True, default=False, help="Indicates if the created repository " "is a test authentication repository") - def create(path, keys_description, keystore, no_commit, test): + @pin_managed + def create(path, keys_description, keystore, no_commit, test, pin_manager): create_repository( path=path, + pin_manager=pin_manager, keystore=keystore, roles_key_infos=keys_description, commit=not no_commit, diff --git a/taf/tools/roles/__init__.py b/taf/tools/roles/__init__.py index 7d887fddd..985ac53d9 100644 --- a/taf/tools/roles/__init__.py +++ b/taf/tools/roles/__init__.py @@ -1,10 +1,8 @@ import json from pathlib import Path -import sys import click from taf.api.roles import ( - add_multiple_roles, - add_role, + add_roles as add_multiple_roles, list_keys_of_role, add_signing_key, remove_role, @@ -19,13 +17,12 @@ from taf.tools.cli import catch_cli_exception, find_repository from taf.api.roles import add_role_paths +from taf.tools.repo import pin_managed -def add_role_command(): +def add_roles_command(): @click.command(help=""" - Add a new delegated target role. Allows optional specification of the role's properties through a JSON configuration file. - If the configuration file is not provided or specific properties are omitted, default values are used. - Only a list of one or more delegated paths has to be provided. + Add new delegated target roles. Allows optional specification of each role's properties through a JSON configuration file. Configuration file (JSON) can specify: - 'parent_role' (string): The parent role under which the new role will be delegated. Default is 'targets'. @@ -35,59 +32,57 @@ def add_role_command(): - 'yubikey' (boolean): Whether to use a YubiKey for signing. Default is false. - 'scheme' (string): Signature scheme, e.g., 'rsa-pkcs1v15-sha256'. Default is 'rsa-pkcs1v15-sha256'. + The structure of the configuration file is the very similar tohe structure of the one use when creating the repository, + but does not have to contain all roles, just the ones that should be added. If roles that already + exist are also defined, they will be skipped. If the role's parent is not the main targets role, + it's necessary to specify it using the "parent_role" option + Example JSON structure: { - "parent_role": "targets", - "delegated_path": ["/delegated_path_inside_targets1", "/delegated_path_inside_targets2"], - "keys_number": 1, - "threshold": 1, - "yubikey": true, - "scheme": "rsa-pkcs1v15-sha256" + "yubikeys": { + "user1": { + "public": "-----BEGIN PUBLIC KEY-----\nMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA95lvROpv0cjcXM4xBYe1\nhNYajb/lfM+57UhTteJsTsUgFefLKJmvXLZ7gFVroHTRzMeU0UvCaEWAxFWJiPOr\nxYBOtClSiPs4e0a/safLKDX0zBwT776CqA/EJ/P6+rPc2E2fawmq1k8RzalJj+0W\nz/xr9fKyMpZU7RQjJmuLcyqfUYTdnZHADn0CDM54gBZ4dYDGGQ70Pjmc1otq4jzh\nI390O4W9Gj9yXd6SyxW2Wpj2CI3g4J0pLl2c2Wjf7Jd4PVNxLGAFOU2YLoI4F3Ri\nsACFUWjfT7p6AagSPStzIMik1YfLq+qFUlhn3KbNMAY9afkvdbTPWT+vajjsoc4c\nOAex1y/uZ2npn/5Q0lT7gMH/JxB3GmAYHCew5W6GmO2mRfNO3J8A+hqS3nKGEbfR\ncb7V176O/tdRM0HguIWAuV75khrCpGLx/fZNAMFf3Q9p0iJsx9p6gCAHERi5e4BJ\nSCBkbtVGGsQ7JM7ptSiLLgi79hIXWehZFUIjuU7a2y4xAgMBAAE=\n-----END PUBLIC KEY-----", + "scheme": "rsa-pkcs1v15-sha256", + "present": false + }, + "userYK": { + "scheme": "rsa-pkcs1v15-sha256" + } + }, + roles: { + "name": { + "parent_role": "targets", + "patsh": ["/delegated_path_inside_targets1", "/delegated_path_inside_targets2"], + "keys_number": 2, + "threshold": 1, + "yubikey": true, + "scheme": "rsa-pkcs1v15-sha256" + "yubikeys": [ + "user1", "userYK" + ] + } } """) @find_repository @catch_cli_exception(handle=TAFError) - @click.argument("role") @click.option("--config-file", type=click.Path(exists=True), help="Path to the JSON configuration file.") @click.option("--path", default=".", help="Authentication repository's location. If not specified, set to the current directory") + @click.option("--scheme", default=DEFAULT_RSA_SIGNATURE_SCHEME, help="A signature scheme used for signing") @click.option("--keystore", default=None, help="Location of the keystore files") @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") - def add(role, config_file, path, keystore, no_commit, prompt_for_keys): - - config_data = {} - if config_file: - try: - config_data = json.loads(Path(config_file).read_text()) - except json.JSONDecodeError: - click.echo("Invalid JSON provided. Please check your input.", err=True) - sys.exit(1) - - delegated_path = config_data.get("delegated_path", []) - if not delegated_path: - taf_logger.log("NOTICE", "Specify at least one delegated path through a configuration file.") - return - - parent_role = config_data.get("parent_role", "targets") - keys_number = config_data.get("keys_number", 1) - threshold = config_data.get("threshold", 1) - yubikey = config_data.get("yubikey", False) - scheme = config_data.get("scheme", DEFAULT_RSA_SIGNATURE_SCHEME) - - add_role( + @pin_managed + def add_roles(config_file, path, scheme, keystore, no_commit, prompt_for_keys, pin_manager): + add_multiple_roles( path=path, - role=role, - parent_role=parent_role, - paths=delegated_path, - keys_number=keys_number, - threshold=threshold, - yubikey=yubikey, + pin_manager=pin_manager, keystore=keystore, + roles_key_infos=config_file, scheme=scheme, - commit=not no_commit, prompt_for_keys=prompt_for_keys, + commit=not no_commit, ) - return add + return add_roles def export_roles_description_command(): @@ -99,8 +94,9 @@ def export_roles_description_command(): @click.option("--path", default=".", help="Authentication repository's location. If not specified, set to the current directory") @click.option("--output", default=None, help="Output file path") @click.option("--keystore", default=None, help="Location of the keystore files") - def export_roles_description(path, output, keystore): - auth_repo = AuthenticationRepository(path=path) + @pin_managed + def export_roles_description(path, output, keystore, pin_manager): + auth_repo = AuthenticationRepository(path=path, pin_manager=pin_manager) roles_description = auth_repo.generate_roles_description() if keystore: roles_description["keystore"] = keystore @@ -114,60 +110,6 @@ def export_roles_description(path, output, keystore): return export_roles_description -def add_multiple_command(): - @click.command(help="""Adds new roles based on the provided keys-description file by - comparing it with the current state of the repository. - - The current state can be exported using taf roles export_roles_description and then - edited manually to add new roles. - - For each role, the following can be defined: - - Total number of keys per role. - - Threshold of required signatures per role. - - Use of Yubikeys or keystore files for storing keys. - - Signature scheme, with the default being 'rsa-pkcs1v15-sha256'. - - Keystore path, if not specified via the keystore option. - - \b - Example of a JSON configuration: - { - "roles": { - "root": { - "number": 3, - "length": 2048, - "passwords": ["password1", "password2", "password3"], - "threshold": 2, - "yubikey": true - }, - "targets": { - "length": 2048 - }, - "snapshot": {}, - "timestamp": {} - }, - "keystore": "keystore_path" - } - """) - @find_repository - @catch_cli_exception(handle=TAFError) - @click.option("--path", default=".", help="Authentication repository's location. If not specified, set to the current directory") - @click.argument("keys-description") - @click.option("--keystore", default=None, help="Location of the keystore files") - @click.option("--scheme", default=DEFAULT_RSA_SIGNATURE_SCHEME, help="A signature scheme used for signing") - @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") - @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") - def add_multiple(path, keystore, keys_description, scheme, no_commit, prompt_for_keys): - add_multiple_roles( - path=path, - keystore=keystore, - roles_key_infos=keys_description, - scheme=scheme, - prompt_for_keys=prompt_for_keys, - commit=not no_commit, - ) - return add_multiple - - def add_role_paths_command(): @click.command(help="Add a new delegated target role, specifying which paths are delegated to the new role. Its parent role, number of signing keys and signatures threshold can also be defined. Update and sign all metadata files and commit.") @find_repository @@ -178,13 +120,15 @@ def add_role_paths_command(): @click.option("--keystore", default=None, help="Location of the keystore files") @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") - def adding_role_paths(role, path, delegated_path, keystore, no_commit, prompt_for_keys): + @pin_managed + def adding_role_paths(role, path, delegated_path, keystore, no_commit, prompt_for_keys, pin_manager): if not delegated_path: print("Specify at least one path") return add_role_paths( paths=delegated_path, + pin_manager=pin_manager, delegated_role=role, keystore=keystore, commit=not no_commit, @@ -213,9 +157,11 @@ def remove_role_command(): @click.option("--remove-targets/--no-remove-targets", default=True, help="Should targets delegated to this role also be removed. If not removed, they are signed by the parent role") @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") - def remove(role, path, keystore, scheme, remove_targets, no_commit, prompt_for_keys): + @pin_managed + def remove(role, path, keystore, scheme, remove_targets, no_commit, prompt_for_keys, pin_manager): remove_role( path=path, + pin_manager=pin_manager, role=role, keystore=keystore, scheme=scheme, @@ -236,13 +182,15 @@ def remove_paths_command(): @click.option("--scheme", default=DEFAULT_RSA_SIGNATURE_SCHEME, help="A signature scheme used for signing") @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") - def remove_delegated_paths(path, delegated_path, keystore, scheme, no_commit, prompt_for_keys): + @pin_managed + def remove_delegated_paths(path, delegated_path, keystore, scheme, no_commit, prompt_for_keys, pin_manager): if not delegated_path: print("Specify at least one role") return remove_paths( path=path, + pin_manager=pin_manager, paths=delegated_path, keystore=keystore, scheme=scheme, @@ -271,13 +219,15 @@ def add_signing_key_command(): @click.option("--scheme", default=DEFAULT_RSA_SIGNATURE_SCHEME, help="A signature scheme used for signing") @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") - def adding_signing_key(path, role, pub_key_path, keystore, scheme, no_commit, prompt_for_keys): + @pin_managed + def adding_signing_key(path, role, pub_key_path, keystore, scheme, no_commit, prompt_for_keys, pin_manager): if not role: print("Specify at least one role") return add_signing_key( path=path, + pin_manager=pin_manager, roles=role, pub_key_path=pub_key_path, keystore=keystore, @@ -301,10 +251,12 @@ def revoke_signing_key_command(): @click.option("--scheme", default=DEFAULT_RSA_SIGNATURE_SCHEME, help="A signature scheme used for signing") @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") - def revoke_key(path, role, keyid, keystore, scheme, no_commit, prompt_for_keys): + @pin_managed + def revoke_key(path, role, keyid, keystore, scheme, no_commit, prompt_for_keys, pin_manager): revoke_signing_key( path=path, + pin_manager=pin_manager, roles=role, key_id=keyid, keystore=keystore, @@ -330,9 +282,11 @@ def rotate_signing_key_command(): @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") @click.option("--revoke-commit-msg", default=None, help="Revoke key commit message") @click.option("--add-commit-msg", default=None, help="Add new signing key commit message") - def rotate_key(path, role, keyid, pub_key_path, keystore, scheme, prompt_for_keys, revoke_commit_msg, add_commit_msg): + @pin_managed + def rotate_key(path, role, keyid, pub_key_path, keystore, scheme, prompt_for_keys, revoke_commit_msg, add_commit_msg, pin_manager): rotate_signing_key( path=path, + pin_manager=pin_manager, roles=role, key_id=keyid, keystore=keystore, @@ -365,8 +319,7 @@ def list_keys(role, path): def attach_to_group(group): - group.add_command(add_role_command(), name='add') - group.add_command(add_multiple_command(), name='add-multiple') + group.add_command(add_roles_command(), name='add') group.add_command(add_role_paths_command(), name='add-role-paths') group.add_command(remove_paths_command(), name='remove-paths') # group.add_command(remove_role_command(), name='remove') diff --git a/taf/tools/targets/__init__.py b/taf/tools/targets/__init__.py index d27452328..a3913c01b 100644 --- a/taf/tools/targets/__init__.py +++ b/taf/tools/targets/__init__.py @@ -15,6 +15,7 @@ from taf.exceptions import TAFError from taf.tools.cli import catch_cli_exception, find_repository from taf.log import taf_logger +from taf.tools.repo import pin_managed def add_repo_command(): @@ -69,7 +70,8 @@ def add_repo_command(): @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") @click.option("--scheme", default=DEFAULT_RSA_SIGNATURE_SCHEME, help="A signature scheme used for signing") @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") - def add_repo(path, target_path, target_name, role, config_file, keystore, prompt_for_keys, scheme, no_commit): + @pin_managed + def add_repo(path, target_path, target_name, role, config_file, keystore, prompt_for_keys, scheme, no_commit, pin_manager): config_data = {} if config_file: @@ -89,6 +91,7 @@ def add_repo(path, target_path, target_name, role, config_file, keystore, prompt add_target_repo( path=path, + pin_manager=pin_manager, target_path=target_path, target_name=target_name, library_dir=None, @@ -108,6 +111,7 @@ def add_repo(path, target_path, target_name, role, config_file, keystore, prompt else: add_target_repo( path=path, + pin_manager=pin_manager, target_path=target_path, target_name=target_name, library_dir=None, @@ -172,9 +176,11 @@ def remove_repo_command(): @click.argument("target-name") @click.option("--keystore", default=None, help="Location of the keystore files") @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") - def remove_repo(path, target_name, keystore, prompt_for_keys): + @pin_managed + def remove_repo(path, target_name, keystore, prompt_for_keys, pin_manager): remove_target_repo( path=path, + pin_manager=pin_manager, target_name=target_name, keystore=keystore, prompt_for_keys=prompt_for_keys, @@ -197,10 +203,12 @@ def sign_targets_command(): @click.option("--scheme", default=DEFAULT_RSA_SIGNATURE_SCHEME, help="A signature scheme used for signing") @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") - def sign(path, keystore, keys_description, scheme, prompt_for_keys, no_commit): + @pin_managed + def sign(path, keystore, keys_description, scheme, prompt_for_keys, no_commit, pin_manager): try: register_target_files( path=path, + pin_manager=pin_manager, keystore=keystore, roles_key_infos=keys_description, scheme=scheme, @@ -245,11 +253,13 @@ def update_and_sign_command(): @click.option("--scheme", default=DEFAULT_RSA_SIGNATURE_SCHEME, help="A signature scheme used for signing") @click.option("--prompt-for-keys", is_flag=True, default=False, help="Whether to ask the user to enter their key if not located inside the keystore directory") @click.option("--no-commit", is_flag=True, default=False, help="Indicates that the changes should not be committed automatically") - def update_and_sign(path, library_dir, target_type, keystore, keys_description, scheme, prompt_for_keys, no_commit): + @pin_managed + def update_and_sign(path, library_dir, target_type, keystore, keys_description, scheme, prompt_for_keys, no_commit, pin_manager): try: if len(target_type): update_and_sign_targets( path, + pin_manager, library_dir, target_type, keystore=keystore, diff --git a/taf/tools/yubikey/__init__.py b/taf/tools/yubikey/__init__.py index ec720cf79..a605a196e 100644 --- a/taf/tools/yubikey/__init__.py +++ b/taf/tools/yubikey/__init__.py @@ -10,18 +10,25 @@ from taf.exceptions import YubikeyError from taf.repository_utils import find_valid_repository from taf.tools.cli import catch_cli_exception +from taf.tools.repo import pin_managed +from taf.yubikey.yubikey import list_connected_yubikeys def check_pin_command(): @click.command(help="Checks if the specified pin is valid") @click.argument("pin") - def check_pin(pin): + @click.option("--serial", help="Serial number of a YubiKey. Has to be provided if more than one YK is inserted") + @catch_cli_exception(handle=YubikeyError) + def check_pin(pin, serial): + # TODO entering a pin like this seems very insecure + # is this still needed? try: - from taf.yubikey import is_valid_pin + from taf.yubikey.yubikey import is_valid_pin - valid, retries = is_valid_pin(pin) + valid, retries = is_valid_pin(pin, serial=serial) inserted = True - except YubikeyError: + except YubikeyError as e: + print(e) valid = False inserted = False retries = None @@ -32,12 +39,13 @@ def check_pin(pin): def export_pub_key_command(): @click.command( - help="Export the inserted Yubikey's public key and save it to the specified location." + help="Export public keys of the inserted YubiKeys" ) @click.option( "--output", help="File to which the exported public key will be written. The result will be written to the console if path is not specified", ) + @catch_cli_exception(handle=YubikeyError) def export_pub_key(output): export_yk_public_pem(output) @@ -46,7 +54,7 @@ def export_pub_key(output): def get_roles_command(): @click.command( - help="Export the inserted Yubikey's public key and save it to the specified location." + help="List roles the inserted YubiKey is allowed to sign." ) @catch_cli_exception(handle=YubikeyError, print_error=True) @click.option( @@ -54,31 +62,43 @@ def get_roles_command(): default=".", help="Authentication repository's location. If not specified, set to the current directory", ) + @catch_cli_exception(handle=YubikeyError) def get_roles(path): path = find_valid_repository(path) roles_with_paths = get_yk_roles(path) - for role, paths in roles_with_paths.items(): - print(f"\n{role}") - for path in paths: - print(f"\n -{path}") + for serial, roles_and_paths in roles_with_paths.items(): + print(f"\nSerial: {serial}") + for role, paths in roles_and_paths.items(): + print(f"\n{role}") + for path in paths: + print(f"\n -{path}") return get_roles def export_certificate_command(): @click.command( - help="Export the inserted Yubikey's public key and save it to the specified location." + help="Export certificates of the inserted YubiKeys" ) @click.option( "--output", help="File to which the exported certificate key will be written. The result will be written to the user's home directory by default", ) + @catch_cli_exception(handle=YubikeyError) def export_certificate(output): export_yk_certificate(output) return export_certificate +def list_key_command(): + @click.command(help="List All Connected Keys and their information") + @catch_cli_exception(handle=YubikeyError) + def list_keys(): + list_connected_yubikeys() + return list_keys + + def setup_signing_key_command(): @click.command( help="""Generate a new key on the yubikey and set the pin. Export the generated certificate @@ -89,8 +109,10 @@ def setup_signing_key_command(): "--certs-dir", help="Path of the directory where the exported certificate will be saved. Set to the user home directory by default", ) - def setup_signing_key(certs_dir): - setup_signing_yubikey(certs_dir, key_size=2048) + @catch_cli_exception(handle=YubikeyError) + @pin_managed + def setup_signing_key(certs_dir, pin_manager): + setup_signing_yubikey(pin_manager, certs_dir, key_size=2048) return setup_signing_key @@ -101,16 +123,19 @@ def setup_test_key_command(): WARNING - this will reset the inserted key.""" ) @click.argument("key-path") - def setup_test_key(key_path): - setup_test_yubikey(key_path) + @catch_cli_exception(handle=YubikeyError) + @pin_managed + def setup_test_key(key_path, pin_manager): + setup_test_yubikey(pin_manager, key_path) return setup_test_key def attach_to_group(group): - group.add_command(check_pin_command(), name="check-pin") - group.add_command(export_pub_key_command(), name="export-pub-key") - group.add_command(get_roles_command(), name="get-roles") - group.add_command(export_certificate_command(), name="export-certificate") - group.add_command(setup_signing_key_command(), name="setup-signing-key") - group.add_command(setup_test_key_command(), name="setup-test-key") + group.add_command(check_pin_command(), name='check-pin') + group.add_command(export_pub_key_command(), name='export-pub-key') + group.add_command(get_roles_command(), name='get-roles') + group.add_command(export_certificate_command(), name='export-certificate') + group.add_command(list_key_command(), name='list-key') + group.add_command(setup_signing_key_command(), name='setup-signing-key') + group.add_command(setup_test_key_command(), name='setup-test-key') diff --git a/taf/tuf/keys.py b/taf/tuf/keys.py index 624201e96..9c2fde411 100644 --- a/taf/tuf/keys.py +++ b/taf/tuf/keys.py @@ -185,15 +185,31 @@ class YkSigner(Signer): _SECRET_PROMPT = "pin" - def __init__(self, public_key: SSlibKey, pin_handler: SecretsHandler): + def __init__( + self, + public_key: SSlibKey, + serial_num: str, + pin_handler: SecretsHandler, + key_name: str, + ): self._public_key = public_key self._pin_handler = pin_handler + self._serial_num = serial_num + self._key_name = key_name @property def public_key(self) -> SSlibKey: return self._public_key + @property + def serial_num(self) -> str: + return self._serial_num + + @property + def key_name(self) -> str: + return self._key_name + @classmethod def import_(cls) -> SSlibKey: """Import rsa public key from Yubikey. @@ -205,20 +221,23 @@ def import_(cls) -> SSlibKey: See e.g. `self.from_priv_key_uri` and other `import_` methods on securesystemslib signers, e.g. `HSMSigner.import_`. """ - # TODO: export pyca/cryptography key to avoid duplicate deserialization - from taf.yubikey import export_piv_pub_key - - pem = export_piv_pub_key() + # if multiple keys are inserted, we need to know from which key should be imported + # TODO + # only used for testing purposes now + from taf.yubikey.yubikey import export_piv_pub_key, get_serial_num + + serials = get_serial_num() + serial = serials[0] + pem = export_piv_pub_key(serial=serial) pub = load_pem_public_key(pem) return _from_crypto(pub) def sign(self, payload: bytes) -> Signature: pin = self._pin_handler(self._SECRET_PROMPT) - # TODO: openlawlibrary/taf#515 - # sig = sign_piv_rsa_pkcs1v15(payload, pin, self.public_key.keyval["public"]) - from taf.yubikey import sign_piv_rsa_pkcs1v15 + from taf.yubikey.yubikey import sign_piv_rsa_pkcs1v15, verify_yk_inserted - sig = sign_piv_rsa_pkcs1v15(payload, pin) + verify_yk_inserted(self.serial_num, self.key_name) + sig = sign_piv_rsa_pkcs1v15(payload, pin, serial=self.serial_num) return Signature(self.public_key.keyid, sig.hex()) @classmethod diff --git a/taf/tuf/repository.py b/taf/tuf/repository.py index e8de21535..f10f8cb1e 100644 --- a/taf/tuf/repository.py +++ b/taf/tuf/repository.py @@ -22,10 +22,11 @@ from securesystemslib.storage import FilesystemBackend +from taf.yubikey.yubikey_manager import YubiKeyStore from tuf.api.metadata import Signed try: - import taf.yubikey as yk + import taf.yubikey.yubikey as yk except ImportError: yk = YubikeyMissingLibrary() # type: ignore @@ -130,6 +131,7 @@ class MetadataRepository(Repository): def __init__(self, path: Union[Path, str], *args, **kwargs) -> None: storage_backend = kwargs.pop("storage", None) + pin_manager = kwargs.pop("pin_manager", None) super().__init__(*args, **kwargs) self.signer_cache: Dict[str, Dict[str, Signer]] = defaultdict(dict) self.path = Path(path) @@ -141,6 +143,19 @@ def __init__(self, path: Union[Path, str], *args, **kwargs) -> None: else: self.storage_backend = FilesystemBackend() self._metadata_to_keep_open: Set[str] = set() + self.pin_manager = pin_manager + self.yubikey_store = YubiKeyStore() + self._keys_name_mappings: Optional[Dict[str, str]] = None + + @property + def keys_name_mappings(self): + try: + if self._keys_name_mappings is None: + self._keys_name_mappings = self.load_key_names() + except TAFError: + # repository does not exist yet, so no metadata files + self._keys_name_mappings = {} + return self._keys_name_mappings @property def metadata_path(self) -> Path: @@ -170,44 +185,9 @@ def snapshot_info(self) -> MetaFile: """ return self._snapshot_info - def calculate_hashes(self, md: Metadata, algorithms: List[str]) -> Dict: - """ - Calculate hashes of the specified signed metadata after serializing - it using the previously initialized serializer. - Hashes are computed for each specified algorithm. - - Arguments: - md: Signed metadata - algorithms: A list of hash algorithms (e.g., 'sha256', 'sha512'). - Return: - A dcitionary mapping algorithms and calculated hashes - """ - hashes = {} - data = md.to_bytes(serializer=self.serializer) - for algo in algorithms: - digest_object = sslib_hash.digest(algo) - digest_object.update(data) - - hashes[algo] = digest_object.hexdigest() - return hashes - - def calculate_length(self, md: Metadata) -> int: - """ - Calculate length of the specified signed metadata after serializing - it using the previously initialized serializer. - - Arguments: - md: Signed metadata - Return: - Langth of the signed metadata - """ - data = md.to_bytes(serializer=self.serializer) - return len(data) - - def add_signers_to_cache(self, roles_signers: Dict): - for role, signers in roles_signers.items(): - if self._role_obj(role): - self._load_role_signers(role, signers) + def add_key_name(self, key_name, key_id, overwrite=False): + if overwrite or not key_id in self.keys_name_mappings: + self._keys_name_mappings[key_id] = key_name def all_target_files(self) -> Set: """ @@ -284,6 +264,11 @@ def _filter_if_can_be_added(roles): return added_keys, already_added_keys, invalid_keys + def add_signers_to_cache(self, roles_signers: Dict): + for role, signers in roles_signers.items(): + if self._role_obj(role): + self._load_role_signers(role, signers) + def add_target_files_to_role(self, added_data: Dict[str, Dict]) -> None: """Add target files to top-level targets metadata. @@ -361,6 +346,40 @@ def open(self, role: str) -> Metadata: except StorageError: raise TAFError(f"Metadata file {path} does not exist") + def calculate_hashes(self, md: Metadata, algorithms: List[str]) -> Dict: + """ + Calculate hashes of the specified signed metadata after serializing + it using the previously initialized serializer. + Hashes are computed for each specified algorithm. + + Arguments: + md: Signed metadata + algorithms: A list of hash algorithms (e.g., 'sha256', 'sha512'). + Return: + A dcitionary mapping algorithms and calculated hashes + """ + hashes = {} + data = md.to_bytes(serializer=self.serializer) + for algo in algorithms: + digest_object = sslib_hash.digest(algo) + digest_object.update(data) + + hashes[algo] = digest_object.hexdigest() + return hashes + + def calculate_length(self, md: Metadata) -> int: + """ + Calculate length of the specified signed metadata after serializing + it using the previously initialized serializer. + + Arguments: + md: Signed metadata + Return: + Langth of the signed metadata + """ + data = md.to_bytes(serializer=self.serializer) + return len(data) + def check_if_keys_loaded(self, role_name: str) -> bool: """ Check if at least a threshold of signers of the specified role @@ -520,9 +539,8 @@ def create( of public keys that should be registered as the corresponding role's keys, but the private keys are not available. E.g. keys exporeted from YubiKeys of maintainers who are not present at the time of the repository's creation + key_name_mappings: A dictionary whose keys are key ids and values are custom names of those keys """ - # TODO add verification keys - # support yubikeys self.metadata_path.mkdir(parents=True) self.signer_cache = defaultdict(dict) @@ -533,19 +551,7 @@ def create( sn = Snapshot() sn.meta["root.json"] = MetaFile(1) - public_keys = { - role_name: { - _get_legacy_keyid(signer.public_key): signer.public_key - for signer in role_signers - } - for role_name, role_signers in signers.items() - } - if additional_verification_keys: - for role_name, roles_public_keys in additional_verification_keys.items(): - for public_key in roles_public_keys: - key_id = _get_legacy_keyid(public_key) - if key_id not in public_keys[role_name]: - public_keys[role_name][key_id] = public_key + public_keys = self._process_keys(signers, additional_verification_keys) for role in RolesIterator(roles_keys_data.roles, include_delegations=False): if signers.get(role.name) is None: @@ -554,6 +560,11 @@ def create( key_id = _get_legacy_keyid(signer.public_key) self.signer_cache[role.name][key_id] = signer for public_key in public_keys[role.name].values(): + key_id = _get_legacy_keyid(public_key) + if key_id in self.keys_name_mappings: + public_key.unrecognized_fields["name"] = self.keys_name_mappings[ + key_id + ] root.add_key(public_key, role.name) root.roles[role.name].threshold = role.threshold @@ -600,8 +611,26 @@ def create( signed.version = 0 # `close` will bump to initial valid verison 1 self.close(name, Metadata(signed)) + def _process_keys(self, signers, additional_verification_keys): + public_keys = {} + for role_name, role_signers in signers.items(): + public_keys[role_name] = {} + for signer in role_signers: + key_id = _get_legacy_keyid(signer.public_key) + public_keys[role_name][key_id] = signer.public_key + + if additional_verification_keys: + for role_name, keys in additional_verification_keys.items(): + for public_key in keys: + key_id = _get_legacy_keyid(public_key) + public_keys[role_name][key_id] = public_key + return public_keys + def create_delegated_roles( - self, roles_data: List[TargetsRole], signers: Dict[str, List[CryptoSigner]] + self, + roles_data: List[TargetsRole], + signers: Dict[str, List[CryptoSigner]], + additional_verification_keys: Optional[dict] = None, ) -> Tuple[List, List]: """ Create a new delegated roles, signes them using the provided signers and @@ -628,15 +657,25 @@ def create_delegated_roles( parent = role_data.parent.name roles_parents_dict[parent].append(role_data) + public_keys = self._process_keys(signers, additional_verification_keys) + for parent, parents_roles_data in roles_parents_dict.items(): with self.edit(parent) as parent_obj: keys_data = {} for role_data in parents_roles_data: + for public_key in public_keys[role_data.name].values(): + key_id = _get_legacy_keyid(public_key) + keys_data[key_id] = public_key + if key_id in self.keys_name_mappings: + public_key.unrecognized_fields[ + "name" + ] = self.keys_name_mappings[key_id] + for signer in signers[role_data.name]: public_key = signer.public_key key_id = _get_legacy_keyid(public_key) - keys_data[key_id] = public_key self.signer_cache[role_data.name][key_id] = signer + delegated_role = DelegatedRole( name=role_data.name, threshold=role_data.threshold, @@ -785,6 +824,25 @@ def get_delegations_of_role(self, role_name: str) -> Dict: return signed_obj.delegations.roles return {} + def get_key_names_of_role(self, role_name: str) -> List: + keys_name_mapping = self.keys_name_mappings + key_names = [] + num_of_keys_without_name = 0 + threshold = self.get_role_threshold(role_name) + if keys_name_mapping: + key_ids = self.get_keyids_of_role(role_name) + for key_id in key_ids: + if key_id in keys_name_mapping: + key_names.append(keys_name_mapping[key_id]) + else: + num_of_keys_without_name += 1 + else: + num_of_keys_without_name = threshold + + for num in range(threshold - num_of_keys_without_name, threshold): + key_names.append(num + 1) + return key_names + def get_keyids_of_role(self, role_name: str) -> List: """ Return all key ids of the specified role @@ -1125,9 +1183,57 @@ def get_key_length_and_scheme_from_metadata( pub_key = serialization.load_pem_public_key( pub_key_pem.encode(), backend=default_backend() ) - return pub_key, scheme + return pub_key, pub_key_pem, scheme except Exception: - return None, None + return None, None, None + + def get_key_names_from_metadata(self, parent_role: str) -> Optional[dict]: + """ + Return length and signing scheme of the specified key id. + This data is specified in metadata files (root or a target role that has delegations) + """ + try: + metadata = json.loads( + Path( + self.path, METADATA_DIRECTORY_NAME, f"{parent_role}.json" + ).read_text() + ) + metadata = metadata["signed"] + if "delegations" in metadata: + metadata = metadata["delegations"] + + keys = metadata["keys"] + names = { + key_id: key_data["name"] + for key_id, key_data in keys.items() + if "name" in key_data + } + return names + except Exception: + return None + + def get_public_key_of_keyid(self, keyid: str): + def _find_keyid(role_name, keyid): + _, pub_key_pem, scheme = self.get_key_length_and_scheme_from_metadata( + role_name, keyid + ) + if pub_key_pem is not None: + return pub_key_pem, scheme + + for delegation in self.get_delegations_of_role(role_name): + pub_key_pem, scheme = self._find_keyid(delegation, keyid) + if pub_key_pem is not None: + return pub_key_pem, scheme + + _, pub_key_pem, scheme = self.get_key_length_and_scheme_from_metadata( + "root", keyid + ) + if pub_key_pem is not None: + return pub_key_pem, scheme + + targets_obj = self.signed_obj("targets") + if targets_obj.delegations: + return _find_keyid("targets", keyid) def generate_roles_description(self) -> Dict: """ @@ -1147,7 +1253,7 @@ def _get_delegations(role_name): "paths": delegated_role.paths, "terminating": delegated_role.terminating, } - pub_key, scheme = self.get_key_length_and_scheme_from_metadata( + pub_key, _, scheme = self.get_key_length_and_scheme_from_metadata( role_name, delegated_role.keyids[0] ) @@ -1166,7 +1272,7 @@ def _get_delegations(role_name): "threshold": role_obj.threshold, "number": len(role_obj.keyids), } - pub_key, scheme = self.get_key_length_and_scheme_from_metadata( + pub_key, _, scheme = self.get_key_length_and_scheme_from_metadata( "root", role_obj.keyids[0] ) roles_description[role_name]["scheme"] = scheme @@ -1356,6 +1462,31 @@ def modify_targets( ) return targets_role + def load_key_names(self): + def _get_keys_of_delegations(role_name): + keys = {} + role_keys = self.get_key_names_from_metadata(role_name) + if role_keys is not None: + keys.update(role_keys) + + for delegation in self.get_delegations_of_role(role_name): + delegated_signed = self.signed_obj(delegation) + if delegated_signed.delegations: + inner_roles_keys = _get_keys_of_delegations(delegation) + if inner_roles_keys: + keys.update(inner_roles_keys) + return keys + + root_metadata = self.signed_obj("root") + name_mapping = {} + keys = root_metadata.keys + for key_id, key_obj in keys.items(): + name_data = key_obj.unrecognized_fields + if name_data is not None and "name" in name_data: + name_mapping[key_id] = name_data["name"] + name_mapping.update(_get_keys_of_delegations("targets")) + return name_mapping + def _modify_targets_role( self, added_target_files: List[TargetFile], diff --git a/taf/yubikey/__init__.py b/taf/yubikey/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/taf/yubikey.py b/taf/yubikey/yubikey.py similarity index 51% rename from taf/yubikey.py rename to taf/yubikey/yubikey.py index 36825e0f1..4e4781b96 100644 --- a/taf/yubikey.py +++ b/taf/yubikey/yubikey.py @@ -1,10 +1,9 @@ import datetime from contextlib import contextmanager from functools import wraps -from collections import defaultdict from getpass import getpass from pathlib import Path -from typing import Callable, Dict, Optional +from typing import Callable, Optional import click from cryptography import x509 @@ -15,6 +14,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa, padding from taf.tuf.keys import get_sslib_key_from_value +from taf.yubikey.yubikey_manager import PinManager from ykman.device import list_all_devices from yubikit.core.smartcard import SmartCardConnection from ykman.piv import ( @@ -33,6 +33,7 @@ from taf.constants import DEFAULT_RSA_SIGNATURE_SCHEME from taf.exceptions import InvalidPINError, YubikeyError from taf.utils import get_pin_for +from taf.log import taf_logger from securesystemslib.signer._key import SSlibKey @@ -40,39 +41,6 @@ DEFAULT_PUK = "12345678" EXPIRATION_INTERVAL = 36500 -_yks_data_dict: Dict = defaultdict(dict) - - -def add_key_id_mapping(serial_num: str, keyid: str) -> None: - if "ids" not in _yks_data_dict: - _yks_data_dict["ids"] = defaultdict(dict) - if keyid not in _yks_data_dict["ids"]: - _yks_data_dict["ids"][keyid] = serial_num - - -def add_key_pin(serial_num: str, pin: str) -> None: - _yks_data_dict[serial_num]["pin"] = pin - - -def add_key_public_key(serial_num: str, public_key: Dict) -> None: - _yks_data_dict[serial_num]["public_key"] = public_key - - -def get_key_pin(serial_num: int) -> Optional[str]: - if serial_num in _yks_data_dict: - return _yks_data_dict.get(serial_num, {}).get("pin") - return None - - -def get_key_serial_by_id(keyid: str) -> Optional[str]: - return _yks_data_dict.get("ids", {}).get(keyid) - - -def get_key_public_key(serial_num: str) -> Optional[Dict]: - if serial_num in _yks_data_dict: - return _yks_data_dict.get(serial_num, {}).get("public_key") - return None - def raise_yubikey_err(msg: Optional[str] = None) -> Callable: """Decorator used to catch all errors raised by yubikey-manager and raise @@ -110,15 +78,32 @@ def _yk_piv_ctrl(serial=None): Raises: - YubikeyError """ - # If pub_key_pem is given, iterate all devices, read x509 certs and try to match - # public keys. - for dev, info in list_all_devices(): - if serial is None or info.serial == serial: - with dev.open_connection(SmartCardConnection) as connection: - session = PivSession(connection) - yield session, info.serial + sessions = [] + devices_info = [] + try: + for dev, info in list_all_devices(): + if serial is None or info.serial == serial: + connection = dev.open_connection(SmartCardConnection) + try: + session = PivSession(connection) + sessions.append((session, info.serial)) + devices_info.append( + (connection, session) + ) # Store to manage cleanup + if serial is not None: + break + except Exception as e: + connection.close() # Ensure we close connection on error + raise e + if serial is not None: + session, serial = sessions[0] + yield session, serial else: - pass + yield sessions + finally: + # Cleanup: ensure all connections are closed properly + for connection, _ in devices_info: + connection.close() def is_inserted(): @@ -137,7 +122,7 @@ def is_inserted(): @raise_yubikey_err() -def is_valid_pin(pin): +def is_valid_pin(pin, serial): """Checks if given pin is valid. Args: @@ -149,7 +134,14 @@ def is_valid_pin(pin): Raises: - YubikeyError """ - with _yk_piv_ctrl() as (ctrl, _): + if serial is None: + serials = get_serial_num() + if len(serials) != 1: + raise YubikeyError( + "Please insert exactly one YubiKey or specify a serial number of the YubiKey whose pin is to be checked" + ) + serial = serials[0] + with _yk_piv_ctrl(serial=serial) as (ctrl, _): try: ctrl.verify_pin(pin) return True, None # ctrl.get_pin_tries() fails if PIN is valid @@ -159,7 +151,7 @@ def is_valid_pin(pin): @raise_yubikey_err("Cannot get serial number.") def get_serial_num(): - """Get Yubikey serial number. + """Get serial numbers of all isnerted YubiKeys Args: - pub_key_pem(str): Match Yubikey's public key (PEM) if multiple keys @@ -171,12 +163,16 @@ def get_serial_num(): Raises: - YubikeyError """ - with _yk_piv_ctrl() as (_, serial): - return serial + serials = [] + with _yk_piv_ctrl() as sessions: + for _, serial in sessions: + # Process each session + serials.append(serial) + return serials @raise_yubikey_err("Cannot export x509 certificate.") -def export_piv_x509(cert_format=serialization.Encoding.PEM): +def export_piv_x509(cert_format=serialization.Encoding.PEM, serial=None): """Exports YubiKey's piv slot x509. Args: @@ -190,13 +186,13 @@ def export_piv_x509(cert_format=serialization.Encoding.PEM): Raises: - YubikeyError """ - with _yk_piv_ctrl() as (ctrl, _): + with _yk_piv_ctrl(serial=serial) as (ctrl, _): x509 = ctrl.get_certificate(SLOT.SIGNATURE) return x509.public_bytes(encoding=cert_format) @raise_yubikey_err("Cannot export public key.") -def export_piv_pub_key(pub_key_format=serialization.Encoding.PEM): +def export_piv_pub_key(pub_key_format=serialization.Encoding.PEM, serial=None): """Exports YubiKey's piv slot public key. Args: @@ -210,7 +206,7 @@ def export_piv_pub_key(pub_key_format=serialization.Encoding.PEM): Raises: - YubikeyError """ - with _yk_piv_ctrl() as (ctrl, _): + with _yk_piv_ctrl(serial=serial) as (ctrl, _): try: x509_cert = ctrl.get_certificate(SLOT.SIGNATURE) public_key = x509_cert.public_key() @@ -223,7 +219,7 @@ def export_piv_pub_key(pub_key_format=serialization.Encoding.PEM): @raise_yubikey_err("Cannot export yk certificate.") -def export_yk_certificate(certs_dir, key: SSlibKey): +def export_yk_certificate(certs_dir, key: SSlibKey, serial: str): if certs_dir is None: certs_dir = Path.home() else: @@ -232,11 +228,28 @@ def export_yk_certificate(certs_dir, key: SSlibKey): cert_path = certs_dir / f"{key.keyid}.cert" print(f"Exporting certificate to {cert_path}") with open(cert_path, "wb") as f: - f.write(export_piv_x509()) + f.write(export_piv_x509(serial=serial)) + + +def get_and_validate_pin(key_name, pin_confirm=True, pin_repeat=True, serial=None): + valid_pin = False + while not valid_pin: + pin = get_pin_for(key_name, pin_confirm, pin_repeat) + valid_pin, retries = is_valid_pin(pin, serial) + if not valid_pin and not retries: + raise InvalidPINError("No retries left. YubiKey locked.") + if not valid_pin: + if not click.confirm( + f"Incorrect PIN. Do you want to try again? {retries} retires left." + ): + raise InvalidPINError("PIN input cancelled") + return pin @raise_yubikey_err("Cannot get public key in TUF format.") -def get_piv_public_key_tuf(scheme=DEFAULT_RSA_SIGNATURE_SCHEME) -> SSlibKey: +def get_piv_public_key_tuf( + scheme=DEFAULT_RSA_SIGNATURE_SCHEME, serial=None +) -> SSlibKey: """Return public key from a Yubikey in TUF's RSAKEY_SCHEMA format. Args: @@ -252,12 +265,178 @@ def get_piv_public_key_tuf(scheme=DEFAULT_RSA_SIGNATURE_SCHEME) -> SSlibKey: Raises: - YubikeyError """ - pub_key_pem = export_piv_pub_key().decode("utf-8") + pub_key_pem = export_piv_pub_key(serial=serial).decode("utf-8") return get_sslib_key_from_value(pub_key_pem, scheme) +def list_connected_yubikeys(): + """Lists all connected YubiKeys with their serial numbers and details.""" + yubikeys = list_all_devices() + if not yubikeys: + print("No YubiKeys connected.") + else: + for index, (_, info) in enumerate(yubikeys, start=1): + print(f"YubiKey {index}:") + print(f" Serial Number: {info.serial}") + print(f" Version: {info.version}") + print(f" Form Factor: {info.form_factor}") + + +def _read_and_check_single_yubikey( + role, + key_name, + taf_repo, + pin_manager, + registering_new_key, + creating_new_key, + pin_confirm, + pin_repeat, + prompt_message, + retrying, + yubikeys_to_skip, +): + + if retrying: + if prompt_message is None: + prompt_message = f"Please insert {key_name} YubiKey and press ENTER" + getpass(prompt_message) + + if not yubikeys_to_skip: + yubikeys_to_skip = [] + + # make sure that YubiKey is inserted + try: + serials = get_serial_num() + + if taf_repo is None: + # if setting up a YubiKey outside of the creation of a new repository or addition of new roles + if len(serials) > 1: + print("\nPlease insert only one YubiKey\n") + return + else: + not_loaded = [ + serial + for serial in serials + if not taf_repo.yubikey_store.is_loaded_for_role(serial, role) + and serial not in yubikeys_to_skip + ] + + if len(not_loaded) != 1: + print("\nPlease insert only one not previously inserted YubiKey\n") + return None + + if not len(not_loaded): + return None + + # no need to try loading keys that we know were previously loaded + serials = not_loaded + + except Exception: + taf_logger.log("NOTICE", "No YubiKeys inserted") + return None + + serial_num = serials[0] + # check if this key is already loaded as the provided role's key (we can use the same key + # to sign different metadata) + # read the public key, unless a new key needs to be generated on the yubikey + public_key = ( + get_piv_public_key_tuf(serial=serial_num) if not creating_new_key else None + ) + # check if this yubikey is can be used for signing the provided role's metadata + # if the key was already registered as that role's key + if not registering_new_key and role is not None and taf_repo is not None: + if not taf_repo.is_valid_metadata_yubikey(role, public_key): + return None + + if pin_manager.get_pin(serial_num) is None: + if creating_new_key: + pin = get_pin_for(key_name, pin_confirm, pin_repeat) + else: + pin = get_and_validate_pin(key_name, pin_confirm, pin_repeat, serial_num) + + pin_manager.add_pin(serial_num, pin) + + if taf_repo is not None: + # when reusing the same yubikey, public key will already be in the public keys dictionary + # but the key name still needs to be added to the key id mapping dictionary + taf_repo.yubikey_store.add_key_data(key_name, serial_num, public_key, role) + + return public_key, serial_num, key_name + + +def _read_and_check_yubikeys( + role, + taf_repo, + pin_manager, + pin_confirm, + pin_repeat, + prompt_message, + key_names, + retrying, + hide_already_loaded_message, + hide_threshold_message, +): + if retrying: + if prompt_message is None: + if not hide_threshold_message: + threshold = taf_repo.get_role_threshold(role) + prompt_message = f"Please insert {role} ({', '.join(key_names)}) YubiKey(s) (threshold {threshold}) and press ENTER" + else: + prompt_message = f"Please insert {role} ({', '.join(key_names)}) YubiKey(s) and press ENTER" + getpass(prompt_message) + + # make sure that YubiKey is inserted + try: + serials = get_serial_num() + except Exception: + taf_logger.log("NOTICE", "No YubiKeys inserted") + return None + + if not len(serials): + return None + + # check if this key is already loaded as the provided role's key (we can use the same key + # to sign different metadata) + yubikeys = [] + invalid_keys = [] + all_loaded = True + for index, serial_num in enumerate(serials): + if not taf_repo.yubikey_store.is_loaded_for_role(serial_num, role): + all_loaded = False + # read the public key, unless a new key needs to be generated on the yubikey + public_key = get_piv_public_key_tuf(serial=serial_num) + # check if this yubikey is can be used for signing the provided role's metadata + # if the key was already registered as that role's key + if role is not None and taf_repo is not None: + if not taf_repo.is_valid_metadata_yubikey(role, public_key): + invalid_keys.append(serial_num) + # print(f"The inserted YubiKey is not a valid {role} key") + continue + + if taf_repo.keys_name_mappings: + key_name = taf_repo.keys_name_mappings.get(public_key.keyid) + else: + key_name = key_names[index] + + if pin_manager.get_pin(serial_num) is None: + pin = get_and_validate_pin( + key_name, pin_confirm, pin_repeat, serial_num + ) + pin_manager.add_pin(serial_num, pin) + + # when reusing the same yubikey, public key will already be in the public keys dictionary + # but the key name still needs to be added to the key id mapping dictionary + taf_repo.yubikey_store.add_key_data(key_name, serial_num, public_key, role) + yubikeys.append((public_key, serial_num, key_name)) + + if not hide_already_loaded_message and all_loaded: + print("All inserted YubiKeys already loaded") + + return yubikeys + + @raise_yubikey_err("Cannot sign data.") -def sign_piv_rsa_pkcs1v15(data, pin): +def sign_piv_rsa_pkcs1v15(data, pin, serial=None): """Sign data with key from YubiKey's piv slot. Args: @@ -272,7 +451,7 @@ def sign_piv_rsa_pkcs1v15(data, pin): Raises: - YubikeyError """ - with _yk_piv_ctrl() as (ctrl, _): + with _yk_piv_ctrl(serial=serial) as (ctrl, _): ctrl.verify_pin(pin) return ctrl.sign( SLOT.SIGNATURE, KEY_TYPE.RSA2048, data, hashes.SHA256(), padding.PKCS1v15() @@ -282,6 +461,7 @@ def sign_piv_rsa_pkcs1v15(data, pin): @raise_yubikey_err("Cannot setup Yubikey.") def setup( pin, + serial, cert_cn, cert_exp_days=365, pin_retries=10, @@ -313,7 +493,7 @@ def setup( - YubikeyError """ - with _yk_piv_ctrl() as (ctrl, _): + with _yk_piv_ctrl(serial=serial) as (ctrl, _): # Factory reset and set PINs ctrl.reset() @@ -370,135 +550,94 @@ def setup( def setup_new_yubikey( - serial_num, scheme=DEFAULT_RSA_SIGNATURE_SCHEME, key_size=2048 + pin_manager: PinManager, + serial: str, + scheme: Optional[str] = DEFAULT_RSA_SIGNATURE_SCHEME, + key_size: Optional[int] = 2048, ) -> SSlibKey: - pin = get_key_pin(serial_num) + pin = pin_manager.get_pin(serial) cert_cn = input("Enter key holder's name: ") print("Generating key, please wait...") pub_key_pem = setup( - pin, cert_cn, cert_exp_days=EXPIRATION_INTERVAL, key_size=key_size + pin, serial, cert_cn, cert_exp_days=EXPIRATION_INTERVAL, key_size=key_size ).decode("utf-8") scheme = DEFAULT_RSA_SIGNATURE_SCHEME key = get_sslib_key_from_value(pub_key_pem, scheme) return key -def get_and_validate_pin(key_name, pin_confirm=True, pin_repeat=True): - valid_pin = False - while not valid_pin: - pin = get_pin_for(key_name, pin_confirm, pin_repeat) - valid_pin, retries = is_valid_pin(pin) - if not valid_pin and not retries: - raise InvalidPINError("No retries left. YubiKey locked.") - if not valid_pin: - if not click.confirm( - f"Incorrect PIN. Do you want to try again? {retries} retires left." - ): - raise InvalidPINError("PIN input cancelled") - return pin +def verify_yk_inserted(serial_num, key_name): + def _check_if_yk_inserted(): + try: + serials = get_serial_num() + except Exception: + return False + + return serial_num in serials + + while not _check_if_yk_inserted(): + prompt_message = f"Please insert {key_name} YubiKey and press ENTER" + getpass(prompt_message) def yubikey_prompt( - key_name, + key_names, + pin_manager, role=None, taf_repo=None, registering_new_key=False, creating_new_key=False, - loaded_yubikeys=None, pin_confirm=True, pin_repeat=True, prompt_message=None, retry_on_failure=True, hide_already_loaded_message=False, + hide_threshold_message=False, + yubikeys_to_skip=None, ): - def _read_and_check_yubikey( - key_name, - role, - taf_repo, - registering_new_key, - creating_new_key, - loaded_yubikeys, - pin_confirm, - pin_repeat, - prompt_message, - retrying, - ): - - if retrying: - if prompt_message is None: - prompt_message = f"Please insert {key_name} YubiKey and press ENTER" - getpass(prompt_message) - # make sure that YubiKey is inserted - try: - serial_num = get_serial_num() - except Exception: - print("YubiKey not inserted") - return False, None, None - - # check if this key is already loaded as the provided role's key (we can use the same key - # to sign different metadata) - if ( - loaded_yubikeys is not None - and serial_num in loaded_yubikeys - and role in loaded_yubikeys[serial_num] - ): - if not hide_already_loaded_message: - print("Key already loaded") - return False, None, None - - # read the public key, unless a new key needs to be generated on the yubikey - public_key = get_piv_public_key_tuf() if not creating_new_key else None - # check if this yubikey is can be used for signing the provided role's metadata - # if the key was already registered as that role's key - if not registering_new_key and role is not None and taf_repo is not None: - if not taf_repo.is_valid_metadata_yubikey(role, public_key): - print(f"The inserted YubiKey is not a valid {role} key") - return False, None, None - - if get_key_pin(serial_num) is None: - if creating_new_key: - pin = get_pin_for(key_name, pin_confirm, pin_repeat) - else: - pin = get_and_validate_pin(key_name, pin_confirm, pin_repeat) - add_key_pin(serial_num, pin) - - if get_key_public_key(serial_num) is None and public_key is not None: - add_key_public_key(serial_num, public_key) - - # when reusing the same yubikey, public key will already be in the public keys dictionary - # but the key name still needs to be added to the key id mapping dictionary - add_key_id_mapping(serial_num, key_name) - - if role is not None: - if loaded_yubikeys is None: - loaded_yubikeys = {serial_num: [role]} - else: - loaded_yubikeys.setdefault(serial_num, []).append(role) - - return True, public_key, serial_num retry_counter = 0 + yubikeys = None while True: - success, key, serial_num = _read_and_check_yubikey( - key_name, - role, - taf_repo, - registering_new_key, - creating_new_key, - loaded_yubikeys, - pin_confirm, - pin_repeat, - prompt_message, - retrying=retry_counter > 0, - ) - if not success and not retry_on_failure: - return None, None - if success: - return key, serial_num + retrying = retry_counter > 0 + if registering_new_key or creating_new_key: + yubikey = _read_and_check_single_yubikey( + role, + key_names[0], + taf_repo, + pin_manager, + registering_new_key, + creating_new_key, + pin_confirm, + pin_repeat, + prompt_message, + retrying, + yubikeys_to_skip, + ) + if yubikey: + yubikeys = [yubikey] + else: + yubikeys = _read_and_check_yubikeys( + role, + taf_repo, + pin_manager, + pin_confirm, + pin_repeat, + prompt_message, + key_names, + retrying, + hide_already_loaded_message, + hide_threshold_message, + ) + + if not yubikeys and not retry_on_failure: + return [(None, None, None)] + if yubikeys: + return yubikeys retry_counter += 1 -def yk_secrets_handler(prompt, serial_num): +def yk_secrets_handler(prompt, pin_manager, serial_num): if prompt == "pin": - return get_key_pin(serial_num) + return pin_manager.get_pin(serial_num) raise YubikeyError(f"Invalid prompt {prompt}") diff --git a/taf/yubikey/yubikey_manager.py b/taf/yubikey/yubikey_manager.py new file mode 100644 index 000000000..c699e8531 --- /dev/null +++ b/taf/yubikey/yubikey_manager.py @@ -0,0 +1,101 @@ +from collections import defaultdict +import contextlib +from typing import Dict, List, Optional, Tuple +from taf.tuf.keys import SSlibKey + + +class YubiKeyStore: + def __init__(self): + # Initializes the dictionary to store YubiKey data + self._yubikeys_data = defaultdict(dict) + + @property + def yubikeys_data(self) -> Dict: + return self._yubikeys_data + + def is_loaded(self, serial_number) -> bool: + return any( + data["serial"] == serial_number for data in self._yubikeys_data.values() + ) + + def is_loaded_for_role(self, serial_number: str, role_name: str) -> bool: + for data in self._yubikeys_data.values(): + if data["serial"] == serial_number and role_name in data["roles"]: + return True + return False + + def is_key_name_loaded(self, key_name: str) -> bool: + """Check if the key name is already loaded.""" + return key_name in self._yubikeys_data + + def add_key_data( + self, + key_name: str, + serial_num: str, + public_key: SSlibKey, + role_name: str, + ) -> None: + """Add data associated with a YubiKey.""" + if role_name in self._yubikeys_data: + key_data = self._yubikeys_data[key_name] + else: + key_data = {"serial": serial_num, "public_key": public_key, "roles": []} + key_data["roles"].append(role_name) + self._yubikeys_data[key_name] = key_data + + def get_key_data(self, key_name: str) -> Optional[Tuple[str, SSlibKey]]: + """Retrieve data associated with a given YubiKey name.""" + if not self.is_key_name_loaded(key_name): + return None + key_data = self._yubikeys_data.get(key_name) + return key_data["public_key"], key_data["serial"] + + def get_roles_of_key(self, serial_number: str) -> List[str]: + roles = [] + for data in self._yubikeys_data.values(): + if data["serial"] == serial_number: + roles.extend(data["roles"]) + return roles + + def remove_key_data(self, key_name: str) -> bool: + """Remove data associated with a given YubiKey name if it exists.""" + if self.is_key_name_loaded(key_name): + del self._yubikeys_data[key_name] + return True + return False + + +class PinManager: + def __init__(self, auto_continue=False): + self._pins = {} + # Automatically continue without prompts, such as loading more keys + self.auto_continue = auto_continue + + def add_pin(self, serial_number, pin): + self._pins[serial_number] = pin + + def clear_pins(self): + for key in list(self._pins.keys()): + self._pins[key] = None + self._pins.clear() + + def get_pin(self, serial_number): + return self._pins.get(serial_number) + + +@contextlib.contextmanager +def manage_pins(): + pin_manager = PinManager() + try: + yield pin_manager + finally: + pin_manager.clear_pins() + + +def pin_managed(func): + def wrapper(*args, **kwargs): + with manage_pins() as pin_manager: + kwargs["pin_manager"] = pin_manager + return func(*args, **kwargs) + + return wrapper