Skip to content
Open
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
58 changes: 51 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,21 @@ 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
# Fetching GPG passphrases from a secret provider

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).
fetch them from Bitwarden or from HashiCorp Vault.

Only **one** provider may be enabled at a time — signing keys should have a
single unambiguous source of truth, so enabling both is a configuration error
rather than a fallback chain. Whichever provider is enabled takes precedence
over the development password and interactive prompts, and startup fails fast
if any keyid is missing from it or its passphrase does not unlock the GPG key.

## Bitwarden

Uses [py-bitwarden-wrapper](https://github.com/AlmaLinux/py-bitwarden-wrapper).

Requirements:
* The Bitwarden CLI (`bw`) must be installed and on `PATH`.
Expand All @@ -54,13 +63,48 @@ bitwarden_password_file: /run/secrets/bw_master
# 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`.

## HashiCorp Vault

Reads passphrases from a KV v2 store using
[hvac](https://github.com/hvac/hvac). For each keyid listed in `pgp_keys`,
create a secret at `<vault_mount>/<vault_path_prefix>/<keyid>` holding the
passphrase in the `passphrase` field:

```
vault kv put secret/albs/sign-keys/7C3955C2A345DA89 passphrase='...'
```

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

```yaml
vault_enabled: yes
vault_addr: https://vault.example.com:8200
vault_mount: secret # KV v2 mount point
vault_path_prefix: albs/sign-keys
# Authenticate with a static token from a file (preferred) ...
vault_token_file: /run/secrets/vault_token
# ... or inline (less safe):
# vault_token: "..."
# ... or via AppRole:
# vault_role_id: <uuid>
# vault_secret_id_file: /run/secrets/vault_secret_id
# Optional: Vault Enterprise / HCP namespace and a custom CA bundle.
# vault_namespace: admin/albs
# vault_ca_cert: /etc/pki/vault-ca.pem
# Optional: read a different field, for an existing secret layout.
# vault_passphrase_field: passphrase
```

`VAULT_ADDR` and `VAULT_TOKEN` from the environment are used as a fallback when
the corresponding options are unset, so a host already running a Vault agent
needs no credentials in the config file.

Passphrases are read once at startup, so a short-lived token is sufficient and
no Vault session is renewed while the node runs.

# Reporting issues

All issues should be reported to the [Build System project](https://github.com/AlmaLinux/build-system).
18 changes: 5 additions & 13 deletions almalinux_sign_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
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
from sign_node.utils.secrets import resolve_passphrases


def init_arg_parser():
Expand Down Expand Up @@ -78,18 +78,10 @@ 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))
try:
preloaded_passwords = resolve_passphrases(config)
except ConfigurationError as e:
args_parser.error(str(e))
password_db = PGPPasswordDB(
gpg,
key_ids_from_config=config.pgp_keys.copy(),
Expand Down
26 changes: 26 additions & 0 deletions node-config/sign_node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,29 @@ is_community_sign_node: true
# bitwarden_password: "..."
# Optional: restrict the lookup to a single collection (real UUID only).
# bitwarden_collection_id: <uuid>

# Alternatively, fetch the passphrases from a HashiCorp Vault KV v2 store.
# For each keyid in 'pgp_keys', create a secret at
# '<vault_mount>/<vault_path_prefix>/<keyid>' holding the passphrase in the
# 'passphrase' field:
# vault kv put secret/albs/sign-keys/<keyid> passphrase='...'
# Only one secret provider may be enabled at a time: switching this on while
# 'bitwarden_enabled' is also set is a configuration error.
# vault_enabled: yes
# vault_addr: https://vault.example.com:8200
# vault_mount: secret
# vault_path_prefix: albs/sign-keys
# Authenticate with a static token from a file (preferred) ...
# vault_token_file: /run/secrets/vault_token
# ... or inline (less safe):
# vault_token: "..."
# ... or via AppRole:
# vault_role_id: <uuid>
# vault_secret_id_file: /run/secrets/vault_secret_id
# VAULT_ADDR and VAULT_TOKEN from the environment are used as a fallback,
# so a host running a Vault agent needs no credentials here.
# Optional: Vault Enterprise / HCP namespace, custom CA bundle, and a
# different field name for an existing secret layout.
# vault_namespace: admin/albs
# vault_ca_cert: /etc/pki/vault-ca.pem
# vault_passphrase_field: passphrase
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ websocket-client==1.8.0
# https://jasonralph.org/?p=997
cryptography==43.0.3
pgpy==0.6.0
hvac==2.4.0
git+https://github.com/AlmaLinux/immudb-wrapper.git@0.1.9#egg=immudb_wrapper
git+https://github.com/AlmaLinux/py-bitwarden-wrapper.git@0.1.17#egg=bitwarden_wrapper
34 changes: 34 additions & 0 deletions sign_node/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from .utils.config import BaseConfig
from .utils.file_utils import normalize_path
from .utils.vault import DEFAULT_FIELD as DEFAULT_VAULT_FIELD
from .utils.vault import DEFAULT_MOUNT as DEFAULT_VAULT_MOUNT

__all__ = ["SignNodeConfig"]

Expand Down Expand Up @@ -82,6 +84,18 @@ def __init__(self, config_file=None, **cmd_args):
'bitwarden_password': None,
'bitwarden_password_file': None,
'bitwarden_collection_id': None,
'vault_enabled': False,
'vault_addr': None,
'vault_token': None,
'vault_token_file': None,
'vault_role_id': None,
'vault_secret_id': None,
'vault_secret_id_file': None,
'vault_namespace': None,
'vault_mount': DEFAULT_VAULT_MOUNT,
'vault_path_prefix': '',
'vault_passphrase_field': DEFAULT_VAULT_FIELD,
'vault_ca_cert': None,
}
schema = {
"development_mode": {"type": "boolean", "default": False},
Expand Down Expand Up @@ -118,6 +132,26 @@ def __init__(self, config_file=None, **cmd_args):
'bitwarden_password': {'type': 'string', 'nullable': True},
'bitwarden_password_file': {'type': 'string', 'nullable': True},
'bitwarden_collection_id': {'type': 'string', 'nullable': True},
'vault_enabled': {'type': 'boolean', 'default': False},
'vault_addr': {'type': 'string', 'nullable': True},
'vault_token': {'type': 'string', 'nullable': True},
'vault_token_file': {'type': 'string', 'nullable': True},
'vault_role_id': {'type': 'string', 'nullable': True},
'vault_secret_id': {'type': 'string', 'nullable': True},
'vault_secret_id_file': {'type': 'string', 'nullable': True},
'vault_namespace': {'type': 'string', 'nullable': True},
'vault_mount': {
'type': 'string',
'default': DEFAULT_VAULT_MOUNT,
'empty': False,
},
'vault_path_prefix': {'type': 'string', 'nullable': True},
'vault_passphrase_field': {
'type': 'string',
'default': DEFAULT_VAULT_FIELD,
'empty': False,
},
'vault_ca_cert': {'type': 'string', 'nullable': True},
}
super(SignNodeConfig, self).__init__(
default_config, config_file, schema, **cmd_args
Expand Down
74 changes: 74 additions & 0 deletions sign_node/utils/secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# -*- mode:python; coding:utf-8; -*-

"""Resolve GPG key passphrases from an external secret provider.

Exactly one provider may be enabled at a time: signing keys should have a
single unambiguous source of truth, so enabling several is a configuration
error rather than a merge of their results.
"""

import logging
from typing import Dict, Optional

from ..errors import ConfigurationError
from . import bitwarden, vault

__all__ = ["resolve_passphrases", "enabled_providers"]

logger = logging.getLogger(__name__)


def enabled_providers(config) -> list:
"""Names of the secret providers switched on in the configuration."""
flags = (
("bitwarden", config.bitwarden_enabled),
("vault", config.vault_enabled),
)
return [name for name, enabled in flags if enabled]


def _from_vault(config) -> Dict[str, str]:
return vault.fetch_passphrases(
keyids=config.pgp_keys,
addr=config.vault_addr,
token=config.vault_token,
token_file=config.vault_token_file,
role_id=config.vault_role_id,
secret_id=config.vault_secret_id,
secret_id_file=config.vault_secret_id_file,
namespace=config.vault_namespace,
mount=config.vault_mount,
path_prefix=config.vault_path_prefix,
field=config.vault_passphrase_field,
ca_cert=config.vault_ca_cert,
)


def _from_bitwarden(config) -> Dict[str, str]:
return bitwarden.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,
)


def resolve_passphrases(config) -> Optional[Dict[str, str]]:
"""Fetch passphrases from the configured provider.

Returns ``None`` when no provider is enabled, leaving the caller to fall
back to development mode or interactive prompts.
"""
providers = enabled_providers(config)
if len(providers) > 1:
raise ConfigurationError(
"Only one secret provider may be enabled at a time, but these "
"are enabled: " + ", ".join(providers)
)
if not providers:
return None
provider = providers[0]
logger.info("Using the %s secret provider for GPG passphrases", provider)
fetchers = {"bitwarden": _from_bitwarden, "vault": _from_vault}
return fetchers[provider](config)
Loading
Loading