Add GPG/OpenPGP secrets engine
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

Provide a transit-style Vault/OpenBao secrets engine whose key material is
OpenPGP, so services can sign/verify/encrypt/decrypt with GPG keys that never
leave the barrier — and so tools like pass can encrypt to an exported public
key while delegating decryption back to Vault.

- Add versioned key management (keys/<name> CRUD+list, config, rotate, import)
  with private material seal-wrapped under key/ and per-key locking.
- Add sign/verify (detached OpenPGP) and encrypt/decrypt paths; decrypt
  auto-detects armored vs raw-binary ciphertext (what pass/gpg write).
- Add export/<public-key|private-key>/<name>; public always exportable,
  private only when the key is marked exportable.
- Use ProtonMail go-crypto for OpenPGP; support rsa-2048/3072/4096 and ed25519.
- Clone the sibling plugin's build/packaging/CI: dual-target RPMs
  (vault + openbao plugin dirs), Woodpecker PR/release pipelines, and a
  Vault+OpenBao e2e harness. Unit tests include real gpg and pass interop.
This commit is contained in:
2026-07-15 21:00:02 +10:00
parent a92b5f100f
commit 71cc398197
28 changed files with 2729 additions and 1 deletions
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
#
# Package the (already built) plugin binary into RPMs with nfpm. Builds one RPM
# per target server: Vault (/opt/vault-plugins) and OpenBao (/opt/openbao-plugins).
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}"
BINARY="vault-plugin-secrets-gpg"
DIST="dist"
if [ ! -f "${DIST}/${BINARY}" ]; then
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
exit 1
fi
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="Vault/OpenBao secrets engine for GPG/OpenPGP keys (sign/verify/encrypt/decrypt)"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-plugin-secrets-gpg"
export PACKAGE_LICENSE="MIT"
build_flavor() {
export PACKAGE_NAME="$1"
export PACKAGE_PLUGIN_DIR="$2"
export PACKAGE_PREINSTALL="${DIST}/preinstall-${PACKAGE_NAME}.sh"
envsubst '${PACKAGE_PLUGIN_DIR}' < packaging/scripts/preinstall.sh.tmpl > "${PACKAGE_PREINSTALL}"
envsubst < packaging/nfpm.yaml > "${DIST}/nfpm-${PACKAGE_NAME}.yaml"
nfpm pkg --config "${DIST}/nfpm-${PACKAGE_NAME}.yaml" --target "${DIST}" --packager rpm
}
build_flavor "vault-plugin-secrets-gpg" "/opt/vault-plugins"
build_flavor "openbao-plugin-secrets-gpg" "/opt/openbao-plugins"
echo "Built:"
ls -1 "${DIST}"/*.rpm
Executable
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
#
# End-to-end test for vault-plugin-secrets-gpg.
#
# Builds the plugin, brings up both Vault and OpenBao, then drives the identical
# lifecycle against each to prove the same binary works on both:
# generate key -> sign/verify -> encrypt/decrypt -> rotate -> decrypt old
# ciphertext -> export public key.
#
# Select engines with ENGINES (default "vault openbao"), e.g. ENGINES=openbao.
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/test/docker-compose.yml"
COMPOSE="docker compose -f ${COMPOSE_FILE}"
BINARY="vault-plugin-secrets-gpg"
MOUNT="gpg"
ENGINES="${ENGINES:-vault openbao}"
red() { printf '\033[31m%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
blue() { printf '\033[34m==> %s\033[0m\n' "$*"; }
cleanup() { blue "Tearing down containers"; ${COMPOSE} down -v >/dev/null 2>&1 || true; }
trap cleanup EXIT
fail() { red "FAIL: $*"; exit 1; }
wait_for() {
local desc="$1"; shift
local i=0
until "$@" >/dev/null 2>&1; do
i=$((i + 1))
[ "$i" -ge "${WAIT_RETRIES:-90}" ] && fail "timed out waiting for ${desc}"
sleep 2
done
green "ready: ${desc}"
}
b64() { printf '%s' "$1" | base64 | tr -d '\n'; }
run_engine() {
local engine="$1" container="$2" cli="$3"
blue "[${engine}] exercising the plugin"
ex() { ${COMPOSE} exec -T "${container}" "${cli}" "$@"; }
local sha; sha="$(sha256sum "${ROOT_DIR}/dist/${BINARY}" | awk '{print $1}')"
ex plugin register -sha256="${sha}" secret "${BINARY}" >/dev/null || true
ex secrets disable "${MOUNT}" >/dev/null 2>&1 || true
ex secrets enable -path="${MOUNT}" "${BINARY}" >/dev/null
green "[${engine}] plugin registered and mounted"
# --- generate a key ---
local pub
pub="$(ex write -field=public_key "${MOUNT}/keys/app" algorithm=rsa-2048 identity='App <app@unkin.net>')"
printf '%s' "${pub}" | grep -q 'BEGIN PGP PUBLIC KEY BLOCK' || fail "[${engine}] no armored public key returned"
green "[${engine}] key generated; public key exported"
# --- sign / verify ---
local msg sig valid
msg="$(b64 'ship it')"
sig="$(ex write -field=signature "${MOUNT}/sign/app" input="${msg}")"
valid="$(ex write -field=valid "${MOUNT}/verify/app" input="${msg}" signature="${sig}")"
[ "${valid}" = "true" ] || fail "[${engine}] signature did not verify"
green "[${engine}] sign/verify ok"
# --- encrypt / decrypt roundtrip ---
local ct pt secret="correct horse battery staple"
ct="$(ex write -field=ciphertext "${MOUNT}/encrypt/app" plaintext="$(b64 "${secret}")")"
pt="$(ex write -field=plaintext "${MOUNT}/decrypt/app" ciphertext="${ct}" | base64 -d)"
[ "${pt}" = "${secret}" ] || fail "[${engine}] encrypt/decrypt roundtrip mismatch"
green "[${engine}] encrypt/decrypt ok"
# --- rotate, then decrypt the pre-rotation ciphertext ---
local ver
ver="$(ex write -field=latest_version "${MOUNT}/keys/app/rotate")"
[ "${ver}" = "2" ] || fail "[${engine}] rotate did not bump to version 2"
pt="$(ex write -field=plaintext "${MOUNT}/decrypt/app" ciphertext="${ct}" | base64 -d)"
[ "${pt}" = "${secret}" ] || fail "[${engine}] old ciphertext failed to decrypt after rotation"
green "[${engine}] rotation retains old versions"
green "[${engine}] PASSED"
}
blue "Building plugin for linux/amd64"
OS=linux ARCH=amd64 PLUGIN_DIR="${ROOT_DIR}/dist" make -C "${ROOT_DIR}" build
blue "Starting Docker stack (vault + openbao)"
${COMPOSE} up -d --build
for engine in ${ENGINES}; do
case "${engine}" in
vault) wait_for "vault" ${COMPOSE} exec -T vault vault status -address=http://127.0.0.1:8200; run_engine vault vault vault ;;
openbao) wait_for "openbao" ${COMPOSE} exec -T openbao bao status -address=http://127.0.0.1:8200; run_engine openbao openbao bao ;;
*) fail "unknown engine: ${engine}" ;;
esac
done
green "ALL END-TO-END CHECKS PASSED (${ENGINES})"