Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,39 @@ Pre-requisites:

To start the system, run the following command: `docker compose up -d`. To rebuild images after your local changes, just run `docker compose up -d --build`.

# Fetching GPG passphrases from Bitwarden

By default the sign node asks for each PGP key passphrase interactively at
startup (or uses `dev_pgp_key_password` in development mode). Instead, it can
fetch passphrases from a Bitwarden vault using
[py-bitwarden-wrapper](https://github.com/AlmaLinux/py-bitwarden-wrapper).

Requirements:
* The Bitwarden CLI (`bw`) must be installed and on `PATH`.
* For each keyid listed in `pgp_keys`, create a Bitwarden login item whose
**name equals the keyid** and whose **password field** holds the passphrase.

Enable it in the node config (`sign_node.yml`):

```yaml
bitwarden_enabled: yes
bitwarden_username: signer@example.com
# Provide the master password via a file (preferred) ...
bitwarden_password_file: /run/secrets/bw_master
# ... or inline (less safe):
# bitwarden_password: "..."
# Optional: restrict the lookup to a single collection (real UUID only;
# omit to search the whole vault).
# bitwarden_collection_id: <uuid>
```

When `bitwarden_enabled` is true, fetched passphrases take precedence over the
development password and interactive prompts. Startup fails fast if any keyid
is missing from the vault or its passphrase does not unlock the GPG key.

`bitwarden-wrapper` is not published on PyPI — it is installed directly from
GitHub via `requirements.txt`.

# Reporting issues

All issues should be reported to the [Build System project](https://github.com/AlmaLinux/build-system).
16 changes: 15 additions & 1 deletion almalinux_sign_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from sign_node.config import SignNodeConfig
from sign_node.errors import ConfigurationError
from sign_node.signer import Signer
from sign_node.utils.bitwarden import fetch_passphrases
from sign_node.utils.config import locate_config_file
from sign_node.utils.file_utils import clean_dir, safe_mkdir
from sign_node.utils.pgp_utils import PGPPasswordDB, init_gpg
Expand Down Expand Up @@ -77,12 +78,25 @@ def main():

init_sentry(config)
gpg = init_gpg()
preloaded_passwords = None
if config.bitwarden_enabled:
try:
preloaded_passwords = fetch_passphrases(
keyids=config.pgp_keys,
username=config.bitwarden_username,
password=config.bitwarden_password,
password_file=config.bitwarden_password_file,
collection_id=config.bitwarden_collection_id,
)
except ConfigurationError as e:
args_parser.error(str(e))
password_db = PGPPasswordDB(
gpg,
key_ids_from_config=config.pgp_keys.copy(),
is_community_sign_node=config.is_community_sign_node,
development_mode=config.development_mode,
development_password=config.dev_pgp_key_password
development_password=config.dev_pgp_key_password,
preloaded_passwords=preloaded_passwords,
)
try:
password_db.ask_for_passwords()
Expand Down
13 changes: 13 additions & 0 deletions node-config/sign_node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,16 @@
# the following line in case your development setup needs it.
# development_mode: yes
is_community_sign_node: true

# Fetch GPG key passphrases from a Bitwarden vault instead of prompting
# interactively (or using dev_pgp_key_password). For each keyid in
# 'pgp_keys', create a Bitwarden login item whose name equals the keyid and
# whose password field holds the passphrase. Requires the 'bw' CLI on PATH.
# bitwarden_enabled: yes
# bitwarden_username: signer@example.com
# Provide the master password via a file (preferred) ...
# bitwarden_password_file: /run/secrets/bw_master
# ... or inline (less safe):
# bitwarden_password: "..."
# Optional: restrict the lookup to a single collection (real UUID only).
# bitwarden_collection_id: <uuid>
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ websocket-client==1.8.0
cryptography==43.0.3
pgpy==0.6.0
git+https://github.com/AlmaLinux/immudb-wrapper.git@0.1.8#egg=immudb_wrapper
git+https://github.com/AlmaLinux/py-bitwarden-wrapper.git@0.1.17#egg=bitwarden_wrapper
10 changes: 10 additions & 0 deletions sign_node/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ def __init__(self, config_file=None, **cmd_args):
'immudb_public_key_file': None,
'files_sign_cert_path': '/etc/pki/ima/ima-sign.key',
'locks_dir_path': '/tmp/gpg_locks',
'bitwarden_enabled': False,
'bitwarden_username': None,
'bitwarden_password': None,
'bitwarden_password_file': None,
'bitwarden_collection_id': None,
}
schema = {
"development_mode": {"type": "boolean", "default": False},
Expand Down Expand Up @@ -108,6 +113,11 @@ def __init__(self, config_file=None, **cmd_args):
'coerce': normalize_path,
},
'locks_dir_path': {'type': 'string', 'required': True},
'bitwarden_enabled': {'type': 'boolean', 'default': False},
'bitwarden_username': {'type': 'string', 'nullable': True},
'bitwarden_password': {'type': 'string', 'nullable': True},
'bitwarden_password_file': {'type': 'string', 'nullable': True},
'bitwarden_collection_id': {'type': 'string', 'nullable': True},
}
super(SignNodeConfig, self).__init__(
default_config, config_file, schema, **cmd_args
Expand Down
70 changes: 70 additions & 0 deletions sign_node/utils/bitwarden.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# -*- mode:python; coding:utf-8; -*-

"""Fetch GPG key passphrases from a Bitwarden vault.

Each GPG keyid must correspond to a Bitwarden item whose *name* equals the
keyid and whose *password* field is the passphrase.
"""

import logging
from typing import Dict, List, Optional

from ..errors import ConfigurationError

__all__ = ["fetch_passphrases"]

logger = logging.getLogger(__name__)


def fetch_passphrases(
keyids: List[str],
username: Optional[str] = None,
password: Optional[str] = None,
password_file: Optional[str] = None,
collection_id: Optional[str] = None,
) -> Dict[str, str]:
try:
from bsbw.wrapper import BWCLIWrapper
except ImportError as e:
raise ConfigurationError(
"bitwarden-wrapper is not installed. "
"Install it from "
"https://github.com/AlmaLinux/py-bitwarden-wrapper"
) from e

if not password and not password_file:
raise ConfigurationError(
"Bitwarden master password or password file must be provided "
"(set bitwarden_password or bitwarden_password_file)"
)

logger.info(
"Fetching GPG passphrases from Bitwarden for %d keys", len(keyids)
)
wrapper = BWCLIWrapper(
username=username,
password=password,
password_file=password_file,
collection_id=collection_id,
)
container = wrapper.get_secrets()

result: Dict[str, str] = {}
missing: List[str] = []
for keyid in keyids:
if keyid not in container:
missing.append(keyid)
continue
passphrase = container.get_credentials(keyid, password_only=True)
if not passphrase:
missing.append(keyid)
continue
result[keyid] = passphrase

if missing:
raise ConfigurationError(
"Bitwarden vault is missing passphrases for keys: "
+ ", ".join(missing)
)

return result
16 changes: 14 additions & 2 deletions sign_node/utils/pgp_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ def __init__(
key_ids_from_config: list[str],
is_community_sign_node: bool = False,
development_mode: bool = False,
development_password: str = None
development_password: str = None,
preloaded_passwords: dict = None,
):
"""
Password DB initialization.
Expand All @@ -123,6 +124,10 @@ def __init__(
Gpg wrapper.
key_ids_from_config : list of str
List of PGP keyids from the config.
preloaded_passwords : dict, optional
Mapping of keyid to passphrase fetched ahead of time (e.g. from
Bitwarden). When provided, these take precedence over the
development password and interactive prompts.
"""
self.__key_ids = defaultdict(dict)
self.__key_ids_from_config = key_ids_from_config
Expand All @@ -134,6 +139,7 @@ def __init__(
'mode')
self.__development_mode = development_mode
self.__development_password = development_password
self.__preloaded_passwords = preloaded_passwords or {}

@property
def key_ids(self):
Expand Down Expand Up @@ -175,7 +181,13 @@ def ask_for_passwords(self):
"PGP key {0} is not found in the " "gnupg2 "
"database".format(keyid)
)
if self.__development_mode:
if self.__preloaded_passwords:
password = self.__preloaded_passwords.get(keyid)
if password is None:
raise ConfigurationError(
"no preloaded passphrase for PGP key {0}".format(keyid)
)
elif self.__development_mode:
password = self.__development_password
else:
password = getpass.getpass('\nPlease enter the {0} PGP key '
Expand Down
120 changes: 120 additions & 0 deletions tests/sign_node/utils/test_bitwarden.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import sys
import types
from unittest.mock import MagicMock

import pytest

from sign_node.errors import ConfigurationError


KEY_A = "AAAA1111BBBB2222"
KEY_B = "CCCC3333DDDD4444"


class FakeContainer:
def __init__(self, mapping):
self._mapping = mapping

def __contains__(self, item):
return item in self._mapping

def get_credentials(self, name, password_only=False):
value = self._mapping[name]
return value if password_only else (None, value)


@pytest.fixture
def fake_bsbw(monkeypatch):
"""Install a stub `bsbw.wrapper` module so the fetcher can import it.

Mirrors the real package layout: ``BWCLIWrapper`` lives in
``bsbw.wrapper`` and is not re-exported from the package root.
"""
package = types.ModuleType("bsbw")
wrapper = types.ModuleType("bsbw.wrapper")
wrapper.BWCLIWrapper = MagicMock()
package.wrapper = wrapper
monkeypatch.setitem(sys.modules, "bsbw", package)
monkeypatch.setitem(sys.modules, "bsbw.wrapper", wrapper)
yield wrapper


def _import_fetcher():
from sign_node.utils.bitwarden import fetch_passphrases
return fetch_passphrases


def test_fetch_passphrases_returns_keyid_map(fake_bsbw):
fake_bsbw.BWCLIWrapper.return_value.get_secrets.return_value = (
FakeContainer({KEY_A: "secret-a", KEY_B: "secret-b"})
)

fetch_passphrases = _import_fetcher()
result = fetch_passphrases(keyids=[KEY_A, KEY_B], password="master")

assert result == {KEY_A: "secret-a", KEY_B: "secret-b"}
fake_bsbw.BWCLIWrapper.assert_called_once_with(
username=None,
password="master",
password_file=None,
collection_id=None,
)


def test_fetch_passphrases_passes_collection_and_password_file(fake_bsbw):
fake_bsbw.BWCLIWrapper.return_value.get_secrets.return_value = (
FakeContainer({KEY_A: "x"})
)

fetch_passphrases = _import_fetcher()
fetch_passphrases(
keyids=[KEY_A],
username="signer@example.com",
password_file="/tmp/bw_master",
collection_id="col-1",
)

fake_bsbw.BWCLIWrapper.assert_called_once_with(
username="signer@example.com",
password=None,
password_file="/tmp/bw_master",
collection_id="col-1",
)


def test_fetch_passphrases_requires_master_credential(fake_bsbw):
fetch_passphrases = _import_fetcher()
with pytest.raises(ConfigurationError, match="master password"):
fetch_passphrases(keyids=[KEY_A])
fake_bsbw.BWCLIWrapper.assert_not_called()


def test_fetch_passphrases_missing_item_raises(fake_bsbw):
fake_bsbw.BWCLIWrapper.return_value.get_secrets.return_value = (
FakeContainer({KEY_A: "secret-a"})
)

fetch_passphrases = _import_fetcher()
with pytest.raises(ConfigurationError, match=KEY_B):
fetch_passphrases(keyids=[KEY_A, KEY_B], password="m")


def test_fetch_passphrases_empty_passphrase_treated_as_missing(fake_bsbw):
fake_bsbw.BWCLIWrapper.return_value.get_secrets.return_value = (
FakeContainer({KEY_A: ""})
)

fetch_passphrases = _import_fetcher()
with pytest.raises(ConfigurationError, match=KEY_A):
fetch_passphrases(keyids=[KEY_A], password="m")


def test_fetch_passphrases_without_bsbw_installed(monkeypatch):
# Ensure bsbw cannot be imported (block both the package and the
# submodule the fetcher imports from).
monkeypatch.setitem(sys.modules, "bsbw", None)
monkeypatch.setitem(sys.modules, "bsbw.wrapper", None)

from sign_node.utils.bitwarden import fetch_passphrases
with pytest.raises(ConfigurationError, match="bitwarden-wrapper"):
fetch_passphrases(keyids=[KEY_A], password="m")
Loading
Loading