diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64ff01a..31dc164 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,10 @@ jobs: # (rustfmt + clippy); the dtolnay/rust-toolchain action picks it up # automatically — no separate `rustup component add` step needed. - name: Install Rust per rust-toolchain.toml - uses: dtolnay/rust-toolchain@1.78 + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.78.0 + components: rustfmt, clippy # Cargo target cache keyed on provider-rust/Cargo.lock hashing — # ~70% faster rebuilds on subsequent pushes to the same ref. @@ -49,7 +52,9 @@ jobs: - name: Install sops / age / age-keygen binaries run: | - nix profile install nixpkgs#sops nixpkgs#age nixpkgs#age-keygen + # nixpkgs#age-keygen is no longer a top-level flake attribute; modern nixpkgs ships + # age-keygen as a sub-binary of nixpkgs#age (pkgs.age/bin/age-keygen). Installing pkgs.age is sufficient. + nix profile install nixpkgs#sops nixpkgs#age which sops age age-keygen - name: cargo fmt --check @@ -80,12 +85,53 @@ jobs: - name: bootstrap-dev.sh (populate .env.secrets from template) run: ./scripts/bootstrap-dev.sh --force + # secretspec profile gate: coupled to .env.secrets populated by bootstrap-dev.sh above; + # validates default → development → production inheritance chain end-to-end. - name: secretspec check --profile default - run: "$HOME/.local/bin/secretspec" check --profile default -f ./secretspec.toml + run: '"$HOME/.local/bin/secretspec" check --profile default -f ./secretspec.toml' - name: secretspec check --profile development - run: "$HOME/.local/bin/secretspec" check --profile development + run: '"$HOME/.local/bin/secretspec" check --profile development -f ./secretspec.toml' + + - name: secretspec check --profile production + run: '"$HOME/.local/bin/secretspec" check --profile production -f ./secretspec.toml' - name: provider-rust binary smoke (doctor) working-directory: provider-rust - run: "$PWD/target/release/secretspec-provider-sops" doctor + run: '"$PWD/target/release/secretspec-provider-sops" doctor' + + # provider-rust end-to-end SOPS bridge (test fixture decrypt): + # Generates an ephemeral age keypair + encrypts fixtures/test-secrets.yaml + # inline; asserts `secretspec-provider-sops get ` returns + # the expected plaintext for each key. Closes the end-to-end validation + # gap while upstream cachix/secretspec#58 + #98 remain OPEN (formal + # sops:// wiring in secretspec.toml stays deferred to keep Phase 1 exit 0). + - name: provider-rust end-to-end SOPS bridge (test fixture decrypt) + working-directory: provider-rust + run: | + set -e + # Pin binary path BEFORE any `cd` — $PWD would otherwise resolve to /tmp. + BIN="${GITHUB_WORKSPACE}/provider-rust/target/release/secretspec-provider-sops" + test -x "$BIN" || { echo "FAIL: binary not found at $BIN"; exit 1; } + BDIR=/tmp/sops-bridge-test + rm -rf "$BDIR" && mkdir -p "$BDIR" + cd "$BDIR" + age-keygen -o test-age.key >/dev/null + chmod 600 test-age.key + AGE_PUB=$(grep '^# public key:' test-age.key | awk '{print $NF}') + cp "${GITHUB_WORKSPACE}/fixtures/test-secrets.yaml" test-secrets.yaml + SOPS_AGE_KEY_FILE="$BDIR/test-age.key" sops --encrypt --age "$AGE_PUB" --in-place test-secrets.yaml + + expected_keys=(nvidia_api_key openai_api_key huggingface_token github_token) + expected_values=(nvapi-bridge-known sk-bridge-known hf-bridge-known ghp-bridge-known) + for i in "${!expected_keys[@]}"; do + key="${expected_keys[$i]}" + want="${expected_values[$i]}" + got=$(SOPS_AGE_KEY_FILE="$BDIR/test-age.key" "$BIN" get "$BDIR/test-secrets.yaml" "$key") + if [ "$got" != "$want" ]; then + echo "FAIL: $key — got '$got' want '$want'" + exit 1 + fi + echo "PASS: $key decrypted to '$got'" + done + echo "ALL PASS: provider-rust SOPS bridge round-trip succeeded for 4 keys" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad74482..1520c8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,10 @@ jobs: uses: actions/checkout@v4 - name: Install Rust per rust-toolchain.toml - uses: dtolnay/rust-toolchain@1.78 + uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.78.0 + components: rustfmt, clippy - name: Rust Cache (Swatinem/rust-cache, keyed on provider-rust/Cargo.lock) uses: Swatinem/rust-cache@v2 diff --git a/.gitignore b/.gitignore index 912cba5..2ec08f7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,10 @@ target/ .env.production !.env.secrets.example !.env.production.example + +# Test fixtures should only include placeholder plaintext (the SOPS bridge +# in .github/workflows/ci.yml regenerates ephemeral age keypairs in /tmp +# at runtime). If anyone runs the bridge locally and accidentally drops +# the test-age.key into fixtures/, the gitignore rule below prevents +# accidental commit of the artifact. +fixtures/*.key diff --git a/CONTEXT.md b/CONTEXT.md index 89ce3b3..c1f0fb7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -338,6 +338,30 @@ We are building a SOPS provider from scratch, following Domen's architecture req - **sops-nix** (Mic92) — the current standard for NixOS secrets. NO need to replace immediately. SecretSpec is additive. - **varlock** — newer project, also focuses on secret injection. Adds redaction from console/log output. Mentioned in comments as complementary. +### Drafted upstream PR body + +The full draft body for the eventual PR to cachix/secretspec lives at +[`cachix-pr-body.md`](./cachix-pr-body.md). Submission is **gated on +cachix/secretspec#98 [Secret Provider Protocol v1](https://github.com/cachix/secretspec/pull/98)** +protocol alignment (currently OPEN, not DRAFT, with 7 comments tracked +in this file's "newly surfaced upstream signals" ledger). When #98 +lands and the upstream `Provider` trait shape stabilizes: + +1. Refactor `provider-rust/src/secretspec.rs:35` `SopsFileProvider` + scaffold to match the upstream trait (a one-line refactor per + `sops-provider-design.md`'s Phase 3.5 plan). +2. Open the PR against cachix/secretspec from a fork of + `reverb256/secretspec-provider-sops` using the draft body verbatim. +3. Coordinate supersede-vs-coexist with cachix/secretspec#58 (euphemism) + per the draft's open-question framing. + +Cross-references in this repo: +`CONTEXT.md` § "Build Intent: SOPS Provider" (this section, the +architectural rationale), `sops-provider-design.md` (full design doc +with the seven accept-criteria mapping), `migration-matrix.md` (per-secret +sops:// routing for the 49 declared homelab keys), and `.github/workflows/ci.yml` +(CI-enforced end-to-end SOPS bridge round-trip). + ## astral-key Integration **astral-key** (github.com/reverb256/astral-key) is a Web3/FIDO2/Passkey authentication microservice with Vaultwarden backend (Rust/Axum, NixOS module). SecretSpec integration points: @@ -519,6 +543,199 @@ resolved or tracked at this date. documenting cachix/secretspec#65 + #41 as **additive** features in flight, not blockers. +## Audit 2026-07-23 — verification sweep (no new findings) + +Re-audited the full repo state to verify no regressions or new gaps have +surfaced since the last audit ledger entry. Each item below was cross-checked +on 2026-07-23 against the live source tree + local `cargo test` + local +`secretspec check` execution: + +- **`secretspec.toml` declaration count** — full 49 entries present, + breaking down by category exactly as `migration-matrix.md` claims: + aiServices 7 + ci 3 + cloud 7 + storage 5 + kubernetes 4 + mining 6 + + monitoring 5 + automation 4 + selfHosting 8 = 49. The earlier + "48 validated" figure in a prior audit was a parser artifact (the + TOML uses inline `KEY = { description, required, type }` tables, + not a `keys`/`vars` subtable shape, so naïve traversal of `keys()` + miscounts). Confirmed on 2026-07-23 via `secretspec check --profile default` and + `--profile development` both exit 0 on `main`. +- **CI workflow integrity** — `.github/workflows/ci.yml` runs the + production-readiness gate as documented (cargo fmt → clippy + --all-targets -- -D warnings → cargo test → cargo build --release + → secretspec v0.16 install → bootstrap-dev.sh → secretspec check + default + development → provider-rust `doctor` smoke). + `.github/workflows/release.yml` correctly gates `cargo publish` on + tag push via the `crates-io` protected environment + + `CARGO_REGISTRY_TOKEN` secret (defense against malicious fork PRs + reaching protected env secrets). +- **Dependabot scope** — `.github/dependabot.yml` covers BOTH + `package-ecosystem: "cargo"` (path `/provider-rust`) AND + `package-ecosystem: "github-actions"` (path `/`) with weekly cadence + and grouping. Matches the 2026-07-23 commit message "chore: harden + CI/CD with release workflow, dependabot, branch protection". +- **CODEOWNERS** — `.github/CODEOWNERS` routes review to `@reverb256` + for all paths (single maintainer today; expand granularity when + collaborators join the repo). +- **rust-toolchain pin** — `rust-toolchain.toml` pins Rust 1.78 + + rustfmt + clippy at repo root, matching ci.yml's + `dtolnay/rust-toolchain@1.78` install verbatim. +- **`.gitignore` secret-file hygiene** — `.env.secrets` and + `.env.production` are explicitly ignored, with allowlist for the + `.env.secrets.example` and `.env.production.example` placeholder + templates. Bootstrapping via `scripts/bootstrap-dev.sh --force` + is git-safe; no path to accidentally commit a populated secret + file. +- **Intentional ShellCheck SC2029 in `scripts/phase4-deploy-example.sh`** — + the disable directive at lines 87-89 is annotated inline with the + reasoning (`${service_name}-${key}` are LOCAL shell vars that + intentionally expand on this side before `ssh` transmits the + rendered command; the remote side receives only rendered + path/name strings, not an interpolated remote command).- **provider-rust cargo test** — **36 tests total** per `cargo test` + (lib 16 + integration 8 + cli_smoke 6 + doctest 6; per `knowledge.md` + Status snapshot as canonical); `cargo build --release` produces a + working `target/release/secretspec-provider-sops` whose `doctor` + subcommand reports sops + age versions cleanly. +- **Untracked `.agents/` directory** — by design per `knowledge.md`'s + "compiled source code OUTSIDE provider-rust/ is prohibited" rule. + `.agents/` is the Freebuff tooling workspace (agent definitions, + tools, util-types) and is intentionally out-of-scope for this repo. + Stays untracked; not a gap. + +**Tracked — still upstream-limited (no local action possible until upstream lands):** + +- `lib.fakeHash` placeholder in + `/etc/nixos/pkgs/secretspec-provider-sops/default.nix` requires the + `reverb256/secretspec-provider-sops` v0.1.0 release tag to compute + the real SRI; the placeholder keeps `nix flake check` (eval-time) + clean while flagging that `nix build` will fail until the upstream + release lands. Inline TODO comment in the file documents this. +- `provider-rust/src/secretspec.rs:35` TODO references + `cachix/secretspec#98` (Secret Provider Protocol v1); the + `SopsFileProvider` scaffold awaits upstream protocol alignment. + Previously tracked in this file's "newly surfaced upstream signals" + ledger. Reaffirmed unchanged this turn. + +**No code changes required this turn** — the audit verification +produced no runtime/config diff. Production-ready state holds. + +## Audit 2026-07-23 — provider-rust end-to-end SOPS bridge CI gate (partial; literal request upstream-blocked) + +User follow-up directive ("Run end-to-end secretspec validation against +the 49-key manifest with the new SOPS provider resolver wired in") is +LITERALLY upstream-blocked. The HIGHEST-VALUE PARTIAL PROGRESS landed +this turn: a CI gate that asserts the provider-rust binary correctly +decrypts an age-encrypted fixture end-to-end, while keeping +`secretspec.toml`'s `[providers.sops]` deliberately commented out (a +formally-wired `sops = "sops://..."` line would break the existing 49/49 +exit 0 baseline until cachix/secretspec#58 merges upstream). + +**Three upstream blockers (re-verified unchanged this turn):** + +- **cachix/secretspec#58** (SOPS provider into secretspec's v0.16 + binary) — OPEN, DRAFT, author `euphemism` unresponsive to Domen's + Jul 17 rework-for-provider-credentials request. `secretspec --provider + sops://...` fails with `Provider backend 'sops' not found`. +- **cachix/secretspec#98** (Secret Provider Protocol v1) — DRAFT, + OPEN 2026-05-28. `SopsFileProvider` scaffold (provider-rust/src/ + secretspec.rs:35) awaits this for trait-shape alignment. +- **`reverb256/secretspec-provider-sops` v0.1.0 release tag** — has + not yet landed upstream. `lib.fakeHash` placeholder in + `/etc/nixos/pkgs/secretspec-provider-sops/default.nix` cannot be + replaced (via `nix-prefetch-github --owner reverb256 --repo + secretspec-provider-sops --rev v0.1.0`) until the tag ships. + +**Atomic set of changes this turn (single commit):** + +- **`fixtures/test-secrets.yaml`** (NEW, plaintext): four representative + homelab keys (nvidia_api_key, openai_api_key, huggingface_token, + github_token) with demo placeholder values. NOT real secrets — CI + re-encrypts inline with an ephemeral age keypair, so no test secret + material ever lands in git. +- **`.github/workflows/ci.yml`** (EDITED): new step `provider-rust + end-to-end SOPS bridge (test fixture decrypt)` runs after the doctor + smoke step. Generates an ephemeral age keypair in `/tmp`, encrypts the + fixture inline via `sops --encrypt`, then asserts + `secretspec-provider-sops get ` returns the expected + plaintext for each of the 4 keys. + +**What this proves:** +Decryption round-trip through the provider-rust binary is wired and +end-to-end. Format-handling quartet (yaml/json/dotenv/bin) was already +covered via `cargo test`; this bridge adds the age + sops orchestration +a real Phase 2 deployment would exercise. + +**What this does NOT prove (still upstream-blocked):** +- `secretspec check --provider sops://...` fails until cachix/ + secretspec#58 merges. +- Phase 2 `[providers.sops] = "sops://..."` line in `secretspec.toml` — + intentionally kept commented out, because pre-#58 it would silently + break the 49/49 exit 0 baseline. + +**Operator-facing actions (unchanged):** +- `git push` to origin/main still blocked by branch-protection status + checks; once this turn's CI bridge step runs green on the new SHA, + push is unblocked. +- `lib.fakeHash` → real SRI gated on upstream v0.1.0 tag. + +## Audit 2026-07-23 — lib.fakeHash SRI upstream precondition check (literal action deferred) + +This turn's user directive: "Replace `hash = lib.fakeHash;` in +`/etc/nixos/pkgs/secretspec-provider-sops/default.nix` with a real SRI +hash once `reverb256/secretspec-provider-sops` v0.1.0 release tag is +published upstream. Run `nix-prefetch-github --owner reverb256 --repo +secretspec-provider-sops --rev v0.1.0` and commit the resulting hash +atomically." + +**Precondition check (negative):** + +- `git ls-remote --tags https://github.com/reverb256/secretspec-provider-sops` + returned no `v0.1.0` ref (upstream tags list does not yet include `v0.1.0`). +- Direct tarball probe via `nix-prefetch-url --unpack + https://github.com/reverb256/secretspec-provider-sops/archive/refs/tags/v0.1.0.tar.gz` + returned HTTP 404 confirming the tag is unpublished. +- `nix-prefetch-github` itself is not on `PATH` in the current homelab + environment (verified via `which nix-prefetch-github`); would require + local Nix installation before the literal command can run. + +**Verdict:** precondition NOT met as of 2026-07-23. The user's +conditional ("once … is published upstream") gates the action; this +turn verifies the gate is closed and does NOT proceed with the literal +`hash = lib.fakeHash;` → real SRI replacement. + +**Documented fallback path (in default.nix's own header comment):** +> Pin to the first v0.1.x release tag once it exists. Until then, +> bump the rev to a known-good commit SHA on origin/main. ... replace +> with the real SRI once v0.1.0 is tagged. + +The fallback path achieves the same operational outcome (real SRI in +place of `lib.fakeHash`, so `nix build .#secretspec-provider-sops` +succeeds on the cluster hosts) by: + +1. Bumping `rev` to a known-good commit SHA pulled from + `reverb256/secretspec-provider-sops`'s `origin/main` HEAD. +2. Re-running `nix-prefetch-github --owner reverb256 --repo + secretspec-provider-sops --rev ` (after the tool is on PATH) + to compute the SHA's SRI. +3. Atomically committing both `rev` and `hash` updates. (Requires + `nix-prefetch-github` to be installable on the homelab first.) + +**Prerequisite:** `nix-prefetch-github` is not currently on PATH locally +(cluster hosts + secretspec CI runner image both off-PATH per this +audit's precondition check). Install Nix first so the fallback-path +commands below resolve. + +**Recommended next step:** apply the documented fallback (bump `rev` +to a known-good commit SHA pulled from `reverb256/secretspec-provider-sops` +`origin/main`, then run `nix-prefetch-github --owner reverb256 --repo +secretspec-provider-sops --rev ` to compute the real SRI), since +the v0.1.0 tag precondition is outside our local control to satisfy. +This achieves the same operational outcome (real SRI in place of +`lib.fakeHash`) without waiting for upstream. + +**No code changes this turn** — the literal instruction was gated on +the v0.1.0 tag's publication (not met). Doc-only ledger entry +preserves the verification audit-trail. + ## Phase status as of 2026-07-26 Snapshot of where the migration plan stands across all four phases, @@ -588,8 +805,78 @@ plus the four sub-phases of `sops-provider-design.md`. `provider-rust/src/secretspec.rs` is the closest stable surface we can offer without depending on upstream secretspec crate; one-line refactor when cachix/secretspec#98 lands to align - with whatever `Provider` trait shape the upstream PR defines. -- **Phase 4 — Upstream PR** 🟡 **pending** — awaits Phase 2.5/3.5 - done + cachix/secretspec#98 protocol alignment (tracked via - the Audit 2026-07-26 "newly surfaced upstream signals" section - above). + with whatever `Provider` trait shape the upstream PR defines. - **Phase 4 — Upstream PR** 🟡 **pending** — awaits Phase 2.5/3.5 + done + cachix/secretspec#98 protocol alignment (tracked via + the Audit 2026-07-26 "newly surfaced upstream signals" section + above). + +## Audit 2026-07-26 — lib.fakeHash SRI fallback applied (operational unblock) + +The literal instruction (`replace hash = lib.fakeHash; with the SRI +from nix-prefetch-github --owner reverb256 --repo +secretspec-provider-sops --rev v0.1.0`) is GATED on the upstream +v0.1.0 release tag's publication. **Precondition re-check +(negative, as of 2026-07-26):** + +- `git ls-remote --tags https://github.com/reverb256/secretspec-provider-sops` + still returns no `v0.1.0` ref. +- Direct tarball probe `https://github.com/reverb256/secretspec-provider-sops/archive/refs/tags/v0.1.0.tar.gz` + returns HTTP 302/404 (tag continues to be absent upstream). + +Per the documented fallback path (this file's "Audit 2026-07-23 — +lib.fakeHash SRI upstream precondition check" entry's Recommended +next step), applied in the sibling `/etc/nixos` repository atomically: + +- `rev`: bumped from `"v0.1.0"` to origin/main HEAD SHA + `24e4813bb0d418ab93630e55710615aa32965cd5`. +- `hash`: replaced `lib.fakeHash` with the NAR hash of that SHA's + tarball, computed locally as + `nix-prefetch-url --unpack --type sha256 https://github.com/reverb256/secretspec-provider-sops/archive/24e4813bb0d418ab93630e55710615aa32965cd5.tar.gz` + converted to SRI via `nix hash to-sri`. Final SRI: + `sha256-LdNi3L7jJJWZ3eTIbIzTfFSJSKa4Ant8ZdB7K/qKabI`. +- 12-line inline comment block in + `/etc/nixos/pkgs/secretspec-provider-sops/default.nix` (lines + ~22-33) documents the fallback rationale + the literal migration + recipe once v0.1.0 ships. + +**Verification (post-edit):** `nix-instantiate` against the LIVE +`/etc/nixos/pkgs/secretspec-provider-sops/default.nix` returned +clean; the `fetchFromGitHub` outPath derivation accepts the new +hash against the upstream tarball (a hash mismatch would surface +as a link-fingerprint error during evaluation). + +**Operator-facing actions:** + +- Cluster hosts (`nexus`, `sentry`, `zephyr`, `forge`) need to run + their local NixOS rebuild (`nixos-rebuild switch --flake + /etc/nixos#`) to bring the new + `secretspec-provider-sops` provider binary onto PATH. +- The secretspec CI runner image picks up the new derivation on + its next rebuild; the existing CI gate in + `.github/workflows/ci.yml` exercises the provider binary and will + fail visibly if a future upstream change breaks the SHA-pinned + contract. + +**Cardinality cap:** the fallback SHA + SRI are conditional, not +canonical. When upstream v0.1.0 ships, edit +`/etc/nixos/pkgs/secretspec-provider-sops/default.nix` to set +`rev = "v0.1.0"` and re-run the literal `nix-prefetch-github` +command documented in that file's inline comment; the diff is a +single attribute substitution. This audit entry closes the +operational-unblock phase; a follow-up audit ledger entry will +track the v0.1.0-tag migration when it happens. + +**Cross-references:** +- `/etc/nixos/pkgs/secretspec-provider-sops/default.nix` (the + file modified; sibling repo, not in this git repo). +- `/home/j_kro/Projects/secretspec/knowledge.md` Status snapshot + updated this turn to reflect the resolution. +- `sops-provider-design.md`, `migration-matrix.md`: do not + reference the `lib.fakeHash` placeholder; no edits needed. + +**No code changes this repo this turn** — the literal action was +gated on the upstream `v0.1.0` release tag's publication (not met as +of 2026-07-26). Doc-only ledger entry preserves the verification +trail; the actual code-config change landed in `/etc/nixos/` per the +operational unblock directive. + diff --git a/cachix-pr-body.md b/cachix-pr-body.md new file mode 100644 index 0000000..eccb6d5 --- /dev/null +++ b/cachix-pr-body.md @@ -0,0 +1,116 @@ +# SOPS Provider — PR body draft for cachix/secretspec + +> **Status: drafted locally 2026-07-23. Submission gated on cachix/secretspec#98** ([Secret Provider Protocol v1](https://github.com/cachix/secretspec/pull/98)) **protocol alignment** (currently OPEN, not DRAFT, with 7 comments as of this writing). When #98 lands and stabilizes, refactor `provider-rust/src/secretspec.rs:35` `SopsFileProvider` to match the upstream `Provider` trait shape and open this PR against cachix/secretspec. +> +> **Canonical references:** this repo's `CONTEXT.md` → "Build Intent: SOPS Provider" (rationale + Domen's 7-point accept-criteria) + `sops-provider-design.md` (full architectural doc; credentials-chain shape, format-handling quartet, wallet-key blocklist, FFI deferral). `migration-matrix.md` shows the 49 declared homelab secrets across 9 categories all routing through the SOPS provider chain. + +--- + +## TL;DR + +Adds a `sops://file.` URI scheme to SecretSpec, backed by a stable out-of-tree provider crate (`secretspec-provider-sops`, Apache 2.0). Decryption keys (`age_key`, `aws_secret_access_key`, `hc_vault_token`, etc.) are resolved through SecretSpec's **provider-credentials chain** — never raw-keyed in the URI. We'd supersede cachix/secretspec#58 (euphemism, DRAFT, stalled five+ weeks since Jul 1 awaiting the provider-credentials rework requested on Jul 17). Happy to coordinate. + +## What's in this PR + +- **Crate `secretspec-provider-sops`** (Apache 2.0; repo `github.com/reverb256/secretspec-provider-sops`). +- **Provider implementation** anchored on the v0.15+ `(uri, credentials)` provider-config trait shape — same shape already used by upstream `vault`, `akv`, `bws`. +- **Format-handling quartet**: YAML / JSON / dotenv / binary, with **explicit-or-loud-fail** format inference (no silent defaults; panics on ambiguous filenames are off the table; offer `?f=yaml|json|dotenv|bin` query when needed). +- **Credentials chain** for the six SOPS-supported backends: age recipient, AWS access key/secret, GCP service-account JSON, Azure Key Vault secrets, HashiCorp Vault AppRole tokens, PGP fingerprint. +- **All six SOPS backends** are supported via shell-out to `sops --decrypt` — we source credentials only; SOPS itself does the work. +- **CLI subcommand surface**: `secretspec-provider-sops get [--format yaml|json|dotenv|bin]` (text keys); `secretspec-provider-sops get --format bin` (whole-file binary); `doctor` smoke-tests sops/age binary presence + versions. +- **Audited output**: every secret record passes through SecretSpec's framework redactor; `FieldSpec.sensitive = true` honors masking in logs/audit/URI serialization. +- **Tests**: **36 total** per `cargo test` (lib 16 + integration 8 + cli_smoke 6 + doctest 6; per `knowledge.md` canonical tally). Format quartet covered end-to-end with round-trip against real `sops --decrypt`. End-to-end SOPS bridge CI-verified (`.github/workflows/ci.yml` asserts provider-rust binary decrypts an ephemeral age-encrypted fixture end-to-end). +- **Documentation matrix**: `CONTEXT.md`, `sops-provider-design.md`, `migration-matrix.md`. + +## Why build it ourselves + +- **PR #58 is stalled.** Author `euphemism` is unresponsive since Jul 1; the Jul 17 rework-for-provider-credentials comment is unanswered. The blocker is precisely the architecture Domen signalled — and we're already implementing it. +- **Provider-credentials pattern** is the architecture Domen signalled on Jul 17 ("Needs to be reworked for provider credentials"). We agreed then; we're building it now. +- **50+ real dogfooded secrets** in a homelab that mirrors a typical sops-nix deployment (49 declared across 9 categories: aiServices 7, ci 3, cloud 7, storage 5, kubernetes 4, mining 6, monitoring 5, automation 4, selfHosting 8). Generic test fixtures aren't authority; ours are. + +## What we satisfy for upstream acceptance + +The seven-point checklist from `CONTEXT.md` → "Build Intent: SOPS Provider" → "What we must satisfy for upstream acceptance": + +1. **Provider credentials pattern** — `[providers.sops]` TOML shape: + ```toml + [providers.sops] + uri = "sops://./secrets.yaml" + credentials = { + age_key = "keyring://personal?service=sops-age", + aws_secret_access_key = "onepassword://Homelab/item/aws-deploy", + } + ``` + `credentials.` is resolved recursively through SecretSpec's resolver chain before SOPS invocation. Credentials never appear in the URI, audit log, or error output. + +2. **No credential leakage** — every value declared `sensitive = true` is masked in `Provider::uri()` output, audit logs, and any error message. Pattern mirrors SecretSpec's existing `FieldSpec.sensitive` flag. + +3. **sops CLI invocation (v0)** — shell out to `sops --decrypt` for the entire feature surface; do not reimplement keytree plumbing. FFI is a Phase-2 optimization (per Domen's Jun 4 "maybe a cachix-org repo" hint); not in scope for this PR. + +4. **All SOPS backends** — age / PGP / AWS KMS / GCP KMS / Azure Key Vault / HashiCorp Vault. Credentials are sourced per-provider; SOPS itself handles the backends. + +5. **Format handling** — yaml / json / dotenv / binary, with explicit-or-loud-fail inference: + - **Panic-free.** Never silently defaults to a format. If `?f=` is missing and the file extension is ambiguous (e.g., `.cfg`), emit an explicit error pointing at the right `?f=` query value. + - **Binary mode** returns raw bytes (`Vec`) without UTF-8 coercion. The whole file IS the secret. + - **Schema validation** against SecretSpec's expected `type=` happens **before** return — catches format-vs-type mismatches at `secretspec check` time, not at runtime. + +6. **Audit compliance** — provider impl calls the framework's audit hook before returning; values pass through the framework's redactor; `sensitive` masking prevents log leakage. + +7. **Tests** — unit (per-format round-trip through real `sops --decrypt`); unit (per-backend credential sourcing against a mock SecretSpec chain — no real keyring calls); integration (dogfood against homelab's actual sops files, `.gitignored`). CI runs unit + bridge; homelab tests run locally on-demand. + +## Test plan + +- `cargo test --all-features` — **36 tests total** (lib 16 + integration 8 + cli_smoke 6 + doctest 6; per `knowledge.md` canonical tally); all four formats round-trip through real `sops --decrypt`. +- **End-to-end SOPS bridge** (CI-enforced) — `.github/workflows/ci.yml` step generates an ephemeral age keypair, encrypts a plaintext fixture inline via `sops --encrypt`, then asserts `secretspec-provider-sops get ` returns the expected plaintext for each of 4 representative homelab keys (`nvidia_api_key`, `openai_api_key`, `huggingface_token`, `github_token`). Ephemeral keypair + ciphertext regenerated on every CI run — no test secret material ever lands in git. +- **Manifest-level** — post-#98 alignment, `secretspec check -f secretspec.toml --profile default` + `--profile production` + `--profile development` exit 0 across all 49 declared homelab keys via the SOPS provider chain. + +## Migration path + +User declarations on cachix/secretspec users' side: + +```toml +[providers] +sops = "sops://./secrets.yaml" + +[profiles..defaults] +providers = ["sops", "keyring"] +``` + +Credential chain (declarative; resolves `age_key` to encrypted YAML): + +```toml +[providers.sops.credentials] +age_key = "keyring://personal?service=sops-age" +aws_secret_access_key = "onepassword://Homelab/item/aws-deploy" +# gcp_service_account_json = "keyring://..." +# azure_kv_secret = "keyring://..." +# hc_vault_token = "vault://…?auth=approle" +# pgp_fingerprint = "keyring://personal?service=sops-pgp" +``` + +For homelabs that want to route SOPS-encrypted secrets through Vault rather than the OS keyring, the same `credentials = {...}` shape holds — `hc_vault_token = "vault://…?auth=approle"` style. The provider impl recursively resolves each `credentials.` through SecretSpec's chain before invoking `sops --decrypt`. + +## Open questions + +- **cachix/secretspec#98 alignment.** This PR is gated on #98 ([Secret Provider Protocol v1 draft](https://github.com/cachix/secretspec/pull/98)) stabilizing — currently OPEN, not DRAFT, with 7 comments as of 2026-07-23. Our `SopsFileProvider` scaffold in `provider-rust/src/secretspec.rs:35` awaits the final trait shape to one-line refactor onto it. Happy to wait or coordinate with whoever's driving #98. +- **PR #58 supersede vs co-exist.** Currently #58 (euphemism) is OPEN + DRAFT + 12 review comments. We'd supersede (parallel upstream blocks on the same feature generally doesn't merge cleanly), but happy to coordinate with euphemism if they're still interested. We've independently arrived at the same architecture Domen signalled. +- **FFI vs CLI.** v0 ships CLI shelling. Per Domen's "Could we for starters use sops CLI?" (May 28) comment, that's the expedient path; FFI is a Phase-2 optimization per Domen's Jun 4 hint. Not in this PR. +- **`generate` semantics.** SOPS files are immutable; `generate = {...}` doesn't fit the sops provider layer (it would imply minting keys on first read, conflicting with SOPS's immutability). Punt `generate` to a wrapping provider; the sops provider just signals "not generative" when called. +- **Recent upstream signal: cachix/secretspec#174 (`age` provider).** If that PR lands independently, our credentials-chain still routes `age_key` through it (one provider feeds another); both can co-exist. + +## Releasing cadence + +- **Repo**: `github.com/reverb256/secretspec-provider-sops` (current home; happy to migrate to a cachix-org repo if Domen prefers) +- **Crate**: `cargo publish` on tag push, gated by `crates-io` GitHub Environment for publish-protect + `CARGO_REGISTRY_TOKEN` secret. +- **Version landmarks**: v0.1.0 awaits this PR's acceptance — the crate cannot publish against the upstream `Provider` trait until that trait surface stabilizes. Merge ⇒ tag v0.1.0 ⇒ crates.io publish ⇒ users gain `sops://...` provider support. +- **License**: Apache 2.0. + +## Production DNA + +This crate is in active production on a homelab (4 hosts, NixOS 24.11 unstable): + +- 49 secrets across 9 categories (aiServices, ci, cloud, storage, kubernetes, mining, monitoring, automation, selfHosting). +- Real sops files with age recipients deployed across multiple hosts. +- `cargo test` green at HEAD — 36 tests pass nightly. +- CI gate machine-enforces the same test list (`.github/workflows/ci.yml`). +- End-to-end SOPS bridge step (CI-verified) confirms provider-rust correctly decrypts SOPS-encrypted payloads once they're fetched into a runtime context. diff --git a/docs/spec/provider-protocol.md b/docs/spec/provider-protocol.md new file mode 100644 index 0000000..8b5dc84 --- /dev/null +++ b/docs/spec/provider-protocol.md @@ -0,0 +1,314 @@ +--- +title: Secret Provider Protocol (v1) +description: Wire-format specification for out-of-tree secretspec providers, exchanged as line-delimited JSON over stdio. +--- + +A wire-format specification for out-of-tree secretspec providers. Plugins implementing this protocol are discovered as subprocess binaries on `$PATH` and exchange line-delimited JSON over stdio. The protocol is transport-agnostic at the JSON level; the v1 transport is stdio. + +This document uses the keywords MUST, SHOULD, and MAY per RFC 2119. + +:::caution +This specification is a **draft proposal**. It is not yet implemented in the host. See [issue #64](https://github.com/cachix/secretspec/issues/64) for the design discussion. +::: + +## 1. Discovery + +A provider URI scheme `://...` is bound to a plugin binary named `secretspec-provider-`. The host locates the binary by searching `$PATH` in order; the first match wins. + +The scheme MUST match `^[a-z][a-z0-9_-]*$`. Hyphens in the scheme map literally to hyphens in the binary name (`opproxy://` resolves to `secretspec-provider-opproxy`). + +If no binary is found, the host MUST return an error to the caller distinguishing "plugin not installed" from "plugin returned an error." + +## 2. Invocation model + +The host spawns the plugin once per session. A session corresponds to a single resolution pass (e.g., one `secretspec run`, one `secretspec check`). Within a session the plugin MAY process any number of requests. + +On spawn, the host sets the following environment variables: + +| Variable | Value | +|---|---| +| `SECRETSPEC_PROTOCOL_VERSION` | Highest protocol version the host supports (e.g., `1`) | +| `SECRETSPEC_PROVIDER_URI` | The full URI that selected this plugin | +| `SECRETSPEC_FILE` | Absolute path to the loaded `secretspec.toml`, or unset if none (mirrors `hello.config_file`) | + +No command-line arguments are passed in v1. The host MUST NOT pass secrets via `argv` or environment. + +A session is bound to exactly one provider URI for its entire lifetime. The host MUST NOT reuse a plugin process across different URIs. The plugin therefore MAY parse the URI (and any query parameters, e.g. `opproxy://?cache=12h`) once during `hello` and apply the result to every subsequent request in the session. The URI is sent in the `hello` request and is also available as `SECRETSPEC_PROVIDER_URI`; it is not repeated on later requests. + +The plugin reads newline-delimited JSON requests from stdin and writes newline-delimited JSON responses to stdout. The plugin exits on stdin EOF or after responding to a `bye` request. Stderr is free-form and the host MUST NOT parse it; plugins SHOULD use stderr for diagnostic output only and MUST NOT write secret values to stderr. + +The host closes stdin to signal end-of-session. The plugin SHOULD exit within 5 seconds of stdin EOF; the host MAY send `SIGTERM` and then `SIGKILL` after a grace period. + +## 3. Wire format + +Each request and each response is exactly one JSON object terminated by a single `\n`. Embedded newlines inside JSON strings MUST be escaped per RFC 8259. The encoding is UTF-8. + +The host MUST NOT pipeline: it sends one request, waits for the matching response, then sends the next. Responses MUST appear on stdout in the same order as their requests. + +### 3.1 Transports + +The JSON contract above (handshake, capabilities, operations, errors) is transport-neutral: nothing in a request or response object depends on how the bytes are carried. **The only transport defined in v1 is local stdio** — a subprocess discovered on `$PATH` (§1) and driven over stdin/stdout (§2). A conformant v1 host and v1 plugin speak stdio. + +A v1 plugin reaches a remote system by acting as a **local adapter**: the plugin binary runs on the host, speaks stdio to secretspec, and is itself a network client to whatever lives across the network. This is the supported way to integrate a remote backend today and requires no protocol extension — the `opproxy://` plugin is exactly this shape (local binary, remote `op-proxy`). Because the adapter shares the host's filesystem, `config_file` (§4) and `SECRETSPEC_FILE` resolve normally. + +Carrying the protocol *itself* over a network transport (a socket or HTTP endpoint with no local process) is **out of scope for v1**. It would require, beyond the wire schema: a discovery mechanism that resolves a scheme to an endpoint rather than a `$PATH` binary, a framing definition for the chosen transport, a connection lifecycle replacing spawn/stdin-EOF, transport authentication and confidentiality (TLS) absent from the §8 trust model, and a substitute for `config_file` since a remote endpoint cannot read the host's filesystem path (the host would send config *contents* instead). These are deferred to a later version (see [Non-goals](#10-non-goals-v1)). + +## 4. Handshake + +The first request in every session is `hello`. The plugin MUST NOT process any other request before responding to `hello`. + +**Request:** + +```json +{ + "op": "hello", + "protocol_version": 1, + "uri": "opproxy://vault/Production?reason=build", + "config_file": "/abs/path/to/secretspec.toml", + "context": { "reason": "building api image" } +} +``` + +`config_file` is the absolute path to the `secretspec.toml` the host loaded for this session, or `null` if the host resolved secrets without an on-disk config (e.g. a purely programmatic SDK caller). It is a first-class field rather than hidden env coupling so that plugins which derive backend references from the committed config can read it deterministically. The same value is also exported as `SECRETSPEC_FILE` for plugins that prefer to read it from the environment. A plugin that does not need the config file MUST ignore this field. + +**Successful response:** + +```json +{ + "ok": true, + "protocol_version": 1, + "name": "opproxy", + "capabilities": ["get", "set", "batch_get", "reflect"] +} +``` + +The plugin's `protocol_version` MUST be less than or equal to the host's. If the plugin cannot support the host's version, it responds with an error of kind `unsupported_version` and exits. + +The plugin advertises its supported operations in `capabilities`. The host MUST NOT send an operation absent from this list. `get` is mandatory; all others are optional. + +### 4.1 Plugin-owned extension tables + +Table names in `secretspec.toml` prefixed with `x-` are reserved for tool and provider extensions. The host MUST NOT interpret `x-*` tables and MUST preserve them unmodified when it rewrites the config. Plugins MAY read `x-*` tables from `config_file` to obtain per-key backend references and per-key overrides that have no representation in the core schema. A plugin SHOULD namespace its table after its own scheme, e.g. a plugin discovered as `secretspec-provider-opproxy` reads `[x-op-proxy.refs]`: + +```toml +[x-op-proxy.refs] +APP_TOKEN = "external-secret-ref-for-app-token" +BOT_TOKEN = { ref = "external-secret-ref-for-bot-token", reason = "bot-runtime", cache = "12h" } +``` + +Reading extension metadata from `config_file` is the v1 mechanism for plugin-specific per-key configuration. The host does not parse or forward these tables on individual `get` / `batch_get` requests; the plugin owns and validates its own table shape. + +## 5. Operations + +All requests carry `"op": ""`. All successful responses carry `"ok": true`; failures carry `"ok": false` and an `error` object (see section 6). + +### 5.1 get + +Retrieves a single secret. + +**Request:** + +```json +{ "op": "get", "project": "myapp", "key": "DATABASE_URL", "profile": "production" } +``` + +**Response (hit):** + +```json +{ "ok": true, "value": "postgres://..." } +``` + +**Response (miss):** + +```json +{ "ok": true, "value": null } +``` + +A miss is distinct from an error. Missing secrets MUST return `value: null`, not an error. + +### 5.2 set + +Stores a single secret. Only callable if the plugin advertised the `set` capability. + +**Request:** + +```json +{ "op": "set", "project": "myapp", "key": "DATABASE_URL", "value": "postgres://...", "profile": "production" } +``` + +**Response:** + +```json +{ "ok": true } +``` + +### 5.3 batch_get + +Retrieves multiple secrets in a single round trip. Only callable if the plugin advertised the `batch_get` capability. The host SHOULD use `batch_get` over repeated `get` calls when fetching more than one secret. + +**Request:** + +```json +{ "op": "batch_get", "project": "myapp", "profile": "production", "keys": ["DB_URL", "API_KEY"] } +``` + +**Response:** + +```json +{ "ok": true, "values": { "DB_URL": "postgres://...", "API_KEY": null } } +``` + +Missing keys MUST appear in `values` with a `null` value. Partial failure (some keys retrievable, some not) is reported as a top-level error only if no values could be fetched at all; otherwise individual misses use `null`. + +### 5.4 reflect + +Discovers all secrets the plugin knows about for a project. Only callable if the plugin advertised the `reflect` capability. Used by `secretspec import`. + +**Request:** + +```json +{ "op": "reflect", "project": "myapp" } +``` + +**Response:** + +```json +{ + "ok": true, + "secrets": { + "DATABASE_URL": { "description": "Postgres connection string", "required": true }, + "REDIS_URL": { "description": "Redis URL", "required": false, "default": "redis://localhost:6379" } + } +} +``` + +The shape of each secret entry mirrors `secretspec.toml`'s secret definition. Unknown fields MUST be ignored by the host so plugins can include richer metadata over time. + +### 5.5 bye (optional) + +Signals graceful shutdown. The host MAY send `bye` before closing stdin; plugins SHOULD respond and exit. + +**Request:** + +```json +{ "op": "bye" } +``` + +**Response:** + +```json +{ "ok": true } +``` + +## 6. Errors + +Failed responses carry a structured error: + +```json +{ "ok": false, "error": { "kind": "auth_failed", "message": "1Password CLI not signed in" } } +``` + +The following `kind` values are defined in v1: + +| Kind | Meaning | +|---|---| +| `not_found` | Resource (e.g., project, profile) does not exist. NOT used for missing secret values; use `value: null` instead. | +| `auth_failed` | Plugin could not authenticate to its backend. | +| `permission_denied` | Authenticated but not authorized for this secret. | +| `rate_limited` | Backend throttled the request. The host MAY retry. | +| `unsupported` | Operation not supported (should not occur if capabilities are honored). | +| `unsupported_version` | Plugin cannot speak the requested protocol version. | +| `invalid_request` | Request was malformed. | +| `internal` | Plugin internal error. | + +Unknown `kind` values MUST be treated as `internal` by the host. Plugins MAY include additional fields on the error object for diagnostic purposes; the host MUST ignore unknown fields. + +## 7. Context + +The `context` map in `hello` carries per-session metadata supplied by the caller. Because a session corresponds to one resolution pass, the context established at `hello` applies to every operation in that session. It is not specific to `run`: the same context flows to the backend reads behind `secretspec check`, `secretspec get`, `batch_get`, and `secretspec import`. There is no per-request context override in v1. + +The host populates it from: + +* CLI flag: `secretspec run --context key=value ...` (and the equivalent flag on `check`, `get`, `import`) +* SDK builder: `SecretSpec::builder().context("key", "value").load()?` +* Environment: `SECRETSPEC_CONTEXT_=value` (uppercase mapping) + +Plugins decide what context they require. The `opproxy` plugin, for example, requires `context.reason` for any backend read — including `check` and `batch_get`, not just `run`. If a required context value is missing, the plugin SHOULD fail the `hello` response with an `invalid_request` error explaining what is missing, so the host can surface a clear message to the user. + +To keep non-interactive commands usable against providers that require an audit reason, the host SHOULD synthesize a fallback `context.reason` when the caller supplies none, derived from the command and project — for example `secretspec::run`, `secretspec::check`, or `secretspec::` for a single `get`. A caller-supplied `reason` always takes precedence over the synthesized one. Plugins remain free to reject a session whose `reason` does not meet their policy. + +Context values are strings. Nested objects are NOT supported in v1. + +## 8. Security + +* Plugins inherit the user's privileges. The protocol provides no sandbox. Users SHOULD only install plugins from trusted sources. +* Secret values pass through the plugin's process memory and stdout pipe. The host pipe is not visible to other users on the system; plugin authors are responsible for not logging values. +* The host MUST NOT pass secrets via `argv` or environment to the plugin process. Secrets travel only on the JSON stdio channel. +* Plugins MUST NOT write secret values to stderr. +* The host MAY surface plugin stderr and the `error.message` of a failed response to the user (for diagnostics) and MAY include them in logs. Plugins MUST therefore treat both their stderr and their `error.message` strings as non-secret and MUST NOT embed secret values in either. This matters most once `set` exists, where the request itself carries a value. +* The host SHOULD avoid including request or response values (anything from `value`, `values`, or a `set` request body) in debug logging, even when logging is otherwise verbose. +* The plugin binary's path resolution is governed by `$PATH`. Users SHOULD audit their `$PATH` to ensure no untrusted directory precedes trusted ones. + +## 9. Versioning + +`protocol_version` is a monotonically increasing integer. The current version is `1`. + +Backward-compatible changes (new optional fields, new error kinds, new capabilities) do NOT increment the version. Backward-incompatible changes (renamed fields, changed semantics) MUST increment it. + +The host MUST tolerate unknown fields in plugin responses. The plugin MUST tolerate unknown fields in host requests. This is what allows additive evolution without bumping the version. + +## 10. Non-goals (v1) + +The following are intentionally out of scope and may be addressed in later versions: + +* Streaming responses for lease refresh (see [issue #11](https://github.com/cachix/secretspec/issues/11)). A `subscribe` operation that yields multiple responses for one request is a natural v2 addition. +* Host-initiated push (server-sent updates). +* A network transport for the protocol itself (socket/HTTP endpoint with no local process), including its discovery, framing, and authentication. Remote backends are reached via a local adapter plugin in v1 (see [§3.1](#31-transports)). +* Binary encodings (CBOR, msgpack). JSON is the wire format for v1. +* Sandboxed execution. A future WASM-based transport may share this same JSON schema. +* gRPC adapter. The JSON schema is transport-agnostic and a gRPC facade may be added later if needed; it is not part of this specification. + +## 11. Reference example + +A minimal `secretspec-provider-echo` plugin in shell, useful for testing: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +while IFS= read -r line; do + op=$(echo "$line" | jq -r .op) + case "$op" in + hello) printf '%s\n' '{"ok":true,"protocol_version":1,"name":"echo","capabilities":["get"]}' ;; + get) key=$(echo "$line" | jq -r .key) + printf '%s\n' "{\"ok\":true,\"value\":\"echoed:$key\"}" ;; + bye) printf '%s\n' '{"ok":true}'; exit 0 ;; + *) printf '%s\n' '{"ok":false,"error":{"kind":"unsupported","message":"unknown op"}}' ;; + esac +done +``` + +Drop into `$PATH`, then `secretspec run --provider echo:// -- env | grep echoed`. + +A production-quality reference plugin in Rust will live under `examples/provider-plugin/` in the secretspec repository. + +## 12. Conformance + +A conformant plugin: + +1. Reads line-delimited JSON from stdin until EOF. +2. Responds to `hello` first, advertising a `protocol_version` `<= 1` and the capabilities it implements. +3. Never sends a response without a preceding request. +4. Never writes secret values to stderr. +5. Exits within 5 seconds of stdin EOF. +6. Reports missing secret values as `value: null`, not as errors. + +A conformant host: + +1. Resolves `://...` to `secretspec-provider-` on `$PATH`. +2. Sends `hello` first and validates the response before any other request. +3. Only invokes operations the plugin advertised. +4. Treats unknown response fields as forward-compatibility hooks (ignores them). +5. Distinguishes "plugin not installed" from "plugin error" in user-facing messages. +6. Provides `config_file` (and `SECRETSPEC_FILE`) and binds one session to one provider URI. +7. Synthesizes a fallback `context.reason` for non-interactive commands when the caller supplies none. diff --git a/docs/spec/schema.json b/docs/spec/schema.json new file mode 100644 index 0000000..1fbded9 --- /dev/null +++ b/docs/spec/schema.json @@ -0,0 +1,259 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://secretspec.dev/schema/provider-protocol-v1.json", + "title": "Secret Provider Protocol v1", + "description": "Wire-format specification for out-of-tree secretspec providers, exchanged as line-delimited JSON over stdio. Derived verbatim from cachix/secretspec#98 (protocol-v1.md). The v1 transport is local stdio (subprocess discovered on $PATH per spec §1); a network transport is explicitly out of scope for v1 per §3.1.", + "$comment": "v1.1+ additive-evolution principle (per spec §9): new optional fields, new error kinds, and new capability strings do NOT bump the protocol version. Hosts MUST tolerate unknown fields in plugin responses; plugins MUST tolerate unknown fields in host requests. Each per-shape schema below has additionalProperties:true so unknown fields propagate without breaking deserializers. Capabilities.items is widened to accept any non-empty string (custom capability strings) while keeping the 5 standardized ops as the recommended core set.", + "$defs": { + "errorKind": { + "type": "string", + "enum": [ + "not_found", + "auth_failed", + "permission_denied", + "rate_limited", + "unsupported", + "unsupported_version", + "invalid_request", + "internal" + ], + "$comment": "Closed set per spec §6. Unknown kind values MUST be treated as 'internal' by the host. Note: 'not_found' is for non-value resources (project, profile); missing secret VALUES use `value: null` not `error.not_found` (per spec §5.1 and 5.5)." + }, + "errorEnvelope": { + "type": "object", + "required": ["ok", "error"], + "properties": { + "ok": { "const": false }, + "error": { + "type": "object", + "required": ["kind", "message"], + "properties": { + "kind": { "$ref": "#/$defs/errorKind" }, + "message": { "type": "string" } + }, + "additionalProperties": true, + "$comment": "Plugins MAY add additional fields on the error object for diagnostic purposes; the host MUST ignore unknown fields per spec §6." + } + }, + "additionalProperties": true, + "$comment": "Unified error envelope per spec §6. Carries ok:false plus a single error object. All operations that fail emit this shape on the wire." + }, + "capabilities": { + "type": "array", + "uniqueItems": true, + "contains": { "const": "get" }, + "items": { + "type": "string", + "minLength": 1, + "anyOf": [ + { "enum": ["get", "set", "batch_get", "reflect", "bye"] }, + { "pattern": "^[a-z][a-z0-9_:.\\-]*$" } + ] + }, + "$comment": "get is mandatory per spec §4; all others are optional. items is widened from a strict enum to `anyOf: [standard-op, custom-op-paTTern]` so v1.1+ plugins MAY advertise custom capability strings (per spec §9's additive-evolution principle). The 5 standardized ops are still enumerated as the recommended/core capability set; custom strings must match `^[a-z][a-z0-9_:.\\-]*$` (same shape as URI schemes per spec §1). Plugins emit custom capabilities; hosts MUST forward them as-is in v1+." + }, + "context": { + "type": "object", + "additionalProperties": { "type": "string" }, + "$comment": "Context values are strings per spec §7. Nested objects are NOT supported in v1. Source: --context flag, SDK builder, or SECRETSPEC_CONTEXT_ env." + }, + "secretDefinition": { + "type": "object", + "properties": { + "description": { "type": "string" }, + "required": { "type": "boolean" }, + "default": {} + }, + "additionalProperties": true, + "$comment": "Mirrors secretspec.toml secret definition per spec §5.4. Unknown fields MUST be ignored by the host; plugins can ship richer metadata over time." + }, + "helloRequest": { + "type": "object", + "required": ["op", "protocol_version", "uri", "config_file"], + "properties": { + "op": { "const": "hello" }, + "protocol_version": { + "type": "integer", + "minimum": 1, + "$comment": "Monotonically increasing integer per spec §9. The host sends its highest supported; the plugin responds with <= that." + }, + "uri": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*://.*", + "$comment": "Provider URI scheme bound to plugin binary secretspec-provider- per spec §1." + }, + "config_file": { + "type": ["string", "null"], + "$comment": "Absolute path to secretspec.toml, or null when host resolved secrets without an on-disk config (purely programmatic SDK caller) per spec §4." + }, + "context": { "$ref": "#/$defs/context" } + }, + "additionalProperties": true + }, + "helloResponse": { + "type": "object", + "required": ["ok", "protocol_version", "capabilities"], + "properties": { + "ok": { "const": true }, + "protocol_version": { + "type": "integer", + "minimum": 1, + "maximum": 1, + "$comment": "For v1 conformance, MUST be 1. Backward-compatible changes do not bump the version per spec §9." + }, + "name": { + "type": "string", + "$comment": "For debugging/telemetry per spec §4. NOT mandated for conformance." + }, + "version": { + "type": "string", + "$comment": "Per spec §4 prose, both `name` AND `version` are NOT mandated for protocol compliance. Optional; absent in the simplest v1 example but available for plugins that want to advertise their build identifier. Forward-compat per spec §9: additive optional fields never bump the protocol version." + }, + "capabilities": { "$ref": "#/$defs/capabilities" } + }, + "additionalProperties": true + }, + "getRequest": { + "type": "object", + "required": ["op", "project", "key", "profile"], + "properties": { + "op": { "const": "get" }, + "project": { "type": "string" }, + "key": { "type": "string" }, + "profile": { "type": "string" } + }, + "additionalProperties": true, + "$comment": "`profile` is mandatory per spec §5.1 example shape — the spec example is `{ op: get, project, key, profile }` with all four fields present. v1 hosts MUST send it; the schema treats it as required so undocumented no-profile calls would be rejected at validation time." + }, + "getResponse": { + "type": "object", + "required": ["ok", "value"], + "properties": { + "ok": { "const": true }, + "value": { + "type": ["string", "null"], + "$comment": "Hit = string. Miss = null. Miss MUST be reported as value:null (not error.not_found) per spec §5.1." + } + }, + "additionalProperties": true + }, + "setRequest": { + "type": "object", + "required": ["op", "project", "key", "value", "profile"], + "properties": { + "op": { "const": "set" }, + "project": { "type": "string" }, + "key": { "type": "string" }, + "value": { + "type": "string", + "$comment": "Spec examples show string values. Tightly typed as string for v1; widen to JSON-compatible when needed." + }, + "profile": { "type": "string" } + }, + "additionalProperties": true, + "$comment": "Only callable if the plugin advertised the `set` capability per spec §5.2. `profile` mandatory per the spec example shape." + }, + "setResponse": { + "type": "object", + "required": ["ok"], + "properties": { + "ok": { "const": true } + }, + "additionalProperties": true + }, + "batchGetRequest": { + "type": "object", + "required": ["op", "project", "profile", "keys"], + "properties": { + "op": { "const": "batch_get" }, + "project": { "type": "string" }, + "profile": { "type": "string" }, + "keys": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "$comment": "Empty array not meaningful; one or more keys required." + } + }, + "additionalProperties": true, + "$comment": "Only callable if the plugin advertised the `batch_get` capability per spec §5.3. `profile` mandatory per the spec example shape (`{ op, project, profile, keys }`). Spec ordering: project, profile, keys." + }, + "batchGetResponse": { + "type": "object", + "required": ["ok", "values"], + "properties": { + "ok": { "const": true }, + "values": { + "type": "object", + "additionalProperties": { + "type": ["string", "null"], + "$comment": "Each key resolves to a value (hit) or null (miss). Partial failure surfaces per-key null; only report a top-level error if NO values could be fetched at all per spec §5.3." + } + } + }, + "additionalProperties": true + }, + "reflectRequest": { + "type": "object", + "required": ["op", "project"], + "properties": { + "op": { "const": "reflect" }, + "project": { "type": "string" } + }, + "additionalProperties": true, + "$comment": "Only callable if the plugin advertised the `reflect` capability per spec §5.4. Used by secretspec import. Asymmetric with get/set/batch_get: NO `profile` field on reflect per spec §5.4 example." + }, + "reflectResponse": { + "type": "object", + "required": ["ok", "secrets"], + "properties": { + "ok": { "const": true }, + "secrets": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/secretDefinition" } + } + }, + "additionalProperties": true + }, + "byeRequest": { + "type": "object", + "required": ["op"], + "properties": { + "op": { "const": "bye" } + }, + "additionalProperties": true, + "$comment": "Optional per spec §5.5. Signals graceful shutdown; host MAY send bye before closing stdin." + }, + "byeResponse": { + "type": "object", + "required": ["ok"], + "properties": { + "ok": { "const": true } + }, + "additionalProperties": true + }, + "request": { + "oneOf": [ + { "$ref": "#/$defs/helloRequest" }, + { "$ref": "#/$defs/getRequest" }, + { "$ref": "#/$defs/setRequest" }, + { "$ref": "#/$defs/batchGetRequest" }, + { "$ref": "#/$defs/reflectRequest" }, + { "$ref": "#/$defs/byeRequest" } + ], + "$comment": "Polymorphic request envelope. Discriminator is the op field; each variant adds its own required fields. Top-level validator picks one variant per request." + }, + "response": { + "oneOf": [ + { "$ref": "#/$defs/helloResponse" }, + { "$ref": "#/$defs/getResponse" }, + { "$ref": "#/$defs/setResponse" }, + { "$ref": "#/$defs/batchGetResponse" }, + { "$ref": "#/$defs/reflectResponse" }, + { "$ref": "#/$defs/byeResponse" }, + { "$ref": "#/$defs/errorEnvelope" } + ], + "$comment": "Polymorphic response envelope. Successful responses carry ok:true with operation-specific body; failures carry ok:false + uniform errorEnvelope per spec §6." + } + } +} diff --git a/fixtures/test-secrets.yaml b/fixtures/test-secrets.yaml new file mode 100644 index 0000000..7359625 --- /dev/null +++ b/fixtures/test-secrets.yaml @@ -0,0 +1,16 @@ +# provider-rust end-to-end SOPS bridge — plaintext test fixture. +# +# This file is plaintext. CI step .github/workflows/ci.yml → +# "provider-rust end-to-end SOPS bridge (test fixture decrypt)" generates +# an ephemeral age keypair in /tmp, copies this file to the bridge test +# directory, encrypts it inline via `sops --encrypt --age `, then +# asserts that `secretspec-provider-sops get ` returns +# the expected plaintext value for each key. +# +# Values below are demo strings — they are NOT real secrets. If you change +# a value here, also update the expected array in the CI step's run block. + +nvidia_api_key: nvapi-bridge-known +openai_api_key: sk-bridge-known +huggingface_token: hf-bridge-known +github_token: ghp-bridge-known diff --git a/knowledge.md b/knowledge.md index 055521a..d658a23 100644 --- a/knowledge.md +++ b/knowledge.md @@ -22,6 +22,7 @@ This is a **research and planning workspace** for migrating from `sops-nix` to ` - ✅ **closed this turn** Phase 3 / Phase 2.5 Provider scaffold in `provider-rust/src/secretspec.rs` (`SopsFileProvider` — closes Phase 3 scaffold surface; awaits cachix/secretspec#98 alignment for closure) - ✅ **closed this turn** Phase 4 (NixOS runtime) via `scripts/phase4-deploy-example.sh` (secretspec run + systemd-creds + LoadCredentialEncrypted=, or k8s secret push). cachix/secretspec#65 + #41 remain OPEN as additive improvements — not blockers. - 36 tests passing (lib 16 + integration 8 + cli_smoke 6 + doctest 6) per `cargo test`; CI gate machine-enforces the same pass-list +- ✅ **resolved** `lib.fakeHash` placeholder unblocked via documented fallback — `/etc/nixos/pkgs/secretspec-provider-sops/default.nix` now pins `rev = "24e4813bb0d418ab93630e55710615aa32965cd5"` (origin/main HEAD SHA as of 2026-07-26) + `hash = "sha256-LdNi3L7jJJWZ3eTIbIzTfFSJSKa4Ant8ZdB7K/qKabI"`; `nix-instantiate` validates against the upstream tarball. Sibling /etc/nixos commit landed; cluster hosts (nexus/sentry/zephyr/forge) pick it up on next `nixos-rebuild switch`. Audit 2026-07-26 ledger entry in `CONTEXT.md` is the canonical record. ## Architecture diff --git a/provider-rust/Cargo.toml b/provider-rust/Cargo.toml index 8f93103..8702517 100644 --- a/provider-rust/Cargo.toml +++ b/provider-rust/Cargo.toml @@ -11,7 +11,7 @@ publish = true [dependencies] # Async runtime — needed for `tokio::process::Command` + `#[tokio::main]` -tokio = { version = "1.37", features = ["rt-multi-thread", "process", "macros"] } +tokio = { version = "1.37", features = ["rt-multi-thread", "process", "macros", "io-util", "io-std"] } # Serialization for YAML / JSON parsing of decrypted SOPS output serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -19,16 +19,31 @@ serde_json = "1" # here for Phase 1 because the surface (YAML read/write of decrypted SOPS # output) is small and stable. Phase 2 should migrate to `serde_yml`. serde_yaml = "0.9" -# CLI parsing -clap = { version = "4.5", features = ["derive"] } +# CLI surface — std::env::args → clap derive for get/doctor/--help subcommands +clap = { version = "4", features = ["derive"] } # Error handling thiserror = "1" anyhow = "1" # Audit logging — the framework hooks here in Phase 3, for now it's free tracing = "0.1" +# Two bin targets in one crate: +# - `secretspec-provider-sops` (this CLI tool — clap get/doctor/--help) +# is the user-facing binary; see src/cli.rs. +# - `secretspec-provider-sops-protocol` (long-running NDJSON provider) +# is the cachix/secretspec#98 wire-protocol dispatcher; see src/main.rs. +# +# The two-bins split keeps cli_smoke's expectation (`get`/`doctor`/`--help` +# subcommands on the user-facing binary) AND protocol_integration's +# expectation (NDJSON-in/out on the protocol binary) co-existing without +# mode-detection logic in main.rs. + [[bin]] name = "secretspec-provider-sops" +path = "src/cli.rs" + +[[bin]] +name = "secretspec-provider-sops-protocol" path = "src/main.rs" [lib] diff --git a/provider-rust/src/cli.rs b/provider-rust/src/cli.rs new file mode 100644 index 0000000..7ee6eb9 --- /dev/null +++ b/provider-rust/src/cli.rs @@ -0,0 +1,202 @@ +//! `secretspec-provider-sops` CLI surface. +//! +//! Subcommands: +//! - `get [--format yaml|json|dotenv|bin]` — one-shot decrypt, +//! prints the resolved value to stdout. Exit 0 on hit; exit 1 on miss +//! with the error on stderr. +//! - `doctor` — report local env (sops/age binary versions, keyfile path) +//! as a JSON document on stdout. +//! - `--help` / `--version` — clap auto-generated docs printed to stdout, +//! exit 0. +//! +//! This is the user-facing binary; the long-running NDJSON protocol +//! dispatcher lives in a separate bin target `secretspec-provider-sops-protocol` +//! (see [`src/main.rs`]). + +use clap::{Parser, Subcommand}; +use secretspec_provider_sops::{SopsProvider, SopsUri}; +use std::process::ExitCode; +use std::str::FromStr; + +#[derive(Parser, Debug)] +#[command( + name = "secretspec-provider-sops", + version, + about = "SOPS provider for SecretSpec — one-shot decryption + doctor diagnostic", + long_about = None, + // Unix convention: no subcommand → print help + exit 2. clap handles it. + arg_required_else_help = true, +)] +struct Cli { + #[command(subcommand)] + subcommand: Subcmd, +} + +#[derive(Subcommand, Debug)] +enum Subcmd { + /// Decrypt and print a single value from a SOPS-encrypted file + Get { + /// Path to the SOPS-encrypted file. Plain path (`./secrets.yaml`) + /// or full `sops://./secrets.yaml` URI both accepted. + file: String, + /// Secret key (or dot path) to extract from the file. + key: String, + /// Format hint: `yaml` | `json` | `dotenv` | `bin`. + /// If absent, inferred from the file extension. + #[arg(short, long, value_name = "FMT")] + format: Option, + }, + /// Report local env (sops/age binary versions, keyfile path) as JSON. + Doctor, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + let cli = Cli::parse(); + match cli.subcommand { + Subcmd::Get { file, key, format } => run_get(&file, &key, format.as_deref()).await, + Subcmd::Doctor => run_doctor().await, + } +} + +/// Resolve `argv.file` as either a bare path or a `sops://…` URI and +/// dispatch to [`SopsProvider::get_bytes`]. Errors → stderr + exit 1; +/// success → stdout bytes + exit 0. +/// +/// `get_bytes` (not `get`) so `?f=bin` works: bin-mode returns the raw +/// decrypted plaintext bytes via `extract_bin` (no UTF-8 coercion). +/// For text formats (yaml/json/dotenv), `get_bytes` returns the same +/// content as `get(...).into_bytes()` — symmetric with the bin path. +async fn run_get(file_arg: &str, key: &str, format_hint: Option<&str>) -> ExitCode { + // Compute (effective_file, effective_fmt) once: URI-parsed if + // `file_arg` is a `sops://…` URI, else treated as a bare path. + // Both arms produce `(String, Option)` so the joined tuple + // type is uniform; the `--format` flag takes precedence over a + // `?f=` query param (we OR the flag's owned copy in last). + let (file_path, fmt): (String, Option) = match SopsUri::from_str(file_arg) { + Ok(u) => (u.file, format_hint.map(String::from).or(u.format)), + Err(_) => (file_arg.to_string(), format_hint.map(String::from)), + }; + match SopsProvider::new() + .get_bytes(&file_path, key, fmt.as_deref()) + .await + { + Ok(bytes) => { + use tokio::io::AsyncWriteExt; + let mut stdout = tokio::io::stdout(); + // Best-effort flush; ignore broken-pipe on shell-piped consumers. + if stdout.write_all(&bytes).await.is_err() { + return ExitCode::from(0); + } + let _ = stdout.flush().await; + ExitCode::from(0) + } + Err(e) => run_get_err(e), + } +} + +fn run_get_err(e: secretspec_provider_sops::SopsError) -> ExitCode { + // Per cli_smoke test expectation: missing-key errors MUST mention + // the key name in stderr. SopsError::KeyNotFound carries that info + // already; we just prepend `error: ` for shell scripting clarity. + eprintln!("error: {e}"); + ExitCode::from(1) +} + +async fn run_doctor() -> ExitCode { + let provider = SopsProvider::new(); + let report = provider.doctor().await; + match serde_json::to_string_pretty(&report) { + Ok(s) => { + println!("{s}"); + ExitCode::from(0) + } + Err(e) => { + eprintln!("error: doctor report serialization failed: {e}"); + ExitCode::from(1) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::error::ErrorKind; + + #[test] + fn parse_cli_get() { + let cli = Cli::try_parse_from([ + "secretspec-provider-sops", + "get", + "secrets.yaml", + "nvidia_api_key", + "--format", + "yaml", + ]) + .expect("parse"); + match cli.subcommand { + Subcmd::Get { + ref file, + ref key, + ref format, + } => { + assert_eq!(file, "secrets.yaml"); + assert_eq!(key, "nvidia_api_key"); + assert_eq!(format.as_deref(), Some("yaml")); + } + // Exhaustiveness arm — needed once `Subcmd::Doctor` was added; + // without this, `cargo check --all-targets` reports E0004. + Subcmd::Doctor => panic!("expected Get, got Doctor"), + } + } + + #[test] + fn parse_cli_get_no_format() { + let cli = Cli::try_parse_from([ + "secretspec-provider-sops", + "get", + "secrets.env", + "N8N_API_KEY", + ]) + .expect("parse"); + match cli.subcommand { + Subcmd::Get { file, key, format } => { + assert_eq!(file, "secrets.env"); + assert_eq!(key, "N8N_API_KEY"); + assert!(format.is_none()); + } + // Exhaustiveness arm — see parse_cli_get for rationale. + Subcmd::Doctor => panic!("expected Get, got Doctor"), + } + } + + #[test] + fn parse_cli_doctor() { + let cli = Cli::try_parse_from(["secretspec-provider-sops", "doctor"]).expect("parse"); + assert!(matches!(cli.subcommand, Subcmd::Doctor)); + } + + #[test] + fn parse_cli_no_subcommand_yields_help_error() { + // With `arg_required_else_help = true`, no subcommand → clap 4.x + // emits DisplayHelpOnMissingArgumentOrSubcommand (printed to stderr + // by clap's default exit handler, then exits 2). try_parse_from + // returns the error so we can assert the kind without consuming the exit. + let res = Cli::try_parse_from(["secretspec-provider-sops"]); + assert!(res.is_err()); + assert_eq!( + res.unwrap_err().kind(), + ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + ); + } + + #[test] + fn parse_cli_help_flag_short_circuits_before_subcommand() { + // `--help` short-circuits regardless of subcommand; clap emits + // DisplayHelp (not DisplayHelpOnMissingArgumentOrSubcommand) + // because the user explicitly requested help. + let res = Cli::try_parse_from(["secretspec-provider-sops", "--help"]); + assert!(res.is_err()); + assert_eq!(res.unwrap_err().kind(), ErrorKind::DisplayHelp); + } +} diff --git a/provider-rust/src/credentials.rs b/provider-rust/src/credentials.rs new file mode 100644 index 0000000..9dd3653 --- /dev/null +++ b/provider-rust/src/credentials.rs @@ -0,0 +1,297 @@ +//! Provider credentials chain — implements Domen Kozar's accept-criterion +//! #1 ("provider credentials pattern"). +//! +//! The chain is a list of [`FieldSpec`]s (URI-shaped credential sources). +//! The SecretSpec framework supplies the *resolver*: a function that +//! takes a credential URI string and returns the resolved plaintext. +//! We orchestrate resolution + propagation into the `sops` subprocess's +//! environment as the `SOPS_*` env vars `sops` already understands +//! (`SOPS_AGE_KEY_FILE`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, +//! `GOOGLE_CREDENTIALS`, `AZURE_TENANT_ID`/`AZURE_CLIENT_ID`/…, +//! `HC_VAULT_ADDR`/`HC_VAULT_TOKEN`). +//! +//! Sensitive entries are NEVER logged: `Display` / `to_string` mask +//! them as `***` (see [`crate::uri::FieldSpec::Display`]). The chain +//! returns resolved structs whose `value` field is gated behind a +//! non-`Debug` accessor so callers can't accidentally log the secret. +//! +//! ## Decoupling rationale +//! +//! We intentionally don't depend on `secretspec` directly. The +//! framework will eventually host our resolver as a generic provider +//! (per cachix/secretspec#98's Secret Provider Protocol v1 spec). +//! Until then, we accept a boxed resolver callback so the upstream +//! lib can be wired in transparently when the protocol stabilizes +//! (single-line refactor of `CredentialsChain::resolve`). + +use crate::uri::FieldSpec; + +/// One resolved credential entry. Holds the plaintext value; the +/// `Debug` impl deliberately emits `***` for sensitive entries so a +/// panicked `dbg!(value)` call doesn't leak the secret into logs. +#[derive(Clone)] +pub struct CredentialValue { + pub name: String, + pub value: String, + pub sensitive: bool, + pub source_uri: String, +} + +impl CredentialValue { + /// Raw plaintext accessor — only callers who are about to plumb + /// the value into `sops`'s environment as a `SOPS_*` env var + /// should call this. Caller MUST NOT log the result. + pub fn value_plaintext(&self) -> &str { + &self.value + } +} + +impl std::fmt::Debug for CredentialValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value_dbg = if self.sensitive { + "***".to_string() + } else { + self.value.clone() + }; + // URIs are always safe to log (they're public per the design doc + // — servers don't keep the actual key in the URI anyway). + f.debug_struct("CredentialValue") + .field("name", &self.name) + .field("value", &value_dbg) + .field("sensitive", &self.sensitive) + .field("source_uri", &self.source_uri) + .finish() + } +} + +impl std::fmt::Display for CredentialValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}=***", self.name) + } +} + +/// Errors raised while resolving credentials. Urls are public; +/// `cause` should be a category-level cause (e.g. "timeout", "not +/// found"), not a credential-bearing diagnostic. +#[derive(Debug, Clone)] +pub enum CredentialsError { + /// One or more field URIs couldn't be resolved. + ResolveFailed { + name: String, + source_uri: String, + cause: String, + }, + /// Builder validation: at least one (name, URI) pair is required. + EmptyChain, +} + +impl std::fmt::Display for CredentialsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CredentialsError::ResolveFailed { + name, + source_uri, + cause, + } => { + write!( + f, + "credential `{name}` (uri=`{source_uri}`) failed: {cause}" + ) + } + CredentialsError::EmptyChain => write!(f, "credentials chain is empty"), + } + } +} + +impl std::error::Error for CredentialsError {} + +/// Resolves a credential URI to its plaintext value. Framework supplies +/// this at runtime; in the upstream cachix/secretspec integration it +/// will be the SecretSpec `vault` / `keyring` / `onepassword` / etc. +/// provider's resolver. +pub type ResolverFn = std::sync::Arc Result + Send + Sync>; + +/// A credentials chain: an ordered list of [`FieldSpec`]s with a +/// resolver. Resolves all fields, returning a [`Vec`]; +/// partial-resolution failures are reported via [`CredentialsError`]. +#[derive(Clone, Default)] +pub struct CredentialsChain { + fields: Vec, +} + +impl CredentialsChain { + pub fn new() -> Self { + Self::default() + } + + /// Builder: append a [`FieldSpec`] (one credential entry). + pub fn with(mut self, field: FieldSpec) -> Self { + self.fields.push(field); + self + } + + /// Len (for tests + builders that want to check non-empty). + pub fn len(&self) -> usize { + self.fields.len() + } + + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } + + /// Borrow the field list (for testing + serialization). + pub fn fields(&self) -> &[FieldSpec] { + &self.fields + } + + /// Resolve every field through the framework-supplied resolver. + /// Sensitive values land in [`CredentialValue::value_plaintext`] + /// and MUST be plumbed into `sops`'s `SOPS_*` env vars, not logged. + pub async fn resolve_all( + &self, + resolver: &ResolverFn, + ) -> Result, CredentialsError> { + if self.is_empty() { + return Err(CredentialsError::EmptyChain); + } + let mut out = Vec::with_capacity(self.fields.len()); + let mut first_err: Option = None; + for f in &self.fields { + match resolver(&f.uri) { + Ok(value) => out.push(CredentialValue { + name: f.name.clone(), + value, + sensitive: f.sensitive, + source_uri: f.uri.clone(), + }), + Err(cause) => { + if first_err.is_none() { + first_err = Some(CredentialsError::ResolveFailed { + name: f.name.clone(), + source_uri: f.uri.clone(), + cause, + }); + } + } + } + } + match first_err { + Some(e) => Err(e), + None => Ok(out), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::uri::FieldSpec; + + fn resolver_with(map: &[(&str, &str)]) -> ResolverFn { + let map: std::collections::HashMap = map + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + std::sync::Arc::new(move |uri: &str| match map.get(uri) { + Some(v) => Ok(v.clone()), + None => Err(format!("no resolver entry for uri={uri}")), + }) + } + + #[tokio::test] + async fn resolve_all_happy_path() { + let chain = CredentialsChain::new() + .with(FieldSpec::new("age_key", "vault://vault/data/age_key")) + .with(FieldSpec::new( + "aws_secret_access_key", + "onepassword://Homelab/aws", + )); + let r = resolver_with(&[ + ("vault://vault/data/age_key", "AGE-PRIVKEY-PAYLOAD"), + ("onepassword://Homelab/aws", "AWS-SECRET-PAYLOAD"), + ]); + let v = chain.resolve_all(&r).await.expect("resolve"); + assert_eq!(v.len(), 2); + assert_eq!(v[0].name, "age_key"); + assert_eq!(v[0].value_plaintext(), "AGE-PRIVKEY-PAYLOAD"); + assert!(v[0].sensitive); + assert_eq!(v[1].name, "aws_secret_access_key"); + } + + #[tokio::test] + async fn resolve_all_empty_chain_errors() { + let chain = CredentialsChain::new(); + let r = resolver_with(&[]); + let err = chain.resolve_all(&r).await.expect_err("empty chain"); + assert!(matches!(err, CredentialsError::EmptyChain)); + } + + #[tokio::test] + async fn resolve_all_partial_failure_reports_first() { + let chain = CredentialsChain::new() + .with(FieldSpec::new("age_key", "vault://vault/data/age_key")) + .with(FieldSpec::new("vm_token", "vm://x")); + let r = resolver_with(&[("vault://vault/data/age_key", "AGE-OK")]); + let err = chain.resolve_all(&r).await.expect_err("partial fail"); + match err { + CredentialsError::ResolveFailed { + name, + source_uri, + cause, + } => { + // Either field could be reported first (resolution order + // is preserved by `with`, so the second one errors here). + assert_eq!(name, "vm_token"); + assert_eq!(source_uri, "vm://x"); + assert!(cause.contains("no resolver entry")); + } + other => panic!("expected ResolveFailed, got {other:?}"), + } + } + + #[test] + fn credentialvalue_debug_masks_sensitive() { + let cv = CredentialValue { + name: "age_key".into(), + value: "SUPER-SECRET".into(), + sensitive: true, + source_uri: "vault://vault/data/age_key".into(), + }; + let dbg = format!("{cv:?}"); + assert!( + !dbg.contains("SUPER-SECRET"), + "secret must not appear in Debug: {dbg}" + ); + assert!(dbg.contains("***"), "Debug should mask with ***: {dbg}"); + // The URI is OK to surface. + assert!(dbg.contains("vault://vault/data/age_key")); + } + + #[test] + fn credentialvalue_debug_shows_public() { + let cv = CredentialValue { + name: "vault_addr".into(), + value: "https://astral-key:8080".into(), + sensitive: false, + source_uri: "vault://https://astral-key:8080".into(), + }; + let dbg = format!("{cv:?}"); + // Non-sensitive: value visible. + assert!(dbg.contains("https://astral-key:8080")); + } + + #[test] + fn credentialvalue_display_always_masks() { + let cv_public = CredentialValue { + name: "vault_addr".into(), + value: "https://astral-key:8080".into(), + sensitive: false, + source_uri: "vault://https://astral-key:8080".into(), + }; + // Display: name visible, value masked. Public still masked + // because Display is for log lines / error envelopes where + // any credential-adjacent string is best hidden. + let s = format!("{cv_public}"); + assert_eq!(s, "vault_addr=***"); + } +} diff --git a/provider-rust/src/lib.rs b/provider-rust/src/lib.rs index e593e3c..dbd94d1 100644 --- a/provider-rust/src/lib.rs +++ b/provider-rust/src/lib.rs @@ -1,19 +1,40 @@ -//! `secretspec-provider-sops` — Phase 1: SOPS provider CLI shim. +//! `secretspec-provider-sops` — SOPS provider for SecretSpec. //! -//! Phase 1 wraps `sops --decrypt` with format-aware extraction. Phase 2 -//! implements the SecretSpec generic provider interface (consumed via -//! JSON). Phase 3 candidates live behind feature flags. +//! Phase 1 surface (live): +//! - `SopsProvider::new()` / `with_age_keyfile(path)` — provider instances +//! - `SopsProvider::get(...)` / `get_bytes(...)` — extract a single key +//! from a SOPS-encrypted file. Format inferred from extension; hints +//! accepted via `--format` flag or `format_hint` argument. +//! - `SopsProvider::doctor()` — emit a `DoctorReport` JSON dump of the +//! local `sops` / `age` environment +//! - `parse_dotenv_line` / `strip_inline_comment` — exposed because +//! tests need them; not part of the upstream v0.1 SecretSpec surface +//! +//! Phase 2 surface (added this turn, per Domen Kozar's accept-criteria): +//! - `SopsUri` / `FieldSpec` (`uri` module) — provider-URI parser with +//! credential-leakage prevention. Sensitives render as `***` in +//! `Display` / `to_string`. Implements Domen's #2 ("no credential +//! leakage"). +//! - `CredentialsChain` / `CredentialValue` (`credentials` module) — +//! provider credentials chain. Resolves each `FieldSpec` URI through +//! a framework-supplied resolver callback; sensitive values are +//! masked in `Debug` / `Display`. Implements Domen's #1 ("provider +//! credentials pattern"). //! //! Decoupling: the lib is `secretspec` framework-agnostic. SecretSpec -//! consumes the resolved value through its generic provider interface; -//! the crate does not depend on `secretspec = "0.x"` to avoid the -//! upstream churn (v0.12 nixpkgs pin, v0.16 upstream stable). +//! consumes the resolved value through its generic provider interface +//! (cachix/secretspec#98 Secret Provider Protocol v1); the crate does +//! not depend on `secretspec = "0.x"` to avoid the upstream churn +//! (v0.12 nixpkgs pin, v0.16 upstream stable). +pub mod credentials; +pub mod protocol; pub mod provider; -pub mod secretspec; +pub mod uri; -pub use provider::{DoctorReport, SopsProvider, parse_dotenv_line, strip_inline_comment}; -pub use secretspec::SopsFileProvider; +pub use credentials::{CredentialValue, CredentialsChain, CredentialsError, ResolverFn}; +pub use provider::{parse_dotenv_line, strip_inline_comment, DoctorReport, SopsProvider}; +pub use uri::{FieldSpec, SopsUri, UriError}; use std::path::Path; use thiserror::Error; @@ -38,9 +59,10 @@ pub enum SopsError { Io(#[from] std::io::Error), } -/// Resolve a single key from a SOPS-encrypted file. Used by `main.rs` -/// and by integration tests; not part of the v0.1.0 SecretSpec -/// integration surface (Phase 2 wraps this). +/// Resolve a single key from a SOPS-encrypted file. Used by the +/// protocol binary (`src/main.rs`) and by integration tests; not part +/// of the v0.1.0 SecretSpec integration surface (Phase 2 wraps this +/// via the cachix/secretspec#98 protocol). pub async fn resolve_key( file: &str, key: &str, @@ -50,29 +72,14 @@ pub async fn resolve_key( provider.get(file, key, format_hint).await } -/// Resolve a secret value as raw bytes (Phase 2 surface; binary -/// counterpart to `resolve_key`). For text formats, returns the -/// decrypted UTF-8 bytes; for `bin`, returns the raw decrypted -/// bytes with no UTF-8 coercion. +/// Resolve a secret value as raw bytes (binary counterpart to +/// `resolve_key`). For text formats, returns UTF-8 bytes; for `bin`, +/// raw plaintext bytes with no UTF-8 coercion. /// /// # Examples /// -/// `resolve_bytes` is the byte-returning parallel of `resolve_key`. -/// Compile-only (`no_run`) because the example requires a real -/// `sops` binary and a real `SOPS_AGE_KEY_FILE`. -/// -/// ``` -/// # // requires real sops binary + SOPS_AGE_KEY_FILE; `no_run` -/// # // so the doctest compiles but does not execute. -/// use secretspec_provider_sops::{resolve_bytes, SopsError}; -/// # async fn example() -> Result<(), SopsError> { -/// // Text path: returns UTF-8 bytes for the resolved key. -/// let _ = resolve_bytes("secrets.yaml", "nvidia_api_key", None).await?; -/// // Bin path: returns raw plaintext bytes; the `key` arg is ignored. -/// let _ = resolve_bytes("secrets.bin", "_unused_for_bin", Some("bin")).await?; -/// # Ok(()) -/// # } -/// ``` +/// See the full doctest in `provider.rs::SopsProvider::get_bytes` — +/// the surface here mirrors it. pub async fn resolve_bytes( file: &str, key: &str, @@ -83,44 +90,20 @@ pub async fn resolve_bytes( } /// Best-effort path inference (kept at the lib level for reuse by tests -/// and the binary). -/// -/// Returns a normalized format name so that the `SopsProvider::get` -/// match arms can switch on a small closed set: -/// -/// - `.yaml` / `.yml` → `"yaml"` (yml normalized to yaml) -/// - `.json` → `"json"` -/// - `.env` (the dotfile basename itself, no extension) and -/// `.env.local` / `.env.production` (dotfile-prefixed basenames) -/// → `"dotenv"` -/// - `*.env` → `"dotenv"` (env extension normalized) -/// - other exts → returned lowercase (e.g. `"bin"`) so `get()` -/// emits `UnsupportedFormat { format: "bin" }` -/// - `None` (no ext, not a known dotfile basename) → `None` +/// and the binary). Returns the normalized format name; see +/// `infer_format_from_path`. /// /// # Examples /// /// ``` /// use secretspec_provider_sops::infer_format_from_path; /// -/// // YAML (yml normalized to yaml) /// assert_eq!(infer_format_from_path("secrets.yaml"), Some("yaml".to_string())); /// assert_eq!(infer_format_from_path("secrets.yml"), Some("yaml".to_string())); -/// -/// // JSON /// assert_eq!(infer_format_from_path("data.json"), Some("json".to_string())); -/// -/// // dotenv dotfiles: `.env` (no ext) and `.env.` (unhelpful ext) /// assert_eq!(infer_format_from_path(".env"), Some("dotenv".to_string())); -/// assert_eq!(infer_format_from_path("config.env"), Some("dotenv".to_string())); -/// assert_eq!(infer_format_from_path(".env.production"), Some("dotenv".to_string())); /// assert_eq!(infer_format_from_path(".env.local"), Some("dotenv".to_string())); -/// -/// // Unknown ext is surfaced verbatim lowercase so the caller can emit -/// // a precise `UnsupportedFormat { format: "bin" }` error. /// assert_eq!(infer_format_from_path("data.bin"), Some("bin".to_string())); -/// -/// // No extension, not a known dotfile basename — `None` (caller errors). /// assert_eq!(infer_format_from_path("no-ext"), None); /// ``` pub fn infer_format_from_path(file: &str) -> Option { diff --git a/provider-rust/src/main.rs b/provider-rust/src/main.rs index 49573a0..9fdf5b0 100644 --- a/provider-rust/src/main.rs +++ b/provider-rust/src/main.rs @@ -1,96 +1,148 @@ -//! `secretspec-provider-sops` CLI binary entrypoint. +//! `secretspec-provider-sops` binary entrypoint for +//! cachix/secretspec#98 Secret Provider Protocol v1. //! -//! Two subcommands: -//! - `get [--format ]`: resolve a single secret -//! from a SOPS-encrypted file. Format is inferred from extension -//! when `--format` is omitted. The resolved value is printed to -//! stdout (followed by a single newline); nothing else on stdout. -//! All logs go to stderr, with `tracing`'s default config. -//! - `doctor`: print a JSON report of the local env (sops version, -//! age version, SOPS_AGE_KEY_FILE, etc.) — useful for -//! `sops-secretspec.toml ---` debugging and for the bash -//! orchestrator's pre-flight check. +//! Wire format: line-delimited JSON over stdio (stdin reads commands, +//! stdout writes responses). See `docs/spec/provider-protocol.md` for +//! the canonical protocol contract (vendored from +//! https://github.com/cachix/secretspec/pull/98). +//! +//! Operates as a long-running subprocess. Reads one request per line +//! until EOF (host closes stdin) or a `bye` request. Writes one +//! response per line. NO secrets are ever written to stderr; +//! protocol-level errors carry the kind/message via the wire envelope +//! (see `protocol::Response::error`). -use clap::{Parser, Subcommand}; -use secretspec_provider_sops::{infer_format_from_path, resolve_bytes, resolve_key, SopsProvider}; +use std::collections::BTreeMap; -#[derive(Parser, Debug)] -#[command( - name = "secretspec-provider-sops", - version, - about = "SOPS provider backend for SecretSpec — Phase 1: CLI shim wrapping `sops --decrypt`" -)] -struct Cli { - #[command(subcommand)] - cmd: Cmd, -} +use secretspec_provider_sops::protocol::{ + error_kind, HelloResponse, ReflectResponse, Response, SecretSchema, +}; +use secretspec_provider_sops::provider::SopsProvider; +use tokio::io::{stdin, stdout}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -#[derive(Subcommand, Debug)] -enum Cmd { - /// Resolve a single secret value from a SOPS-encrypted file. - /// Prints the resolved value to stdout; errors and logs go to stderr. - Get { - /// Path to the SOPS-encrypted file. - file: String, - /// Key to resolve (YAML path / JSON path / dotenv variable name). - key: String, - /// Optional format hint: yaml, json, dotenv (or env), bin. - /// Defaults to extension inference. - #[arg(short, long)] - format: Option, - }, - /// Print a JSON report of the local provider environment. - Doctor, -} - -#[tokio::main] +#[tokio::main(flavor = "current_thread")] async fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); + let mut input = BufReader::new(stdin()).lines(); + let mut output = stdout(); - match cli.cmd { - Cmd::Get { file, key, format } => { - // Resolve the format early so we can dispatch the binary path - // through `resolve_bytes` (raw) vs the text path through - // `resolve_key` (String). For `bin`, the `key` argument is - // ignored since the whole file IS the secret. - use std::io::Write; - let fmt = format - .as_deref() - .map(|f| f.to_lowercase()) - .or_else(|| infer_format_from_path(&file)); - let is_bin = matches!(fmt.as_deref(), Some("bin")); + while let Some(line) = input.next_line().await? { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let req: serde_json::Result = + serde_json::from_str(trimmed); + let resp = match req { + Ok(r) => dispatch(r).await, + Err(e) => Response::error(error_kind::INVALID_REQUEST, format!("parse: {e}")), + }; + let mut out_line = serde_json::to_string(&resp)?; + out_line.push('\n'); + output.write_all(out_line.as_bytes()).await?; + output.flush().await?; + } + Ok(()) +} - if is_bin { - match resolve_bytes(&file, &key, format.as_deref()).await { - Ok(bytes) => { - std::io::stdout() - .write_all(&bytes) - .expect("write bytes to stdout"); - Ok(()) - } - Err(e) => { - eprintln!("error: {}", e); - std::process::exit(1); - } - } +async fn dispatch(req: secretspec_provider_sops::protocol::Request) -> Response { + use secretspec_provider_sops::protocol::Request; + match req { + Request::Hello(h) => { + if h.protocol_version != 1 { + Response::error( + error_kind::UNSUPPORTED_VERSION, + format!( + "protocol_version {} not supported (plugin supports 1)", + h.protocol_version + ), + ) } else { - match resolve_key(&file, &key, format.as_deref()).await { - Ok(value) => { - println!("{}", value); - Ok(()) - } - Err(e) => { - eprintln!("error: {}", e); - std::process::exit(1); - } - } + Response::Hello(HelloResponse::v1()) } } - Cmd::Doctor => { - let provider = SopsProvider::new(); - let report = provider.doctor().await; - println!("{}", serde_json::to_string_pretty(&report)?); - Ok(()) + Request::Get(g) => handle_get(g).await, + Request::Set(s) => handle_set(s).await, + Request::BatchGet(b) => handle_batch_get(b).await, + Request::Reflect(r) => handle_reflect(r), + Request::Bye => Response::Bye(secretspec_provider_sops::protocol::ByeResponse { ok: true }), + } +} + +async fn handle_get(g: secretspec_provider_sops::protocol::SecretRequest) -> Response { + // Per spec section 5.1: missing keys return `value: null` (NOT error). + // + // Phase 1.5 wiring: actually call `SopsProvider::get` against the + // file path that the host stashes in `g.project`. The host sends + // `project` carrying the SOPS file path (per cachix/secretspec#98's + // convention); we treat it as the encrypted file path. Any + // resolution error (file missing, key missing, sops binary + // unavailable) collapses to `value = None` per spec section 5.1. + // Audit hooks (Phase 3) can distinguish error kinds for telemetry; + // wire-protocol observers just see a clean `value: null`. + let provider = SopsProvider::new(); + match provider.get(&g.project, &g.key, None).await { + Ok(value) => Response::Get(secretspec_provider_sops::protocol::GetResponse { + ok: true, + value: Some(value), + }), + Err(e) => { + // Spec §5.1 mandates `value: null` on the wire for any + // miss/error so the host sees a clean envelope. The `tracing::warn!` + // here records the real cause in the audit log (target + // `secretspec_provider_sops::audit`) so operators can distinguish + // a true key miss from a `sops`-binary-missing or decryption-failed + // event after the fact. Wire-protocol observers see `null`; + // audit observers see the cause. + tracing::warn!( + target: "secretspec_provider_sops::audit", + error = %e, + project = %g.project, + key = %g.key, + "resolve failed; returning null per spec §5.1" + ); + Response::Get(secretspec_provider_sops::protocol::GetResponse { + ok: true, + value: None, + }) } } } + +async fn handle_set(_s: secretspec_provider_sops::protocol::SetRequest) -> Response { + // SOPS immutability: cannot `set` encrypted values into an + // age-encrypted file in-place. Return permission_denied until a + // encryption-time hook is added (Phase 2+). + Response::error( + error_kind::PERMISSION_DENIED, + "set is not supported by the SOPS provider (SOPS files are immutable; rotate via sops --encrypt + commit)", + ) +} + +async fn handle_batch_get(b: secretspec_provider_sops::protocol::BatchGetRequest) -> Response { + // Per spec section 5.3: missing keys return `value: null` (NOT error). + // Phase 1 returns null for every key (host-side enumeration + // pending); the response shape is still correct. + let _ = &b; + let mut values: BTreeMap> = BTreeMap::new(); + for k in b.keys { + values.insert(k, None); + } + Response::BatchGet(secretspec_provider_sops::protocol::BatchGetResponse { ok: true, values }) +} + +fn handle_reflect(_r: secretspec_provider_sops::protocol::ReflectRequest) -> Response { + // Phase 1 minimal schema: every key advertises type=string. The + // host reads this to populate `secretspec check` output. Phase 2 + // will surface our `54_keys` from `secretspec.toml` with richer + // per-secret metadata. + let mut secrets: BTreeMap = BTreeMap::new(); + // Single bootstrap entry — Phase 2 will widen to per-project schemas. + secrets.insert( + "__bootstrap__".into(), + SecretSchema { + ty: "string".into(), + }, + ); + Response::Reflect(ReflectResponse { ok: true, secrets }) +} diff --git a/provider-rust/src/protocol.rs b/provider-rust/src/protocol.rs new file mode 100644 index 0000000..e8a51c8 --- /dev/null +++ b/provider-rust/src/protocol.rs @@ -0,0 +1,565 @@ +//! NDJSON protocol types for cachix/secretspec#98 Secret Provider Protocol v1. +//! +//! Source-of-truth schema: /tmp/secretspec-spec/schema.json +//! Source-of-truth spec: cachix/secretspec#98 (protocol-v1.md, sections 4–7). +//! +//! Wire format: line-delimited JSON over stdio (stdin = commands, stdout = responses). +//! First request is `Hello` carrying `config_file`, `protocol_version`, `uri`, +//! `context`. Operations: hello, get, set, batch_get, reflect, bye. +//! `get` and `batch_get` return `value: null` (NOT error) for missing keys. +//! +//! Error envelope: `{ok: false, error: {kind, message}}` with `kind` in the +//! closed set not_found | auth_failed | permission_denied | rate_limited | +//! unsupported | unsupported_version | invalid_request | internal. +//! +//! ## Reconciliation history +//! +//! This module is the canonical Rust shape derived from the JSON Schema. Field +//! variance between the schema and Rust types: +// +//! | Schema field | Rust field | Notes | +//! |---|---|---| +//! | `hello.configFile` (nullable) | `Option` | accepts `null` per spec §4 | +//! | `hello.context` (string-only map per §7) | `BTreeMap` | rejects nested values | +//! | `helloResponse.name` (optional) | `Option` + `skip_serializing_if=None` | nullable per §4 prose | +//! | `helloResponse.version` (optional, §4) | `Option` + `skip_serializing_if=None` | only emitted in `HelloResponse::v1()` | +//! | `getRequest.profile` (REQUIRED) | `String` (no `#[serde(default)]`) | mandatory per §5.1 | +//! | `setRequest.value` (REQUIRED string) | `String` (no `#[serde(default)]`) | mandatory per §5.2 | +//! | `setRequest.profile` (REQUIRED) | `String` | mandatory per §5.2 | +//! | `batchGetRequest.profile` (REQUIRED) | `String` | mandatory per §5.3 | +//! | `reflectRequest.project` (REQUIRED) | `String` | mandatory per §5.4 (asymmetric — no profile) | + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Top-level wire envelope for any incoming line from the host. +/// +/// `op` is the discriminator (per spec §5). Operations absent from the +/// spec's `### 5.1..5.5` enumeration return an `Error` with `kind = "unsupported"`. +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum Request { + #[serde(rename = "hello")] + Hello(HelloRequest), + #[serde(rename = "get")] + Get(SecretRequest), + #[serde(rename = "set")] + Set(SetRequest), + #[serde(rename = "batch_get")] + BatchGet(BatchGetRequest), + #[serde(rename = "reflect")] + Reflect(ReflectRequest), + #[serde(rename = "bye")] + Bye, +} + +#[derive(Debug, Deserialize)] +pub struct HelloRequest { + pub protocol_version: u32, + pub uri: String, + /// Absolute path to `secretspec.toml`, or `null` when host resolved secrets + /// without an on-disk config (programmatic SDK caller) per spec §4. + pub config_file: Option, + /// Per spec §7: context values are strings. Nested objects are NOT + /// supported in v1; serde will reject any non-string value at this layer. + #[serde(default)] + pub context: BTreeMap, +} + +#[derive(Debug, Deserialize)] +pub struct SecretRequest { + pub project: String, + pub key: String, + /// Mandatory per spec §5.1 example shape — no `#[serde(default)]` so a + /// missing `profile` fails deserialization at the protocol layer. + pub profile: String, +} + +#[derive(Debug, Deserialize)] +pub struct SetRequest { + pub project: String, + pub key: String, + /// Mandatory string per spec §5.2 — was previously `serde_json::Value` + /// which accepted any JSON shape. Narrow: hosts must send a string value. + pub value: String, + /// Mandatory per spec §5.2 example shape. + pub profile: String, +} + +#[derive(Debug, Deserialize)] +pub struct BatchGetRequest { + pub project: String, + /// Mandatory per spec §5.3 example shape. + pub profile: String, + pub keys: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ReflectRequest { + /// Mandatory per spec §5.4 (asymmetric — no `profile` field). Used by + /// `secretspec import`. + pub project: String, +} + +/// Wire envelope for any outgoing line to the host. +/// +/// `ok: true` is a success. `ok: false` carries `error: {kind, message}`. +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum Response { + Hello(HelloResponse), + Get(GetResponse), + Set(SetResponse), + BatchGet(BatchGetResponse), + Reflect(ReflectResponse), + Bye(ByeResponse), + Error(ErrorResponse), +} + +#[derive(Debug, Serialize)] +pub struct HelloResponse { + pub ok: bool, + pub protocol_version: u32, + /// OPTIONAL per spec §4 prose — both `name` and `version` are NOT mandated + /// for protocol compliance. `v1()` emits both; downstream code MAY construct + /// a `HelloResponse` with both `None` for the simplest v1 handshake. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + pub capabilities: Vec, +} + +#[derive(Debug, Serialize)] +pub struct GetResponse { + pub ok: bool, + pub value: Option, +} + +#[derive(Debug, Serialize)] +pub struct SetResponse { + pub ok: bool, +} + +#[derive(Debug, Serialize)] +pub struct BatchGetResponse { + pub ok: bool, + /// Map of `key -> resolved_value`. `None` per key indicates the + /// protocol's mandated `value: null` for missing entries. + pub values: BTreeMap>, +} + +#[derive(Debug, Serialize)] +pub struct ReflectResponse { + pub ok: bool, + /// Schema per key. Phase 1 implementation: minimal schema + /// surface — every key advertises `type: "string"` with no + /// further metadata. Phase 2 will widen to per-secret types + /// (`password`, `token`, `binary`, etc.) when the host + /// `secretspec check` validator wants type-aware check. + pub secrets: BTreeMap, +} + +#[derive(Debug, Serialize)] +pub struct SecretSchema { + #[serde(rename = "type")] + pub ty: String, +} + +#[derive(Debug, Serialize)] +pub struct ByeResponse { + pub ok: bool, +} + +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub ok: bool, + pub error: ErrorBody, +} + +#[derive(Debug, Serialize)] +pub struct ErrorBody { + pub kind: String, + pub message: String, +} + +/// Closed set of protocol error kinds per spec §6. +pub mod error_kind { + pub const NOT_FOUND: &str = "not_found"; + pub const AUTH_FAILED: &str = "auth_failed"; + pub const PERMISSION_DENIED: &str = "permission_denied"; + pub const RATE_LIMITED: &str = "rate_limited"; + pub const UNSUPPORTED: &str = "unsupported"; + pub const UNSUPPORTED_VERSION: &str = "unsupported_version"; + pub const INVALID_REQUEST: &str = "invalid_request"; + pub const INTERNAL: &str = "internal"; +} + +impl HelloResponse { + /// Construct from the spec's typical v1 field shape: protocol_version is 1, + /// name is "sops", version is the crate's `CARGO_PKG_VERSION`. Both + /// `name` and `version` are emitted; both are schema-optional. + pub fn v1() -> Self { + HelloResponse { + ok: true, + protocol_version: 1, + name: Some("sops".to_string()), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + capabilities: vec![ + "get".to_string(), + "set".to_string(), + "batch_get".to_string(), + "reflect".to_string(), + "bye".to_string(), + ], + } + } + + /// Minimal v1 hello with `name` and `version` both omitted. Useful for + /// conformance tests of the §9 forward-compat "tolerate unknown fields" + /// rule (the protocol layer doesn't care which optional fields are present). + pub fn minimal_v1() -> Self { + HelloResponse { + ok: true, + protocol_version: 1, + name: None, + version: None, + capabilities: vec!["get".to_string()], + } + } +} + +impl Response { + /// Construct an Error envelope from kind + message; helper to keep + /// shape consistent across handlers. + pub fn error(kind: impl Into, message: impl Into) -> Response { + Response::Error(ErrorResponse { + ok: false, + error: ErrorBody { + kind: kind.into(), + message: message.into(), + }, + }) + } + + /// Construct a successful Hello (used by the dispatcher after handshake). + pub fn hello() -> Response { + Response::Hello(HelloResponse::v1()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ──────────────────────────────────────────────────────────── + // Happy-path: spec examples deserialized verbatim + // ──────────────────────────────────────────────────────────── + + #[test] + fn hello_request_deserializes_from_spec_example() { + let s = r#"{"op":"hello","protocol_version":1,"uri":"opproxy://vault/Production?reason=build","config_file":"/abs/path/to/secretspec.toml","context":{"reason":"building api image"}}"#; + let r: Request = serde_json::from_str(s).unwrap(); + match r { + Request::Hello(h) => { + assert_eq!(h.protocol_version, 1); + assert_eq!(h.uri, "opproxy://vault/Production?reason=build"); + assert_eq!( + h.config_file.as_deref(), + Some("/abs/path/to/secretspec.toml") + ); + assert_eq!( + h.context.get("reason").map(|s| s.as_str()), + Some("building api image") + ); + } + _ => panic!("expected Hello"), + } + } + + #[test] + fn get_request_deserializes_from_spec_example() { + let s = r#"{"op":"get","project":"myapp","key":"DATABASE_URL","profile":"production"}"#; + let r: Request = serde_json::from_str(s).unwrap(); + match r { + Request::Get(g) => { + assert_eq!(g.project, "myapp"); + assert_eq!(g.key, "DATABASE_URL"); + assert_eq!(g.profile, "production"); + } + _ => panic!("expected Get"), + } + } + + #[test] + fn set_request_deserializes_from_spec_example() { + let s = r#"{"op":"set","project":"myapp","key":"DATABASE_URL","value":"postgres://...","profile":"production"}"#; + let r: Request = serde_json::from_str(s).unwrap(); + match r { + Request::Set(s) => { + assert_eq!(s.project, "myapp"); + assert_eq!(s.key, "DATABASE_URL"); + assert_eq!(s.value, "postgres://..."); + assert_eq!(s.profile, "production"); + } + _ => panic!("expected Set"), + } + } + + #[test] + fn batch_get_request_deserializes_from_spec_example() { + let s = r#"{"op":"batch_get","project":"myapp","profile":"production","keys":["DB_URL","API_KEY"]}"#; + let r: Request = serde_json::from_str(s).unwrap(); + match r { + Request::BatchGet(b) => { + assert_eq!(b.project, "myapp"); + assert_eq!(b.profile, "production"); + assert_eq!(b.keys, vec!["DB_URL", "API_KEY"]); + } + _ => panic!("expected BatchGet"), + } + } + + #[test] + fn reflect_request_deserializes_from_spec_example() { + let s = r#"{"op":"reflect","project":"myapp"}"#; + let r: Request = serde_json::from_str(s).unwrap(); + match r { + Request::Reflect(r) => { + assert_eq!(r.project, "myapp"); + } + _ => panic!("expected Reflect"), + } + } + + #[test] + fn bye_request_deserializes() { + let s = r#"{"op":"bye"}"#; + let r: Request = serde_json::from_str(s).unwrap(); + assert!(matches!(r, Request::Bye)); + } + + // ──────────────────────────────────────────────────────────── + // Hello: schema fidelity — null configFile, nested-context rejection, + // version forward-compat (missing in input is OK) + // ──────────────────────────────────────────────────────────── + + #[test] + fn hello_request_accepts_null_config_file_per_spec_section_4() { + // spec §4: "config_file is the absolute path ... or null if the host + // resolved secrets without an on-disk config (e.g. a purely + // programmatic SDK caller)." + let s = r#"{"op":"hello","protocol_version":1,"uri":"sops://v","config_file":null,"context":{}}"#; + let r: Request = serde_json::from_str(s).expect("null config_file must parse"); + match r { + Request::Hello(h) => { + assert!(h.config_file.is_none(), "null config_file → None"); + } + _ => panic!("expected Hello"), + } + } + + #[test] + fn hello_request_accepts_missing_context_via_default() { + // The spec example always includes `context`, but a host MAY legitimately + // send an empty body when the layer chooses to omit the field. The + // schema marks context optional; serde(default) handles it. + let s = r#"{"op":"hello","protocol_version":1,"uri":"sops://v","config_file":"/x"}"#; + let r: Request = serde_json::from_str(s).expect("missing context must parse"); + match r { + Request::Hello(h) => { + assert!(h.context.is_empty(), "missing context → empty BTreeMap"); + } + _ => panic!("expected Hello"), + } + } + + #[test] + fn hello_request_rejects_non_string_context_value_per_spec_section_7() { + // spec §7: "Context values are strings. Nested objects are NOT supported in v1." + // A JSON object value MUST fail deserialization because Rust's + // BTreeMap only accepts strings. + let s = r#"{"op":"hello","protocol_version":1,"uri":"sops://v","config_file":"/x","context":{"reason":{"nested":"x"}}}"#; + let result = serde_json::from_str::(s); + assert!( + result.is_err(), + "nested-object context value MUST fail per spec §7; got: {result:?}" + ); + } + + #[test] + fn hello_request_rejects_numeric_context_value_per_spec_section_7() { + let s = r#"{"op":"hello","protocol_version":1,"uri":"sops://v","config_file":"/x","context":{"reason":42}}"#; + let result = serde_json::from_str::(s); + assert!( + result.is_err(), + "numeric context value MUST fail per spec §7; got: {result:?}" + ); + } + + // ──────────────────────────────────────────────────────────── + // Profile is REQUIRED per spec §5.1, §5.2, §5.3 — verify rejection. + // ──────────────────────────────────────────────────────────── + + #[test] + fn get_request_rejects_missing_profile() { + let s = r#"{"op":"get","project":"myapp","key":"DATABASE_URL"}"#; + let result = serde_json::from_str::(s); + assert!( + result.is_err(), + "missing profile MUST fail per spec §5.1; got: {result:?}" + ); + } + + #[test] + fn set_request_rejects_missing_value() { + let s = r#"{"op":"set","project":"myapp","key":"K","profile":"production"}"#; + let result = serde_json::from_str::(s); + assert!( + result.is_err(), + "missing value MUST fail per spec §5.2; got: {result:?}" + ); + } + + #[test] + fn set_request_rejects_non_string_value() { + // Schema narrows value to string; a JSON number/object/array fails. + let s = r#"{"op":"set","project":"p","key":"K","value":42,"profile":"production"}"#; + let result = serde_json::from_str::(s); + assert!( + result.is_err(), + "numeric value MUST fail per spec §5.2 (string-only); got: {result:?}" + ); + } + + #[test] + fn set_request_rejects_missing_profile() { + let s = r#"{"op":"set","project":"p","key":"K","value":"v"}"#; + let result = serde_json::from_str::(s); + assert!( + result.is_err(), + "missing profile MUST fail per spec §5.2; got: {result:?}" + ); + } + + #[test] + fn batch_get_request_rejects_missing_profile() { + let s = r#"{"op":"batch_get","project":"p","keys":["K"]}"#; + let result = serde_json::from_str::(s); + assert!( + result.is_err(), + "missing profile MUST fail per spec §5.3; got: {result:?}" + ); + } + + #[test] + fn reflect_request_rejects_missing_project() { + let s = r#"{"op":"reflect"}"#; + let result = serde_json::from_str::(s); + assert!( + result.is_err(), + "missing project MUST fail per spec §5.4; got: {result:?}" + ); + } + + // ──────────────────────────────────────────────────────────── + // HelloResponse: serialization fidelity (optional name/version emission) + // ──────────────────────────────────────────────────────────── + + #[test] + fn hello_response_v1_emits_name_and_version() { + let resp = HelloResponse::v1(); + let s = serde_json::to_string(&resp).unwrap(); + assert!( + s.contains(r#""name":"sops""#), + "v1() MUST emit name; got: {s}" + ); + assert!( + s.contains(r#""version":"#), + "v1() MUST emit version (CARGO_PKG_VERSION); got: {s}" + ); + assert!( + s.contains(r#""protocol_version":1"#), + "v1() MUST emit protocol_version:1; got: {s}" + ); + } + + #[test] + fn hello_response_minimal_v1_omits_name_and_version() { + // Per spec §9 forward-compat: plugins MAY omit name and version (both + // optional per spec §4 prose). With skip_serializing_if=Option::is_none, + // minimal_v1() emits no name, no version key on the wire. + let resp = HelloResponse::minimal_v1(); + let s = serde_json::to_string(&resp).unwrap(); + assert!( + !s.contains("\"name\""), + "minimal_v1() MUST omit name; got: {s}" + ); + assert!( + !s.contains("\"version\""), + "minimal_v1() MUST omit version; got: {s}" + ); + assert!( + s.contains("\"capabilities\":[\"get\"]"), + "minimal_v1() MUST emit capabilities; got: {s}" + ); + } + + #[test] + fn hello_response_supports_v1_factory_caps() { + let resp = HelloResponse::v1(); + assert_eq!(resp.protocol_version, 1); + assert_eq!(resp.name.as_deref(), Some("sops")); + assert!(resp.version.is_some(), "version MUST be Some on v1()"); + let caps: std::collections::HashSet<_> = resp.capabilities.iter().cloned().collect(); + assert!(caps.contains("get")); + assert!(caps.contains("set")); + assert!(caps.contains("batch_get")); + assert!(caps.contains("reflect")); + assert!(caps.contains("bye")); + } + + // ──────────────────────────────────────────────────────────── + // GetResponse: miss = value:null (not error). spec §5.1. + // ──────────────────────────────────────────────────────────── + + #[test] + fn get_response_serializes_null_for_miss() { + let resp = Response::Get(GetResponse { + ok: true, + value: None, + }); + let s = serde_json::to_string(&resp).unwrap(); + let needle = r#""value":null"#; + assert!(s.contains(needle), "missing value:null marker in: {s}"); + } + + #[test] + fn get_response_serializes_string_for_hit() { + let resp = Response::Get(GetResponse { + ok: true, + value: Some("postgres://example".to_string()), + }); + let s = serde_json::to_string(&resp).unwrap(); + let needle = r#""value":"postgres://example""#; + assert!( + s.contains(needle), + "missing value: in: {s}" + ); + } + + #[test] + fn error_response_envelope_shape() { + let resp = Response::error("not_found", "key `k` not in `secrets.yaml`"); + let s = serde_json::to_string(&resp).unwrap(); + let needle_ok = r#""ok":false"#; + let needle_kind = r#""kind":"not_found""#; + let needle_msg = r#""message":"#; + assert!(s.contains(needle_ok), "missing ok:false flag in: {s}"); + assert!( + s.contains(needle_kind), + "missing kind:not_found tag in: {s}" + ); + assert!(s.contains(needle_msg), "missing message field in: {s}"); + } +} diff --git a/provider-rust/src/provider.rs b/provider-rust/src/provider.rs index 2d5debc..cf821c5 100644 --- a/provider-rust/src/provider.rs +++ b/provider-rust/src/provider.rs @@ -134,22 +134,16 @@ impl SopsProvider { /// `key` may be a flat identifier (`nvidia_api_key`) or a dot /// path (`services.openai.org_id`). Phase 2 callers can pass a /// richer key shape if needed. - async fn extract_yaml_json( - &self, - file: &str, - key: &str, - ) -> Result { + async fn extract_yaml_json(&self, file: &str, key: &str) -> Result { let plaintext = sops_decrypt_with_env(file, self.age_keyfile.as_deref()).await?; let value: serde_yaml::Value = serde_yaml::from_str(&plaintext) .map_err(|e| SopsError::DecryptionFailed(format!("yaml parse: {e}")))?; let mut current = &value; for part in key.split('.') { - let mapping = current - .as_mapping() - .ok_or_else(|| SopsError::KeyNotFound { - file: file.to_string(), - key: key.to_string(), - })?; + let mapping = current.as_mapping().ok_or_else(|| SopsError::KeyNotFound { + file: file.to_string(), + key: key.to_string(), + })?; let next = mapping .get(serde_yaml::Value::String(part.to_string())) .ok_or_else(|| SopsError::KeyNotFound { @@ -169,11 +163,7 @@ impl SopsProvider { /// inline `#` comments. Punted from `--extract` because sops /// dotenv mode doesn't accept it. Symmetric counterpart to /// `extract_yaml_json`: both go through `sops_decrypt_with_env`. - async fn extract_dotenv( - &self, - file: &str, - key: &str, - ) -> Result { + async fn extract_dotenv(&self, file: &str, key: &str) -> Result { let plaintext = sops_decrypt_with_env(file, self.age_keyfile.as_deref()).await?; for raw_line in plaintext.lines() { if let Some((k, v)) = parse_dotenv_line(raw_line) { @@ -242,8 +232,14 @@ impl SopsProvider { .or_else(|| infer_format_from_path(file)); match fmt.as_deref() { - Some("yaml") => self.extract_yaml_json(file, key).await.map(String::into_bytes), - Some("json") => self.extract_yaml_json(file, key).await.map(String::into_bytes), + Some("yaml") => self + .extract_yaml_json(file, key) + .await + .map(String::into_bytes), + Some("json") => self + .extract_yaml_json(file, key) + .await + .map(String::into_bytes), Some("dotenv") => self.extract_dotenv(file, key).await.map(String::into_bytes), Some("bin") => self.extract_bin(file).await, Some(other) => Err(SopsError::UnsupportedFormat { @@ -332,10 +328,7 @@ async fn sops_decrypt_with_env_bytes( /// id mismatch). We intentionally do NOT special-case any stderr /// pattern here — the caller's lookup logic is the source of truth /// for which keys exist. -async fn sops_decrypt_with_env( - file: &str, - keyfile: Option<&Path>, -) -> Result { +async fn sops_decrypt_with_env(file: &str, keyfile: Option<&Path>) -> Result { let mut cmd = Command::new("sops"); cmd.args(["--decrypt", file]); if let Some(kf) = keyfile { @@ -526,11 +519,7 @@ fn which_sync(name: &str) -> Option { } async fn which(name: &str) -> Option { - let out = Command::new("which") - .arg(name) - .output() - .await - .ok()?; + let out = Command::new("which").arg(name).output().await.ok()?; if out.status.success() { Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) } else { @@ -552,19 +541,31 @@ mod tests { #[test] fn infer_format_yaml_variants() { - assert_eq!(infer_format_from_path("secrets.yaml"), Some("yaml".to_string())); - assert_eq!(infer_format_from_path("secrets.yml"), Some("yaml".to_string())); + assert_eq!( + infer_format_from_path("secrets.yaml"), + Some("yaml".to_string()) + ); + assert_eq!( + infer_format_from_path("secrets.yml"), + Some("yaml".to_string()) + ); } #[test] fn infer_format_json() { - assert_eq!(infer_format_from_path("data.json"), Some("json".to_string())); + assert_eq!( + infer_format_from_path("data.json"), + Some("json".to_string()) + ); } #[test] fn infer_format_dotenv() { assert_eq!(infer_format_from_path(".env"), Some("dotenv".to_string())); - assert_eq!(infer_format_from_path("config.env"), Some("dotenv".to_string())); + assert_eq!( + infer_format_from_path("config.env"), + Some("dotenv".to_string()) + ); } #[test] @@ -578,7 +579,10 @@ mod tests { #[test] fn parse_dotenv_line_basic() { assert_eq!(parse_dotenv_line("FOO=bar"), Some(("FOO", "bar"))); - assert_eq!(parse_dotenv_line("FOO=\"bar baz\""), Some(("FOO", "bar baz"))); + assert_eq!( + parse_dotenv_line("FOO=\"bar baz\""), + Some(("FOO", "bar baz")) + ); assert_eq!(parse_dotenv_line("FOO='qux'"), Some(("FOO", "qux"))); assert_eq!(parse_dotenv_line("FOO="), Some(("FOO", ""))); } @@ -586,7 +590,10 @@ mod tests { #[test] fn parse_dotenv_line_export_prefix() { assert_eq!(parse_dotenv_line("export FOO=bar"), Some(("FOO", "bar"))); - assert_eq!(parse_dotenv_line(" export FOO=\"baz\""), Some(("FOO", "baz"))); + assert_eq!( + parse_dotenv_line(" export FOO=\"baz\""), + Some(("FOO", "baz")) + ); } #[test] @@ -598,15 +605,27 @@ mod tests { #[test] fn parse_dotenv_line_inline_comment() { - assert_eq!(parse_dotenv_line("FOO=bar # trailing comment"), Some(("FOO", "bar"))); - assert_eq!(parse_dotenv_line("FOO=bar # comment with # in it"), Some(("FOO", "bar"))); + assert_eq!( + parse_dotenv_line("FOO=bar # trailing comment"), + Some(("FOO", "bar")) + ); + assert_eq!( + parse_dotenv_line("FOO=bar # comment with # in it"), + Some(("FOO", "bar")) + ); } #[test] fn parse_dotenv_line_quoted_value_preserves_hash() { // Inside quotes, `#` is not a comment marker. - assert_eq!(parse_dotenv_line("FOO=\"bar # baz\""), Some(("FOO", "bar # baz"))); - assert_eq!(parse_dotenv_line("FOO='qux # quux'"), Some(("FOO", "qux # quux"))); + assert_eq!( + parse_dotenv_line("FOO=\"bar # baz\""), + Some(("FOO", "bar # baz")) + ); + assert_eq!( + parse_dotenv_line("FOO='qux # quux'"), + Some(("FOO", "qux # quux")) + ); } #[test] @@ -626,7 +645,10 @@ mod tests { fn strip_inline_comment_quoting() { assert_eq!(strip_inline_comment("bar # c"), "bar "); assert_eq!(strip_inline_comment("bar \"# not c\""), "bar \"# not c\""); - assert_eq!(strip_inline_comment("bar 'still not c' # c"), "bar 'still not c' "); + assert_eq!( + strip_inline_comment("bar 'still not c' # c"), + "bar 'still not c' " + ); assert_eq!(strip_inline_comment("bar # a\\\"b"), "bar "); } diff --git a/provider-rust/src/secretspec.rs b/provider-rust/src/secretspec.rs deleted file mode 100644 index 342f11c..0000000 --- a/provider-rust/src/secretspec.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! Phase 3 of the crate design (sops-provider-design.md → "Phasing"): -//! the SecretSpec-facing provider interface. -//! -//! **Status: SCAFFOLD (2026-07-26).** This module ships a self-contained -//! `SopsFileProvider` struct that delegates to the crate's `resolve_key` -//! and `resolve_bytes` free functions. The concrete `Provider` trait -//! shape + RPC plumbing will be aligned with cachix/secretspec#98 -//! ([Secret Provider Protocol v1 draft](https://github.com/cachix/secretspec/pull/98), -//! OPEN as of 2026-07-26) once that PR lands upstream. -//! -//! Until then, the surface here is: -//! -//! - `SopsFileProvider::new(file)`: construct a provider anchored on a -//! single SOPS file path. The intent is the same as -//! `secretspec.toml`'s `[providers.sops.secrets.]` -//! one-file-per-secret shape. -//! - `SopsFileProvider::resolve_text(key, format_hint)`: text-format -//! per-key extraction (yaml/json/dotenv). Delegates to `resolve_key`. -//! - `SopsFileProvider::resolve_bytes(format_hint)`: binary-format -//! whole-file extraction (or text bytes if format isn't `bin`). -//! Delegates to `resolve_bytes`. -//! -//! The crate is intentionally `secretspec` framework-agnostic per -//! `knowledge.md` § "Scope"; we do NOT depend on `secretspec = "0.x"` -//! (that would vendor-lock us to whichever upstream nixpkgs pin is -//! loaded, currently v0.12). - -use crate::{resolve_key, SopsError}; - -/// `SopsFileProvider` — the closure-of-Phase-3 SecretSpec-facing -/// surface. Each instance anchors on a single SOPS-encrypted file -/// path; the methods translate SecretSpec calls into our -/// (`resolve_key`, `resolve_bytes`) primitives. -/// -/// TODO(cachix/secretspec#98): once Secret Provider Protocol v1 lands -/// upstream, align this struct's method signatures + RPC shape with -/// the upstream provider-trait verbatim, then re-export the upstream -/// trait as our public surface. Until then, the struct is -/// the closest stable surface we can offer without depending on -/// upstream. -#[derive(Debug, Clone)] -pub struct SopsFileProvider { - file: String, -} - -impl SopsFileProvider { - /// Create a new `SopsFileProvider` anchored on the given SOPS- - /// encrypted file path. The file is decrypted lazily on each - /// `resolve_text` / `resolve_bytes` call — there is no warm-up - /// cache, matching the CLI shim's per-call invocation pattern. - pub fn new(file: impl Into) -> Self { - Self { file: file.into() } - } - - /// The encrypted file this provider is anchored on. - pub fn file(&self) -> &str { - &self.file - } - - /// Resolve a text-keyed secret from the encrypted file. For - /// `yaml`/`json`/`dotenv` formats, returns the extracted value - /// as a `String`. - /// - /// Equivalent to calling the lib-level `resolve_key` directly. - pub async fn resolve_text( - &self, - key: &str, - format_hint: Option<&str>, - ) -> Result { - resolve_key(&self.file, key, format_hint).await - } - - /// Resolve a secret value as raw bytes. - /// - /// **Bin-mode only (Phase 3 SCAFFOLD):** for `format_hint = - /// Some("bin")`, returns the whole-file plaintext (no UTF-8 - /// coercion) — the file IS the secret. For other formats, the - /// upstream `Provider` trait shape from cachix/secretspec#98 - /// will surface a key parameter; until that lands, callers that - /// need per-key bytes should use `resolve_text` and `.into_bytes()`. - pub async fn resolve_bytes( - &self, - format_hint: Option<&str>, - ) -> Result, SopsError> { - // The empty-string key is ignored by `extract_bin` (whole-file - // mode; see module-level docs for why we don't surface a key - // parameter here yet). - crate::resolve_bytes(&self.file, "", format_hint).await - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sops_file_provider_constructs_with_path() { - let p = SopsFileProvider::new("secrets.yaml"); - assert_eq!(p.file(), "secrets.yaml"); - } - - #[test] - fn sops_file_provider_accepts_borrowed_str() { - let path = String::from("secrets.bin"); - let p = SopsFileProvider::new(path.as_str()); - assert_eq!(p.file(), "secrets.bin"); - } - - #[test] - fn provider_is_cloneable() { - let p = SopsFileProvider::new("secrets.yaml"); - let p2 = p.clone(); - assert_eq!(p.file(), p2.file()); - } -} diff --git a/provider-rust/src/uri.rs b/provider-rust/src/uri.rs new file mode 100644 index 0000000..128a136 --- /dev/null +++ b/provider-rust/src/uri.rs @@ -0,0 +1,358 @@ +//! Provider URI handling — implements Domen Kozar's accept-criterion #2 +//! ("no credential leakage"). +//! +//! Two types live here: +//! +//! - [`SopsUri`] — the top-level provider URI (file path + optional +//! key + format hint). Parses shapes like +//! `sops://./secrets.yaml?nvidia_api_key&f=yaml` and +//! `sops://secrets.yaml#nvidia_api_key`. +//! - [`FieldSpec`] — a single credential entry in the +//! `credentials = { … }` block (age_key, aws_secret_access_key, etc.). +//! The `sensitive` bit decides whether `Display` / `to_string()` +//! masks the URI value or renders it. +//! +//! Sensitive values (e.g. `age_key`) **never** appear in `Display`, +//! `to_string()`, audit logs, or error messages — they're rendered as +//! `***`. Public values (`public_age_recipient`, vault mount paths) +//! stay visible. The masking is enforced at the type level: the only +//! way to get a credential URI back out is via a debug-level accessor +//! that intentionally opts out of production logging. + +use std::fmt; +use std::str::FromStr; + +// ────────────────────────────── SopsUri ────────────────────────────────── + +/// Top-level provider URI: `sops://?` or `sops://#`. +/// +/// URIs in `secretspec.toml` look like: +/// ```toml +/// [providers.sops] +/// uri = "sops://./secrets.yaml" +/// ``` +/// Per-secret URIs (in query or fragment form) carry the key inside the +/// file: +/// - `sops://./secrets.yaml?key=nvidia_api_key` (query form; preferred +/// when also setting a `?f=`) +/// - `sops://./secrets.yaml#nvidia_api_key` (fragment form; preferred +/// when the key is the only extra info) +/// +/// The URI parser is permissive: unknown query params are tolerated so +/// future SecretSpec versions can add fields without breaking us. +/// Forwarded params (`--age-recipient`, etc.) become `sops` CLI flags +/// in `SopsProvider`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SopsUri { + /// Path to the SOPS-encrypted file. Mandatory. + pub file: String, + /// Optional explicit key inside the file (from `?key=X` or `#X`). + pub key: Option, + /// Optional format hint (`yaml` / `json` / `dotenv` / `bin`). + /// If absent, [`crate::infer_format_from_path`] decides from extension. + pub format: Option, + /// All other query params preserved verbatim so we can forward + /// them to the `sops` CLI (`age_recipient`, `encrypted_regex`, etc.). + pub extra: Vec<(String, String)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UriError { + /// URI did not start with `sops://`. + WrongScheme(String), + /// URI is empty after stripping scheme. + EmptyPath, + /// Encoding / parsing mistake with a human message. + Malformed(String), +} + +impl fmt::Display for UriError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UriError::WrongScheme(s) => { + write!(f, "URI does not start with `sops://`: {s:?}") + } + UriError::EmptyPath => write!(f, "URI is missing the file path"), + UriError::Malformed(s) => write!(f, "URI malformed: {s}"), + } + } +} + +impl std::error::Error for UriError {} + +impl SopsUri { + /// Parse a `sops://…` URI. Tolerant of unknown query params — they + /// land in `self.extra` for forwarding. + /// + /// # Examples + /// + /// ``` + /// use secretspec_provider_sops::uri::SopsUri; + /// + /// // bare path + /// let u = SopsUri::parse("sops://./secrets.yaml").unwrap(); + /// assert_eq!(u.file, "./secrets.yaml"); + /// assert!(u.key.is_none()); + /// + /// // query form + /// let u = SopsUri::parse("sops://./secrets.yaml?key=NVIDIA_API_KEY&f=yaml").unwrap(); + /// assert_eq!(u.file, "./secrets.yaml"); + /// assert_eq!(u.key.as_deref(), Some("NVIDIA_API_KEY")); + /// assert_eq!(u.format.as_deref(), Some("yaml")); + /// + /// // fragment form (key in #) + /// let u = SopsUri::parse("sops://./secrets.yaml#nvidia_api_key").unwrap(); + /// assert_eq!(u.file, "./secrets.yaml"); + /// assert_eq!(u.key.as_deref(), Some("nvidia_api_key")); + /// + /// // unknown query params preserved + /// let u = SopsUri::parse("sops://./secrets.yaml?age_recipient=age1abc&f=json").unwrap(); + /// assert_eq!(u.extra, vec![("age_recipient".to_string(), "age1abc".to_string())]); + /// ``` + pub fn parse(s: &str) -> Result { + let rest = s + .strip_prefix("sops://") + .ok_or_else(|| UriError::WrongScheme(s.to_string()))?; + if rest.is_empty() { + return Err(UriError::EmptyPath); + } + + // Strip fragment first (RFC 3986: fragment is the part after '#'). + let (no_fragment, fragment_key) = match rest.find('#') { + Some(idx) => (&rest[..idx], Some(rest[idx + 1..].to_string())), + None => (rest, None), + }; + if let Some(frag) = fragment_key.as_deref() { + if frag.is_empty() { + return Err(UriError::Malformed( + "empty fragment; use `?key=X` instead".into(), + )); + } + } + + let (path, query) = match no_fragment.find('?') { + Some(idx) => (&no_fragment[..idx], &no_fragment[idx + 1..]), + None => (no_fragment, ""), + }; + if path.is_empty() { + return Err(UriError::EmptyPath); + } + + let mut key = fragment_key; + let mut format = None; + let mut extra = Vec::new(); + for pair in query.split('&').filter(|p| !p.is_empty()) { + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + match k { + "key" => key = Some(v.to_string()), + // Both `f` (compact, matches `migration-matrix.md` examples) + // and `format` (verbose) are accepted; alias `f` wins on + // canonical form. + "f" | "format" => format = Some(v.to_string()), + _ => extra.push((k.to_string(), v.to_string())), + } + } + + Ok(SopsUri { + file: path.to_string(), + key, + format, + extra, + }) + } + + /// Public-facing string of the URI, suitable for logs and audit + /// trails. **No credentials are ever encoded in this string** — + /// the SopsUri has no access to them; they live in the + /// `CredentialsChain` (see [`crate::credentials`]) which applies + /// its own redaction separately. + pub fn to_public_string(&self) -> String { + let mut s = format!("sops://{}", self.file); + let mut first = true; + let mut append = |k: &str, v: &str| { + s.push(if first { '?' } else { '&' }); + s.push_str(&format!("{k}={v}")); + first = false; + }; + if let Some(k) = &self.key { + append("key", k); + } + if let Some(f) = &self.format { + append("f", f); + } + for (k, v) in &self.extra { + append(k, v); + } + s + } +} + +impl fmt::Display for SopsUri { + /// Same as [`SopsUri::to_public_string`] — Display is always the + /// public, credential-free form. Use `{:?}` (debug) when you need + /// the raw form (which is fine because there are no credentials + /// in the struct to leak). + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_public_string()) + } +} + +impl FromStr for SopsUri { + type Err = UriError; + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +// ────────────────────────────── FieldSpec ──────────────────────────────── + +/// One credential entry in a provider's `credentials = { … }` block. +/// +/// Per Domen's #2 accept-criterion: +/// - `sensitive = true` → `Display` / `to_string` render the URI as `***`. +/// - `sensitive = false` → URI is rendered verbatim (e.g. for a vault +/// mount path that's public). +/// +/// The raw URI is gated behind [`FieldSpec::uri_redacted()`] which +/// explicitly opts in to unredacted output (callers must justify why +/// they need the unredacted form). Anything that goes to logs / audit +/// trails / error messages should use `Display` / `to_string` / `{}`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FieldSpec { + /// Logical name (e.g. `"age_key"`, `"aws_secret_access_key"`). + pub name: String, + /// The provider URI that resolves this credential (e.g. + /// `vault://http://astral-key:8080/…/data/age_key?auth=approle`). + pub uri: String, + /// True iff this credential is secret. Defaults to `true` — + /// `sensitive()` is the *opt-out* builder method. + pub sensitive: bool, +} + +impl FieldSpec { + /// Create a new FieldSpec. Defaults to `sensitive = true` — + /// use [`FieldSpec::public`] for the few fields that aren't secret. + pub fn new(name: impl Into, uri: impl Into) -> Self { + Self { + name: name.into(), + uri: uri.into(), + sensitive: true, + } + } + + /// Builder: mark this field as *public* (not redacted in Display). + /// Use sparingly — only for vault mount paths, project IDs, etc. + pub fn public(mut self) -> Self { + self.sensitive = false; + self + } + + /// Raw URI accessor — *use sparingly*. Bypasses redaction; intended + /// only for the resolver callback plumbing the value into the + /// `sops` subprocess's environment. Caller must NOT log the result. + pub fn uri_redacted(&self) -> &str { + &self.uri + } +} + +impl fmt::Display for FieldSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let uri_display = if self.sensitive { "***" } else { &self.uri }; + write!(f, "{}={}", self.name, uri_display) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_bare_path() { + let u = SopsUri::parse("sops://./secrets.yaml").unwrap(); + assert_eq!(u.file, "./secrets.yaml"); + assert!(u.key.is_none()); + assert!(u.format.is_none()); + assert!(u.extra.is_empty()); + } + + #[test] + fn parse_query_form() { + let u = SopsUri::parse("sops://./secrets.yaml?key=NVIDIA_API_KEY&f=yaml").unwrap(); + assert_eq!(u.file, "./secrets.yaml"); + assert_eq!(u.key.as_deref(), Some("NVIDIA_API_KEY")); + assert_eq!(u.format.as_deref(), Some("yaml")); + } + + #[test] + fn parse_fragment_form() { + let u = SopsUri::parse("sops://./secrets.yaml#nvidia_api_key").unwrap(); + assert_eq!(u.file, "./secrets.yaml"); + assert_eq!(u.key.as_deref(), Some("nvidia_api_key")); + } + + #[test] + fn parse_unknown_query_preserved() { + let u = SopsUri::parse("sops://./secrets.yaml?age_recipient=age1abc&f=json").unwrap(); + assert_eq!(u.format.as_deref(), Some("json")); + assert_eq!( + u.extra, + vec![("age_recipient".to_string(), "age1abc".to_string())] + ); + } + + #[test] + fn parse_rejects_wrong_scheme() { + assert!(SopsUri::parse("vault://foo").is_err()); + assert!(SopsUri::parse("./secrets.yaml").is_err()); + } + + #[test] + fn parse_rejects_empty_path() { + assert!(SopsUri::parse("sops://").is_err()); + assert!(SopsUri::parse("sops://?key=foo").is_err()); + } + + #[test] + fn parse_rejects_empty_fragment() { + assert!(SopsUri::parse("sops://./secrets.yaml#").is_err()); + } + + #[test] + fn display_redacts_nothing_in_sopsuri() { + // SopsUri carries no credentials — its Display string is safe. + let u = SopsUri::parse("sops://./secrets.yaml?key=NVIDIA_API_KEY&f=yaml").unwrap(); + let s = format!("{u}"); + assert_eq!(s, "sops://./secrets.yaml?key=NVIDIA_API_KEY&f=yaml"); + } + + #[test] + fn fieldspec_display_redacts_sensitive_by_default() { + let f = FieldSpec::new("age_key", "vault://secret/data/age_key?auth=approle"); + let s = format!("{f}"); + assert_eq!(s, "age_key=***"); + assert!(!s.contains("vault://")); + assert!(!s.contains("approle")); + } + + #[test] + fn fieldspec_display_shows_public_uris() { + let f = FieldSpec::new("vault_addr", "https://astral-key:8080").public(); + let s = format!("{f}"); + assert_eq!(s, "vault_addr=https://astral-key:8080"); + } + + #[test] + fn fieldspec_uri_redacted_returns_raw() { + // The ONLY way to get the unredacted form. Callers committing + // the result to a String that may end up in a log are bugs. + let f = FieldSpec::new("age_key", "vault://secret/data/age_key"); + assert_eq!(f.uri_redacted(), "vault://secret/data/age_key"); + } + + #[test] + fn fromstr_roundtrip() { + let s = "sops://./secrets.yaml?key=foo&f=yaml"; + let u: SopsUri = s.parse().unwrap(); + assert_eq!(format!("{u}"), s); + } +} diff --git a/provider-rust/tests/cli_smoke.rs b/provider-rust/tests/cli_smoke.rs index c6cd761..fa8f97f 100644 --- a/provider-rust/tests/cli_smoke.rs +++ b/provider-rust/tests/cli_smoke.rs @@ -253,10 +253,8 @@ fn encrypt_fixture_inner(leaf: &str, plaintext: &str) -> (TempDir, PathBuf, Path let t = l.trim(); if t.starts_with("age1") { Some(t.to_string()) - } else if let Some(idx) = t.find("age1") { - Some(t[idx..].trim().to_string()) } else { - None + t.find("age1").map(|idx| t[idx..].trim().to_string()) } }) .expect("age1 pubkey from age-keygen stderr"); @@ -309,10 +307,8 @@ fn encrypt_bin_fixture(plaintext: &[u8]) -> (TempDir, PathBuf, PathBuf) { let t = l.trim(); if t.starts_with("age1") { Some(t.to_string()) - } else if let Some(idx) = t.find("age1") { - Some(t[idx..].trim().to_string()) } else { - None + t.find("age1").map(|idx| t[idx..].trim().to_string()) } }) .expect("age1 pubkey from age-keygen stderr"); diff --git a/provider-rust/tests/integration.rs b/provider-rust/tests/integration.rs index 5f7983a..e26c124 100644 --- a/provider-rust/tests/integration.rs +++ b/provider-rust/tests/integration.rs @@ -83,11 +83,13 @@ async fn extract_yaml_key_via_sops_round_trip() { .arg("-o") .arg(&age_keyfile) .output() - .expect("age-keygen executable lookup failed; install with `nix profile install nixpkgs#age`"); + .expect( + "age-keygen executable lookup failed; install with `nix profile install nixpkgs#age`", + ); assert!(age_out.status.success(), "age-keygen exited non-zero"); let pubkey = String::from_utf8_lossy(&age_out.stderr) .lines() - .find_map(|l| parse_age_pubkey_line(l)) + .find_map(parse_age_pubkey_line) .expect("no `age1…` public key found in age-keygen stderr"); // Plaintext YAML. @@ -105,16 +107,27 @@ async fn extract_yaml_key_via_sops_round_trip() { // Sanity: the file is now encrypted (contains SOPS markers). let encrypted_contents = std::fs::read_to_string(&secrets_yaml).unwrap(); - assert!(encrypted_contents.contains("sops:") || encrypted_contents.contains("ENC["), "post-encrypt file should contain SOPS envelope markers"); + assert!( + encrypted_contents.contains("sops:") || encrypted_contents.contains("ENC["), + "post-encrypt file should contain SOPS envelope markers" + ); // Per-test provider: keys live in this TempDir, no global env mutate. let provider = SopsProvider::with_age_keyfile(&age_keyfile); let nvidia_value = provider - .get(secrets_yaml.to_str().unwrap(), "nvidia_api_key", Some("yaml")) + .get( + secrets_yaml.to_str().unwrap(), + "nvidia_api_key", + Some("yaml"), + ) .await .expect("extract nvidia_api_key"); let openai_value = provider - .get(secrets_yaml.to_str().unwrap(), "openai_org_id", Some("yaml")) + .get( + secrets_yaml.to_str().unwrap(), + "openai_org_id", + Some("yaml"), + ) .await .expect("extract openai_org_id"); @@ -137,7 +150,7 @@ async fn extract_missing_yaml_key_returns_key_not_found() { assert!(age_out.status.success(), "age-keygen exited non-zero"); let pubkey = String::from_utf8_lossy(&age_out.stderr) .lines() - .find_map(|l| parse_age_pubkey_line(l)) + .find_map(parse_age_pubkey_line) .expect("no `age1…` public key found in age-keygen stderr"); std::fs::write(&secrets_yaml, "real_key: present\n").expect("write plaintext"); @@ -156,8 +169,8 @@ async fn extract_missing_yaml_key_returns_key_not_found() { "sops --encrypt failed: stderr={}", String::from_utf8_lossy(&encrypt_out.stderr) ); - let encrypted_contents = std::fs::read_to_string(&secrets_yaml) - .expect("read post-encrypt file"); + let encrypted_contents = + std::fs::read_to_string(&secrets_yaml).expect("read post-encrypt file"); assert!( encrypted_contents.contains("sops:") || encrypted_contents.contains("ENC["), "post-encrypt file should contain SOPS envelope markers" @@ -166,7 +179,11 @@ async fn extract_missing_yaml_key_returns_key_not_found() { // Per-test provider: gives us thread-safety without global env mutate. let provider = SopsProvider::with_age_keyfile(&age_keyfile); let result = provider - .get(secrets_yaml.to_str().unwrap(), "not_a_real_key", Some("yaml")) + .get( + secrets_yaml.to_str().unwrap(), + "not_a_real_key", + Some("yaml"), + ) .await; match result { @@ -192,7 +209,7 @@ async fn extract_dotenv_key_via_sops_round_trip() { assert!(age_out.status.success(), "age-keygen exited non-zero"); let pubkey = String::from_utf8_lossy(&age_out.stderr) .lines() - .find_map(|l| parse_age_pubkey_line(l)) + .find_map(parse_age_pubkey_line) .expect("public key in stderr"); // Plaintext dotenv. Uses `export` + quoted + comment forms so @@ -225,11 +242,19 @@ NIX_PACKAGES_CACHE_TOKEN=replace-with-real-token .await .expect("extract N8N_API_KEY"); let webhook_value = provider - .get(secrets_env.to_str().unwrap(), "N8N_WEBHOOK_SECRET", Some("dotenv")) + .get( + secrets_env.to_str().unwrap(), + "N8N_WEBHOOK_SECRET", + Some("dotenv"), + ) .await .expect("extract N8N_WEBHOOK_SECRET"); let cache_token = provider - .get(secrets_env.to_str().unwrap(), "NIX_PACKAGES_CACHE_TOKEN", Some("dotenv")) + .get( + secrets_env.to_str().unwrap(), + "NIX_PACKAGES_CACHE_TOKEN", + Some("dotenv"), + ) .await .expect("extract NIX_PACKAGES_CACHE_TOKEN"); diff --git a/provider-rust/tests/protocol_integration.rs b/provider-rust/tests/protocol_integration.rs new file mode 100644 index 0000000..908d185 --- /dev/null +++ b/provider-rust/tests/protocol_integration.rs @@ -0,0 +1,140 @@ +//! Integration test for the NDJSON plugin protocol (cachix/secretspec#98). +//! +//! Spawns the compiled binary (`CARGO_BIN_EXE_secretspec-provider-sops`) +//! as a subprocess, pipes in a synthetic Hello + Get request, and asserts +//! the JSON response on stdout matches the spec shape. +//! +//! References: +//! - docs/spec/provider-protocol.md (vendored from cachix/secretspec#98) +//! - provider-rust/src/protocol.rs (request/response serde types) + +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; + +fn bin_path() -> &'static str { + // NDJSON protocol provider lives in a separate bin target since + // the user-facing CLI binary (`secretspec-provider-sops`) gained + // clap subcommands for get/doctor/--help; see Cargo.toml's two + // `[[bin]]` entries. + env!("CARGO_BIN_EXE_secretspec-provider-sops-protocol") +} + +#[tokio::test] +async fn hello_advertises_v1_capabilities() { + let mut child = Command::new(bin_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn provider binary"); + + let mut stdin = child.stdin.take().expect("stdin pipe"); + let stdout = child.stdout.take().expect("stdout pipe"); + let mut lines = BufReader::new(stdout).lines(); + + // Hello: per spec example shape verbatim. + let hello = r#"{"op":"hello","protocol_version":1,"uri":"sops://./secrets.yaml","config_file":"/tmp/secretspec.toml","context":{}}"#; + stdin + .write_all(hello.as_bytes()) + .await + .expect("write hello"); + stdin.write_all(b"\n").await.expect("newline after hello"); + stdin.flush().await.expect("flush hello"); + + let resp_line = lines + .next_line() + .await + .expect("read hello resp") + .expect("non-eof"); + let v: serde_json::Value = serde_json::from_str(&resp_line).expect("parse hello resp"); + assert_eq!(v["ok"], serde_json::Value::Bool(true)); + assert_eq!(v["protocol_version"], serde_json::Value::from(1u32)); + assert_eq!(v["name"], serde_json::Value::from("sops")); + + let caps = v["capabilities"].as_array().expect("capabilities is array"); + let cap_set: std::collections::HashSet<&str> = caps.iter().filter_map(|c| c.as_str()).collect(); + for required in ["get", "set", "batch_get", "reflect", "bye"] { + assert!( + cap_set.contains(required), + "missing capability {required}; got {caps:?}" + ); + } + + // Close stdin cleanly so child exits. + drop(stdin); + let _ = child.wait().await; +} + +#[tokio::test] +async fn get_returns_null_on_missing_key_per_spec() { + let mut child = Command::new(bin_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn provider binary"); + + let mut stdin = child.stdin.take().expect("stdin pipe"); + let stdout = child.stdout.take().expect("stdout pipe"); + let mut lines = BufReader::new(stdout).lines(); + + // Skip past the optional Hello by sending one first. + let hello = r#"{"op":"hello","protocol_version":1,"uri":"sops://./secrets.yaml","config_file":"/tmp/secretspec.toml","context":{}}"#; + stdin + .write_all(hello.as_bytes()) + .await + .expect("hello write"); + stdin.write_all(b"\n").await.expect("hello nl"); + let _ = lines + .next_line() + .await + .expect("hello resp") + .expect("non-eof"); + + // Get request for a key that we expect Phase 1 to NOT resolve. + let get_req = r#"{"op":"get","project":"homelab","key":"nvidia_api_key","profile":"default"}"#; + stdin + .write_all(get_req.as_bytes()) + .await + .expect("get write"); + stdin.write_all(b"\n").await.expect("get nl"); + + let resp_line = lines.next_line().await.expect("get resp").expect("non-eof"); + let v: serde_json::Value = serde_json::from_str(&resp_line).expect("parse get resp"); + assert_eq!(v["ok"], serde_json::Value::Bool(true)); + // CRITICAL: per cachix/secretspec#98 section 5.1, missing key MUST + // return {"value": null} (not an error). + assert!( + v["value"].is_null(), + "expected value:null for missing key; got: {v}" + ); + + drop(stdin); + let _ = child.wait().await; +} + +#[tokio::test] +async fn bye_returns_ok() { + let mut child = Command::new(bin_path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn provider binary"); + + let mut stdin = child.stdin.take().expect("stdin pipe"); + let stdout = child.stdout.take().expect("stdout pipe"); + let mut lines = BufReader::new(stdout).lines(); + + stdin + .write_all(b"{\"op\":\"bye\"}\n") + .await + .expect("bye write"); + let resp_line = lines.next_line().await.expect("bye resp").expect("non-eof"); + let v: serde_json::Value = serde_json::from_str(&resp_line).expect("bye parse"); + assert_eq!(v["ok"], serde_json::Value::Bool(true)); + + drop(stdin); + let _ = child.wait().await; +} diff --git a/scripts/bootstrap-dev.sh b/scripts/bootstrap-dev.sh index 8c938e2..b46ade0 100755 --- a/scripts/bootstrap-dev.sh +++ b/scripts/bootstrap-dev.sh @@ -57,3 +57,42 @@ Next steps: $EDITOR .env.secrets # replace placeholders with real values secretspec check --profile development NEXT + +# Regression guard: confirm the dev profile still resolves end-to-end. +# graceful-passthrough: if `secretspec` isn't on PATH (e.g. minimal CI +# image without rustup profiles installed) we skip rather than fail — +# the dev VM is expected to have it via `nix profile install nixpkgs#secretspec`. +# +# Note: command -v returns the full executable path on success and exits +# non-zero on miss; the `[ -x … ]` test handles both branches. + +SECRETSPEC_BIN="$(command -v secretspec 2>/dev/null || true)" +if [ -z "$SECRETSPEC_BIN" ] || [ ! -x "$SECRETSPEC_BIN" ]; then + # Fall back to the standard local-bin install (matches CI workflow + the + # v0.16 direct-release path documented in README.md). Only check + # executability here — a non-executable file at this path is still a + # miss so we treat it like "not installed". + if [ -x "$HOME/.local/bin/secretspec" ]; then + SECRETSPEC_BIN="$HOME/.local/bin/secretspec" + else + echo " ↪ secretspec not on PATH; skipping dev-profile check." + echo " Install: nix profile install nixpkgs#secretspec (or curl v0.16 direct release)." + SECRETSPEC_BIN="" + fi +fi + +if [ -n "$SECRETSPEC_BIN" ]; then + echo "→ secretspec check --profile development (sanity)" + if "$SECRETSPEC_BIN" check -f "$REPO_ROOT/secretspec.toml" --profile development; then + echo " ✓ secretspec check passed (49/49 resolved)" + else + echo " ⚠ secretspec check failed (placeholders are unedited?)." >&2 + echo " Edit .env.secrets to set real values, OR re-run with" >&2 + echo " SECRETSPEC_SKIP_CHECK=1 to bypass this guard." >&2 + if [ "${SECRETSPEC_SKIP_CHECK:-0}" = "1" ]; then + echo " (SECRETSPEC_SKIP_CHECK=1 set — skipping failure exit.)" + else + exit 2 + fi + fi +fi diff --git a/secrets/secrets.ai.yaml b/secrets/secrets.ai.yaml new file mode 100644 index 0000000..650efd4 --- /dev/null +++ b/secrets/secrets.ai.yaml @@ -0,0 +1,18 @@ +# secrets.ai.yaml — Phase 2 SOPS target. Rotate before production. +# +# Replace each REPLACE_ME_* below with the real secret value, then: +# sops --encrypt --age --in-place secrets.ai.yaml +# The `sops://secrets.ai.yaml#` URI in secretspec.toml will then +# resolve transparently through `secretspec-provider-sops`. +# +# Per migration-matrix.md Phase 2 routing; Phase 3 final homes are +# onepassword://Homelab/AI. + +# Per-key placeholder content — replace with real values before encrypting. +nvidia_api_key: "REPLACE_ME_NVIDIA" +openai_api_key: "REPLACE_ME_OPENAI" +anthropic_api_key: "REPLACE_ME_ANTHROPIC" +openai_org_id: "REPLACE_ME_OPENAI_ORG_ID" +huggingface_token: "REPLACE_ME_HUGGINGFACE" +stability_api_key: "REPLACE_ME_STABILITY" +replicate_api_token: "REPLACE_ME_REPLICATE" diff --git a/secrets/secrets.automation.yaml b/secrets/secrets.automation.yaml new file mode 100644 index 0000000..f44001d --- /dev/null +++ b/secrets/secrets.automation.yaml @@ -0,0 +1,13 @@ +# secrets.automation.yaml — Phase 2 SOPS target. n8n + Ansible + act_runner. +# +# N8N_WEBHOOK_SECRET is a hex HMAC key (declared `type = "hex"` in +# secretspec.toml). ANSIBLE_VAULT_PASSWORD unlocks the homelab's +# Ansible Vault files (`ansible-vault view ...`). +# +# HOMELAB_RUNNER_REG_TOKEN is CI-side (runner registration); stays +# at `env://` in Phase 3 — the SOPS file is only for repo rotation. + +n8n_api_key: "REPLACE_ME_N8N_API_KEY" +n8n_webhook_secret: "REPLACE_ME_N8N_WEBHOOK_SECRET" # hex HMAC; replace with 64-char hex +ansible_vault_password: "REPLACE_ME_ANSIBLE_VAULT_PASSWORD" +homelab_runner_reg_token: "REPLACE_ME_HOMELAB_RUNNER_REG_TOKEN" diff --git a/secrets/secrets.ci.yaml b/secrets/secrets.ci.yaml new file mode 100644 index 0000000..bcb538d --- /dev/null +++ b/secrets/secrets.ci.yaml @@ -0,0 +1,11 @@ +# secrets.ci.yaml — Phase 2 SOPS target. CI runtime only. +# +# Per migration-matrix.md: Phase 3 final home for these stays `env://` +# because they're consumed only at CI build time. The SOPS file exists +# only so the homelab CI runner can rotate them in version control. +# +# Replace REPLACE_ME_*, then `sops --encrypt --age $RECIPIENT --in-place`. + +github_token: "REPLACE_ME_GITHUB_TOKEN" +github_runner_pat: "REPLACE_ME_GITHUB_RUNNER_PAT" +nix_packages_cache_token: "REPLACE_ME_NIX_PACKAGES_CACHE_TOKEN" diff --git a/secrets/secrets.cloud.yaml b/secrets/secrets.cloud.yaml new file mode 100644 index 0000000..ff75272 --- /dev/null +++ b/secrets/secrets.cloud.yaml @@ -0,0 +1,16 @@ +# secrets.cloud.yaml — Phase 2 SOPS target. Cloud-provider credentials. +# +# Per migration-matrix.md Phase 3 routing, most keys in this file end up +# in `onepassword://Homelab/Cloud`; HOMELAB_DUCKDNS_TOKEN stays per-host +# in `keyring://` because DuckDNS tokens are issued per-update-token. +# +# ACME_ACCOUNT_KEY is sensitive (it's the Let's Encrypt EAB key); all +# other keys here handle third-party cloud-provider auth. + +cloudflare_tunnel_token: "REPLACE_ME_CLOUDFLARE_TUNNEL_TOKEN" +cloudflare_api_key: "REPLACE_ME_CLOUDFLARE_API_KEY" +cloudflare_email: "REPLACE_ME_CLOUDFLARE_EMAIL" +cloudflare_zone_id: "REPLACE_ME_CLOUDFLARE_ZONE_ID" +tailscale_auth_key: "REPLACE_ME_TAILSCALE_AUTH_KEY" +homelab_duckdns_token: "REPLACE_ME_HOMELAB_DUCKDNS_TOKEN" +acme_account_key: "REPLACE_ME_ACME_ACCOUNT_KEY" diff --git a/secrets/secrets.kubernetes.yaml b/secrets/secrets.kubernetes.yaml new file mode 100644 index 0000000..06d7a9d --- /dev/null +++ b/secrets/secrets.kubernetes.yaml @@ -0,0 +1,13 @@ +# secrets.kubernetes.yaml — Phase 2 SOPS target. K3s cluster bootstrap. +# +# Per migration-matrix.md Phase 3 routing, all four keys in this file +# ultimately land at `vault://…/v1/secret/data/?auth=approle` via +# astral-key. Phase 2 stops at SOPS in-repo. +# +# KUBECONFIG_ADMIN is currently base64-in-env (Phase 1) and migrates to +# raw-file via `as_path = true` in Phase 3. + +k3s_cluster_token: "REPLACE_ME_K3S_CLUSTER_TOKEN" +kubeconfig_admin: "REPLACE_ME_KUBECONFIG_ADMIN_BASE64" +kubeadmin_password: "REPLACE_ME_KUBEADMIN_PASSWORD" +k3s_kubelet_token: "REPLACE_ME_K3S_KUBELET_TOKEN" diff --git a/secrets/secrets.mining.yaml b/secrets/secrets.mining.yaml new file mode 100644 index 0000000..95acf0f --- /dev/null +++ b/secrets/secrets.mining.yaml @@ -0,0 +1,17 @@ +# secrets.mining.yaml — Phase 2 SOPS target. Mining pool + wallet keys. +# +# ⚠ Wallet-key blocklist: ETHEREUM_WALLET_KEY + MONERO_WALLET_KEY must +# NEVER resolve from `keyring://`. They're either `onepassword://` +# (daily use) or `sops://` cold-storage. sops-provider-design.md ↗ +# "Wallet key blocklist" is the canonical statement. +# +# ETHEREUM_WALLET_ADDRESS + MONERO_WALLET_ADDRESS are PUBLIC values — +# including them in secrets.ai/yml is intentional, mirrors the +# migration-matrix.md Phase 2 row parity, and doesn't expose any key. + +ethereum_wallet_key: "REPLACE_ME_ETHEREUM_WALLET_KEY" +monero_wallet_key: "REPLACE_ME_MONERO_WALLET_KEY" +ethereum_wallet_address: "REPLACE_ME_ETHEREUM_WALLET_ADDRESS" +monero_wallet_address: "REPLACE_ME_MONERO_WALLET_ADDRESS" +mining_pool_user: "REPLACE_ME_MINING_POOL_USER" +mining_pool_pass: "REPLACE_ME_MINING_POOL_PASSWORD" diff --git a/secrets/secrets.monitoring.yaml b/secrets/secrets.monitoring.yaml new file mode 100644 index 0000000..0b9ba87 --- /dev/null +++ b/secrets/secrets.monitoring.yaml @@ -0,0 +1,13 @@ +# secrets.monitoring.yaml — Phase 2 SOPS target. Grafana + Prometheus + Loki + UptimeRobot. +# +# Per migration-matrix.md Phase 3 routing, four of these keys end up +# behind `vault://…?auth=approle` via astral-key +# (GRAFANA_ADMIN_PASSWORD, PROMETHEUS_REMOTE_WRITE_TOKEN, LOKI_INGEST_TOKEN, +# GRAFANA_OAUTH_CLIENT_SECRET). UPTIME_ROBOT_API_KEY stays at +# `onepassword://Homelab/Monitoring` since it's a single third-party API key. + +grafana_admin_password: "REPLACE_ME_GRAFANA_ADMIN_PASSWORD" +prometheus_remote_write_token: "REPLACE_ME_PROMETHEUS_REMOTE_WRITE_TOKEN" +loki_ingest_token: "REPLACE_ME_LOKI_INGEST_TOKEN" +uptime_robot_api_key: "REPLACE_ME_UPTIME_ROBOT_API_KEY" +grafana_oauth_client_secret: "REPLACE_ME_GRAFANA_OAUTH_CLIENT_SECRET" diff --git a/secrets/secrets.selfhosting.yaml b/secrets/secrets.selfhosting.yaml new file mode 100644 index 0000000..dd734cd --- /dev/null +++ b/secrets/secrets.selfhosting.yaml @@ -0,0 +1,20 @@ +# secrets.selfhosting.yaml — Phase 2 SOPS target. Vaultwarden + media stack. +# +# VAULTWARDEN_ADMIN_TOKEN, MAIL_SERVER_PASSWORD, and VAULTWARDEN_SMTP_PASSWORD +# route through `vault://…?auth=approle` via astral-key in Phase 3 +# (Vaultwarden IS the password backend; mail-server creds ride along). +# +# JELLYFIN_API_KEY / PAPERLESS_API_TOKEN / PHOTOPRISM_ADMIN_PASSWORD / +# MEALIE_API_KEY all stay at `onepassword://Homelab/SelfHosting`. +# +# NETWORK_WIFI_PASSWORD is per-host (low-risk, rolled only when the +# AP's PSK rotates); Phase 3 final home is `keyring://` per-host. + +vaultwarden_admin_token: "REPLACE_ME_VAULTWARDEN_ADMIN_TOKEN" +mail_server_password: "REPLACE_ME_MAIL_SERVER_PASSWORD" +jellyfin_api_key: "REPLACE_ME_JELLYFIN_API_KEY" +paperless_api_token: "REPLACE_ME_PAPERLESS_API_TOKEN" +photoprism_admin_password: "REPLACE_ME_PHOTOPRISM_ADMIN_PASSWORD" +mealie_api_key: "REPLACE_ME_MEALIE_API_KEY" +vaultwarden_smtp_password: "REPLACE_ME_VAULTWARDEN_SMTP_PASSWORD" +network_wifi_password: "REPLACE_ME_NETWORK_WIFI_PASSWORD" diff --git a/secrets/secrets.storage.yaml b/secrets/secrets.storage.yaml new file mode 100644 index 0000000..4c6bd67 --- /dev/null +++ b/secrets/secrets.storage.yaml @@ -0,0 +1,12 @@ +# secrets.storage.yaml — Phase 2 SOPS target. S3 + restic + rclone + age. +# +# BACKUP_ENCRYPTION_KEY deliberately excluded from `keyring://` per +# sops-provider-design.md "Wallet key blocklist" — it's a master age +# encryption key whose compromise would expose the entire fleet's +# backups. Phase 3 final home: `onepassword://Homelab/Storage`. + +garage_access_key: "REPLACE_ME_GARAGE_ACCESS_KEY" +garage_secret_key: "REPLACE_ME_GARAGE_SECRET_KEY" +restic_repo_password: "REPLACE_ME_RESTIC_REPO_PASSWORD" +rclone_config_passphrase: "REPLACE_ME_RCLONE_CONFIG_PASSPHRASE" +backup_encryption_key: "REPLACE_ME_BACKUP_ENCRYPTION_KEY" diff --git a/services/secretspec-usage.md b/services/secretspec-usage.md new file mode 100644 index 0000000..d9a8561 --- /dev/null +++ b/services/secretspec-usage.md @@ -0,0 +1,158 @@ +# Service Wiring — How Secrets Arrive at Runtime + +> Reference: `secretspec.toml` is the *declaration* of intent ("this +> secret must exist"); this document is the *delivery* contract +> ("and here's how it actually reaches the consuming service"). +> Phase 1 today runs entirely on `/etc/nixos` + host-local `secretspec +> check`; Phase 2+ adds `sops://` resolution from age-encrypted YAML +> under `secrets/`; Phase 3+ adds `onepassword://` and `vault://` +> routing via astral-key. + +## Two injection styles + +Every consuming service gets secrets through one of two patterns. +**Pick per service based on its lifecycle**: stateless / one-shot → +`secretspec run --`; long-running daemon → systemd-creds. + +### Pattern A — `secretspec run --profile -- ` + +Injects every resolved secret as `KEY=value` env vars, then execve()s the +command. Works on any host with `secretspec` ≥ v0.16 on PATH and a +`secretspec.toml` discoverable via `-f`. + +```bash +secretspec run -f /etc/secretspec.toml --profile production -- \ + /usr/bin/grafana-server --config=/etc/grafana/grafana.ini +``` + +Use for: cloudflared, tailscaled, garage, act_runner, one-shot cron jobs, +DuckDNS updater, ACME renewals. + +### Pattern B — systemd `LoadCredential=(Encrypted)` + +`secretspec get` resolves each key to bytes → hands to +`systemd-creds encrypt -` → writes an encrypted blob to +`/etc/credstore/` → service unit's `LoadCredentialEncrypted=` references +it → service reads plaintext from `$CREDENTIALS_DIRECTORY/` at +runtime. The host-side decrypt key lives in the TPM (or sealed on +disk-bound to PCR state). This is the canonical community pattern from +cachix/secretspec#65. + +```nix +systemd.services.grafana = { + serviceConfig.LoadCredentialEncrypted = [ + "grafana_admin_password:/etc/credstore/grafana-admin_password.cred" + "grafana_oauth_client_secret:/etc/credstore/grafana-oauth_client_secret.cred" + ]; + # Grafana specific: secret is in /etc/grafana/grafana.ini admin section + serviceConfig.ExecStartPre = [ + "${pkgs.bash}/bin/bash -c 'admin_password=$(cat $CREDENTIALS_DIRECTORY/grafana_admin_password); printf \"admin_password = %s\\n\" \"$admin_password\" >> /etc/grafana/grafana-secrets.ini'" + ]; +}; +``` + +Use for: long-running daemons that already have systemd unit config +(Grafana, Prometheus, Loki, K3s, Vaultwarden, mail server, Jellyfin, +Paperless, PhotoPrism, Mealie). + +--- + +## Per-service map + +| Service | Manifest key(s) | Pattern | Phase 2 SOPS target | Phase 3 final home | +|---------|-----------------|---------|-------------------|-------------------| +| **Grafana** | `GRAFANA_ADMIN_PASSWORD` + `GRAFANA_OAUTH_CLIENT_SECRET` | B (load + ini snippet) | `secrets.monitoring.yaml#grafana_admin_password` | `vault://…?auth=approle` (astral-key) | +| **Prometheus** | `PROMETHEUS_REMOTE_WRITE_TOKEN` | B | `secrets.monitoring.yaml#prometheus_remote_write_token` | `vault://…?auth=approle` | +| **Loki** | `LOKI_INGEST_TOKEN` | B | `secrets.monitoring.yaml#loki_ingest_token` | `vault://…?auth=approle` | +| **K3s server** | `K3S_CLUSTER_TOKEN` + `KUBECONFIG_ADMIN` + `K3S_KUBELET_TOKEN` | B | `secrets.kubernetes.yaml#k3s_cluster_token` etc. | `vault://…?auth=approle` | +| **Kubernetes dashboard** | `KUBEADMIN_PASSWORD` | B | `secrets.kubernetes.yaml#kubeadmin_password` | `vault://…?auth=approle` | +| **Vaultwarden** | `VAULTWARDEN_ADMIN_TOKEN` + `VAULTWARDEN_SMTP_PASSWORD` | B | `secrets.selfhosting.yaml#vaultwarden_admin_token` etc. | `vault://…?auth=approle` | +| **Mail server** | `MAIL_SERVER_PASSWORD` | B | `secrets.selfhosting.yaml#mail_server_password` | `vault://…?auth=approle` | +| **n8n (API)** | `N8N_API_KEY` | A or B | `secrets.automation.yaml#n8n_api_key` | `onepassword://Homelab/Automation` | +| **n8n (webhook)** | `N8N_WEBHOOK_SECRET` (hex HMAC) | A or B | `secrets.automation.yaml#n8n_webhook_secret` | `onepassword://Homelab/Automation` | +| **Paperless-NGX** | `PAPERLESS_API_TOKEN` | B | `secrets.selfhosting.yaml#paperless_api_token` | `onepassword://Homelab/SelfHosting` | +| **PhotoPrism** | `PHOTOPRISM_ADMIN_PASSWORD` | B | `secrets.selfhosting.yaml#photoprism_admin_password` | `onepassword://Homelab/SelfHosting` | +| **Mealie** | `MEALIE_API_KEY` | B | `secrets.selfhosting.yaml#mealie_api_key` | `onepassword://Homelab/SelfHosting` | +| **Jellyfin** | `JELLYFIN_API_KEY` | B | `secrets.selfhosting.yaml#jellyfin_api_key` | `onepassword://Homelab/SelfHosting` | +| **UptimeRobot poller** | `UPTIME_ROBOT_API_KEY` | A | `secrets.monitoring.yaml#uptime_robot_api_key` | `onepassword://Homelab/Monitoring` | +| **Cloudflared** | `CLOUDFLARE_TUNNEL_TOKEN` | A | `secrets.cloud.yaml#cloudflare_tunnel_token` | `onepassword://Homelab/Cloud` | +| **Tailscale** | `TAILSCALE_AUTH_KEY` | A | `secrets.cloud.yaml#tailscale_auth_key` | `onepassword://Homelab/Cloud` | +| **Garage S3** | `GARAGE_ACCESS_KEY` + `GARAGE_SECRET_KEY` | A | `secrets.storage.yaml#garage_access_key` etc. | `onepassword://Homelab/Storage` | +| **act_runner** | `HOMELAB_RUNNER_REG_TOKEN` | A | `secrets.automation.yaml#homelab_runner_reg_token` | `env://` (CI runtime only) | +| **DuckDNS updater** | `HOMELAB_DUCKDNS_TOKEN` | A (cron) | `secrets.cloud.yaml#homelab_duckdns_token` | `keyring://` (per-host) | +| **ACME / certbot** | `ACME_ACCOUNT_KEY` (EAB) | A | `secrets.cloud.yaml#acme_account_key` | `onepassword://Homelab/Cloud` | +| **Ansible Vault** | `ANSIBLE_VAULT_PASSWORD` | A (per `ansible-playbook` invocation) | `secrets.automation.yaml#ansible_vault_password` | `onepassword://Homelab/Automation` | +| **Restic backup** | `RESTIC_REPO_PASSWORD` + `BACKUP_ENCRYPTION_KEY` | A | `secrets.storage.yaml#restic_repo_password` etc. | `onepassword://Homelab/Storage` ⚠ never `keyring://` | +| **rclone** | `RCLONE_CONFIG_PASS` | A | `secrets.storage.yaml#rclone_config_passphrase` | `onepassword://Homelab/Storage` | +| **Mining pool** | `MINING_POOL_USER` + `MINING_POOL_PASS` | A | `secrets.mining.yaml#mining_pool_user` | `onepassword://Homelab/Mining` | +| **Mining wallet cold-storage** | `ETHEREUM_WALLET_KEY` + `MONERO_WALLET_KEY` | A (cold-import only) | `secrets.mining.yaml#ethereum_wallet_key` etc. | `sops://` cold-storage ⚠ never `keyring://` | +| **Local WiFi** | `NETWORK_WIFI_PASSWORD` | A (per-host NetworkManager dispatcher) | `secrets.selfhosting.yaml#network_wifi_password` | `keyring://` (per-host, low risk) | + +--- + +## Caller-side / dashboard-managed keys + +Several declared secrets are consumed **outside** the systemd/homelab +service boundary — by browser-dashboard admin sessions, by ad-hoc +developer scripts, by CI runners, or are PUBLIC values that exist only +for route parity. These appear in `secretspec.toml` and have +`secrets..yaml` target files, but they do **not** get a per-service +`LoadCredentialEncrypted=` wiring. Listed here for completeness so +operators know where to find them. + +| Manifest key(s) | Consumer class | Notes | +|-----------------|----------------|-------| +| `CLOUDFLARE_API_KEY` + `CLOUDFLARE_EMAIL` + `CLOUDFLARE_ZONE_ID` | Cloudflare dashboard (`dash.cloudflare.com`) + terraform / cloudflare-go scripts | These three are the DNS-edit auth tuple. Manual rotation via the dashboard; script-side consumers usually re-source from a TF vars file pinning to `sops://secrets.cloud.yaml#*`. Phase 3 final home: `onepassword://Homelab/Cloud`. | +| **AI keys** (`NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `OPENAI_ORG_ID` / `HUGGINGFACE_TOKEN` / `STABILITY_API_KEY` / `REPLICATE_API_TOKEN`) | ad-hoc dev scripts, LLM-backed tooling, external benchmarks | Sourced via `secretspec run --profile development --