diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aa64b6a..87c39823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Passbolt provider** (`passbolt://`): store and read secrets in a + self-hosted [Passbolt](https://www.passbolt.com/) server via the official + [`go-passbolt-cli`](https://github.com/passbolt/go-passbolt-cli). Convention + secrets map to a resource named `secretspec/{project}/{profile}/{key}` with + the value in the `password` field; a secret's `ref` can instead point at an + existing resource by id or name and pick a `field` + (`password`/`username`/`uri`/`description`). Credentials are supplied either + through the `passbolt` CLI's own configuration or through secretspec-owned env + vars (`SECRETSPEC_PASSBOLT_SERVER`, `SECRETSPEC_PASSBOLT_PRIVATE_KEY_FILE` / + `SECRETSPEC_PASSBOLT_PRIVATE_KEY`, `SECRETSPEC_PASSBOLT_PASSPHRASE`) that the + provider forwards to the CLI — the passphrase and inline key via the child's + environment, never the argv — so no credentials live in `secretspec.toml` or + the provider URI. + ## [0.14.0] - 2026-07-09 ### Added diff --git a/docs/astro.config.ts b/docs/astro.config.ts index 8e016586..bf3fc546 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -168,6 +168,7 @@ Secrets can be stored in: keyring (default), dotenv files, environment variables { label: "Environment Variables", slug: "providers/env" }, { label: "Pass", slug: "providers/pass" }, { label: "Proton Pass", slug: "providers/protonpass" }, + { label: "Passbolt", slug: "providers/passbolt" }, { label: "LastPass", slug: "providers/lastpass" }, { label: "1Password", slug: "providers/onepassword" }, { diff --git a/docs/src/content/docs/concepts/providers.md b/docs/src/content/docs/concepts/providers.md index 8220fd4a..227c7b6f 100644 --- a/docs/src/content/docs/concepts/providers.md +++ b/docs/src/content/docs/concepts/providers.md @@ -14,6 +14,7 @@ Providers are pluggable storage backends that handle the storage and retrieval o | **env** | Read-only access to existing environment variables | ✓ | ✗ | ✗ | | **pass** | Unix password manager with GPG encryption | ✓ | ✓ | ✓ | | **protonpass** | Integration with Proton password manager | ✓ | ✓ | ✓ | +| **passbolt** | Self-hosted Passbolt via go-passbolt-cli | ✓ | ✓ | ✓ | | **onepassword** | Integration with OnePassword password manager | ✓ | ✓ | ✓ | | **lastpass** | Integration with LastPass password manager | ✓ | ✓ | ✓ | | **gcsm** | Google Cloud Secret Manager (requires `--features gcsm`) | ✓ | ✓ | ✓ | diff --git a/docs/src/content/docs/providers/passbolt.md b/docs/src/content/docs/providers/passbolt.md new file mode 100644 index 00000000..f76f69bb --- /dev/null +++ b/docs/src/content/docs/providers/passbolt.md @@ -0,0 +1,147 @@ +--- +title: Passbolt Provider +description: Passbolt self-hosted password manager via go-passbolt-cli +--- + +The Passbolt provider integrates with [Passbolt](https://www.passbolt.com/), the +self-hosted, open-source password manager, via the official +[`go-passbolt-cli`](https://github.com/passbolt/go-passbolt-cli) (`passbolt`). + +It fits the workflow where **a human enters an API key or service password once** +(via the Passbolt web UI or CLI) and **dev machines read it at runtime** — no +secret material is ever written to `secretspec.toml` or to disk. + +## Prerequisites + +- The `passbolt` CLI installed and on `PATH` (build from + [go-passbolt-cli](https://github.com/passbolt/go-passbolt-cli)). +- Credentials for your Passbolt account: the server address, your OpenPGP + private key, and its passphrase. + +## Authentication + +The private key and passphrase are the bootstrap secrets that unlock every +other secret, so they never belong in `secretspec.toml` or the provider URI. +There are two ways to supply them. + +### Option A — secretspec-owned env vars (no separate CLI config) + +Set these in your environment (e.g. via your shell profile, a `.envrc`, or your +CI secret store) and secretspec forwards them to the CLI for you — the +passphrase and inline key go through the child process environment, never the +command line: + +| Variable | Purpose | +|----------|---------| +| `SECRETSPEC_PASSBOLT_SERVER` | Server address (or use the URI's `?server=`) | +| `SECRETSPEC_PASSBOLT_PRIVATE_KEY_FILE` | Path to your OpenPGP private key file | +| `SECRETSPEC_PASSBOLT_PRIVATE_KEY` | Inline OpenPGP private key (alternative to the file) | +| `SECRETSPEC_PASSBOLT_PASSPHRASE` | Private-key passphrase | + +```bash +export SECRETSPEC_PASSBOLT_SERVER=https://pass.example.com +export SECRETSPEC_PASSBOLT_PRIVATE_KEY_FILE=~/.config/passbolt/ada.asc +export SECRETSPEC_PASSBOLT_PASSPHRASE='' + +secretspec get STRIPE_SECRET_KEY --provider passbolt +``` + +### Option B — the CLI's own configuration + +Run `passbolt configure` once; with none of the env vars above set, the +provider inherits that configuration: + +```bash +passbolt configure \ + --serverAddress https://pass.example.com \ + --userPrivateKeyFile ada.asc \ + --userPassword '' +``` + +Either way, no credentials appear in `secretspec.toml` or the provider URI. + +## Configuration + +### URI Format + +``` +passbolt://[name-template][?folder=&server=] +``` + +- `name-template`: resource-name pattern for convention secrets, supporting the + `{project}`, `{profile}`, `{key}` placeholders. Defaults to + `secretspec/{project}/{profile}/{key}`. +- `folder` (optional): a Passbolt folder id. New convention resources are + created inside it (`--folderParentID`) and name lookups are scoped to it. +- `server` (optional): overrides the CLI's configured server address + (`--serverAddress`). Useful when one machine talks to several Passbolt servers. + +### Examples + +```bash +# Default resource name (secretspec/{project}/{profile}/{key}) +passbolt:// + +# Custom resource-name template +passbolt://secretspec/{project}/{profile}/{key} + +# Pin the server address (e.g. a tailnet-internal Passbolt) +passbolt://?server=https://pass.example.com + +# Scope convention resources to a folder +passbolt://?folder=a9230ec4-5507-4870-b8b5-b3f500587e4c +``` + +## Storage model + +Each convention secret maps to one Passbolt **resource**: + +- The resource **name** encodes `{project}/{profile}/{key}`. +- The secret value lives in the resource's **password** field. + +## Secret references + +A secret's [`ref`](/reference/configuration/#secret-references) points at an +existing, human-provisioned resource instead of the convention layout — the +common case for "someone added the API key in Passbolt, I just want to read it": + +- `item`: the resource **id** (a UUID, copied from the Passbolt UI) or the exact + resource **name**. +- `field` (optional): which resource field holds the value — one of `password` + (default), `username`, `uri`, `description`. + +```toml +[profiles.production] +# By resource id (unambiguous — recommended for refs) +STRIPE_SECRET_KEY = { description = "Stripe key", ref = { item = "a9230ec4-5507-4870-b8b5-b3f500587e4c" }, providers = ["passbolt://?server=https://pass.example.com"] } + +# By resource name, reading a non-default field +SERVICE_USER = { description = "Service account user", ref = { item = "Payments service account", field = "username" }, providers = ["passbolt"] } +``` + +Reads fetch the resource by id (or resolve the name to an id first) and decrypt +the requested field. Writes update that field on the existing resource; a `ref` +whose id resolves to nothing is an error (secretspec never creates a detached +resource for an externally managed reference). + +## Usage + +```bash +# Set a secret (creates or updates the secretspec/... resource's password) +$ secretspec set STRIPE_SECRET_KEY --provider passbolt +Enter value for STRIPE_SECRET_KEY: sk_live_... + +# Get a secret +$ secretspec get STRIPE_SECRET_KEY --provider passbolt + +# Run a process with secrets injected +$ secretspec run --provider passbolt -- npm start +``` + +## Environment variables + +- `SECRETSPEC_PASSBOLT_SERVER`, `SECRETSPEC_PASSBOLT_PRIVATE_KEY_FILE`, + `SECRETSPEC_PASSBOLT_PRIVATE_KEY`, `SECRETSPEC_PASSBOLT_PASSPHRASE`: credentials + forwarded to the CLI (see [Authentication](#authentication)). +- `SECRETSPEC_PASSBOLT_CLI_PATH`: path to the `passbolt` binary when it is not + simply `passbolt` on `PATH`. diff --git a/docs/src/content/docs/quick-start.mdx b/docs/src/content/docs/quick-start.mdx index 700fb41b..3d4184db 100644 --- a/docs/src/content/docs/quick-start.mdx +++ b/docs/src/content/docs/quick-start.mdx @@ -95,6 +95,7 @@ $ secretspec config init env: Read-only environment variables pass: Unix password manager with GPG encryption protonpass: Proton Pass via official pass-cli + passbolt: Passbolt self-hosted password manager via go-passbolt-cli lastpass: LastPass password manager gcsm: Google Cloud Secret Manager awssm: AWS Secrets Manager diff --git a/docs/src/content/docs/reference/providers.md b/docs/src/content/docs/reference/providers.md index 3ba29a50..9084f93d 100644 --- a/docs/src/content/docs/reference/providers.md +++ b/docs/src/content/docs/reference/providers.md @@ -97,6 +97,21 @@ protonpass://Work/{project}/{profile}/{key} # Custom vault and title temp **Prerequisites**: `pass-cli`, authenticated with `pass-cli login` (or `pass-cli login --pat $PAT` for CI) **Storage**: Note item titled `{project}/{profile}/{key}` inside the configured vault +## Passbolt Provider + +**URI**: `passbolt://[name-template][?folder=&server=
]` - Stores secrets in self-hosted Passbolt via the official `go-passbolt-cli` + +```bash +passbolt:// # Default name (secretspec/{project}/{profile}/{key}) +passbolt://secretspec/{project}/{profile}/{key} # Custom resource-name template +passbolt://?server=https://pass.example.com # Pin the server address +passbolt://?folder= # Create/scope resources in a folder +``` + +**Features**: Read/write, end-to-end encryption, self-hosted, `ref` by resource id or name (fields: `password`/`username`/`uri`/`description`) +**Prerequisites**: `passbolt` CLI, configured with `passbolt configure --serverAddress https://... --userPrivateKeyFile key.asc --userPassword ` +**Storage**: Resource named `secretspec/{project}/{profile}/{key}`, value in the `password` field + ## Google Cloud Secret Manager Provider **URI**: `gcsm://PROJECT_ID` - Stores secrets in Google Cloud Secret Manager @@ -189,6 +204,7 @@ export SECRETSPEC_PROVIDER="dotenv:///config/.env" | Keyring | ✅ System encryption | System keychain | ❌ No | | Pass | ✅ GPG encryption | Local filesystem | ❌ No | | Proton Pass | ✅ End-to-end | Cloud (Proton) | ✅ Yes | +| Passbolt | ✅ End-to-end | Self-hosted (Passbolt server) | ✅ Yes | | LastPass | ✅ End-to-end | Cloud (LastPass) | ✅ Yes | | OnePassword | ✅ End-to-end | Cloud (OnePassword) | ✅ Yes | | GCSM | ✅ Google-managed | Cloud (GCP) | ✅ Yes | diff --git a/secretspec/src/provider/mod.rs b/secretspec/src/provider/mod.rs index 4b251b0c..c67fc221 100644 --- a/secretspec/src/provider/mod.rs +++ b/secretspec/src/provider/mod.rs @@ -206,6 +206,7 @@ pub mod keyring; pub mod lastpass; pub mod onepassword; pub mod pass; +pub mod passbolt; pub mod protonpass; #[cfg(feature = "vault")] pub mod vault; diff --git a/secretspec/src/provider/passbolt.rs b/secretspec/src/provider/passbolt.rs new file mode 100644 index 00000000..3eea0906 --- /dev/null +++ b/secretspec/src/provider/passbolt.rs @@ -0,0 +1,721 @@ +use crate::provider::{Address, Provider, ProviderUrl}; +use crate::{Result, SecretSpecError}; +use secrecy::{ExposeSecret, SecretString}; +use serde::Deserialize; +use std::io; +use std::process::{Command, Stdio}; + +/// The resource field that carries a secret's value when a `ref` does not name +/// one explicitly. Passbolt resources model a login (name/username/uri/password +/// /description); the password is the secret material, so it is the default. +const DEFAULT_FIELD: &str = "password"; + +/// Resource fields a native `ref` may address via its `field` coordinate. These +/// are the standard Passbolt resource attributes; `password` is the secret, the +/// rest are metadata that a `ref` can still pin (e.g. reading a stored `uri`). +const KNOWN_FIELDS: &[&str] = &["password", "username", "uri", "description", "name"]; + +/// Environment variables that let secretspec supply the `passbolt` CLI's own +/// credentials, so `secretspec.toml` stays secret-free and no separate +/// `passbolt configure` step is needed. All are optional; when none are set the +/// provider falls back to whatever the CLI is already configured with (its +/// config file or native env vars). +/// +/// The passphrase and inline key are *secrets*: they are forwarded to the child +/// through its environment (the names `go-passbolt-cli`'s viper reads), never on +/// the argv (which is world-visible via `ps`) and never in the provider URI. +const ENV_SERVER: &str = "SECRETSPEC_PASSBOLT_SERVER"; +const ENV_PRIVATE_KEY_FILE: &str = "SECRETSPEC_PASSBOLT_PRIVATE_KEY_FILE"; +const ENV_PRIVATE_KEY: &str = "SECRETSPEC_PASSBOLT_PRIVATE_KEY"; +const ENV_PASSPHRASE: &str = "SECRETSPEC_PASSBOLT_PASSPHRASE"; + +/// One resource as emitted by `passbolt get/list resource --json`. Every column +/// is optional in the CLI output (fields are omitted when not requested or +/// empty), so all are `Option`. Only the subset secretspec needs is modeled. +#[derive(Debug, Deserialize)] +struct PassboltResource { + id: Option, + name: Option, + username: Option, + uri: Option, + password: Option, + description: Option, +} + +impl PassboltResource { + /// Extracts the named resource field, or `None` when the field is absent or + /// empty. `field` is one of [`KNOWN_FIELDS`] (already validated upstream). + fn field(&self, field: &str) -> Option { + let value = match field { + "password" => &self.password, + "username" => &self.username, + "uri" => &self.uri, + "description" => &self.description, + "name" => &self.name, + _ => &None, + }; + value.clone().filter(|v| !v.is_empty()) + } +} + +/// Resolved `passbolt` CLI credentials, kept separate from [`PassboltProvider::command`] +/// so the env → CLI mapping is unit-testable without mutating the process env. +/// +/// Non-secret values (server address, key *file path*) go on the argv as flags, +/// which take reliable precedence over the CLI's config file. Secrets (the +/// passphrase and an inline key) are passed through the child's environment +/// under the names `go-passbolt-cli` reads, keeping them off the world-readable +/// argv. +struct CliAuth { + server: Option, + key_file: Option, + key_inline: Option, + passphrase: Option, +} + +impl CliAuth { + fn apply(&self, cmd: &mut Command) { + if let Some(server) = &self.server { + cmd.arg("--serverAddress").arg(server); + } + // A key *file* is a path (not itself secret) and takes precedence over + // an inline key, matching go-passbolt-cli's own --userPrivateKeyFile + // precedence. + if let Some(key_file) = &self.key_file { + cmd.arg("--userPrivateKeyFile").arg(key_file); + } else if let Some(key) = &self.key_inline { + cmd.env("USERPRIVATEKEY", key); + } + if let Some(passphrase) = &self.passphrase { + cmd.env("USERPASSWORD", passphrase); + } + } +} + +/// Reports whether `s` is a Passbolt resource id (a canonical UUID), so an +/// `item` coordinate can be routed straight to `get resource --id` instead of a +/// name lookup. Passbolt ids are lowercase UUIDs, but we accept any case. +fn is_uuid(s: &str) -> bool { + let bytes = s.as_bytes(); + if bytes.len() != 36 { + return false; + } + bytes.iter().enumerate().all(|(i, &b)| match i { + 8 | 13 | 18 | 23 => b == b'-', + _ => b.is_ascii_hexdigit(), + }) +} + +/// Configuration for the Passbolt provider. +/// +/// The Passbolt server address and the user's private key + passphrase are +/// *not* held here: they are the `passbolt` CLI's own configuration (written by +/// `passbolt configure`, or supplied via its env vars/flags), which the provider +/// inherits by shelling out. Only non-secret addressing lives in the URI. +#[derive(Debug, Clone, Default)] +pub struct PassboltConfig { + /// Resource-name format string for convention secrets. Supports the + /// `{project}`, `{profile}`, `{key}` placeholders. Defaults to + /// `secretspec/{project}/{profile}/{key}` when absent. + pub name_template: Option, + /// Optional folder id (`--folderParentID`) new convention resources are + /// created under, and which name lookups are scoped to. From `?folder=`. + pub folder_id: Option, + /// Optional Passbolt server address, passed as `--serverAddress`. When + /// absent the CLI's configured server is used. From `?server=`. Not a + /// secret (it is a URL), so it is safe to echo in [`Provider::uri`]. + pub server_address: Option, +} + +impl TryFrom<&ProviderUrl> for PassboltConfig { + type Error = SecretSpecError; + + fn try_from(url: &ProviderUrl) -> std::result::Result { + if url.scheme() != "passbolt" { + return Err(SecretSpecError::ProviderOperationFailed(format!( + "Invalid scheme '{}' for passbolt provider", + url.scheme() + ))); + } + + let mut config = Self::default(); + + // host + path form the resource-name template, mirroring the pass + // provider's folder_prefix (e.g. `passbolt://secretspec/{profile}/{key}`). + if let Some(host) = url.host() { + let path = url.path(); + config.name_template = Some(format!("{}{}", host, path)); + } + + config.folder_id = url.query_value("folder"); + config.server_address = url.query_value("server"); + + Ok(config) + } +} + +/// Provider for [Passbolt](https://www.passbolt.com/), the self-hosted +/// open-source password manager, via the official `go-passbolt-cli` (`passbolt`). +/// +/// Each convention secret maps to one Passbolt resource whose *name* encodes +/// `{project}/{profile}/{key}` and whose *password* field holds the value. This +/// suits the "a human enters an API key once via the Passbolt web UI, dev +/// machines read it at runtime" workflow: point a secret's [`ref`] at the +/// resource (by id or name) and the value is fetched with no secrets on disk. +/// +/// # Authentication +/// +/// The Passbolt private key and passphrase are the bootstrap secrets that +/// unlock every other secret, so they live neither in `secretspec.toml` nor in +/// the provider URI. Supply them one of two ways: +/// +/// 1. **secretspec-owned env vars** (no separate CLI config step): set +/// `SECRETSPEC_PASSBOLT_PRIVATE_KEY_FILE` (or `SECRETSPEC_PASSBOLT_PRIVATE_KEY` +/// for an inline key) and `SECRETSPEC_PASSBOLT_PASSPHRASE`; the server address +/// comes from the URI's `?server=` or `SECRETSPEC_PASSBOLT_SERVER`. The +/// provider forwards these to the CLI itself — the passphrase and inline key +/// via the child's environment, never the argv. +/// 2. **the CLI's own configuration**: run `passbolt configure --serverAddress +/// https://... --userPrivateKeyFile key.asc --userPassword ` +/// once. With none of the env vars above set, the provider inherits it. +/// +/// Either way, no credentials appear in `secretspec.toml` or the provider URI. +/// +/// # Storage +/// +/// - Resource name: `secretspec/{project}/{profile}/{key}` by default, +/// customizable via the URI (`passbolt://