Skip to content

V1.1 tier a#16

Merged
mikeappsec merged 6 commits into
mainfrom
v1.1-tier-a
Apr 28, 2026
Merged

V1.1 tier a#16
mikeappsec merged 6 commits into
mainfrom
v1.1-tier-a

Conversation

@mikeappsec

Copy link
Copy Markdown
Owner

No description provided.

…(K-AUTHN-2)

A misbehaving IdP would otherwise become a per-request DoS amplifier: every retry during a blip re-dials the wounded IdP. The Guard circuit-breaker is per (tenant, upstream); this adds per-credential coalescing on top, keyed on sha256(token) with a short TTL (errorTtl, default 5s, set to 0 to disable).

Three cache lines now run side-by-side, all keyed by the same digest: positive (claims, TTL = min(exp - now, maxCacheTtl)), negative (active:false sentinel, negativeTtl), and the new error line (ErrUpstream sentinel, errorTtl). Singleflight still coalesces concurrent first-misses.

Tests: TestIntrospection_ErrorCacheCoalescesUpstreamErrors, TestIntrospection_ErrorCacheTTLExpires, TestIntrospection_ErrorCachePerCredential, TestIntrospection_ErrorCacheNotPoisonedBySuccess, TestIntrospection_ErrorCacheKeyHashed. Docs updated; DESIGN.md and v1.0-review.md mark K-AUTHN-2 shipped.
Optional plugin/v1.1 application-layer signature: plugins HMAC-SHA256 their IdentifyResponse / AuthorizePluginResponse / MutateResponse over a deterministic length-prefixed canonical encoding, and the host verifies before trusting the result. Carried as gRPC trailing metadata (lwauth-sig / lwauth-kid / lwauth-alg) so the proto messages don't need a security-only field and old plugins remain wire-compatible.

Three modes: disabled (v1.0 default - existing configs untouched), verify (accept signed or unsigned, reject *bad* signatures - rolling-deployment escape valve), require (every response must be signed - safe-by-default). Alg/kid are bound into the protected payload, so a downgrade attempt invalidates the signature instead of silently degrading. v1.1 ships HMAC only; X.509/asymmetric is forward-compatible (alg name lives in the protected payload).

Closes the same-host attacker scenarios TLS doesn't: Unix-socket path race between dial and call, plaintext loopback bind race, compromised mesh terminator that can rewrite plugin replies. Application-layer signing is defense-in-depth on top of TLS, not a replacement.

New: pkg/plugin/sign (canonical encoder + Sign/Verify, 11 tests), pkg/plugin/grpc/sign.go (signing config + verifyTrailer policy, 14 integration tests). Wired into all three remote adapters (identifier, authorizer, mutator). Existing plugin tests untouched and still green; the 34-package go test ./... is clean.

Docs: docs/modules/plugin-grpc.md gains a 'Application-layer signing' section with mode-vs-behaviour table and threat model. v1.0-review.md and DESIGN.md mark F-PLUGIN-2 shipped.
Both items previously sat as numbered entries 15 and 16 in the legacy
Next-milestones list in DESIGN.md, alongside completed milestones.
Neither is on a v1.0.x patch line nor a Tier-A hardening slice:

- M13 (supply-chain hardening: dhi.io bases, cosign, SBOM, mirrored
  images) is operator-trust quality work that ships on its own cadence
  and complements but does not overlap K-CRYPTO-2 (FIPS, Tier A). Now
  B4.
- M14 (opt-in revocation surface: token revocation list, decision-cache
  invalidation API, shared session store) is net-new feature surface;
  the v1.0 short-TTL + refresh-rotation default still covers most
  deployments at zero cost. Now C3.

Also updates the section 4 introspection note cross-reference to point
at M14-REVOCATION (Tier C) instead of bare M14, and mirrors the new
rows in docs/security/v1.0-review.md tier tables.
Opt-in supervisor for grpc-plugin children: process exec, periodic
grpc.health.v1.Health.Check, exponential-backoff restart with jitter.
Operators on Kubernetes / systemd / launchd keep the v1.0 default
"platform owns the sidecar" model unchanged (no lifecycle block =
no supervisor). Operators running outside an orchestrator add a
lifecycle: block to the grpc-plugin config and let lwauth itself own
the plugin process.

New package pkg/plugin/supervisor:
- Config validates required fields and applies sane defaults
  (gracefulTimeout=5s, healthCheck.interval=5s, .timeout=1s,
  .failureThreshold=3, restart.initialBackoff=200ms, .maxBackoff=30s,
  .jitter=0.2, maxRestarts=0=unlimited).
- Supervisor spawns the child via os/exec, runs a health-probe loop
  alongside it, terminates the process on failureThreshold
  consecutive failures (SIGTERM on Unix, Kill on Windows via build-
  tag-split signal_unix.go / signal_windows.go), waits up to
  gracefulTimeout, then SIGKILL.
- Restart loop: exponential backoff (initial * 2^n capped at
  maxBackoff) with uniform ±jitter; MaxRestarts==0 = unlimited;
  exhaustion enters StateGaveUp.
- Stdout/stderr captured line-by-line into the host's slog under
  "plugin stdout"/"plugin stderr" keys.
- WaitReady blocks until the first successful probe (or give-up).

Wiring (pkg/plugin/grpc/lifecycle.go):
- New lifecycle: yaml block parsed by parseLifecycle (full schema:
  command/args/env/workDir/gracefulTimeout/startTimeout +
  healthCheck/restart sub-blocks). Absent => nil => v1.0 behaviour.
- startSupervisorIfConfigured pools supervisors by poolKey(cfg) so
  multiple module kinds (identifier+authorizer+mutator) referencing
  one plugin share one child + one ClientConn + one supervisor.
- Health probe uses the same dial() the data plane uses, so a probe
  failure means exactly what an Authorize failure would (TLS, mTLS,
  signing all behave identically).
- Engine startup blocks up to startTimeout (default 30s) on the first
  successful probe and surfaces failure as ErrConfig.

Tests (all green, race-clean):
- pkg/plugin/supervisor: 11 tests via TestMain helper-process pattern
  (sleep / sleep_then_exit / exit_immediately / exit_nonzero) + pure
  computeBackoff / validate tests. Covers ready, health-failure
  restart, child-crash restart, give-up, idempotent stop, double-
  start error, probe context honours timeout, bad-command path.
- pkg/plugin/grpc: 9 lifecycle wiring tests covering
  parse/absent/required/full-schema/defaults/bad-duration/bad-args-
  type plus startSupervisor nil-noop, bad-command-surfaces-ErrConfig,
  and pool-deduplication.

Docs:
- docs/modules/plugin-grpc.md Lifecycle section rewritten end-to-end
  with full schema and behaviour notes.
- docs/DESIGN.md A4 marked shipped with implementation summary.
- docs/security/v1.0-review.md A4 row marked shipped.

Tier A progress: 3/5 shipped (F-PLUGIN-2, K-AUTHN-2, M10).
Remaining: K-DOS-1, K-CRYPTO-2.
Optional Valkey-backed cluster-wide rate-limit aggregator. Per-replica
buckets remain the default and continue to act as a safety floor; the
new rateLimit.distributed: block adds a cross-replica cap so under N
pods a tenant cannot spend N x limit before any replica trips.

Schema (under AuthConfig.rateLimit):

  distributed:
    type: valkey
    addr: valkey-master.cache.svc:6379
    password: ${VALKEY_PASSWORD}      # optional
    keyPrefix: lwauth-rl/             # optional
    tls: false
    window: 1s                        # rolling window length
    timeout: 50ms                     # per-call deadline
    failOpen: false                   # allow on backend error?

Cluster cap = perTenant.burst per window (or perTenant.rps * window
when burst is zero). On backend success the local bucket is ALSO
charged so a single replica cannot exceed its configured rps even if
the cluster cap had headroom. On backend error / circuit-open / ctx
timeout the limiter falls back to the local bucket (or, with
failOpen: true, allows unconditionally) - the gateway stays
available during a Valkey blip with a transient N x rps worst case
instead of unbounded.

Backend abstraction (pkg/ratelimit/backend.go):
- DistributedSpec / DistributedBackend interface (Allow, Close).
- DistributedFactory + RegisterBackend / BuildBackend - same pattern
  the cache layer uses, so pkg/ratelimit stays dependency-free.

Valkey backend (pkg/ratelimit/valkey/valkey.go):
- Registers under type "valkey".
- Atomic Lua script per Allow:
    ZREMRANGEBYSCORE key -inf (now-window)
    if ZCARD key >= limit then return 0 end
    ZADD key now <unique-member>
    PEXPIRE key window
    return 1
- Per-key memory bounded by limit; TTL refreshed on every admission
  so idle tenants don't accumulate state.
- Wraps every call in upstream.Guard so circuit-breaking integrates
  with the rest of the upstream policy.
- Auto-imported via pkg/builtins so cmd/lwauth picks it up by default.

API change:
- ratelimit.New(spec) -> (*Limiter, error). MustNew added for tests
  and tightly-scoped callers. internal/config/loader.go and
  internal/pipeline/engine_test.go updated.
- Limiter.Close() releases the configured backend.
- Empty TenantID skips the aggregator (cluster-wide-cap-per-tenant is
  meaningless without a tenant key); routes to the local Default
  bucket only.

Tests (all green, race-clean):
- pkg/ratelimit/valkey: 9 miniredis-backed tests covering admit-
  under-limit, deny-at-limit, sliding-window expiry, tenant
  isolation, key-prefix isolation, TTL bounding, atomic concurrent
  admission (50x10 goroutines x attempts must yield exactly limit
  admissions), zero-limit, zero-window.
- pkg/ratelimit: 11 dispatch tests (factory dispatch, limit-from-RPS
  fallback, unknown-backend, distributed-deny-authoritative, key-
  prefix, fallback-on-error, failOpen, success-also-charges-local,
  empty-tenant-skip, Close passthrough, duplicate-register panic).

Docs:
- docs/modules/ratelimit.md gains "Cluster-wide aggregation
  (K-DOS-1, v1.1+)" section: full schema, semantics-under-outage
  table, wire protocol, ACL note.
- docs/DESIGN.md A3 marked shipped with implementation summary.
- docs/security/v1.0-review.md A3 row marked shipped.

Tier A progress: 4/5 shipped. Remaining: K-CRYPTO-2 (FIPS).
Last item of v1.1 Tier A. Operators in regulated environments
(FedRAMP High, DoD IL5, PCI-DSS, HIPAA-with-cryptographic-controls)
can now consume a separately-tagged FIPS image alongside the stock
artifact, with three independent verification paths.

Build path uses Go 1.24+'s in-tree FIPS 140-3 module selected via
GOFIPS140 — pure Go, no CGO, ~3 % overhead — rather than the older
GOEXPERIMENT=boringcrypto path (~10–20 % regression). Switching the
build mode flips the standard library's crypto backend; nothing in
lwauth's runtime code path needs to change.

New code:
- pkg/buildinfo: tiny zero-dep package exposing Version/Commit/Date
  (-ldflags-stamped), GoVersion(), FIPSEnabled() (wraps
  crypto/fips140.Enabled), and a Summary() helper for log lines.
- pkg/observability/metrics: registers two new collectors —
  lwauth_fips_enabled (always-present 0/1 gauge for alerting) and
  lwauth_build_info (constant labelled gauge with version / commit /
  go_version / fips). Test added for label set.
- pkg/lwauthd: logs build identity at startup (version, commit,
  go_version, fips_enabled). New --print-build-info flag prints a
  deterministic single-line banner that CI / Dockerfile self-checks
  grep for `fips_enabled=true`.

Build / packaging:
- Makefile: new targets `fips`, `fips-test` (runs full race suite
  under GOFIPS140 so a primitive that only differs in FIPS mode —
  e.g. RSA<2048, MD5 fallback, non-approved cipher — fails here
  rather than at promotion), `fips-verify` (asserts the built
  artifact self-reports fips_enabled=true), `docker-fips`. Default
  `build` target now stamps Version/Commit/Date via -ldflags into
  pkg/buildinfo.
- Dockerfile.fips: separate FIPS image with `GOFIPS140=v1.0.0` env,
  `org.lightweightauth.fips140=enabled` OCI label, and an in-build
  self-assertion that fails the image build if the produced binary
  doesn't carry the FIPS module. Tag suffix `-fips` plus the OCI
  label give image-policy admission webhooks two independent ways
  to refuse a stock image landing in a regulated namespace.

CI:
- .github/workflows/build.yaml: new fips-test job (vet + race tests
  under GOFIPS140 + make fips + make fips-verify) and a build-fips
  job that publishes <image>:<tag>-fips alongside the stock image
  with provenance and SBOM.

Docs:
- docs/operations/fips.md: full operator guide. Build modes table,
  three-pronged verification (admission webhook, Prometheus gauge,
  runtime self-report), primitive-by-primitive backend table, common
  pitfalls the FIPS test suite catches, performance notes, and
  validation lifecycle pointers.
- docs/DESIGN.md A5 marked shipped with implementation summary.
- docs/security/v1.0-review.md A5 row marked shipped.

Tier A complete — 5/5 shipped: F-PLUGIN-2, K-AUTHN-2, M10-PLUGIN-
LIFECYCLE, K-DOS-1, K-CRYPTO-2.

Validation: full `go vet ./...` clean and `go test ./... -race -timeout=180s`
green across all 35 test packages.
@mikeappsec
mikeappsec merged commit 5166ffe into main Apr 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant