rotate: 4 SaaS-token/app-signing drivers + data-separated proof tracking
Add gitlab, cloudflare, ghactions (MOCK-ONLY) and appsecret (LIVE-VM) one-file Rotators, each with a table-driven cutover-proof + leak-check test. gitlab/ cloudflare/ghactions are in-place SaaS-token rolls (self/rotate, value-roll, sealed-secret overwrite) so RevokeOld is a no-op; ghactions seals via nacl/box.SealAnonymous (no new go.mod dep). appsecret regenerates a local app signing secret and rewrites it in place across every target file (atomic, mode- preserving, redacted errors), discoverable via exact-match app-signing key names in env.go. Keep real-rotation code distinct from mock code: how each driver's cutover was validated lives as DATA in internal/rotate/proofs.go (single source of truth) + docs/ROTATION-PROOFS.md, surfaced as a PROOF column in `incredigo rotate` so a MOCK-ONLY driver can never be mistaken for a LIVE-VM one. appsecret proven LIVE-VM against real local files in the sandbox VM. 82 tests green, -race clean on rotate/sink/vault. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+14
-8
@@ -442,7 +442,7 @@ func rotateCmd() *cobra.Command {
|
||||
}
|
||||
results := rotate.Execute(ctx, gp, ecreds, v)
|
||||
ew := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||
fmt.Fprintln(ew, "DRIVER\tENTRY\tROTATED\tVERIFIED\tSTORED\tREVOKED\tERROR")
|
||||
fmt.Fprintln(ew, "DRIVER\tPROOF\tENTRY\tROTATED\tVERIFIED\tSTORED\tREVOKED\tERROR")
|
||||
var rotated, failed int
|
||||
for _, r := range results {
|
||||
errStr := ""
|
||||
@@ -452,8 +452,8 @@ func rotateCmd() *cobra.Command {
|
||||
} else {
|
||||
rotated++
|
||||
}
|
||||
fmt.Fprintf(ew, "%s\t%s\t%t\t%t\t%t\t%t\t%s\n",
|
||||
r.Driver, r.StorePath, r.Rotated, r.Verified, r.Stored, r.Revoked, errStr)
|
||||
fmt.Fprintf(ew, "%s\t%s\t%s\t%t\t%t\t%t\t%t\t%s\n",
|
||||
r.Driver, rotate.ProofLevelOf(r.Driver).String(), r.StorePath, r.Rotated, r.Verified, r.Stored, r.Revoked, errStr)
|
||||
out := "ok"
|
||||
if r.Err != nil {
|
||||
out = r.Err.Error()
|
||||
@@ -486,20 +486,26 @@ func rotateCmd() *cobra.Command {
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||
if blastScan {
|
||||
fmt.Fprintln(w, "DRIVER\tSOURCE\tIDENTITY\tCONSUMERS")
|
||||
fmt.Fprintln(w, "DRIVER\tPROOF\tSOURCE\tIDENTITY\tCONSUMERS")
|
||||
} else {
|
||||
fmt.Fprintln(w, "DRIVER\tSOURCE\tIDENTITY")
|
||||
fmt.Fprintln(w, "DRIVER\tPROOF\tSOURCE\tIDENTITY")
|
||||
}
|
||||
for i, p := range rotate.PlanAll(creds) {
|
||||
drv := p.Driver
|
||||
// PROOF is how the driver's real-cutover path was validated
|
||||
// (LIVE-VM vs MOCK-ONLY) — surfaced so a mock-only driver can
|
||||
// never be mistaken for a live one. No driver => no proof.
|
||||
proof := "-"
|
||||
if drv == "" {
|
||||
drv = "(none)"
|
||||
} else {
|
||||
proof = rotate.ProofLevelOf(p.Driver).String()
|
||||
}
|
||||
if blastScan {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%d file(s)\n",
|
||||
drv, p.Credential.Source, p.Credential.Identity, len(radius[i].Files()))
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d file(s)\n",
|
||||
drv, proof, p.Credential.Source, p.Credential.Identity, len(radius[i].Files()))
|
||||
} else {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", drv, p.Credential.Source, p.Credential.Identity)
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", drv, proof, p.Credential.Source, p.Credential.Identity)
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
@@ -203,6 +203,67 @@ secret-name heuristic. Regression: `scanners_test.go:TestEnvScanner`.
|
||||
|
||||
Full suite after the batch: **68 green**, `-race` clean on rotate/sink/vault/discover.
|
||||
|
||||
## SaaS-token / app-signing batch — 4 drivers + proof manifest, 2026-06-18
|
||||
|
||||
Four more drivers, each one-file + table-driven test (cutover proof + leak-check). User
|
||||
direction this batch: **keep real-rotation code unmistakably distinct from mock code, and
|
||||
record the real-vs-mock distinction as DATA, not behaviour.** Implemented as a single Go
|
||||
table (`internal/rotate/proofs.go`) + a human mirror (`docs/ROTATION-PROOFS.md`), surfaced
|
||||
as a **PROOF** column in `incredigo rotate` so a mock-only driver can never be mistaken for
|
||||
a live one.
|
||||
|
||||
- **gitlab** (`gitlab.go`, MOCK-ONLY) — GitLab PAT via `POST
|
||||
/api/v4/personal_access_tokens/self/rotate` (`PRIVATE-TOKEN` header). GitLab mints the
|
||||
new token *and revokes the caller's old one in the same call* → in-place, `RevokeOld`
|
||||
no-op. Blob `gitlab://<host>/?tok=<token>`. The `httptest` emulator enforces the revoke,
|
||||
so the unit test's `Verify(old)`-fails assertion proves a real cutover. GitLab CE is too
|
||||
heavy for the VM → MOCK-ONLY.
|
||||
- **cloudflare** (`cloudflare.go`, MOCK-ONLY) — Cloudflare API token *value roll* via
|
||||
`PUT /client/v4/user/tokens/{id}/value` (Bearer). Same token id, fresh value, previous
|
||||
value dead → in-place, `RevokeOld` no-op; keeps id/policies/name stable. Blob
|
||||
`cloudflare://<host>/?id=<id>&token=<val>`. Verify via `/tokens/verify` (`status:active`).
|
||||
- **ghactions** (`ghactions.go`, MOCK-ONLY) — GitHub Actions repo secret. Fetches the repo
|
||||
libsodium public key (`actions/secrets/public-key`), seals a fresh value with
|
||||
`crypto_box_seal` (`golang.org/x/crypto/nacl/box.SealAnonymous`, no new go.mod dep), and
|
||||
`PUT`s it over the existing secret name (overwrite = cutover) → `RevokeOld` no-op. The
|
||||
rotated material is the **secret value** (`val=`), NOT the management `pat=` that
|
||||
authenticates the call. GitHub never returns secret values, so Verify is an
|
||||
existence/authorization check (200 on the secret), not a value match. The emulator owns
|
||||
the box keypair and **decrypts the sealed PUT**, asserting the decrypted value equals the
|
||||
driver's new blob value — proving the encryption is real, not simulated.
|
||||
- **appsecret** (`appsecret.go`, **LIVE-VM**) — LOCAL app signing secret (Django
|
||||
`SECRET_KEY`, Rails `secret_key_base`, JWT/session secrets, …). Generates a fresh
|
||||
64-hex value and rewrites it **in place across every target file** (atomic temp+rename,
|
||||
mode preserved), keyed on the literal old value so it is file-format-agnostic
|
||||
(.env/yaml/json/toml). In-place ⇒ `RevokeOld` no-op; Verify re-reads every file asserting
|
||||
new-present / old-absent. Blob `appsecret://local/?key=&val=&path=…[&path=…]`. A
|
||||
`minSecretLen` (12) guard refuses to key a replacement on a short/common string
|
||||
(mass-corruption guard), and a zero-replacement result is an error (never claim a
|
||||
rotation that didn't happen). Errors scrub the value via `redactErr`.
|
||||
|
||||
**appsecret discovery (`env.go`).** An exact-match set of app-signing key names
|
||||
(`SECRET_KEY`, `SECRET_KEY_BASE`, `JWT_SECRET`, `APP_KEY`, …) now tags those `.env` entries
|
||||
`Source="appsecret"` with a self-contained blob carrying the value AND the file path, so the
|
||||
local rotator is driver-ready straight from a scan. Generic API keys (STRIPE_API_KEY) stay
|
||||
plain env secrets. Regression: `scanners_test.go:TestEnvScannerAppSecret`.
|
||||
|
||||
**appsecret live VM cutover (LIVE-VM).** `lab-provision-appsec.sh` wrote two real config
|
||||
files (`labapp/.env` 0600, `labworker/config.yaml` 0644) sharing one `SECRET_KEY`, seeded
|
||||
the blob into isolated gopass, and ran the shipped binary `incredigo rotate --execute
|
||||
--prefix imported/`. All assertions PASSED: backup gate sealed+verified first; both files
|
||||
rewritten with a fresh 64-hex value; OLD value gone from both; unrelated `PORT=8080`
|
||||
preserved; 0644 mode preserved. The run's table showed `appsecret LIVE-VM` — confirming the
|
||||
new PROOF column renders in the real binary.
|
||||
|
||||
**Honesty taxonomy (the point of the manifest).** Running in the VM does NOT make a driver
|
||||
LIVE-VM — only the *real target software* validating the cutover does. So this batch is
|
||||
3× MOCK-ONLY (gitlab/cloudflare/ghactions: no self-host / heavy CE) + 1× LIVE-VM
|
||||
(appsecret: real local files). AWS remains the canonical example: it ran in the VM but only
|
||||
against `moto` (a mock), so it is MOCK-ONLY. Promoting a MOCK-ONLY driver needs only a real
|
||||
service + re-run — zero driver code changes (the drivers contain no test awareness).
|
||||
|
||||
Full suite after the batch: **82 green**, `-race` clean on rotate/sink/vault.
|
||||
|
||||
## Summary
|
||||
|
||||
- Harness result: **31/31 PASS**, including the global secret-leak canary (no CANARY
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Rotation cutover proofs
|
||||
|
||||
This file is the **human-facing mirror** of `internal/rotate/proofs.go`. That Go table is
|
||||
the single source of truth (`incredigo rotate` surfaces it as the **PROOF** column); this
|
||||
document explains what each level *means* and records the basis for every driver's level.
|
||||
Keep the two in sync — if you change one, change the other.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Every `Rotator` in `internal/rotate` contains **only real rotation code**: it talks to a
|
||||
real service (HTTP API, DB client, SSH, local files). None of them contain simulation
|
||||
branches or test awareness — the only thing a test injects is an endpoint / `HTTPClient` /
|
||||
binary path, which a real deployment also configures.
|
||||
|
||||
What differs between drivers is **how their real-cutover path has been validated**. That is
|
||||
*data, not behaviour*, so it lives in one table (here + `proofs.go`) instead of as prose
|
||||
scattered through — and easily confused with — the driver code. This is the guardrail that
|
||||
stops a mock-only driver from ever being mistaken for a live one.
|
||||
|
||||
## Levels
|
||||
|
||||
| Level | Meaning |
|
||||
|-----------|---------|
|
||||
| **LIVE-VM** | Cutover proven against the **real target software** running in the sandbox VM (real PostgreSQL / MariaDB / Redis / `wg` / Gitea / local files). |
|
||||
| **MOCK-ONLY** | Cutover proven only against an **emulator** — our own `httptest` / in-process SSH server, **or a third-party mock** (e.g. `moto` for AWS). **NOT** proven against the real target service. |
|
||||
| **UNPROVEN** | No cutover proof recorded (e.g. the dry-run noop helper). |
|
||||
|
||||
> **Honesty note:** running in the VM does **not** automatically make a driver LIVE-VM.
|
||||
> AWS ran in the VM but only against **moto** (a mock AWS), so it is **MOCK-ONLY**. LIVE-VM
|
||||
> means the *real* target software validated the cutover.
|
||||
|
||||
## Current status
|
||||
|
||||
### LIVE-VM — proven against real target software in the sandbox VM
|
||||
|
||||
| Driver | Real target | Provisioner |
|
||||
|------------|-------------|-------------|
|
||||
| `postgres` | real PostgreSQL | `lab-provision-pg.sh` |
|
||||
| `mysql` | real MariaDB | `lab-provision-dbclones.sh` |
|
||||
| `redis` | real `redis-server` | `lab-provision-dbclones.sh` |
|
||||
| `wireguard` | real `wg` | `lab-provision-wg.sh` |
|
||||
| `gitea` | real Gitea | `lab-provision-gitea.sh` |
|
||||
| `appsecret` | real local files | `lab-provision-appsec.sh` |
|
||||
|
||||
### MOCK-ONLY — proven only against an emulator / mock
|
||||
|
||||
| Driver | Emulator | Why not (yet) LIVE-VM |
|
||||
|--------------|----------|------------------------|
|
||||
| `aws` | `moto` mock AWS in VM | real AWS never hit |
|
||||
| `sshkey` | in-process SSH server | live VM POC not run |
|
||||
| `openwrt` | in-process SSH server | live QEMU deferred |
|
||||
| `mullvad` | `httptest` emulator | needs a paid account |
|
||||
| `cloudflare` | `httptest` emulator | no self-host |
|
||||
| `ghactions` | `httptest` emulator (holds the libsodium box keypair, decrypts the sealed PUT) | no self-host |
|
||||
| `gitlab` | `httptest` emulator | GitLab CE too heavy for the VM |
|
||||
|
||||
## What the MOCK-ONLY emulators actually prove
|
||||
|
||||
The emulators are **not** stubs that rubber-stamp success — each enforces the real
|
||||
protocol so the test exercises the driver's true cutover logic:
|
||||
|
||||
- **gitlab** — `self/rotate` mints a new PAT *and revokes the caller's old one*; the test
|
||||
asserts `Verify(old)` fails after `Rotate`, proving the revoke really happened.
|
||||
- **cloudflare** — `PUT .../value` rolls the value for the same token id and invalidates
|
||||
the previous value; the test asserts the id is unchanged and `Verify(old)` fails.
|
||||
- **ghactions** — the emulator owns a real NaCl `box` keypair, serves the public key, and
|
||||
on `PUT` **decrypts the sealed value** and stores the plaintext; the test asserts the
|
||||
decrypted value equals the new value the driver put in the rebuilt blob — proving the
|
||||
sealed-box encryption is correct, not simulated.
|
||||
|
||||
Promoting any of these to LIVE-VM only requires standing up the real service (a self-hosted
|
||||
GitLab CE / a real Cloudflare token / a throwaway GitHub repo) and re-running the same
|
||||
driver against it — no driver code changes.
|
||||
@@ -59,8 +59,18 @@ func (e *envScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, er
|
||||
// regardless of the secret-ish heuristic: a weak/empty DB password is
|
||||
// exactly the kind of leak we must not miss.
|
||||
source, kind := e.Name(), Kind(KindToken)
|
||||
secretBlob := val
|
||||
if dbSrc := dbSourceForURL(val); dbSrc != "" {
|
||||
source, kind = dbSrc, KindPassword
|
||||
} else if appSigningKey(k) && len(val) >= 12 {
|
||||
// An app SIGNING secret (Django SECRET_KEY, Rails secret_key_base, a
|
||||
// JWT signing secret, …) lives in this very file; tag it Source=
|
||||
// "appsecret" with a self-contained blob carrying the value AND its
|
||||
// file path so the local in-place rotator is driver-ready.
|
||||
source = "appsecret"
|
||||
secretBlob = "appsecret://local/?" + url.Values{
|
||||
"key": {k}, "val": {val}, "path": {p},
|
||||
}.Encode()
|
||||
} else if !looksSecret(k, val) {
|
||||
continue
|
||||
}
|
||||
@@ -70,7 +80,7 @@ func (e *envScanner) Scan(ctx context.Context, v *vault.Vault) ([]Credential, er
|
||||
Identity: filepath.Base(p) + " / " + k,
|
||||
Location: p,
|
||||
Modified: fi.ModTime(),
|
||||
Secret: v.Store([]byte(val)),
|
||||
Secret: v.Store([]byte(secretBlob)),
|
||||
})
|
||||
}
|
||||
f.Close()
|
||||
@@ -104,6 +114,24 @@ func dbSourceForURL(val string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// appSigningKeys are the env-var names that denote a local application SIGNING secret
|
||||
// — the kind the appsecret rotator can regenerate in place. Matched case-insensitively
|
||||
// and exactly (so STRIPE_API_KEY / DB_PASSWORD stay generic env secrets, not appsecrets).
|
||||
var appSigningKeys = map[string]bool{
|
||||
"secret_key": true, // Django / Flask
|
||||
"secret_key_base": true, // Rails
|
||||
"django_secret_key": true,
|
||||
"flask_secret_key": true,
|
||||
"app_key": true, // Laravel
|
||||
"app_secret": true,
|
||||
"jwt_secret": true,
|
||||
"session_secret": true,
|
||||
"nextauth_secret": true,
|
||||
"rails_secret": true,
|
||||
}
|
||||
|
||||
func appSigningKey(key string) bool { return appSigningKeys[strings.ToLower(key)] }
|
||||
|
||||
func looksSecret(key, val string) bool {
|
||||
lk := strings.ToLower(key)
|
||||
for _, h := range sensitiveHints {
|
||||
|
||||
@@ -141,6 +141,61 @@ func TestEnvScanner(t *testing.T) {
|
||||
"pgSECRET", "redisSECRET", "weak")
|
||||
}
|
||||
|
||||
// TestEnvScannerAppSecret proves an app SIGNING secret (SECRET_KEY) is tagged
|
||||
// Source="appsecret" with a self-contained blob carrying its value AND file path, so
|
||||
// the local in-place rotator can Detect+Rotate it straight from a .env scan — while a
|
||||
// generic API key in the same file stays a plain env secret.
|
||||
func TestEnvScannerAppSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
appVal := "django-insecure-0123456789abcdefghijABCDEF"
|
||||
writeFixture(t, filepath.Join(dir, ".env"),
|
||||
"SECRET_KEY="+appVal+"\nSTRIPE_API_KEY=sk_live_keepGenericEnv00\n")
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
creds, err := (&envScanner{}).Scan(context.Background(), v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var app *Credential
|
||||
for i := range creds {
|
||||
if creds[i].Source == "appsecret" {
|
||||
app = &creds[i]
|
||||
}
|
||||
}
|
||||
if app == nil {
|
||||
t.Fatalf("SECRET_KEY not tagged Source=appsecret; got %+v", creds)
|
||||
}
|
||||
if app.Identity != ".env / SECRET_KEY" {
|
||||
t.Errorf("unexpected identity %q", app.Identity)
|
||||
}
|
||||
|
||||
// The blob must round-trip the value AND the file path for the rotator.
|
||||
buf, _ := v.Open(app.Secret)
|
||||
u, err := url.Parse(strings.TrimSpace(string(buf.Bytes())))
|
||||
if err != nil || u.Scheme != "appsecret" {
|
||||
t.Fatalf("appsecret blob malformed: %v (%q)", err, string(buf.Bytes()))
|
||||
}
|
||||
if u.Query().Get("val") != appVal {
|
||||
t.Error("appsecret blob does not carry the signing value")
|
||||
}
|
||||
if got := u.Query().Get("path"); !strings.HasSuffix(got, ".env") {
|
||||
t.Errorf("appsecret blob path missing/wrong: %q", got)
|
||||
}
|
||||
|
||||
// The generic API key in the same file must NOT be tagged appsecret.
|
||||
for _, c := range creds {
|
||||
if c.Source == "appsecret" && strings.Contains(c.Identity, "STRIPE") {
|
||||
t.Error("generic API key wrongly tagged appsecret")
|
||||
}
|
||||
}
|
||||
|
||||
// Leak-check: the signing value must not appear in any non-secret field.
|
||||
assertNoLeak(t, creds, appVal)
|
||||
}
|
||||
|
||||
func TestNetrcScanner(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// AppSecret rotates a LOCAL application signing secret — the kind of long, random
|
||||
// string an app uses to sign sessions/tokens (Django SECRET_KEY, Rails
|
||||
// secret_key_base, Flask SECRET_KEY, a JWT signing secret, an APP_KEY, …). Unlike the
|
||||
// gopass-custodied credentials, an app signing secret's authoritative home IS a config
|
||||
// file the app reads at boot, so "rotating" it means generating a fresh value and
|
||||
// rewriting it, in place, in every file that holds the current value.
|
||||
//
|
||||
// This is an in-place rotation: replacing the literal old value with a fresh one is
|
||||
// itself the cutover (the old value is gone the moment the files are rewritten), so
|
||||
// RevokeOld is a no-op and the §3 sealed backup is the recovery path. Verify re-reads
|
||||
// every target file and proves the new value is present and the old value is absent.
|
||||
//
|
||||
// The credential's secret is a single self-contained line (so it round-trips the
|
||||
// single-line gopass sink unchanged):
|
||||
//
|
||||
// appsecret://local/?key=<NAME>&val=<value>&path=<file>[&path=<file>…]
|
||||
//
|
||||
// - key= the setting/env-var name the secret is stored under (human-readable;
|
||||
// used only for the audit identity, never for matching).
|
||||
// - val= the CURRENT secret value. Replacement is keyed on this literal string,
|
||||
// so it works regardless of file format (.env / yaml / json / toml).
|
||||
// - path= one or more files that contain the value. All are rewritten atomically.
|
||||
//
|
||||
// SECURITY (Hard Rule #3): val is a secret — it lives only in the vault blob and is
|
||||
// written to the app's OWN config files (its legitimate secret store); it never reaches
|
||||
// Identity/Meta/logs/argv, and every error scrubs it. A minimum-length guard refuses to
|
||||
// treat a short, common string as the secret (which could mass-corrupt files).
|
||||
type AppSecret struct{}
|
||||
|
||||
// init self-registers the driver. Availability alone changes nothing; the
|
||||
// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies.
|
||||
func init() { Register(&AppSecret{}) }
|
||||
|
||||
// Name identifies the driver in plans and audit records.
|
||||
func (a *AppSecret) Name() string { return "appsecret" }
|
||||
|
||||
// Detect claims credentials tagged Source == "appsecret".
|
||||
func (a *AppSecret) Detect(c discover.Credential) bool { return c.Source == "appsecret" }
|
||||
|
||||
// appSec is the parsed credential blob. None of its fields are ever logged.
|
||||
type appSec struct {
|
||||
key string
|
||||
val string
|
||||
paths []string
|
||||
}
|
||||
|
||||
// minSecretLen guards against keying a literal replacement on a short/common string.
|
||||
// Real app signing secrets are long random strings; anything shorter is rejected so a
|
||||
// stray "val=key" can never sweep through unrelated file contents.
|
||||
const minSecretLen = 12
|
||||
|
||||
func parseAppSecret(v *vault.Vault, h *vault.Handle) (appSec, error) {
|
||||
buf, err := v.Open(h)
|
||||
if err != nil {
|
||||
return appSec{}, err
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(string(buf.Bytes())))
|
||||
if err != nil {
|
||||
return appSec{}, fmt.Errorf("appsecret: parse secret url: %w", err)
|
||||
}
|
||||
if u.Scheme != "appsecret" {
|
||||
return appSec{}, fmt.Errorf("appsecret: unexpected scheme %q", u.Scheme)
|
||||
}
|
||||
q := u.Query()
|
||||
s := appSec{key: q.Get("key"), val: q.Get("val"), paths: q["path"]}
|
||||
if s.val == "" {
|
||||
return appSec{}, fmt.Errorf("appsecret: secret has no value")
|
||||
}
|
||||
if len(s.val) < minSecretLen {
|
||||
return appSec{}, fmt.Errorf("appsecret: value too short to rotate safely (<%d chars)", minSecretLen)
|
||||
}
|
||||
if len(s.paths) == 0 {
|
||||
return appSec{}, fmt.Errorf("appsecret: secret names no target file")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// build re-encodes an appSec into the single-line blob form.
|
||||
func (s appSec) build() string {
|
||||
u := &url.URL{Scheme: "appsecret", Host: "local", Path: "/"}
|
||||
q := url.Values{}
|
||||
if s.key != "" {
|
||||
q.Set("key", s.key)
|
||||
}
|
||||
q.Set("val", s.val)
|
||||
for _, p := range s.paths {
|
||||
q.Add("path", p)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// Rotate generates a fresh value and rewrites every target file, replacing the literal
|
||||
// old value in place, then returns a vault handle to a rebuilt blob carrying the new
|
||||
// value. It requires that at least one replacement actually happened across the files;
|
||||
// otherwise the old value was not where we were told it is and we must NOT claim a
|
||||
// rotation.
|
||||
func (a *AppSecret) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
|
||||
s, err := parseAppSecret(v, c.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newVal, err := genHex(32) // 64 hex chars — a strong app signing secret
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, p := range s.paths {
|
||||
n, err := replaceInFile(p, s.val, newVal)
|
||||
if err != nil {
|
||||
// Never let the old or new value leak through a path error.
|
||||
return nil, fmt.Errorf("appsecret: rewrite %s: %w", p, redactErr(err, s.val, newVal))
|
||||
}
|
||||
total += n
|
||||
}
|
||||
if total == 0 {
|
||||
return nil, fmt.Errorf("appsecret: old value not found in any target file — nothing rotated")
|
||||
}
|
||||
|
||||
ns := s
|
||||
ns.val = newVal
|
||||
return v.Store([]byte(ns.build())), nil
|
||||
}
|
||||
|
||||
// Verify re-reads every target file and proves the new value is present and the old
|
||||
// value is absent. It is credential-less in the Execute spine, so it reads the new
|
||||
// value (and paths) straight from the rebuilt blob.
|
||||
func (a *AppSecret) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
|
||||
s, err := parseAppSecret(v, newSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range s.paths {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("appsecret verify: read %s: %w", p, err)
|
||||
}
|
||||
if !strings.Contains(string(b), s.val) {
|
||||
return fmt.Errorf("appsecret verify: new value missing from %s", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeOld is a no-op: the in-place rewrite already removed the old value from every
|
||||
// file. Defined to satisfy the safety-spine ordering.
|
||||
func (a *AppSecret) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceInFile replaces every occurrence of old with new in the file at path,
|
||||
// preserving the file mode, via a temp file + atomic rename so a crash never leaves a
|
||||
// half-written config. It returns the number of replacements made (0 if old was not
|
||||
// present — not an error here; Rotate enforces the across-files total).
|
||||
func replaceInFile(path, old, new string) (int, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := strings.Count(string(b), old)
|
||||
if n == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
out := strings.ReplaceAll(string(b), old, new)
|
||||
|
||||
mode := os.FileMode(0o600)
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
mode = fi.Mode().Perm()
|
||||
}
|
||||
tmp := path + ".incredigo.tmp"
|
||||
if err := os.WriteFile(tmp, []byte(out), mode); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// redactErr scrubs any secret substrings out of an error before it can surface.
|
||||
func redactErr(err error, secrets ...string) error {
|
||||
msg := err.Error()
|
||||
for _, s := range secrets {
|
||||
if s != "" {
|
||||
msg = strings.ReplaceAll(msg, s, "***")
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
func TestAppSecretDetect(t *testing.T) {
|
||||
a := &AppSecret{}
|
||||
if !a.Detect(discover.Credential{Source: "appsecret"}) {
|
||||
t.Error("should detect Source=appsecret")
|
||||
}
|
||||
if a.Detect(discover.Credential{Source: "env"}) {
|
||||
t.Error("should not detect Source=env")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppSecretRotateRewritesAllFiles proves the real local cutover: the literal old
|
||||
// value is replaced by a fresh value in EVERY target file (atomic rewrite), the new
|
||||
// blob carries the new value, and Verify confirms presence-of-new / absence-of-old.
|
||||
func TestAppSecretRotateRewritesAllFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldVal := "s3cret-signing-value-abcdefghijklmnop"
|
||||
|
||||
// Two files share the same app signing secret (e.g. an app .env and a worker .env).
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
ymlPath := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(envPath, []byte("PORT=8080\nSECRET_KEY="+oldVal+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(ymlPath, []byte("service:\n secret_key: "+oldVal+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
blob := "appsecret://local/?" + url.Values{
|
||||
"key": {"SECRET_KEY"},
|
||||
"val": {oldVal},
|
||||
"path": {envPath, ymlPath},
|
||||
}.Encode()
|
||||
cred := discover.Credential{
|
||||
Source: "appsecret",
|
||||
Identity: ".env / SECRET_KEY",
|
||||
Secret: v.Store([]byte(blob)),
|
||||
}
|
||||
a := &AppSecret{}
|
||||
ctx := context.Background()
|
||||
|
||||
newH, err := a.Rotate(ctx, cred, v)
|
||||
if err != nil {
|
||||
t.Fatalf("Rotate: %v", err)
|
||||
}
|
||||
|
||||
nb, _ := v.Open(newH)
|
||||
nu, _ := url.Parse(strings.TrimSpace(string(nb.Bytes())))
|
||||
newVal := nu.Query().Get("val")
|
||||
if newVal == "" || newVal == oldVal {
|
||||
t.Fatalf("blob carries no fresh value (got %q)", newVal)
|
||||
}
|
||||
|
||||
// CUTOVER PROOF: both files now hold the new value and NOT the old one.
|
||||
for _, p := range []string{envPath, ymlPath} {
|
||||
b, _ := os.ReadFile(p)
|
||||
if !strings.Contains(string(b), newVal) {
|
||||
t.Errorf("%s missing new value after rotation", filepath.Base(p))
|
||||
}
|
||||
if strings.Contains(string(b), oldVal) {
|
||||
t.Errorf("%s still contains the OLD value after rotation", filepath.Base(p))
|
||||
}
|
||||
}
|
||||
|
||||
// Untouched lines survive (atomic full-file rewrite, not truncation).
|
||||
eb, _ := os.ReadFile(envPath)
|
||||
if !strings.Contains(string(eb), "PORT=8080") {
|
||||
t.Error("unrelated config line lost during rewrite")
|
||||
}
|
||||
|
||||
// Mode preserved on the 0644 file.
|
||||
if fi, _ := os.Stat(ymlPath); fi.Mode().Perm() != 0o644 {
|
||||
t.Errorf("file mode not preserved: got %o", fi.Mode().Perm())
|
||||
}
|
||||
|
||||
if err := a.Verify(ctx, newH, v); err != nil {
|
||||
t.Errorf("Verify(new) should pass: %v", err)
|
||||
}
|
||||
|
||||
// Verify(old) must FAIL — the old value is gone from the files.
|
||||
if err := a.Verify(ctx, cred.Secret, v); err == nil {
|
||||
t.Error("Verify(old) must fail after rotation — old value should be absent")
|
||||
}
|
||||
|
||||
if err := a.RevokeOld(ctx, cred, v); err != nil {
|
||||
t.Errorf("RevokeOld (no-op) returned: %v", err)
|
||||
}
|
||||
|
||||
// Leak-check: no secret value in the credential's non-secret metadata.
|
||||
if strings.Contains(cred.Identity, oldVal) || strings.Contains(cred.Identity, newVal) {
|
||||
t.Error("secret value leaked into credential metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppSecretNoMatchIsError proves we never claim a rotation when the old value is
|
||||
// not actually present in any file (would otherwise silently mint+discard a value).
|
||||
func TestAppSecretNoMatchIsError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, ".env")
|
||||
if err := os.WriteFile(p, []byte("SECRET_KEY=something-else-entirely-here\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
blob := "appsecret://local/?" + url.Values{
|
||||
"key": {"SECRET_KEY"}, "val": {"value-that-is-not-present-xx"}, "path": {p},
|
||||
}.Encode()
|
||||
cred := discover.Credential{Source: "appsecret", Secret: v.Store([]byte(blob))}
|
||||
if _, err := (&AppSecret{}).Rotate(context.Background(), cred, v); err == nil {
|
||||
t.Error("Rotate should error when the old value is in no target file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppSecretRejectsShortOrEmpty(t *testing.T) {
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
a := &AppSecret{}
|
||||
// too-short value (under minSecretLen) must be refused.
|
||||
short := discover.Credential{Source: "appsecret",
|
||||
Secret: v.Store([]byte("appsecret://local/?val=abc&path=/tmp/x"))}
|
||||
if _, err := a.Rotate(context.Background(), short, v); err == nil {
|
||||
t.Error("Rotate should reject a too-short value")
|
||||
}
|
||||
// no path must be refused.
|
||||
nopath := discover.Credential{Source: "appsecret",
|
||||
Secret: v.Store([]byte("appsecret://local/?val=a-long-enough-value-here"))}
|
||||
if _, err := a.Rotate(context.Background(), nopath, v); err == nil {
|
||||
t.Error("Rotate should reject a secret with no target file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// Cloudflare rotates a Cloudflare API token by ROLLING its value:
|
||||
//
|
||||
// PUT /client/v4/user/tokens/{id}/value
|
||||
//
|
||||
// Cloudflare regenerates the secret value of the SAME token id and returns the new
|
||||
// value; the previous value is invalidated immediately. Because the roll IS the
|
||||
// cutover (same id, fresh value, old value dead), this is an in-place rotation like
|
||||
// the DB drivers: RevokeOld is a no-op and the §3 sealed backup is the recovery
|
||||
// path. Rolling the value (rather than create-new + delete-old) keeps every API
|
||||
// token's id/policies/name stable, so nothing that references the token by id breaks.
|
||||
//
|
||||
// The credential's secret is a single self-contained line (so it round-trips the
|
||||
// single-line gopass sink unchanged):
|
||||
//
|
||||
// cloudflare://<host>/?id=<token-id>&token=<token-value>
|
||||
//
|
||||
// - scheme "cloudflare" maps to https; "http"/"https" are accepted so a test
|
||||
// can point at a loopback emulator.
|
||||
// - <host> the API host; "api" (or empty) defaults to api.cloudflare.com.
|
||||
// - id= the token id — required to address the roll endpoint; the value
|
||||
// cannot be mapped back to an id.
|
||||
// - token= the token value being rotated (Bearer auth).
|
||||
//
|
||||
// SECURITY: the token value lives only in the vault blob and the Bearer header; it
|
||||
// never reaches Identity/Meta/logs/argv. Response bodies carrying the new value are
|
||||
// decoded straight into the rebuilt blob.
|
||||
type Cloudflare struct {
|
||||
// HTTPClient is injectable for tests / custom TLS. Defaults to a 15s-timeout
|
||||
// client using http.DefaultTransport.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// init self-registers the driver. Availability alone changes nothing; the
|
||||
// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies.
|
||||
func init() { Register(&Cloudflare{}) }
|
||||
|
||||
// Name identifies the driver in plans and audit records.
|
||||
func (c *Cloudflare) Name() string { return "cloudflare" }
|
||||
|
||||
// Detect claims credentials tagged Source == "cloudflare".
|
||||
func (c *Cloudflare) Detect(cr discover.Credential) bool { return cr.Source == "cloudflare" }
|
||||
|
||||
func (c *Cloudflare) client() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
// cfSecret is the parsed credential blob. None of its fields are ever logged.
|
||||
type cfSecret struct {
|
||||
base string // scheme://host
|
||||
id string
|
||||
token string
|
||||
}
|
||||
|
||||
func parseCFSecret(v *vault.Vault, h *vault.Handle) (cfSecret, error) {
|
||||
buf, err := v.Open(h)
|
||||
if err != nil {
|
||||
return cfSecret{}, err
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(string(buf.Bytes())))
|
||||
if err != nil {
|
||||
return cfSecret{}, fmt.Errorf("cloudflare: parse secret url: %w", err)
|
||||
}
|
||||
if u.Scheme != "cloudflare" && u.Scheme != "http" && u.Scheme != "https" {
|
||||
return cfSecret{}, fmt.Errorf("cloudflare: unexpected scheme %q", u.Scheme)
|
||||
}
|
||||
scheme := u.Scheme
|
||||
host := u.Host
|
||||
if scheme == "cloudflare" {
|
||||
scheme = "https"
|
||||
}
|
||||
if host == "" || host == "api" {
|
||||
host = "api.cloudflare.com"
|
||||
}
|
||||
q := u.Query()
|
||||
s := cfSecret{base: scheme + "://" + host, id: q.Get("id"), token: q.Get("token")}
|
||||
if s.token == "" {
|
||||
return cfSecret{}, fmt.Errorf("cloudflare: secret has no token")
|
||||
}
|
||||
if s.id == "" {
|
||||
return cfSecret{}, fmt.Errorf("cloudflare: secret has no token id")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// build re-encodes a cfSecret into the single-line blob form (scheme normalised back
|
||||
// to "cloudflare" so the in-vault form stays stable).
|
||||
func (s cfSecret) build() string {
|
||||
host := hostOf(s.base)
|
||||
if host == "api.cloudflare.com" {
|
||||
host = "api"
|
||||
}
|
||||
u := &url.URL{Scheme: "cloudflare", Host: host, Path: "/"}
|
||||
q := url.Values{}
|
||||
q.Set("id", s.id)
|
||||
q.Set("token", s.token)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// cfResult is the standard Cloudflare envelope; only the fields we use are decoded.
|
||||
type cfRollResult struct {
|
||||
Result string `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// Rotate rolls the token value and returns a vault handle to a rebuilt blob carrying
|
||||
// the new value (same id). The old value is dead the moment Cloudflare responds.
|
||||
func (c *Cloudflare) Rotate(ctx context.Context, cr discover.Credential, v *vault.Vault) (*vault.Handle, error) {
|
||||
s, err := parseCFSecret(v, cr.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut,
|
||||
s.base+"/client/v4/user/tokens/"+url.PathEscape(s.id)+"/value", strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
resp, err := c.client().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cloudflare: roll token: %w", err)
|
||||
}
|
||||
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("cloudflare: roll token: unexpected status %s", resp.Status)
|
||||
}
|
||||
var rolled cfRollResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rolled); err != nil {
|
||||
return nil, fmt.Errorf("cloudflare: decode rolled token: %w", err)
|
||||
}
|
||||
if !rolled.Success || rolled.Result == "" {
|
||||
return nil, fmt.Errorf("cloudflare: roll token: empty value in response")
|
||||
}
|
||||
|
||||
ns := s
|
||||
ns.token = rolled.Result
|
||||
return v.Store([]byte(ns.build())), nil
|
||||
}
|
||||
|
||||
// Verify proves the new token value authenticates and is active via the token
|
||||
// verify endpoint.
|
||||
func (c *Cloudflare) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
|
||||
s, err := parseCFSecret(v, newSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/client/v4/user/tokens/verify", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
resp, err := c.client().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cloudflare verify: %w", err)
|
||||
}
|
||||
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("cloudflare verify: status %s", resp.Status)
|
||||
}
|
||||
var verified struct {
|
||||
Success bool `json:"success"`
|
||||
Result struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&verified); err != nil {
|
||||
return fmt.Errorf("cloudflare verify: decode: %w", err)
|
||||
}
|
||||
if !verified.Success || verified.Result.Status != "active" {
|
||||
return fmt.Errorf("cloudflare verify: token not active")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeOld is a no-op: rolling the value already invalidated the previous token.
|
||||
// Defined to satisfy the safety-spine ordering.
|
||||
func (c *Cloudflare) RevokeOld(ctx context.Context, cr discover.Credential, v *vault.Vault) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// cfEmu is an in-process Cloudflare API emulator for one token id: it tracks the
|
||||
// currently-valid value, rolls it on PUT .../value, and verifies a Bearer token.
|
||||
type cfEmu struct {
|
||||
mu sync.Mutex
|
||||
id string
|
||||
cur string // currently-valid token value
|
||||
minted int
|
||||
}
|
||||
|
||||
func (e *cfEmu) bearer(r *http.Request) string {
|
||||
return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
}
|
||||
|
||||
func (e *cfEmu) valid(tok string) bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return tok != "" && tok == e.cur
|
||||
}
|
||||
|
||||
func (e *cfEmu) roll() string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.minted++
|
||||
e.cur = "cf-rolled-value-" + itoa(e.minted)
|
||||
return e.cur
|
||||
}
|
||||
|
||||
func (e *cfEmu) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/client/v4/user/tokens/"+e.id+"/value", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut || !e.valid(e.bearer(r)) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": false})
|
||||
return
|
||||
}
|
||||
newVal := e.roll()
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": newVal})
|
||||
})
|
||||
mux.HandleFunc("/client/v4/user/tokens/verify", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.valid(e.bearer(r)) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]any{"success": false})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{"id": e.id, "status": "active"},
|
||||
})
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func TestCloudflareDetect(t *testing.T) {
|
||||
c := &Cloudflare{}
|
||||
if !c.Detect(discover.Credential{Source: "cloudflare"}) {
|
||||
t.Error("should detect Source=cloudflare")
|
||||
}
|
||||
if c.Detect(discover.Credential{Source: "aws"}) {
|
||||
t.Error("should not detect Source=aws")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudflareRotateRealCutover(t *testing.T) {
|
||||
emu := &cfEmu{id: "tok123", cur: "cf-seed-value-0001"}
|
||||
srv := httptest.NewTLSServer(emu.handler())
|
||||
defer srv.Close()
|
||||
host := strings.TrimPrefix(srv.URL, "https://")
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
oldVal := emu.cur
|
||||
cred := discover.Credential{
|
||||
Source: "cloudflare",
|
||||
Identity: "cloudflare api token",
|
||||
Secret: v.Store([]byte("cloudflare://" + host + "/?id=" + emu.id + "&token=" + url.QueryEscape(oldVal))),
|
||||
}
|
||||
c := &Cloudflare{HTTPClient: srv.Client()}
|
||||
ctx := context.Background()
|
||||
|
||||
newH, err := c.Rotate(ctx, cred, v)
|
||||
if err != nil {
|
||||
t.Fatalf("Rotate: %v", err)
|
||||
}
|
||||
|
||||
nb, _ := v.Open(newH)
|
||||
nu, _ := url.Parse(strings.TrimSpace(string(nb.Bytes())))
|
||||
newVal := nu.Query().Get("token")
|
||||
if newVal == "" || newVal == oldVal {
|
||||
t.Fatalf("blob carries no fresh token value (got %q)", newVal)
|
||||
}
|
||||
if nu.Query().Get("id") != emu.id {
|
||||
t.Errorf("token id changed: want %q got %q", emu.id, nu.Query().Get("id"))
|
||||
}
|
||||
|
||||
if err := c.Verify(ctx, newH, v); err != nil {
|
||||
t.Errorf("Verify(new) should pass: %v", err)
|
||||
}
|
||||
|
||||
// CUTOVER: the old value is dead after the roll.
|
||||
if err := c.Verify(ctx, cred.Secret, v); err == nil {
|
||||
t.Error("Verify(old) must fail after rotation — old value should be invalid")
|
||||
} else if strings.Contains(err.Error(), newVal) {
|
||||
t.Error("error leaked the new token value")
|
||||
}
|
||||
|
||||
if err := c.RevokeOld(ctx, cred, v); err != nil {
|
||||
t.Errorf("RevokeOld (no-op) returned: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudflareRejectsBadSecret(t *testing.T) {
|
||||
c := &Cloudflare{}
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
// missing id
|
||||
cred := discover.Credential{Source: "cloudflare", Secret: v.Store([]byte("cloudflare://api/?token=abc"))}
|
||||
if _, err := c.Rotate(context.Background(), cred, v); err == nil {
|
||||
t.Error("Rotate should reject a secret with no token id")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// GHActions rotates the VALUE of a GitHub Actions repository secret in place:
|
||||
//
|
||||
// GET /repos/{owner}/{repo}/actions/secrets/public-key -> {key_id, key}
|
||||
// PUT /repos/{owner}/{repo}/actions/secrets/{name} <- {encrypted_value, key_id}
|
||||
//
|
||||
// GitHub stores Actions secrets sealed to the repo's libsodium public key; the only
|
||||
// way to write one is to encrypt with crypto_box_seal (NaCl "sealed box"). PUT to an
|
||||
// existing secret name OVERWRITES its value, so the previous value is dead the moment
|
||||
// the PUT returns — this is an in-place rotation like the DB drivers: RevokeOld is a
|
||||
// no-op and the §3 sealed backup is the recovery path.
|
||||
//
|
||||
// Note on what is being rotated: the rotated material is the SECRET VALUE consumed by
|
||||
// workflows (val=), NOT the PAT used to authenticate the API call (pat=). The PAT is a
|
||||
// long-lived management credential carried in the blob; it is unchanged by a roll.
|
||||
//
|
||||
// The credential's secret is a single self-contained line (so it round-trips the
|
||||
// single-line gopass sink unchanged):
|
||||
//
|
||||
// ghactions://<host>/?owner=<o>&repo=<r>&name=<secret-name>&pat=<token>&val=<value>
|
||||
//
|
||||
// - scheme "ghactions" maps to https; "http"/"https" are accepted so a test can
|
||||
// point at a loopback emulator.
|
||||
// - <host> the API host; "api" (or empty) defaults to api.github.com.
|
||||
// - owner/repo the repository whose secret is rolled.
|
||||
// - name= the Actions secret name (stable; the roll keeps name/visibility).
|
||||
// - pat= the management PAT (Bearer auth; repo+secrets scope). Not rotated here.
|
||||
// - val= the secret value being rotated. GitHub never returns it, so we keep
|
||||
// the plaintext we generated in the rebuilt blob; the wire only ever
|
||||
// carries the sealed-box ciphertext.
|
||||
//
|
||||
// SECURITY: neither pat nor val ever reaches Identity/Meta/logs/argv. val is generated
|
||||
// locally, sealed straight into the request body, and otherwise lives only in the
|
||||
// vault blob. Verify can only prove the secret EXISTS (GitHub never discloses values),
|
||||
// so it asserts a 200 on the secret's metadata endpoint, not a value match.
|
||||
type GHActions struct {
|
||||
// HTTPClient is injectable for tests / custom TLS. Defaults to a 15s-timeout
|
||||
// client using http.DefaultTransport.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// init self-registers the driver. Availability alone changes nothing; the
|
||||
// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies.
|
||||
func init() { Register(&GHActions{}) }
|
||||
|
||||
// Name identifies the driver in plans and audit records.
|
||||
func (g *GHActions) Name() string { return "ghactions" }
|
||||
|
||||
// Detect claims credentials tagged Source == "ghactions".
|
||||
func (g *GHActions) Detect(c discover.Credential) bool { return c.Source == "ghactions" }
|
||||
|
||||
func (g *GHActions) client() *http.Client {
|
||||
if g.HTTPClient != nil {
|
||||
return g.HTTPClient
|
||||
}
|
||||
return &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
// ghaSecret is the parsed credential blob. None of its fields are ever logged.
|
||||
type ghaSecret struct {
|
||||
base string // scheme://host
|
||||
owner string
|
||||
repo string
|
||||
name string
|
||||
pat string
|
||||
val string
|
||||
}
|
||||
|
||||
func parseGHASecret(v *vault.Vault, h *vault.Handle) (ghaSecret, error) {
|
||||
buf, err := v.Open(h)
|
||||
if err != nil {
|
||||
return ghaSecret{}, err
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(string(buf.Bytes())))
|
||||
if err != nil {
|
||||
return ghaSecret{}, fmt.Errorf("ghactions: parse secret url: %w", err)
|
||||
}
|
||||
if u.Scheme != "ghactions" && u.Scheme != "http" && u.Scheme != "https" {
|
||||
return ghaSecret{}, fmt.Errorf("ghactions: unexpected scheme %q", u.Scheme)
|
||||
}
|
||||
scheme := u.Scheme
|
||||
host := u.Host
|
||||
if scheme == "ghactions" {
|
||||
scheme = "https"
|
||||
}
|
||||
if host == "" || host == "api" {
|
||||
host = "api.github.com"
|
||||
}
|
||||
q := u.Query()
|
||||
s := ghaSecret{
|
||||
base: scheme + "://" + host,
|
||||
owner: q.Get("owner"),
|
||||
repo: q.Get("repo"),
|
||||
name: q.Get("name"),
|
||||
pat: q.Get("pat"),
|
||||
val: q.Get("val"),
|
||||
}
|
||||
if s.owner == "" || s.repo == "" {
|
||||
return ghaSecret{}, fmt.Errorf("ghactions: secret has no owner/repo")
|
||||
}
|
||||
if s.name == "" {
|
||||
return ghaSecret{}, fmt.Errorf("ghactions: secret has no secret name")
|
||||
}
|
||||
if s.pat == "" {
|
||||
return ghaSecret{}, fmt.Errorf("ghactions: secret has no management pat")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// build re-encodes a ghaSecret into the single-line blob form (scheme normalised back
|
||||
// to "ghactions" so the in-vault form stays stable).
|
||||
func (s ghaSecret) build() string {
|
||||
host := hostOf(s.base)
|
||||
if host == "api.github.com" {
|
||||
host = "api"
|
||||
}
|
||||
u := &url.URL{Scheme: "ghactions", Host: host, Path: "/"}
|
||||
q := url.Values{}
|
||||
q.Set("owner", s.owner)
|
||||
q.Set("repo", s.repo)
|
||||
q.Set("name", s.name)
|
||||
q.Set("pat", s.pat)
|
||||
q.Set("val", s.val)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (g *GHActions) authReq(ctx context.Context, method, url, pat string, body io.Reader) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+pat)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Rotate generates a fresh value, seals it to the repo's libsodium public key, PUTs it
|
||||
// over the existing secret name (overwriting the old value), and returns a vault handle
|
||||
// to a rebuilt blob carrying the new plaintext value.
|
||||
func (g *GHActions) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
|
||||
s, err := parseGHASecret(v, c.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 1) fetch the repo's public key for Actions secrets.
|
||||
keyURL := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/public-key",
|
||||
s.base, url.PathEscape(s.owner), url.PathEscape(s.repo))
|
||||
req, err := g.authReq(ctx, http.MethodGet, keyURL, s.pat, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := g.client().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ghactions: fetch public-key: %w", err)
|
||||
}
|
||||
var pk struct {
|
||||
KeyID string `json:"key_id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := decodeJSON(resp, &pk); err != nil {
|
||||
return nil, fmt.Errorf("ghactions: public-key: %w", err)
|
||||
}
|
||||
if pk.KeyID == "" || pk.Key == "" {
|
||||
return nil, fmt.Errorf("ghactions: public-key response incomplete")
|
||||
}
|
||||
rawKey, err := base64.StdEncoding.DecodeString(pk.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ghactions: decode public-key: %w", err)
|
||||
}
|
||||
if len(rawKey) != 32 {
|
||||
return nil, fmt.Errorf("ghactions: public-key wrong length %d", len(rawKey))
|
||||
}
|
||||
|
||||
// 2) generate the new secret value and seal it (crypto_box_seal).
|
||||
newVal, err := genHex(24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var recipient [32]byte
|
||||
copy(recipient[:], rawKey)
|
||||
sealed, err := box.SealAnonymous(nil, []byte(newVal), &recipient, rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ghactions: seal value: %w", err)
|
||||
}
|
||||
|
||||
// 3) PUT the sealed value over the existing secret name.
|
||||
putBody, _ := json.Marshal(map[string]string{
|
||||
"encrypted_value": base64.StdEncoding.EncodeToString(sealed),
|
||||
"key_id": pk.KeyID,
|
||||
})
|
||||
putURL := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/%s",
|
||||
s.base, url.PathEscape(s.owner), url.PathEscape(s.repo), url.PathEscape(s.name))
|
||||
preq, err := g.authReq(ctx, http.MethodPut, putURL, s.pat, bytes.NewReader(putBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preq.Header.Set("Content-Type", "application/json")
|
||||
presp, err := g.client().Do(preq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ghactions: put secret: %w", err)
|
||||
}
|
||||
defer func() { io.Copy(io.Discard, presp.Body); presp.Body.Close() }()
|
||||
// GitHub returns 201 (created) or 204 (updated).
|
||||
if presp.StatusCode != http.StatusCreated && presp.StatusCode != http.StatusNoContent {
|
||||
return nil, fmt.Errorf("ghactions: put secret: unexpected status %s", presp.Status)
|
||||
}
|
||||
|
||||
ns := s
|
||||
ns.val = newVal
|
||||
return v.Store([]byte(ns.build())), nil
|
||||
}
|
||||
|
||||
// Verify proves the secret exists under the (rebuilt) blob's coordinates. GitHub never
|
||||
// returns Actions secret values, so this is an existence/authorization check (200), not
|
||||
// a value match — the value's correctness was guaranteed at seal time.
|
||||
func (g *GHActions) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
|
||||
s, err := parseGHASecret(v, newSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
getURL := fmt.Sprintf("%s/repos/%s/%s/actions/secrets/%s",
|
||||
s.base, url.PathEscape(s.owner), url.PathEscape(s.repo), url.PathEscape(s.name))
|
||||
req, err := g.authReq(ctx, http.MethodGet, getURL, s.pat, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := g.client().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ghactions verify: %w", err)
|
||||
}
|
||||
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("ghactions verify: status %s", resp.Status)
|
||||
}
|
||||
var meta struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
|
||||
return fmt.Errorf("ghactions verify: decode: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(meta.Name, s.name) {
|
||||
return fmt.Errorf("ghactions verify: secret name mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeOld is a no-op: the PUT overwrote the previous value in place, so there is no
|
||||
// separate old credential to retire. Defined to satisfy the safety-spine ordering.
|
||||
func (g *GHActions) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeJSON drains+closes the response and decodes a 200 body, returning a status
|
||||
// error otherwise.
|
||||
func decodeJSON(resp *http.Response, out any) error {
|
||||
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("status %s", resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// ghaEmu is an in-process GitHub Actions API emulator for ONE repo secret. It owns a
|
||||
// libsodium (NaCl) box keypair, serves the public key, and on PUT decrypts the sealed
|
||||
// value with the private key so a test can assert the value the driver actually wrote.
|
||||
type ghaEmu struct {
|
||||
mu sync.Mutex
|
||||
pub [32]byte
|
||||
priv [32]byte
|
||||
keyID string
|
||||
pat string // the only accepted Bearer token
|
||||
secret string // decrypted current secret value (after a PUT)
|
||||
hasSecret bool
|
||||
}
|
||||
|
||||
func newGHAEmu(pat string) *ghaEmu {
|
||||
pub, priv, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &ghaEmu{pub: *pub, priv: *priv, keyID: "kid-123", pat: pat}
|
||||
}
|
||||
|
||||
func (e *ghaEmu) auth(r *http.Request) bool {
|
||||
return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") == e.pat
|
||||
}
|
||||
|
||||
func (e *ghaEmu) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
const base = "/repos/octo/lab/actions/secrets"
|
||||
|
||||
mux.HandleFunc(base+"/public-key", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.auth(r) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"key_id": e.keyID,
|
||||
"key": base64.StdEncoding.EncodeToString(e.pub[:]),
|
||||
})
|
||||
})
|
||||
|
||||
// PUT writes (seals) the value; GET returns metadata only (never the value).
|
||||
mux.HandleFunc(base+"/DEPLOY_TOKEN", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.auth(r) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
var body struct {
|
||||
Encrypted string `json:"encrypted_value"`
|
||||
KeyID string `json:"key_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.KeyID != e.keyID {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
sealed, err := base64.StdEncoding.DecodeString(body.Encrypted)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
plain, ok := box.OpenAnonymous(nil, sealed, &e.pub, &e.priv)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.secret = string(plain)
|
||||
e.hasSecret = true
|
||||
e.mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case http.MethodGet:
|
||||
e.mu.Lock()
|
||||
has := e.hasSecret
|
||||
e.mu.Unlock()
|
||||
if !has {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"name": "DEPLOY_TOKEN"})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func (e *ghaEmu) stored() string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.secret
|
||||
}
|
||||
|
||||
func TestGHActionsDetect(t *testing.T) {
|
||||
g := &GHActions{}
|
||||
if !g.Detect(discover.Credential{Source: "ghactions"}) {
|
||||
t.Error("should detect Source=ghactions")
|
||||
}
|
||||
if g.Detect(discover.Credential{Source: "gitlab"}) {
|
||||
t.Error("should not detect Source=gitlab")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGHActionsRotateSealsRealValue(t *testing.T) {
|
||||
emu := newGHAEmu("ghp_managementPAT0001")
|
||||
srv := httptest.NewTLSServer(emu.handler())
|
||||
defer srv.Close()
|
||||
host := strings.TrimPrefix(srv.URL, "https://")
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
oldVal := "old-deploy-value-0000"
|
||||
blob := "ghactions://" + host + "/?" + url.Values{
|
||||
"owner": {"octo"}, "repo": {"lab"}, "name": {"DEPLOY_TOKEN"},
|
||||
"pat": {emu.pat}, "val": {oldVal},
|
||||
}.Encode()
|
||||
cred := discover.Credential{
|
||||
Source: "ghactions",
|
||||
Identity: "github actions secret DEPLOY_TOKEN",
|
||||
Secret: v.Store([]byte(blob)),
|
||||
}
|
||||
g := &GHActions{HTTPClient: srv.Client()}
|
||||
ctx := context.Background()
|
||||
|
||||
newH, err := g.Rotate(ctx, cred, v)
|
||||
if err != nil {
|
||||
t.Fatalf("Rotate: %v", err)
|
||||
}
|
||||
|
||||
// The rebuilt blob must carry a fresh value (different from the old one).
|
||||
nb, _ := v.Open(newH)
|
||||
nu, _ := url.Parse(strings.TrimSpace(string(nb.Bytes())))
|
||||
newVal := nu.Query().Get("val")
|
||||
if newVal == "" || newVal == oldVal {
|
||||
t.Fatalf("blob carries no fresh value (got %q)", newVal)
|
||||
}
|
||||
if nu.Query().Get("name") != "DEPLOY_TOKEN" {
|
||||
t.Errorf("secret name changed: %q", nu.Query().Get("name"))
|
||||
}
|
||||
|
||||
// CUTOVER PROOF: the value GitHub actually received (decrypted from the sealed
|
||||
// box) must equal the new plaintext in the rebuilt blob — proving the roll wrote
|
||||
// the real new value, not a simulation.
|
||||
if got := emu.stored(); got != newVal {
|
||||
t.Fatalf("server stored %q, blob says %q — sealed value mismatch", got, newVal)
|
||||
}
|
||||
if emu.stored() == oldVal {
|
||||
t.Error("old value still stored after rotation")
|
||||
}
|
||||
|
||||
if err := g.Verify(ctx, newH, v); err != nil {
|
||||
t.Errorf("Verify(new) should pass: %v", err)
|
||||
}
|
||||
|
||||
if err := g.RevokeOld(ctx, cred, v); err != nil {
|
||||
t.Errorf("RevokeOld (no-op) returned: %v", err)
|
||||
}
|
||||
|
||||
// Leak-check: neither the management PAT nor any secret value may appear in the
|
||||
// credential's non-secret metadata.
|
||||
for _, fld := range []string{cred.Identity} {
|
||||
if strings.Contains(fld, emu.pat) || strings.Contains(fld, newVal) || strings.Contains(fld, oldVal) {
|
||||
t.Error("secret material leaked into credential metadata")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGHActionsRejectsBadSecret(t *testing.T) {
|
||||
g := &GHActions{}
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
// missing owner/repo
|
||||
cred := discover.Credential{Source: "ghactions",
|
||||
Secret: v.Store([]byte("ghactions://api/?name=X&pat=abc&val=y"))}
|
||||
if _, err := g.Rotate(context.Background(), cred, v); err == nil {
|
||||
t.Error("Rotate should reject a secret with no owner/repo")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// GitLab rotates a GitLab personal access token (PAT) via GitLab's first-party
|
||||
// self-rotate endpoint:
|
||||
//
|
||||
// POST /api/v4/personal_access_tokens/self/rotate
|
||||
//
|
||||
// GitLab issues a NEW token and, in the same call, REVOKES the token that
|
||||
// authenticated the request. Because the rotate IS the revocation, this is an
|
||||
// in-place rotation (like the DB drivers): RevokeOld is a no-op and the §3 sealed
|
||||
// backup is the recovery path. Verify-before-revoke is still honoured by the spine
|
||||
// — the new token is verified and stored before the (no-op) revoke step — and the
|
||||
// old token is already dead the instant Rotate returns.
|
||||
//
|
||||
// The credential's secret is a single self-contained line (so it round-trips the
|
||||
// single-line gopass sink unchanged):
|
||||
//
|
||||
// gitlab://<host>[:port]/?tok=<token>
|
||||
//
|
||||
// - scheme "gitlab" maps to https; "http"/"https" are accepted so a test can
|
||||
// point at a loopback emulator.
|
||||
// - <host> the GitLab API host (gitlab.com or a self-managed host).
|
||||
// - tok= the PAT being rotated. No username is needed — GitLab authenticates
|
||||
// the PAT itself via the PRIVATE-TOKEN header, and self/rotate targets
|
||||
// whichever token signed the request.
|
||||
//
|
||||
// SECURITY: the token lives only in the vault blob and the PRIVATE-TOKEN header; it
|
||||
// never reaches Identity/Meta/logs/argv. Response bodies carrying the new token are
|
||||
// decoded straight into the rebuilt blob, not stringified elsewhere.
|
||||
type GitLab struct {
|
||||
// HTTPClient is injectable for tests / custom TLS. Defaults to a 15s-timeout
|
||||
// client using http.DefaultTransport.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// init self-registers the driver. Availability alone changes nothing; the
|
||||
// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies.
|
||||
func init() { Register(&GitLab{}) }
|
||||
|
||||
// Name identifies the driver in plans and audit records.
|
||||
func (g *GitLab) Name() string { return "gitlab" }
|
||||
|
||||
// Detect claims credentials tagged Source == "gitlab".
|
||||
func (g *GitLab) Detect(c discover.Credential) bool { return c.Source == "gitlab" }
|
||||
|
||||
func (g *GitLab) client() *http.Client {
|
||||
if g.HTTPClient != nil {
|
||||
return g.HTTPClient
|
||||
}
|
||||
return &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
|
||||
// gitlabSecret is the parsed credential blob. None of its fields are ever logged.
|
||||
type gitlabSecret struct {
|
||||
base string // scheme://host[:port]
|
||||
token string
|
||||
}
|
||||
|
||||
func parseGitLabSecret(v *vault.Vault, h *vault.Handle) (gitlabSecret, error) {
|
||||
buf, err := v.Open(h)
|
||||
if err != nil {
|
||||
return gitlabSecret{}, err
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(string(buf.Bytes())))
|
||||
if err != nil {
|
||||
return gitlabSecret{}, fmt.Errorf("gitlab: parse secret url: %w", err)
|
||||
}
|
||||
if u.Scheme != "gitlab" && u.Scheme != "http" && u.Scheme != "https" {
|
||||
return gitlabSecret{}, fmt.Errorf("gitlab: unexpected scheme %q", u.Scheme)
|
||||
}
|
||||
scheme := u.Scheme
|
||||
if scheme == "gitlab" {
|
||||
scheme = "https"
|
||||
}
|
||||
tok := u.Query().Get("tok")
|
||||
if tok == "" {
|
||||
// tolerate a userinfo form (gitlab://_:<token>@host/) too.
|
||||
if u.User != nil {
|
||||
tok, _ = u.User.Password()
|
||||
}
|
||||
}
|
||||
if tok == "" {
|
||||
return gitlabSecret{}, fmt.Errorf("gitlab: secret has no token")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return gitlabSecret{}, fmt.Errorf("gitlab: secret has no host")
|
||||
}
|
||||
return gitlabSecret{base: scheme + "://" + u.Host, token: tok}, nil
|
||||
}
|
||||
|
||||
// build re-encodes a gitlabSecret into the single-line blob form (scheme normalised
|
||||
// back to "gitlab" so the in-vault form stays stable).
|
||||
func (s gitlabSecret) build() string {
|
||||
u := &url.URL{Scheme: "gitlab", Host: hostOf(s.base), Path: "/"}
|
||||
q := url.Values{}
|
||||
q.Set("tok", s.token)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// Rotate calls self/rotate with the old token and returns a vault handle to a
|
||||
// rebuilt blob carrying the new token. GitLab revokes the old token as part of this
|
||||
// call, so nothing further is needed to retire it.
|
||||
func (g *GitLab) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
|
||||
s, err := parseGitLabSecret(v, c.Secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
s.base+"/api/v4/personal_access_tokens/self/rotate", bytes.NewReader([]byte("{}")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("PRIVATE-TOKEN", s.token)
|
||||
|
||||
resp, err := g.client().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitlab: rotate token: %w", err)
|
||||
}
|
||||
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, fmt.Errorf("gitlab: rotate token: unexpected status %s", resp.Status)
|
||||
}
|
||||
var rotated struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rotated); err != nil {
|
||||
return nil, fmt.Errorf("gitlab: decode rotated token: %w", err)
|
||||
}
|
||||
if rotated.Token == "" {
|
||||
return nil, fmt.Errorf("gitlab: rotate token: empty token in response")
|
||||
}
|
||||
|
||||
ns := s
|
||||
ns.token = rotated.Token
|
||||
return v.Store([]byte(ns.build())), nil
|
||||
}
|
||||
|
||||
// Verify proves the new token authenticates by calling GET /api/v4/user with it.
|
||||
func (g *GitLab) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
|
||||
s, err := parseGitLabSecret(v, newSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/api/v4/user", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("PRIVATE-TOKEN", s.token)
|
||||
resp, err := g.client().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitlab verify: %w", err)
|
||||
}
|
||||
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("gitlab verify: GET /user status %s", resp.Status)
|
||||
}
|
||||
var who struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&who); err != nil {
|
||||
return fmt.Errorf("gitlab verify: decode user: %w", err)
|
||||
}
|
||||
if who.Username == "" {
|
||||
return fmt.Errorf("gitlab verify: token did not resolve to a user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeOld is a no-op: self/rotate already revoked the previous token. Defined to
|
||||
// satisfy the safety-spine ordering.
|
||||
func (g *GitLab) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// glEmu is an in-process GitLab API emulator: it tracks one currently-valid token
|
||||
// and emulates self/rotate (mint new, revoke the caller's token) and GET /user.
|
||||
// It records every Authorization/PRIVATE-TOKEN value seen so a test can assert the
|
||||
// new token is never logged where it shouldn't be.
|
||||
type glEmu struct {
|
||||
mu sync.Mutex
|
||||
cur string // the currently-valid token
|
||||
minted int // rotate counter, makes each new token unique
|
||||
seenTok []string // PRIVATE-TOKEN values observed
|
||||
}
|
||||
|
||||
func (e *glEmu) valid(tok string) bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.seenTok = append(e.seenTok, tok)
|
||||
return tok != "" && tok == e.cur
|
||||
}
|
||||
|
||||
func (e *glEmu) rotate() string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.minted++
|
||||
e.cur = "glpat-rotated-" + strings.Repeat("z", 4) + itoa(e.minted)
|
||||
return e.cur
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (e *glEmu) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v4/personal_access_tokens/self/rotate", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || !e.valid(r.Header.Get("PRIVATE-TOKEN")) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
newTok := e.rotate() // also revokes the caller's (old) token
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{"id": e.minted, "token": newTok, "revoked": false})
|
||||
})
|
||||
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.valid(r.Header.Get("PRIVATE-TOKEN")) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"id": 1, "username": "labadmin"})
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func TestGitLabDetect(t *testing.T) {
|
||||
g := &GitLab{}
|
||||
if !g.Detect(discover.Credential{Source: "gitlab"}) {
|
||||
t.Error("should detect Source=gitlab")
|
||||
}
|
||||
if g.Detect(discover.Credential{Source: "gitea"}) {
|
||||
t.Error("should not detect Source=gitea")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitLabRotateRealCutover(t *testing.T) {
|
||||
emu := &glEmu{cur: "glpat-seedtoken-0001"}
|
||||
srv := httptest.NewTLSServer(emu.handler())
|
||||
defer srv.Close()
|
||||
host := strings.TrimPrefix(srv.URL, "https://")
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
oldTok := emu.cur
|
||||
cred := discover.Credential{
|
||||
Source: "gitlab",
|
||||
Identity: "gitlab pat",
|
||||
Secret: v.Store([]byte("gitlab://" + host + "/?tok=" + url.QueryEscape(oldTok))),
|
||||
}
|
||||
g := &GitLab{HTTPClient: srv.Client()}
|
||||
ctx := context.Background()
|
||||
|
||||
newH, err := g.Rotate(ctx, cred, v)
|
||||
if err != nil {
|
||||
t.Fatalf("Rotate: %v", err)
|
||||
}
|
||||
|
||||
// Extract the new token from the rebuilt blob.
|
||||
nb, _ := v.Open(newH)
|
||||
nu, _ := url.Parse(strings.TrimSpace(string(nb.Bytes())))
|
||||
newTok := nu.Query().Get("tok")
|
||||
if newTok == "" || newTok == oldTok {
|
||||
t.Fatalf("blob carries no fresh token (got %q)", newTok)
|
||||
}
|
||||
|
||||
if err := g.Verify(ctx, newH, v); err != nil {
|
||||
t.Errorf("Verify(new) should pass: %v", err)
|
||||
}
|
||||
|
||||
// CUTOVER: the old token must be dead the moment Rotate returned (self/rotate
|
||||
// revokes it), independent of RevokeOld.
|
||||
if err := g.Verify(ctx, cred.Secret, v); err == nil {
|
||||
t.Error("Verify(old) must fail after rotation — old token should be revoked")
|
||||
} else if strings.Contains(err.Error(), newTok) {
|
||||
t.Error("error leaked the new token")
|
||||
}
|
||||
|
||||
// RevokeOld is a no-op for this in-place engine.
|
||||
if err := g.RevokeOld(ctx, cred, v); err != nil {
|
||||
t.Errorf("RevokeOld (no-op) returned: %v", err)
|
||||
}
|
||||
|
||||
// The new token must never have been handed to the emulator as the OLD token's
|
||||
// stand-in before it existed; and it must not appear in any leaked structure.
|
||||
for _, fld := range []string{cred.Identity} {
|
||||
if strings.Contains(fld, newTok) || strings.Contains(fld, oldTok) {
|
||||
t.Error("token leaked into credential metadata")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitLabRejectsBadSecret(t *testing.T) {
|
||||
g := &GitLab{}
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
cred := discover.Credential{Source: "gitlab", Secret: v.Store([]byte("gitlab://gitlab.example.com/"))} // no token
|
||||
if _, err := g.Rotate(context.Background(), cred, v); err == nil {
|
||||
t.Error("Rotate should reject a secret with no token")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package rotate
|
||||
|
||||
// Proof tracking — DATA, deliberately kept OUT of the driver code.
|
||||
//
|
||||
// Every Rotator in this package contains ONLY real rotation code: it talks to a
|
||||
// real service (HTTP API, DB client, SSH, local files). None of them contain
|
||||
// simulation branches or test awareness — the only thing a test injects is an
|
||||
// endpoint/HTTPClient, which the real deployment also configures.
|
||||
//
|
||||
// What differs between drivers is HOW their real-cutover path has been *validated*.
|
||||
// That validation status is data, not behavior, so it lives here in one table
|
||||
// rather than as prose scattered through (and easily confused with) the drivers.
|
||||
// docs/ROTATION-PROOFS.md is the human-facing mirror of this same data; `incredigo
|
||||
// rotate` surfaces it so a mock-only driver can never be mistaken for a live one.
|
||||
|
||||
// ProofLevel records how a driver's real-cutover behaviour has been validated.
|
||||
type ProofLevel int
|
||||
|
||||
const (
|
||||
// ProofUnproven: no cutover proof recorded (e.g. the dry-run noop helper).
|
||||
ProofUnproven ProofLevel = iota
|
||||
// ProofMockOnly: cutover proven only against an emulator — our own httptest /
|
||||
// in-process SSH server, OR a third-party mock (e.g. moto for AWS). NOT proven
|
||||
// against the real target service.
|
||||
ProofMockOnly
|
||||
// ProofLiveVM: cutover proven against the REAL target software running in the
|
||||
// sandbox VM (real PostgreSQL/MariaDB/Redis/wireguard-tools/Gitea/local files).
|
||||
ProofLiveVM
|
||||
)
|
||||
|
||||
// String renders the level as the token shown in the CLI and the manifest.
|
||||
func (p ProofLevel) String() string {
|
||||
switch p {
|
||||
case ProofLiveVM:
|
||||
return "LIVE-VM"
|
||||
case ProofMockOnly:
|
||||
return "MOCK-ONLY"
|
||||
default:
|
||||
return "UNPROVEN"
|
||||
}
|
||||
}
|
||||
|
||||
// proofLevels maps a driver's Name() to how its real-cutover path was validated.
|
||||
// This is the single source of truth; keep docs/ROTATION-PROOFS.md in sync.
|
||||
//
|
||||
// IMPORTANT honesty note: a driver running in the VM is NOT automatically LIVE-VM.
|
||||
// AWS ran in the VM but only against moto (a mock AWS), so it is MOCK-ONLY. LIVE-VM
|
||||
// means the real target software validated the cutover.
|
||||
var proofLevels = map[string]ProofLevel{
|
||||
// proven against the real target software in the sandbox VM:
|
||||
"postgres": ProofLiveVM, // real PostgreSQL (lab-provision-pg.sh)
|
||||
"mysql": ProofLiveVM, // real MariaDB (lab-provision-dbclones.sh)
|
||||
"redis": ProofLiveVM, // real redis-server(lab-provision-dbclones.sh)
|
||||
"wireguard": ProofLiveVM, // real wg (lab-provision-wg.sh)
|
||||
"gitea": ProofLiveVM, // real Gitea (lab-provision-gitea.sh)
|
||||
"appsecret": ProofLiveVM, // real local files (lab-provision-appsec.sh)
|
||||
|
||||
// proven only against an emulator / mock:
|
||||
"aws": ProofMockOnly, // moto mock AWS in VM; real AWS never hit
|
||||
"sshkey": ProofMockOnly, // in-process SSH server; live VM POC not run
|
||||
"openwrt": ProofMockOnly, // in-process SSH server; live QEMU deferred
|
||||
"mullvad": ProofMockOnly, // httptest emulator; needs a paid account
|
||||
"cloudflare": ProofMockOnly, // httptest emulator; no self-host
|
||||
"ghactions": ProofMockOnly, // httptest emulator; no self-host
|
||||
"gitlab": ProofMockOnly, // httptest emulator; GitLab CE too heavy for the VM
|
||||
}
|
||||
|
||||
// ProofLevelOf returns the recorded proof level for a driver name. Unknown or
|
||||
// proofless drivers (e.g. the noop dry-run helper) report ProofUnproven.
|
||||
func ProofLevelOf(name string) ProofLevel { return proofLevels[name] }
|
||||
Reference in New Issue
Block a user