Ver Fonte

Security hardening

Efren Yevale Varela há 3 semanas atrás
pai
commit
3e8f4d32d1

+ 14 - 0
.clusterfuzzlite/Dockerfile

@@ -0,0 +1,14 @@
+# ClusterFuzzLite builder image (T4-3).
+#
+# Pinned to the Rust base builder; the nightly selection comes from
+# `fuzz/rust-toolchain.toml` (nearest toolchain file to the working
+# directory), so the root stable pin is never violated. `cargo-fuzz` is
+# installed here so `build.sh` stays a plain build script.
+FROM gcr.io/oss-fuzz-base/base-builder-rust:v1
+
+RUN cargo install cargo-fuzz --locked
+
+COPY . "$SRC/d-oauth-server"
+WORKDIR "$SRC/d-oauth-server"
+
+COPY .clusterfuzzlite/build.sh "$SRC/build.sh"

+ 19 - 0
.clusterfuzzlite/build.sh

@@ -0,0 +1,19 @@
+#!/bin/bash -eu
+# ClusterFuzzLite build script (T4-3): compile every cargo-fuzz target and
+# stage the binaries plus any checked-in seed corpus into $OUT.
+#
+# Hermetic by construction: every target exercises only pure parsers (no
+# network, Postgres, or Redis), so the OSS-Fuzz sandbox needs no services.
+
+cd "$SRC/d-oauth-server/fuzz"
+
+cargo fuzz build -O --debug-assertions
+
+for target_path in fuzz_targets/*.rs; do
+  target="$(basename "$target_path" .rs)"
+  cp "build/x86_64-unknown-linux-gnu/release/$target" "$OUT/" 2>/dev/null \
+    || cp "target/x86_64-unknown-linux-gnu/release/$target" "$OUT/"
+  if [ -d "corpus/$target" ]; then
+    zip -j "$OUT/${target}_seed_corpus.zip" "corpus/$target/"* 2>/dev/null || true
+  fi
+done

+ 11 - 0
.clusterfuzzlite/project.yaml

@@ -0,0 +1,11 @@
+# ClusterFuzzLite project definition (T4-3).
+#
+# Continuous fuzzing for the cargo-fuzz targets under `fuzz/fuzz_targets/`.
+# The scheduled `fuzz-run` leg in `.github/workflows/ci.yml` stays as the
+# bounded fallback; this directory is what makes coverage continuous.
+# Owner step (needs no secret, but needs a repo owner): merging the
+# `cflite_*.yml` workflows is enough for PR fuzzing; batch (cron) fuzzing
+# additionally needs the Cloud Project dance in `docs/CONTRIBUTING.md`.
+language: rust
+sanitizers:
+  - address

+ 3 - 0
.dockerignore

@@ -14,3 +14,6 @@ __pycache__/
 fuzz/
 scripts/__pycache__/
 target/
+.clusterfuzzlite/
+lcov.info
+*.sarif

+ 83 - 0
.github/dependabot.yml

@@ -0,0 +1,83 @@
+# Dependabot keeps crates, Actions, and container base images current between
+# the `cargo audit` / `cargo deny` gates on push (see docs/TODO.md T4-2).
+# The `docker` entry aligns with the SHA-pinned bases in T4-1; the
+# `github-actions` entry tracks the SHA-pinned `uses:` refs the same way.
+# Conservative on purpose: one weekly run, few concurrent PRs.
+version: 2
+updates:
+  - package-ecosystem: "cargo"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+      day: "monday"
+      time: "06:00"
+      timezone: "UTC"
+    open-pull-requests-limit: 5
+    labels:
+      - "dependencies"
+    commit-message:
+      prefix: "chore"
+    groups:
+      minor-patch:
+        patterns:
+          - "*"
+        update-types:
+          - "minor"
+          - "patch"
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+      day: "monday"
+      time: "06:00"
+      timezone: "UTC"
+    open-pull-requests-limit: 5
+    labels:
+      - "dependencies"
+    commit-message:
+      prefix: "chore"
+    groups:
+      actions:
+        patterns:
+          - "*"
+        update-types:
+          - "minor"
+          - "patch"
+  - package-ecosystem: "docker"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+      day: "monday"
+      time: "06:00"
+      timezone: "UTC"
+    open-pull-requests-limit: 3
+    labels:
+      - "dependencies"
+    commit-message:
+      prefix: "chore"
+    groups:
+      base-images:
+        patterns:
+          - "*"
+        update-types:
+          - "minor"
+          - "patch"
+  - package-ecosystem: "docker"
+    directory: "/.clusterfuzzlite"
+    schedule:
+      interval: "weekly"
+      day: "monday"
+      time: "06:00"
+      timezone: "UTC"
+    open-pull-requests-limit: 1
+    labels:
+      - "dependencies"
+    commit-message:
+      prefix: "chore"
+    groups:
+      base-images:
+        patterns:
+          - "*"
+        update-types:
+          - "minor"
+          - "patch"

+ 36 - 0
.github/workflows/cflite_batch.yml

@@ -0,0 +1,36 @@
+# ClusterFuzzLite batch fuzzing (T4-3): continuous coverage of every target
+# in `fuzz/fuzz_targets/` on a schedule, independent of PRs. This is the leg
+# that discharges the `ci.yml` promise of continuous OSS-Fuzz/ClusterFuzzLite
+# coverage (see `docs/CONTRIBUTING.md` for the one-time owner setup).
+
+name: cflite-batch
+
+on:
+  schedule:
+    # Daily 04:00 UTC batch run; the weekly `fuzz-run` leg in `ci.yml`
+    # remains as the bounded fallback.
+    - cron: "0 4 * * *"
+  workflow_dispatch: {}
+
+permissions:
+  contents: read
+
+jobs:
+  batch:
+    name: batch-fuzz
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - name: Build fuzzers
+        uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
+        with:
+          language: rust
+          project-src-path: .
+      - name: Run fuzzers (batch, bounded)
+        uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
+        with:
+          language: rust
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          fuzz-seconds: 3600
+          mode: "batch"
+          report-unreproducible-crashes: false

+ 33 - 0
.github/workflows/cflite_pr.yml

@@ -0,0 +1,33 @@
+# ClusterFuzzLite PR fuzzing (T4-3): builds every target in
+# `fuzz/fuzz_targets/` via `.clusterfuzzlite/` and fuzzes code-change
+# coverage on each pull request. No credential needed beyond the default
+# `GITHUB_TOKEN` (`contents: read` suffices).
+
+name: cflite-pr
+
+on:
+  pull_request:
+    branches: ["master"]
+
+permissions:
+  contents: read
+
+jobs:
+  pr:
+    name: pr-fuzz
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - name: Build fuzzers
+        uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
+        with:
+          language: rust
+          project-src-path: .
+      - name: Run fuzzers (code-change, bounded)
+        uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
+        with:
+          language: rust
+          github-token: ${{ secrets.GITHUB_TOKEN }}
+          fuzz-seconds: 300
+          mode: "code-change"
+          report-unreproducible-crashes: false

+ 114 - 29
.github/workflows/ci.yml

@@ -1,20 +1,35 @@
 # Main quality gate (task 42).
 #
-# Per-PR legs (all must be green):
+# Per-PR legs (all must be green, except `coverage` which is report-only):
 #   fmt, clippy, audit, deny, sqlx-prepare-check, nextest, doc-tests,
+#   coverage (llvm-cov report upload over lib unit tests; non-blocking),
 #   fuzz-build (nightly leg inside `fuzz/`, so the pinned stable
 #   `rust-toolchain.toml` at the root is never violated).
 # Scheduled legs (not per-PR):
 #   mutants (weekly budgeted mutation run over the pure security modules),
-#   fuzz-run (bounded run of every fuzz target; the per-PR leg only builds).
+#   fuzz-run (bounded run of every fuzz target; the per-PR leg only builds),
+#   miri (scoped interpreter run over the sync crypto/JOSE helpers;
+#   allow-failure, never gates).
 #
 # Must NOT gate on miri for tokio/sqlx/redis; no lint suppressions anywhere.
+# The `miri` leg therefore runs only sync unit tests (`crypto::{ct, pkce,
+# tokens}` and the sync subset of `jose::jar` — the fetch-decode,
+# request-URI-membership, and alg-rejection tests, which short-circuit before
+# any crypto or I/O), excluding the argon2 KDF
+# (`crypto::secret`, cost prohibitive under the interpreter), the `ring`-backed
+# modules (`crypto::field`, `crypto::totp`, whose C/asm calls the interpreter
+# cannot execute), and every
+# `#[tokio::test]` / redis-backed test (DPoP replay store, async claim
+# paths, including the async `jose::jar::resolve` path itself — only its sync
+# unit tests are selected); the whole leg is `continue-on-error` so it can
+# never gate a PR. If a `#[tokio::test]` or a real-verification test is ever
+# added under `jose::jar`, narrow the filter to the explicit sync test paths.
 
 name: ci
 
 on:
   push:
-    branches: ["main"]
+    branches: ["master"]
   pull_request: {}
   schedule:
     # Weekly budgeted mutation run (Sunday 03:00 UTC).
@@ -33,8 +48,8 @@ jobs:
     name: fmt
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: dtolnay/rust-toolchain@stable
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
         with:
           components: rustfmt
       - name: Check formatting
@@ -44,8 +59,8 @@ jobs:
     name: clippy
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: dtolnay/rust-toolchain@stable
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
         with:
           components: clippy
       - name: Clippy (all targets, warnings denied)
@@ -55,8 +70,8 @@ jobs:
     name: audit
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: dtolnay/rust-toolchain@stable
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
       - name: Install cargo-audit
         run: cargo install cargo-audit --locked
       - name: Audit advisories
@@ -66,8 +81,8 @@ jobs:
     name: deny
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: dtolnay/rust-toolchain@stable
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
       - name: Install cargo-deny
         run: cargo install cargo-deny --locked
       - name: Check supply chain (ring-only TLS enforced in deny.toml)
@@ -91,8 +106,8 @@ jobs:
           --health-timeout 5s
           --health-retries 10
     steps:
-      - uses: actions/checkout@v4
-      - uses: dtolnay/rust-toolchain@stable
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
       - name: Install sqlx-cli
         run: cargo install sqlx-cli --locked --version 0.9.0 --no-default-features --features postgres,rustls
       - name: Apply migrations to the check database
@@ -108,8 +123,8 @@ jobs:
     name: doc-links
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: actions/setup-python@v5
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
         with:
           python-version: "3.x"
       - name: Check documentation links (markdown-link-check equivalent)
@@ -119,10 +134,10 @@ jobs:
     name: nextest
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: dtolnay/rust-toolchain@stable
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
       - name: Install cargo-nextest
-        uses: taiki-e/install-action@v2
+        uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2
         with:
           tool: nextest
       - name: Install redis-server (in-process Sentinel/Cluster fixtures)
@@ -139,8 +154,8 @@ jobs:
     name: doc-tests
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: dtolnay/rust-toolchain@stable
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
       - name: Documentation tests
         run: cargo test --doc
 
@@ -151,7 +166,7 @@ jobs:
       run:
         working-directory: fuzz
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
       # The `fuzz/rust-toolchain.toml` in the working directory selects
       # nightly via rustup override; the root stable pin is untouched.
       - name: Install cargo-fuzz (nightly, per fuzz/rust-toolchain.toml)
@@ -164,19 +179,20 @@ jobs:
     # Scheduled bounded fuzzing. `fuzz-build` (above) only compiles the
     # targets per PR; this leg actually executes them, so a crash is found
     # without a human starting it. It is bounded and scheduled, not 24/7:
-    # continuous coverage is OSS-Fuzz/ClusterFuzzLite, tracked in docs/TODO.md.
+    # continuous coverage is ClusterFuzzLite (`.clusterfuzzlite/`, docs in
+    # `docs/CONTRIBUTING.md`); the remaining owner step is enabling it.
     if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
     runs-on: ubuntu-latest
     timeout-minutes: 70
     strategy:
       fail-fast: false
       matrix:
-        target: [token_form, dcr_json, jar_claims]
+        target: [token_form, dcr_json, jar_claims, dpop_proof, jar_verify, redirect_uri, scope_parse]
     defaults:
       run:
         working-directory: fuzz
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
       # `fuzz/rust-toolchain.toml` selects nightly; the root stable pin is
       # untouched, exactly as in `fuzz-build`.
       - name: Install cargo-fuzz (nightly)
@@ -184,7 +200,7 @@ jobs:
       # `uses` steps resolve paths from the workspace root, not the job's
       # `working-directory`; keep the fuzz/ prefix explicit.
       - name: Restore corpus for ${{ matrix.target }}
-        uses: actions/cache/restore@v4
+        uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
         with:
           path: fuzz/corpus/${{ matrix.target }}
           key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}
@@ -196,13 +212,13 @@ jobs:
       # `restore-keys` always pick the newest entry.
       - name: Save corpus for ${{ matrix.target }}
         if: success()
-        uses: actions/cache/save@v4
+        uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
         with:
           path: fuzz/corpus/${{ matrix.target }}
           key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}
       - name: Upload crash artifacts
         if: failure()
-        uses: actions/upload-artifact@v4
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
         with:
           name: fuzz-crash-${{ matrix.target }}
           path: fuzz/artifacts/${{ matrix.target }}
@@ -215,9 +231,78 @@ jobs:
     runs-on: ubuntu-latest
     timeout-minutes: 60
     steps:
-      - uses: actions/checkout@v4
-      - uses: dtolnay/rust-toolchain@stable
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
       - name: Install cargo-mutants
         run: cargo install cargo-mutants --locked
       - name: Budgeted mutation run (see .cargo/mutants.toml)
         run: cargo mutants --timeout 1800 --test-tool nextest
+
+  coverage:
+    name: coverage
+    # Report-only coverage signal (T4-4). Non-blocking by design: the upload
+    # is the deliverable; there is no `--fail-under-*` threshold yet.
+    # Services mirror `nextest` (shared Postgres/Redis plus redis-server),
+    # because several lib unit tests are testcontainers-backed.
+    # Follow-up: add a threshold and extend beyond `--lib` once
+    # the first reports land.
+    runs-on: ubuntu-latest
+    continue-on-error: true
+    steps:
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
+        with:
+          components: llvm-tools-preview
+      - name: Install cargo-llvm-cov
+        uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2
+        with:
+          tool: cargo-llvm-cov
+      - name: Install redis-server (in-process Sentinel/Cluster fixtures)
+        run: sudo apt-get update && sudo apt-get install -y redis-server
+      - name: Start shared test services (Postgres + Redis)
+        run: docker compose -f compose.test.yaml up -d
+      - name: Coverage report (lib unit tests only; integration tests stay in `nextest`)
+        run: cargo llvm-cov --lib --lcov --output-path lcov.info
+      - name: Stop shared test services
+        if: always()
+        run: docker compose -f compose.test.yaml down -v
+      - name: Upload coverage report
+        if: always()
+        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+        with:
+          name: coverage-lcov
+          path: lcov.info
+          if-no-files-found: error
+
+  miri:
+    name: miri
+    # Scoped miri leg (T4-4). Scheduled only: the interpreter is slow and
+    # nightly-only, so this never runs per-PR and never gates
+    # (`continue-on-error`). Nightly is confined to this job; the root
+    # `rust-toolchain.toml` stable pin is untouched.
+    # Scope is the sync unit tests of the dependency-free helpers, plus the
+    # sync subset of `jose::jar` (fetch-decode, request-URI-membership, and
+    # alg-rejection tests only — never the async `resolve` path).
+    # Explicitly excluded, per the header constraint: tokio (`#[tokio::test]`), sqlx, and redis
+    # (DPoP replay store, async claim paths), plus the argon2 KDF
+    # (`crypto::secret`, cost prohibitive under the interpreter) and the
+    # `ring`-backed modules (`crypto::field`, `crypto::totp`, whose C/asm
+    # calls Miri cannot interpret).
+    if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+    runs-on: ubuntu-latest
+    timeout-minutes: 60
+    continue-on-error: true
+    steps:
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: dtolnay/rust-toolchain@f0d5300ba8908d942c808f4d31c08471d7798ba3 # nightly
+        with:
+          components: miri
+      - name: Scoped miri run (sync crypto/JOSE helpers only)
+        env:
+          # Relaxed isolation for the first scheduled runs (wall-clock/env
+          # access); tighten in a follow-up once green.
+          MIRIFLAGS: -Zmiri-disable-isolation
+        run: >
+          cargo miri test --lib --
+          crypto::ct crypto::pkce crypto::tokens
+          jose::jar

+ 2 - 2
.github/workflows/docs.yml

@@ -35,8 +35,8 @@ jobs:
     name: docs link-check
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: actions/setup-python@v5
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
         with:
           python-version: "3.x"
       - name: Check documentation links

+ 14 - 14
.github/workflows/supply-chain.yml

@@ -2,7 +2,7 @@
 #
 # Per-PR legs: `manifest-lint` (kustomize build + scripts/check_manifests.py,
 # including the L7-vs-L4 mTLS pairing check).
-# Per-push-to-main legs: multi-arch `docker buildx bake` with SBOM +
+# Per-push-to-master legs: multi-arch `docker buildx bake` with SBOM +
 # provenance, trivy/grype gating HIGH/CRITICAL, keyless cosign signing, and
 # digest pinning of `deploy/k8s/kustomization.yaml` (strict lint re-run).
 # Scheduled legs (weekly rebuild, Monday 04:00 UTC): rebuild + rescan + resign
@@ -15,7 +15,7 @@ name: supply-chain
 
 on:
   push:
-    branches: ["main"]
+    branches: ["master"]
   pull_request: {}
   schedule:
     # Weekly rebuild (Monday 04:00 UTC).
@@ -34,8 +34,8 @@ jobs:
     name: manifest-lint
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: actions/setup-python@v5
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
         with:
           python-version: "3.x"
       - name: Install pyyaml + kustomize
@@ -59,10 +59,10 @@ jobs:
     outputs:
       digest: ${{ steps.bake.outputs.digest }}
     steps:
-      - uses: actions/checkout@v4
-      - uses: docker/setup-qemu-action@v3
-      - uses: docker/setup-buildx-action@v3
-      - uses: docker/login-action@v3
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
+      - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
+      - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
         with:
           registry: ghcr.io
           username: ${{ github.actor }}
@@ -85,12 +85,12 @@ jobs:
           open('${GITHUB_OUTPUT}', 'a').write(f'digest={node}\n')
           "
       - name: SBOM (syft, committed as artifact)
-        uses: anchore/sbom-action@v0
+        uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
         with:
           image: ${{ env.REGISTRY_IMAGE }}@${{ steps.bake.outputs.digest }}
           artifact-name: sbom.spdx.json
       - name: Scan (trivy, gate HIGH/CRITICAL)
-        uses: aquasecurity/trivy-action@0.24.0
+        uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 # v0.24.0
         with:
           image-ref: ${{ env.REGISTRY_IMAGE }}@${{ steps.bake.outputs.digest }}
           format: sarif
@@ -98,12 +98,12 @@ jobs:
           severity: HIGH,CRITICAL
           exit-code: "1"
       - name: Scan (grype, gate high/critical)
-        uses: anchore/scan-action@v4
+        uses: anchore/scan-action@64a33b277ea7a1215a3c142735a1091341939ff5 # v4
         with:
           image: ${{ env.REGISTRY_IMAGE }}@${{ steps.bake.outputs.digest }}
           fail-build: true
           severity-cutoff: high
-      - uses: sigstore/cosign-installer@v3
+      - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
       - name: Keyless sign (cosign, OIDC)
         env:
           COSIGN_YES: "true"
@@ -117,8 +117,8 @@ jobs:
     needs: [build]
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: actions/setup-python@v5
+      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+      - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
         with:
           python-version: "3.x"
       - name: Pin kustomization to the built digest + strict lint

+ 2 - 0
.gitignore

@@ -6,3 +6,5 @@
 /target
 __pycache__/
 scripts/__pycache__/
+lcov.info
+*.sarif

+ 4 - 0
.well-known/security.txt

@@ -0,0 +1,4 @@
+Contact: mailto:REPLACE-ME--security-contact@example.invalid
+Expires: 2027-12-31T00:00:00.000Z
+Preferred-Languages: en
+Policy: https://REPLACE-ME--server-origin.example.invalid/SECURITY.md

+ 1 - 0
README.md

@@ -13,6 +13,7 @@ state is in Redis, so replicas are interchangeable.
 
 - Protocol and deltas: [docs/OAUTH.md](docs/OAUTH.md)
 - Threat model and token design: [docs/SECURITY.md](docs/SECURITY.md)
+- Vulnerability reporting policy: [SECURITY.md](SECURITY.md)
 - Module layout and request flow: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
 - Every configuration key: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
 - Schema and migrations: [docs/DATABASE.md](docs/DATABASE.md)

+ 36 - 0
RUNBOOK.md

@@ -171,3 +171,39 @@ correlation. No step below writes a secret to a log.
 - Do not relax the fail-closed policy to keep the security endpoints up.
 - Do not paste the leaked registration tokens, client secrets, or the pepper
   into a ticket, chat, or log.
+
+## 6. Deploy provenance (signed image + digest record)
+
+Every rollout ships the signed `ghcr.io/d-oauth/d-oauth-server` image the
+`supply-chain.yml` `build` job signed (step `Keyless sign (cosign, OIDC)`)
+and the `pin-digest` job pinned into `deploy/k8s/kustomization.yaml`
+(step `Pin kustomization to the built digest + strict lint`). Nothing ships
+from an unverified local tarball. The full path is in
+[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#deploy-path-use-the-signed-ghcrio-image).
+
+- [ ] Read the digest the `pin-digest` job committed:
+  `grep -A2 'name: ghcr.io/d-oauth/d-oauth-server' deploy/k8s/kustomization.yaml`.
+- [ ] Verify it before applying (placeholder `sha256:<digest>` stands for the
+  value from the previous step; no real digest is recorded here):
+
+  ```sh
+  cosign verify \
+    --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+    --certificate-identity-regexp 'supply-chain' \
+    'ghcr.io/d-oauth/d-oauth-server@sha256:<digest>'
+  kustomize build deploy/k8s
+  python3 scripts/check_manifests.py --strict-digest
+  kubectl apply --dry-run=server -k deploy/k8s
+  kubectl apply -k deploy/k8s
+  ```
+
+- [ ] Record the rollout: the shipped `sha256:<digest>`, the `build` run that
+  produced the `sbom.spdx.json` artifact and `trivy.sarif`, and the
+  `cosign verify` result. A rollout with no matching record is unverified:
+  stop and re-verify.
+- [ ] Local-build fallback only (registry unreachable): build with
+  `docker buildx bake image --set image.platform=linux/amd64`, sign with
+  `cosign sign --key <local-key-ref>`, confirm with
+  `cosign verify --key <local-pub-ref>`, then record the local digest as an
+  explicitly local, non-`ghcr.io` deploy. Never
+  `docker save | docker load` an unsigned image onto a live host.

+ 60 - 0
SECURITY.md

@@ -0,0 +1,60 @@
+# Security Policy
+
+How to report a vulnerability in d-OAuth-Server, and what to expect.
+The threat model and token design live in the separate
+[threat-model guide](docs/SECURITY.md); this file is only the reporting
+process.
+
+> [!IMPORTANT]
+> Placeholder contact — not yet configured.
+> No reporting address exists in the repo yet (checked README and docs;
+> only `example.com` operational placeholders were found). Replace
+> `REPLACE-ME--security-contact@example.invalid` below with Efren's
+> preferred contact before publishing. `.invalid` never resolves, so the
+> placeholder cannot receive mail.
+
+## Reporting contact
+
+- Email: `REPLACE-ME--security-contact@example.invalid`
+  (**placeholder — see the note above**).
+- Alternative: open a private security advisory on the repository instead
+  of a public issue.
+
+## Scope
+
+In scope: this repository — the OAuth endpoints, token handling, key
+management, login/consent/device pages, CLI admin surface, and container
+configuration. Out of scope: third-party dependencies (report those
+upstream, but tell us if you think we are affected), and production
+deployments run by others (report those to the deployment's operator).
+
+## What to include in a report
+
+- Affected version or commit, and how to reproduce the issue.
+- The impact as you see it (what an attacker gains, and the preconditions).
+- Any logs or proof-of-concept output, with secrets, tokens, and key
+  material redacted.
+
+## What to expect
+
+- Acknowledgement of your report within 5 business days.
+- A fix timeline once the issue is confirmed; we will keep you updated if
+  the timeline slips.
+- Credit in the changelog on request, once the fix is released.
+- Please do not disclose the issue publicly until a fix is available and
+  we have agreed a disclosure date with you.
+
+## Safe harbor
+
+Good-faith research reported through this process and confined to systems
+you are authorized to test will not be treated as abuse. Do not access
+other users' data, degrade the service, or retain data beyond what is
+needed to demonstrate the issue.
+
+## security.txt
+
+A template for operators lives at
+[`.well-known/security.txt`](.well-known/security.txt) (same placeholder
+contact). If you deploy this server at an origin, serve that file's content
+at `<origin>/.well-known/security.txt` and replace the placeholders with
+your deployment's contact and canonical policy URL first.

+ 48 - 0
docs/CONTRIBUTING.md

@@ -102,6 +102,54 @@ spawns in-process `redis-test` Sentinel/Cluster fixtures, which need a real
 `testcontainers`. They need a working Docker daemon and, for the in-process
 topology fixtures, the `redis-server` binary.
 
+### Fuzzing
+
+Fuzz targets live in `fuzz/fuzz_targets/` (a standalone workspace under
+`fuzz/`, built only by the nightly leg — the root stable pin is untouched):
+
+| Target | Real entry point fuzzed |
+| --- | --- |
+| `token_form` | `crypto::tokens::TokenHash::parse_storage_string` |
+| `dcr_json` | `protocol::dcr::RegisterRequest` deserialization |
+| `jar_claims` | JWT-claims envelope shape + `jose::jar::require_par_or_jar` |
+| `dpop_proof` | `jose::jwk::{decoding_key, thumbprint}` (embedded-JWK gate) |
+| `jar_verify` | `protocol::client_auth::assertion::{unverified_iss, validate_assertion}` |
+| `redirect_uri` | `protocol::redirect_repo::match_redirect_uri` |
+| `scope_parse` | `protocol::scope::ScopeSet::{parse, render}` |
+
+Every target is small and hermetic (no network, Postgres, or Redis):
+signature verification that needs keys and replay claims that need the store
+stay under unit/proptest cover. Targets contain no `unwrap`/`expect`/`panic`/
+`unsafe`, so arbitrary input can only surface a genuine parser panic.
+
+```bash
+cd fuzz
+cargo fuzz build                       # every target must compile
+cargo fuzz run <target> -- -max_total_time=60 -rss_limit_mb=2048
+```
+
+Corpora persist across runs: `cargo fuzz run` reads and writes
+`fuzz/corpus/<target>/` (gitignored, but cached across CI runs by the
+`fuzz-run` leg), and crashes land in `fuzz/artifacts/<target>/`. To grow a
+local seed, drop an input in `fuzz/corpus/<target>/` and confirm the
+target still passes; keep seeds tiny and non-secret (never a real token, key,
+or password). Seeds stay local: `fuzz/.gitignore` excludes `corpus/`, so a
+seed is only shared by committing it with `git add -f` deliberately.
+
+Continuous coverage is ClusterFuzzLite (`.clusterfuzzlite/` plus
+`.github/workflows/cflite_pr.yml` for PRs and `cflite_batch.yml` for the
+daily batch run). One-time owner setup for Efren:
+
+1. Merge the `cflite_*.yml` workflows (PR fuzzing needs only the default
+   `GITHUB_TOKEN`).
+2. For batch fuzzing, follow the ClusterFuzzLite batch setup (Cloud Project +
+   filestore) at <https://google.github.io/clusterfuzzlite/> and point it at
+   this repository; no code change is needed — `.clusterfuzzlite/build.sh`
+   already builds every target.
+3. OSS-Fuzz enrollment (optional, stronger): propose the project at
+   <https://github.com/google/oss-fuzz> reusing the same `Dockerfile` and
+   `build.sh` layout.
+
 ## Module conventions
 
 The crate is one library (`src/lib.rs`) plus a thin binary (`src/main.rs`).

+ 77 - 6
docs/DEPLOYMENT.md

@@ -495,17 +495,88 @@ on, see the TLS-terminating reverse proxy section above.
 
 ## Supply chain
 
-The image pipeline is gated on:
-
-- Multi-arch build with an SBOM and provenance attestation.
-- A vulnerability scan (`trivy` or `grype`) that fails on HIGH and CRITICAL.
-- Keyless signing with `cosign`.
-- Digest pinning, with a periodic rebuild so base digests advance.
+The image pipeline lives in `.github/workflows/supply-chain.yml` and is gated
+on:
+
+- Multi-arch build with an SBOM and provenance attestation (job `build`, step
+  `Multi-arch bake with SBOM + provenance`: `docker buildx bake image` with
+  `--sbom=true --provenance=mode=max`).
+- A vulnerability scan that fails on HIGH and CRITICAL (job `build`, steps
+  `Scan (trivy, gate HIGH/CRITICAL)` writing `trivy.sarif` and
+  `Scan (grype, gate high/critical)`; the SBOM is committed as the
+  `sbom.spdx.json` artifact by step `SBOM (syft, committed as artifact)`).
+- Keyless signing with `cosign` (job `build`, step
+  `Keyless sign (cosign, OIDC)`: `cosign sign` plus `cosign attest`).
+- Digest pinning (job `pin-digest`, step
+  `Pin kustomization to the built digest + strict lint`): the signed build
+  digest overwrites `images[].digest` in `deploy/k8s/kustomization.yaml`,
+  then re-runs `python3 scripts/check_manifests.py --strict-digest`, with a
+  periodic rebuild so base digests advance.
 - `cargo deny check` in CI, which enforces the ring-only TLS policy in
   `deny.toml`.
 - A per-PR fuzz build plus a scheduled bounded run of every fuzz target
   (`.github/workflows/ci.yml`); a crash uploads its artifact and fails the run.
 
+### Deploy path: use the signed `ghcr.io` image
+
+The only shippable artifact is the signed registry image, never a locally
+built tarball. Every rollout verifies provenance before applying anything,
+then records the exact digest it shipped:
+
+```bash
+# 1. Read the pinned digest the `pin-digest` job committed.
+grep -A2 'name: ghcr.io/d-oauth/d-oauth-server' deploy/k8s/kustomization.yaml
+
+# 2. Verify the keyless cosign signature from the `build` job's
+#    `Keyless sign (cosign, OIDC)` step before pulling or applying.
+#    Match the identity to the signing workflow; the placeholder below
+#    stands for the real digest from step 1 (no real digest is recorded here).
+cosign verify \
+  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+  --certificate-identity-regexp 'supply-chain' \
+  'ghcr.io/d-oauth/d-oauth-server@sha256:<digest>'
+
+# 3. Confirm the manifests resolve to that digest and pass policy.
+kustomize build deploy/k8s
+python3 scripts/check_manifests.py --strict-digest
+
+# 4. Roll out (migration Job first; the Deployment readiness gate waits for it).
+kubectl apply --dry-run=server -k deploy/k8s
+kubectl apply -k deploy/k8s
+```
+
+Record the shipped `sha256:<digest>` in [RUNBOOK.md](../RUNBOOK.md)
+(deploy provenance entry: digest, `sbom.spdx.json` / `trivy.sarif` run, and
+the `cosign verify` result). A rollout with no matching record is an
+unverified deploy: stop and re-verify. `:latest` is never a shippable
+reference.
+
+### Fallback: local build (unsigned host, sign and verify locally)
+
+When the registry is unreachable, build locally with the same bake target the
+`build` job uses, sign with a local key, verify the signature, and only then
+load the image. Do not `docker save | docker load` an unsigned image onto a
+live host:
+
+```bash
+# 1. Build the same target CI signs.
+docker buildx bake image --set image.platform=linux/amd64
+docker images --digests d-oauth-server
+
+# 2. Sign the local digest and verify it before loading or running.
+#    (Keyless OIDC is unavailable off CI, so a local key stands in;
+#    keep the private key off the target host.)
+cosign sign --key <local-key-ref> 'd-oauth-server@sha256:<digest>'
+cosign verify --key <local-pub-ref> 'd-oauth-server@sha256:<digest>'
+
+# 3. Record the local `sha256:<digest>` and the verify result in RUNBOOK.md
+#    as an explicitly local, non-`ghcr.io` deploy.
+```
+
+Prefer the signed `ghcr.io` path for every production rollout; the fallback
+exists so an air-gapped host still gets a sign/verify step and a recorded
+digest, not an anonymous tarball.
+
 ## Security properties
 
 - No shell and no package manager in the runtime image narrows the attack

+ 9 - 4
docs/SECURITY.md

@@ -2,7 +2,9 @@
 
 This guide states the threat model, the token and key design, the sender
 constraint model, the fail-closed policy, and the admin replacement procedure.
-The implementation is the authority; the citations point at it.
+The implementation is the authority; the citations point at it. To report a
+vulnerability, see the separate [reporting policy](../SECURITY.md), not this
+guide.
 
 ## Purpose
 
@@ -58,7 +60,7 @@ class, or runtime setting).
 
 | Vector | Existing controls | Residual risk |
 | --- | --- | --- |
-| Application-level RCE in a parser (form bodies, DPoP/JAR/`private_key_jwt` JWTs, DCR JSON, `jwks_uri`/`request_uri` fetches) | `#![forbid(unsafe_code)]`, no `unwrap`/`panic` in request paths, rustls/ring (no OpenSSL), fuzz targets for token-form/DCR-JSON/JAR-claims | Fuzzing is not exhaustive. |
+| Application-level RCE in a parser (form bodies, DPoP/JAR/`private_key_jwt` JWTs, DCR JSON, `jwks_uri`/`request_uri` fetches) | `#![forbid(unsafe_code)]`, no `unwrap`/`panic` in request paths, rustls/ring (no OpenSSL), fuzz targets for token-form/DCR-JSON/JAR-claims plus DPoP-JWK/assertion-verify/redirect-URI/scope-parse, with continuous ClusterFuzzLite coverage (`.clusterfuzzlite/`, `cflite_*.yml`) | Fuzzing is not exhaustive. |
 | Dependency / supply-chain compromise | Committed `Cargo.lock`, `cargo deny`/`cargo audit` in CI, SBOM + provenance + keyless cosign, digest-pinned bases | A zero-day in a trusted crate. |
 | Container escape via kernel or runtime bug | Non-root UID 65532, read-only root filesystem, all capabilities dropped, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault` | A kernel LPE or runc bug, mitigated but not eliminated. |
 | Secret exfiltration from the process environment | Secrets are injected at runtime, not baked into image layers; the pepper/TOTP/PII keys live outside the database; the Deployment mounts every secret — pepper, TOTP key, PII key, and the database/Redis passwords — as `0400` tmpfs files under `/run/secrets` (read through their `*_FILE` variables), and the passwordless DSNs come from the ConfigMap, so no secret enters the environment | An attacker with code execution can still read the mounted secret files under `/run/secrets`; that is the same capability the server has. |
@@ -67,12 +69,15 @@ class, or runtime setting).
 | Unrestricted pod egress (SSRF to metadata, exfiltration) | `networkpolicy.yaml` default-denies egress except DNS (53 to `kube-system`) and Postgres/Redis (5432/6379) scoped to the namespace hosting them — no internet-wide `0.0.0.0/0` peer; any `ipBlock` peer excludes `169.254.0.0/16`; ingress is limited to pod ports 8080/8081 | Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. A managed database outside the cluster needs a specific CIDR or a CNI FQDN policy. The default-deny also blocks the JAR `request_uri` HTTPS fetch — remote request objects are unsupported unless the deployment adds a scoped allowance (see `docs/DEPLOYMENT.md`). |
 | Operator `exec` into a running pod | No shell and a non-root, read-only-rootfs container limit what runs; every secret is a file the container user already reads, and no secret is in the environment | `pods/exec` (and `docker exec`) bypass the application's own authorization; restrict it with RBAC and audit it (`docs/OPERATIONS.md`). |
 | Kubernetes API access from the pod | `automountServiceAccountToken: false`; the binary never calls the API | A future feature that needs the API would have to re-enable it deliberately. |
-| Image/registry compromise | Digest pinning, cosign verification, and a committed digest reference with no shippable `:latest`; CI writes the signed build digest into `kustomization.yaml` | A registry compromise before the digest is recorded still ships; the pin step must run. |
+| Image/registry compromise | Digest pinning, keyless cosign signing in the `supply-chain.yml` `build` job (`Keyless sign (cosign, OIDC)`), verify-before-rollout with `cosign verify` on the pinned `ghcr.io/d-oauth/d-oauth-server@sha256:<digest>` (see `docs/DEPLOYMENT.md`), `pin-digest` writing the signed digest into `kustomization.yaml` with a `--strict-digest` lint, a committed digest reference with no shippable `:latest`, and a recorded digest per rollout in `RUNBOOK.md`; the local-build fallback still signs (`cosign sign`) and verifies (`cosign verify`) before loading | A registry compromise of an unpinned tag still ships if the operator skips the verify step; an unsigned `docker save \\| docker load` tarball bypasses every control, so it is forbidden except through the documented local sign/verify fallback. |
 | Rate-limit evasion via spoofed `X-Forwarded-For` behind an L7 proxy | Default-empty   `rate_limit.trusted_proxies` (headers ignored unless the TCP peer is an allowlisted proxy IP/CIDR); rightmost-untrusted selection; malformed headers fall back to the verified peer; attribution is bucket selection only, never authentication | An allowlist entry that is too broad (or a compromised proxy) attributes attacker-chosen IPs; keep entries to the proxy peers and never a `/0` (rejected at load). Proxies must emit bare IPs (no port/brackets/`unknown`); anything else is skipped, and only the rightmost 32 entries are considered. |
 
 Last reviewed: 2026-10-09. File-mounted DB/Redis passwords, digest pinning,
 NetworkPolicy, `automountServiceAccountToken: false`, and scheduled fuzzing are
-implemented in the shipped manifests and workflows.
+implemented in the shipped manifests and workflows. Deploy provenance is a
+documented verify-before-rollout path (`docs/DEPLOYMENT.md`, `RUNBOOK.md`):
+the signed `ghcr.io` image with `cosign verify` and a recorded digest, with
+local builds kept only as a signed fallback.
 
 ## Toplevel rules
 

+ 17 - 0
fuzz/Cargo.toml

@@ -14,6 +14,7 @@ cargo-fuzz = true
 
 [dependencies]
 base64 = "0.23"
+jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto"] }
 libfuzzer-sys = "0.4"
 serde_json = "1"
 time = "0.3"
@@ -35,3 +36,19 @@ path = "fuzz_targets/dcr_json.rs"
 [[bin]]
 name = "jar_claims"
 path = "fuzz_targets/jar_claims.rs"
+
+[[bin]]
+name = "dpop_proof"
+path = "fuzz_targets/dpop_proof.rs"
+
+[[bin]]
+name = "jar_verify"
+path = "fuzz_targets/jar_verify.rs"
+
+[[bin]]
+name = "redirect_uri"
+path = "fuzz_targets/redirect_uri.rs"
+
+[[bin]]
+name = "scope_parse"
+path = "fuzz_targets/scope_parse.rs"

+ 40 - 0
fuzz/fuzz_targets/dpop_proof.rs

@@ -0,0 +1,40 @@
+//! Fuzz the DPoP proof's embedded-JWK gate (`jose::jwk`).
+//!
+//! The proof JWK is self-asserted hostile input: [`decoding_key`] must refuse
+//! private/symmetric material, enforce the `alg`-to-key-type correspondence,
+//! and floor RSA moduli, while [`thumbprint`] canonicalizes (RFC 7638) only
+//! the public members. Both must never panic on any input; a JWK accepted by
+//! [`decoding_key`] always carries the members [`thumbprint`] needs.
+//! Signature verification and the `jti` replay claim need keys and Redis, so
+//! they stay under unit/proptest cover.
+
+#![no_main]
+
+use d_oauth::jose::jwk::{decoding_key, thumbprint};
+use jsonwebtoken::Algorithm;
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+  let Some((&selector, rest)) = data.split_first() else {
+    return;
+  };
+  let alg = match selector % 3 {
+    0 => Algorithm::ES256,
+    1 => Algorithm::EdDSA,
+    _ => Algorithm::RS256,
+  };
+  let Ok(text) = core::str::from_utf8(rest) else {
+    return;
+  };
+  let Ok(jwk) = serde_json::from_str::<serde_json::Value>(text) else {
+    return;
+  };
+  let key = decoding_key(alg, &jwk);
+  let print = thumbprint(&jwk);
+  // An accepted verification key always thumbprints: every path that builds
+  // a `DecodingKey` already required the exact public members the canonical
+  // form consumes.
+  if key.is_ok() {
+    debug_assert!(print.is_ok());
+  }
+});

+ 5 - 3
fuzz/fuzz_targets/jar_claims.rs

@@ -37,9 +37,10 @@ fn envelope_invariants(compact: &str) {
     return;
   };
   // Header and payload are JSON objects; a request object names its
-  // algorithm and carries claims. Presence (not validity) is the envelope
-  // property the fuzzer pins.
-  let _ = header.is_object() && payload.is_object();
+  // algorithm and carries claims. Parsing robustness (never panicking on
+  // hostile input) is the property under test; validity is decided by the
+  // verifier, not the envelope shape, so nothing is asserted here.
+  let _ = (header, payload);
 }
 
 fn client_with_method(method: &str) -> Client {
@@ -60,6 +61,7 @@ fn client_with_method(method: &str) -> Client {
     request_uris: None,
     contacts: None,
     software_id: None,
+    dpop_bound_access_tokens: false,
     active: true,
     created_at: time::OffsetDateTime::UNIX_EPOCH,
     updated_at: time::OffsetDateTime::UNIX_EPOCH,

+ 41 - 0
fuzz/fuzz_targets/jar_verify.rs

@@ -0,0 +1,41 @@
+//! Fuzz JOSE assertion verification (`private_key_jwt`, RFC 7523).
+//!
+//! The assertion and the client JWKS document are both hostile input:
+//! [`validate_assertion`] parses the compact-JWT envelope, enforces the
+//! asymmetric `alg` allowlist, refuses `jku`/`x5u` fetch directives, pins a
+//! single `kid` candidate, and checks `iss`/`sub`/`aud`/`exp`/`jti` — all
+//! without I/O (the JWKS document is passed in, so no `jwks_uri` fetch and
+//! no replay store). [`unverified_iss`] reads only the envelope's `iss` for
+//! client lookup. Neither must ever panic; the `jti` replay claim itself
+//! needs Redis and stays under unit cover.
+
+#![no_main]
+
+use d_oauth::protocol::client_auth::assertion::{unverified_iss, validate_assertion};
+use libfuzzer_sys::fuzz_target;
+
+/// Pinned clock for the assertion window: a fixed instant, never the wall
+/// clock, so the harness is deterministic and hermetic.
+fn now() -> time::OffsetDateTime {
+  match time::OffsetDateTime::from_unix_timestamp(1_700_000_000) {
+    Ok(instant) => instant,
+    Err(_) => time::OffsetDateTime::UNIX_EPOCH,
+  }
+}
+
+fuzz_target!(|data: &[u8]| {
+  let Ok(text) = core::str::from_utf8(data) else {
+    return;
+  };
+  let mut lines = text.split('\n');
+  let (Some(assertion), Some(jwks_text), Some(client_id), Some(token_endpoint)) =
+    (lines.next(), lines.next(), lines.next(), lines.next())
+  else {
+    return;
+  };
+  let Ok(jwks_doc) = serde_json::from_str::<serde_json::Value>(jwks_text) else {
+    return;
+  };
+  let _ = unverified_iss(assertion);
+  let _ = validate_assertion(assertion, &jwks_doc, client_id, token_endpoint, now());
+});

+ 36 - 0
fuzz/fuzz_targets/redirect_uri.rs

@@ -0,0 +1,36 @@
+//! Fuzz `redirect_uri` matching (`protocol::redirect_repo`, RFC 3986 §6.2.1
+//! plus the RFC 8252 §7.3 loopback exception).
+//!
+//! Both the requested URI (first input line) and the registered catalog set
+//! (following lines, capped) are hostile input: the matcher must never panic,
+//! never fall back to prefix/wildcard comparison, and only report `Exact`
+//! for a character-for-character member of the registered set.
+
+#![no_main]
+
+use d_oauth::protocol::redirect_repo::{RedirectDecision, match_redirect_uri};
+use libfuzzer_sys::fuzz_target;
+
+/// Maximum registered URIs taken from one input; matching is linear, so the
+/// cap keeps a single execution bounded.
+const MAX_REGISTERED: usize = 8;
+
+fuzz_target!(|data: &[u8]| {
+  let Ok(text) = core::str::from_utf8(data) else {
+    return;
+  };
+  let mut lines = text.split('\n');
+  let Some(requested) = lines.next() else {
+    return;
+  };
+  let registered: Vec<&str> = lines.take(MAX_REGISTERED).collect();
+  let decision = match_redirect_uri(&registered, requested);
+  // An empty catalog can never match.
+  if registered.is_empty() {
+    debug_assert_eq!(decision, RedirectDecision::Reject);
+  }
+  // `Exact` fires only for a character-for-character registered member.
+  if decision == RedirectDecision::Exact {
+    debug_assert!(registered.contains(&requested));
+  }
+});

+ 25 - 0
fuzz/fuzz_targets/scope_parse.rs

@@ -0,0 +1,25 @@
+//! Fuzz scope parsing (`protocol::scope::ScopeSet`, RFC 6749 §3.3).
+//!
+//! The raw `scope` value is hostile input: tokens split on single `0x20`
+//! spaces with no empty tokens, length- and count-capped. Parsing must never
+//! panic; an accepted set re-renders to the canonical wire form
+//! (single-space-joined, sorted, deduped), which parses back to the same set.
+
+#![no_main]
+
+use d_oauth::protocol::scope::ScopeSet;
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+  let Ok(raw) = core::str::from_utf8(data) else {
+    return;
+  };
+  let Ok(parsed) = ScopeSet::parse(raw) else {
+    return;
+  };
+  let rendered = parsed.render();
+  let Ok(reparsed) = ScopeSet::parse(&rendered) else {
+    return;
+  };
+  debug_assert_eq!(parsed, reparsed);
+});