diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..8392d159f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake \ No newline at end of file diff --git a/.github/workflows/audit.yaml b/.github/workflows/audit.yaml new file mode 100644 index 000000000..67deb626f --- /dev/null +++ b/.github/workflows/audit.yaml @@ -0,0 +1,39 @@ +name: Supply-chain audit + +# RustSec CVE scanning and the network-dependent cargo-deny advisories check for +# the ./rust workspace. These need to fetch the RustSec advisory database, so +# (unlike the offline cargo-deny licenses/bans/sources flake check in nix.yaml) +# they live in their own workflow and also run on a schedule to catch newly +# disclosed advisories against an unchanged dependency tree. +on: + push: + paths: + - "rust/**" + - "nix/audit.nix" + - ".github/workflows/audit.yaml" + pull_request: + paths: + - "rust/**" + - "nix/audit.nix" + - ".github/workflows/audit.yaml" + schedule: + # Weekly, Mondays 07:00 UTC. + - cron: "0 7 * * 1" + +permissions: + contents: read + +jobs: + audit: + name: cargo-audit + cargo-deny advisories + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@v22 + - uses: DeterminateSystems/magic-nix-cache-action@v13 + # RustSec advisory scan of the workspace Cargo.lock. + - name: cargo audit + run: nix run -L .#audit + # Full cargo-deny run (licenses + bans + sources + advisories). + - name: cargo deny check + run: nix run -L .#deny diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a8ab70630..8ff75eb9b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ jobs: valgrind fi - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup run: | autoreconf -fiv @@ -49,7 +49,7 @@ jobs: env: CC: ${{ matrix.compiler }} run: make -s distcheck DISTCHECK_CONFIGURE_FLAGS="CC=\"$CC\"" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: failure() with: name: logs ${{ matrix.name }}, ${{ matrix.compiler }} diff --git a/.github/workflows/coverity.yml b/.github/workflows/coverity.yml index 3832e0fec..e4a7759be 100644 --- a/.github/workflows/coverity.yml +++ b/.github/workflows/coverity.yml @@ -10,9 +10,11 @@ jobs: coverity: name: Coverity Scan runs-on: ubuntu-22.04 + env: + RUN_COV: '0' steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Install dependencies run: | sudo apt-get update @@ -24,15 +26,23 @@ jobs: autoreconf -fiv ./configure - name: Check for changes + env: + COVERITY_SCAN_TOKEN: ${{ secrets.COVERITY_SCAN_TOKEN }} run: | echo "RUN_COV=0" >> $GITHUB_ENV; + # Without the Coverity token (e.g. on forks) the scan action would + # download an HTML error page instead of the build tool and fail; skip. + if [ -z "${COVERITY_SCAN_TOKEN}" ]; then + echo "COVERITY_SCAN_TOKEN not configured; skipping Coverity scan." + exit 0 + fi DIFF=`git log --since=1week | wc -l` if [ x${DIFF} != "x0" ]; then echo "RUN_COV=1" >> $GITHUB_ENV; fi - name: Coverity Scan - if: env.RUN_COV == 1 - uses: vapier/coverity-scan-action@v1 + if: env.RUN_COV == '1' + uses: vapier/coverity-scan-action@v1.8.0 with: project: "gssproxy" email: ${{ secrets.COVERITY_SCAN_EMAIL }} diff --git a/.github/workflows/kani.yaml b/.github/workflows/kani.yaml new file mode 100644 index 000000000..ba71b2b0d --- /dev/null +++ b/.github/workflows/kani.yaml @@ -0,0 +1,58 @@ +name: Kani + +# Bounded model checking of the FFI-free, security-critical Rust logic: the +# gssx wire codec (gssproxy-proto) and the pure OID helpers in the interposer. +# These proofs upgrade the proptest fuzzers to exhaustive guarantees over +# bounded inputs. See rust/docs/formal-verification.md. +# +# This is intentionally separate from the Nix workflow and from `nix flake +# check`: Kani is not packaged in nixpkgs (nightly toolchain + downloads CBMC), +# so CI uses the official Kani GitHub Action and developers use `nix run .#kani`. +# +# The job's pass/fail status is the gate: `cargo kani` exits non-zero if any +# harness fails to verify. (SARIF/Code-Scanning upload is intentionally not used +# yet: `--sarif` is not available in released Kani 0.67.0, only on unreleased +# main. Re-add `--sarif` + github/codeql-action/upload-sarif once it ships.) +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + proto: + name: Kani proofs (gssproxy-proto codec) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Run Kani on gssproxy-proto + uses: model-checking/kani-github-action@v1.1 + with: + kani-version: "0.67.0" + working-directory: rust + args: >- + -p gssproxy-proto + --output-format=terse + + interposer: + name: Kani proofs (interposer OID helpers) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + # gssproxy-interposer depends on gssapi-sys, whose build.rs runs bindgen + # against the krb5/gssapi headers (the OID harnesses don't call any FFI, + # but the crate must still compile). + - name: Install krb5/clang build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libkrb5-dev libclang-dev clang pkg-config + - name: Run Kani on gssproxy-interposer OID helpers + uses: model-checking/kani-github-action@v1.1 + with: + kani-version: "0.67.0" + working-directory: rust + args: >- + -p gssproxy-interposer + --output-format=terse diff --git a/.github/workflows/nix.yaml b/.github/workflows/nix.yaml new file mode 100644 index 000000000..dd0f5c36e --- /dev/null +++ b/.github/workflows/nix.yaml @@ -0,0 +1,91 @@ +name: Nix + +# Validates the Nix flake and the ./rust port: formatting, linting, the C<->Rust +# interposer ABI parity gate, CLI parity, the upstream integration suite across +# the C/Rust daemon and interposer combinations, and the dev container image. +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + lint: + name: Format & lint (nixpkgs-fmt, rustfmt, clippy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@v22 + - uses: DeterminateSystems/magic-nix-cache-action@v13 + - name: nixpkgs-fmt + run: nix build -L .#checks.x86_64-linux.nix-fmt + - name: cargo fmt --check + run: nix build -L .#checks.x86_64-linux.rust-fmt + - name: cargo clippy -D warnings + run: nix build -L .#checks.x86_64-linux.clippy + - name: cargo-deny (licenses, bans, sources) + run: nix build -L .#checks.x86_64-linux.cargo-deny + + rust-tests: + name: Rust tests (property-based + chaos/fuzz) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@v22 + - uses: DeterminateSystems/magic-nix-cache-action@v13 + # Runs the whole `cargo test` workspace: proptest round-trip fidelity, + # the config/CLI property tests, and the chaos-monkey robustness fuzzers + # that hammer the gssx decoders with arbitrary/truncated/biased input. + - name: cargo test (property + chaos/fuzz) + run: nix build -L .#checks.x86_64-linux.rust-tests + + abi-parity: + name: Interposer ABI parity (C vs Rust) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@v22 + - uses: DeterminateSystems/magic-nix-cache-action@v13 + # The headline gate: the Rust proxymech.so's exported gss_mech_interposer + # /gssi_* surface must stay a subset of (and match) the C plugin's ABI. + - name: proxymech.so symbol parity + run: nix build -L .#checks.x86_64-linux.interposer-symbol-parity + - name: CLI parity (C vs Rust daemon) + run: nix build -L .#checks.x86_64-linux.cli-parity + + build: + name: Build packages (C, Rust, devcontainer) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@v22 + - uses: DeterminateSystems/magic-nix-cache-action@v13 + - name: gssproxy (C) + run: nix build -L .#gssproxy + - name: gssproxy-rs (Rust daemon + interposer) + run: nix build -L .#gssproxy-rs + - name: devcontainer image + run: nix build -L .#devcontainer + + integration: + name: Upstream integration suite (${{ matrix.check }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + check: + # C daemon + C interposer (baseline). + - integration-tests + # Rust daemon, C interposer/tests (wire/ABI compat, gate #1). + - integration-tests-rust + # C daemon, Rust interposer (interposer data-path compat, gate #2). + - integration-tests-rust-proxymech + # Rust daemon + Rust interposer end-to-end. + - integration-tests-all-rust + steps: + - uses: actions/checkout@v6 + - uses: DeterminateSystems/nix-installer-action@v22 + - uses: DeterminateSystems/magic-nix-cache-action@v13 + - name: ${{ matrix.check }} + run: nix build -L .#checks.x86_64-linux.${{ matrix.check }} diff --git a/.gitignore b/.gitignore index cc340c173..2bcf7f423 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,45 @@ tests/t_impersonate tests/t_init tests/t_names tests/t_setcredopt +direnv/ +result +result-man +result-c-man/**/* +rust/target/ +rust/gssproxy-bench/target/ + +# Generated by Cargo +# will have compiled files and executables +rust/debug +rust/target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# Project-local Kani toolchain/cargo/CBMC state created by `nix run .#kani` +.kani/ + +# cargo-flamegraph output (written to the cwd by `nix run .#flamegraph`). +# pprof per-bench SVGs live under rust/target/criterion and are covered above. +flamegraph.svg +perf.data +perf.data.old + +# rustc will dump stack traces when hitting an internal compiler error to PWD +rustc-ice-*.txt + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ +result-c-man +result-* diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..8569b1ed9 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,11 @@ +{ + "recommendations": [ + "waderyan.gitblame", + "rust-lang.rust-analyzer", + "tombi-toml.tombi", + "anysphere.remote-containers", + "ms-azuretools.vscode-containers", + "jnoortheen.nix-ide", + "davidanson.vscode-markdownlint" + ] +} \ No newline at end of file diff --git a/.vscode/rust-analyzer-nix.sh b/.vscode/rust-analyzer-nix.sh new file mode 100755 index 000000000..e7013e5bb --- /dev/null +++ b/.vscode/rust-analyzer-nix.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Launch rust-analyzer inside the Nix flake dev shell. +# +# This keeps a single toolchain in play: the server's proc-macro expander, +# the sysroot, and every cargo/rustc invocation rust-analyzer spawns all come +# from the flake's pinned rustc (matching the artifacts built into rust/target). +# It also inherits LIBCLANG_PATH (via rustPlatform.bindgenHook) so the bindgen +# build scripts (libgssapi-sys, gssapi-sys) work. +# +# Without this, the IDE's bundled rust-analyzer uses the host rustup toolchain, +# producing "Unable to find libclang" and proc-macro ABI mismatch errors. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +exec nix develop "$repo_root" --command rust-analyzer "$@" diff --git a/.vscode/rust-analyzer-proc-macro-srv.sh b/.vscode/rust-analyzer-proc-macro-srv.sh new file mode 100755 index 000000000..230fb0235 --- /dev/null +++ b/.vscode/rust-analyzer-proc-macro-srv.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Proc-macro server for rust-analyzer, sourced from the flake dev shell's rustc. +# +# rust-analyzer's built-in expander matches whatever rustc the rust-analyzer +# package was built with, which is not necessarily the dev shell's rustc that +# compiles rust/target. Point rust-analyzer.procMacro.server at this script so +# the expander always comes from the *same* rustc sysroot that builds the +# crates, avoiding proc-macro ABI mismatches. The sysroot is resolved at runtime +# (no hardcoded /nix/store path, so it survives toolchain updates). +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +exec nix develop "$repo_root" --command \ + sh -c 'exec "$(rustc --print sysroot)/libexec/rust-analyzer-proc-macro-srv" "$@"' sh "$@" diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..2f6b9266e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,55 @@ +{ + // The Rust workspace under ./rust needs the Nix flake dev shell to build: + // - bindgen build scripts (libgssapi-sys, gssapi-sys) need libclang, + // which is only provided in the dev shell via rustPlatform.bindgenHook + // (it exports LIBCLANG_PATH). + // - artifacts in rust/target are built with the flake's pinned rustc, so + // rust-analyzer must use that same toolchain or proc-macro .so files + // fail to load with an ABI mismatch. + // + // The IDE's bundled rust-analyzer runs with the host environment (rustup + // toolchain, no LIBCLANG_PATH), which triggers both failures. Running the + // whole server inside `nix develop` (see rust-analyzer-nix.sh) puts the + // server, its proc-macro expander, the sysroot, and every cargo invocation + // it spawns on one toolchain, without hardcoding store paths that change on + // toolchain updates. + "rust-analyzer.server.path": "${workspaceFolder}/.vscode/rust-analyzer-nix.sh", + "rust-analyzer.cachePriming.numThreads": "physical", + // Use the proc-macro expander from the dev shell's rustc sysroot (the same + // rustc that builds rust/target), so proc-macro .so files load without an ABI + // mismatch. Don't leave this null: that would fall back to rust-analyzer's + // built-in expander, which tracks whatever rustc the rust-analyzer package was + // built with and may differ from the dev shell's rustc. + "rust-analyzer.procMacro.server": "${workspaceFolder}/.vscode/rust-analyzer-proc-macro-srv.sh", + "rust-analyzer.restartServerOnConfigChange": true, + // Pin rust-analyzer to the real workspace. Without this, rust-analyzer + // auto-discovers every Cargo.toml under the repo and loads the vendored crates + // in .kani/cargo/registry (bootstrapped by `nix run .#kani`) as spurious + // projects. files.exclude alone does not reliably stop that discovery. + "rust-analyzer.linkedProjects": [ + "rust/Cargo.toml" + ], + // Don't let rust-analyzer scan the project-local Kani toolchain/registry. + // The bench crate's target dir is likewise not a source tree. + "rust-analyzer.files.exclude": [ + ".kani", + "rust/gssproxy-bench/target" + ], + // rust-analyzer.files.exclude stops indexing, but Code still *watches* these + // dirs. Exclude them from the file watcher too so the large vendored Kani + // registry and build output don't churn CPU/inotify. + "files.watcherExclude": { + "**/.kani/**": true, + "**/rust/gssproxy-bench/target/**": true + }, + // Hide the Kani toolchain/registry and bench output from the file explorer + // and from search so they don't clutter navigation or grep results. + "files.exclude": { + "**/.kani": true, + "**/rust/gssproxy-bench/target": true + }, + "search.exclude": { + "**/.kani": true, + "**/rust/gssproxy-bench/target": true + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..6769406b0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1781074563, + "narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..232d4f9b4 --- /dev/null +++ b/flake.nix @@ -0,0 +1,197 @@ +{ + description = "GSS Proxy: a GSSAPI proxy daemon and a NixOS module that uses it as a drop-in replacement for rpc.svcgssd"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f { + inherit system; + pkgs = import nixpkgs { + inherit system; + overlays = [ self.overlays.default ]; + }; + }); + in + { + overlays.default = final: prev: { + gssproxy = final.callPackage ./nix/package.nix { }; + # Rust reimplementation (daemon + proxymech.so interposer). + gssproxy-rs = final.callPackage ./nix/rust.nix { }; + }; + + packages = forAllSystems ({ pkgs, ... }: { + inherit (pkgs) gssproxy gssproxy-rs; + default = pkgs.gssproxy; + + # Reproducible developer container image (built by Nix, no Dockerfile): + # nix build .#devcontainer && docker load < result + devcontainer = import ./nix/devcontainer.nix { inherit pkgs; }; + }); + + nixosModules.gssproxy = { + imports = [ ./nix/module.nix ]; + nixpkgs.overlays = [ self.overlays.default ]; + }; + nixosModules.default = self.nixosModules.gssproxy; + + checks = forAllSystems ({ pkgs, ... }: + let + # Rust formatting (`cargo fmt --check`) and lint (`cargo clippy + # -D warnings`) gates for the ./rust workspace. + rustChecks = import ./nix/rust-checks.nix { inherit pkgs; }; + + # Just the Nix sources, so the formatting gate doesn't depend on the + # whole tree. + nixSrc = pkgs.lib.fileset.toSource { + root = ./.; + fileset = pkgs.lib.fileset.unions [ ./flake.nix ./nix ]; + }; + in + { + vm-test = import ./nix/test.nix { + inherit pkgs; + module = self.nixosModules.gssproxy; + }; + + # Full upstream in-repo test suite (tests/runtests.py via `make check`). + integration-tests = import ./nix/integration-tests.nix { + inherit pkgs; + inherit (pkgs) gssproxy; + }; + + # Same upstream suite, but driven against the Rust daemon (oracle gate + # #1): the C-built proxymech.so and test programs talk to the Rust + # gssproxy over the wire, proving protocol/ABI compatibility. + integration-tests-rust = import ./nix/integration-tests.nix { + inherit pkgs; + inherit (pkgs) gssproxy; + daemon = pkgs.gssproxy-rs; + }; + + # CLI behaviour parity between the C and Rust gssproxy binaries + # (--version/--help/unknown-option/missing-config). + cli-parity = import ./nix/cli-tests.nix { + inherit pkgs; + inherit (pkgs) gssproxy gssproxy-rs; + }; + + # Interposer ABI parity: the Rust proxymech.so's exported + # gss_mech_interposer/gssi_* surface must be a subset of the C plugin's. + interposer-symbol-parity = import ./nix/interposer-symbols.nix { + inherit pkgs; + inherit (pkgs) gssproxy gssproxy-rs; + }; + + # Upstream suite with the Rust libproxymech.so loaded in place of the + # C interposer (oracle gate #2), talking to the C daemon. This proves + # the Rust interposer data path is wire/ABI compatible. + integration-tests-rust-proxymech = import ./nix/integration-tests.nix { + inherit pkgs; + inherit (pkgs) gssproxy; + proxymech = pkgs.gssproxy-rs; + }; + + # Upstream suite end-to-end on the Rust port: Rust daemon AND Rust + # interposer, with only the C test programs unchanged. + integration-tests-all-rust = import ./nix/integration-tests.nix { + inherit pkgs; + inherit (pkgs) gssproxy; + daemon = pkgs.gssproxy-rs; + proxymech = pkgs.gssproxy-rs; + }; + + # Nix sources must be nixpkgs-fmt clean. + nix-fmt = import ./nix/nix-fmt.nix { inherit pkgs; src = nixSrc; }; + + # Rust workspace must be rustfmt-clean and clippy-clean, and the full + # test suite (property-based + chaos/fuzz robustness tests) must pass. + # cargo-deny gates dependency licenses/sources/bans (offline subset). + inherit (rustChecks) rust-fmt clippy rust-tests cargo-deny; + }); + + apps = forAllSystems ({ pkgs, ... }: + let + kani = import ./nix/kani.nix { inherit pkgs; }; + bench = import ./nix/bench.nix { inherit pkgs; }; + audit = import ./nix/audit.nix { inherit pkgs; }; + in + { + # Bounded Kani proofs for the FFI-free codec/helpers, run in an FHS + # sandbox (Kani isn't packaged in nixpkgs). Not a flake check. + # nix run .#kani -- -p gssproxy-proto + kani = { + type = "app"; + program = "${kani.app}/bin/gssproxy-kani"; + }; + + # Whole-binary flamegraph of the codec benchmarks via cargo-flamegraph + # + perf. Needs perf access (see rust/docs/benchmarking.md). + # nix run .#flamegraph + flamegraph = { + type = "app"; + program = "${bench.flamegraph}/bin/gssproxy-flamegraph"; + }; + + # RustSec CVE scan of the workspace Cargo.lock. nix run .#audit + audit = { + type = "app"; + program = "${audit.audit}/bin/gssproxy-audit"; + }; + + # Full cargo-deny (licenses + bans + sources + advisories). + # nix run .#deny + deny = { + type = "app"; + program = "${audit.deny}/bin/gssproxy-deny"; + }; + }); + + devShells = forAllSystems ({ pkgs, ... }: { + # Interactive FHS shell for running Kani locally: `nix develop .#kani`, + # then `cargo kani -p gssproxy-proto`. + kani = (import ./nix/kani.nix { inherit pkgs; }).shell.env; + + # Benchmark shell (criterion + cargo-flamegraph + perf): + # nix develop .#bench, then cargo bench --bench codec + bench = (import ./nix/bench.nix { inherit pkgs; }).shell; + + # Supply-chain audit shell (cargo-audit + cargo-deny): + # nix develop .#audit, then cargo audit / cargo deny check + audit = (import ./nix/audit.nix { inherit pkgs; }).shell; + + default = pkgs.mkShell { + inputsFrom = [ pkgs.gssproxy ]; + packages = with pkgs; [ + autoconf + automake + libtool + gettext + python3 + # Rust toolchain for the port under ./rust. + cargo + rustc + clippy + rustfmt + rust-analyzer + pkg-config + krb5 + # Nix formatter, so `nixpkgs-fmt` (matching the nix-fmt check) and + # `nix fmt` are available locally. + nixpkgs-fmt + # Supplies libclang/LIBCLANG_PATH so libgssapi-sys's bindgen build + # script works in the dev shell, matching nix/rust.nix. + rustPlatform.bindgenHook + ]; + # The committed .cargo/config.toml disables rustup's self-contained + # lld for the non-Nix host, but Nixpkgs' rustc rejects that flag, so + # neutralise the override inside the dev shell. (cargo uses RUSTFLAGS + # in preference to target.*.rustflags from the config file.) + RUSTFLAGS = " "; + }; + }); + + formatter = forAllSystems ({ pkgs, ... }: pkgs.nixpkgs-fmt); + }; +} diff --git a/nix/README.md b/nix/README.md new file mode 100644 index 000000000..90949fab7 --- /dev/null +++ b/nix/README.md @@ -0,0 +1,134 @@ +# gssproxy on NixOS + +This directory contains a Nix flake that builds `gssproxy` from this repository +and exports a NixOS module (`services.gssproxy`). The primary use case is to run +gssproxy as the in-kernel NFS server GSS helper, a drop-in replacement for +`rpc.svcgssd`, so the kernel NFS server can handle large RPCSEC/GSS credentials +(for example Kerberos tickets carrying a Microsoft PAC payload from Active +Directory or FreeIPA). This addresses the `RPCSEC/GSS credential too large - +please use gssproxy` error tracked in +[nixpkgs#528636](https://github.com/nixos/nixpkgs/issues/528636). + +## Usage + +Add this flake as an input and import the module: + +```nix +{ + inputs.gssproxy.url = "github:gssapi/gssproxy"; + + outputs = { nixpkgs, gssproxy, ... }: { + nixosConfigurations.my-nfs-server = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + gssproxy.nixosModules.gssproxy + ({ ... }: { + services.gssproxy.enable = true; + services.gssproxy.nfs.server.enable = true; + + # The existing nixpkgs NFS server module: + services.nfs.server = { + enable = true; + exports = '' + /srv/nfs client.example.com(rw,sec=krb5) + ''; + }; + }) + ]; + }; + }; +} +``` + +You also need a working Kerberos setup: a `/etc/krb5.conf` and a keytab at +`/etc/krb5.keytab` containing the `nfs/` and `host/` service principals for the +server. + +## Important: reboot requirement + +The kernel decides **once**, on the first GSS authentication request, whether to +use the classic `rpc.svcgssd` protocol or the gssproxy protocol, and the choice +cannot be changed afterwards. If kerberized NFS has already been used on the +machine, you must **reboot** the server after enabling this module for gssproxy +to take over. Enabling `services.gssproxy.nfs.server.enable` masks the +`rpc-svcgssd` service so it cannot race gssproxy for the kernel upcall. + +## Options + +- `services.gssproxy.enable` - run the gssproxy daemon. +- `services.gssproxy.nfs.server.enable` - add the NFS-server drop-in + (`service/nfs-server` with `kernel_nfsd = yes`). +- `services.gssproxy.nfs.server.keytab` - keytab path (default `/etc/krb5.keytab`). +- `services.gssproxy.nfs.server.socket` - kernel socket path + (default `/run/gssproxy.sock`; hardcoded in the kernel, change only if you + know what you are doing). +- `services.gssproxy.debugLevel` - global `debug_level`. +- `services.gssproxy.settings` - free-form configuration. Section name maps to an + attribute set of `key = value` pairs; list values produce repeated lines + (needed for `cred_store`). These deep-merge over the NFS-server defaults, so + you can extend or override any generated entry. + +Example adding an NFS client service definition alongside the server drop-in: + +```nix +services.gssproxy.settings."service/nfs-client" = { + mechs = "krb5"; + cred_store = [ + "keytab:/etc/krb5.keytab" + "ccache:FILE:/var/lib/gssproxy/clients/krb5cc_%U" + ]; + cred_usage = "initiate"; + allow_any_uid = true; + euid = 0; +}; +``` + +## Building and testing + +- `nix build .#gssproxy` - build the daemon and the `proxymech.so` interposer. +- `nix flake check` - run all checks: + - `integration-tests` ([nix/integration-tests.nix](integration-tests.nix)) - + the full upstream in-repo test suite (`tests/runtests.py` via `make check`). + It stands up a real MIT KDC with an OpenLDAP backend and exercises the + acquire/accept/impersonation/constrained-delegation/interposer/reload flows + against a live gssproxy, then runs `userproxytest`. `socket_wrapper`/ + `nss_wrapper` fake the network, so it needs neither real networking nor KVM + and runs on Hydra. + - `vm-test` ([nix/test.nix](test.nix)) - a NixOS VM smoke test that verifies + gssproxy starts, registers via `/proc/net/rpc/use-gss-proxy`, and that + `rpc-svcgssd` is masked. (Requires KVM.) +- `nix build .#checks..integration-tests` - run just the upstream suite. +- `nix develop` - drop into a shell with the autotools build dependencies + (`autoreconf -fi && ./configure && make`). + +### Running the upstream suite outside Nix + +The suite (`tests/runtests.py`) historically assumes an FHS layout (tools under +`/bin`, `/usr/lib/mit/sbin`, …), a `/bin/bash`, and that it may binary-patch +`libgssapi` to load the interposer. To run it in non-FHS environments such as a +Nix build, it honors these optional overrides (defaults preserve the previous +behavior): + +- `GSSPROXY_TEST_BASH` - shell to run test commands with (default `/bin/bash`). +- `GSSPROXY_TEST_OPENLDAP_SCHEMA_DIR` - directory containing the OpenLDAP + `*.schema` files. +- `GSSPROXY_TEST_KRB5_LDAP_SCHEMA` - path to krb5's `kerberos.schema`. +- `GSSPROXY_TEST_SOCKET_WRAPPER_LIB` / `GSSPROXY_TEST_NSS_WRAPPER_LIB` - + absolute paths to the wrapper libraries for `LD_PRELOAD`. +- `GSSPROXY_TEST_USE_GSS_MECH_CONFIG` - load the interposer via the + `GSS_MECH_CONFIG` environment variable instead of binary-patching `libgssapi` + (needed when the embedded mech.d path is not `/etc/gss/mech.d`). + +## Manual end-to-end verification + +The smoke test does not exercise real Kerberos authentication. To verify a full +setup, join the server and a client to a KDC (for example FreeIPA), provision +keytabs, mount `server:/srv/nfs` with `sec=krb5` from the client, and confirm a +user can access files without the `credential too large` error appearing in the +server's `dmesg`. + +## Upstreaming to nixpkgs + +The package derivation in [nix/package.nix](package.nix) and the module in +[nix/module.nix](module.nix) are structured to ease an eventual contribution to +nixpkgs (`Resolves #528636`). diff --git a/nix/audit.nix b/nix/audit.nix new file mode 100644 index 000000000..5a161bb04 --- /dev/null +++ b/nix/audit.nix @@ -0,0 +1,48 @@ +# Supply-chain auditing tools for the ./rust workspace. +# +# cargo-audit (RustSec CVE scan) and the network-dependent parts of cargo-deny +# need to fetch the RustSec advisory database, so unlike the offline +# `cargo-deny` flake check they are exposed as a dev shell + apps rather than as +# sealed Nix checks: +# +# nix develop .#audit # cargo audit / cargo deny available +# nix run .#audit # cargo audit (CVEs + yanked crates) +# nix run .#deny # cargo deny check (all, incl. advisories) +{ pkgs }: +let + auditInputs = with pkgs; [ + cargo + rustc + cargo-audit + cargo-deny + # cargo-audit / cargo-deny fetch the advisory DB over git/https. + git + cacert + ]; +in +{ + shell = pkgs.mkShell { + packages = auditInputs; + RUSTFLAGS = " "; + }; + + audit = pkgs.writeShellApplication { + name = "gssproxy-audit"; + runtimeInputs = auditInputs; + text = '' + cd "''${RUST_DIR:-rust}" + # Scan Cargo.lock for crates with RustSec advisories or that are yanked. + exec cargo audit "$@" + ''; + }; + + deny = pkgs.writeShellApplication { + name = "gssproxy-deny"; + runtimeInputs = auditInputs; + text = '' + cd "''${RUST_DIR:-rust}" + # Full cargo-deny run including the network-fetched advisories check. + exec cargo deny check "$@" + ''; + }; +} diff --git a/nix/bench.nix b/nix/bench.nix new file mode 100644 index 000000000..8e8c556cc --- /dev/null +++ b/nix/bench.nix @@ -0,0 +1,61 @@ +# Benchmark tooling for the ./rust port. +# +# Provides a dev shell and a `cargo flamegraph` runner for the `gssproxy-bench` +# crate, which compares the Rust gssx codec against the C rpcgen XDR. The crate +# links gssrpc + krb5-gssapi (via its build.rs) and reads ../../rpcgen, so it is +# excluded from the workspace and only built here. +# +# Wired into the flake as: +# nix develop .#bench # then: cargo bench --bench codec +# nix run .#flamegraph -- +# +# Deliberately NOT a flake check: benchmarks are non-deterministic and the crate +# reaches outside the sealed rust/ source tree. +{ pkgs }: +let + benchInputs = with pkgs; [ + cargo + rustc + stdenv.cc # `cc` for the linker and the cc-rs build script + pkg-config + krb5 + krb5.dev + cargo-flamegraph + linuxPackages.perf + ]; + + # krb5-gssapi.pc / krb5.pc live in the dev output. + pkgConfigPath = "${pkgs.krb5.dev}/lib/pkgconfig"; +in +{ + shell = pkgs.mkShell { + packages = benchInputs; + # Match the default shell: neutralise the .cargo/config.toml lld override + # that Nixpkgs' rustc rejects. + RUSTFLAGS = " "; + shellHook = '' + export PKG_CONFIG_PATH="${pkgConfigPath}''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" + echo "gssproxy bench shell: cd rust/gssproxy-bench && cargo bench --bench codec" >&2 + ''; + }; + + flamegraph = pkgs.writeShellApplication { + name = "gssproxy-flamegraph"; + runtimeInputs = benchInputs; + text = '' + export PKG_CONFIG_PATH="${pkgConfigPath}''${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" + export RUSTFLAGS="''${RUSTFLAGS:- }" + cd "''${BENCH_DIR:-rust/gssproxy-bench}" + # Run from inside target/ so BOTH the flamegraph.svg (-o) and perf.data + # (cargo-flamegraph always writes it to the cwd) land under target/ and are + # therefore removed by `cargo clean`. + out="target/flamegraph" + mkdir -p "$out" + cd "$out" + # cargo-flamegraph runs the criterion bench binary under perf; the trailing + # `--bench` puts criterion in bench mode. Extra args are forwarded. + exec cargo flamegraph --manifest-path ../../Cargo.toml \ + --output flamegraph.svg --bench codec -- --bench "$@" + ''; + }; +} diff --git a/nix/cli-tests.nix b/nix/cli-tests.nix new file mode 100644 index 000000000..ab1ea8a01 --- /dev/null +++ b/nix/cli-tests.nix @@ -0,0 +1,75 @@ +{ pkgs, gssproxy, gssproxy-rs }: + +# CLI parity check between the C daemon (`src/gssproxy.c`, popt-based) and the +# Rust daemon (`rust/gssproxy-server/src/main.rs`). It exercises the flag-level +# behaviours that do not require a live KDC, asserting both binaries agree on +# inputs and outputs: +# * `--version` exits 0 with identical single-line output, +# * `--help` exits 0, +# * an unknown option exits non-zero, +# * a non-integer `--idle-timeout` exits non-zero, +# * combining `-D` and `-i` exits 0 (popt conflict message, success exit), +# * `--extract-ccache` on a missing ccache exits non-zero (no hang), +# * a missing config file exits non-zero (no daemon left running). +# +# The Rust-only readiness behaviour ("Initialization complete." + socket bind) +# is covered by the cargo integration test `gssproxy-server/tests/cli.rs`. +pkgs.runCommand "gssproxy-cli-parity" { } '' + set -u + + C=${gssproxy}/bin/gssproxy + R=${gssproxy-rs}/bin/gssproxy + + fail() { echo "CLI PARITY FAIL: $*"; exit 1; } + + # --version: exit 0, single non-empty line, identical output for both. + # (Note: avoid the variable name `out`, which is the derivation output path.) + cvers=$("$C" --version 2>/dev/null) || fail "C --version exited non-zero" + rvers=$("$R" --version 2>/dev/null) || fail "Rust --version exited non-zero" + [ -n "$cvers" ] || fail "C --version produced no output" + [ -n "$rvers" ] || fail "Rust --version produced no output" + echo "C --version -> $cvers ; Rust --version -> $rvers" + [ "$cvers" = "$rvers" ] || fail "version output differs: C='$cvers' Rust='$rvers'" + + # --help: exit 0 for both. + "$C" --help >/dev/null 2>&1 || fail "C --help exited non-zero" + "$R" --help >/dev/null 2>&1 || fail "Rust --help exited non-zero" + + # Unknown option: non-zero exit for both. + if "$C" --definitely-not-a-flag >/dev/null 2>&1; then + fail "C accepted an unknown option" + fi + if "$R" --definitely-not-a-flag >/dev/null 2>&1; then + fail "Rust accepted an unknown option" + fi + + # --idle-timeout requires an integer; a non-integer errors for both. + if "$C" --idle-timeout notanint >/dev/null 2>&1; then + fail "C accepted a non-integer --idle-timeout" + fi + if "$R" --idle-timeout notanint >/dev/null 2>&1; then + fail "Rust accepted a non-integer --idle-timeout" + fi + + # Combining -D and -i: both print a conflict message and exit 0. + "$C" -D -i >/dev/null 2>&1 || fail "C -D -i did not exit 0" + "$R" -D -i >/dev/null 2>&1 || fail "Rust -D -i did not exit 0" + + # --extract-ccache on a missing ccache: non-zero exit for both (no hang). + if "$C" --extract-ccache "FILE:$PWD/nope.ccache" >/dev/null 2>&1; then + fail "C extract-ccache succeeded on a missing ccache" + fi + if "$R" --extract-ccache "FILE:$PWD/nope.ccache" >/dev/null 2>&1; then + fail "Rust extract-ccache succeeded on a missing ccache" + fi + + # Missing config file: non-zero exit for both (and must not hang / daemonize). + if "$C" -i -s "$PWD/none.sock" -c "$PWD/does-not-exist.conf" >/dev/null 2>&1; then + fail "C started with a missing config file" + fi + if "$R" -i -s "$PWD/none.sock" -c "$PWD/does-not-exist.conf" >/dev/null 2>&1; then + fail "Rust started with a missing config file" + fi + + echo "CLI parity OK" > $out +'' diff --git a/nix/devcontainer.nix b/nix/devcontainer.nix new file mode 100644 index 000000000..7a7251797 --- /dev/null +++ b/nix/devcontainer.nix @@ -0,0 +1,87 @@ +{ pkgs }: + +# A reproducible developer container image, built entirely by Nix (no Dockerfile). +# +# Build and load it with: +# nix build .#devcontainer +# docker load < result # (or: podman load < result) +# docker run --rm -it -v "$PWD:/workspace" gssproxy-devcontainer:latest +# +# It bundles the full toolchain needed to hack on both the autotools C build and +# the ./rust port (daemon + proxymech.so), matching the flake's devShell. +let + inherit (pkgs) lib; + + # Everything a developer needs on PATH inside the container. + devTools = with pkgs; [ + # Shell / base userland so the image is usable interactively. + bashInteractive + coreutils + findutils + gnugrep + gnused + gawk + which + less + gnumake + git + # C build toolchain for the autotools project. + gcc + binutils + autoconf + automake + libtool + gettext + pkg-config + krb5 + # Rust toolchain for the ./rust workspace. + cargo + rustc + clippy + rustfmt + rust-analyzer + # Formatting / Nix tooling. + nixpkgs-fmt + # Test helpers used by the upstream suite. + python3 + ]; +in +pkgs.dockerTools.buildLayeredImage { + name = "gssproxy-devcontainer"; + tag = "latest"; + + contents = devTools ++ [ + pkgs.dockerTools.binSh + pkgs.dockerTools.usrBinEnv + pkgs.dockerTools.caCertificates + ]; + + # Create a writable /tmp and the default workspace mount point. + extraCommands = '' + mkdir -p tmp workspace + chmod 1777 tmp + ''; + + config = { + Cmd = [ "${pkgs.bashInteractive}/bin/bash" ]; + WorkingDir = "/workspace"; + Env = [ + "PKG_CONFIG_PATH=${pkgs.krb5.dev}/lib/pkgconfig" + # bindgen (libgssapi-sys build script) needs libclang at runtime. + "LIBCLANG_PATH=${pkgs.libclang.lib}/lib" + "SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt" + "RUSTFLAGS= " + "PAGER=less" + ]; + Labels = { + "org.opencontainers.image.title" = "gssproxy-devcontainer"; + "org.opencontainers.image.description" = + "Dev container for gssproxy (C autotools build + Rust port)"; + }; + }; + + meta = { + description = "Nix-built developer container image for gssproxy"; + platforms = lib.platforms.linux; + }; +} diff --git a/nix/integration-tests.nix b/nix/integration-tests.nix new file mode 100644 index 000000000..feb7455c8 --- /dev/null +++ b/nix/integration-tests.nix @@ -0,0 +1,100 @@ +{ pkgs, gssproxy, daemon ? null, proxymech ? null }: + +# Runs the upstream in-repo test suite (tests/runtests.py via `make check`) +# inside a pure Nix build. The suite stands up a real MIT KDC with an OpenLDAP +# backend, exercises acquire/accept/impersonation/constrained-delegation/ +# interposer/reload flows against a live gssproxy, and finally runs +# userproxytest. socket_wrapper/nss_wrapper fake the network so no real +# networking (or KVM) is required, which keeps this runnable on Hydra. +# +# The test scripts honor a handful of GSSPROXY_TEST_* overrides (added so the +# suite can run outside an FHS layout); everything Nix-specific is wired up +# through those here rather than by patching the suite at build time. +# +# `daemon` (optional): an alternative gssproxy package whose `bin/gssproxy` is +# launched instead of the freshly built C daemon (via GSSPROXY_TEST_DAEMON). +# This is how the Rust port is validated against the upstream suite while still +# using the C-built proxymech.so and test programs ("oracle gate #1"). + +let + inherit (pkgs) lib; + # The test KDC uses the LDAP kdb backend, so it needs krb5 built with LDAP + # support (kdb5_ldap_util + the kldap plugin). nixpkgs' krb5 does not install + # the LDAP schema that provisioning the directory requires, so ship it too. + krb5Ldap = (pkgs.krb5.override { withLdap = true; }).overrideAttrs (old: { + postInstall = (old.postInstall or "") + '' + install -Dm444 plugins/kdb/ldap/libkdb_ldap/kerberos.schema \ + "$out/share/gssproxy-tests/kerberos.schema" + ''; + }); + + # When validating an external daemon (the Rust port), build it against the + # same krb5 the suite provisions so the GSSAPI/krb5 runtime matches. + externalDaemon = + if daemon == null then null else daemon.override { krb5 = krb5Ldap; }; + + # When validating an external interposer (the Rust libproxymech.so), build it + # against the same krb5 so the loaded plugin matches the suite's GSSAPI/krb5. + externalProxymech = + if proxymech == null then null else proxymech.override { krb5 = krb5Ldap; }; +in +(gssproxy.override { krb5 = krb5Ldap; }).overrideAttrs (old: { + pname = if daemon == null then "gssproxy-tests" else "gssproxy-rust-tests"; + + doCheck = true; + + nativeCheckInputs = (old.nativeCheckInputs or [ ]) ++ (with pkgs; [ + openldap + socket_wrapper + nss_wrapper + valgrind + which + gzip + python3 + ]); + + preCheck = (old.preCheck or "") + '' + # runtests.py is executed via its shebang by `make check`. + patchShebangs tests/runtests.py + + # slapd ships in libexec, not on the default PATH. + export PATH="${pkgs.openldap}/libexec:$PATH" + + export GSSPROXY_TEST_BASH="$(command -v bash)" + export GSSPROXY_TEST_OPENLDAP_SCHEMA_DIR="${pkgs.openldap}/etc/schema" + export GSSPROXY_TEST_KRB5_LDAP_SCHEMA="${krb5Ldap}/share/gssproxy-tests/kerberos.schema" + export GSSPROXY_TEST_SOCKET_WRAPPER_LIB="${pkgs.socket_wrapper}/lib/libsocket_wrapper.so" + export GSSPROXY_TEST_NSS_WRAPPER_LIB="${pkgs.nss_wrapper}/lib/libnss_wrapper.so" + # libgssapi embeds a long, non-FHS mech.d path under Nix, so the suite's + # binary-patch trick cannot work; load the interposer via GSS_MECH_CONFIG. + export GSSPROXY_TEST_USE_GSS_MECH_CONFIG=1 + '' + lib.optionalString (externalDaemon != null) '' + # Drive the suite against the external (Rust) daemon. The C-built + # proxymech.so and test programs are still used unchanged. + export GSSPROXY_TEST_DAEMON="${externalDaemon}/bin/gssproxy" + '' + lib.optionalString (externalProxymech != null) '' + # Load the external (Rust) interposer instead of the in-tree C proxymech.so + # (oracle gate #2). The test programs and (optionally) the daemon are + # otherwise unchanged. + export GSSPROXY_TEST_PROXYMECH="${externalProxymech}/lib/libproxymech.so" + ''; + + # When validating the external daemon, surface the daemon log and krb5 trace + # on failure (the sandbox testdir is otherwise discarded), so a failing + # oracle-gate run is debuggable from `nix log`. + checkPhase = lib.optionalString (externalDaemon != null || externalProxymech != null) '' + runHook preCheck + set +e + make ''${checkTarget:-check} ''${checkFlags:-} + rc=$? + set -e + if [ "$rc" -ne 0 ]; then + echo "================ gssproxy.log ================" + cat testdir/gssproxy.log 2>/dev/null || true + echo "================ gp_krb5_trace.log ================" + cat testdir/gp_krb5_trace.log 2>/dev/null || true + exit "$rc" + fi + runHook postCheck + ''; +}) diff --git a/nix/interposer-symbols.nix b/nix/interposer-symbols.nix new file mode 100644 index 000000000..15b40c8ca --- /dev/null +++ b/nix/interposer-symbols.nix @@ -0,0 +1,66 @@ +{ pkgs, gssproxy, gssproxy-rs }: + +# ABI parity check for the interposer plugin: the GSSAPI mechglue resolves a +# fixed set of symbols from proxymech.so (the entry point `gss_mech_interposer` +# plus the per-operation `gssi_*` functions). This derivation compares that +# surface between the C build (`$out/lib/gssproxy/proxymech.so`) and the Rust +# build (`$out/lib/libproxymech.so`). +# +# Gate (fails the build): every interposer symbol the Rust library exports must +# also be exported by the C library, with an identical name. This catches typos +# and accidentally-exported/mis-named symbols. As the Rust data path is filled +# in, the set grows toward full parity; the C-only remainder is reported for +# visibility but does not fail the check. +pkgs.runCommand "gssproxy-interposer-symbol-parity" +{ + nativeBuildInputs = [ pkgs.binutils ]; +} '' + set -eu + + cso=${gssproxy}/lib/gssproxy/proxymech.so + rso=${gssproxy-rs}/lib/libproxymech.so + + test -f "$cso" || { echo "missing C proxymech.so at $cso"; exit 1; } + test -f "$rso" || { echo "missing Rust libproxymech.so at $rso"; exit 1; } + + # Extract the interposer ABI surface (globally-defined text symbols named + # gss_mech_interposer or gssi_*), sorted, from each library. + extract() { + nm -D --defined-only "$1" \ + | awk '$2=="T" && $3 ~ /^(gss_mech_interposer|gssi_)/ { print $3 }' \ + | sort -u + } + + extract "$cso" > c.txt + extract "$rso" > r.txt + + echo "== C interposer symbols ($(wc -l < c.txt)) ==" + cat c.txt + echo "== Rust interposer symbols ($(wc -l < r.txt)) ==" + cat r.txt + + # Sanity: C must export the canonical entry point and a large gssi_* surface. + grep -qx gss_mech_interposer c.txt || { echo "C lacks gss_mech_interposer"; exit 1; } + if [ "$(wc -l < c.txt)" -lt 50 ]; then + echo "unexpectedly few C interposer symbols"; exit 1 + fi + + # Rust must at least export the entry point. + grep -qx gss_mech_interposer r.txt || { echo "Rust lacks gss_mech_interposer"; exit 1; } + + # Correctness gate: Rust-exported interposer symbols must be a subset of C's. + comm -23 r.txt c.txt > extra.txt + if [ -s extra.txt ]; then + echo "FAIL: Rust exports interposer symbols absent from the C ABI:" + cat extra.txt + exit 1 + fi + + # Coverage report (informational): C interposer symbols not yet in Rust. + comm -13 r.txt c.txt > todo.txt + echo "== Implemented in Rust: $(wc -l < r.txt) / $(wc -l < c.txt) ==" + echo "== C interposer symbols not yet implemented in Rust ($(wc -l < todo.txt)) ==" + cat todo.txt + + echo "interposer symbol parity OK (Rust subset of C)" > $out +'' diff --git a/nix/kani.nix b/nix/kani.nix new file mode 100644 index 000000000..4ace04450 --- /dev/null +++ b/nix/kani.nix @@ -0,0 +1,107 @@ +# Local Kani runner. +# +# Kani is not packaged in nixpkgs (it needs a pinned nightly toolchain and +# downloads the CBMC backend into KANI_HOME at runtime), so we sandbox it in an +# FHS environment that provides rustup/cargo/gcc plus the krb5/clang headers the +# `gssapi-sys` build script needs. On first use it installs the Kani driver and +# CBMC into a project-local `.kani/` directory; subsequent runs are fast. +# +# Wired into the flake as: +# nix run .#kani -- -p gssproxy-proto +# nix develop .#kani (interactive FHS shell, then `cargo kani ...`) +# +# Deliberately NOT part of `nix flake check`, which stays hermetic. +{ pkgs }: +let + # Note: we deliberately do NOT use nixpkgs' `rustup`, which patches toolchains + # with a NixOS-specific lld wrapper that fails inside a plain FHS. Instead the + # runner bootstraps upstream rustup via rustup-init, whose toolchains link + # against the FHS gcc/ld normally. + targetPkgs = p: with p; [ + gcc + binutils + gnumake + python3 + pkg-config + git + curl + cacert + which + gnutar + gzip + xz + krb5 + krb5.dev + openssl + openssl.dev + # libclang + a full clang (its resource headers) so libgssapi-sys's bindgen + # build script can parse gssapi.h; glibc.dev supplies sys/types.h etc. + llvmPackages.libclang.lib + clang + glibc.dev + zlib + ]; + + # Shared environment: project-local cargo/rustup/kani state (isolated from any + # host ~/.rustup, whose NixOS-wrapped toolchains don't work inside this FHS) + # plus the paths bindgen needs to find libclang and the krb5/gssapi headers. + # Sourced both by the interactive shell (`profile`) and the runner script. + # NOTE: forced (not `:-` defaulted) because a NixOS host typically exports + # RUSTUP_HOME/CARGO_HOME pointing at a nixpkgs-rustup install whose toolchains + # carry an lld wrapper that is broken inside this FHS. We must isolate to a + # project-local, FHS-built rust toolchain. KANI_ROOT lets callers relocate it. + envSetup = '' + KANI_ROOT="''${KANI_ROOT:-$PWD/.kani}" + export CARGO_HOME="$KANI_ROOT/cargo" + export RUSTUP_HOME="$KANI_ROOT/rustup" + export KANI_HOME="$KANI_ROOT/home" + export PATH="$CARGO_HOME/bin:$PATH" + export LIBCLANG_PATH="${pkgs.llvmPackages.libclang.lib}/lib" + export SSL_CERT_FILE="${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + ''; + + profile = envSetup; + + # Bootstrap the Kani driver + CBMC on first use, then run `cargo kani` in the + # rust workspace with whatever args were passed (e.g. -p gssproxy-proto). + # We set the environment here too (not just via `profile`) so the project-local + # RUSTUP_HOME/CARGO_HOME are guaranteed to apply to this non-interactive run. + runScript = pkgs.writeShellScript "gssproxy-kani-run" '' + set -euo pipefail + ${envSetup} + + if [ ! -x "$CARGO_HOME/bin/rustup" ]; then + echo "kani: installing upstream rustup + stable toolchain (first run)..." >&2 + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ + sh -s -- -y --no-modify-path --profile minimal --default-toolchain stable + fi + if ! command -v cargo-kani >/dev/null 2>&1; then + echo "kani: installing kani-verifier (first run)..." >&2 + cargo install --locked kani-verifier + fi + if [ ! -x "$KANI_HOME/bin/kani" ] && ! ls -d "$KANI_HOME"/kani-* >/dev/null 2>&1; then + echo "kani: running 'cargo kani setup' to fetch CBMC (first run)..." >&2 + cargo kani setup + fi + + if [ -f rust/Cargo.toml ]; then + cd rust + fi + exec cargo kani "$@" + ''; +in +{ + # `nix run .#kani -- ` + app = pkgs.buildFHSEnv { + name = "gssproxy-kani"; + inherit targetPkgs profile; + runScript = "${runScript}"; + }; + + # `nix develop .#kani` (interactive shell with the same environment) + shell = pkgs.buildFHSEnv { + name = "gssproxy-kani-shell"; + inherit targetPkgs profile; + runScript = "bash"; + }; +} diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 000000000..b6b868ef4 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,196 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.gssproxy; + + # gssproxy.conf is an INI-like file: sections such as "[gssproxy]" or + # "[service/nfs-server]", with indented "key = value" entries. Some keys + # (notably cred_store) may legitimately appear multiple times, so list + # values are rendered as one line per element. + renderValue = v: + if v == true then "yes" + else if v == false then "no" + else if builtins.isInt v then toString v + else toString v; + + renderKV = key: value: + if builtins.isList value + then lib.concatMapStringsSep "\n" (item: " ${key} = ${renderValue item}") value + else " ${key} = ${renderValue value}"; + + renderSection = name: attrs: + "[${name}]\n" + + lib.concatStringsSep "\n" (lib.mapAttrsToList renderKV attrs) + + "\n"; + + settingsToConf = settings: + lib.concatStringsSep "\n" (lib.mapAttrsToList renderSection settings); + + # Drop-in configuration that makes gssproxy act as the in-kernel NFS server + # GSS helper, replacing rpc.svcgssd. Mirrors examples/24-nfs-server.conf.in. + nfsServerSettings = lib.optionalAttrs cfg.nfs.server.enable { + "service/nfs-server" = { + mechs = "krb5"; + socket = cfg.nfs.server.socket; + cred_store = "keytab:${cfg.nfs.server.keytab}"; + trusted = true; + kernel_nfsd = true; + euid = 0; + }; + }; + + baseSettings = { + gssproxy = lib.optionalAttrs (cfg.debugLevel != null) { + debug_level = cfg.debugLevel; + }; + }; + + finalSettings = lib.recursiveUpdate (lib.recursiveUpdate baseSettings nfsServerSettings) cfg.settings; + + configFile = pkgs.writeText "gssproxy-nixos.conf" (settingsToConf finalSettings); +in +{ + options.services.gssproxy = { + enable = lib.mkEnableOption "the GSSAPI proxy daemon (gssproxy)"; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.gssproxy; + defaultText = lib.literalExpression "pkgs.gssproxy"; + description = "The gssproxy package to use."; + }; + + debugLevel = lib.mkOption { + type = lib.types.nullOr lib.types.int; + default = null; + example = 1; + description = "Value for the global `debug_level` setting in the `[gssproxy]` section. `null` omits it."; + }; + + settings = lib.mkOption { + type = with lib.types; attrsOf (attrsOf (oneOf [ bool int str (listOf (oneOf [ bool int str ])) ])); + default = { }; + example = lib.literalExpression '' + { + "service/nfs-client" = { + mechs = "krb5"; + cred_store = [ + "keytab:/etc/krb5.keytab" + "ccache:FILE:/var/lib/gssproxy/clients/krb5cc_%U" + ]; + cred_usage = "initiate"; + allow_any_uid = true; + euid = 0; + }; + } + ''; + description = '' + Free-form gssproxy configuration. The top-level attribute name is the + section name (for example `"gssproxy"` or `"service/nfs-server"`), and + each value is an attribute set of `key = value` entries. Boolean values + are rendered as `yes`/`no`. A list value is emitted as one line per + element, which is required for repeatable keys such as `cred_store`. + + These settings are deep-merged over (and therefore can override) the + defaults generated by `services.gssproxy.nfs.server.enable`. + ''; + }; + + nfs.server = { + enable = lib.mkEnableOption '' + the NFS server drop-in for gssproxy. This configures gssproxy as the + in-kernel NFSD GSS helper (replacing rpc.svcgssd), which is required to + handle large RPCSEC/GSS credentials such as tickets carrying a + Microsoft PAC payload (Active Directory / FreeIPA) + ''; + + keytab = lib.mkOption { + type = lib.types.path; + default = "/etc/krb5.keytab"; + description = "Keytab holding the `nfs/` and `host/` service keys for the NFS server."; + }; + + socket = lib.mkOption { + type = lib.types.str; + default = "/run/gssproxy.sock"; + description = '' + Path of the UNIX socket the kernel uses to talk to gssproxy. This + path is hardcoded in the kernel and should not normally be changed. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ cfg.package ]; + + # gssproxy always loads its main config file (PUBCONF_PATH/gssproxy.conf) + # and fails to start if it is missing, so the generated configuration is + # written there directly. + environment.etc."gssproxy/gssproxy.conf".source = configFile; + + # Mask the classic kerberos NFS server helper so the kernel binds to + # gssproxy on the first authentication request instead of rpc.svcgssd. + systemd.services.rpc-svcgssd = lib.mkIf cfg.nfs.server.enable { + enable = lib.mkForce false; + }; + + warnings = lib.optional cfg.nfs.server.enable '' + services.gssproxy.nfs.server: the kernel chooses between rpc.svcgssd and + gssproxy once, on the first GSS authentication request, and cannot switch + afterwards. If kerberized NFS was already in use, reboot the server for + gssproxy to take over. + ''; + + systemd.services.gssproxy = { + description = "GSSAPI Proxy Daemon"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + before = [ "rpc-gssd.service" "nfs-server.service" "nfs-mountd.service" ]; + + environment.KRB5RCACHEDIR = "/var/lib/gssproxy/rcache"; + + serviceConfig = { + Type = "notify"; + ExecStart = "${lib.getExe cfg.package} -i"; + ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + + StateDirectory = "gssproxy gssproxy/clients gssproxy/rcache"; + StateDirectoryMode = "0700"; + + # Hardening, ported from upstream systemd/gssproxy.service.in. + ProtectSystem = "strict"; + PrivateDevices = true; + PrivateNetwork = true; + PrivateIPC = true; + # Blocks access to /home which may hold ccaches, also breaks euid mappings. + PrivateUsers = false; + PrivateTmp = false; + ProtectHome = false; + ReadWritePaths = [ "/root" "/home" "/run/user" ]; + ProtectHostname = false; + ProtectClock = true; + # Must *not* block rw access to /proc/net/rpc/use-gss-proxy. + ProtectKernelTunables = true; + ProtectProc = "default"; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_UNIX" "AF_LOCAL" ]; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + PrivateMounts = true; + SystemCallFilter = [ "@system-service" ]; + SystemCallErrorNumber = "EPERM"; + SystemCallArchitectures = "native"; + NoNewPrivileges = true; + CapabilityBoundingSet = [ "CAP_DAC_OVERRIDE" ]; + IPAddressDeny = "any"; + UMask = "0177"; + }; + }; + }; +} diff --git a/nix/nix-fmt.nix b/nix/nix-fmt.nix new file mode 100644 index 000000000..753e6e813 --- /dev/null +++ b/nix/nix-fmt.nix @@ -0,0 +1,18 @@ +{ pkgs, src }: + +# Formatting gate for the Nix sources: every tracked *.nix file (the flake plus +# everything under ./nix) must be nixpkgs-fmt clean. `src` should be a filtered +# source tree containing flake.nix and the nix/ directory. +pkgs.runCommand "gssproxy-nix-fmt-check" +{ + nativeBuildInputs = [ pkgs.nixpkgs-fmt ]; +} '' + set -eu + cd ${src} + echo "== checking nix formatting (nixpkgs-fmt --check) ==" + # List the files we check so failures are easy to map back. + files=$(find . -name '*.nix' | sort) + echo "$files" + nixpkgs-fmt --check $files + echo "nix formatting OK" > $out +'' diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 000000000..464b16521 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,129 @@ +{ lib +, stdenv +, autoreconfHook +, pkg-config +, gettext +, libxslt +, libxml2 +, docbook-xsl-nons +, docbook_xml_dtd_45 +, findutils +, krb5 +, libverto +, ding-libs +, keyutils +, popt +, systemdLibs +, libselinux +, libcap +, withSelinux ? false +, withCap ? true +}: + +stdenv.mkDerivation (finalAttrs: { + pname = "gssproxy"; + version = "0.9.2"; + + src = lib.cleanSource ../.; + + outputs = [ "out" "man" ]; + + nativeBuildInputs = [ + autoreconfHook + pkg-config + gettext + libxslt + libxml2 + docbook-xsl-nons + docbook_xml_dtd_45 + findutils + ]; + + buildInputs = [ + krb5 + libverto + ding-libs + keyutils + popt + # libsystemd: enables sd_notify so the daemon supports Type=notify (the + # unit shipped by the NixOS module relies on readiness notification). + systemdLibs + ] + ++ lib.optional withSelinux libselinux + ++ lib.optional withCap libcap; + + configureFlags = [ + # The NixOS module ships its own systemd unit; avoid installing into an + # impure absolute systemdunitdir while keeping the proxy daemon enabled. + "--with-initscript=none" + # Compile in the runtime FHS locations (gssproxy reads /etc/gssproxy and the + # kernel socket lives under /var). `make install` is redirected back into + # $out via installFlags below so nothing is written outside the store. + "--sysconfdir=/etc" + "--localstatedir=/var" + ] + ++ lib.optional withSelinux "--with-selinux" + ++ lib.optional (!withSelinux) "--with-selinux=no" + ++ lib.optional withCap "--with-cap"; + + # gssproxy calls verto_cleanup() once, immediately before returning from + # main(), purely as exit-time leak hygiene. configure detects the symbol with + # AC_CHECK_FUNCS against the default linker path, where krb5 ships its own + # embedded libverto.so.0 built with BUILTIN_MODULE. That copy's verto_cleanup() + # frees a static `builtin_record` and its "" string literal, so the daemon + # aborts with "free(): invalid pointer" on every clean shutdown (SIGTERM). + # Force the feature off so the call is compiled out; the OS reclaims everything + # at exit anyway. (autoconf cache override for AC_CHECK_FUNCS(verto_cleanup).) + ac_cv_func_verto_cleanup = "no"; + + # The compiled-in paths point at /etc and /var, but `make install` must write + # only into $out. sysconfdir/localstatedir cover the generic autotools dirs; + # the gssproxy-specific install dirs below are expanded from configure-time + # values, so they are redirected explicitly as well. + installFlags = [ + "sysconfdir=${placeholder "out"}/etc" + "localstatedir=${placeholder "out"}/var" + "gssconfdir=${placeholder "out"}/etc/gss/mech.d" + "pubconfpath=${placeholder "out"}/etc/gssproxy" + "logpath=${placeholder "out"}/var/log/gssproxy" + "gpstatedir=${placeholder "out"}/var/lib/gssproxy" + "gpclidir=${placeholder "out"}/var/lib/gssproxy/clients" + ]; + + # The man pages are built with `xmllint/xsltproc --catalogs`, which resolve the + # DocBook DTD and stylesheet URLs through SGML_CATALOG_FILES (a single path + # accepted by --with-xml-catalog-path). Build one combined catalog that + # delegates to both the DTD and the (non-namespaced) XSL catalogs so the build + # works offline in the sandbox. + preConfigure = '' + combinedCatalog="$PWD/.nix-docbook-catalog.xml" + xmlcatalog --noout --create "$combinedCatalog" + xmlcatalog --noout --add nextCatalog "" \ + "${docbook_xml_dtd_45}/xml/dtd/docbook/catalog.xml" "$combinedCatalog" + xmlcatalog --noout --add nextCatalog "" \ + "${docbook-xsl-nons}/share/xml/docbook-xsl-nons/catalog.xml" "$combinedCatalog" + configureFlagsArray+=("--with-xml-catalog-path=$combinedCatalog") + ''; + + enableParallelBuilding = true; + + # The upstream test suite ("make check") needs a full MIT KDC, OpenLDAP and + # network namespaces, which is not available in the build sandbox. + doCheck = false; + + meta = { + description = "GSSAPI proxy daemon, a drop-in replacement for rpc.svcgssd handling large Kerberos tickets"; + longDescription = '' + GSS Proxy is a daemon that sits between GSSAPI clients (such as the + in-kernel NFS server and client) and the Kerberos credentials they use. + It allows the kernel NFS server to handle large RPCSEC/GSS credentials + (for example tickets carrying a Microsoft PAC payload from Active + Directory or FreeIPA) that the classic rpc.svcgssd cannot. + ''; + homepage = "https://github.com/gssapi/gssproxy"; + changelog = "https://github.com/gssapi/gssproxy/releases/tag/v${finalAttrs.version}"; + license = lib.licenses.gpl3Plus; + platforms = lib.platforms.linux; + mainProgram = "gssproxy"; + }; +}) diff --git a/nix/rust-checks.nix b/nix/rust-checks.nix new file mode 100644 index 000000000..a71770b39 --- /dev/null +++ b/nix/rust-checks.nix @@ -0,0 +1,120 @@ +{ pkgs }: + +# Rust formatting and lint gates for the ./rust workspace. Each entry is a +# derivation that fails to build if the corresponding check fails, so they can +# be wired straight into `flake.checks`. +# +# Both gates reuse the same vendored dependency set (via cargoSetupHook + +# importCargoLock) as nix/rust.nix, so they run fully offline in the sandbox. +let + inherit (pkgs) lib rustPlatform; + + src = lib.cleanSource ../rust; + + cargoDeps = rustPlatform.importCargoLock { + lockFile = ../rust/Cargo.lock; + }; + + common = { + inherit src cargoDeps; + # The committed .cargo/config.toml disables rustup's self-contained lld, + # which Nixpkgs' rustc rejects; drop it like nix/rust.nix does. + postPatch = '' + rm -f .cargo/config.toml + ''; + # bindgenHook supplies libclang for libgssapi-sys; pkg-config + krb5 let the + # FFI crates resolve the system GSSAPI during macro/type expansion. + buildInputs = [ pkgs.krb5 ]; + dontInstall = false; + installPhase = "touch $out"; + }; +in +{ + # `cargo fmt --check`: the workspace must be rustfmt-clean. + rust-fmt = pkgs.stdenv.mkDerivation (common // { + name = "gssproxy-rs-rustfmt-check"; + nativeBuildInputs = [ + rustPlatform.cargoSetupHook + pkgs.cargo + pkgs.rustc + pkgs.rustfmt + pkgs.pkg-config + rustPlatform.bindgenHook + ]; + buildPhase = '' + runHook preBuild + echo "cargo fmt --all --check" + cargo fmt --all -- --check + runHook postBuild + ''; + }); + + # `cargo clippy` with warnings denied across all crates and targets. + clippy = pkgs.stdenv.mkDerivation (common // { + name = "gssproxy-rs-clippy"; + nativeBuildInputs = [ + rustPlatform.cargoSetupHook + pkgs.cargo + pkgs.rustc + pkgs.clippy + pkgs.pkg-config + rustPlatform.bindgenHook + ]; + buildPhase = '' + runHook preBuild + echo "cargo clippy --workspace --all-targets -- -D warnings" + cargo clippy --workspace --all-targets -- -D warnings + runHook postBuild + ''; + }); + + # Whole-workspace `cargo test` as an explicit gate. This is where the + # property-based tests (proptest round-trip fidelity in gssproxy-proto, the + # config/CLI parsers in gssproxy-server) and the "chaos monkey" robustness + # fuzzers (gssproxy-proto/tests/proptest_proto.rs: decode arbitrary/truncated/ + # biased byte streams must never panic, hang, or over-allocate) are validated. + # Run in release so the higher proptest case counts (up to 512) stay fast. + rust-tests = pkgs.stdenv.mkDerivation (common // { + name = "gssproxy-rs-tests"; + nativeBuildInputs = [ + rustPlatform.cargoSetupHook + pkgs.cargo + pkgs.rustc + pkgs.pkg-config + rustPlatform.bindgenHook + ]; + # Deterministic, reproducible fuzzing run; raise the floor for any + # non-pinned proptest blocks and surface full backtraces on failure. + PROPTEST_CASES = "1024"; + RUST_BACKTRACE = "1"; + buildPhase = '' + runHook preBuild + echo "cargo test --workspace --release (property + chaos/fuzz suite)" + cargo test --workspace --release + runHook postBuild + ''; + }); + + # Supply-chain license/bans/sources gate (rust/deny.toml). This is the offline + # subset of `cargo deny`: it validates the dependency tree's licenses against + # the MIT-compatible allow-list, forbids unknown registries/git sources, and + # bans wildcard versions. The `advisories` check needs the network-fetched + # RustSec DB, so it runs in CI / the `audit` dev shell instead of here. + cargo-deny = pkgs.stdenv.mkDerivation (common // { + name = "gssproxy-rs-cargo-deny"; + nativeBuildInputs = [ + rustPlatform.cargoSetupHook + pkgs.cargo + pkgs.rustc + pkgs.cargo-deny + ]; + # Resolve the workspace metadata from the vendored lockfile, no network. + CARGO_NET_OFFLINE = "true"; + buildPhase = '' + runHook preBuild + echo "cargo deny check licenses bans sources" + cargo deny --offline check licenses bans sources + runHook postBuild + ''; + }); +} diff --git a/nix/rust.nix b/nix/rust.nix new file mode 100644 index 000000000..b7dbf4b42 --- /dev/null +++ b/nix/rust.nix @@ -0,0 +1,43 @@ +{ lib +, rustPlatform +, pkg-config +, krb5 +}: + +# Builds the Rust reimplementation workspace under ../rust: +# - the `gssproxy` daemon binary, +# - the `proxymech.so` interposer cdylib, +# - and the supporting library crates. +# +# Kept as a separate derivation from the autotools package (nix/package.nix) so +# both can coexist during the port; flake.nix exposes this as `gssproxy-rs`. +rustPlatform.buildRustPackage { + pname = "gssproxy-rs"; + version = "0.9.2"; + + src = lib.cleanSource ../rust; + + cargoLock.lockFile = ../rust/Cargo.lock; + + # The committed .cargo/config.toml disables the rustup self-contained lld + # (needed only on the non-Nix dev host). Nixpkgs' rust ships a working + # linker, so drop the override here to avoid pinning linker behaviour. + postPatch = '' + rm -f .cargo/config.toml + ''; + + # pkg-config locates the MIT GSSAPI; bindgenHook supplies libclang/LIBCLANG_PATH + # for libgssapi-sys's bindgen build script. + nativeBuildInputs = [ pkg-config rustPlatform.bindgenHook ]; + + # The daemon and interposer link against the system GSSAPI/krb5. + buildInputs = [ krb5 ]; + + meta = { + description = "Rust reimplementation of the gssproxy daemon and proxymech.so interposer"; + homepage = "https://github.com/gssapi/gssproxy"; + license = lib.licenses.gpl3Plus; + platforms = lib.platforms.linux; + mainProgram = "gssproxy"; + }; +} diff --git a/nix/test.nix b/nix/test.nix new file mode 100644 index 000000000..5d574768a --- /dev/null +++ b/nix/test.nix @@ -0,0 +1,70 @@ +{ pkgs, module }: + +# Smoke test: bring up a single NixOS node with the NFS-server drop-in enabled +# and verify that gssproxy starts, registers with the kernel via +# /proc/net/rpc/use-gss-proxy, and that the classic rpc.svcgssd is masked. +# +# End-to-end Kerberos authentication (KDC + real keytabs) is intentionally out +# of scope here; see nix/README.md for manual verification steps. +pkgs.testers.nixosTest { + name = "gssproxy-nfs-server"; + + nodes.server = { config, pkgs, ... }: { + imports = [ module ]; + + services.gssproxy = { + enable = true; + nfs.server.enable = true; + }; + + services.nfs.server = { + enable = true; + exports = "/srv/nfs *(rw,sec=krb5,fsid=0,no_subtree_check)"; + }; + + # gssproxy references this keytab; an empty placeholder is enough for the + # daemon to start and register with the kernel in this smoke test. + systemd.tmpfiles.rules = [ + "d /srv/nfs 0755 root root -" + "f /etc/krb5.keytab 0600 root root -" + ]; + }; + + testScript = '' + # Type=notify only reaches "active" if sd_notify(READY=1) is sent, which + # also proves the daemon parsed its config and started serving. + server.wait_for_unit("gssproxy.service") + + # The kernel module that exposes the proc switch must be present before + # gssproxy can register; nfsd pulls it in. + server.succeed("modprobe nfsd") + server.wait_until_succeeds("test -e /proc/net/rpc/use-gss-proxy") + + # gssproxy retries kernel registration every 10s, so once the proc file + # exists it claims the upcall path (value becomes "1") without a restart. + server.wait_until_succeeds("grep -q 1 /proc/net/rpc/use-gss-proxy") + + # The classic helper must be masked so it does not race gssproxy. + server.fail("systemctl is-active rpc-svcgssd.service") + + # Our generated drop-in config must be in place. + server.succeed("grep -q kernel_nfsd /etc/gssproxy/gssproxy.conf") + + # Regression test: gssproxy must shut down cleanly on SIGTERM. When linked + # against krb5's embedded (BUILTIN_MODULE) libverto, verto_cleanup() frees a + # static record and aborts ("free(): invalid pointer"), so systemd records a + # core-dump on every stop. The package disables that exit-time call; assert + # the daemon stops without being killed by a signal. + server.succeed("systemctl stop gssproxy.service") + result = server.succeed( + "systemctl show -p Result --value gssproxy.service" + ).strip() + assert result == "success", f"gssproxy did not stop cleanly: Result={result}" + + # After a restart the daemon re-registers with the kernel on its own via the + # retry timer, with no manual intervention; the proc switch stays claimed. + server.succeed("systemctl start gssproxy.service") + server.wait_for_unit("gssproxy.service") + server.wait_until_succeeds("grep -q 1 /proc/net/rpc/use-gss-proxy") + ''; +} diff --git a/rust/.cargo/config.toml b/rust/.cargo/config.toml new file mode 100644 index 000000000..b30ad53ca --- /dev/null +++ b/rust/.cargo/config.toml @@ -0,0 +1,9 @@ +# The rustup toolchain on this host ships a self-contained `lld` whose wrapper +# references a stale Nix store path, so linking fails. Fall back to the system +# `cc`/`ld`. (Inside `nix develop` with a Nix-provided rust toolchain this is a +# harmless no-op.) +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-self-contained=no", "-C", "linker-features=-lld"] + +[target.aarch64-unknown-linux-gnu] +rustflags = ["-C", "link-self-contained=no", "-C", "linker-features=-lld"] diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 000000000..d10d725df --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,1082 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gssapi-sys" +version = "0.9.2" +dependencies = [ + "bindgen", + "libc", + "libgssapi-sys", + "pkg-config", +] + +[[package]] +name = "gssproxy-client" +version = "0.9.2" +dependencies = [ + "gssapi-sys", + "gssproxy-proto", + "libc", + "proptest", + "tracing", +] + +[[package]] +name = "gssproxy-interposer" +version = "0.9.2" +dependencies = [ + "gssapi-sys", + "gssproxy-client", + "gssproxy-proto", + "libc", + "proptest", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "gssproxy-proto" +version = "0.9.2" +dependencies = [ + "proptest", +] + +[[package]] +name = "gssproxy-server" +version = "0.9.2" +dependencies = [ + "gssapi-sys", + "gssproxy-proto", + "libc", + "proptest", + "rust-ini", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libgssapi-sys" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5103ac4557eacd36ff678b654b943f8966d3db9688fbd180a0b4c5464759ce17" +dependencies = [ + "bindgen", + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "windows-sys", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 000000000..dcbc0fac1 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,46 @@ +[workspace] +resolver = "2" +members = [ + "gssproxy-proto", + "gssapi-sys", + "gssproxy-client", + "gssproxy-server", + "gssproxy-interposer", +] +# gssproxy-bench links the C rpcgen XDR (gssrpc) and reads ../../rpcgen, so it is +# kept out of the workspace: the hermetic `--workspace` gates (clippy, rust-tests, +# kani) must not try to build it. Run it via `nix develop .#bench`. +exclude = [ + "gssproxy-bench", +] + +[workspace.package] +version = "0.9.2" +edition = "2024" +# Matches the root project license (see ../COPYING). +license = "MIT" +repository = "https://github.com/gssapi/gssproxy" +# Edition 2024 needs rustc >= 1.85; pinned to the flake's toolchain (1.95) since +# that is the only version the CI gates build and test against. +rust-version = "1.95" + +# Intra-workspace deps carry an explicit version (not just a path) so they are +# not treated as wildcard ("*") dependencies by cargo-deny's `bans` check. +[workspace.dependencies] +gssproxy-proto = { path = "gssproxy-proto", version = "0.9.2" } +gssapi-sys = { path = "gssapi-sys", version = "0.9.2" } +gssproxy-client = { path = "gssproxy-client", version = "0.9.2" } +libc = "0.2" +# Structured logging. `tracing` provides the (near-zero-cost when disabled) +# span/event macros used throughout the crates; `tracing-subscriber` (with +# `env-filter`) is only pulled in by the components that actually install a +# subscriber (the daemon, and the interposer when GSSPROXY_LOG is set). +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# `kani` is a custom cfg injected by `cargo kani` (--cfg kani). Declare it so the +# `unexpected_cfgs` lint (denied via clippy -D warnings) doesn't fire on the +# `#[cfg(kani)]` verification modules during normal builds. Crates that use it +# opt in with `[lints] workspace = true`. +[workspace.lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] } diff --git a/rust/deny.toml b/rust/deny.toml new file mode 100644 index 000000000..1a6b05992 --- /dev/null +++ b/rust/deny.toml @@ -0,0 +1,65 @@ +# cargo-deny configuration for the ./rust workspace. +# +# Run locally: +# nix develop .#audit -c cargo deny check # all checks (needs network for advisories) +# nix develop .#audit -c cargo deny check licenses bans sources # offline subset +# nix build .#checks.x86_64-linux.cargo-deny # the offline gate run in CI +# +# Tested against cargo-deny 0.19.x. + +[graph] +# Check the dependency tree for all targets, not just the host, so a +# platform-specific dependency can't sneak in a disallowed license. +all-features = true + +[advisories] +# RustSec advisory database (https://github.com/rustsec/advisory-db). Fetching +# it needs network access, so the advisories check is run in CI / the dev shell +# rather than in the sealed Nix `cargo-deny` flake check. +db-urls = ["https://github.com/rustsec/advisory-db"] +# Fail on any crate with an unfixed advisory or that has been yanked. +yanked = "deny" +ignore = [] + +[licenses] +# Permissive, MIT-compatible licenses only - matching the project license +# (../COPYING is MIT; see rust/Cargo.toml `license = "MIT"`). Adding a copyleft +# (e.g. GPL/AGPL) or otherwise incompatible dependency will fail this check. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "CC0-1.0", + "Unicode-3.0", + "Unicode-DFS-2016", + "Unlicense", + "Zlib", +] +# Require fairly high confidence in the detected license text. +confidence-threshold = 0.9 +# The allow-list is intentionally a bit broader than what the current tree uses +# (common permissive licenses), so don't warn about allowed-but-unused entries. +unused-allowed-license = "allow" + +# Our own workspace crates are not published to crates.io; don't gate them. +[licenses.private] +ignore = true + +[bans] +# Duplicate versions (e.g. bindgen 0.70 + 0.71 pulled transitively) are a +# hygiene smell but not a correctness/legal problem, so warn rather than fail. +multiple-versions = "warn" +# Wildcard ("*") version requirements are forbidden, except intra-workspace +# path dependencies which legitimately have no version requirement. +wildcards = "deny" +allow-wildcard-paths = true + +[sources] +# Only crates.io and explicitly-listed sources are allowed; an unknown registry +# or a git dependency from an unvetted host fails the check. +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/rust/docs/benchmarking.md b/rust/docs/benchmarking.md new file mode 100644 index 000000000..7fb6f25d9 --- /dev/null +++ b/rust/docs/benchmarking.md @@ -0,0 +1,92 @@ +# Benchmarking the gssx codec (Rust vs C) with flamegraphs + +The [`gssproxy-bench`](../gssproxy-bench) crate benchmarks the gssx wire codec +and compares the pure-Rust [`gssproxy-proto`](../gssproxy-proto) implementation +head-to-head against the C rpcgen XDR (`rpcgen/gss_proxy_xdr.c`, +`gp_rpc_xdr.c`, `gp_xdr.c`, linking MIT krb5's `gssrpc`). + +It is **excluded from the Rust workspace** (see `exclude` in +[rust/Cargo.toml](../Cargo.toml)) because its `build.rs` compiles the C sources +under `../../rpcgen` and links `gssrpc` + `krb5-gssapi`. The hermetic +`--workspace` gates (clippy, `rust-tests`, Kani) therefore never build it, and +it is intentionally not a `nix flake check`. + +## What is measured + +Criterion groups, each with a `rust` and a `c` function (rendered side by side): + +- `encode/indicate_mechs`, `decode/indicate_mechs` - the minimal CALL envelope. +- `encode/init_sec_context`, `decode/init_sec_context` - the krb5 CALL with an + `input_token` swept over `0, 256, 4096, 65536` bytes (exercises the + opaque/length-prefix/padding hot path and optional-pointer fields), tagged + with `Throughput::Bytes`. + +Both sides serialize the same wire body (`gp_rpc_msg` + arg); the benches assert +the C and Rust encoders agree on the body length and can each decode the other's +bytes before timing. The C struct construction is done once in +`cbench_setup_*`, so only encode/decode is timed. + +### Fairness caveat + +The C path uses MIT krb5's `gssrpc` `xdrmem` routines; the Rust path uses the +hand-rolled `XdrEncoder`/`XdrDecoder`. Both go through one in-process call, but +they are different implementations of the same wire format - treat the numbers +as an implementation comparison, not a controlled microbenchmark of a single +algorithm. + +## Running + +Enter the bench shell (provides cargo, the C toolchain, krb5/gssrpc, +cargo-flamegraph, and perf): + +```sh +nix develop .#bench +cd rust/gssproxy-bench + +# Full comparison report (HTML at target/criterion/report/index.html): +cargo bench --bench codec + +# Quick run while iterating: +cargo bench --bench codec -- --warm-up-time 1 --measurement-time 2 +``` + +## Flamegraphs + +Two complementary mechanisms: + +### 1. pprof-rs, per-benchmark (no perf/root) + +The Criterion harness is configured with a `pprof` profiler, so passing +`--profile-time` samples each benchmark in-process and writes a flamegraph: + +```sh +cargo bench --bench codec -- --profile-time=5 +# -> target/criterion///profile/flamegraph.svg +``` + +### 2. cargo-flamegraph, whole binary (perf-based) + +```sh +nix run .#flamegraph # profiles `cargo bench --bench codec` +nix run .#flamegraph -- decode # forward a filter to the bench binary +# -> rust/gssproxy-bench/target/flamegraph/flamegraph.svg (+ perf.data) +``` + +The app runs from inside `target/` so both `flamegraph.svg` and perf's +`perf.data` land under `target/flamegraph/` and are removed by `cargo clean` +(the pprof SVGs under `target/criterion` are cleaned too). If you instead run +`cargo flamegraph` by hand from the crate root, it writes `flamegraph.svg` / +`perf.data` to the cwd, outside `target/` - those are gitignored but not removed +by `cargo clean`. + +`cargo flamegraph` uses Linux `perf`, which usually needs relaxed kernel +permissions. If it fails with a permissions error: + +```sh +sudo sysctl kernel.perf_event_paranoid=1 +# and, if needed for kernel symbols: +sudo sysctl kernel.kptr_restrict=0 +``` + +(perf access is environment-specific; in containers/VMs it may require +`--cap-add SYS_ADMIN` or a privileged session.) diff --git a/rust/docs/export_name_composite.md b/rust/docs/export_name_composite.md new file mode 100644 index 000000000..c24134642 --- /dev/null +++ b/rust/docs/export_name_composite.md @@ -0,0 +1,224 @@ +# `gssi_export_name_composite` - status, background, and round-trip gap + +This note documents the **composite name export** GSSAPI operation, why the +gssproxy interposer entry point (`gssi_export_name_composite`) is currently +*not* exported, and what would have to change - on both the **interposer** and +the **daemon (server)** side - to support it under a configuration that needs +attribute-carrying exported names (e.g. PAC / authorization-data round trips). + +It lives next to [`names.rs`](./names.rs), which implements the `gssi_*` name +operations and is where `gssi_export_name_composite` would be added if/when the +round trip is fixed. + +## TL;DR + +- `gss_export_name_composite()` (RFC 6680) exports a name **including its + attributes** (and the authenticated/complete flags), unlike `gss_export_name()` + which exports only the bare mechanism name (MN). +- The C interposer deliberately disables `gssi_export_name_composite` with + `#if 0` (`src/mechglue/gpp_import_and_canon_name.c`), commented *"disabled until + better understood"*. The v0.2.2 changelog reads *"Disable + gss_export_name_composite() for now."* The matching test in + `tests/interposetest.c` is also `#if 0` *"disabled until + gss_export_name_composite server-side is fixed."* +- The Rust port faithfully mirrors this: there is **no** `gssi_export_name_composite` + in `names.rs`, keeping byte-for-byte symbol parity with the C `proxymech.so`. +- The reason it is unfinished is a **round-trip asymmetry in the daemon**: the + daemon *exports* the composite blob but never *re-imports* it and never + transfers `name_attributes`. Enabling the interposer entry alone would expose + a one-way feature whose result cannot be faithfully imported back through + gssproxy. + +## What `gss_export_name_composite` is (RFC 6680) + +RFC 6680 ("GSS-API Naming Extensions", §7.8) defines: + +```c +OM_uint32 gss_export_name_composite( + OM_uint32 *minor_status, + gss_const_name_t name, + gss_buffer_t exp_composite_name); +``` + +Key properties from the spec: + +- It outputs a token that can be re-imported with `gss_import_name()` using the + name type **`GSS_C_NT_COMPOSITE_EXPORT`**, OID **`1.3.6.1.5.6.6`** + (`{iso(1) org(3) dod(6) internet(1) security(5) nametypes(6) + gss-composite-export(6)}`). In this tree it is `NT_COMPOSITE_EXPORT_OID` + (`rust/gssapi-sys/src/consts.rs`). +- Unlike `gss_export_name()`, the composite token **preserves any name attribute + information**, including the *authenticated* and *complete* flags associated + with the input name. Plain `gss_export_name()` "may well not" preserve them. +- The token format is intentionally unspecified (it is meant for + inter-process communication only) **except** that every token MUST begin with + the two-octet token ID `04 02` (network byte order). For comparison, + `gss_export_name()` (RFC 2743) tokens start with `04 01`. +- Output buffer is freed by the caller with `gss_release_buffer()`. + +The canonical use case is moving an authenticated name - together with its +mechanism attributes (e.g. a Kerberos PAC, group memberships, SID/UID mappings, +`urn:` naming-extension attributes) - from one process to another and then +re-importing it without losing the attributes or the "this was authenticated" +property. + +## How other implementations expose it + +### MIT krb5 + +- Declared in `src/lib/gssapi/generic/gssapi_ext.h` and exported from + `libgssapi_krb5` (`src/lib/gssapi/libgssapi_krb5.exports`). +- The krb5 mechanism wires `krb5_gss_export_name_composite` into its dispatch + table (`krb5_mechanism` in `src/lib/gssapi/krb5/gssapi_krb5.c`). +- The **mechglue** is the dispatch layer. For an **interposer** module (which is + what gssproxy's `proxymech.so` is), the mechglue copies the *real* mechanism's + dispatch table and overrides only the slots for which the interposer exports a + `gssi_` symbol. Therefore: + - If `gssi_export_name_composite` is **not exported**, `gss_export_name_composite()` + routes straight to the underlying real krb5 mechanism (no proxying) - which is + exactly today's behavior. The local krb5 library answers from the local + name; gssproxy is bypassed for this single call. + - MIT's own plugin docs note that a module may simply refrain from exporting an + extension and "the mechglue will fail gracefully" (returns + `GSS_S_UNAVAILABLE`) if neither interposer nor real mech implements it. +- MIT also documents the interposer re-entry rule: to call back into the original + mechanism for token-bearing functions, the interposer must wrap the mech token + in the mechglue's concatenated-OID format. For name import specifically, the + exported-name token is `04 01` + 2-byte OID len + mech OID (DER, with `06` + tag) + 4-byte token len + token, with `input_name_type = GSS_C_NT_EXPORT_NAME`. + The composite variant is analogous but begins `04 02` and is imported with + `GSS_C_NT_COMPOSITE_EXPORT`. + +### Heimdal + +- `lib/gssapi/gssapi/gssapi.h` declares `gss_export_name_composite` under the + "Naming extensions" section and reserves the static + `__gss_c_nt_composite_export_oid_desc` (`{1.3.6.1.5.6.6}`), exposed as + `GSS_C_NT_COMPOSITE_EXPORT`, matching the RFC. + +## How gssproxy carries it on the wire + +The gssx protocol already has a slot for the composite blob: `gssx_name` has both +`exported_name` and `exported_composite_name` fields. + +- C: `struct gssx_name` (xdr) / Rust: `GssxName` in + `rust/gssproxy-proto/src/gssx.rs` (`exported_composite_name: GssxBuffer`). +- The client-direction RPC stub already exists and simply returns the cached + blob from the `gssx_name`: + - C: `gpm_export_name_composite` (`src/client/gpm_import_and_canon_name.c`) - + returns `GSS_S_NAME_NOT_MN` if `exported_composite_name` is empty, otherwise + copies it out. + - The disabled interposer wrapper (`gpp_import_and_canon_name.c`, `#if 0`) would + dispatch to either `gss_export_name_composite` (local name) or + `gpm_export_name_composite` (remote name). + +So the **export** half is plumbed end-to-end. The gap is the **import / attribute +transfer** half. + +## The round-trip gap (this is the "server fix") + +A faithful composite-name feature must round-trip: export on side A → transport → +import on side B → attributes still present and usable. Today the daemon does the +first half but not the second. Concretely: + +1. **Export side - attributes never serialized.** `gp_conv_name_to_gssx` + (`src/gp_conv.c`) calls `gss_export_name_composite()` and stores the blob in + `out.exported_composite_name`, but the `out->name_attributes` population is a + bare comment / no-op (`/* out->name_attributes */`). Only the opaque composite + blob travels; the structured attribute set does not. + + The Rust port mirrors this exactly: `name_to_gssx` in + `rust/gssproxy-server/src/conv.rs` fills `exported_composite_name` via + `Name::export_composite()` but does not populate `name_attributes`. + +2. **Import side - composite blob ignored.** `gp_conv_gssx_to_name` + (`src/gp_conv.c`) reconstructs a live name from `display_name` (re-import + + canonicalize) or else from `exported_name` (imported as + `GSS_C_NT_EXPORT_NAME`). It **never** looks at `exported_composite_name` and + never imports it as `GSS_C_NT_COMPOSITE_EXPORT`, so the attribute-carrying form + is dropped on the way back in. + + Rust mirror: `gssx_to_name` in `rust/gssproxy-server/src/conv.rs` does the same + (display_name → `Name::import`, else `Name::import_exported`). + +3. **Import RPC handler - explicit TODOs.** `gp_rpc_import_and_canon_name.c` + carries the smoking gun: + + ```c + /* TODO: check also icna->input_name.exported_composite_name */ + /* TODO: icna->name_attributes */ + ``` + + i.e. the daemon's import-and-canonicalize entry never honors a + composite-exported input name or incoming attributes. + +Net effect: even though `gssi_export_name_composite` *could* return a blob, a +client that then re-imported that blob (`GSS_C_NT_COMPOSITE_EXPORT`) through +gssproxy would get a name stripped of its attributes and of the authenticated/ +complete flags - defeating the entire purpose of the call. That is why the +interposer entry was disabled rather than shipped half-working. + +## What a fix would require + +To support composite names "under a different configuration", changes are needed +on **both** sides; doing only the interposer side reintroduces the original +half-working trap. + +Daemon (server) - the actual blocker: + +1. **Serialize attributes on export.** In `gp_conv_name_to_gssx` / + `name_to_gssx`, enumerate `gss_inquire_name` + `gss_get_name_attribute` and + fill `gssx_name.name_attributes` (mirrored in + `rust/gssproxy-server/src/conv.rs`). +2. **Honor the composite blob on import.** In `gp_conv_gssx_to_name` / + `gssx_to_name`, when `exported_composite_name` is present, import it with + `GSS_C_NT_COMPOSITE_EXPORT` (preferring it over, or in addition to, + `exported_name`). +3. **Re-apply attributes on import.** In `gp_rpc_import_and_canon_name.c` + (and the Rust `handlers::import_and_canon_name`), resolve the two TODOs: + consume `input_name.exported_composite_name` and re-apply + `input_name.name_attributes` via `gss_set_name_attribute` on the reconstructed + name. +4. **Validate symmetry.** Confirm MIT krb5's `gss_import_name` with + `GSS_C_NT_COMPOSITE_EXPORT` accepts the blob produced by the server's + `gss_export_name_composite` for the deployed krb5 version (historically this + asymmetry is exactly why upstream disabled the feature). + +Interposer (this crate) - straightforward once the daemon round-trips: + +5. Add `gssi_export_name_composite` to [`names.rs`](./names.rs), dispatching like + the other name ops: local name → real `gss_export_name_composite`; remote name + → `gpm::export_name_composite` (a thin wrapper over the cached + `exported_composite_name`, matching `gpm_export_name`). Tolerate + `GSS_S_NAME_NOT_MN` / `GSS_S_UNAVAILABLE` like `export_composite()` already + does in `rust/gssapi-sys/src/wrap.rs`. +6. Re-enable the interposer composite test (the `#if 0` block in + `tests/interposetest.c`) and add it to the Nix integration matrix. + +## Decision / current stance + +Until the daemon round trip (steps 1–4) is implemented and validated against the +target krb5, the interposer entry stays **unexported** in both the C and Rust +builds. This is the correct, parity-preserving choice: omitting `gssi_export_name_composite` +makes the mechglue route `gss_export_name_composite()` to the local krb5 +mechanism, which is well-defined behavior, rather than shipping a proxied path +whose results cannot be imported back faithfully. + +## References + +- RFC 6680, GSS-API Naming Extensions, §7.8 `GSS_Export_name_composite()` and §8 + (IANA: `gss-composite-export` = `1.3.6.1.5.6.6`). + +- MIT krb5 GSSAPI mechanism/interposer interface: + +- MIT krb5 dispatch table (`krb5_gss_export_name_composite`): + `src/lib/gssapi/krb5/gssapi_krb5.c`; signature in + `src/lib/gssapi/generic/gssapi_ext.h`. +- Heimdal declaration + OID: `lib/gssapi/gssapi/gssapi.h` + (`__gss_c_nt_composite_export_oid_desc`). +- In-tree: `src/gp_conv.c` (`gp_conv_name_to_gssx`, `gp_conv_gssx_to_name`), + `src/gp_rpc_import_and_canon_name.c` (TODOs), + `src/mechglue/gpp_import_and_canon_name.c` (`#if 0` wrapper), + `src/client/gpm_import_and_canon_name.c` (`gpm_export_name_composite`), + `rust/gssproxy-server/src/conv.rs`, `rust/gssapi-sys/src/wrap.rs` + (`export_composite`), `rust/gssapi-sys/src/consts.rs` (`NT_COMPOSITE_EXPORT_OID`). diff --git a/rust/docs/formal-verification.md b/rust/docs/formal-verification.md new file mode 100644 index 000000000..f954d3c62 --- /dev/null +++ b/rust/docs/formal-verification.md @@ -0,0 +1,140 @@ +# Formal verification of the Rust port: Creusot vs Kani + +This document evaluates two Rust verification tools - [Creusot](https://creusot.rs) +and [Kani](https://github.com/model-checking/kani) - for **documenting and proving +the safety invariants of the unsafe code** in this workspace, records the decision, +and describes how the chosen tooling is wired into the project. + +## The problem: an FFI-dominated unsafe surface + +The Rust port is a drop-in replacement for a C GSSAPI daemon and its +`proxymech.so` interposer, so it is unavoidably unsafe-heavy. There are ~225 +`unsafe` sites, concentrated in: + +| Area | Nature of the unsafe | +| --- | --- | +| `gssapi-sys/src/wrap.rs` (53), `seal.rs`, `ccache.rs` | Calls into MIT `gss_*` / `krb5_*` C functions; marshal `gss_buffer_desc`, OID sets | +| `gssproxy-interposer/*` (`creds.rs`, `names.rs`, `handle.rs`, `convert.rs`, `msgprot.rs`, `ctxlife.rs`, `special.rs`, `mechstatus.rs`) | ~125 `#[no_mangle] extern "C"` `gssi_*` entry points the mechglue calls; raw `gss_*` pointer/handle marshaling; `Box::into_raw`/`from_raw` handle round-trips | +| `gssproxy-client/src/lib.rs` | RPC client FFI glue | +| `gssproxy-proto/*` | **No FFI** - pure XDR/gssx wire codec (the one large body of self-contained, security-critical logic) | + +The defining characteristic is that **most unsafe blocks are FFI calls** whose +real behavior lives in the external MIT krb5/GSSAPI C libraries. No Rust-level +tool can see across that boundary; both tools can only reason about the Rust +side (the pointer/length/lifetime contracts we must uphold *around* each call), +and about the pure Rust logic that never crosses into C. + +## Tool comparison + +| Criterion | Kani | Creusot | +| --- | --- | --- | +| Technique | Bit-precise bounded model checking (CBMC backend) over MIR | Deductive verification: contracts (`#[requires]`/`#[ensures]`/invariants) → Why3 → SMT | +| What it proves | No panic / no UB / no overflow / no OOB / termination, for **all** inputs within given bounds | Full functional correctness + absence of panics/overflow/UB, unbounded, relative to a spec | +| Unsafe / raw pointers | First-class; designed for unsafe Rust | Supported via linear ghost `Perm` tokens ([2026 work](https://creusot.rs)); high annotation cost | +| FFI (`extern "C"`) | **Not supported** - "call to foreign C function … is not currently supported" ([kani#2423](https://github.com/model-checking/kani/issues/2423)); needs experimental [`#[kani::stub]`](https://model-checking.github.io/kani/reference/experimental/stubbing.html) mocks | Cannot model external C behavior; foreign functions must be axiomatized as `trusted` | +| Proof authoring effort | Low - harnesses look like tests (`kani::any()`, asserts) | High - contracts + loop invariants + ghost code, per function | +| Annotation burden on prod code | None (harnesses are `#[cfg(kani)]`, separate) | Invasive - specs/ghost args woven into the code under test | +| CI integration | Turnkey [`model-checking/kani-github-action`](https://github.com/model-checking/kani-github-action), SARIF → Code Scanning | No first-class action; needs Why3 + SMT solvers (Z3/CVC5/Alt-Ergo) installed | +| Nix story | Painful: not in nixpkgs ([request closed](https://github.com/NixOS/nixpkgs/issues/394161)), nightly toolchain, downloads CBMC into `KANI_HOME`; usually run via `buildFHSEnv` or the GH Action | Also nightly-pinned + solver stack; heavier | +| Toolchain stability | Pinned Kani release; stable Rust harnesses | Tied to a specific rustc nightly | + +## Decision + +**Adopt Kani now, scoped to the FFI-free logic; document Creusot as future work.** + +Rationale: + +1. The single highest-value, self-contained target is the **gssx wire codec** + (`gssproxy-proto`): security-critical (it parses attacker-influenced bytes off + a socket), pure Rust, and already exercised by the chaos/property fuzzers in + `gssproxy-proto/tests/proptest_proto.rs`. Kani upgrades those fuzz properties + to **proofs over all bounded inputs** with near-zero authoring cost and no + changes to production code. +2. Kani's FFI limitation is not a blocker for that target (no `gss_*` is + reachable), and we deliberately keep harnesses on the FFI boundary out of + scope - those invariants are documented (see below) and exercised by the + integration suite, not model-checked, because every such harness would + require hand-written `#[kani::stub]` mocks of the krb5/GSSAPI C surface. +3. Kani's GitHub Action makes CI trivial; the Nix friction is sidestepped by + running Kani via the Action in CI and a `buildFHSEnv` wrapper locally + (`nix run .#kani`), keeping it out of `nix flake check`. +4. Creusot's higher ceiling (functional-correctness proofs) does not pay for its + invasive annotation burden here, where the dominant risk is *memory safety + around FFI* rather than algorithmic correctness of pure functions. It is + recorded as a future option, most plausibly for the codec's round-trip + fidelity spec, should we want unbounded guarantees. + +## What we verify with Kani + +Harnesses live under `#[cfg(kani)]` (compiled only with `cargo kani`, never in +normal builds), alongside the code: + +- `gssproxy-proto/src/verification.rs` - the codec: + - XDR primitive round-trips (`u32`/`u64`/`bool`): `decode(encode(v)) == v` and + the decoder consumes exactly the produced bytes. + - `Opaque`/`xdr_bytes` decode over arbitrary bounded byte streams: never + panics, never over-allocates (length is validated against remaining bytes + before any copy), and accepted values re-encode self-consistently. + - `frame::parse_header`/`encode_header`: the fragment-bit/size-cap contract + holds for every 32-bit word, and every in-range body length round-trips. +- `gssproxy-interposer/src/oids.rs` (`#[cfg(kani)]`) - pure OID helpers: + - `oid_equal` is exactly byte-equality (and reflexive). + - `is_krb5_oid` is true iff the OID bytes are one of the four known + krb5/iakerb OIDs. + +These mirror the invariants asserted probabilistically in +`tests/proptest_proto.rs` and `oids.rs`'s `prop_tests`, but prove them +exhaustively within the input bounds. + +## Running Kani + +CI: the [`Kani` workflow](../../.github/workflows/kani.yaml) runs +`cargo kani` via `model-checking/kani-github-action@v1.1` (pinned to Kani +`0.67.0`) on every push/PR. The job's pass/fail status is the gate - `cargo +kani` exits non-zero if any harness fails to verify. + +> Note: SARIF / GitHub Code-Scanning upload is intentionally not wired up yet. +> The `--sarif` flag is documented upstream but is only present in unreleased +> Kani `main`, not in the latest release (`0.67.0`); passing it makes +> `cargo kani` error out. Once a release ships `--sarif`, add it to the workflow +> args and re-add a `github/codeql-action/upload-sarif` step (which will need +> `permissions: security-events: write`). + +Locally (Kani is not packaged in nixpkgs, so we use an FHS sandbox that installs +the pinned Kani release into a project-local `KANI_HOME` on first use): + +```sh +# Enter the FHS shell with rustup/cargo/CBMC deps, then run Kani: +nix run .#kani -- -p gssproxy-proto +nix run .#kani -- -p gssproxy-interposer +``` + +The first invocation runs `cargo install --locked kani-verifier && cargo kani +setup`, which downloads the CBMC toolchain; subsequent runs are fast. This is +intentionally **not** part of `nix flake check` (which stays hermetic). + +## Documenting unsafe invariants (`# Safety` convention) + +Independent of proving, every unsafe item documents its contract: + +- Every `pub unsafe fn` carries a `# Safety` doc section stating the caller's + obligations (pointer validity, length, ownership transfer). This is already + enforced for public items by clippy's `missing_safety_doc`, which is denied + via the `-D warnings` clippy gate. +- Every `unsafe { ... }` block should carry a `// SAFETY:` comment explaining + why the operation is sound at that site. + +Optional follow-up (not enabled here, since it is a large mechanical change): +turn on `#![warn(clippy::undocumented_unsafe_blocks)]` workspace-wide to require +a `// SAFETY:` comment on *every* unsafe block, then backfill comments. + +## Future work + +- Extend Kani harnesses to more codec types (`proc::Arg*`/`Res*`) via + `kani::Arbitrary` derives with bounded collections. +- Evaluate `#[kani::stub]` mocks for a few high-risk FFI marshalers + (e.g. `convert::read_buffer`, handle `Box` round-trips) if memory-safety bugs + are suspected there. +- Consider a Creusot pilot on `xdr.rs` round-trip fidelity for an unbounded + functional-correctness proof, if the bounded Kani guarantees prove + insufficient. diff --git a/rust/gssapi-sys/Cargo.toml b/rust/gssapi-sys/Cargo.toml new file mode 100644 index 000000000..f29ef7b6b --- /dev/null +++ b/rust/gssapi-sys/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "gssapi-sys" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Safe-ish FFI bindings to the system GSSAPI/krb5 needed by gssproxy" + +[dependencies] +libc.workspace = true +# Raw, bindgen-generated bindings to the system GSSAPI (MIT krb5). We add only +# the gssproxy-specific OID constants, computed GSS_S_*/GSS_C_* values (bindgen +# drops computed macros), and safe RAII wrappers on top. +libgssapi-sys = "0.3" + +[build-dependencies] +# libgssapi-sys binds only the gssapi headers; the credential sealing layer +# (gp_export.c) needs the krb5 crypto/keytab API, so we generate our own krb5 +# bindings here and link libkrb5. 0.71 for `rust_edition` (emits the edition-2024 +# `unsafe extern "C"` blocks); matches the bindgen libgssapi-sys pulls in. +bindgen = "0.71.1" +pkg-config = "0.3" diff --git a/rust/gssapi-sys/build.rs b/rust/gssapi-sys/build.rs new file mode 100644 index 000000000..ddc161097 --- /dev/null +++ b/rust/gssapi-sys/build.rs @@ -0,0 +1,97 @@ +//! Generate krb5 crypto/keytab bindings and link libkrb5. +//! +//! `libgssapi-sys` only binds the gssapi headers, but the credential sealing +//! layer (a port of `src/gp_export.c`) needs the low-level krb5 crypto and +//! keytab API. We generate a tight, allowlisted binding for just those symbols. + +use std::env; +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-changed=krb5_wrapper.h"); + + // Locate krb5 (include path + link flags) via pkg-config; this also emits + // the cargo:rustc-link-lib lines needed to resolve krb5_c_* / krb5_kt_*. + let lib = pkg_config::Config::new() + .probe("krb5") + .expect("pkg-config could not find krb5"); + + let mut builder = bindgen::Builder::default() + // The crate is edition 2024, where `extern "C"` blocks must be marked + // `unsafe`. bindgen 0.72's default rust-target (1.82) already emits + // `unsafe extern "C"` (the unsafe-extern-blocks feature stabilised in + // 1.82), which compiles cleanly here. We deliberately do NOT call + // `.rust_edition(Edition2024)`: that requires a rust-target >= 1.85, + // which bindgen 0.72.1 does not know about (its newest target is 1.82), + // so setting it makes `generate()` fail with `UnsupportedEdition`. + .header("krb5_wrapper.h") + .allowlist_function("krb5_init_context") + .allowlist_function("krb5_free_context") + .allowlist_function("krb5_cc_resolve") + .allowlist_function("krb5_cc_destroy") + .allowlist_function("krb5_cc_close") + .allowlist_function("krb5_cc_default") + .allowlist_function("krb5_cc_default_name") + .allowlist_function("krb5_cc_set_default_name") + .allowlist_function("krb5_cc_initialize") + .allowlist_function("krb5_cc_store_cred") + .allowlist_function("krb5_cc_cache_match") + .allowlist_function("krb5_cc_new_unique") + .allowlist_function("krb5_cc_switch") + .allowlist_function("krb5_cc_get_principal") + .allowlist_function("krb5_cc_retrieve_cred") + .allowlist_function("krb5_parse_name") + .allowlist_function("krb5_free_principal") + .allowlist_function("krb5_free_cred_contents") + .allowlist_function("krb5_kt_resolve") + .allowlist_function("krb5_kt_default") + .allowlist_function("krb5_kt_default_name") + .allowlist_function("krb5_kt_have_content") + .allowlist_function("krb5_kt_close") + .allowlist_function("krb5_kt_start_seq_get") + .allowlist_function("krb5_kt_next_entry") + .allowlist_function("krb5_kt_end_seq_get") + .allowlist_function("krb5_free_keytab_entry_contents") + .allowlist_function("krb5_get_permitted_enctypes") + .allowlist_function("krb5_free_enctypes") + .allowlist_function("krb5_copy_keyblock") + .allowlist_function("krb5_free_keyblock") + .allowlist_function("krb5_init_keyblock") + .allowlist_function("krb5_c_make_random_key") + .allowlist_function("krb5_c_encrypt_length") + .allowlist_function("krb5_c_encrypt") + .allowlist_function("krb5_c_decrypt") + .allowlist_type("krb5_keyblock") + .allowlist_type("krb5_data") + .allowlist_type("krb5_enc_data") + .allowlist_type("krb5_keytab_entry") + .allowlist_type("krb5_creds") + .allowlist_type("krb5_principal") + .allowlist_type("krb5_context") + .allowlist_type("krb5_ccache") + .allowlist_type("krb5_keytab") + .allowlist_type("krb5_kt_cursor") + .allowlist_type("krb5_enctype") + .allowlist_type("krb5_error_code") + .allowlist_var("ENCTYPE_AES256_CTS_HMAC_SHA1_96") + .allowlist_var("KRB5_KEYUSAGE_APP_DATA_ENCRYPT") + .allowlist_var("KRB5_KT_END") + .allowlist_var("KRB5_CC_NOTFOUND") + .allowlist_var("KRB5_WRONG_ETYPE") + .allowlist_var("MAX_KEYTAB_NAME_LEN") + .layout_tests(false) + .generate_comments(false); + + for path in &lib.include_paths { + builder = builder.clang_arg(format!("-I{}", path.display())); + } + + let bindings = builder + .generate() + .expect("failed to generate krb5 bindings"); + + let out = PathBuf::from(env::var("OUT_DIR").unwrap()); + bindings + .write_to_file(out.join("krb5_bindings.rs")) + .expect("failed to write krb5 bindings"); +} diff --git a/rust/gssapi-sys/krb5_wrapper.h b/rust/gssapi-sys/krb5_wrapper.h new file mode 100644 index 000000000..919a460fc --- /dev/null +++ b/rust/gssapi-sys/krb5_wrapper.h @@ -0,0 +1 @@ +#include diff --git a/rust/gssapi-sys/src/ccache.rs b/rust/gssapi-sys/src/ccache.rs new file mode 100644 index 000000000..8ce33821e --- /dev/null +++ b/rust/gssapi-sys/src/ccache.rs @@ -0,0 +1,228 @@ +//! krb5 credential-cache helpers for the interposer's cred-sync path. +//! +//! Port of the ccache machinery in `src/mechglue/gpp_creds.c` +//! (`gpp_store_remote_creds` / `gppint_retrieve_remote_creds`). gssproxy stashes +//! a remote `gssx_cred` in a local krb5 ccache as the `ticket` blob of a krb5 +//! credential whose server principal is the well-known [`GPKRB_SRV_NAME`], keyed +//! by the credential's client (display) name. +//! +//! These helpers operate on the already-XDR-encoded `gssx_cred` bytes so this +//! crate stays free of a `gssproxy-proto` dependency; the interposer does the +//! `gssx_cred` <-> bytes conversion. + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +use crate::krb5; + +/// The well-known server principal under which a sealed `gssx_cred` blob is +/// stashed (`GPKRB_SRV_NAME` in `src/gp_common.h`). +const GPKRB_SRV_NAME: &str = "Encrypted/Credentials/v1@X-GSSPROXY:"; + +/// Maximum size of the encoded credential blob (`GPKRB_MAX_CRED_SIZE`). +const GPKRB_MAX_CRED_SIZE: usize = 1024 * 512; + +/// A krb5 error code (or a synthetic errno) describing a ccache failure. +pub type CcResult = std::result::Result; + +/// Run `f` with an initialised krb5 context, freeing it afterwards. +unsafe fn with_context(f: impl FnOnce(krb5::krb5_context) -> CcResult) -> CcResult { + unsafe { + let mut ctx: krb5::krb5_context = ptr::null_mut(); + let ret = krb5::krb5_init_context(&mut ctx); + if ret != 0 { + return Err(ret as i32); + } + let out = f(ctx); + krb5::krb5_free_context(ctx); + out + } +} + +/// `gpp_store_remote_creds`: persist the encoded `gssx_cred` blob `ticket` into +/// a local krb5 ccache, keyed by the client principal `client_name`. +/// +/// `cred_store` mirrors the `gss_key_value_set` the caller configured (only the +/// `ccache` entry is honoured, as in C). When `store_as_default` is set the +/// resulting ccache is switched to be the collection default. +pub fn store_remote_cred( + cred_store: &[(String, String)], + client_name: &[u8], + ticket: &[u8], + store_as_default: bool, +) -> CcResult<()> { + if ticket.len() > GPKRB_MAX_CRED_SIZE { + return Err(libc::ENOSPC); + } + let client_c = CString::new(client_name).map_err(|_| libc::EINVAL)?; + let server_c = CString::new(GPKRB_SRV_NAME).unwrap(); + + unsafe { + with_context(|ctx| { + let mut ccache: krb5::krb5_ccache = ptr::null_mut(); + // Build the krb5_creds carrying our blob (gpp_construct_cred). + let mut cred: krb5::krb5_creds = std::mem::zeroed(); + let mut ret = krb5::krb5_parse_name(ctx, client_c.as_ptr(), &mut cred.client); + if ret == 0 { + ret = krb5::krb5_parse_name(ctx, server_c.as_ptr(), &mut cred.server); + } + if ret == 0 { + // ticket.data must be malloc'd so krb5_free_cred_contents frees it. + let data = libc::malloc(ticket.len().max(1)) as *mut c_char; + if data.is_null() { + ret = libc::ENOMEM; + } else { + ptr::copy_nonoverlapping(ticket.as_ptr(), data as *mut u8, ticket.len()); + cred.ticket.data = data; + cred.ticket.length = ticket.len() as _; + } + } + + if ret == 0 { + // Point the context's default ccache name at the configured store. + for (k, v) in cred_store { + if k == "ccache" { + if let Ok(cv) = CString::new(v.as_bytes()) { + ret = krb5::krb5_cc_set_default_name(ctx, cv.as_ptr()); + } else { + ret = libc::EINVAL; + } + break; + } + } + } + + if ret == 0 { + ret = store_into_ccache(ctx, &mut cred, &mut ccache, store_as_default); + } + + krb5::krb5_free_cred_contents(ctx, &mut cred); + if !ccache.is_null() { + krb5::krb5_cc_close(ctx, ccache); + } + if ret == 0 { Ok(()) } else { Err(ret as i32) } + }) + } +} + +/// The collection-vs-FILE store logic factored out of [`store_remote_cred`]. +unsafe fn store_into_ccache( + ctx: krb5::krb5_context, + cred: &mut krb5::krb5_creds, + ccache: &mut krb5::krb5_ccache, + store_as_default: bool, +) -> krb5::krb5_error_code { + unsafe { + let cc_name_ptr = krb5::krb5_cc_default_name(ctx); + if cc_name_ptr.is_null() { + return libc::ENOMEM as krb5::krb5_error_code; + } + let cc_name = CStr::from_ptr(cc_name_ptr).to_bytes().to_vec(); + + let is_file = cc_name.starts_with(b"FILE:") || !cc_name.contains(&b':'); + if is_file { + // FILE ccaches blackhole same-principal updates: reinitialise. + let mut ret = krb5::krb5_cc_default(ctx, ccache); + if ret == 0 { + ret = krb5::krb5_cc_initialize(ctx, *ccache, cred.client); + } + if ret == 0 { + ret = krb5::krb5_cc_store_cred(ctx, *ccache, cred); + } + return ret; + } + + let mut ret = krb5::krb5_cc_cache_match(ctx, cred.client, ccache); + if ret == krb5::KRB5_CC_NOTFOUND as krb5::krb5_error_code { + // New ccache in the collection; krb5_cc_new_unique takes only the type. + let colon = cc_name + .iter() + .position(|&b| b == b':') + .unwrap_or(cc_name.len()); + let cc_type = match CString::new(&cc_name[..colon]) { + Ok(t) => t, + Err(_) => return libc::ENOMEM as krb5::krb5_error_code, + }; + ret = krb5::krb5_cc_new_unique(ctx, cc_type.as_ptr(), ptr::null(), ccache); + if ret == 0 { + ret = krb5::krb5_cc_initialize(ctx, *ccache, cred.client); + } + } + if ret != 0 { + return ret; + } + + ret = krb5::krb5_cc_store_cred(ctx, *ccache, cred); + if ret != 0 { + return ret; + } + + if store_as_default { + ret = krb5::krb5_cc_switch(ctx, *ccache); + } + ret + } +} + +/// `gppint_retrieve_remote_creds`: fetch the encoded `gssx_cred` blob stashed +/// under [`GPKRB_SRV_NAME`] for `client_name` (or the ccache's default +/// principal when `None`). Returns the raw ticket bytes (the XDR `gssx_cred`). +pub fn retrieve_remote_cred( + ccache_name: Option<&str>, + client_name: Option<&[u8]>, +) -> CcResult> { + let server_c = CString::new(GPKRB_SRV_NAME).unwrap(); + let ccache_c = match ccache_name { + Some(n) => Some(CString::new(n).map_err(|_| libc::EINVAL)?), + None => None, + }; + let client_c = match client_name { + Some(n) => Some(CString::new(n).map_err(|_| libc::EINVAL)?), + None => None, + }; + + unsafe { + with_context(|ctx| { + let mut ccache: krb5::krb5_ccache = ptr::null_mut(); + let mut cred: krb5::krb5_creds = std::mem::zeroed(); + let mut icred: krb5::krb5_creds = std::mem::zeroed(); + + let mut ret = match &ccache_c { + Some(c) => krb5::krb5_cc_resolve(ctx, c.as_ptr(), &mut ccache), + None => krb5::krb5_cc_default(ctx, &mut ccache), + }; + + if ret == 0 { + ret = match &client_c { + Some(c) => krb5::krb5_parse_name(ctx, c.as_ptr(), &mut icred.client), + None => krb5::krb5_cc_get_principal(ctx, ccache, &mut icred.client), + }; + } + if ret == 0 { + ret = krb5::krb5_parse_name(ctx, server_c.as_ptr(), &mut icred.server); + } + if ret == 0 { + ret = krb5::krb5_cc_retrieve_cred(ctx, ccache, 0, &mut icred, &mut cred); + } + + let out = if ret == 0 { + let len = cred.ticket.length as usize; + if cred.ticket.data.is_null() || len == 0 { + Err(libc::EIO) + } else { + Ok(std::slice::from_raw_parts(cred.ticket.data as *const u8, len).to_vec()) + } + } else { + Err(ret as i32) + }; + + krb5::krb5_free_cred_contents(ctx, &mut cred); + krb5::krb5_free_cred_contents(ctx, &mut icred); + if !ccache.is_null() { + krb5::krb5_cc_close(ctx, ccache); + } + out + }) + } +} diff --git a/rust/gssapi-sys/src/consts.rs b/rust/gssapi-sys/src/consts.rs new file mode 100644 index 000000000..605fcfba5 --- /dev/null +++ b/rust/gssapi-sys/src/consts.rs @@ -0,0 +1,102 @@ +//! gssproxy-specific GSSAPI constants that `libgssapi-sys` does not provide. +//! +//! Everything `libgssapi-sys` already exports as a proper bindgen item - the +//! request/return flag bits (`GSS_C_DELEG_FLAG`…), credential usage +//! (`GSS_C_BOTH/INITIATE/ACCEPT`), status selectors (`GSS_C_GSS_CODE`, +//! `GSS_C_MECH_CODE`), the error/supplementary offsets, `GSS_S_COMPLETE`, the +//! supplementary token bits, `GSS_C_INDEFINITE`, `GSS_C_QOP_DEFAULT` - should be +//! used directly via [`crate::sys`]. This module adds only what bindgen cannot +//! emit: the computed shifted routine-error codes, and the well-known OID byte +//! strings (which the sys crate exposes as runtime symbols, not byte arrays). + +#![allow(non_upper_case_globals)] + +use libgssapi_sys::{GSS_C_CALLING_ERROR_OFFSET, GSS_C_ROUTINE_ERROR_OFFSET, OM_uint32}; + +// bindgen does not expand the computed `GSS_S_*` routine-error macros (only the +// raw `_GSS_S_*` bases), so we recompute the ones gssproxy synthesizes itself. +const fn routine_error(n: OM_uint32) -> OM_uint32 { + n << GSS_C_ROUTINE_ERROR_OFFSET +} + +const fn calling_error(n: OM_uint32) -> OM_uint32 { + n << GSS_C_CALLING_ERROR_OFFSET +} + +pub const GSS_S_CALL_INACCESSIBLE_READ: OM_uint32 = calling_error(1); +pub const GSS_S_CALL_INACCESSIBLE_WRITE: OM_uint32 = calling_error(2); +pub const GSS_S_CALL_BAD_STRUCTURE: OM_uint32 = calling_error(3); + +pub const GSS_S_BAD_NAME: OM_uint32 = routine_error(2); +pub const GSS_S_BAD_STATUS: OM_uint32 = routine_error(5); + +pub const GSS_S_FAILURE: OM_uint32 = routine_error(13); +/// MIT defines `GSS_S_CRED_UNAVAIL` as `GSS_S_FAILURE`. +pub const GSS_S_CRED_UNAVAIL: OM_uint32 = GSS_S_FAILURE; +pub const GSS_S_NO_CRED: OM_uint32 = routine_error(7); +pub const GSS_S_DEFECTIVE_CREDENTIAL: OM_uint32 = routine_error(10); +pub const GSS_S_CREDENTIALS_EXPIRED: OM_uint32 = routine_error(11); +pub const GSS_S_CONTEXT_EXPIRED: OM_uint32 = routine_error(12); +pub const GSS_S_NO_CONTEXT: OM_uint32 = routine_error(8); +pub const GSS_S_DEFECTIVE_TOKEN: OM_uint32 = routine_error(9); +pub const GSS_S_BAD_MECH: OM_uint32 = routine_error(1); +pub const GSS_S_UNAVAILABLE: OM_uint32 = routine_error(16); +pub const GSS_S_NAME_NOT_MN: OM_uint32 = routine_error(18); +pub const GSS_S_UNAUTHORIZED: OM_uint32 = routine_error(15); + +/// The krb5 mechanism OID: 1.2.840.113554.1.2.2 +pub const KRB5_MECH_OID: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x02]; + +/// The deprecated/old krb5 mech OID: 1.3.5.1.5.2 +pub const KRB5_OLD_MECH_OID: &[u8] = &[0x2b, 0x05, 0x01, 0x05, 0x02]; + +/// Microsoft's incorrectly emitted krb5 OID. +pub const KRB5_WRONG_MECH_OID: &[u8] = &[0x2a, 0x86, 0x48, 0x82, 0xf7, 0x12, 0x01, 0x02, 0x02]; + +/// IAKERB OID: 1.3.6.1.5.2.5 +pub const IAKERB_MECH_OID: &[u8] = &[0x2b, 0x06, 0x01, 0x05, 0x02, 0x05]; + +/// The gssproxy interposer mech OID 2.16.840.1.113730.3.8.15.1. +pub const GSSPROXY_INTERPOSER_OID: &[u8] = &[ + 0x60, 0x86, 0x48, 0x01, 0x86, 0xf8, 0x42, 0x03, 0x08, 0x0f, 0x01, +]; + +/// `GSS_C_NT_USER_NAME` OID: 1.2.840.113554.1.2.1.1 +pub const NT_USER_NAME_OID: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x01, 0x01]; + +/// `GSS_C_NT_HOSTBASED_SERVICE` OID: 1.2.840.113554.1.2.1.4 +pub const NT_HOSTBASED_SERVICE_OID: &[u8] = + &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x01, 0x04]; + +/// `GSS_C_NT_EXPORT_NAME` OID: 1.3.6.1.5.6.4 +pub const NT_EXPORT_NAME_OID: &[u8] = &[0x2b, 0x06, 0x01, 0x05, 0x06, 0x04]; + +/// `GSS_C_NT_HOSTBASED_SERVICE_X` OID: 1.3.6.1.5.6.2 +pub const NT_HOSTBASED_SERVICE_X_OID: &[u8] = &[0x2b, 0x06, 0x01, 0x05, 0x06, 0x02]; + +/// `GSS_C_NT_ANONYMOUS` OID: 1.3.6.1.5.6.3 +pub const NT_ANONYMOUS_OID: &[u8] = &[0x2b, 0x06, 0x01, 0x05, 0x06, 0x03]; + +/// `GSS_C_NT_COMPOSITE_EXPORT` OID: 1.3.6.1.5.6.6 +pub const NT_COMPOSITE_EXPORT_OID: &[u8] = &[0x2b, 0x06, 0x01, 0x05, 0x06, 0x06]; + +/// `GSS_C_NT_MACHINE_UID_NAME` OID: 1.2.840.113554.1.2.1.2 +pub const NT_MACHINE_UID_NAME_OID: &[u8] = + &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x01, 0x02]; + +/// `GSS_C_NT_STRING_UID_NAME` OID: 1.2.840.113554.1.2.1.3 +pub const NT_STRING_UID_NAME_OID: &[u8] = + &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x01, 0x03]; + +/// `GSS_KRB5_NT_PRINCIPAL_NAME` OID: 1.2.840.113554.1.2.2.1 +pub const KRB5_NT_PRINCIPAL_NAME_OID: &[u8] = + &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x02, 0x01]; + +/// `GSS_KRB5_GET_CRED_IMPERSONATOR` OID: 1.2.840.113554.1.2.2.5.14. +/// +/// Passed to `gss_inquire_cred_by_oid` to retrieve the principal that +/// impersonated the credential (constrained-delegation marker), matching the +/// hard-coded `impersonator_oid` fallback in `gp_creds.c`. +pub const KRB5_GET_CRED_IMPERSONATOR_OID: &[u8] = &[ + 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x02, 0x05, 0x0e, +]; diff --git a/rust/gssapi-sys/src/krb5.rs b/rust/gssapi-sys/src/krb5.rs new file mode 100644 index 000000000..79b62fafb --- /dev/null +++ b/rust/gssapi-sys/src/krb5.rs @@ -0,0 +1,9 @@ +//! Raw bindgen bindings for the subset of the krb5 crypto/keytab API used by +//! the credential sealing layer (see [`crate::seal`]). Generated by `build.rs`. + +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(dead_code)] + +include!(concat!(env!("OUT_DIR"), "/krb5_bindings.rs")); diff --git a/rust/gssapi-sys/src/lib.rs b/rust/gssapi-sys/src/lib.rs new file mode 100644 index 000000000..07a82bb9d --- /dev/null +++ b/rust/gssapi-sys/src/lib.rs @@ -0,0 +1,19 @@ +//! GSSAPI/krb5 FFI surface used by gssproxy. +//! +//! The raw bindings come from the maintained, bindgen-generated `libgssapi-sys` +//! crate (re-exported here as [`sys`]), so we don't hand-maintain hundreds of +//! `extern "C"` declarations and C struct layouts. This crate adds only what +//! that crate cannot provide: +//! +//! * gssproxy-specific and well-known OID byte strings ([`consts`]), +//! * the computed `GSS_S_*` / `GSS_C_*` values bindgen omits (it does not +//! expand computed `#define` macros), +//! * thin safe wrappers we layer on top as the daemon/interposer need them. + +pub use libgssapi_sys as sys; + +pub mod ccache; +pub mod consts; +pub mod krb5; +pub mod seal; +pub mod wrap; diff --git a/rust/gssapi-sys/src/seal.rs b/rust/gssapi-sys/src/seal.rs new file mode 100644 index 000000000..a9f6e3e8d --- /dev/null +++ b/rust/gssapi-sys/src/seal.rs @@ -0,0 +1,371 @@ +//! Credential-handle sealing, ported from `gp_encrypt_buffer` / +//! `gp_decrypt_buffer` / `gp_init_creds_handle` in `src/gp_export.c`. +//! +//! gssproxy hands the client an opaque, encrypted blob ("cred_handle_reference") +//! instead of a live credential, so the daemon can stay stateless. The blob is +//! the `gss_export_cred` token sealed with a per-service key (the first usable +//! key from the service keytab, or an ephemeral random AES256 key) using +//! `krb5_c_encrypt` with the `APP_DATA_ENCRYPT` key usage. +//! +//! We only need the blob to be self-consistent within a daemon's lifetime (the +//! client round-trips it back to the same daemon), so the contents need not be +//! byte-identical to the C daemon. We keep the same algorithm anyway, including +//! the explicit padding dance that works around `krb5_c_decrypt` padding for +//! some enctypes. +//! +//! The handle stores only the raw key bytes + enctype; each seal/unseal spins up +//! a short-lived `krb5_context` so the type is `Send`/`Sync` and safe to share +//! across the daemon's blocking worker threads (a `krb5_context` must not be used +//! concurrently from multiple threads). + +use std::ffi::CString; +use std::os::raw::{c_char, c_uint}; +use std::ptr; + +use crate::krb5::*; + +/// Minimum padding length (matches `ENC_MIN_PAD_LEN` in `src/gp_common.h`). +const ENC_MIN_PAD_LEN: u8 = 8; + +/// A krb5 error wrapped with the operation that produced it. +#[derive(Debug, Clone)] +pub struct SealError { + pub code: krb5_error_code, + pub what: &'static str, +} + +impl std::fmt::Display for SealError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} failed (krb5 error {})", self.what, self.code) + } +} + +impl std::error::Error for SealError {} + +type Result = std::result::Result; + +fn check(code: krb5_error_code, what: &'static str) -> Result<()> { + if code == 0 { + Ok(()) + } else { + Err(SealError { code, what }) + } +} + +/// A per-service sealing key. +#[derive(Debug, Clone)] +pub struct CredHandle { + enctype: krb5_enctype, + key: Vec, +} + +// The handle holds only plain bytes; no krb5 state is retained. +unsafe impl Send for CredHandle {} +unsafe impl Sync for CredHandle {} + +impl CredHandle { + /// Derive the sealing key for a service. Tries the given keytab (falling + /// back to the default keytab), and if no usable key is found, generates an + /// ephemeral random AES256 key - exactly the fallback order in + /// `gp_init_creds_handle`. + pub fn new(keytab: Option<&str>) -> Result { + unsafe { + let mut context: krb5_context = ptr::null_mut(); + check(krb5_init_context(&mut context), "krb5_init_context")?; + + let derived = derive_from_keytab(context, keytab).or_else(|| derive_ephemeral(context)); + + krb5_free_context(context); + + match derived { + Some((enctype, key)) => Ok(CredHandle { enctype, key }), + None => Err(SealError { + code: 0, + what: "key derivation", + }), + } + } + } + + /// Build a keyblock view over our stored key bytes for use in a crypto call. + fn keyblock(&self) -> krb5_keyblock { + let mut kb: krb5_keyblock = unsafe { std::mem::zeroed() }; + kb.enctype = self.enctype; + kb.length = self.key.len() as c_uint; + kb.contents = self.key.as_ptr() as *mut _; + kb + } + + /// Encrypt `plain`, returning the ciphertext blob. + pub fn seal(&self, plain: &[u8]) -> Result> { + unsafe { + let mut context: krb5_context = ptr::null_mut(); + check(krb5_init_context(&mut context), "krb5_init_context")?; + let res = self.seal_inner(context, plain); + krb5_free_context(context); + res + } + } + + unsafe fn seal_inner(&self, context: krb5_context, plain: &[u8]) -> Result> { + unsafe { + let kb = self.keyblock(); + let len = plain.len(); + + let mut cipherlen: usize = 0; + check( + krb5_c_encrypt_length(context, self.enctype, len, &mut cipherlen), + "krb5_c_encrypt_length", + )?; + let mut padcheck: usize = 0; + check( + krb5_c_encrypt_length(context, self.enctype, len + 1, &mut padcheck), + "krb5_c_encrypt_length", + )?; + + // Determine how much explicit padding is needed (see the long comment in + // gp_export.c): if adding one byte doesn't grow the ciphertext, the + // enctype pads internally and we must add our own deterministic padding. + let mut pad: u8 = 0; + if padcheck == cipherlen { + pad = ENC_MIN_PAD_LEN; + check( + krb5_c_encrypt_length( + context, + self.enctype, + len + pad as usize, + &mut cipherlen, + ), + "krb5_c_encrypt_length", + )?; + for i in 0..15usize { + check( + krb5_c_encrypt_length( + context, + self.enctype, + len + pad as usize + i + 1, + &mut padcheck, + ), + "krb5_c_encrypt_length", + )?; + if padcheck > cipherlen { + pad += i as u8; + break; + } + } + } + + let data_in: Vec = if pad != 0 { + let mut v = Vec::with_capacity(len + pad as usize); + v.extend_from_slice(plain); + v.extend(std::iter::repeat_n(pad, pad as usize)); + v + } else { + plain.to_vec() + }; + + let input = krb5_data { + magic: 0, + length: data_in.len() as c_uint, + data: data_in.as_ptr() as *mut c_char, + }; + + let mut ct = vec![0u8; cipherlen]; + let mut output: krb5_enc_data = std::mem::zeroed(); + output.ciphertext.length = cipherlen as c_uint; + output.ciphertext.data = ct.as_mut_ptr() as *mut c_char; + + check( + krb5_c_encrypt( + context, + &kb, + KRB5_KEYUSAGE_APP_DATA_ENCRYPT as krb5_keyusage, + ptr::null(), + &input, + &mut output, + ), + "krb5_c_encrypt", + )?; + + ct.truncate(output.ciphertext.length as usize); + Ok(ct) + } + } + + /// Decrypt a blob produced by [`CredHandle::seal`]. + pub fn unseal(&self, cipher: &[u8]) -> Result> { + unsafe { + let mut context: krb5_context = ptr::null_mut(); + check(krb5_init_context(&mut context), "krb5_init_context")?; + let res = self.unseal_inner(context, cipher); + krb5_free_context(context); + res + } + } + + unsafe fn unseal_inner(&self, context: krb5_context, cipher: &[u8]) -> Result> { + unsafe { + let kb = self.keyblock(); + + let mut enc: krb5_enc_data = std::mem::zeroed(); + enc.enctype = self.enctype; + enc.ciphertext.length = cipher.len() as c_uint; + enc.ciphertext.data = cipher.as_ptr() as *mut c_char; + + // The plaintext is no longer than the ciphertext; krb5_c_decrypt trims + // output.length to the real value. + let mut out = vec![0u8; cipher.len()]; + let mut data_out = krb5_data { + magic: 0, + length: out.len() as c_uint, + data: out.as_mut_ptr() as *mut c_char, + }; + + check( + krb5_c_decrypt( + context, + &kb, + KRB5_KEYUSAGE_APP_DATA_ENCRYPT as krb5_keyusage, + ptr::null(), + &enc, + &mut data_out, + ), + "krb5_c_decrypt", + )?; + + let mut length = data_out.length as usize; + // Strip our explicit padding (mirrors gp_decrypt_buffer): the last byte + // is the pad length; it is valid only if every padding byte equals it. + if length >= 1 { + let i = length - 1; + let pad = out[i]; + if pad >= ENC_MIN_PAD_LEN && (pad as usize) < i { + let all_match = (0..pad as usize).all(|j| out[i - j] == pad); + if all_match { + length -= pad as usize; + } + } + } + out.truncate(length); + Ok(out) + } + } +} + +/// Find the first keytab key whose enctype is permitted, returning its bytes. +unsafe fn derive_from_keytab( + context: krb5_context, + keytab: Option<&str>, +) -> Option<(krb5_enctype, Vec)> { + unsafe { + let mut ktid: krb5_keytab = ptr::null_mut(); + + let mut resolved = false; + if let Some(kt) = keytab + && let Ok(c) = CString::new(kt) + && krb5_kt_resolve(context, c.as_ptr(), &mut ktid) == 0 + { + resolved = true; + } + if !resolved && krb5_kt_default(context, &mut ktid) != 0 { + return None; + } + + if krb5_kt_have_content(context, ktid) != 0 { + krb5_kt_close(context, ktid); + return None; + } + + let mut permitted: *mut krb5_enctype = ptr::null_mut(); + if krb5_get_permitted_enctypes(context, &mut permitted) != 0 { + krb5_kt_close(context, ktid); + return None; + } + + let mut found: Option<(krb5_enctype, Vec)> = None; + let mut cursor: krb5_kt_cursor = ptr::null_mut(); + if krb5_kt_start_seq_get(context, ktid, &mut cursor) == 0 { + loop { + let mut entry: krb5_keytab_entry = std::mem::zeroed(); + if krb5_kt_next_entry(context, ktid, &mut entry, &mut cursor) != 0 { + break; + } + if found.is_none() { + let mut p = permitted; + while *p != 0 { + if *p == entry.key.enctype { + let bytes = std::slice::from_raw_parts( + entry.key.contents, + entry.key.length as usize, + ) + .to_vec(); + found = Some((entry.key.enctype, bytes)); + break; + } + p = p.add(1); + } + } + krb5_free_keytab_entry_contents(context, &mut entry); + if found.is_some() { + break; + } + } + krb5_kt_end_seq_get(context, ktid, &mut cursor); + } + + krb5_free_enctypes(context, permitted); + krb5_kt_close(context, ktid); + found + } +} + +/// Generate an ephemeral random AES256 key (the keytab fallback). +unsafe fn derive_ephemeral(context: krb5_context) -> Option<(krb5_enctype, Vec)> { + unsafe { + let enctype = ENCTYPE_AES256_CTS_HMAC_SHA1_96 as krb5_enctype; + let mut key: *mut krb5_keyblock = ptr::null_mut(); + if krb5_init_keyblock(context, enctype, 0, &mut key) != 0 { + return None; + } + if krb5_c_make_random_key(context, enctype, key) != 0 { + krb5_free_keyblock(context, key); + return None; + } + let kb = &*key; + let bytes = std::slice::from_raw_parts(kb.contents, kb.length as usize).to_vec(); + let et = kb.enctype; + krb5_free_keyblock(context, key); + Some((et, bytes)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seal_unseal_round_trip() { + // No keytab in the build sandbox, so this exercises the ephemeral-key + // fallback path plus the full encrypt/pad/decrypt/depad round trip. + let handle = CredHandle::new(None).expect("derive sealing key"); + + for len in [0usize, 1, 7, 8, 15, 16, 17, 31, 100, 1000] { + let plain: Vec = (0..len).map(|i| (i * 7 + 3) as u8).collect(); + let sealed = handle.seal(&plain).expect("seal"); + let opened = handle.unseal(&sealed).expect("unseal"); + assert_eq!(opened, plain, "round trip mismatch at len {len}"); + } + } + + #[test] + fn distinct_handles_do_not_share_keys() { + let a = CredHandle::new(None).expect("a"); + let b = CredHandle::new(None).expect("b"); + let sealed = a.seal(b"secret payload").expect("seal"); + // b has a different random key, so decryption must not yield the + // plaintext (it either errors or produces garbage). + if let Ok(p) = b.unseal(&sealed) { + assert_ne!(p, b"secret payload"); + } + } +} diff --git a/rust/gssapi-sys/src/wrap.rs b/rust/gssapi-sys/src/wrap.rs new file mode 100644 index 000000000..9870636ed --- /dev/null +++ b/rust/gssapi-sys/src/wrap.rs @@ -0,0 +1,1124 @@ +//! Thin, safe-ish RAII wrappers over the raw `libgssapi_sys` bindings. +//! +//! These exist because the high-level `libgssapi` crate deliberately hides the +//! raw handles, but gssproxy is a *proxy*: it must export/import security +//! contexts and credentials and reach the MIT SPIs. So we keep ownership of the +//! raw `gss_*_t` handles (Drop releases them) while still being able to hand the +//! raw pointer to any `sys::gss_*` call the daemon needs. + +use std::os::raw::{c_int, c_void}; +use std::ptr; +use std::slice; + +use libgssapi_sys as sys; +use sys::{OM_uint32, gss_OID_desc, gss_buffer_desc, gss_cred_id_t, gss_ctx_id_t, gss_name_t}; + +use crate::consts; +use crate::krb5; + +/// A captured GSSAPI status (major/minor) plus the human-readable messages +/// rendered from `gss_display_status`. +#[derive(Debug, Clone)] +pub struct GssError { + pub major: OM_uint32, + pub minor: OM_uint32, + pub messages: Vec, +} + +impl std::fmt::Display for GssError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "gssapi error (major={:#010x} minor={}): {}", + self.major, + self.minor, + self.messages.join(", ") + ) + } +} + +impl std::error::Error for GssError {} + +pub type Result = std::result::Result; + +/// True when the calling-error or routine-error bits are set in a major status. +#[inline] +pub fn is_error(major: OM_uint32) -> bool { + (major & 0xffff_0000) != 0 +} + +/// Turn a major/minor pair into `Ok`/`Err`, rendering the messages on error. +pub fn check(major: OM_uint32, minor: OM_uint32) -> Result<()> { + if is_error(major) { + Err(make_error(major, minor)) + } else { + Ok(()) + } +} + +fn make_error(major: OM_uint32, minor: OM_uint32) -> GssError { + let mut messages = display_status(major, sys::GSS_C_GSS_CODE as c_int, None); + messages.extend(display_status(minor, sys::GSS_C_MECH_CODE as c_int, None)); + GssError { + major, + minor, + messages, + } +} + +/// Render a status code into its (possibly multi-part) message strings, mirroring +/// the `do { gss_display_status } while (msg_ctx)` loop in `gp_conv.c`. +pub fn display_status(code: OM_uint32, code_type: c_int, mech: Option<&[u8]>) -> Vec { + let mut out = Vec::new(); + let mut msg_ctx: OM_uint32 = 0; + let mut mech_oid = mech.map(oid_desc); + let mech_ptr = mech_oid + .as_mut() + .map(|o| o as *mut gss_OID_desc) + .unwrap_or(ptr::null_mut()); + + loop { + let mut minor: OM_uint32 = 0; + let mut buf = OutputBuffer::empty(); + let major = unsafe { + sys::gss_display_status( + &mut minor, + code, + code_type, + mech_ptr, + &mut msg_ctx, + buf.as_mut_ptr(), + ) + }; + if is_error(major) { + break; + } + out.push(String::from_utf8_lossy(buf.as_bytes()).into_owned()); + if msg_ctx == 0 { + break; + } + } + out +} + +/// Build a borrowed input `gss_buffer_desc` over `data`. The descriptor borrows +/// `data`, so it must not outlive it; only pass it to calls that read the input. +fn input_buffer(data: &[u8]) -> gss_buffer_desc { + gss_buffer_desc { + length: data.len() as _, + value: data.as_ptr() as *mut c_void, + } +} + +/// Build a borrowed `gss_OID_desc` over `oid`'s DER bytes. +fn oid_desc(oid: &[u8]) -> gss_OID_desc { + gss_OID_desc { + length: oid.len() as OM_uint32, + elements: oid.as_ptr() as *mut c_void, + } +} + +/// Owns a `gss_buffer_desc` that GSSAPI allocated; releases it on drop. +pub struct OutputBuffer(gss_buffer_desc); + +impl OutputBuffer { + pub fn empty() -> Self { + OutputBuffer(gss_buffer_desc { + length: 0, + value: ptr::null_mut(), + }) + } + + fn as_mut_ptr(&mut self) -> *mut gss_buffer_desc { + &mut self.0 + } + + pub fn as_bytes(&self) -> &[u8] { + if self.0.value.is_null() || self.0.length == 0 { + &[] + } else { + unsafe { slice::from_raw_parts(self.0.value as *const u8, self.0.length) } + } + } + + pub fn to_vec(&self) -> Vec { + self.as_bytes().to_vec() + } +} + +impl Drop for OutputBuffer { + fn drop(&mut self) { + if !self.0.value.is_null() { + let mut minor: OM_uint32 = 0; + unsafe { + sys::gss_release_buffer(&mut minor, &mut self.0); + } + } + } +} + +/// Owns a `gss_name_t`; releases it on drop. +pub struct Name(gss_name_t); + +impl Name { + /// Import a name (`gss_import_name`). `name_type` of `None` passes + /// `GSS_C_NO_OID`, matching the `len == 0` handling in `gp_conv.c`. + pub fn import(value: &[u8], name_type: Option<&[u8]>) -> Result { + let mut buf = input_buffer(value); + let mut oid_storage; + let oid_ptr = match name_type { + Some(nt) => { + oid_storage = oid_desc(nt); + &mut oid_storage as *mut gss_OID_desc + } + None => ptr::null_mut(), + }; + let mut out: gss_name_t = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let major = unsafe { sys::gss_import_name(&mut minor, &mut buf, oid_ptr, &mut out) }; + check(major, minor)?; + Ok(Name(out)) + } + + /// Import a previously exported (GSS_C_NT_EXPORT_NAME) name blob. + pub fn import_exported(value: &[u8]) -> Result { + Name::import(value, Some(consts::NT_EXPORT_NAME_OID)) + } + + /// Export the (canonicalized) name to its wire form (`gss_export_name`). + /// Returns `Ok(None)` when the name is not a mechanism name + /// (`GSS_S_NAME_NOT_MN`), which `gp_conv.c` treats as "simply do not export". + pub fn export(&self) -> Result>> { + let mut out = OutputBuffer::empty(); + let mut minor: OM_uint32 = 0; + let major = unsafe { sys::gss_export_name(&mut minor, self.0, out.as_mut_ptr()) }; + if major == consts::GSS_S_NAME_NOT_MN { + return Ok(None); + } + check(major, minor)?; + Ok(Some(out.to_vec())) + } + + /// Export the composite (attribute-carrying) name (`gss_export_name_composite`). + /// Tolerates `GSS_S_NAME_NOT_MN`/`GSS_S_UNAVAILABLE` like `gp_conv.c`. + pub fn export_composite(&self) -> Result>> { + let mut out = OutputBuffer::empty(); + let mut minor: OM_uint32 = 0; + let major = unsafe { sys::gss_export_name_composite(&mut minor, self.0, out.as_mut_ptr()) }; + if major == consts::GSS_S_NAME_NOT_MN || major == consts::GSS_S_UNAVAILABLE { + return Ok(None); + } + check(major, minor)?; + Ok(Some(out.to_vec())) + } + + /// Display the name, returning `(display_bytes, name_type_oid_bytes)`. + pub fn display(&self) -> Result<(Vec, Vec)> { + let mut out = OutputBuffer::empty(); + let mut name_type: sys::gss_OID = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let major = + unsafe { sys::gss_display_name(&mut minor, self.0, out.as_mut_ptr(), &mut name_type) }; + check(major, minor)?; + let oid_bytes = unsafe { oid_to_vec(name_type) }; + Ok((out.to_vec(), oid_bytes)) + } + + /// Canonicalize the name to a mechanism name (`gss_canonicalize_name`). + pub fn canonicalize(&self, mech: &[u8]) -> Result { + let mut oid = oid_desc(mech); + let mut out: gss_name_t = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let major = unsafe { sys::gss_canonicalize_name(&mut minor, self.0, &mut oid, &mut out) }; + check(major, minor)?; + Ok(Name(out)) + } + + /// Map the name to a local (POSIX) name for `mech` (`gss_localname`). A + /// `mech` of `None` passes `GSS_C_NO_OID`. + pub fn localname(&self, mech: Option<&[u8]>) -> Result> { + let oid_storage; + let oid_ptr: sys::gss_const_OID = match mech { + Some(m) => { + oid_storage = oid_desc(m); + &oid_storage as *const gss_OID_desc + } + None => ptr::null(), + }; + let mut out = OutputBuffer::empty(); + let mut minor: OM_uint32 = 0; + let major = unsafe { sys::gss_localname(&mut minor, self.0, oid_ptr, out.as_mut_ptr()) }; + check(major, minor)?; + Ok(out.to_vec()) + } + + /// `gss_compare_name`: whether two names denote the same entity. + pub fn compare(&self, other: &Name) -> Result { + let mut equal: c_int = 0; + let mut minor: OM_uint32 = 0; + let major = unsafe { sys::gss_compare_name(&mut minor, self.0, other.0, &mut equal) }; + check(major, minor)?; + Ok(equal != 0) + } + + pub fn as_raw(&self) -> gss_name_t { + self.0 + } + + /// Take ownership of a raw handle (caller must transfer sole ownership). + /// + /// # Safety + /// `name` must be a valid `gss_name_t` (or null) whose ownership is + /// transferred to the returned `Name`; it must not be used or freed + /// elsewhere afterwards. + pub unsafe fn from_raw(name: gss_name_t) -> Name { + Name(name) + } + + /// Relinquish ownership of the raw handle without releasing it. + pub fn into_raw(self) -> gss_name_t { + let p = self.0; + std::mem::forget(self); + p + } +} + +impl Drop for Name { + fn drop(&mut self) { + if !self.0.is_null() { + let mut minor: OM_uint32 = 0; + unsafe { + sys::gss_release_name(&mut minor, &mut self.0); + } + } + } +} + +/// Result of `gss_inquire_cred`: the cred's overall name, remaining lifetime, +/// usage, and the mechanisms it covers. +pub struct CredInfo { + pub name: Option, + pub lifetime: OM_uint32, + pub usage: c_int, + pub mechs: Vec>, +} + +/// Result of `gss_inquire_cred_by_mech` for a single mechanism. +pub struct CredByMech { + pub name: Option, + pub initiator_lifetime: OM_uint32, + pub acceptor_lifetime: OM_uint32, + pub usage: c_int, +} + +/// Owns a `gss_cred_id_t`; releases it on drop. +pub struct Cred(gss_cred_id_t); + +impl Cred { + pub fn as_raw(&self) -> gss_cred_id_t { + self.0 + } + + /// # Safety + /// `cred` must be a valid `gss_cred_id_t` (or null) whose ownership is + /// transferred to the returned `Cred`; it must not be used or freed + /// elsewhere afterwards. + pub unsafe fn from_raw(cred: gss_cred_id_t) -> Cred { + Cred(cred) + } + + pub fn into_raw(self) -> gss_cred_id_t { + let p = self.0; + std::mem::forget(self); + p + } + + /// `gss_inquire_cred`: the credential's name, lifetime, usage and mechs. + pub fn inquire(&self) -> Result { + let mut name: gss_name_t = ptr::null_mut(); + let mut lifetime: OM_uint32 = 0; + let mut usage: c_int = 0; + let mut set: sys::gss_OID_set = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let major = unsafe { + sys::gss_inquire_cred( + &mut minor, + self.0, + &mut name, + &mut lifetime, + &mut usage, + &mut set, + ) + }; + check(major, minor)?; + let mechs = unsafe { oid_set_drain(&mut set) }; + Ok(CredInfo { + name: if name.is_null() { + None + } else { + Some(Name(name)) + }, + lifetime, + usage, + mechs, + }) + } + + /// `gss_inquire_cred_by_mech`: per-mechanism name/lifetimes/usage. + pub fn inquire_by_mech(&self, mech: &[u8]) -> Result { + let mut oid = oid_desc(mech); + let mut name: gss_name_t = ptr::null_mut(); + let mut initiator_lifetime: OM_uint32 = 0; + let mut acceptor_lifetime: OM_uint32 = 0; + let mut usage: c_int = 0; + let mut minor: OM_uint32 = 0; + let major = unsafe { + sys::gss_inquire_cred_by_mech( + &mut minor, + self.0, + &mut oid, + &mut name, + &mut initiator_lifetime, + &mut acceptor_lifetime, + &mut usage, + ) + }; + check(major, minor)?; + Ok(CredByMech { + name: if name.is_null() { + None + } else { + Some(Name(name)) + }, + initiator_lifetime, + acceptor_lifetime, + usage, + }) + } + + /// `gss_inquire_cred_by_oid`: return the data buffers a mechanism associates + /// with `oid` for this credential. Used with + /// [`consts::KRB5_GET_CRED_IMPERSONATOR_OID`] to detect constrained-delegation + /// (proxy) credentials. A `GSS_S_UNAVAILABLE` major (the SPI/OID is not + /// supported by the installed mechanism) is reported as an empty result; the + /// C daemon's raw-krb5 `proxy_impersonator` ccache fallback is not ported, + /// since modern MIT krb5 supports this SPI directly. + pub fn inquire_by_oid(&self, oid: &[u8]) -> Result>> { + let mut desired = oid_desc(oid); + let mut set: sys::gss_buffer_set_t = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let major = + unsafe { sys::gss_inquire_cred_by_oid(&mut minor, self.0, &mut desired, &mut set) }; + if major == consts::GSS_S_UNAVAILABLE { + return Ok(Vec::new()); + } + check(major, minor)?; + Ok(unsafe { buffer_set_drain(&mut set) }) + } + + /// `gss_export_cred`: serialize the credential to an opaque token. + pub fn export_token(&self) -> Result> { + let mut out = OutputBuffer::empty(); + let mut minor: OM_uint32 = 0; + let major = unsafe { sys::gss_export_cred(&mut minor, self.0, out.as_mut_ptr()) }; + check(major, minor)?; + Ok(out.to_vec()) + } + + /// `gss_import_cred`: reconstruct a credential from an exported token. + pub fn import_token(token: &[u8]) -> Result { + let mut buf = input_buffer(token); + let mut out: gss_cred_id_t = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let major = unsafe { sys::gss_import_cred(&mut minor, &mut buf, &mut out) }; + check(major, minor)?; + Ok(Cred(out)) + } +} + +/// `gss_acquire_cred_from`: acquire a credential for `name` (or the default +/// principal when `None`) over the given mechanisms and credential store. +pub fn acquire_cred_from( + name: Option<&Name>, + time_req: OM_uint32, + mechs: &[&[u8]], + usage: c_int, + cred_store: &[(String, String)], +) -> Result { + // The mech OID set borrows these descs, which borrow the caller's slices. + let mut oid_descs: Vec = mechs.iter().map(|m| oid_desc(m)).collect(); + let mut mech_set = sys::gss_OID_set_desc { + count: oid_descs.len() as _, + elements: oid_descs.as_mut_ptr(), + }; + + // Hold the CStrings alive for the duration of the call; the element descs + // borrow their pointers. + let cstrings: Vec<(std::ffi::CString, std::ffi::CString)> = cred_store + .iter() + .map(|(k, v)| { + ( + std::ffi::CString::new(k.as_bytes()).unwrap_or_default(), + std::ffi::CString::new(v.as_bytes()).unwrap_or_default(), + ) + }) + .collect(); + let mut elements: Vec = cstrings + .iter() + .map(|(k, v)| sys::gss_key_value_element_desc { + key: k.as_ptr(), + value: v.as_ptr(), + }) + .collect(); + let store = sys::gss_key_value_set_desc { + count: elements.len() as _, + elements: elements.as_mut_ptr(), + }; + let store_ptr: sys::gss_const_key_value_set_t = if elements.is_empty() { + ptr::null() + } else { + &store + }; + + let name_raw = name.map(|n| n.0).unwrap_or(ptr::null_mut()); + let mut out: gss_cred_id_t = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let major = unsafe { + sys::gss_acquire_cred_from( + &mut minor, + name_raw, + time_req, + &mut mech_set, + usage, + store_ptr, + &mut out, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + check(major, minor)?; + Ok(Cred(out)) +} + +/// `gss_store_cred_into`: persist `cred` to a credential store. Mirrors the call +/// in `extract_ccache` (`GSS_C_BOTH`, default mech, `overwrite` + `default` both +/// set). When `ccache` is `None` the default ccache is used. +pub fn store_cred_into(cred: &Cred, ccache: Option<&str>) -> Result<()> { + let cstrings: Vec<(std::ffi::CString, std::ffi::CString)> = match ccache { + Some(c) => vec![( + std::ffi::CString::new("ccache").unwrap_or_default(), + std::ffi::CString::new(c).unwrap_or_default(), + )], + None => Vec::new(), + }; + let mut elements: Vec = cstrings + .iter() + .map(|(k, v)| sys::gss_key_value_element_desc { + key: k.as_ptr(), + value: v.as_ptr(), + }) + .collect(); + let store = sys::gss_key_value_set_desc { + count: elements.len() as _, + elements: elements.as_mut_ptr(), + }; + let store_ptr: sys::gss_const_key_value_set_t = if elements.is_empty() { + ptr::null() + } else { + &store + }; + + let mut minor: OM_uint32 = 0; + let major = unsafe { + sys::gss_store_cred_into( + &mut minor, + cred.0, + 0, // GSS_C_BOTH + ptr::null_mut(), + 1, // overwrite_cred + 1, // default_cred + store_ptr, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + check(major, minor) +} + +/// `gss_acquire_cred_impersonate_name`: obtain, via S4U2Self, a credential for +/// `desired_name` (the impersonated user) backed by `impersonator`'s ticket. +/// `mechs` and `usage` mirror the C call; `actual_mechs`/`time_rec` are not +/// surfaced (the daemon does not use them on this path). +pub fn acquire_cred_impersonate_name( + impersonator: &Cred, + desired_name: Option<&Name>, + time_req: OM_uint32, + mechs: &[&[u8]], + usage: c_int, +) -> Result { + let mut oid_descs: Vec = mechs.iter().map(|m| oid_desc(m)).collect(); + let mut mech_set = sys::gss_OID_set_desc { + count: oid_descs.len() as _, + elements: oid_descs.as_mut_ptr(), + }; + let name_raw = desired_name.map(|n| n.0).unwrap_or(ptr::null_mut()); + let mut out: gss_cred_id_t = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let major = unsafe { + sys::gss_acquire_cred_impersonate_name( + &mut minor, + impersonator.0, + name_raw, + time_req, + &mut mech_set, + usage, + &mut out, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + check(major, minor)?; + Ok(Cred(out)) +} + +/// Destroy a credential cache by name (a port of `safe_free_mem_ccache` in +/// `gp_creds.c`). Used to tear down the per-request `MEMORY:` ccache the +/// acquisition layer hands to MIT so that a later acquisition reusing the same +/// (thread-keyed) ccache name does not observe a stale, mismatched principal +/// (`KG_CCACHE_NOMATCH`). Best-effort: errors are swallowed, matching the C +/// daemon's cleanup callback. +pub fn destroy_ccache(name: &str) { + let cname = match std::ffi::CString::new(name) { + Ok(c) => c, + Err(_) => return, + }; + unsafe { + let mut ctx: krb5::krb5_context = ptr::null_mut(); + if krb5::krb5_init_context(&mut ctx) != 0 { + return; + } + let mut cc: krb5::krb5_ccache = ptr::null_mut(); + if krb5::krb5_cc_resolve(ctx, cname.as_ptr(), &mut cc) == 0 { + // krb5_cc_destroy also closes the handle. + krb5::krb5_cc_destroy(ctx, cc); + } + krb5::krb5_free_context(ctx); + } +} + +impl Drop for Cred { + fn drop(&mut self) { + if !self.0.is_null() { + let mut minor: OM_uint32 = 0; + unsafe { + sys::gss_release_cred(&mut minor, &mut self.0); + } + } + } +} + +/// Owns a `gss_ctx_id_t`; deletes it on drop. +pub struct Context(gss_ctx_id_t); + +impl Context { + pub fn as_raw(&self) -> gss_ctx_id_t { + self.0 + } + + /// # Safety + /// `ctx` must be a valid `gss_ctx_id_t` (or null) whose ownership is + /// transferred to the returned `Context`; it must not be used or freed + /// elsewhere afterwards. + pub unsafe fn from_raw(ctx: gss_ctx_id_t) -> Context { + Context(ctx) + } + + pub fn into_raw(self) -> gss_ctx_id_t { + let p = self.0; + std::mem::forget(self); + p + } + + /// Serialize an established context (`gss_export_sec_context`). This consumes + /// the underlying handle (GSSAPI invalidates it), so the wrapper is taken by + /// value. + pub fn export(mut self) -> Result> { + let mut out = OutputBuffer::empty(); + let mut minor: OM_uint32 = 0; + let major = + unsafe { sys::gss_export_sec_context(&mut minor, &mut self.0, out.as_mut_ptr()) }; + // The handle is consumed regardless; forget so Drop doesn't double-delete. + self.0 = ptr::null_mut(); + check(major, minor)?; + Ok(out.to_vec()) + } + + /// Reconstruct a context from an interprocess token (`gss_import_sec_context`). + pub fn import(token: &[u8]) -> Result { + let mut buf = input_buffer(token); + let mut out: gss_ctx_id_t = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let major = unsafe { sys::gss_import_sec_context(&mut minor, &mut buf, &mut out) }; + check(major, minor)?; + Ok(Context(out)) + } + + /// `gss_inquire_context`: the context's names, lifetime, mech, flags, and + /// initiator/open state. The mechanism OID is copied (it is owned by GSSAPI). + pub fn inquire(&self) -> Result { + let mut src: gss_name_t = ptr::null_mut(); + let mut targ: gss_name_t = ptr::null_mut(); + let mut lifetime: OM_uint32 = 0; + let mut mech: sys::gss_OID = ptr::null_mut(); + let mut flags: OM_uint32 = 0; + let mut locally: c_int = 0; + let mut open: c_int = 0; + let mut minor: OM_uint32 = 0; + let major = unsafe { + sys::gss_inquire_context( + &mut minor, + self.0, + &mut src, + &mut targ, + &mut lifetime, + &mut mech, + &mut flags, + &mut locally, + &mut open, + ) + }; + check(major, minor)?; + Ok(ContextInfo { + src_name: if src.is_null() { None } else { Some(Name(src)) }, + targ_name: if targ.is_null() { + None + } else { + Some(Name(targ)) + }, + lifetime, + mech: unsafe { oid_to_vec(mech) }, + flags, + locally_initiated: locally != 0, + open: open != 0, + }) + } + + /// `gss_get_mic`. + pub fn get_mic(&self, qop: OM_uint32, message: &[u8]) -> Result> { + let mut msg = input_buffer(message); + let mut token = OutputBuffer::empty(); + let mut minor: OM_uint32 = 0; + let major = + unsafe { sys::gss_get_mic(&mut minor, self.0, qop, &mut msg, token.as_mut_ptr()) }; + check(major, minor)?; + Ok(token.to_vec()) + } + + /// `gss_verify_mic`, returning the resulting QOP state. + pub fn verify_mic(&self, message: &[u8], token: &[u8]) -> Result { + let mut msg = input_buffer(message); + let mut tok = input_buffer(token); + let mut qop: sys::gss_qop_t = 0; + let mut minor: OM_uint32 = 0; + let major = + unsafe { sys::gss_verify_mic(&mut minor, self.0, &mut msg, &mut tok, &mut qop) }; + check(major, minor)?; + Ok(qop) + } + + /// `gss_wrap`, returning `(token, conf_state)`. + pub fn wrap(&self, conf_req: bool, qop: OM_uint32, message: &[u8]) -> Result<(Vec, bool)> { + let mut msg = input_buffer(message); + let mut conf: c_int = 0; + let mut out = OutputBuffer::empty(); + let mut minor: OM_uint32 = 0; + let major = unsafe { + sys::gss_wrap( + &mut minor, + self.0, + conf_req as c_int, + qop, + &mut msg, + &mut conf, + out.as_mut_ptr(), + ) + }; + check(major, minor)?; + Ok((out.to_vec(), conf != 0)) + } + + /// `gss_unwrap`, returning `(message, conf_state, qop_state)`. + pub fn unwrap(&self, token: &[u8]) -> Result<(Vec, bool, OM_uint32)> { + let mut tok = input_buffer(token); + let mut conf: c_int = 0; + let mut qop: sys::gss_qop_t = 0; + let mut out = OutputBuffer::empty(); + let mut minor: OM_uint32 = 0; + let major = unsafe { + sys::gss_unwrap( + &mut minor, + self.0, + &mut tok, + out.as_mut_ptr(), + &mut conf, + &mut qop, + ) + }; + check(major, minor)?; + Ok((out.to_vec(), conf != 0, qop)) + } + + /// `gss_wrap_size_limit`: the largest message that wraps within `req_output_size`. + pub fn wrap_size_limit( + &self, + conf_req: bool, + qop: OM_uint32, + req_output_size: OM_uint32, + ) -> Result { + let mut max: OM_uint32 = 0; + let mut minor: OM_uint32 = 0; + let major = unsafe { + sys::gss_wrap_size_limit( + &mut minor, + self.0, + conf_req as c_int, + qop, + req_output_size, + &mut max, + ) + }; + check(major, minor)?; + Ok(max) + } +} + +/// Result of [`Context::inquire`]. +pub struct ContextInfo { + pub src_name: Option, + pub targ_name: Option, + pub lifetime: OM_uint32, + pub mech: Vec, + pub flags: OM_uint32, + pub locally_initiated: bool, + pub open: bool, +} + +/// Borrowed channel-bindings for init/accept (`gss_channel_bindings_struct`). +pub struct ChannelBindings<'a> { + pub initiator_addrtype: OM_uint32, + pub initiator_address: &'a [u8], + pub acceptor_addrtype: OM_uint32, + pub acceptor_address: &'a [u8], + pub application_data: &'a [u8], +} + +impl ChannelBindings<'_> { + fn to_raw(&self) -> sys::gss_channel_bindings_struct { + sys::gss_channel_bindings_struct { + initiator_addrtype: self.initiator_addrtype, + initiator_address: input_buffer(self.initiator_address), + acceptor_addrtype: self.acceptor_addrtype, + acceptor_address: input_buffer(self.acceptor_address), + application_data: input_buffer(self.application_data), + } + } +} + +/// Result of [`init_sec_context`]. +pub struct InitResult { + pub context: Context, + pub actual_mech: Vec, + pub output: Vec, + pub continue_needed: bool, +} + +/// `gss_init_sec_context`. `existing` is the context handle from a previous step +/// (consumed). On a GSSAPI error the partial context is released automatically. +#[allow(clippy::too_many_arguments)] +pub fn init_sec_context( + cred: Option<&Cred>, + existing: Option, + target: &Name, + mech: &[u8], + req_flags: OM_uint32, + time_req: OM_uint32, + cb: Option<&ChannelBindings>, + input: &[u8], +) -> Result { + let mut ctx_raw = existing.map(Context::into_raw).unwrap_or(ptr::null_mut()); + let mut oid = oid_desc(mech); + let mut input_buf = input_buffer(input); + let mut actual: sys::gss_OID = ptr::null_mut(); + let mut out = OutputBuffer::empty(); + let mut minor: OM_uint32 = 0; + let cred_raw = cred.map(Cred::as_raw).unwrap_or(ptr::null_mut()); + let mut cb_storage; + let cb_ptr = match cb { + Some(c) => { + cb_storage = c.to_raw(); + &mut cb_storage as *mut sys::gss_channel_bindings_struct + } + None => ptr::null_mut(), + }; + let major = unsafe { + sys::gss_init_sec_context( + &mut minor, + cred_raw, + &mut ctx_raw, + target.as_raw(), + &mut oid, + req_flags, + time_req, + cb_ptr, + &mut input_buf, + &mut actual, + out.as_mut_ptr(), + ptr::null_mut(), + ptr::null_mut(), + ) + }; + // Own the (possibly partial) context so it is released on the error path. + let context = Context(ctx_raw); + if is_error(major) { + return Err(make_error(major, minor)); + } + Ok(InitResult { + context, + actual_mech: unsafe { oid_to_vec(actual) }, + output: out.to_vec(), + continue_needed: major == sys::GSS_S_CONTINUE_NEEDED, + }) +} + +/// Result of [`accept_sec_context`]. +pub struct AcceptResult { + pub context: Context, + pub src_name: Option, + pub mech: Vec, + pub output: Vec, + pub ret_flags: OM_uint32, + pub delegated_cred: Option, + pub continue_needed: bool, +} + +/// `gss_accept_sec_context`. `existing` is the context handle from a previous +/// step (consumed). On a GSSAPI error the partial context is released. +pub fn accept_sec_context( + existing: Option, + cred: Option<&Cred>, + input: &[u8], + cb: Option<&ChannelBindings>, + ret_deleg: bool, +) -> Result { + let mut ctx_raw = existing.map(Context::into_raw).unwrap_or(ptr::null_mut()); + let cred_raw = cred.map(Cred::as_raw).unwrap_or(ptr::null_mut()); + let mut input_buf = input_buffer(input); + let mut src: gss_name_t = ptr::null_mut(); + let mut mech: sys::gss_OID = ptr::null_mut(); + let mut out = OutputBuffer::empty(); + let mut ret_flags: OM_uint32 = 0; + let mut deleg: gss_cred_id_t = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let mut cb_storage; + let cb_ptr = match cb { + Some(c) => { + cb_storage = c.to_raw(); + &mut cb_storage as *mut sys::gss_channel_bindings_struct + } + None => ptr::null_mut(), + }; + let deleg_ptr = if ret_deleg { + &mut deleg as *mut gss_cred_id_t + } else { + ptr::null_mut() + }; + let major = unsafe { + sys::gss_accept_sec_context( + &mut minor, + &mut ctx_raw, + cred_raw, + &mut input_buf, + cb_ptr, + &mut src, + &mut mech, + out.as_mut_ptr(), + &mut ret_flags, + ptr::null_mut(), + deleg_ptr, + ) + }; + let context = Context(ctx_raw); + let delegated_cred = if deleg.is_null() { + None + } else { + Some(Cred(deleg)) + }; + if is_error(major) { + return Err(make_error(major, minor)); + } + Ok(AcceptResult { + context, + src_name: if src.is_null() { None } else { Some(Name(src)) }, + mech: unsafe { oid_to_vec(mech) }, + output: out.to_vec(), + ret_flags, + delegated_cred, + continue_needed: major == sys::GSS_S_CONTINUE_NEEDED, + }) +} + +impl Drop for Context { + fn drop(&mut self) { + if !self.0.is_null() { + let mut minor: OM_uint32 = 0; + unsafe { + sys::gss_delete_sec_context(&mut minor, &mut self.0, ptr::null_mut()); + } + } + } +} + +/// Enumerate the mechanisms the local GSSAPI supports (`gss_indicate_mechs`), +/// returning each mechanism OID's DER bytes. +pub fn indicate_mechs() -> Result>> { + let mut set: sys::gss_OID_set = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let major = unsafe { sys::gss_indicate_mechs(&mut minor, &mut set) }; + check(major, minor)?; + + let mut out = Vec::new(); + if !set.is_null() { + unsafe { + let count = (*set).count as usize; + for i in 0..count { + let elem = (*set).elements.add(i); + out.push(oid_to_vec(elem)); + } + sys::gss_release_oid_set(&mut minor, &mut set); + } + } + Ok(out) +} + +/// Copy a (non-owning) `gss_OID`'s DER bytes into a `Vec`. Returns empty for a +/// null OID. +unsafe fn oid_to_vec(oid: sys::gss_OID) -> Vec { + unsafe { + if oid.is_null() || (*oid).elements.is_null() { + Vec::new() + } else { + slice::from_raw_parts((*oid).elements as *const u8, (*oid).length as usize).to_vec() + } + } +} + +/// Drain a `gss_OID_set` into owned DER byte vectors and release the set. +unsafe fn oid_set_drain(set: &mut sys::gss_OID_set) -> Vec> { + unsafe { + let mut out = Vec::new(); + if !set.is_null() { + let count = (**set).count; + for i in 0..count { + out.push(oid_to_vec((**set).elements.add(i))); + } + let mut minor: OM_uint32 = 0; + sys::gss_release_oid_set(&mut minor, set); + } + out + } +} + +/// Drain a `gss_buffer_set` into owned byte vectors and release the set. +unsafe fn buffer_set_drain(set: &mut sys::gss_buffer_set_t) -> Vec> { + unsafe { + let mut out = Vec::new(); + if !set.is_null() { + let count = (**set).count; + for i in 0..count { + let elem = (**set).elements.add(i); + let bytes = if (*elem).value.is_null() || (*elem).length == 0 { + Vec::new() + } else { + slice::from_raw_parts((*elem).value as *const u8, (*elem).length).to_vec() + }; + out.push(bytes); + } + let mut minor: OM_uint32 = 0; + sys::gss_release_buffer_set(&mut minor, set); + } + out + } +} + +/// `gss_inquire_names_for_mech`: the name-types a mechanism supports. +pub fn inquire_names_for_mech(mech: &[u8]) -> Result>> { + let mut oid = oid_desc(mech); + let mut set: sys::gss_OID_set = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let major = unsafe { sys::gss_inquire_names_for_mech(&mut minor, &mut oid, &mut set) }; + check(major, minor)?; + Ok(unsafe { oid_set_drain(&mut set) }) +} + +/// A pair of OID-set members: `(mech_attrs, known_mech_attrs)`. +pub type MechAttrSets = (Vec>, Vec>); + +/// `gss_inquire_attrs_for_mech`: returns `(mech_attrs, known_mech_attrs)`. +pub fn inquire_attrs_for_mech(mech: &[u8]) -> Result { + let oid = oid_desc(mech); + let mut mech_attrs: sys::gss_OID_set = ptr::null_mut(); + let mut known: sys::gss_OID_set = ptr::null_mut(); + let mut minor: OM_uint32 = 0; + let major = unsafe { + sys::gss_inquire_attrs_for_mech( + &mut minor, + &oid as *const gss_OID_desc, + &mut mech_attrs, + &mut known, + ) + }; + check(major, minor)?; + let a = unsafe { oid_set_drain(&mut mech_attrs) }; + let k = unsafe { oid_set_drain(&mut known) }; + Ok((a, k)) +} + +/// `gss_inquire_saslname_for_mech`: `(sasl_mech_name, mech_name, mech_desc)`. +pub fn inquire_saslname_for_mech(mech: &[u8]) -> Result<(Vec, Vec, Vec)> { + let mut oid = oid_desc(mech); + let mut sasl = OutputBuffer::empty(); + let mut name = OutputBuffer::empty(); + let mut desc = OutputBuffer::empty(); + let mut minor: OM_uint32 = 0; + let major = unsafe { + sys::gss_inquire_saslname_for_mech( + &mut minor, + &mut oid, + sasl.as_mut_ptr(), + name.as_mut_ptr(), + desc.as_mut_ptr(), + ) + }; + check(major, minor)?; + Ok((sasl.to_vec(), name.to_vec(), desc.to_vec())) +} + +/// `gss_display_mech_attr`: `(name, short_desc, long_desc)` for an attribute OID. +pub fn display_mech_attr(attr: &[u8]) -> Result<(Vec, Vec, Vec)> { + let oid = oid_desc(attr); + let mut name = OutputBuffer::empty(); + let mut short = OutputBuffer::empty(); + let mut long = OutputBuffer::empty(); + let mut minor: OM_uint32 = 0; + let major = unsafe { + sys::gss_display_mech_attr( + &mut minor, + &oid as *const gss_OID_desc, + name.as_mut_ptr(), + short.as_mut_ptr(), + long.as_mut_ptr(), + ) + }; + check(major, minor)?; + Ok((name.to_vec(), short.to_vec(), long.to_vec())) +} diff --git a/rust/gssproxy-bench/Cargo.lock b/rust/gssproxy-bench/Cargo.lock new file mode 100644 index 000000000..49b4c6b3b --- /dev/null +++ b/rust/gssproxy-bench/Cargo.lock @@ -0,0 +1,1347 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gssproxy-bench" +version = "0.9.2" +dependencies = [ + "cc", + "criterion", + "gssproxy-proto", + "pkg-config", + "pprof", +] + +[[package]] +name = "gssproxy-proto" +version = "0.9.2" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inferno" +version = "0.11.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232929e1d75fe899576a3d5c7416ad0d88dbfbb3c3d6aa00873a7408a50ddb88" +dependencies = [ + "ahash", + "indexmap", + "is-terminal", + "itoa", + "log", + "num-format", + "once_cell", + "quick-xml", + "rgb", + "str_stack", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "pprof" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a01da47675efa7673b032bf8efd8214f1917d89685e07e395ab125ea42b187" +dependencies = [ + "aligned-vec", + "backtrace", + "cfg-if", + "findshlibs", + "inferno", + "libc", + "log", + "nix", + "once_cell", + "smallvec", + "spin", + "symbolic-demangle", + "tempfile", + "thiserror", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f50b1c63b38611e7d4d7f68b82d3ad0cc71a2ad2e7f61fc10f1328d917c93cd" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "str_stack" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f446288b699d66d0fd2e30d1cfe7869194312524b3b9252594868ed26ef056a" + +[[package]] +name = "symbolic-common" +version = "12.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "332615d90111d8eeaf86a84dc9bbe9f65d0d8c5cf11b4caccedc37754eb0dcfd" +dependencies = [ + "debugid", + "memmap2", + "stable_deref_trait", + "uuid", +] + +[[package]] +name = "symbolic-demangle" +version = "12.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "912017718eb4d21930546245af9a3475c9dccf15675a5c215664e76621afc471" +dependencies = [ + "cpp_demangle", + "rustc-demangle", + "symbolic-common", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/gssproxy-bench/Cargo.toml b/rust/gssproxy-bench/Cargo.toml new file mode 100644 index 000000000..b7a382cfe --- /dev/null +++ b/rust/gssproxy-bench/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "gssproxy-bench" +version = "0.9.2" +edition = "2024" +rust-version = "1.95" +license = "MIT" +repository = "https://github.com/gssapi/gssproxy" +description = "Criterion benchmarks comparing the Rust gssx codec against the C rpcgen XDR" +publish = false + +[dependencies] +gssproxy-proto = { path = "../gssproxy-proto" } + +[dev-dependencies] +criterion = "0.8" +# pprof's own "criterion" integration is pinned to criterion 0.5, so we drive +# pprof directly via a small custom Profiler (see benches/codec.rs) and only +# need the flamegraph output here. +pprof = { version = "0.15", features = ["flamegraph"] } + +[build-dependencies] +cc = "1" +pkg-config = "0.3" + +[[bench]] +name = "codec" +harness = false + +# Keep debuginfo in the bench binary so cargo-flamegraph / perf can resolve +# symbols (and pprof can unwind) without a separate CARGO_PROFILE_BENCH_DEBUG. +[profile.bench] +debug = true diff --git a/rust/gssproxy-bench/benches/codec.rs b/rust/gssproxy-bench/benches/codec.rs new file mode 100644 index 000000000..f80b48a15 --- /dev/null +++ b/rust/gssproxy-bench/benches/codec.rs @@ -0,0 +1,160 @@ +//! Criterion benchmarks for the gssx wire codec, comparing the pure-Rust +//! `gssproxy-proto` implementation against the C rpcgen XDR (via the FFI shim). +//! +//! Run: +//! cargo bench --bench codec # full comparison report +//! cargo bench --bench codec -- --profile-time=5 # pprof flamegraph SVGs +//! +//! Each group holds a `rust` and a `c` function so the HTML report renders them +//! side by side. See rust/docs/benchmarking.md. + +use std::fs::File; +use std::hint::black_box; +use std::path::Path; + +use criterion::profiler::Profiler; +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use pprof::ProfilerGuard; + +use gssproxy_bench::{SCRATCH, c, rust}; + +/// init_sec_context input_token sizes (bytes): empty, small, page, large. +const TOKEN_SIZES: &[usize] = &[0, 256, 4096, 65536]; + +/// A Criterion `Profiler` that samples with pprof and writes a flamegraph SVG. +/// +/// pprof ships its own `PProfProfiler`, but its `criterion` feature is locked to +/// criterion 0.5; this mirrors that logic for criterion 0.8. Activated by +/// `cargo bench --bench codec -- --profile-time=`, it emits +/// `target/criterion///profile/flamegraph.svg`. +struct FlamegraphProfiler<'a> { + frequency: std::os::raw::c_int, + guard: Option>, +} + +impl FlamegraphProfiler<'_> { + fn new(frequency: std::os::raw::c_int) -> Self { + Self { + frequency, + guard: None, + } + } +} + +impl Profiler for FlamegraphProfiler<'_> { + fn start_profiling(&mut self, _benchmark_id: &str, _benchmark_dir: &Path) { + self.guard = Some(ProfilerGuard::new(self.frequency).expect("failed to start pprof")); + } + + fn stop_profiling(&mut self, _benchmark_id: &str, benchmark_dir: &Path) { + if let Some(guard) = self.guard.take() { + std::fs::create_dir_all(benchmark_dir).expect("failed to create profile dir"); + let path = benchmark_dir.join("flamegraph.svg"); + let file = File::create(&path) + .unwrap_or_else(|e| panic!("failed to create {}: {e}", path.display())); + guard + .report() + .build() + .expect("failed to build pprof report") + .flamegraph(file) + .expect("failed to write flamegraph"); + } + } +} + +fn bench_indicate_mechs(crit: &mut Criterion) { + c::setup_indicate_mechs(); + let arg = rust::indicate_mechs_arg(); + let bytes = rust::encode_indicate_mechs(&arg); + + // Sanity: both encoders agree on the body length before we benchmark. + let mut scratch = vec![0u8; SCRATCH]; + let c_len = c::encode_indicate_mechs(&mut scratch); + assert_eq!( + c_len, + bytes.len(), + "C/Rust indicate_mechs body length differ" + ); + assert!(c::decode_indicate_mechs(&bytes), "C must decode Rust bytes"); + assert!( + rust::decode_indicate_mechs(&scratch[..c_len]), + "Rust must decode C bytes" + ); + + let mut g = crit.benchmark_group("encode/indicate_mechs"); + g.bench_function("rust", |b| { + b.iter(|| black_box(rust::encode_indicate_mechs(black_box(&arg)))) + }); + g.bench_function("c", |b| { + b.iter(|| black_box(c::encode_indicate_mechs(black_box(&mut scratch)))) + }); + g.finish(); + + let mut g = crit.benchmark_group("decode/indicate_mechs"); + g.bench_function("rust", |b| { + b.iter(|| black_box(rust::decode_indicate_mechs(black_box(&bytes)))) + }); + g.bench_function("c", |b| { + b.iter(|| black_box(c::decode_indicate_mechs(black_box(&bytes)))) + }); + g.finish(); +} + +fn bench_init_sec_context(crit: &mut Criterion) { + let mut scratch = vec![0u8; SCRATCH]; + + let mut enc = crit.benchmark_group("encode/init_sec_context"); + for &n in TOKEN_SIZES { + c::setup_init_sec_context(n); + let arg = rust::init_sec_context_arg(n); + let bytes = rust::encode_init_sec_context(&arg); + let c_len = c::encode_init_sec_context(&mut scratch); + assert_eq!( + c_len, + bytes.len(), + "C/Rust init_sec_context len differ at {n}" + ); + + enc.throughput(Throughput::Bytes(bytes.len() as u64)); + enc.bench_with_input(BenchmarkId::new("rust", n), &arg, |b, arg| { + b.iter(|| black_box(rust::encode_init_sec_context(black_box(arg)))) + }); + enc.bench_with_input(BenchmarkId::new("c", n), &n, |b, _| { + b.iter(|| black_box(c::encode_init_sec_context(black_box(&mut scratch)))) + }); + } + enc.finish(); + + let mut dec = crit.benchmark_group("decode/init_sec_context"); + for &n in TOKEN_SIZES { + c::setup_init_sec_context(n); + let arg = rust::init_sec_context_arg(n); + let bytes = rust::encode_init_sec_context(&arg); + assert!( + c::decode_init_sec_context(&bytes), + "C must decode Rust bytes at {n}" + ); + assert!( + rust::decode_init_sec_context(&bytes), + "Rust must decode its bytes at {n}" + ); + + dec.throughput(Throughput::Bytes(bytes.len() as u64)); + dec.bench_with_input(BenchmarkId::new("rust", n), &bytes, |b, bytes| { + b.iter(|| black_box(rust::decode_init_sec_context(black_box(bytes)))) + }); + dec.bench_with_input(BenchmarkId::new("c", n), &bytes, |b, bytes| { + b.iter(|| black_box(c::decode_init_sec_context(black_box(bytes)))) + }); + } + dec.finish(); +} + +criterion_group! { + name = benches; + // 997 Hz: high enough to get usable stacks on the larger token cases + // (the sub-µs benches are dominated by the iteration loop regardless). + config = Criterion::default().with_profiler(FlamegraphProfiler::new(997)); + targets = bench_indicate_mechs, bench_init_sec_context +} +criterion_main!(benches); diff --git a/rust/gssproxy-bench/build.rs b/rust/gssproxy-bench/build.rs new file mode 100644 index 000000000..d74ea269d --- /dev/null +++ b/rust/gssproxy-bench/build.rs @@ -0,0 +1,61 @@ +//! Compile the C rpcgen XDR layer + a thin benchmark shim so the Criterion +//! benches can time the C codec head-to-head with the Rust `gssproxy-proto` +//! codec. +//! +//! The three rpcgen sources live at the repo root (`../../rpcgen`), outside this +//! crate's tree; that is why `gssproxy-bench` is excluded from the Rust +//! workspace and only ever built from a checkout (e.g. `nix develop .#bench`). + +use std::path::PathBuf; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + // rust/gssproxy-bench -> rust -> repo root + let repo_root = manifest + .join("../..") + .canonicalize() + .expect("failed to resolve repo root from CARGO_MANIFEST_DIR"); + let rpcgen = repo_root.join("rpcgen"); + + for f in ["gp_rpc_xdr.c", "gss_proxy_xdr.c", "gp_xdr.c"] { + assert!( + rpcgen.join(f).exists(), + "missing rpcgen source {}; run from a full checkout", + rpcgen.join(f).display() + ); + } + + // krb5-gssapi supplies the gssrpc headers (gssrpc/rpc.h) and pulls in the + // krb5/gssapi link flags. It also emits the cargo:rustc-link-lib lines. + let krb5 = pkg_config::Config::new() + .probe("krb5-gssapi") + .expect("pkg-config could not find krb5-gssapi (install krb5 dev headers)"); + + let mut build = cc::Build::new(); + build + .file(rpcgen.join("gp_rpc_xdr.c")) + .file(rpcgen.join("gss_proxy_xdr.c")) + .file(rpcgen.join("gp_xdr.c")) + .file("csrc/bench_shim.c") + // rpcgen sources include "rpcgen/..." relative to the repo root. + .include(&repo_root) + .include(repo_root.join("include")) + .include("csrc") + // Keep debug info + frame pointers so pprof can unwind into the C XDR + // functions instead of stopping at the FFI boundary. + .debug(true) + .flag_if_supported("-fno-omit-frame-pointer") + .warnings(false); + for p in &krb5.include_paths { + build.include(p); + } + build.compile("gpbenchc"); + + // gssrpc has no .pc file (configure.ac links it explicitly); the krb5 lib + // dir is already on the search path from the krb5-gssapi probe. + println!("cargo:rustc-link-lib=gssrpc"); + + println!("cargo:rerun-if-changed=csrc/bench_shim.c"); + println!("cargo:rerun-if-changed=csrc/bench_shim.h"); + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/rust/gssproxy-bench/csrc/bench_shim.c b/rust/gssproxy-bench/csrc/bench_shim.c new file mode 100644 index 000000000..423b4437e --- /dev/null +++ b/rust/gssproxy-bench/csrc/bench_shim.c @@ -0,0 +1,134 @@ +/* Copyright (C) 2026 the GSS-PROXY contributors, see COPYING for license */ + +#include "bench_shim.h" + +#include +#include + +#include "rpcgen/gp_xdr.h" +#include "rpcgen/gp_rpc.h" +#include "rpcgen/gss_proxy.h" + +/* krb5 mech OID DER bytes: 1.2.840.113554.1.2.2 (matches oids.rs KRB5). */ +static char krb5_oid[] = { + 0x2a, (char)0x86, 0x48, (char)0x86, (char)0xf7, 0x12, 0x01, 0x02, 0x02, +}; + +/* Build the SunRPC CALL envelope shared by every benchmarked request. */ +static void fill_call_header(gp_rpc_msg *msg, unsigned int proc) +{ + memset(msg, 0, sizeof(*msg)); + msg->xid = 1; + msg->header.type = GP_RPC_CALL; + + gp_rpc_call_header *chdr = &msg->header.gp_rpc_msg_union_u.chdr; + chdr->rpcvers = 2; + chdr->prog = GSSPROXY; + chdr->vers = GSSPROXYVERS; + chdr->proc = proc; + chdr->cred.flavor = GP_RPC_AUTH_NONE; + chdr->cred.body.body_len = 0; + chdr->cred.body.body_val = NULL; + chdr->verf.flavor = GP_RPC_AUTH_NONE; + chdr->verf.body.body_len = 0; + chdr->verf.body.body_val = NULL; +} + +/* ---------------------------------------------------------------- indicate_mechs */ + +static gp_rpc_msg im_msg; +static gssx_arg_indicate_mechs im_arg; + +void cbench_setup_indicate_mechs(void) +{ + fill_call_header(&im_msg, GSSX_INDICATE_MECHS); + memset(&im_arg, 0, sizeof(im_arg)); +} + +size_t cbench_encode_indicate_mechs(unsigned char *buf, size_t cap) +{ + XDR xdrs; + size_t pos = 0; + xdrmem_create(&xdrs, (caddr_t)buf, (u_int)cap, XDR_ENCODE); + if (xdr_gp_rpc_msg(&xdrs, &im_msg) && + xdr_gssx_arg_indicate_mechs(&xdrs, &im_arg)) { + pos = xdr_getpos(&xdrs); + } + xdr_destroy(&xdrs); + return pos; +} + +int cbench_decode_indicate_mechs(const unsigned char *buf, size_t len) +{ + XDR xdrs; + gp_rpc_msg msg; + gssx_arg_indicate_mechs arg; + int ok; + + memset(&msg, 0, sizeof(msg)); + memset(&arg, 0, sizeof(arg)); + xdrmem_create(&xdrs, (caddr_t)buf, (u_int)len, XDR_DECODE); + ok = xdr_gp_rpc_msg(&xdrs, &msg) && xdr_gssx_arg_indicate_mechs(&xdrs, &arg); + xdr_destroy(&xdrs); + + /* Release whatever the decoder allocated. */ + xdr_free((xdrproc_t)xdr_gssx_arg_indicate_mechs, (char *)&arg); + xdr_free((xdrproc_t)xdr_gp_rpc_msg, (char *)&msg); + return ok; +} + +/* ------------------------------------------------------------- init_sec_context */ + +static gp_rpc_msg isc_msg; +static gssx_arg_init_sec_context isc_arg; +static gssx_buffer isc_token; + +void cbench_setup_init_sec_context(size_t payload_len) +{ + fill_call_header(&isc_msg, GSSX_INIT_SEC_CONTEXT); + + memset(&isc_arg, 0, sizeof(isc_arg)); + isc_arg.mech_type.octet_string_len = (u_int)sizeof(krb5_oid); + isc_arg.mech_type.octet_string_val = krb5_oid; + + free(isc_token.octet_string_val); + isc_token.octet_string_len = (u_int)payload_len; + isc_token.octet_string_val = NULL; + if (payload_len > 0) { + isc_token.octet_string_val = calloc(1, payload_len); + } + /* input_token is an optional (pointer) field in the XDR. */ + isc_arg.input_token = &isc_token; +} + +size_t cbench_encode_init_sec_context(unsigned char *buf, size_t cap) +{ + XDR xdrs; + size_t pos = 0; + xdrmem_create(&xdrs, (caddr_t)buf, (u_int)cap, XDR_ENCODE); + if (xdr_gp_rpc_msg(&xdrs, &isc_msg) && + xdr_gssx_arg_init_sec_context(&xdrs, &isc_arg)) { + pos = xdr_getpos(&xdrs); + } + xdr_destroy(&xdrs); + return pos; +} + +int cbench_decode_init_sec_context(const unsigned char *buf, size_t len) +{ + XDR xdrs; + gp_rpc_msg msg; + gssx_arg_init_sec_context arg; + int ok; + + memset(&msg, 0, sizeof(msg)); + memset(&arg, 0, sizeof(arg)); + xdrmem_create(&xdrs, (caddr_t)buf, (u_int)len, XDR_DECODE); + ok = xdr_gp_rpc_msg(&xdrs, &msg) && + xdr_gssx_arg_init_sec_context(&xdrs, &arg); + xdr_destroy(&xdrs); + + xdr_free((xdrproc_t)xdr_gssx_arg_init_sec_context, (char *)&arg); + xdr_free((xdrproc_t)xdr_gp_rpc_msg, (char *)&msg); + return ok; +} diff --git a/rust/gssproxy-bench/csrc/bench_shim.h b/rust/gssproxy-bench/csrc/bench_shim.h new file mode 100644 index 000000000..12dcc0754 --- /dev/null +++ b/rust/gssproxy-bench/csrc/bench_shim.h @@ -0,0 +1,31 @@ +/* Copyright (C) 2026 the GSS-PROXY contributors, see COPYING for license */ + +/* + * Thin benchmark shim over the C rpcgen XDR codec. + * + * Each message has a setup function (builds a representative gp_rpc_msg + arg + * into file-static storage) and hot encode/decode functions that only exercise + * the serialization path, so Criterion times the codec rather than struct + * construction. All functions are single-threaded and reuse static buffers. + */ + +#ifndef GSSPROXY_BENCH_SHIM_H +#define GSSPROXY_BENCH_SHIM_H + +#include + +/* indicate_mechs: minimal CALL (empty call_ctx). */ +void cbench_setup_indicate_mechs(void); +size_t cbench_encode_indicate_mechs(unsigned char *buf, size_t cap); +int cbench_decode_indicate_mechs(const unsigned char *buf, size_t len); + +/* + * init_sec_context: CALL with the krb5 mech OID and an input_token of + * payload_len bytes (exercises the opaque/length-prefix/padding hot path and + * the optional-pointer envelope fields). + */ +void cbench_setup_init_sec_context(size_t payload_len); +size_t cbench_encode_init_sec_context(unsigned char *buf, size_t cap); +int cbench_decode_init_sec_context(const unsigned char *buf, size_t len); + +#endif /* GSSPROXY_BENCH_SHIM_H */ diff --git a/rust/gssproxy-bench/src/lib.rs b/rust/gssproxy-bench/src/lib.rs new file mode 100644 index 000000000..eb29b523f --- /dev/null +++ b/rust/gssproxy-bench/src/lib.rs @@ -0,0 +1,100 @@ +//! Shared helpers for the codec benchmarks: safe wrappers over the C rpcgen +//! shim ([`c`]) and matching Rust fixtures built from `gssproxy-proto` types. +//! +//! The C and Rust encoders produce the same wire body (`gp_rpc_msg` + arg), so +//! the Criterion groups in `benches/codec.rs` compare like with like. + +use gssproxy_proto::gssx::Opaque; +use gssproxy_proto::proc::{ArgIndicateMechs, ArgInitSecContext, GssxProc}; +use gssproxy_proto::{Message, Xdr, XdrDecoder, encode_request}; + +/// krb5 mech OID DER bytes (1.2.840.113554.1.2.2) - mirrors the C shim. +pub const KRB5_OID: &[u8] = b"\x2a\x86\x48\x86\xf7\x12\x01\x02\x02"; + +/// A generous scratch buffer for the C encoder (it writes into caller memory). +pub const SCRATCH: usize = 2 * 1024 * 1024; + +/// Safe wrappers around the C benchmark shim (`csrc/bench_shim.c`). +pub mod c { + use std::os::raw::c_int; + + unsafe extern "C" { + fn cbench_setup_indicate_mechs(); + fn cbench_encode_indicate_mechs(buf: *mut u8, cap: usize) -> usize; + fn cbench_decode_indicate_mechs(buf: *const u8, len: usize) -> c_int; + + fn cbench_setup_init_sec_context(payload_len: usize); + fn cbench_encode_init_sec_context(buf: *mut u8, cap: usize) -> usize; + fn cbench_decode_init_sec_context(buf: *const u8, len: usize) -> c_int; + } + + pub fn setup_indicate_mechs() { + // SAFETY: builds file-static C state; no aliasing, single-threaded bench. + unsafe { cbench_setup_indicate_mechs() } + } + + /// Encode the pre-built indicate_mechs CALL into `buf`, returning its length. + pub fn encode_indicate_mechs(buf: &mut [u8]) -> usize { + // SAFETY: the shim writes at most `buf.len()` bytes into `buf`. + unsafe { cbench_encode_indicate_mechs(buf.as_mut_ptr(), buf.len()) } + } + + pub fn decode_indicate_mechs(buf: &[u8]) -> bool { + // SAFETY: the shim only reads `buf.len()` bytes from `buf`. + unsafe { cbench_decode_indicate_mechs(buf.as_ptr(), buf.len()) != 0 } + } + + pub fn setup_init_sec_context(payload_len: usize) { + // SAFETY: builds file-static C state; allocates a payload_len token. + unsafe { cbench_setup_init_sec_context(payload_len) } + } + + pub fn encode_init_sec_context(buf: &mut [u8]) -> usize { + // SAFETY: the shim writes at most `buf.len()` bytes into `buf`. + unsafe { cbench_encode_init_sec_context(buf.as_mut_ptr(), buf.len()) } + } + + pub fn decode_init_sec_context(buf: &[u8]) -> bool { + // SAFETY: the shim only reads `buf.len()` bytes from `buf`. + unsafe { cbench_decode_init_sec_context(buf.as_ptr(), buf.len()) != 0 } + } +} + +/// Rust fixtures + codec, mirroring the C shim's structs via `gssproxy-proto`. +pub mod rust { + use super::*; + + pub fn indicate_mechs_arg() -> ArgIndicateMechs { + ArgIndicateMechs::default() + } + + /// init_sec_context CALL arg with the krb5 mech OID and an `input_token` of + /// `payload_len` zero bytes. + pub fn init_sec_context_arg(payload_len: usize) -> ArgInitSecContext { + ArgInitSecContext { + mech_type: Opaque::new(KRB5_OID.to_vec()), + input_token: Some(Opaque::new(vec![0u8; payload_len])), + ..Default::default() + } + } + + pub fn encode_indicate_mechs(arg: &ArgIndicateMechs) -> Vec { + encode_request(1, GssxProc::IndicateMechs as u32, arg) + } + + pub fn encode_init_sec_context(arg: &ArgInitSecContext) -> Vec { + encode_request(1, GssxProc::InitSecContext as u32, arg) + } + + /// Decode a full request body (`Message` envelope + arg), matching the C + /// `xdr_gp_rpc_msg` + `xdr_gssx_arg_*` decode path. + pub fn decode_indicate_mechs(bytes: &[u8]) -> bool { + let mut d = XdrDecoder::new(bytes); + Message::decode(&mut d).is_ok() && ArgIndicateMechs::decode(&mut d).is_ok() + } + + pub fn decode_init_sec_context(bytes: &[u8]) -> bool { + let mut d = XdrDecoder::new(bytes); + Message::decode(&mut d).is_ok() && ArgInitSecContext::decode(&mut d).is_ok() + } +} diff --git a/rust/gssproxy-client/Cargo.toml b/rust/gssproxy-client/Cargo.toml new file mode 100644 index 000000000..d73336a22 --- /dev/null +++ b/rust/gssproxy-client/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "gssproxy-client" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Client transport for talking to the gssproxy daemon over its Unix socket" + +[dependencies] +gssproxy-proto.workspace = true +gssapi-sys.workspace = true +libc.workspace = true +tracing.workspace = true + +[dev-dependencies] +proptest = "1.11.0" diff --git a/rust/gssproxy-client/src/gpm.rs b/rust/gssproxy-client/src/gpm.rs new file mode 100644 index 000000000..81a0a721b --- /dev/null +++ b/rust/gssproxy-client/src/gpm.rs @@ -0,0 +1,909 @@ +//! The typed `gpm_*` RPC layer, mirroring `src/client/gpm_*.c`. +//! +//! Each function here is the Rust analogue of one `gpm_*` entry point: it builds +//! the `gssx_arg_*` for a daemon procedure (or operates purely on cached/handle +//! state), drives [`crate::make_call`], and decodes the `gssx_res_*` into plain +//! Rust / `gssx` values for the interposer (`gssproxy-interposer`) to translate +//! back into the GSSAPI C ABI. +//! +//! Unlike the C client - which threads opaque `gss_OID` "static" pointers and +//! `gss_buffer_t` out-params throughout - this layer returns owned `gssx` +//! structs and byte vectors. The interposer owns the C-ABI concerns: the +//! mech-OID static cache (`gpm_mech_to_static`) and name-type static mapping +//! (`gpm_name_oid_to_static`) live there, since those produce stable pointers +//! handed across the ABI. + +use std::cell::RefCell; + +use gssapi_sys::consts; +use gssapi_sys::sys::{GSS_S_COMPLETE, GSS_S_CONTINUE_NEEDED}; +use gssproxy_proto::gssx::{ + GssxBuffer, GssxCb, GssxCred, GssxCtx, GssxName, GssxOption, GssxStatus, Opaque, +}; +use gssproxy_proto::proc::*; +use gssproxy_proto::xdr::{Xdr, XdrDecoder, XdrEncoder}; + +use crate::make_call; + +// ---- option keys (sizeof() in C includes the trailing NUL) ---- +const ACQUIRE_TYPE_OPTION: &[u8] = b"acquire_type\0"; +const ACQUIRE_IMPERSONATE_NAME: &[u8] = b"impersonate_name\0"; +const CRED_SYNC_OPTION: &[u8] = b"sync_modified_creds\0"; +const CRED_SYNC_DEFAULT: &[u8] = b"default\0"; +const CRED_SYNC_PAYLOAD: &[u8] = b"sync_creds\0"; +const LOCALNAME_OPTION: &[u8] = b"localname\0"; + +// ---- gssx_cred_usage enum values (x-files/gss_proxy.x) ---- +const GSSX_C_INITIATE: i32 = 1; +const GSSX_C_ACCEPT: i32 = 2; +const GSSX_C_BOTH: i32 = 3; + +// ---- GSS_C_* credential usage (gssapi.h) ---- +const GSS_C_BOTH: i32 = 0; +const GSS_C_INITIATE: i32 = 1; +const GSS_C_ACCEPT: i32 = 2; + +/// `GSS_C_INDEFINITE`. +const GSS_C_INDEFINITE: u32 = 0xffff_ffff; + +/// `gp_conv_cred_usage_to_gssx`. +fn cred_usage_to_gssx(usage: i32) -> i32 { + match usage { + GSS_C_BOTH => GSSX_C_BOTH, + GSS_C_INITIATE => GSSX_C_INITIATE, + GSS_C_ACCEPT => GSSX_C_ACCEPT, + _ => 0, + } +} + +/// Build a `GssxOption` with a key/value, matching `gp_add_option`'s bytewise +/// copy (the trailing NUL is part of the key/value as the `sizeof()` callers +/// pass it). +fn option(key: &[u8], value: &[u8]) -> GssxOption { + GssxOption { + option: Opaque::new(key.to_vec()), + value: Opaque::new(value.to_vec()), + } +} + +/// `gp_options_find`: locate an option by exact key bytes. +fn find_option<'a>(options: &'a [GssxOption], key: &[u8]) -> Option<&'a GssxBuffer> { + options + .iter() + .find(|o| o.option.as_slice() == key) + .map(|o| &o.value) +} + +// =========================================================================== +// Thread-local saved status (gpm_display_status.c) +// =========================================================================== + +thread_local! { + static LAST_STATUS: RefCell> = const { RefCell::new(None) }; +} + +/// `gpm_save_status`: stash the last remote `gssx_status` for `display_status`. +pub fn save_status(status: &GssxStatus) { + LAST_STATUS.with(|s| *s.borrow_mut() = Some(status.clone())); +} + +/// `gpm_save_internal_status`: record a client-internal failure. +pub fn save_internal_status(err: u32, err_str: &str) { + const STD_MAJ_ERROR_STR: &[u8] = b"Internal gssproxy error\0"; + let mut minor_string = err_str.as_bytes().to_vec(); + minor_string.push(0); + let status = GssxStatus { + major_status: consts::GSS_S_FAILURE as u64, + major_status_string: Opaque::new(STD_MAJ_ERROR_STR.to_vec()), + minor_status: err as u64, + minor_status_string: Opaque::new(minor_string), + ..Default::default() + }; + save_status(&status); +} + +/// `gpm_display_status`: render a saved major/minor status string. Returns +/// `(major, minor, message_bytes)`. +pub fn display_status( + status_value: u32, + status_type: i32, + message_context: u32, +) -> (u32, u32, Vec) { + // GSS_C_GSS_CODE == 1, GSS_C_MECH_CODE == 2 (gssapi.h). + const GSS_C_GSS_CODE: i32 = 1; + const GSS_C_MECH_CODE: i32 = 2; + LAST_STATUS.with(|s| { + let last = s.borrow(); + match status_type { + GSS_C_GSS_CODE => match last.as_ref() { + Some(st) + if st.major_status == status_value as u64 + && !st.major_status_string.is_empty() => + { + ( + GSS_S_COMPLETE, + 0, + st.major_status_string.as_slice().to_vec(), + ) + } + _ => (consts::GSS_S_UNAVAILABLE, 0, Vec::new()), + }, + GSS_C_MECH_CODE => match last.as_ref() { + Some(st) + if st.minor_status == status_value as u64 + && !st.minor_status_string.is_empty() => + { + if message_context != 0 { + (consts::GSS_S_FAILURE, libc::EINVAL as u32, Vec::new()) + } else { + ( + GSS_S_COMPLETE, + 0, + st.minor_status_string.as_slice().to_vec(), + ) + } + } + _ => (consts::GSS_S_UNAVAILABLE, 0, Vec::new()), + }, + _ => (consts::GSS_S_BAD_STATUS, libc::EINVAL as u32, Vec::new()), + } + }) +} + +// =========================================================================== +// Names (gpm_import_and_canon_name.c) +// =========================================================================== + +/// `gpm_import_name`: build a `gssx_name` from a display buffer + name-type OID. +pub fn import_name(value: &[u8], name_type: &[u8]) -> GssxName { + GssxName { + display_name: Opaque::new(value.to_vec()), + name_type: Opaque::new(name_type.to_vec()), + ..Default::default() + } +} + +/// `gpm_display_name`: returns `(major, minor, display_bytes, name_type_bytes)`. +/// +/// When `in_name` has no display name it is reconstructed from the exported +/// blob (the exported bytes become the display name, tagged `GSS_C_NT_EXPORT_NAME`), +/// matching the C "steal display_name/name_type" path. The returned name-type +/// bytes are mapped to a static OID by the caller when requested. +pub fn display_name(in_name: &mut GssxName) -> (u32, u32, Vec, Vec) { + if in_name.display_name.is_empty() { + if in_name.exported_name.is_empty() { + return (consts::GSS_S_BAD_NAME, 0, Vec::new(), Vec::new()); + } + let imported = import_name(in_name.exported_name.as_slice(), consts::NT_EXPORT_NAME_OID); + in_name.display_name = imported.display_name; + in_name.name_type = imported.name_type; + } + ( + GSS_S_COMPLETE, + 0, + in_name.display_name.as_slice().to_vec(), + in_name.name_type.as_slice().to_vec(), + ) +} + +/// `gpm_canonicalize_name`: GSSX_IMPORT_AND_CANON_NAME, returning the canonical +/// `gssx_name`. +pub fn canonicalize_name(input: &GssxName, mech: &[u8]) -> (u32, u32, Option) { + let arg = ArgImportAndCanonName { + input_name: input.clone(), + mech: Opaque::new(mech.to_vec()), + ..Default::default() + }; + let res: ResImportAndCanonName = match make_call(GssxProc::ImportAndCanonName, &arg) { + Ok(r) => r, + Err(e) => return (consts::GSS_S_FAILURE, e.errno() as u32, None), + }; + if res.status.major_status != 0 { + save_status(&res.status); + return ( + res.status.major_status as u32, + res.status.minor_status as u32, + None, + ); + } + (GSS_S_COMPLETE, 0, res.output_name) +} + +/// `gpm_localname`: GSSX_IMPORT_AND_CANON_NAME with the `localname` option, +/// returning the local name bytes. +pub fn localname(input: &GssxName, mech: &[u8]) -> (u32, u32, Option>) { + let arg = ArgImportAndCanonName { + input_name: input.clone(), + mech: Opaque::new(mech.to_vec()), + options: vec![option(LOCALNAME_OPTION, b"")], + ..Default::default() + }; + let res: ResImportAndCanonName = match make_call(GssxProc::ImportAndCanonName, &arg) { + Ok(r) => r, + Err(e) => return (consts::GSS_S_FAILURE, e.errno() as u32, None), + }; + if res.status.major_status != 0 { + save_status(&res.status); + return ( + res.status.major_status as u32, + res.status.minor_status as u32, + None, + ); + } + match find_option(&res.options, LOCALNAME_OPTION) { + Some(v) => (GSS_S_COMPLETE, 0, Some(v.as_slice().to_vec())), + None => (consts::GSS_S_FAILURE, libc::ENOTSUP as u32, None), + } +} + +/// Result of [`inquire_name`]. +pub struct InquireName { + pub name_is_mn: bool, + pub name_type: Vec, + pub attrs: Vec>, +} + +/// `gpm_inquire_name`: returns whether the name is a mechanism name, its +/// name-type bytes (mapped to a static OID by the caller), and its attribute +/// keys. +pub fn inquire_name(name: &GssxName) -> InquireName { + InquireName { + name_is_mn: !name.exported_name.is_empty(), + name_type: name.name_type.as_slice().to_vec(), + attrs: name + .name_attributes + .iter() + .map(|a| a.attr.as_slice().to_vec()) + .collect(), + } +} + +/// `gpm_compare_name`: byte-for-byte port of the (quirky) C comparison. +pub fn compare_name(name1: &GssxName, name2: &GssxName) -> (u32, u32, bool) { + let mut n1 = name1.clone(); + let mut n2 = name2.clone(); + let (maj1, min1, b1, t1) = display_name(&mut n1); + if maj1 != GSS_S_COMPLETE { + return (maj1, min1, false); + } + let (maj2, min2, b2, t2) = display_name(&mut n2); + if maj2 != GSS_S_COMPLETE { + return (maj2, min2, false); + } + + // C: c = len1 - len2; if 0, memcmp; if still 0, c = gss_oid_equal(t1,t2). + // *name_equal = (c != 0). (This mirrors the upstream behaviour verbatim.) + let mut c: i64 = b1.len() as i64 - b2.len() as i64; + if c == 0 { + c = match b1.cmp(&b2) { + std::cmp::Ordering::Equal => { + if t1 == t2 { + 1 + } else { + 0 + } + } + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Greater => 1, + }; + } + (GSS_S_COMPLETE, 0, c != 0) +} + +// =========================================================================== +// Credentials (gpm_acquire_cred.c) +// =========================================================================== + +/// Result of [`acquire_cred`]. +pub struct AcquireCred { + pub major: u32, + pub minor: u32, + pub cred: Option, + pub time_rec: u32, +} + +/// `gpm_acquire_cred`: GSSX_ACQUIRE_CRED. +pub fn acquire_cred( + in_cred: Option<&GssxCred>, + desired_name: Option<&GssxName>, + time_req: u32, + desired_mechs: &[Vec], + cred_usage: i32, + impersonate: bool, +) -> AcquireCred { + let mut options = Vec::new(); + if impersonate { + options.push(option(ACQUIRE_TYPE_OPTION, ACQUIRE_IMPERSONATE_NAME)); + } + let arg = ArgAcquireCred { + input_cred_handle: in_cred.cloned(), + desired_name: desired_name.cloned(), + time_req: time_req as u64, + desired_mechs: desired_mechs + .iter() + .map(|m| Opaque::new(m.clone())) + .collect(), + cred_usage: cred_usage_to_gssx(cred_usage), + options, + ..Default::default() + }; + let res: ResAcquireCred = match make_call(GssxProc::AcquireCred, &arg) { + Ok(r) => r, + Err(e) => { + return AcquireCred { + major: consts::GSS_S_FAILURE, + minor: e.errno() as u32, + cred: None, + time_rec: 0, + }; + } + }; + if res.status.major_status != 0 { + save_status(&res.status); + return AcquireCred { + major: res.status.major_status as u32, + minor: res.status.minor_status as u32, + cred: None, + time_rec: 0, + }; + } + let mut time_rec = 0u32; + if let Some(c) = &res.output_cred_handle + && let Some(e) = c.elements.first() + { + time_rec = std::cmp::min(e.initiator_time_rec, e.acceptor_time_rec) as u32; + } + AcquireCred { + major: GSS_S_COMPLETE, + minor: 0, + cred: res.output_cred_handle, + time_rec, + } +} + +/// Result of [`inquire_cred`]. +pub struct InquireCred { + pub major: u32, + pub minor: u32, + pub name: Option, + pub lifetime: u32, + pub usage: i32, + pub mechs: Vec>, +} + +/// `gpm_inquire_cred`: derive overall name/lifetime/usage/mechs from a cred's +/// elements (no RPC). +pub fn inquire_cred(cred: &GssxCred) -> InquireCred { + if cred.elements.is_empty() { + return InquireCred { + major: consts::GSS_S_FAILURE, + minor: 0, + name: None, + lifetime: 0, + usage: 0, + mechs: Vec::new(), + }; + } + let mut life = GSS_C_INDEFINITE; + let mut cu: i32 = -1; + let mut mechs = Vec::new(); + for e in &cred.elements { + match e.cred_usage { + GSSX_C_INITIATE => { + if e.initiator_time_rec != 0 && (e.initiator_time_rec as u32) < life { + life = e.initiator_time_rec as u32; + } + cu = match cu { + GSS_C_BOTH => GSS_C_BOTH, + GSS_C_ACCEPT => GSS_C_BOTH, + _ => GSS_C_INITIATE, + }; + } + GSSX_C_ACCEPT => { + if e.acceptor_time_rec != 0 && (e.acceptor_time_rec as u32) < life { + life = e.acceptor_time_rec as u32; + } + cu = match cu { + GSS_C_BOTH => GSS_C_BOTH, + GSS_C_INITIATE => GSS_C_BOTH, + _ => GSS_C_ACCEPT, + }; + } + GSSX_C_BOTH => { + if e.initiator_time_rec != 0 && (e.initiator_time_rec as u32) < life { + life = e.initiator_time_rec as u32; + } + if e.acceptor_time_rec != 0 && (e.acceptor_time_rec as u32) < life { + life = e.acceptor_time_rec as u32; + } + cu = GSS_C_BOTH; + } + _ => {} + } + mechs.push(e.mech.as_slice().to_vec()); + } + InquireCred { + major: GSS_S_COMPLETE, + minor: 0, + name: Some(cred.desired_name.clone()), + lifetime: life, + usage: cu, + mechs, + } +} + +/// Result of [`inquire_cred_by_mech`]. +pub struct InquireCredByMech { + pub major: u32, + pub minor: u32, + pub name: Option, + pub initiator_lifetime: u32, + pub acceptor_lifetime: u32, + pub usage: i32, +} + +/// `gpm_inquire_cred_by_mech`. +pub fn inquire_cred_by_mech(cred: &GssxCred, mech_type: &[u8]) -> InquireCredByMech { + let mut out = InquireCredByMech { + major: consts::GSS_S_FAILURE, + minor: 0, + name: None, + initiator_lifetime: 0, + acceptor_lifetime: 0, + usage: 0, + }; + if cred.elements.is_empty() { + return out; + } + for e in &cred.elements { + if e.mech.as_slice() != mech_type { + continue; + } + match e.cred_usage { + GSSX_C_INITIATE => { + out.initiator_lifetime = e.initiator_time_rec as u32; + out.usage = GSS_C_INITIATE; + } + GSSX_C_ACCEPT => { + out.acceptor_lifetime = e.acceptor_time_rec as u32; + out.usage = GSS_C_ACCEPT; + } + GSSX_C_BOTH => { + out.initiator_lifetime = e.initiator_time_rec as u32; + out.acceptor_lifetime = e.acceptor_time_rec as u32; + out.usage = GSS_C_BOTH; + } + _ => {} + } + out.major = GSS_S_COMPLETE; + out.name = Some(e.mn.clone()); + return out; + } + out +} + +// =========================================================================== +// Context establishment (gpm_init_sec_context.c / gpm_accept_sec_context.c) +// =========================================================================== + +/// Result of [`init_sec_context`]. +pub struct InitSecContext { + pub major: u32, + pub minor: u32, + pub context: Option, + pub output_token: Option>, + pub actual_mech: Vec, + pub out_cred: Option, +} + +/// `gpm_init_sec_context`: GSSX_INIT_SEC_CONTEXT (always requests cred sync). +#[allow(clippy::too_many_arguments)] +pub fn init_sec_context( + cred: Option<&GssxCred>, + context: Option<&GssxCtx>, + target: Option<&GssxName>, + mech: &[u8], + req_flags: u32, + time_req: u32, + input_cb: Option<&GssxCb>, + input_token: Option<&[u8]>, +) -> InitSecContext { + let arg = ArgInitSecContext { + context_handle: context.cloned(), + cred_handle: cred.cloned(), + target_name: target.cloned(), + mech_type: Opaque::new(mech.to_vec()), + req_flags: req_flags as u64, + time_req: time_req as u64, + input_cb: input_cb.cloned(), + input_token: input_token.map(|t| Opaque::new(t.to_vec())), + options: vec![option(CRED_SYNC_OPTION, CRED_SYNC_DEFAULT)], + ..Default::default() + }; + let res: ResInitSecContext = match make_call(GssxProc::InitSecContext, &arg) { + Ok(r) => r, + Err(e) => { + save_internal_status(e.errno() as u32, &e.to_string()); + return InitSecContext { + major: consts::GSS_S_FAILURE, + minor: e.errno() as u32, + context: None, + output_token: None, + actual_mech: Vec::new(), + out_cred: None, + }; + } + }; + + let actual_mech = res.status.mech.as_slice().to_vec(); + let major = res.status.major_status as u32; + let minor = res.status.minor_status as u32; + save_status(&res.status); + + let keep = major == GSS_S_COMPLETE || major == GSS_S_CONTINUE_NEEDED; + + // Cred sync: a returned gssx_cred is XDR-encoded inside the option value. + let mut out_cred = None; + if let Some(v) = find_option(&res.options, CRED_SYNC_PAYLOAD) { + let mut d = XdrDecoder::new(v.as_slice()); + if let Ok(c) = GssxCred::decode(&mut d) { + out_cred = Some(c); + } + } + + InitSecContext { + major, + minor, + context: if keep { res.context_handle } else { None }, + output_token: if keep { + res.output_token.map(|t| t.0) + } else { + None + }, + actual_mech, + out_cred, + } +} + +/// Result of [`accept_sec_context`]. +pub struct AcceptSecContext { + pub major: u32, + pub minor: u32, + pub context: Option, + pub src_name: Option, + pub output_token: Option>, + pub actual_mech: Vec, + pub delegated_cred: Option, +} + +/// `gpm_accept_sec_context`: GSSX_ACCEPT_SEC_CONTEXT. +pub fn accept_sec_context( + context: Option<&GssxCtx>, + acceptor_cred: Option<&GssxCred>, + input_token: &[u8], + input_cb: Option<&GssxCb>, + want_deleg: bool, +) -> AcceptSecContext { + let arg = ArgAcceptSecContext { + context_handle: context.cloned(), + cred_handle: acceptor_cred.cloned(), + input_token: Opaque::new(input_token.to_vec()), + input_cb: input_cb.cloned(), + ret_deleg_cred: want_deleg, + ..Default::default() + }; + let res: ResAcceptSecContext = match make_call(GssxProc::AcceptSecContext, &arg) { + Ok(r) => r, + Err(e) => { + return AcceptSecContext { + major: consts::GSS_S_FAILURE, + minor: e.errno() as u32, + context: None, + src_name: None, + output_token: None, + actual_mech: Vec::new(), + delegated_cred: None, + }; + } + }; + + if res.status.major_status != 0 { + save_status(&res.status); + return AcceptSecContext { + major: res.status.major_status as u32, + minor: res.status.minor_status as u32, + context: None, + src_name: None, + output_token: None, + actual_mech: res.status.mech.as_slice().to_vec(), + delegated_cred: None, + }; + } + + let ctx = match res.context_handle { + Some(c) => c, + None => { + return AcceptSecContext { + major: consts::GSS_S_FAILURE, + minor: libc::EINVAL as u32, + context: None, + src_name: None, + output_token: None, + actual_mech: Vec::new(), + delegated_cred: None, + }; + } + }; + + let src_name = Some(ctx.src_name.clone()); + AcceptSecContext { + major: GSS_S_COMPLETE, + minor: 0, + src_name, + output_token: res.output_token.map(|t| t.0), + actual_mech: res.status.mech.as_slice().to_vec(), + delegated_cred: res.delegated_cred_handle, + context: Some(ctx), + } +} + +// =========================================================================== +// Context inquiry / release (gpm_inquire_context.c / gpm_release_handle.c) +// =========================================================================== + +/// Result of [`inquire_context`]. +pub struct InquireContext { + pub src_name: GssxName, + pub targ_name: GssxName, + pub lifetime: u32, + pub mech: Vec, + pub ctx_flags: u32, + pub locally_initiated: bool, + pub open: bool, +} + +/// `gpm_inquire_context`: read context attributes from a cached `gssx_ctx`. +pub fn inquire_context(ctx: &GssxCtx) -> InquireContext { + InquireContext { + src_name: ctx.src_name.clone(), + targ_name: ctx.targ_name.clone(), + lifetime: ctx.lifetime as u32, + mech: ctx.mech.as_slice().to_vec(), + ctx_flags: ctx.ctx_flags as u32, + locally_initiated: ctx.locally_initiated, + open: ctx.open, + } +} + +/// `gpm_release_cred`: GSSX_RELEASE_HANDLE when the cred needs daemon release. +pub fn release_cred(cred: &GssxCred) -> (u32, u32) { + if !cred.needs_release { + return (GSS_S_COMPLETE, 0); + } + let arg = ArgReleaseHandle { + call_ctx: Default::default(), + cred_handle: gssproxy_proto::gssx::GssxHandle::Cred(cred.clone()), + }; + let res: ResReleaseHandle = match make_call(GssxProc::ReleaseHandle, &arg) { + Ok(r) => r, + Err(e) => return (consts::GSS_S_FAILURE, e.errno() as u32), + }; + if res.status.major_status != 0 { + save_status(&res.status); + return ( + res.status.major_status as u32, + res.status.minor_status as u32, + ); + } + (GSS_S_COMPLETE, 0) +} + +/// `gpm_delete_sec_context`: GSSX_RELEASE_HANDLE when the ctx needs release. +pub fn delete_sec_context(ctx: &GssxCtx) -> (u32, u32) { + if !ctx.needs_release { + return (GSS_S_COMPLETE, 0); + } + let arg = ArgReleaseHandle { + call_ctx: Default::default(), + cred_handle: gssproxy_proto::gssx::GssxHandle::SecCtx(ctx.clone()), + }; + let res: ResReleaseHandle = match make_call(GssxProc::ReleaseHandle, &arg) { + Ok(r) => r, + Err(e) => return (consts::GSS_S_FAILURE, e.errno() as u32), + }; + if res.status.major_status != 0 { + save_status(&res.status); + return ( + res.status.major_status as u32, + res.status.minor_status as u32, + ); + } + (GSS_S_COMPLETE, 0) +} + +// =========================================================================== +// Mech inquiry (gpm_indicate_mechs.c) +// =========================================================================== + +/// `gpm_indicate_mechs` (raw): GSSX_INDICATE_MECHS, returning the wire result +/// for the interposer to build its static-OID mech cache from. +pub fn indicate_mechs() -> (u32, u32, ResIndicateMechs) { + let arg = ArgIndicateMechs::default(); + match make_call::<_, ResIndicateMechs>(GssxProc::IndicateMechs, &arg) { + Ok(res) => { + if res.status.major_status != 0 { + save_status(&res.status); + } + ( + res.status.major_status as u32, + res.status.minor_status as u32, + res, + ) + } + Err(e) => ( + consts::GSS_S_FAILURE, + e.errno() as u32, + ResIndicateMechs::default(), + ), + } +} + +// =========================================================================== +// Message protection (gpm_wrap.c / unwrap / get_mic / verify_mic / +// wrap_size_limit). The interposer performs these locally, so these wrappers +// exist for completeness of the gpm library surface and are not on its hot +// path. +// =========================================================================== + +/// `gpm_wrap`: GSSX_WRAP, returning `(major, minor, token, conf_state)`. +pub fn wrap( + ctx: &GssxCtx, + conf_req: bool, + qop: u32, + message: &[u8], +) -> (u32, u32, Option>, bool) { + let arg = ArgWrap { + context_handle: ctx.clone(), + conf_req, + message_buffer: vec![Opaque::new(message.to_vec())], + qop_state: qop as u64, + ..Default::default() + }; + let res: ResWrap = match make_call(GssxProc::Wrap, &arg) { + Ok(r) => r, + Err(e) => return (consts::GSS_S_FAILURE, e.errno() as u32, None, false), + }; + if res.status.major_status != 0 { + save_status(&res.status); + return ( + res.status.major_status as u32, + res.status.minor_status as u32, + None, + false, + ); + } + ( + GSS_S_COMPLETE, + 0, + res.token_buffer.into_iter().next().map(|t| t.0), + res.conf_state.unwrap_or(false), + ) +} + +/// `gpm_unwrap`: GSSX_UNWRAP, returning `(major, minor, message, conf, qop)`. +pub fn unwrap(ctx: &GssxCtx, token: &[u8]) -> (u32, u32, Option>, bool, u32) { + let arg = ArgUnwrap { + context_handle: ctx.clone(), + token_buffer: vec![Opaque::new(token.to_vec())], + ..Default::default() + }; + let res: ResUnwrap = match make_call(GssxProc::Unwrap, &arg) { + Ok(r) => r, + Err(e) => return (consts::GSS_S_FAILURE, e.errno() as u32, None, false, 0), + }; + if res.status.major_status != 0 { + save_status(&res.status); + return ( + res.status.major_status as u32, + res.status.minor_status as u32, + None, + false, + 0, + ); + } + ( + GSS_S_COMPLETE, + 0, + res.message_buffer.into_iter().next().map(|t| t.0), + res.conf_state.unwrap_or(false), + res.qop_state.unwrap_or(0) as u32, + ) +} + +/// `gpm_get_mic`: GSSX_GET_MIC. +pub fn get_mic(ctx: &GssxCtx, qop: u32, message: &[u8]) -> (u32, u32, Option>) { + let arg = ArgGetMic { + context_handle: ctx.clone(), + qop_req: qop as u64, + message_buffer: Opaque::new(message.to_vec()), + ..Default::default() + }; + let res: ResGetMic = match make_call(GssxProc::GetMic, &arg) { + Ok(r) => r, + Err(e) => return (consts::GSS_S_FAILURE, e.errno() as u32, None), + }; + if res.status.major_status != 0 { + save_status(&res.status); + return ( + res.status.major_status as u32, + res.status.minor_status as u32, + None, + ); + } + (GSS_S_COMPLETE, 0, Some(res.token_buffer.0)) +} + +/// `gpm_verify_mic`: GSSX_VERIFY. +pub fn verify_mic(ctx: &GssxCtx, message: &[u8], token: &[u8]) -> (u32, u32, u32) { + let arg = ArgVerifyMic { + context_handle: ctx.clone(), + message_buffer: Opaque::new(message.to_vec()), + token_buffer: Opaque::new(token.to_vec()), + ..Default::default() + }; + let res: ResVerifyMic = match make_call(GssxProc::VerifyMic, &arg) { + Ok(r) => r, + Err(e) => return (consts::GSS_S_FAILURE, e.errno() as u32, 0), + }; + if res.status.major_status != 0 { + save_status(&res.status); + return ( + res.status.major_status as u32, + res.status.minor_status as u32, + 0, + ); + } + (GSS_S_COMPLETE, 0, res.qop_state.unwrap_or(0) as u32) +} + +/// `gpm_wrap_size_limit`: GSSX_WRAP_SIZE_LIMIT. +pub fn wrap_size_limit( + ctx: &GssxCtx, + conf_req: bool, + qop: u32, + req_output_size: u32, +) -> (u32, u32, u32) { + let arg = ArgWrapSizeLimit { + context_handle: ctx.clone(), + conf_req, + qop_state: qop as u64, + req_output_size: req_output_size as u64, + ..Default::default() + }; + let res: ResWrapSizeLimit = match make_call(GssxProc::WrapSizeLimit, &arg) { + Ok(r) => r, + Err(e) => return (consts::GSS_S_FAILURE, e.errno() as u32, 0), + }; + if res.status.major_status != 0 { + save_status(&res.status); + return ( + res.status.major_status as u32, + res.status.minor_status as u32, + 0, + ); + } + (GSS_S_COMPLETE, 0, res.max_input_size as u32) +} + +/// Encode a `gssx_cred` to its XDR bytes (used to stash a cred in a ccache). +pub fn encode_cred(cred: &GssxCred) -> Vec { + let mut e = XdrEncoder::new(); + cred.encode(&mut e); + e.into_bytes() +} + +/// Decode a `gssx_cred` from its XDR bytes (the ccache `ticket` blob). +pub fn decode_cred(bytes: &[u8]) -> Option { + let mut d = XdrDecoder::new(bytes); + GssxCred::decode(&mut d).ok() +} diff --git a/rust/gssproxy-client/src/lib.rs b/rust/gssproxy-client/src/lib.rs new file mode 100644 index 000000000..2dba732c4 --- /dev/null +++ b/rust/gssproxy-client/src/lib.rs @@ -0,0 +1,1038 @@ +//! `gssproxy-client`: the client-side transport that talks to the gssproxy +//! daemon over its Unix socket. +//! +//! This is the Rust port of `src/client/gpm_common.c` - specifically the +//! connection-management and `gpm_make_call` machinery. The per-procedure +//! `gpm_*` wrappers in C also performed the GSSAPI <-> gssx conversion; in the +//! Rust split that conversion lives in the interposer (`gssproxy-interposer`), +//! which calls into [`make_call`] here with already-encoded `gssx_arg_*` +//! values and decodes the returned `gssx_res_*`. +//! +//! The transport mirrors the C client's behaviour: +//! - a single process-global connection guarded by a recursive-friendly +//! mutex (here a plain [`std::sync::Mutex`], since `make_call` never +//! re-enters itself), +//! - fork / euid / egid change detection that drops a stale socket (C: +//! `gpm_grab_sock`), +//! - SunRPC single-fragment record-marking framing (C: `gpm_send_buffer` / +//! `gpm_recv_buffer`), +//! - a 15s response timeout with up to 3 reconnect-and-retry attempts (C: +//! `RESPONSE_TIMEOUT` / `MAX_TIMEOUT_RETRY` driven by epoll + timerfd). +//! +//! Environment lookups use `secure_getenv` (C: `gp_getenv`) so the library is +//! safe to load into setuid programs. + +use std::ffi::{CStr, CString, OsString}; +use std::io::{self, Read, Write}; +use std::os::unix::ffi::OsStringExt; +use std::os::unix::net::UnixStream; +use std::sync::Mutex; +use std::time::Duration; + +use gssproxy_proto::frame::{encode_header, parse_header}; +use gssproxy_proto::proc::GssxProc; +use gssproxy_proto::rpc::{MAX_RPC_SIZE, Message, ReplyBody}; +use gssproxy_proto::xdr::{Xdr, XdrDecoder}; + +pub mod gpm; + +/// Compiled-in default socket path (autotools `GP_SOCKET_NAME`). +const GP_SOCKET_NAME: &str = "/var/lib/gssproxy/default.sock"; + +/// Per-call response timeout (C: `RESPONSE_TIMEOUT`). +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(15); + +/// Number of reconnect-and-retry attempts on timeout (C: `MAX_TIMEOUT_RETRY`). +const MAX_TIMEOUT_RETRY: usize = 3; + +/// Error returned by [`make_call`]. +/// +/// The interposer maps these onto GSSAPI major/minor status codes; the +/// `errno`-style value (where one exists) is preserved so callers can +/// distinguish "daemon unreachable" from "bad reply". +#[derive(Debug)] +pub enum GpmError { + /// Underlying socket I/O failed (connect/read/write), including timeouts. + Io(io::Error), + /// The RPC framing header was malformed (multi-fragment or oversized). + Frame(gssproxy_proto::FrameError), + /// The reply could not be decoded as the expected `gssx_res_*` type. + Decode(gssproxy_proto::XdrError), + /// The reply envelope was well-formed but not an accepted-success reply + /// for our xid (wrong xid, denied, or non-success accept status). + BadReply, + /// The request body exceeded `MAX_RPC_SIZE`. + TooLarge, +} + +impl GpmError { + /// Best-effort `errno` for the failure, mirroring the integer returned by + /// the C `gpm_make_call`. Defaults to `EIO` for protocol-level errors. + pub fn errno(&self) -> i32 { + match self { + GpmError::Io(e) => e.raw_os_error().unwrap_or(libc::EIO), + GpmError::TooLarge => libc::EMSGSIZE, + _ => libc::EIO, + } + } +} + +impl std::fmt::Display for GpmError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GpmError::Io(e) => write!(f, "gssproxy socket I/O error: {e}"), + GpmError::Frame(e) => write!(f, "gssproxy framing error: {e}"), + GpmError::Decode(e) => write!(f, "gssproxy reply decode error: {e}"), + GpmError::BadReply => write!(f, "gssproxy returned an unexpected RPC reply"), + GpmError::TooLarge => write!(f, "gssproxy request exceeds MAX_RPC_SIZE"), + } + } +} + +impl std::error::Error for GpmError {} + +impl From for GpmError { + fn from(e: io::Error) -> Self { + GpmError::Io(e) + } +} + +/// Result alias for client transport operations. +pub type Result = std::result::Result; + +unsafe extern "C" { + // glibc's secure_getenv (not exposed by the libc crate on all versions). + fn secure_getenv(name: *const libc::c_char) -> *mut libc::c_char; +} + +/// `secure_getenv` wrapper (C: `gp_getenv`). Returns `None` in setuid/setgid +/// contexts so an attacker cannot redirect the proxy socket. +fn secure_getenv_os(name: &str) -> Option { + let cname = CString::new(name).ok()?; + // SAFETY: `cname` is a valid NUL-terminated string; secure_getenv returns + // either NULL or a pointer into the environment that we copy immediately. + let ptr = unsafe { secure_getenv(cname.as_ptr()) }; + if ptr.is_null() { + return None; + } + let bytes = unsafe { CStr::from_ptr(ptr) }.to_bytes().to_vec(); + Some(OsString::from_vec(bytes)) +} + +/// Resolve the daemon socket path (C: `get_pipe_name`). +fn socket_path() -> OsString { + secure_getenv_os("GSSPROXY_SOCKET").unwrap_or_else(|| OsString::from(GP_SOCKET_NAME)) +} + +/// Process-global connection state, mirroring C's `gpm_global_ctx`. +struct ClientConn { + stream: Option, + /// Identity captured when `stream` was opened, used to detect fork/setuid. + pid: libc::pid_t, + uid: libc::uid_t, + gid: libc::gid_t, + next_xid: u32, + seeded: bool, +} + +static CONN: Mutex = Mutex::new(ClientConn { + stream: None, + pid: 0, + uid: 0, + gid: 0, + next_xid: 0, + seeded: false, +}); + +impl ClientConn { + /// Seed the xid counter once from the kernel CSPRNG (C: `getrandom` in + /// `gpm_init_once`). The daemon merely echoes the xid, so any sequence is + /// acceptable; we randomise the start only to match upstream behaviour. + fn ensure_seeded(&mut self) { + if self.seeded { + return; + } + let mut buf = [0u8; 4]; + // SAFETY: writing exactly buf.len() bytes into a valid local buffer. + let mut filled = 0usize; + while filled < buf.len() { + let n = unsafe { + libc::getrandom( + buf.as_mut_ptr().add(filled) as *mut libc::c_void, + buf.len() - filled, + 0, + ) + }; + if n < 0 { + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::Interrupted { + continue; + } + // Fall back to a fixed seed; correctness does not depend on it. + break; + } + filled += n as usize; + } + self.next_xid = u32::from_ne_bytes(buf); + self.seeded = true; + } + + /// Return the next xid, mirroring `gpm_next_xid`'s wrap handling. + fn next_xid(&mut self) -> u32 { + let xid = self.next_xid; + self.next_xid = self.next_xid.wrapping_add(1); + xid + } + + /// Drop a connection whose owning identity changed (C: fork/setuid check + /// in `gpm_grab_sock`). + fn refresh_identity(&mut self) { + if self.stream.is_none() { + return; + } + // SAFETY: these syscalls are always safe to call. + let p = unsafe { libc::getpid() }; + let u = unsafe { libc::geteuid() }; + let g = unsafe { libc::getegid() }; + if p != self.pid || u != self.uid || g != self.gid { + self.disconnect(); + } + } + + /// Open the socket and record the owning identity (C: `gpm_open_socket`). + fn connect(&mut self) -> Result<()> { + let path = socket_path(); + let stream = UnixStream::connect(&path)?; + stream.set_read_timeout(Some(RESPONSE_TIMEOUT))?; + stream.set_write_timeout(Some(RESPONSE_TIMEOUT))?; + // SAFETY: always-safe syscalls. + self.pid = unsafe { libc::getpid() }; + self.uid = unsafe { libc::geteuid() }; + self.gid = unsafe { libc::getegid() }; + self.stream = Some(stream); + tracing::debug!(socket = %path.to_string_lossy(), "connected to gssproxy daemon"); + Ok(()) + } + + fn disconnect(&mut self) { + self.stream = None; + } + + /// Send a framed request body and read back the framed reply body + /// (C: `gpm_send_buffer` + `gpm_recv_buffer`, single fragment). + fn try_transact(&mut self, body: &[u8]) -> Result> { + let stream = self.stream.as_mut().expect("transact without a stream"); + + stream.write_all(&encode_header(body.len()))?; + stream.write_all(body)?; + stream.flush()?; + + let mut hdr = [0u8; 4]; + stream.read_exact(&mut hdr)?; + let len = parse_header(u32::from_be_bytes(hdr)).map_err(GpmError::Frame)?; + + let mut buf = vec![0u8; len]; + stream.read_exact(&mut buf)?; + Ok(buf) + } + + /// Run one request/response transaction with reconnect-and-retry on + /// timeout or broken socket (C: `gpm_send_recv_loop`). + fn transact(&mut self, body: &[u8]) -> Result> { + if body.len() > MAX_RPC_SIZE { + return Err(GpmError::TooLarge); + } + + let mut last_err: Option = None; + for _ in 0..MAX_TIMEOUT_RETRY { + if self.stream.is_none() { + self.connect()?; + } + match self.try_transact(body) { + Ok(buf) => return Ok(buf), + Err(GpmError::Io(e)) if is_retryable(&e) => { + // Close and reopen before trying again (C: gpm_retry_socket). + tracing::debug!(error = %e, "transient socket error; reconnecting and retrying"); + self.disconnect(); + last_err = Some(GpmError::Io(e)); + continue; + } + Err(e) => { + self.disconnect(); + return Err(e); + } + } + } + Err(last_err.unwrap_or(GpmError::BadReply)) + } +} + +/// Whether a socket error warrants a reconnect-and-retry (timeout, reset, +/// closed pipe). Mirrors the C client's handling of `ETIMEDOUT`/`EIO`. +fn is_retryable(e: &io::Error) -> bool { + matches!( + e.kind(), + io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock + | io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::UnexpectedEof + ) +} + +/// Perform a complete gssproxy RPC: encode the call envelope plus `arg`, send +/// it to the daemon, and decode the accepted-success reply into `R`. +/// +/// This is the typed analogue of C's `gpm_make_call`: instead of a runtime +/// `xdrproc_t` table indexed by procedure number, the argument and result +/// types are supplied as generics by the caller. +pub fn make_call(proc: GssxProc, arg: &A) -> Result { + let mut conn = CONN.lock().unwrap_or_else(|e| e.into_inner()); + conn.ensure_seeded(); + conn.refresh_identity(); + + let xid = conn.next_xid(); + let body = gssproxy_proto::encode_request(xid, proc as u32, arg); + + // One span per RPC; argument/result payloads carry tokens and credentials, + // so only the procedure, xid, and byte lengths are recorded - never the + // contents. + let span = tracing::debug_span!("gpm_call", ?proc, xid); + let _enter = span.enter(); + tracing::debug!(req_len = body.len(), "sending request to daemon"); + + let reply = conn.transact(&body)?; + // The lock is intentionally held across the whole conversation, matching + // the C client which serialises all traffic on a single socket. + drop(conn); + + let mut d = XdrDecoder::new(&reply); + let msg = Message::decode(&mut d).map_err(GpmError::Decode)?; + if msg.xid != xid || msg.is_call { + tracing::warn!(reply_xid = msg.xid, "reply xid mismatch or unexpected call"); + return Err(GpmError::BadReply); + } + match msg.reply { + Some(ReplyBody::AcceptedSuccess { .. }) => {} + _ => { + tracing::warn!("daemon returned a non-success RPC reply"); + return Err(GpmError::BadReply); + } + } + + tracing::debug!(res_len = reply.len(), "received reply from daemon"); + R::decode(&mut d).map_err(GpmError::Decode) +} + +#[cfg(test)] +mod tests { + use super::*; + use gssproxy_proto::proc::{ + ArgIndicateMechs, ArgInitSecContext, ResIndicateMechs, ResInitSecContext, + }; + use gssproxy_proto::rpc::{FRAGMENT_BIT, GSSPROXY, GSSPROXYVERS, RPC_VERS, ReplyBody}; + use gssproxy_proto::xdr::XdrEncoder; + use gssproxy_proto::{Message, encode_reply, encode_request, frame}; + use proptest::prelude::*; + use std::os::unix::net::{UnixListener, UnixStream}; + use std::path::PathBuf; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::thread::{self, JoinHandle}; + + // make_call drives a *process-global* connection keyed on the + // GSSPROXY_SOCKET environment variable. Serialise every test that touches + // that state so parallel test threads don't clobber each other. + fn serial() -> std::sync::MutexGuard<'static, ()> { + static S: Mutex<()> = Mutex::new(()); + S.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Force the next make_call to open a fresh connection (drops any socket + /// left connected by a previous test). + fn reset_connection() { + CONN.lock().unwrap_or_else(|e| e.into_inner()).stream = None; + } + + fn unique() -> u64 { + static N: AtomicU64 = AtomicU64::new(0); + N.fetch_add(1, Ordering::Relaxed) + } + + /// Create a private temp dir + socket path and point GSSPROXY_SOCKET at it. + fn setup_socket(tag: &str) -> (PathBuf, PathBuf) { + let dir = + std::env::temp_dir().join(format!("gpm-{tag}-{}-{}", std::process::id(), unique())); + std::fs::create_dir_all(&dir).unwrap(); + let sock = dir.join("s.sock"); + let _ = std::fs::remove_file(&sock); + // SAFETY: the env-mutating tests are serialised via `serial()`, so no + // other thread is reading the environment concurrently. + unsafe { std::env::set_var("GSSPROXY_SOCKET", &sock) }; + reset_connection(); + (dir, sock) + } + + fn cleanup(dir: PathBuf, sock: PathBuf) { + let _ = std::fs::remove_file(&sock); + let _ = std::fs::remove_dir(&dir); + } + + /// Read one record frame (4-byte header + body). Returns `None` on EOF. + fn read_frame(c: &mut UnixStream) -> Option<(u32, Vec)> { + let mut hdr = [0u8; 4]; + c.read_exact(&mut hdr).ok()?; + let word = u32::from_be_bytes(hdr); + let len = parse_header(word).expect("client must set the fragment bit"); + let mut body = vec![0u8; len]; + c.read_exact(&mut body).ok()?; + Some((word, body)) + } + + fn xid_of(body: &[u8]) -> u32 { + Message::decode(&mut XdrDecoder::new(body)).unwrap().xid + } + + fn success_reply(xid: u32, res: &R) -> Vec { + frame(&encode_reply(xid, res)) + } + + /// Server accepting exactly `conns` connections, reading exactly one + /// request per connection. `script(i, body)` returns the raw bytes to write + /// back (already framed), or `None` to close without replying. Returns the + /// recorded `(header_word, body)` of every request seen. + fn spawn_server(path: PathBuf, conns: usize, script: F) -> JoinHandle)>> + where + F: Fn(usize, &[u8]) -> Option> + Send + 'static, + { + let listener = UnixListener::bind(&path).unwrap(); + thread::spawn(move || { + let mut recorded = Vec::new(); + for i in 0..conns { + let (mut c, _) = match listener.accept() { + Ok(x) => x, + Err(_) => break, + }; + if let Some((word, body)) = read_frame(&mut c) { + recorded.push((word, body.clone())); + if let Some(raw) = script(i, &body) { + let _ = c.write_all(&raw); + let _ = c.flush(); + } + } + } + recorded + }) + } + + /// Server accepting a single connection and replying success to every + /// request until the client closes the socket (models the C daemon's + /// long-lived per-client connection). Returns the recorded request bodies. + fn spawn_server_persistent(path: PathBuf) -> JoinHandle>> { + let listener = UnixListener::bind(&path).unwrap(); + thread::spawn(move || { + let mut recorded = Vec::new(); + if let Ok((mut c, _)) = listener.accept() { + while let Some((_, body)) = read_frame(&mut c) { + let xid = xid_of(&body); + recorded.push(body); + let reply = success_reply(xid, &ResIndicateMechs::default()); + if c.write_all(&reply).is_err() { + break; + } + let _ = c.flush(); + } + } + recorded + }) + } + + #[test] + fn request_is_byte_exact_with_c_envelope() { + let _g = serial(); + let (dir, sock) = setup_socket("byteexact"); + let server = spawn_server(sock.clone(), 1, |_, body| { + Some(success_reply(xid_of(body), &ResIndicateMechs::default())) + }); + + let _res: ResIndicateMechs = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()).unwrap(); + + let recorded = server.join().unwrap(); + assert_eq!(recorded.len(), 1); + let (word, body) = &recorded[0]; + + // Framing: the fragment (last-record) bit is set and the advertised + // length matches the body (C: FRAGMENT_BIT in gpm_send_buffer). + assert_ne!(word & FRAGMENT_BIT, 0, "fragment bit must be set"); + assert_eq!((word & !FRAGMENT_BIT) as usize, body.len()); + + // The body is byte-for-byte what gssproxy-proto encodes (the same XDR + // the C client emits via xdr_gp_rpc_msg + the proc arg encoder). + let xid = xid_of(body); + let expected = encode_request( + xid, + GssxProc::IndicateMechs as u32, + &ArgIndicateMechs::default(), + ); + assert_eq!(body, &expected); + + // Envelope fields match the constants gpm_common.c hard-codes. + let msg = Message::decode(&mut XdrDecoder::new(body)).unwrap(); + assert!(msg.is_call); + let ch = msg.call.unwrap(); + assert_eq!(ch.rpcvers, RPC_VERS); + assert_eq!(ch.prog, GSSPROXY); + assert_eq!(ch.vers, GSSPROXYVERS); + assert_eq!(ch.proc_num, GssxProc::IndicateMechs as u32); + assert_eq!(ch.cred.flavor, 0); // AUTH_NONE + assert!(ch.cred.body.is_empty()); + assert_eq!(ch.verf.flavor, 0); + assert!(ch.verf.body.is_empty()); + + cleanup(dir, sock); + } + + #[test] + fn round_trips_multiple_procs() { + let _g = serial(); + let (dir, sock) = setup_socket("multiproc"); + // Two connections: one indicate_mechs, one init_sec_context. + let server = spawn_server(sock.clone(), 2, |i, body| { + let xid = xid_of(body); + Some(if i == 0 { + success_reply(xid, &ResIndicateMechs::default()) + } else { + success_reply(xid, &ResInitSecContext::default()) + }) + }); + + let r1: ResIndicateMechs = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()).unwrap(); + assert_eq!(r1, ResIndicateMechs::default()); + reset_connection(); // force the second proc onto a new connection + let r2: ResInitSecContext = + make_call(GssxProc::InitSecContext, &ArgInitSecContext::default()).unwrap(); + assert_eq!(r2, ResInitSecContext::default()); + + let recorded = server.join().unwrap(); + assert_eq!(recorded.len(), 2); + assert_eq!( + Message::decode(&mut XdrDecoder::new(&recorded[1].1)) + .unwrap() + .call + .unwrap() + .proc_num, + GssxProc::InitSecContext as u32 + ); + cleanup(dir, sock); + } + + #[test] + fn reuses_connection_and_increments_xid() { + let _g = serial(); + let (dir, sock) = setup_socket("reuse"); + let server = spawn_server_persistent(sock.clone()); + + let _a: ResIndicateMechs = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()).unwrap(); + let _b: ResIndicateMechs = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()).unwrap(); + // Closing the client connection lets the single-connection server exit. + reset_connection(); + + let recorded = server.join().unwrap(); + assert_eq!(recorded.len(), 2, "both calls used the same connection"); + let x0 = xid_of(&recorded[0]); + let x1 = xid_of(&recorded[1]); + assert_eq!(x1, x0.wrapping_add(1), "xid increments by one per call"); + cleanup(dir, sock); + } + + #[test] + fn rejects_reply_with_wrong_xid() { + let _g = serial(); + let (dir, sock) = setup_socket("wrongxid"); + let server = spawn_server(sock.clone(), 1, |_, body| { + Some(success_reply( + xid_of(body).wrapping_add(99), + &ResIndicateMechs::default(), + )) + }); + let r: Result = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()); + assert!(matches!(r, Err(GpmError::BadReply))); + server.join().unwrap(); + cleanup(dir, sock); + } + + #[test] + fn rejects_reply_that_is_a_call() { + let _g = serial(); + let (dir, sock) = setup_socket("iscall"); + let server = spawn_server(sock.clone(), 1, |_, body| { + // Echo a CALL message back instead of a REPLY. + Some(frame(&encode_request( + xid_of(body), + GssxProc::IndicateMechs as u32, + &ArgIndicateMechs::default(), + ))) + }); + let r: Result = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()); + assert!(matches!(r, Err(GpmError::BadReply))); + server.join().unwrap(); + cleanup(dir, sock); + } + + fn encode_reply_body(reply: ReplyBody, xid: u32) -> Vec { + let msg = Message { + xid, + is_call: false, + call: None, + reply: Some(reply), + }; + let mut e = XdrEncoder::new(); + msg.encode(&mut e); + frame(&e.into_bytes()) + } + + #[test] + fn rejects_denied_reply() { + let _g = serial(); + let (dir, sock) = setup_socket("denied"); + let server = spawn_server(sock.clone(), 1, |_, body| { + Some(encode_reply_body( + ReplyBody::Denied { + reject_status: 1, + value: 0, + }, + xid_of(body), + )) + }); + let r: Result = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()); + assert!(matches!(r, Err(GpmError::BadReply))); + server.join().unwrap(); + cleanup(dir, sock); + } + + #[test] + fn rejects_accepted_non_success_reply() { + let _g = serial(); + let (dir, sock) = setup_socket("garbage"); + let server = spawn_server(sock.clone(), 1, |_, body| { + Some(encode_reply_body( + ReplyBody::AcceptedOther { + verf: Default::default(), + status: 4, // GARBAGE_ARGS + }, + xid_of(body), + )) + }); + let r: Result = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()); + assert!(matches!(r, Err(GpmError::BadReply))); + server.join().unwrap(); + cleanup(dir, sock); + } + + #[test] + fn rejects_multi_fragment_reply_header() { + let _g = serial(); + let (dir, sock) = setup_socket("multifrag"); + let server = spawn_server(sock.clone(), 1, |_, _| { + // A 4-byte header WITHOUT the fragment bit, then a byte of body. + let mut raw = (8u32).to_be_bytes().to_vec(); + raw.extend_from_slice(&[0u8; 8]); + Some(raw) + }); + let r: Result = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()); + assert!(matches!(r, Err(GpmError::Frame(_)))); + server.join().unwrap(); + cleanup(dir, sock); + } + + #[test] + fn rejects_oversized_reply_header() { + let _g = serial(); + let (dir, sock) = setup_socket("bigreply"); + let server = spawn_server(sock.clone(), 1, |_, _| { + let word = ((MAX_RPC_SIZE as u32) + 1) | FRAGMENT_BIT; + Some(word.to_be_bytes().to_vec()) + }); + let r: Result = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()); + assert!(matches!(r, Err(GpmError::Frame(_)))); + server.join().unwrap(); + cleanup(dir, sock); + } + + #[test] + fn truncated_reply_body_errors_after_retries() { + let _g = serial(); + let (dir, sock) = setup_socket("truncated"); + // Every attempt advertises 100 bytes but sends only 4, then closes. + // The short read is retryable, so all MAX_TIMEOUT_RETRY connections are + // consumed before the error surfaces. + let server = spawn_server(sock.clone(), MAX_TIMEOUT_RETRY, |_, _| { + let mut raw = (100u32 | FRAGMENT_BIT).to_be_bytes().to_vec(); + raw.extend_from_slice(&[0u8; 4]); + Some(raw) + }); + let r: Result = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()); + assert!(matches!(r, Err(GpmError::Io(_)))); + server.join().unwrap(); + cleanup(dir, sock); + } + + #[test] + fn reconnects_after_dropped_connection() { + let _g = serial(); + let (dir, sock) = setup_socket("reconnect"); + // First connection: read the request then drop it (dead daemon). + // Second connection (the retry): reply success. + let server = spawn_server(sock.clone(), 2, |i, body| { + if i == 0 { + None + } else { + Some(success_reply(xid_of(body), &ResIndicateMechs::default())) + } + }); + let r: ResIndicateMechs = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()).unwrap(); + assert_eq!(r, ResIndicateMechs::default()); + let recorded = server.join().unwrap(); + assert_eq!(recorded.len(), 2, "request was retried on a new connection"); + cleanup(dir, sock); + } + + #[test] + fn oversize_request_is_rejected_locally() { + let _g = serial(); + // No server needed: transact rejects on size before connecting. + let big = vec![0u8; MAX_RPC_SIZE + 1]; + let mut conn = CONN.lock().unwrap_or_else(|e| e.into_inner()); + let r = conn.transact(&big); + assert!(matches!(r, Err(GpmError::TooLarge))); + } + + #[test] + fn errno_mapping_matches_c_intent() { + assert_eq!(GpmError::TooLarge.errno(), libc::EMSGSIZE); + assert_eq!(GpmError::BadReply.errno(), libc::EIO); + let io = GpmError::Io(io::Error::from_raw_os_error(libc::ECONNREFUSED)); + assert_eq!(io.errno(), libc::ECONNREFUSED); + } + + #[test] + fn refresh_identity_drops_connection_on_pid_change() { + let _g = serial(); + let (a, _b) = UnixStream::pair().unwrap(); + let mut conn = CONN.lock().unwrap_or_else(|e| e.into_inner()); + conn.stream = Some(a); + // Pretend the connection was opened by a different process (post-fork). + conn.pid = unsafe { libc::getpid() }.wrapping_add(1); + conn.uid = unsafe { libc::geteuid() }; + conn.gid = unsafe { libc::getegid() }; + conn.refresh_identity(); + assert!( + conn.stream.is_none(), + "stale post-fork socket must be dropped" + ); + } + + #[test] + fn refresh_identity_keeps_connection_for_same_identity() { + let _g = serial(); + let (a, _b) = UnixStream::pair().unwrap(); + let mut conn = CONN.lock().unwrap_or_else(|e| e.into_inner()); + conn.stream = Some(a); + conn.pid = unsafe { libc::getpid() }; + conn.uid = unsafe { libc::geteuid() }; + conn.gid = unsafe { libc::getegid() }; + conn.refresh_identity(); + assert!(conn.stream.is_some(), "live connection must be retained"); + conn.stream = None; + } + + #[test] + fn socket_path_defaults_when_env_unset() { + let _g = serial(); + // SAFETY: env-mutating tests hold the `serial()` lock, so no other + // thread reads the environment concurrently. + unsafe { + std::env::remove_var("GSSPROXY_SOCKET"); + assert_eq!(socket_path(), OsString::from(GP_SOCKET_NAME)); + std::env::set_var("GSSPROXY_SOCKET", "/tmp/custom.sock"); + assert_eq!(socket_path(), OsString::from("/tmp/custom.sock")); + std::env::remove_var("GSSPROXY_SOCKET"); + } + } + + #[test] + fn survives_fork_with_independent_connection() { + let _g = serial(); + let (dir, sock) = setup_socket("fork"); + // Parent uses connection 0, child (after fork) uses connection 1. + let server = spawn_server(sock.clone(), 2, |_, body| { + Some(success_reply(xid_of(body), &ResIndicateMechs::default())) + }); + + // Parent call. + let _parent: ResIndicateMechs = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()).unwrap(); + + // SAFETY: single-threaded section guarded by the serial lock; the child + // only performs an async-signal-unsafe-free make_call then _exit. + let pid = unsafe { libc::fork() }; + assert!(pid >= 0, "fork failed"); + if pid == 0 { + let ok = make_call::<_, ResIndicateMechs>( + GssxProc::IndicateMechs, + &ArgIndicateMechs::default(), + ) + .is_ok(); + unsafe { libc::_exit(if ok { 0 } else { 1 }) }; + } + + let mut status: libc::c_int = 0; + unsafe { libc::waitpid(pid, &mut status, 0) }; + let child_ok = libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0; + assert!(child_ok, "child make_call after fork should succeed"); + + let recorded = server.join().unwrap(); + assert_eq!( + recorded.len(), + 2, + "parent and child each opened a connection" + ); + cleanup(dir, sock); + } + + // ---- chaos-monkey transport tests -------------------------------------- + // + // A misbehaving daemon shouldn't be able to panic, hang, or corrupt the + // client. We model the daemon as a per-connection behaviour and assert the + // observable outcome of `make_call` matches the C `gpm_send_recv_loop` + // contract: retryable transport faults are retried up to MAX_TIMEOUT_RETRY + // times (each on a fresh connection), while protocol-level faults surface + // immediately as a typed error. No input should ever produce a panic. + + #[derive(Debug, Clone, Copy)] + enum Behavior { + /// Correct accepted-success reply echoing our xid. + SuccessCorrect, + /// Same, but after a short (sub-timeout) delay. + SuccessSlow, + /// Correct reply with extra bytes inside the record frame (ignored). + SuccessTrailingGarbage, + /// Accepted-success reply for the wrong xid. + WrongXid, + /// A CALL message instead of a REPLY. + ReplyIsCall, + /// MSG_DENIED. + Denied, + /// MSG_ACCEPTED with a non-success accept status. + AcceptedOther, + /// Accepted-success envelope with no result body (undecodable result). + EmptyResult, + /// Record header without the last-fragment bit. + BadHeaderNoFragment, + /// Record header advertising more than MAX_RPC_SIZE. + BadHeaderOversized, + /// Header promising a long body, then a short body + close. + ShortBody, + /// Read the request, then close without replying. + DropAfterRead, + /// Close immediately without reading the request. + DropBeforeRead, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Expect { + Ok, + BadReply, + Frame, + Decode, + Io, + } + + /// Terminal outcome for a behaviour, or `None` if it is a retryable + /// transport fault that makes the client reconnect and try again. + fn classify(b: Behavior) -> Option { + match b { + Behavior::SuccessCorrect | Behavior::SuccessSlow | Behavior::SuccessTrailingGarbage => { + Some(Expect::Ok) + } + Behavior::WrongXid + | Behavior::ReplyIsCall + | Behavior::Denied + | Behavior::AcceptedOther => Some(Expect::BadReply), + Behavior::BadHeaderNoFragment | Behavior::BadHeaderOversized => Some(Expect::Frame), + Behavior::EmptyResult => Some(Expect::Decode), + Behavior::ShortBody | Behavior::DropAfterRead | Behavior::DropBeforeRead => None, + } + } + + /// Predict `make_call`'s result from the per-attempt behaviour sequence, + /// applying the same retry budget the client uses. + fn simulate(behs: &[Behavior]) -> Expect { + for &b in behs.iter().take(MAX_TIMEOUT_RETRY) { + if let Some(e) = classify(b) { + return e; + } + } + Expect::Io + } + + fn apply_behavior(b: Behavior, c: &mut UnixStream) { + if let Behavior::DropBeforeRead = b { + return; // dropping `c` closes it before the request is read + } + let body = match read_frame(c) { + Some((_, body)) => body, + None => return, + }; + let xid = xid_of(&body); + let bytes: Vec = match b { + Behavior::DropBeforeRead => return, + Behavior::DropAfterRead => return, + Behavior::SuccessCorrect => success_reply(xid, &ResIndicateMechs::default()), + Behavior::SuccessSlow => { + thread::sleep(Duration::from_millis(8)); + success_reply(xid, &ResIndicateMechs::default()) + } + Behavior::SuccessTrailingGarbage => { + let mut env = encode_reply(xid, &ResIndicateMechs::default()); + env.extend_from_slice(&[0xAA; 7]); + frame(&env) + } + Behavior::WrongXid => success_reply(xid.wrapping_add(99), &ResIndicateMechs::default()), + Behavior::ReplyIsCall => frame(&encode_request( + xid, + GssxProc::IndicateMechs as u32, + &ArgIndicateMechs::default(), + )), + Behavior::Denied => encode_reply_body( + ReplyBody::Denied { + reject_status: 1, + value: 0, + }, + xid, + ), + Behavior::AcceptedOther => encode_reply_body( + ReplyBody::AcceptedOther { + verf: Default::default(), + status: 4, + }, + xid, + ), + Behavior::EmptyResult => { + let mut e = XdrEncoder::new(); + Message::reply_success(xid).encode(&mut e); + frame(&e.into_bytes()) + } + Behavior::BadHeaderNoFragment => 8u32.to_be_bytes().to_vec(), + Behavior::BadHeaderOversized => (((MAX_RPC_SIZE as u32) + 1) | FRAGMENT_BIT) + .to_be_bytes() + .to_vec(), + Behavior::ShortBody => { + let mut raw = (100u32 | FRAGMENT_BIT).to_be_bytes().to_vec(); + raw.extend_from_slice(&[0u8; 4]); + raw + } + }; + let _ = c.write_all(&bytes); + let _ = c.flush(); + } + + /// Drive one `make_call` against a daemon that applies `behs[i]` to the + /// i-th connection the client opens. Returns the client's result. + fn run_sequence(behs: Vec) -> Result { + let (dir, sock) = setup_socket("chaos"); + let listener = UnixListener::bind(&sock).unwrap(); + listener.set_nonblocking(true).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_srv = stop.clone(); + let behs_srv = behs.clone(); + let handle = thread::spawn(move || { + let mut i = 0usize; + loop { + match listener.accept() { + Ok((mut c, _)) => { + c.set_nonblocking(false).ok(); + let b = behs_srv[i.min(behs_srv.len() - 1)]; + apply_behavior(b, &mut c); + i += 1; + } + Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => { + if stop_srv.load(Ordering::Relaxed) { + break; + } + thread::sleep(Duration::from_millis(1)); + } + Err(_) => break, + } + } + }); + + let r: Result = + make_call(GssxProc::IndicateMechs, &ArgIndicateMechs::default()); + + stop.store(true, Ordering::Relaxed); + let _ = handle.join(); + reset_connection(); + cleanup(dir, sock); + r + } + + fn behavior() -> impl Strategy { + prop_oneof![ + Just(Behavior::SuccessCorrect), + Just(Behavior::SuccessSlow), + Just(Behavior::SuccessTrailingGarbage), + Just(Behavior::WrongXid), + Just(Behavior::ReplyIsCall), + Just(Behavior::Denied), + Just(Behavior::AcceptedOther), + Just(Behavior::EmptyResult), + Just(Behavior::BadHeaderNoFragment), + Just(Behavior::BadHeaderOversized), + Just(Behavior::ShortBody), + Just(Behavior::DropAfterRead), + Just(Behavior::DropBeforeRead), + ] + } + + proptest! { + #![proptest_config(ProptestConfig { + cases: 96, + failure_persistence: None, + ..ProptestConfig::default() + })] + + /// For any sequence of daemon misbehaviours, `make_call` returns a + /// result consistent with the C retry contract and never panics/hangs. + #[test] + fn chaos_retry_matches_c_semantics( + behs in prop::collection::vec(behavior(), MAX_TIMEOUT_RETRY) + ) { + let _g = serial(); + let expected = simulate(&behs); + let r = run_sequence(behs.clone()); + let ok = match (expected, &r) { + (Expect::Ok, Ok(v)) => *v == ResIndicateMechs::default(), + (Expect::BadReply, Err(GpmError::BadReply)) => true, + (Expect::Frame, Err(GpmError::Frame(_))) => true, + (Expect::Decode, Err(GpmError::Decode(_))) => true, + (Expect::Io, Err(GpmError::Io(_))) => true, + _ => false, + }; + prop_assert!(ok, "behaviors {:?}: expected {:?}, got {:?}", behs, expected, r); + } + } +} diff --git a/rust/gssproxy-interposer/Cargo.toml b/rust/gssproxy-interposer/Cargo.toml new file mode 100644 index 000000000..6c420f070 --- /dev/null +++ b/rust/gssproxy-interposer/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "gssproxy-interposer" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "proxymech.so: a GSSAPI mechanism interposer plugin that proxies to gssproxy" + +[lib] +name = "proxymech" +crate-type = ["cdylib"] + +[lints] +workspace = true + +[dependencies] +gssproxy-proto.workspace = true +gssproxy-client.workspace = true +gssapi-sys.workspace = true +libc.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +proptest = "1.11.0" diff --git a/rust/gssproxy-interposer/src/behavior.rs b/rust/gssproxy-interposer/src/behavior.rs new file mode 100644 index 000000000..7d3635770 --- /dev/null +++ b/rust/gssproxy-interposer/src/behavior.rs @@ -0,0 +1,98 @@ +//! Interposer behavior selection and the global enable check. +//! +//! Port of `gpp_get_behavior` and `enabled()` from `src/mechglue/gss_plugin.c`. + +use std::sync::OnceLock; + +use crate::env; + +/// `enum gpp_behavior`. The wire/daemon split decides whether each operation +/// is attempted locally (real mech), remotely (via gssproxy), or both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Behavior { + LocalOnly, + LocalFirst, + RemoteFirst, + RemoteOnly, +} + +/// Compile-time default (autotools `--with-gpp-default-behavior`, default +/// `LOCAL_FIRST`). +const DEFAULT_BEHAVIOR: Behavior = Behavior::LocalFirst; + +/// Compile-time default for `GSS_ALWAYS_INTERPOSE` (autotools +/// `--enable-always-interpose`, default `false`). +const GSS_ALWAYS_INTERPOSE: bool = false; + +// Consumed by the forthcoming gssi_* data path (gpp_get_behavior call sites). +#[allow(dead_code)] +static BEHAVIOR: OnceLock = OnceLock::new(); + +/// Pure mapping of a `GSSPROXY_BEHAVIOR` value to a [`Behavior`] (unknown or +/// absent values fall back to the compiled-in default). Factored out so it can +/// be unit tested without the process-global `OnceLock` cache. +pub fn parse_behavior(value: Option<&str>) -> Behavior { + match value { + Some("LOCAL_ONLY") => Behavior::LocalOnly, + Some("LOCAL_FIRST") => Behavior::LocalFirst, + Some("REMOTE_FIRST") => Behavior::RemoteFirst, + Some("REMOTE_ONLY") => Behavior::RemoteOnly, + _ => DEFAULT_BEHAVIOR, + } +} + +/// `gpp_get_behavior`: resolve (once) the interposer behavior from +/// `GSSPROXY_BEHAVIOR`, falling back to the compiled-in default. +#[allow(dead_code)] +pub fn get() -> Behavior { + *BEHAVIOR.get_or_init(|| parse_behavior(env::get("GSSPROXY_BEHAVIOR").as_deref())) +} + +/// Pure form of [`enabled`]: resolve the `GSS_USE_PROXY` value to whether the +/// interposer is active, defaulting to `GSS_ALWAYS_INTERPOSE` when unset. +pub fn enabled_from(value: Option<&str>) -> bool { + match value { + Some(v) => env::boolean_is_true(v), + None => GSS_ALWAYS_INTERPOSE, + } +} + +/// `enabled()`: whether interposition is active at all. Defaults to +/// `GSS_ALWAYS_INTERPOSE`, overridden by `GSS_USE_PROXY`. This is what prevents +/// the gssproxy daemon itself from looping back into the interposer. +pub fn enabled() -> bool { + enabled_from(env::get("GSS_USE_PROXY").as_deref()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn behavior_parsing_matches_c_names() { + assert_eq!(parse_behavior(Some("LOCAL_ONLY")), Behavior::LocalOnly); + assert_eq!(parse_behavior(Some("LOCAL_FIRST")), Behavior::LocalFirst); + assert_eq!(parse_behavior(Some("REMOTE_FIRST")), Behavior::RemoteFirst); + assert_eq!(parse_behavior(Some("REMOTE_ONLY")), Behavior::RemoteOnly); + } + + #[test] + fn unknown_or_absent_behavior_uses_default() { + // Default is LOCAL_FIRST (autotools --with-gpp-default-behavior). + assert_eq!(parse_behavior(None), Behavior::LocalFirst); + assert_eq!(parse_behavior(Some("garbage")), Behavior::LocalFirst); + assert_eq!(parse_behavior(Some("")), Behavior::LocalFirst); + } + + #[test] + fn enabled_follows_gss_use_proxy() { + // GSS_ALWAYS_INTERPOSE defaults to false, so absence disables. + assert!(!enabled_from(None)); + for truthy in ["1", "on", "true", "yes", "YES", "True"] { + assert!(enabled_from(Some(truthy)), "{truthy:?} should enable"); + } + for falsy in ["0", "off", "false", "no", "nonsense", ""] { + assert!(!enabled_from(Some(falsy)), "{falsy:?} should disable"); + } + } +} diff --git a/rust/gssproxy-interposer/src/context.rs b/rust/gssproxy-interposer/src/context.rs new file mode 100644 index 000000000..82b004eeb --- /dev/null +++ b/rust/gssproxy-interposer/src/context.rs @@ -0,0 +1,494 @@ +//! `gssi_init_sec_context` and `gssi_accept_sec_context`. Port of +//! `gpp_init_sec_context.c` and `gpp_accept_sec_context.c`. + +use std::ptr; + +use gssapi_sys::consts; +use gssapi_sys::sys::{ + self, OM_uint32, gss_OID, gss_buffer_t, gss_channel_bindings_t, gss_cred_id_t, gss_ctx_id_t, + gss_name_t, +}; +use gssproxy_client::gpm; + +use crate::behavior::{self, Behavior}; +use crate::convert; +use crate::error::map_error; +use crate::handle::{ + CredHandle, CtxHandle, NameHandle, local_to_name, name_to_local, store_remote_creds, +}; +use crate::{handle, logging, special}; + +const COMPLETE: u32 = 0; +const CONTINUE: u32 = sys::GSS_S_CONTINUE_NEEDED; + +fn keep(maj: u32) -> bool { + maj == COMPLETE || maj == CONTINUE +} + +/// `init_ctx_local`: establish via the real local mechanism. +#[allow(clippy::too_many_arguments)] +unsafe fn init_ctx_local( + cred: &CredHandle, + ctx: &mut CtxHandle, + name: &mut NameHandle, + mech_type: gss_OID, + req_flags: OM_uint32, + time_req: OM_uint32, + input_cb: gss_channel_bindings_t, + input_token: gss_buffer_t, + actual_mech_type: *mut gss_OID, + output_token: gss_buffer_t, + ret_flags: *mut OM_uint32, + time_rec: *mut OM_uint32, +) -> (u32, u32) { + unsafe { + if name.local.is_null() + && let Some(r) = name.remote.as_mut() + { + let (maj, min, local) = name_to_local(r, mech_type); + if maj != COMPLETE { + return (maj, min); + } + name.local = local; + } + let sp = special::special_mech(mech_type as *const _); + let mut min: OM_uint32 = 0; + let maj = sys::gss_init_sec_context( + &mut min, + cred.local, + &mut ctx.local, + name.local, + sp, + req_flags, + time_req, + input_cb, + input_token, + actual_mech_type, + output_token, + ret_flags, + time_rec, + ); + (maj, min) + } +} + +/// `gssi_init_sec_context`. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn gssi_init_sec_context( + minor_status: *mut OM_uint32, + claimant_cred_handle: gss_cred_id_t, + context_handle: *mut gss_ctx_id_t, + target_name: gss_name_t, + mech_type: gss_OID, + req_flags: OM_uint32, + time_req: OM_uint32, + input_cb: gss_channel_bindings_t, + input_token: gss_buffer_t, + actual_mech_type: *mut gss_OID, + output_token: gss_buffer_t, + ret_flags: *mut OM_uint32, + time_rec: *mut OM_uint32, +) -> OM_uint32 { + unsafe { + logging::init(); + let _span = tracing::debug_span!("gssi_init_sec_context").entered(); + tracing::debug!( + req_flags, + time_req, + input_token = logging::buf_len(input_token), + "init_sec_context: enter" + ); + if !minor_status.is_null() { + *minor_status = 0; + } + if target_name.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + if mech_type.is_null() || special::is_special_oid(mech_type as *const _) { + return consts::GSS_S_BAD_MECH; + } + + let mut tmaj = COMPLETE; + let mut tmin = 0u32; + let mut local_only = false; + + // Context handle: reuse the existing one or allocate a fresh payload. + let ctx_ptr: *mut CtxHandle = if !(*context_handle).is_null() { + let p = *context_handle as *mut CtxHandle; + if !(*p).local.is_null() { + local_only = true; + } + p + } else { + CtxHandle::into_raw(CtxHandle::empty()) as *mut CtxHandle + }; + + // Credential handle. + let mut owned_cred: Option> = None; + let cred_ptr: *mut CredHandle; + let mut early: Option<(u32, u32)> = None; + if !claimant_cred_handle.is_null() { + cred_ptr = claimant_cred_handle as *mut CredHandle; + if !(*cred_ptr).local.is_null() { + local_only = true; + } else if local_only { + early = Some((consts::GSS_S_DEFECTIVE_CREDENTIAL, 0)); + } + } else { + owned_cred = Some(CredHandle::new(true, None)); + cred_ptr = owned_cred.as_mut().unwrap().as_mut() as *mut CredHandle; + } + + let behavior = if local_only { + Behavior::LocalOnly + } else { + behavior::get() + }; + let name_ptr = target_name as *mut NameHandle; + + let (mut maj, mut min) = 'done: { + if let Some(e) = early { + break 'done e; + } + let cred = &mut *cred_ptr; + let ctx = &mut *ctx_ptr; + let name = &mut *name_ptr; + + // Local first. + if behavior == Behavior::LocalOnly || behavior == Behavior::LocalFirst { + let (m, mi) = init_ctx_local( + cred, + ctx, + name, + mech_type, + req_flags, + time_req, + input_cb, + input_token, + actual_mech_type, + output_token, + ret_flags, + time_rec, + ); + if keep(m) || behavior == Behavior::LocalOnly { + break 'done (m, mi); + } + tmaj = m; + tmin = mi; + } + + // Remote. + if behavior != Behavior::LocalOnly { + if !name.local.is_null() && name.remote.is_none() { + let (m, mi, rn) = local_to_name(name.local); + if m != COMPLETE { + break 'done (m, mi); + } + name.remote = rn; + } + + if cred.remote.is_none() { + let mut slot: Option> = None; + let _ = handle::get_def_creds(Behavior::RemoteOnly, None, 1, &mut slot); + if let Some(s) = slot { + cred.remote = s.remote.clone(); + } + } + + let mech_bytes = convert::oid_bytes(mech_type).unwrap_or(&[]).to_vec(); + let cb = convert::cb_to_gssx(input_cb); + let in_tok = if input_token.is_null() { + None + } else { + Some(convert::read_buffer(input_token).to_vec()) + }; + let res = gpm::init_sec_context( + cred.remote.as_ref(), + ctx.remote.as_ref(), + name.remote.as_ref(), + &mech_bytes, + req_flags, + time_req, + cb.as_ref(), + in_tok.as_deref(), + ); + + if keep(res.major) { + ctx.remote = res.context; + if !actual_mech_type.is_null() { + *actual_mech_type = convert::intern_oid(&res.actual_mech); + } + if let Some(tok) = &res.output_token { + convert::write_buffer(output_token, tok); + } + if let Some(c) = &ctx.remote { + if !ret_flags.is_null() { + *ret_flags = c.ctx_flags as OM_uint32; + } + if !time_rec.is_null() { + *time_rec = c.lifetime as OM_uint32; + } + } + if let Some(oc) = res.out_cred { + cred.remote = Some(oc); + if let Some(rc) = &cred.remote { + let _ = store_remote_creds(cred.default_creds, &cred.store, rc); + } + } + break 'done (res.major, res.minor); + } + + if behavior == Behavior::RemoteFirst { + let (m, mi) = init_ctx_local( + cred, + ctx, + name, + mech_type, + req_flags, + time_req, + input_cb, + input_token, + actual_mech_type, + output_token, + ret_flags, + time_rec, + ); + break 'done (m, mi); + } + + break 'done (res.major, res.minor); + } + + (consts::GSS_S_FAILURE, 0) + }; + + // done: + if !keep(maj) && tmaj != COMPLETE { + maj = tmaj; + min = tmin; + } + + let ctx_local = (*ctx_ptr).local; + let ctx_remote_some = (*ctx_ptr).remote.is_some(); + if !keep(maj) { + if ctx_local.is_null() && !ctx_remote_some { + // Free the (empty) context payload. + drop(CtxHandle::from_raw(ctx_ptr as gss_ctx_id_t)); + *context_handle = ptr::null_mut(); + } else { + *context_handle = ctx_ptr as gss_ctx_id_t; + } + if !minor_status.is_null() { + *minor_status = map_error(min); + } + } else { + *context_handle = ctx_ptr as gss_ctx_id_t; + } + + drop(owned_cred); + maj + } +} + +/// `gssi_accept_sec_context`. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn gssi_accept_sec_context( + minor_status: *mut OM_uint32, + context_handle: *mut gss_ctx_id_t, + acceptor_cred_handle: gss_cred_id_t, + input_token_buffer: gss_buffer_t, + input_chan_bindings: gss_channel_bindings_t, + src_name: *mut gss_name_t, + mech_type: *mut gss_OID, + output_token: gss_buffer_t, + ret_flags: *mut OM_uint32, + time_rec: *mut OM_uint32, + delegated_cred_handle: *mut gss_cred_id_t, +) -> OM_uint32 { + unsafe { + logging::init(); + let _span = tracing::debug_span!("gssi_accept_sec_context").entered(); + tracing::debug!( + input_token = logging::buf_len(input_token_buffer), + "accept_sec_context: enter" + ); + let mut behavior = behavior::get(); + + let ctx_ptr: *mut CtxHandle = if !(*context_handle).is_null() { + let p = *context_handle as *mut CtxHandle; + if !(*p).local.is_null() { + behavior = Behavior::LocalOnly; + } else if (*p).remote.is_some() { + behavior = Behavior::RemoteOnly; + } + p + } else { + CtxHandle::into_raw(CtxHandle::empty()) as *mut CtxHandle + }; + + let mut owned_cred: Option> = None; + let cred_ptr: *mut CredHandle; + let mut early: Option<(u32, u32)> = None; + if !acceptor_cred_handle.is_null() { + cred_ptr = acceptor_cred_handle as *mut CredHandle; + } else { + let mut slot: Option> = None; + let (m, mi) = handle::get_def_creds(behavior, None, 2, &mut slot); + if m != COMPLETE { + early = Some((m, mi)); + } + owned_cred = slot; + cred_ptr = match owned_cred.as_mut() { + Some(c) => c.as_mut() as *mut CredHandle, + None => ptr::null_mut(), + }; + } + + if early.is_none() && !cred_ptr.is_null() { + let cred = &*cred_ptr; + if !cred.local.is_null() { + if behavior == Behavior::RemoteOnly { + early = Some((consts::GSS_S_DEFECTIVE_CREDENTIAL, 0)); + } else { + behavior = Behavior::LocalOnly; + } + } else if cred.remote.is_some() { + if behavior == Behavior::LocalOnly { + early = Some((consts::GSS_S_DEFECTIVE_CREDENTIAL, 0)); + } else { + behavior = Behavior::RemoteOnly; + } + } + } + + let mut name: Option> = if !src_name.is_null() { + Some(NameHandle::empty()) + } else { + None + }; + let mut deleg: Option> = if !delegated_cred_handle.is_null() { + Some(CredHandle::new(false, None)) + } else { + None + }; + + let (maj, min) = 'done: { + if let Some(e) = early { + break 'done e; + } + let cred = &*cred_ptr; + let ctx = &mut *ctx_ptr; + + match behavior { + Behavior::LocalOnly => { + let mut min: OM_uint32 = 0; + let name_local = name + .as_mut() + .map(|n| &mut n.local as *mut gss_name_t) + .unwrap_or(ptr::null_mut()); + let deleg_local = deleg + .as_mut() + .map(|d| &mut d.local as *mut gss_cred_id_t) + .unwrap_or(ptr::null_mut()); + let m = sys::gss_accept_sec_context( + &mut min, + &mut ctx.local, + cred.local, + input_token_buffer, + input_chan_bindings, + name_local, + mech_type, + output_token, + ret_flags, + time_rec, + deleg_local, + ); + (m, min) + } + Behavior::RemoteOnly => { + let cb = convert::cb_to_gssx(input_chan_bindings); + let in_tok = if input_token_buffer.is_null() { + Vec::new() + } else { + convert::read_buffer(input_token_buffer).to_vec() + }; + let res = gpm::accept_sec_context( + ctx.remote.as_ref(), + cred.remote.as_ref(), + &in_tok, + cb.as_ref(), + !delegated_cred_handle.is_null(), + ); + if keep(res.major) { + ctx.remote = res.context; + if let Some(n) = name.as_mut() { + n.remote = res.src_name; + } + if let Some(tok) = &res.output_token { + convert::write_buffer(output_token, tok); + } + if !mech_type.is_null() { + *mech_type = convert::intern_oid(&res.actual_mech); + } + if let Some(c) = &ctx.remote { + if !ret_flags.is_null() { + *ret_flags = c.ctx_flags as OM_uint32; + } + if !time_rec.is_null() { + *time_rec = c.lifetime as OM_uint32; + } + } + if let Some(d) = deleg.as_mut() { + d.remote = res.delegated_cred; + } + } + (res.major, res.minor) + } + _ => (consts::GSS_S_FAILURE, 0), + } + }; + + if !minor_status.is_null() { + *minor_status = map_error(min); + } + + if !keep(maj) { + let ctx_local = (*ctx_ptr).local; + let ctx_remote_some = (*ctx_ptr).remote.is_some(); + if ctx_local.is_null() && !ctx_remote_some { + drop(CtxHandle::from_raw(ctx_ptr as gss_ctx_id_t)); + *context_handle = ptr::null_mut(); + } else { + *context_handle = ctx_ptr as gss_ctx_id_t; + } + drop(name); + drop(deleg); + } else { + *context_handle = ctx_ptr as gss_ctx_id_t; + if !src_name.is_null() { + *src_name = match name.take() { + Some(n) => NameHandle::into_raw(n), + None => ptr::null_mut(), + }; + } + if !delegated_cred_handle.is_null() { + *delegated_cred_handle = match deleg.take() { + Some(d) => CredHandle::into_raw(d), + None => ptr::null_mut(), + }; + } + } + + // gpp: when we synthesised a default cred, release it like + // `gssi_release_cred` does (remote daemon release + local release on drop). + if let Some(c) = &owned_cred + && let Some(r) = &c.remote + { + let _ = gpm::release_cred(r); + } + drop(owned_cred); + maj + } +} diff --git a/rust/gssproxy-interposer/src/convert.rs b/rust/gssproxy-interposer/src/convert.rs new file mode 100644 index 000000000..7d7ac5bb3 --- /dev/null +++ b/rust/gssproxy-interposer/src/convert.rs @@ -0,0 +1,343 @@ +//! Conversions between the GSSAPI C ABI and the `gssx`/Rust values used by the +//! `gpm` layer. The mirror of the daemon-side `gp_conv.c`, but in the +//! client/interposer direction. + +use std::os::raw::c_void; +use std::ptr; + +use gssapi_sys::sys::{ + self, OM_uint32, gss_OID, gss_OID_desc, gss_buffer_desc, gss_buffer_t, gss_channel_bindings_t, +}; +use gssproxy_proto::gssx::{GssxCb, Opaque}; + +/// Read an input `gss_buffer_t` as a byte slice (empty when null/empty). +/// +/// # Safety +/// `buf` must be null or point to a valid `gss_buffer_desc`. +pub unsafe fn read_buffer<'a>(buf: gss_buffer_t) -> &'a [u8] { + unsafe { + if buf.is_null() { + return &[]; + } + let b = &*buf; + if b.value.is_null() || b.length == 0 { + return &[]; + } + std::slice::from_raw_parts(b.value as *const u8, b.length) + } +} + +/// Write `data` into an output `gss_buffer_t`, allocating with `malloc` so the +/// generic `gss_release_buffer` (which calls `free`) can release it. +/// +/// Returns false on allocation failure. +/// +/// # Safety +/// `out` must be null or point to a writable `gss_buffer_desc`. +pub unsafe fn write_buffer(out: gss_buffer_t, data: &[u8]) -> bool { + unsafe { + if out.is_null() { + return true; + } + let b = &mut *out; + if data.is_empty() { + b.length = 0; + b.value = ptr::null_mut(); + return true; + } + let p = libc::malloc(data.len()) as *mut u8; + if p.is_null() { + b.length = 0; + b.value = ptr::null_mut(); + return false; + } + ptr::copy_nonoverlapping(data.as_ptr(), p, data.len()); + b.length = data.len() as _; + b.value = p as *mut c_void; + true + } +} + +/// Borrow the DER bytes behind a `gss_OID` (None when null). +/// +/// # Safety +/// `oid` must be null or a valid `gss_OID`. +pub unsafe fn oid_bytes<'a>(oid: gss_OID) -> Option<&'a [u8]> { + unsafe { crate::oids::oid_bytes(oid as *const gss_OID_desc) } +} + +/// Build an owned `gss_OID_desc` over leaked bytes for a returned static OID +/// pointer. Used where C hands back a stable `gss_OID` the caller must not free. +pub fn leak_oid(bytes: &[u8]) -> gss_OID { + let boxed: Box<[u8]> = bytes.to_vec().into_boxed_slice(); + let len = boxed.len(); + let elements = Box::into_raw(boxed) as *mut c_void; + let desc = Box::new(gss_OID_desc { + length: len as OM_uint32, + elements, + }); + Box::into_raw(desc) as gss_OID +} + +/// Convert GSSAPI channel bindings into the `gssx` form. +/// +/// # Safety +/// `cb` must be null or point to a valid `gss_channel_bindings_struct`. +pub unsafe fn cb_to_gssx(cb: gss_channel_bindings_t) -> Option { + unsafe { + if cb.is_null() { + return None; + } + let c = &*cb; + let read = |b: &gss_buffer_desc| -> Opaque { + if b.value.is_null() || b.length == 0 { + Opaque::new(Vec::new()) + } else { + Opaque::new(std::slice::from_raw_parts(b.value as *const u8, b.length).to_vec()) + } + }; + Some(GssxCb { + initiator_addrtype: c.initiator_addrtype as u64, + initiator_address: read(&c.initiator_address), + acceptor_addrtype: c.acceptor_addrtype as u64, + acceptor_address: read(&c.acceptor_address), + application_data: read(&c.application_data), + }) + } +} + +/// A transient `gss_OID_desc` over borrowed bytes, for passing to `sys::gss_*`. +pub struct TmpOid { + desc: gss_OID_desc, + _bytes: Vec, +} + +impl TmpOid { + pub fn new(bytes: &[u8]) -> Self { + let b = bytes.to_vec(); + let desc = gss_OID_desc { + length: b.len() as OM_uint32, + elements: b.as_ptr() as *mut c_void, + }; + TmpOid { desc, _bytes: b } + } + pub fn as_ptr(&self) -> gss_OID { + &self.desc as *const _ as gss_OID + } +} + +/// A transient input `gss_buffer_desc` over borrowed bytes. +pub struct TmpBuf { + desc: gss_buffer_desc, + _bytes: Vec, +} + +impl TmpBuf { + pub fn new(bytes: &[u8]) -> Self { + let b = bytes.to_vec(); + let desc = gss_buffer_desc { + length: b.len() as _, + value: b.as_ptr() as *mut c_void, + }; + TmpBuf { desc, _bytes: b } + } + pub fn as_ptr(&self) -> gss_buffer_t { + &self.desc as *const _ as gss_buffer_t + } +} + +use std::collections::HashMap; +use std::sync::Mutex; + +static OID_INTERN: Mutex, usize>>> = Mutex::new(None); + +/// Return a process-stable `gss_OID` for `bytes`, leaking it on first use and +/// reusing the same pointer thereafter. Used for "static" mech/name-type OIDs +/// the mechglue hands back and callers must not free (C: `gpm_*_to_static`). +pub fn intern_oid(bytes: &[u8]) -> gss_OID { + let mut guard = OID_INTERN.lock().unwrap_or_else(|e| e.into_inner()); + let map = guard.get_or_insert_with(HashMap::new); + if let Some(&p) = map.get(bytes) { + return p as gss_OID; + } + let p = leak_oid(bytes); + map.insert(bytes.to_vec(), p as usize); + p +} + +/// `gpm_name_oid_to_static`: map name-type OID bytes to a recognised static +/// OID pointer, or None (ENOENT) when not one of the known name types. +pub fn name_type_static(bytes: &[u8]) -> Option { + use gssapi_sys::consts::*; + const KNOWN: &[&[u8]] = &[ + NT_USER_NAME_OID, + NT_MACHINE_UID_NAME_OID, + NT_STRING_UID_NAME_OID, + NT_HOSTBASED_SERVICE_X_OID, + NT_HOSTBASED_SERVICE_OID, + NT_ANONYMOUS_OID, + NT_EXPORT_NAME_OID, + NT_COMPOSITE_EXPORT_OID, + KRB5_NT_PRINCIPAL_NAME_OID, + ]; + if KNOWN.contains(&bytes) { + Some(intern_oid(bytes)) + } else { + None + } +} + +/// Release a `gss_buffer_desc` produced by a real `gss_*` call. +/// +/// # Safety +/// `buf` must point to a valid `gss_buffer_desc`. +pub unsafe fn release_buffer(buf: *mut gss_buffer_desc) { + unsafe { + let mut min: OM_uint32 = 0; + sys::gss_release_buffer(&mut min, buf); + } +} + +/// Collect the member OIDs of a `gss_OID_set` as byte vectors. +/// +/// # Safety +/// `set` must be null or a valid `gss_OID_set`. +pub unsafe fn oidset_to_vecs(set: sys::gss_OID_set) -> Vec> { + unsafe { + if set.is_null() { + return Vec::new(); + } + let s = &*set; + let mut out = Vec::with_capacity(s.count); + for i in 0..s.count { + let m = s.elements.add(i) as gss_OID; + out.push(oid_bytes(m).unwrap_or(&[]).to_vec()); + } + out + } +} + +/// Build a freshly-allocated `gss_OID_set` from byte-vector OIDs. Returns null +/// on allocation failure. +pub unsafe fn build_oid_set(mechs: &[Vec]) -> sys::gss_OID_set { + unsafe { + let mut min: OM_uint32 = 0; + let mut set: sys::gss_OID_set = ptr::null_mut(); + if sys::gss_create_empty_oid_set(&mut min, &mut set) != 0 { + return ptr::null_mut(); + } + for m in mechs { + let t = TmpOid::new(m); + sys::gss_add_oid_set_member(&mut min, t.as_ptr(), &mut set); + } + set + } +} + +/// `gpmint_cred_to_actual_mechs`: write a remote cred's element mechs into +/// `*out` (left as `GSS_C_NO_OID_SET` when the cred has no elements). +/// +/// # Safety +/// `out` must be null or point to a writable `gss_OID_set`. +pub unsafe fn write_actual_mechs(out: *mut sys::gss_OID_set, mechs: &[Vec]) -> bool { + unsafe { + if out.is_null() { + return true; + } + if mechs.is_empty() { + *out = ptr::null_mut(); + return true; + } + let set = build_oid_set(mechs); + if set.is_null() { + return false; + } + *out = set; + true + } +} + +/// Read the `ccache` entry from a `gss_const_key_value_set_t`, if present. +/// +/// # Safety +/// `store` must be null or a valid `gss_key_value_set_desc`. +pub unsafe fn ccache_from_store(store: sys::gss_const_key_value_set_t) -> Option { + unsafe { + let kv = kvset_to_vec(store); + kv.into_iter().find(|(k, _)| k == "ccache").map(|(_, v)| v) + } +} + +/// Convert a `gss_const_key_value_set_t` into owned key/value pairs. +/// +/// # Safety +/// `store` must be null or a valid `gss_key_value_set_desc`. +pub unsafe fn kvset_to_vec(store: sys::gss_const_key_value_set_t) -> Vec<(String, String)> { + unsafe { + if store.is_null() { + return Vec::new(); + } + let s = &*store; + let mut out = Vec::with_capacity(s.count as usize); + for i in 0..s.count { + let e = &*s.elements.add(i as usize); + let key = cstr_to_string(e.key); + let value = cstr_to_string(e.value); + if let (Some(k), Some(v)) = (key, value) { + out.push((k, v)); + } + } + out + } +} + +unsafe fn cstr_to_string(p: *const std::os::raw::c_char) -> Option { + unsafe { + if p.is_null() { + return None; + } + std::ffi::CStr::from_ptr(p) + .to_str() + .ok() + .map(|s| s.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn intern_oid_is_stable_and_round_trips() { + let a = intern_oid(b"\x2a\x86\x48"); + let b = intern_oid(b"\x2a\x86\x48"); + // Same bytes => identical stable pointer (mechglue compares by identity). + assert_eq!(a as *const _, b as *const _); + let bytes = unsafe { oid_bytes(a) }.unwrap(); + assert_eq!(bytes, b"\x2a\x86\x48"); + + // Distinct bytes => distinct interned pointer. + let c = intern_oid(b"\x2a\x86\x49"); + assert_ne!(a as *const _, c as *const _); + } + + #[test] + fn name_type_static_maps_known_only() { + use gssapi_sys::consts::NT_USER_NAME_OID; + assert!(name_type_static(NT_USER_NAME_OID).is_some()); + assert!(name_type_static(b"\x99\x99\x99").is_none()); + } + + #[test] + fn oid_set_round_trips_through_bytes() { + let mechs = vec![b"\x2a\x86\x48".to_vec(), b"\x2b\x06\x01".to_vec()]; + unsafe { + let set = build_oid_set(&mechs); + assert!(!set.is_null()); + let back = oidset_to_vecs(set); + assert_eq!(back, mechs); + let mut min: OM_uint32 = 0; + sys::gss_release_oid_set(&mut min, &mut (set as sys::gss_OID_set)); + } + } +} diff --git a/rust/gssproxy-interposer/src/creds.rs b/rust/gssproxy-interposer/src/creds.rs new file mode 100644 index 000000000..167ea21c9 --- /dev/null +++ b/rust/gssproxy-interposer/src/creds.rs @@ -0,0 +1,1022 @@ +//! `gssi_*` credential operations. Port of `gpp_acquire_cred.c` and +//! `gpp_creds.c`. + +use std::ptr; + +use gssapi_sys::consts; +use gssapi_sys::sys::{ + self, OM_uint32, gss_OID, gss_OID_set, gss_buffer_set_t, gss_buffer_t, + gss_const_key_value_set_t, gss_cred_id_t, gss_name_t, +}; +use gssproxy_client::gpm; + +use crate::behavior::{self, Behavior}; +use crate::convert; +use crate::error::map_error; +use crate::handle::{CredHandle, NameHandle, name_to_local, store_remote_creds}; +use crate::{handle, logging, special}; + +const COMPLETE: u32 = 0; +const CONTINUE: u32 = sys::GSS_S_CONTINUE_NEEDED; +const GSS_C_ACCEPT: i32 = 2; +const GSS_C_INITIATE: i32 = 1; +const GSS_C_BOTH: i32 = 0; + +unsafe fn set_min(minor_status: *mut OM_uint32, min: u32) { + unsafe { + if !minor_status.is_null() { + *minor_status = map_error(min); + } + } +} + +/// `acquire_local`: acquire local creds (optionally impersonating) using the +/// special form of `desired_mechs`. +#[allow(clippy::too_many_arguments)] +unsafe fn acquire_local( + imp_cred: Option<&CredHandle>, + name: Option<&mut NameHandle>, + time_req: u32, + desired_mechs: gss_OID_set, + cred_usage: i32, + cred_store: gss_const_key_value_set_t, + out: &mut CredHandle, + actual_mechs: *mut gss_OID_set, + time_rec: *mut OM_uint32, +) -> handle::Status { + unsafe { + let mut special = special::special_available_mechs(desired_mechs); + if special.is_null() { + return (consts::GSS_S_BAD_MECH, 0); + } + + let name_local = match name { + Some(n) => { + if n.local.is_null() { + let mech = n.mech_ptr(); + if let Some(r) = n.remote.as_mut() { + let (maj, min, local) = name_to_local(r, mech); + if maj != COMPLETE { + let mut m: OM_uint32 = 0; + sys::gss_release_oid_set(&mut m, &mut special); + return (maj, min); + } + n.local = local; + } + } + n.local + } + None => ptr::null_mut(), + }; + + let mut min: OM_uint32 = 0; + let maj = if let Some(ic) = imp_cred { + sys::gss_acquire_cred_impersonate_name( + &mut min, + ic.local, + name_local, + time_req, + special, + cred_usage, + &mut out.local, + actual_mechs, + time_rec, + ) + } else { + sys::gss_acquire_cred_from( + &mut min, + name_local, + time_req, + special, + cred_usage, + cred_store, + &mut out.local, + actual_mechs, + time_rec, + ) + }; + + let mut m: OM_uint32 = 0; + sys::gss_release_oid_set(&mut m, &mut special); + (maj, min) + } +} + +/// `gssi_acquire_cred`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_acquire_cred( + minor_status: *mut OM_uint32, + desired_name: gss_name_t, + time_req: OM_uint32, + desired_mechs: gss_OID_set, + cred_usage: i32, + output_cred_handle: *mut gss_cred_id_t, + actual_mechs: *mut gss_OID_set, + time_rec: *mut OM_uint32, +) -> OM_uint32 { + unsafe { + gssi_acquire_cred_from( + minor_status, + desired_name, + time_req, + desired_mechs, + cred_usage, + ptr::null(), + output_cred_handle, + actual_mechs, + time_rec, + ) + } +} + +/// `gssi_acquire_cred_from`. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn gssi_acquire_cred_from( + minor_status: *mut OM_uint32, + desired_name: gss_name_t, + time_req: OM_uint32, + desired_mechs: gss_OID_set, + cred_usage: i32, + cred_store: gss_const_key_value_set_t, + output_cred_handle: *mut gss_cred_id_t, + actual_mechs: *mut gss_OID_set, + time_rec: *mut OM_uint32, +) -> OM_uint32 { + unsafe { + logging::init(); + let _span = tracing::debug_span!("gssi_acquire_cred_from", cred_usage, time_req).entered(); + tracing::debug!("acquire_cred: enter"); + if output_cred_handle.is_null() { + set_min(minor_status, libc::EINVAL as u32); + return consts::GSS_S_FAILURE; + } + + let mut tmaj = COMPLETE; + let mut tmin = 0u32; + let mut behavior = behavior::get(); + + let ccache_name = convert::ccache_from_store(cred_store); + let mut out = CredHandle::new(ccache_name.is_none(), ccache_name.as_deref()); + + // Always check whether we have remote creds in the local ccache. + let mut in_cred_remote = None; + if behavior != Behavior::LocalOnly { + let (rmaj, _, remote) = handle::retrieve_remote_creds(ccache_name.as_deref(), None); + if rmaj == COMPLETE { + in_cred_remote = remote; + behavior = Behavior::RemoteFirst; + } else if ccache_name.is_some() { + behavior = Behavior::LocalFirst; + } + } + + let name = NameHandle::as_mut(desired_name); + let mut maj; + let mut min; + + // Local first. + if behavior == Behavior::LocalOnly || behavior == Behavior::LocalFirst { + let nref = NameHandle::as_mut(desired_name); + let (m, mi) = acquire_local( + None, + nref, + time_req, + desired_mechs, + cred_usage, + cred_store, + &mut out, + actual_mechs, + time_rec, + ); + maj = m; + min = mi; + if maj == COMPLETE || behavior == Behavior::LocalOnly { + return finish_acquire(minor_status, output_cred_handle, out, maj, min, tmaj, tmin); + } + tmaj = maj; + tmin = min; + } + + // Remote. + if let Some(n) = name + && !n.local.is_null() + && n.remote.is_none() + { + let (m, mi, rn) = handle::local_to_name(n.local); + if m != COMPLETE { + return finish_acquire(minor_status, output_cred_handle, out, m, mi, tmaj, tmin); + } + n.remote = rn; + } + + let desired = convert::oidset_to_vecs(desired_mechs); + let name_remote = NameHandle::as_mut(desired_name).and_then(|n| n.remote.clone()); + let acq = gpm::acquire_cred( + in_cred_remote.as_ref(), + name_remote.as_ref(), + time_req, + &desired, + cred_usage, + false, + ); + maj = acq.major; + min = acq.minor; + if maj == COMPLETE { + out.remote = acq.cred; + if !actual_mechs.is_null() { + let mechs = out + .remote + .as_ref() + .map(|c| { + c.elements + .iter() + .map(|e| e.mech.as_slice().to_vec()) + .collect::>() + }) + .unwrap_or_default(); + convert::write_actual_mechs(actual_mechs, &mechs); + } + if !time_rec.is_null() { + *time_rec = acq.time_rec; + } + if let Some(rc) = &out.remote + && !handle::creds_are_equal(in_cred_remote.as_ref(), Some(rc)) + { + let (sm, _) = store_remote_creds(out.default_creds, &out.store, rc); + if sm != COMPLETE { + maj = sm; + } + } + return finish_acquire(minor_status, output_cred_handle, out, maj, min, tmaj, tmin); + } + + if behavior == Behavior::RemoteFirst { + tmaj = maj; + tmin = min; + let nref = NameHandle::as_mut(desired_name); + let (m, mi) = acquire_local( + None, + nref, + time_req, + desired_mechs, + cred_usage, + cred_store, + &mut out, + actual_mechs, + time_rec, + ); + maj = m; + min = mi; + } + + finish_acquire(minor_status, output_cred_handle, out, maj, min, tmaj, tmin) + } +} + +unsafe fn finish_acquire( + minor_status: *mut OM_uint32, + output_cred_handle: *mut gss_cred_id_t, + out: Box, + mut maj: u32, + mut min: u32, + tmaj: u32, + tmin: u32, +) -> OM_uint32 { + unsafe { + if maj != COMPLETE && maj != CONTINUE && tmaj != COMPLETE { + maj = tmaj; + min = tmin; + } + if maj == COMPLETE { + *output_cred_handle = CredHandle::into_raw(out); + } else { + drop(out); + } + set_min(minor_status, min); + maj + } +} + +/// `gssi_add_cred`. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn gssi_add_cred( + minor_status: *mut OM_uint32, + input_cred_handle: gss_cred_id_t, + desired_name: gss_name_t, + desired_mech: gss_OID, + cred_usage: i32, + initiator_time_req: OM_uint32, + acceptor_time_req: OM_uint32, + output_cred_handle: *mut gss_cred_id_t, + actual_mechs: *mut gss_OID_set, + initiator_time_rec: *mut OM_uint32, + acceptor_time_rec: *mut OM_uint32, +) -> OM_uint32 { + unsafe { + gssi_add_cred_from( + minor_status, + input_cred_handle, + desired_name, + desired_mech, + cred_usage, + initiator_time_req, + acceptor_time_req, + ptr::null(), + output_cred_handle, + actual_mechs, + initiator_time_rec, + acceptor_time_rec, + ) + } +} + +/// `gssi_add_cred_from`. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn gssi_add_cred_from( + minor_status: *mut OM_uint32, + _input_cred_handle: gss_cred_id_t, + desired_name: gss_name_t, + desired_mech: gss_OID, + cred_usage: i32, + initiator_time_req: OM_uint32, + acceptor_time_req: OM_uint32, + _cred_store: gss_const_key_value_set_t, + output_cred_handle: *mut gss_cred_id_t, + actual_mechs: *mut gss_OID_set, + initiator_time_rec: *mut OM_uint32, + acceptor_time_rec: *mut OM_uint32, +) -> OM_uint32 { + unsafe { + if output_cred_handle.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_WRITE; + } + + let mut desired_mechs: gss_OID_set = ptr::null_mut(); + if !desired_mech.is_null() { + let mut min: OM_uint32 = 0; + if sys::gss_create_empty_oid_set(&mut min, &mut desired_mechs) != COMPLETE { + set_min(minor_status, min); + return consts::GSS_S_FAILURE; + } + if sys::gss_add_oid_set_member(&mut min, desired_mech, &mut desired_mechs) != COMPLETE { + sys::gss_release_oid_set(&mut min, &mut desired_mechs); + set_min(minor_status, min); + return consts::GSS_S_FAILURE; + } + } + + let time_req = match cred_usage { + GSS_C_ACCEPT => acceptor_time_req, + GSS_C_INITIATE => initiator_time_req, + GSS_C_BOTH => acceptor_time_req.max(initiator_time_req), + _ => 0, + }; + + let mut time_rec: OM_uint32 = 0; + let maj = gssi_acquire_cred_from( + minor_status, + desired_name, + time_req, + desired_mechs, + cred_usage, + ptr::null(), + output_cred_handle, + actual_mechs, + &mut time_rec, + ); + if maj == COMPLETE { + if !acceptor_time_rec.is_null() + && (cred_usage == GSS_C_ACCEPT || cred_usage == GSS_C_BOTH) + { + *acceptor_time_rec = time_rec; + } + if !initiator_time_rec.is_null() + && (cred_usage == GSS_C_INITIATE || cred_usage == GSS_C_BOTH) + { + *initiator_time_rec = time_rec; + } + } + + let mut min: OM_uint32 = 0; + sys::gss_release_oid_set(&mut min, &mut desired_mechs); + maj + } +} + +/// `gssi_acquire_cred_with_password`: local-only (REMOTE_ONLY unsupported). +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn gssi_acquire_cred_with_password( + minor_status: *mut OM_uint32, + desired_name: gss_name_t, + password: gss_buffer_t, + time_req: OM_uint32, + desired_mechs: gss_OID_set, + cred_usage: i32, + output_cred_handle: *mut gss_cred_id_t, + actual_mechs: *mut gss_OID_set, + time_rec: *mut OM_uint32, +) -> OM_uint32 { + unsafe { + let name = match NameHandle::as_mut(desired_name) { + Some(n) => n, + None => { + set_min(minor_status, libc::EINVAL as u32); + return consts::GSS_S_BAD_NAME; + } + }; + if output_cred_handle.is_null() { + set_min(minor_status, libc::EINVAL as u32); + return consts::GSS_S_FAILURE; + } + if desired_mechs.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + + let behavior = behavior::get(); + let mut out = CredHandle::new(false, None); + + let (maj, min) = match behavior { + Behavior::LocalOnly | Behavior::LocalFirst | Behavior::RemoteFirst => { + let mut special = special::special_available_mechs(desired_mechs); + if special.is_null() { + (consts::GSS_S_FAILURE, libc::EINVAL as u32) + } else { + if name.local.is_null() { + let mech = name.mech_ptr(); + if let Some(r) = name.remote.as_mut() { + let (m, mi, local) = name_to_local(r, mech); + if m != COMPLETE { + let mut z: OM_uint32 = 0; + sys::gss_release_oid_set(&mut z, &mut special); + set_min(minor_status, mi); + return m; + } + name.local = local; + } + } + let mut min: OM_uint32 = 0; + let maj = sys::gss_acquire_cred_with_password( + &mut min, + name.local, + password, + time_req, + special, + cred_usage, + &mut out.local, + actual_mechs, + time_rec, + ); + let mut z: OM_uint32 = 0; + sys::gss_release_oid_set(&mut z, &mut special); + (maj, min) + } + } + Behavior::RemoteOnly => (consts::GSS_S_FAILURE, libc::EINVAL as u32), + }; + + if maj == COMPLETE { + *output_cred_handle = CredHandle::into_raw(out); + } else { + drop(out); + } + set_min(minor_status, min); + maj + } +} + +/// `gssi_acquire_cred_impersonate_name`. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn gssi_acquire_cred_impersonate_name( + minor_status: *mut OM_uint32, + imp_cred_handle: gss_cred_id_t, + desired_name: gss_name_t, + time_req: OM_uint32, + desired_mechs: gss_OID_set, + cred_usage: i32, + output_cred_handle: *mut gss_cred_id_t, + actual_mechs: *mut gss_OID_set, + time_rec: *mut OM_uint32, +) -> OM_uint32 { + unsafe { + // NOTE: the GSSAPI SPI passes the impersonator credential *by value* + // (`gss_cred_id_t`), even though gssproxy's C prototype spells it + // `gss_cred_id_t *`. The handle pointer is used directly, not dereferenced. + if imp_cred_handle.is_null() { + set_min(minor_status, libc::EINVAL as u32); + return consts::GSS_S_NO_CRED; + } + let imp = match CredHandle::as_mut(imp_cred_handle) { + Some(c) => c, + None => { + set_min(minor_status, libc::EINVAL as u32); + return consts::GSS_S_NO_CRED; + } + }; + if output_cred_handle.is_null() { + set_min(minor_status, libc::EINVAL as u32); + return consts::GSS_S_FAILURE; + } + + let mut tmaj = COMPLETE; + let mut tmin = 0u32; + let mut out = CredHandle::new(false, None); + let behavior = behavior::get(); + + let mut maj; + let mut min; + + if behavior == Behavior::LocalOnly || behavior == Behavior::LocalFirst { + let nref = NameHandle::as_mut(desired_name); + let (m, mi) = acquire_local( + Some(&*imp), + nref, + time_req, + desired_mechs, + cred_usage, + ptr::null(), + &mut out, + actual_mechs, + time_rec, + ); + maj = m; + min = mi; + if maj == COMPLETE || behavior == Behavior::LocalOnly { + return finish_acquire(minor_status, output_cred_handle, out, maj, min, tmaj, tmin); + } + tmaj = maj; + tmin = min; + } + + if let Some(n) = NameHandle::as_mut(desired_name) + && !n.local.is_null() + && n.remote.is_none() + { + let (m, mi, rn) = handle::local_to_name(n.local); + if m != COMPLETE { + return finish_acquire(minor_status, output_cred_handle, out, m, mi, tmaj, tmin); + } + n.remote = rn; + } + + let desired = convert::oidset_to_vecs(desired_mechs); + let name_remote = NameHandle::as_mut(desired_name).and_then(|n| n.remote.clone()); + let acq = gpm::acquire_cred( + imp.remote.as_ref(), + name_remote.as_ref(), + time_req, + &desired, + cred_usage, + true, + ); + maj = acq.major; + min = acq.minor; + if maj == COMPLETE { + out.remote = acq.cred; + if !actual_mechs.is_null() { + let mechs = out + .remote + .as_ref() + .map(|c| { + c.elements + .iter() + .map(|e| e.mech.as_slice().to_vec()) + .collect::>() + }) + .unwrap_or_default(); + convert::write_actual_mechs(actual_mechs, &mechs); + } + if !time_rec.is_null() { + *time_rec = acq.time_rec; + } + return finish_acquire(minor_status, output_cred_handle, out, maj, min, tmaj, tmin); + } + if behavior == Behavior::RemoteOnly { + return finish_acquire(minor_status, output_cred_handle, out, maj, min, tmaj, tmin); + } + + if behavior == Behavior::RemoteFirst { + let nref = NameHandle::as_mut(desired_name); + let (m, mi) = acquire_local( + Some(&*imp), + nref, + time_req, + desired_mechs, + cred_usage, + ptr::null(), + &mut out, + actual_mechs, + time_rec, + ); + maj = m; + min = mi; + } + + finish_acquire(minor_status, output_cred_handle, out, maj, min, tmaj, tmin) + } +} + +/// `gssi_inquire_cred`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_inquire_cred( + minor_status: *mut OM_uint32, + cred_handle: gss_cred_id_t, + name: *mut gss_name_t, + lifetime: *mut OM_uint32, + cred_usage: *mut i32, + mechanisms: *mut gss_OID_set, +) -> OM_uint32 { + unsafe { + // Default-cred case: build a temporary handle. + let mut owned: Option> = None; + let no_cred = cred_handle.is_null(); + if no_cred { + let (maj, min) = + handle::get_def_creds(behavior::get(), None, GSS_C_INITIATE, &mut owned); + if maj != COMPLETE { + set_min(minor_status, min); + return maj; + } + } + let cred: &CredHandle = if no_cred { + owned.as_ref().unwrap() + } else { + match CredHandle::as_mut(cred_handle) { + Some(c) => c, + None => return consts::GSS_S_FAILURE, + } + }; + + let mut gpname = NameHandle::empty(); + let (maj, min) = if !cred.local.is_null() { + let mut min: OM_uint32 = 0; + let m = sys::gss_inquire_cred( + &mut min, + cred.local, + if name.is_null() { + ptr::null_mut() + } else { + &mut gpname.local + }, + lifetime, + cred_usage, + mechanisms, + ); + (m, min) + } else if cred.remote.is_some() { + let info = gpm::inquire_cred(cred.remote.as_ref().unwrap()); + if info.major == COMPLETE { + if !lifetime.is_null() { + *lifetime = info.lifetime; + } + if !cred_usage.is_null() { + *cred_usage = info.usage; + } + if !mechanisms.is_null() { + *mechanisms = convert::build_oid_set(&info.mechs); + } + gpname.remote = info.name; + } + (info.major, info.minor) + } else { + (consts::GSS_S_FAILURE, 0) + }; + + set_min(minor_status, min); + if !name.is_null() && maj == COMPLETE { + *name = NameHandle::into_raw(gpname); + } + maj + } +} + +/// `gssi_inquire_cred_by_mech`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_inquire_cred_by_mech( + minor_status: *mut OM_uint32, + cred_handle: gss_cred_id_t, + mech_type: gss_OID, + name: *mut gss_name_t, + initiator_lifetime: *mut OM_uint32, + acceptor_lifetime: *mut OM_uint32, + cred_usage: *mut i32, +) -> OM_uint32 { + unsafe { + let mut owned: Option> = None; + let no_cred = cred_handle.is_null(); + if no_cred { + let (maj, min) = + handle::get_def_creds(behavior::get(), None, GSS_C_INITIATE, &mut owned); + if maj != COMPLETE { + set_min(minor_status, min); + return maj; + } + } + let cred: &CredHandle = if no_cred { + owned.as_ref().unwrap() + } else { + match CredHandle::as_mut(cred_handle) { + Some(c) => c, + None => return consts::GSS_S_FAILURE, + } + }; + + let mut gpname = NameHandle::empty(); + let (maj, min) = if !cred.local.is_null() { + let mut min: OM_uint32 = 0; + let m = sys::gss_inquire_cred_by_mech( + &mut min, + cred.local, + special::special_mech(mech_type as *const _), + if name.is_null() { + ptr::null_mut() + } else { + &mut gpname.local + }, + initiator_lifetime, + acceptor_lifetime, + cred_usage, + ); + (m, min) + } else if cred.remote.is_some() { + let unspec = special::unspecial_mech(mech_type as *const _); + let mech_bytes = convert::oid_bytes(unspec).unwrap_or(&[]).to_vec(); + let info = gpm::inquire_cred_by_mech(cred.remote.as_ref().unwrap(), &mech_bytes); + if info.major == COMPLETE { + if !initiator_lifetime.is_null() { + *initiator_lifetime = info.initiator_lifetime; + } + if !acceptor_lifetime.is_null() { + *acceptor_lifetime = info.acceptor_lifetime; + } + if !cred_usage.is_null() { + *cred_usage = info.usage; + } + gpname.remote = info.name; + } + (info.major, info.minor) + } else { + (consts::GSS_S_FAILURE, 0) + }; + + set_min(minor_status, min); + if !name.is_null() && maj == COMPLETE { + *name = NameHandle::into_raw(gpname); + } + maj + } +} + +/// `gssi_inquire_cred_by_oid`: local only. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_inquire_cred_by_oid( + minor_status: *mut OM_uint32, + cred_handle: gss_cred_id_t, + desired_object: gss_OID, + data_set: *mut gss_buffer_set_t, +) -> OM_uint32 { + unsafe { + if !minor_status.is_null() { + *minor_status = 0; + } + if cred_handle.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + let cred = match CredHandle::as_mut(cred_handle) { + Some(c) => c, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + if cred.local.is_null() { + return consts::GSS_S_UNAVAILABLE; + } + let mut min: OM_uint32 = 0; + let maj = sys::gss_inquire_cred_by_oid(&mut min, cred.local, desired_object, data_set); + set_min(minor_status, min); + maj + } +} + +/// `gssi_set_cred_option`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_set_cred_option( + minor_status: *mut OM_uint32, + cred_handle: *mut gss_cred_id_t, + desired_object: gss_OID, + value: gss_buffer_t, +) -> OM_uint32 { + unsafe { + if !minor_status.is_null() { + *minor_status = 0; + } + if cred_handle.is_null() || (*cred_handle).is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + let cred = match CredHandle::as_mut(*cred_handle) { + Some(c) => c, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + // NOTE: remote cred options (allowable enctypes / no_ci_flags) are not yet + // wired; only local creds are handled, matching the practical test surface. + if cred.remote.is_some() { + return consts::GSS_S_UNAVAILABLE; + } + if cred.local.is_null() { + return consts::GSS_S_UNAVAILABLE; + } + let mut min: OM_uint32 = 0; + let maj = sys::gss_set_cred_option(&mut min, &mut cred.local, desired_object, value); + set_min(minor_status, min); + maj + } +} + +/// `gssi_store_cred`. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn gssi_store_cred( + minor_status: *mut OM_uint32, + input_cred_handle: gss_cred_id_t, + input_usage: i32, + desired_mech: gss_OID, + overwrite_cred: OM_uint32, + default_cred: OM_uint32, + elements_stored: *mut gss_OID_set, + cred_usage_stored: *mut i32, +) -> OM_uint32 { + unsafe { + gssi_store_cred_into( + minor_status, + input_cred_handle, + input_usage, + desired_mech, + overwrite_cred, + default_cred, + ptr::null(), + elements_stored, + cred_usage_stored, + ) + } +} + +/// `gssi_store_cred_into`. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn gssi_store_cred_into( + minor_status: *mut OM_uint32, + input_cred_handle: gss_cred_id_t, + input_usage: i32, + desired_mech: gss_OID, + overwrite_cred: OM_uint32, + default_cred: OM_uint32, + cred_store: gss_const_key_value_set_t, + elements_stored: *mut gss_OID_set, + cred_usage_stored: *mut i32, +) -> OM_uint32 { + unsafe { + if !minor_status.is_null() { + *minor_status = 0; + } + if input_cred_handle.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + let cred = match CredHandle::as_mut(input_cred_handle) { + Some(c) => c, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + + let (maj, min) = if let Some(rc) = &cred.remote { + let store = convert::kvset_to_vec(cred_store); + store_remote_creds(default_cred != 0, &store, rc) + } else { + let mut min: OM_uint32 = 0; + let m = sys::gss_store_cred_into( + &mut min, + cred.local, + input_usage, + special::special_mech(desired_mech as *const _), + overwrite_cred, + default_cred, + cred_store, + elements_stored, + cred_usage_stored, + ); + (m, min) + }; + set_min(minor_status, min); + maj + } +} + +/// `gssi_release_cred`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_release_cred( + minor_status: *mut OM_uint32, + cred_handle: *mut gss_cred_id_t, +) -> OM_uint32 { + unsafe { + logging::init(); + tracing::debug!("release_cred"); + if cred_handle.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + if (*cred_handle).is_null() { + if !minor_status.is_null() { + *minor_status = 0; + } + return COMPLETE; + } + let handle = CredHandle::from_raw(*cred_handle); + let (tmaj, tmin) = match &handle.remote { + Some(r) => gpm::release_cred(r), + None => (COMPLETE, 0), + }; + // Drop releases the local credential (gpp_cred_handle_free). + drop(handle); + *cred_handle = ptr::null_mut(); + + let (maj, min) = if tmaj != COMPLETE { + (tmaj, tmin) + } else { + (COMPLETE, 0) + }; + if !minor_status.is_null() { + *minor_status = min; + } + maj + } +} + +/// `gssi_export_cred`: local only. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_export_cred( + minor_status: *mut OM_uint32, + cred_handle: gss_cred_id_t, + token: gss_buffer_t, +) -> OM_uint32 { + unsafe { + let cred = match CredHandle::as_mut(cred_handle) { + Some(c) => c, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + if cred.local.is_null() { + return consts::GSS_S_CRED_UNAVAIL; + } + sys::gss_export_cred(minor_status, cred.local, token) + } +} + +/// `gssi_import_cred`: not supported (UNAVAILABLE). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_import_cred( + _minor_status: *mut OM_uint32, + _token: gss_buffer_t, + _cred_handle: *mut gss_cred_id_t, +) -> OM_uint32 { + consts::GSS_S_UNAVAILABLE +} + +/// `gssi_import_cred_by_mech`: local only, wrapping the token with the special +/// mech so the real mechglue imports it. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_import_cred_by_mech( + minor_status: *mut OM_uint32, + mech_type: gss_OID, + token: gss_buffer_t, + cred_handle: *mut gss_cred_id_t, +) -> OM_uint32 { + unsafe { + let mut cred = CredHandle::new(false, None); + + let spmech = special::special_mech(mech_type as *const _); + let sp_bytes = match convert::oid_bytes(spmech) { + Some(b) => b.to_vec(), + None => { + set_min(minor_status, 0); + return consts::GSS_S_FAILURE; + } + }; + let inner = convert::read_buffer(token); + let total = 4 + sp_bytes.len() + inner.len(); + let mut wrap = Vec::with_capacity(total); + // gssi_import_cred_by_mech prepends the *total* length (not the mech len). + wrap.extend_from_slice(&(total as u32).to_be_bytes()); + wrap.extend_from_slice(&sp_bytes); + wrap.extend_from_slice(inner); + + let wrapbuf = convert::TmpBuf::new(&wrap); + let mut min: OM_uint32 = 0; + let maj = sys::gss_import_cred(&mut min, wrapbuf.as_ptr(), &mut cred.local); + + set_min(minor_status, min); + if maj == COMPLETE { + *cred_handle = CredHandle::into_raw(cred); + } else { + drop(cred); + } + maj + } +} diff --git a/rust/gssproxy-interposer/src/ctxlife.rs b/rust/gssproxy-interposer/src/ctxlife.rs new file mode 100644 index 000000000..0368ee74b --- /dev/null +++ b/rust/gssproxy-interposer/src/ctxlife.rs @@ -0,0 +1,434 @@ +//! Context lifecycle: export/import, delete, inquire, context_time, +//! process_context_token, inquire_by_oid, set_sec_context_option, +//! pseudo_random. Port of `gpp_context.c`. + +use std::ptr; + +use gssapi_sys::consts; +use gssapi_sys::sys::{ + self, OM_uint32, gss_OID, gss_buffer_set_t, gss_buffer_t, gss_ctx_id_t, gss_name_t, +}; +use gssproxy_client::gpm; + +use crate::convert; +use crate::error::map_error; +use crate::handle::{self, CtxHandle, NameHandle, OwnedOid}; +use crate::logging; + +const COMPLETE: u32 = 0; + +unsafe fn set_min(minor_status: *mut OM_uint32, min: u32) { + unsafe { + if !minor_status.is_null() { + *minor_status = map_error(min); + } + } +} + +/// `gssi_export_sec_context`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_export_sec_context( + minor_status: *mut OM_uint32, + context_handle: *mut gss_ctx_id_t, + interprocess_token: gss_buffer_t, +) -> OM_uint32 { + unsafe { + logging::init(); + tracing::debug!("export_sec_context"); + if context_handle.is_null() || (*context_handle).is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + let local = match handle::ensure_local_ctx(*context_handle) { + Ok(l) => l, + Err((maj, min)) => { + set_min(minor_status, min); + return maj; + } + }; + let ctx = CtxHandle::as_mut(*context_handle).unwrap(); + ctx.local = local; + + let maj = sys::gss_export_sec_context(minor_status, &mut ctx.local, interprocess_token); + if maj == COMPLETE + && let Some(r) = &ctx.remote + { + let _ = gpm::delete_sec_context(r); + ctx.remote = None; + } + maj + } +} + +/// `gssi_import_sec_context`: not supported. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_import_sec_context( + _minor_status: *mut OM_uint32, + _interprocess_token: gss_buffer_t, + _context_handle: *mut gss_ctx_id_t, +) -> OM_uint32 { + consts::GSS_S_UNAVAILABLE +} + +/// `gssi_import_sec_context_by_mech`: local only. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_import_sec_context_by_mech( + minor_status: *mut OM_uint32, + mech_type: gss_OID, + interprocess_token: gss_buffer_t, + context_handle: *mut gss_ctx_id_t, +) -> OM_uint32 { + unsafe { + let mut ctx = CtxHandle::empty(); + + let inner = convert::read_buffer(interprocess_token); + let wrapped = match handle::wrap_sec_ctx_token(mech_type, inner) { + Some(w) => w, + None => { + set_min(minor_status, 0); + return consts::GSS_S_FAILURE; + } + }; + let wrapbuf = convert::TmpBuf::new(&wrapped); + let mut min: OM_uint32 = 0; + let maj = sys::gss_import_sec_context(&mut min, wrapbuf.as_ptr(), &mut ctx.local); + + set_min(minor_status, min); + if maj == COMPLETE { + *context_handle = CtxHandle::into_raw(ctx); + } else { + drop(ctx); + } + maj + } +} + +/// `gssi_process_context_token`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_process_context_token( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + token_buffer: gss_buffer_t, +) -> OM_uint32 { + unsafe { + let local = match handle::ensure_local_ctx(context_handle) { + Ok(l) => l, + Err((maj, min)) => { + set_min(minor_status, min); + return maj; + } + }; + sys::gss_process_context_token(minor_status, local, token_buffer) + } +} + +/// `gssi_context_time`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_context_time( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + time_rec: *mut OM_uint32, +) -> OM_uint32 { + unsafe { + if !minor_status.is_null() { + *minor_status = 0; + } + let ctx = match CtxHandle::as_mut(context_handle) { + Some(c) => c, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + if let Some(r) = &ctx.remote { + let info = gpm::inquire_context(r); + if info.lifetime > 0 { + if !time_rec.is_null() { + *time_rec = info.lifetime; + } + COMPLETE + } else { + if !time_rec.is_null() { + *time_rec = 0; + } + consts::GSS_S_CONTEXT_EXPIRED + } + } else if !ctx.local.is_null() { + sys::gss_context_time(minor_status, ctx.local, time_rec) + } else { + consts::GSS_S_NO_CONTEXT + } + } +} + +/// `gssi_inquire_context`. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn gssi_inquire_context( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + src_name: *mut gss_name_t, + targ_name: *mut gss_name_t, + lifetime_rec: *mut OM_uint32, + mech_type: *mut gss_OID, + ctx_flags: *mut OM_uint32, + locally_initiated: *mut i32, + open: *mut i32, +) -> OM_uint32 { + unsafe { + if context_handle.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + let ctx = match CtxHandle::as_mut(context_handle) { + Some(c) => c, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + if ctx.local.is_null() && ctx.remote.is_none() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + + let mut s_name: Option> = if !src_name.is_null() { + Some(NameHandle::empty()) + } else { + None + }; + let mut t_name: Option> = if !targ_name.is_null() { + Some(NameHandle::empty()) + } else { + None + }; + + let mut mech_bytes: Vec = Vec::new(); + let mut mech_oid_local: gss_OID = ptr::null_mut(); + + let (maj, min) = if !ctx.local.is_null() { + let s_ptr = s_name + .as_mut() + .map(|n| &mut n.local as *mut gss_name_t) + .unwrap_or(ptr::null_mut()); + let t_ptr = t_name + .as_mut() + .map(|n| &mut n.local as *mut gss_name_t) + .unwrap_or(ptr::null_mut()); + let mut min: OM_uint32 = 0; + let m = sys::gss_inquire_context( + &mut min, + ctx.local, + s_ptr, + t_ptr, + lifetime_rec, + &mut mech_oid_local, + ctx_flags, + locally_initiated, + open, + ); + if m == COMPLETE { + mech_bytes = convert::oid_bytes(mech_oid_local).unwrap_or(&[]).to_vec(); + } + (m, min) + } else { + let info = gpm::inquire_context(ctx.remote.as_ref().unwrap()); + if !lifetime_rec.is_null() { + *lifetime_rec = info.lifetime; + } + if !ctx_flags.is_null() { + *ctx_flags = info.ctx_flags; + } + if !locally_initiated.is_null() { + *locally_initiated = info.locally_initiated as i32; + } + if !open.is_null() { + *open = info.open as i32; + } + if let Some(n) = s_name.as_mut() { + n.remote = Some(info.src_name.clone()); + } + if let Some(n) = t_name.as_mut() { + n.remote = Some(info.targ_name.clone()); + } + mech_bytes = info.mech.clone(); + (COMPLETE, 0) + }; + + if maj != COMPLETE { + set_min(minor_status, min); + if !mech_oid_local.is_null() { + let mut m: OM_uint32 = 0; + sys::gss_release_oid(&mut m, &mut mech_oid_local); + } + return maj; + } + + if let Some(n) = s_name.as_mut() { + n.mech_type = Some(OwnedOid::new(mech_bytes.clone())); + } + if let Some(n) = t_name.as_mut() { + n.mech_type = Some(OwnedOid::new(mech_bytes.clone())); + } + + set_min(minor_status, 0); + + if !mech_type.is_null() { + // Hand back a stable OID pointer (interned for the remote path, or the + // mechglue's own OID for the local path). + if !mech_oid_local.is_null() { + *mech_type = mech_oid_local; + } else { + *mech_type = convert::intern_oid(&mech_bytes); + } + } else if !mech_oid_local.is_null() { + let mut m: OM_uint32 = 0; + sys::gss_release_oid(&mut m, &mut mech_oid_local); + } + + if !src_name.is_null() { + *src_name = match s_name.take() { + Some(n) => NameHandle::into_raw(n), + None => ptr::null_mut(), + }; + } + if !targ_name.is_null() { + *targ_name = match t_name.take() { + Some(n) => NameHandle::into_raw(n), + None => ptr::null_mut(), + }; + } + COMPLETE + } +} + +/// `gssi_inquire_sec_context_by_oid`: local only. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_inquire_sec_context_by_oid( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + desired_object: gss_OID, + data_set: *mut gss_buffer_set_t, +) -> OM_uint32 { + unsafe { + let local = match handle::ensure_local_ctx(context_handle) { + Ok(l) => l, + Err((maj, min)) => { + set_min(minor_status, min); + return maj; + } + }; + sys::gss_inquire_sec_context_by_oid(minor_status, local, desired_object, data_set) + } +} + +/// `gssi_set_sec_context_option`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_set_sec_context_option( + minor_status: *mut OM_uint32, + context_handle: *mut gss_ctx_id_t, + desired_object: gss_OID, + value: gss_buffer_t, +) -> OM_uint32 { + unsafe { + if context_handle.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + let ctx_ptr: *mut CtxHandle = if !(*context_handle).is_null() { + *context_handle as *mut CtxHandle + } else { + CtxHandle::into_raw(CtxHandle::empty()) as *mut CtxHandle + }; + + let ctx = &mut *ctx_ptr; + if ctx.remote.is_some() && ctx.local.is_null() { + let (maj, min) = handle::remote_to_local_ctx(&mut ctx.remote, &mut ctx.local); + if maj != COMPLETE { + set_min(minor_status, min); + *context_handle = ctx_ptr as gss_ctx_id_t; + let mut m: OM_uint32 = 0; + gssi_delete_sec_context(&mut m, context_handle, ptr::null_mut()); + return maj; + } + } + + let maj = + sys::gss_set_sec_context_option(minor_status, &mut ctx.local, desired_object, value); + *context_handle = ctx_ptr as gss_ctx_id_t; + if maj != COMPLETE { + let mut m: OM_uint32 = 0; + gssi_delete_sec_context(&mut m, context_handle, ptr::null_mut()); + } + maj + } +} + +/// `gssi_delete_sec_context`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_delete_sec_context( + minor_status: *mut OM_uint32, + context_handle: *mut gss_ctx_id_t, + output_token: gss_buffer_t, +) -> OM_uint32 { + unsafe { + logging::init(); + tracing::debug!("delete_sec_context"); + if context_handle.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + let ptr_val = *context_handle; + *context_handle = ptr::null_mut(); + if ptr_val.is_null() { + if !minor_status.is_null() { + *minor_status = 0; + } + return COMPLETE; + } + + let mut ctx = CtxHandle::from_raw(ptr_val); + let mut rmaj = COMPLETE; + + if !ctx.local.is_null() { + let mut min: OM_uint32 = 0; + let maj = sys::gss_delete_sec_context(&mut min, &mut ctx.local, output_token); + if maj != COMPLETE { + rmaj = maj; + set_min(minor_status, min); + } + } + if let Some(r) = &ctx.remote { + let (maj, min) = gpm::delete_sec_context(r); + if maj != COMPLETE && rmaj == COMPLETE { + rmaj = maj; + set_min(minor_status, min); + } + } + // The local cred has already been released above; avoid the Drop double + // release by clearing it before the box is dropped. + ctx.local = ptr::null_mut(); + ctx.remote = None; + drop(ctx); + + rmaj + } +} + +/// `gssi_pseudo_random`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_pseudo_random( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + prf_key: i32, + prf_in: gss_buffer_t, + desired_output_len: isize, + prf_out: gss_buffer_t, +) -> OM_uint32 { + unsafe { + let local = match handle::ensure_local_ctx(context_handle) { + Ok(l) => l, + Err((maj, min)) => { + set_min(minor_status, min); + return maj; + } + }; + sys::gss_pseudo_random( + minor_status, + local, + prf_key, + prf_in, + desired_output_len, + prf_out, + ) + } +} diff --git a/rust/gssproxy-interposer/src/env.rs b/rust/gssproxy-interposer/src/env.rs new file mode 100644 index 000000000..104122039 --- /dev/null +++ b/rust/gssproxy-interposer/src/env.rs @@ -0,0 +1,49 @@ +//! `secure_getenv`-based environment access (C: `gp_getenv`). +//! +//! The interposer is loaded into arbitrary, possibly setuid, programs, so it +//! must consult the environment through `secure_getenv` to avoid honouring +//! attacker-controlled variables across a privilege boundary. + +use std::ffi::{CStr, CString}; + +unsafe extern "C" { + fn secure_getenv(name: *const libc::c_char) -> *mut libc::c_char; +} + +/// Return the value of environment variable `name` via `secure_getenv`, or +/// `None` when unset (or suppressed in a setuid/setgid context). +pub fn get(name: &str) -> Option { + let cname = CString::new(name).ok()?; + // SAFETY: `cname` is a valid NUL-terminated C string; the returned pointer + // (if non-null) references the process environment and is copied at once. + let ptr = unsafe { secure_getenv(cname.as_ptr()) }; + if ptr.is_null() { + return None; + } + let bytes = unsafe { CStr::from_ptr(ptr) }.to_bytes(); + Some(String::from_utf8_lossy(bytes).into_owned()) +} + +/// `gp_boolean_is_true`: true for `1`/`on`/`true`/`yes` (case-insensitive). +pub fn boolean_is_true(s: &str) -> bool { + let s = s.trim(); + s.eq_ignore_ascii_case("1") + || s.eq_ignore_ascii_case("on") + || s.eq_ignore_ascii_case("true") + || s.eq_ignore_ascii_case("yes") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn boolean_is_true_matches_c_semantics() { + for t in ["1", "on", "true", "yes", "YES", " TrUe ", "On"] { + assert!(boolean_is_true(t), "{t:?} should be true"); + } + for f in ["0", "off", "false", "no", "", "2", "enable"] { + assert!(!boolean_is_true(f), "{f:?} should be false"); + } + } +} diff --git a/rust/gssproxy-interposer/src/error.rs b/rust/gssproxy-interposer/src/error.rs new file mode 100644 index 000000000..5006c1963 --- /dev/null +++ b/rust/gssproxy-interposer/src/error.rs @@ -0,0 +1,90 @@ +//! Minor-status error code mapping between the remote (daemon) mech and the +//! local mechglue. +//! +//! Port of `gpp_map_error`/`gpp_unmap_error` from `src/mechglue/gss_plugin.c`. +//! As in the C code this is a placeholder scheme: a fixed base is added to any +//! non-zero minor code so that remote minor codes do not collide with local +//! mechglue codes. It must stay byte-for-byte identical to the C behaviour so +//! that an application seeing a mapped minor code from the Rust interposer gets +//! the same value the C interposer would have produced. + +const MAP_ERROR_BASE: u32 = 0x0420_0000; + +/// `gpp_map_error`: shift a remote minor code into the mapped range. +pub fn map_error(err: u32) -> u32 { + if err != 0 { + err.wrapping_add(MAP_ERROR_BASE) + } else { + err + } +} + +/// `gpp_unmap_error`: reverse [`map_error`]. +pub fn unmap_error(err: u32) -> u32 { + if err != 0 { + err.wrapping_sub(MAP_ERROR_BASE) + } else { + err + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn map_unmap_round_trip() { + assert_eq!(map_error(0), 0); + assert_eq!(unmap_error(0), 0); + assert_eq!(unmap_error(map_error(42)), 42); + assert_eq!(map_error(1), 0x0420_0001); + } +} + +#[cfg(test)] +mod prop_tests { + use super::*; + use proptest::prelude::*; + + /// The single non-zero minor code whose mapping wraps back to 0. This is an + /// inherent property of the C scheme (unsigned add of a fixed base) and is + /// preserved here deliberately for byte-for-byte parity with `gpp_map_error`. + const WRAP_TO_ZERO: u32 = 0u32.wrapping_sub(MAP_ERROR_BASE); + + proptest! { + #![proptest_config(ProptestConfig { + cases: 1024, + failure_persistence: None, + ..ProptestConfig::default() + })] + + /// `unmap_error` inverts `map_error` for every input except the lone + /// wrap-collision value, exactly as the C placeholder scheme does. + #[test] + fn map_then_unmap_is_identity(x in any::()) { + let mapped = map_error(x); + if mapped == 0 { + // Either x == 0, or the wrap-collision value; both unmap to 0, + // matching the C behaviour (which is likewise non-invertible + // for this one code). + prop_assert!(x == 0 || x == WRAP_TO_ZERO); + prop_assert_eq!(unmap_error(mapped), 0); + } else { + prop_assert_eq!(unmap_error(mapped), x); + } + } + + /// Non-zero codes are shifted by exactly `MAP_ERROR_BASE`; zero is the + /// identity in both directions. + #[test] + fn shift_amount_matches_c(x in any::()) { + if x == 0 { + prop_assert_eq!(map_error(x), 0); + prop_assert_eq!(unmap_error(x), 0); + } else { + prop_assert_eq!(map_error(x), x.wrapping_add(MAP_ERROR_BASE)); + prop_assert_eq!(unmap_error(x), x.wrapping_sub(MAP_ERROR_BASE)); + } + } + } +} diff --git a/rust/gssproxy-interposer/src/handle.rs b/rust/gssproxy-interposer/src/handle.rs new file mode 100644 index 000000000..a4b65f20e --- /dev/null +++ b/rust/gssproxy-interposer/src/handle.rs @@ -0,0 +1,578 @@ +//! The opaque interposer handle payloads and the `gpp_*` helper functions that +//! convert between local (real-mech) and remote (gssx/daemon) representations. +//! +//! Mirrors the `struct gpp_cred_handle` / `gpp_context_handle` / +//! `gpp_name_handle` definitions in `gss_plugin.h` and the helper functions in +//! `gss_plugin.c` / `gpp_creds.c`. The mechglue treats these as opaque +//! `gss_cred_id_t` / `gss_ctx_id_t` / `gss_name_t` pointers; we move them across +//! the C ABI with `Box::into_raw` / `Box::from_raw`. + +use std::os::raw::c_void; +use std::ptr; + +use gssapi_sys::sys::{ + self, OM_uint32, gss_OID, gss_OID_desc, gss_buffer_desc, gss_cred_id_t, gss_ctx_id_t, + gss_name_t, +}; +use gssapi_sys::{ccache, consts}; +use gssproxy_client::gpm; +use gssproxy_proto::gssx::{GssxCred, GssxCtx, GssxName}; + +use crate::behavior::Behavior; +use crate::{convert, oids, special}; + +/// `(major, minor)` status pair. +pub type Status = (u32, u32); + +const COMPLETE: u32 = 0; + +// =========================================================================== +// Owned OID (gpp_copy_oid) +// =========================================================================== + +/// A heap-owned `gss_OID` with stable address, like `gpp_copy_oid`'s malloc'd +/// descriptor. The `desc.elements` pointer references `bytes`, which never moves +/// while boxed. +pub struct OwnedOid { + bytes: Vec, + desc: gss_OID_desc, +} + +impl OwnedOid { + pub fn new(bytes: Vec) -> Box { + let mut b = Box::new(OwnedOid { + desc: gss_OID_desc { + length: bytes.len() as OM_uint32, + elements: ptr::null_mut(), + }, + bytes, + }); + b.desc.elements = b.bytes.as_ptr() as *mut c_void; + b + } + + /// `gpp_copy_oid`: clone the bytes behind a C `gss_OID`. + /// + /// # Safety + /// `oid` must be null or a valid `gss_OID`. + pub unsafe fn from_oid(oid: gss_OID) -> Option> { + unsafe { convert::oid_bytes(oid).map(|b| OwnedOid::new(b.to_vec())) } + } + + pub fn as_ptr(&self) -> gss_OID { + &self.desc as *const _ as gss_OID + } +} + +// =========================================================================== +// Credential handle +// =========================================================================== + +pub struct CredHandle { + pub remote: Option, + /// gss_key_value_set: only the `ccache` entry is used (as in C). + pub store: Vec<(String, String)>, + pub default_creds: bool, + pub local: gss_cred_id_t, +} + +impl CredHandle { + /// `gpp_cred_handle_init`. + pub fn new(defcred: bool, ccache_name: Option<&str>) -> Box { + let store = match ccache_name { + Some(c) => vec![("ccache".to_string(), c.to_string())], + None => Vec::new(), + }; + Box::new(CredHandle { + remote: None, + store, + default_creds: defcred, + local: ptr::null_mut(), + }) + } + + pub fn into_raw(b: Box) -> gss_cred_id_t { + Box::into_raw(b) as gss_cred_id_t + } + + /// # Safety + /// `p` must be null or a pointer previously produced by [`into_raw`]. + pub unsafe fn as_mut<'a>(p: gss_cred_id_t) -> Option<&'a mut CredHandle> { + unsafe { (p as *mut CredHandle).as_mut() } + } + + /// # Safety + /// `p` must be a pointer previously produced by [`into_raw`] and not freed. + pub unsafe fn from_raw(p: gss_cred_id_t) -> Box { + unsafe { Box::from_raw(p as *mut CredHandle) } + } +} + +impl Drop for CredHandle { + /// `gpp_cred_handle_free`: release the local cred; the remote `gssx_cred` is + /// just dropped (the daemon-side release is the explicit `gssi_release_cred` + /// path, which calls `gpm_release_cred` first). + fn drop(&mut self) { + if !self.local.is_null() { + let mut min: OM_uint32 = 0; + unsafe { sys::gss_release_cred(&mut min, &mut self.local) }; + } + } +} + +// =========================================================================== +// Context handle +// =========================================================================== + +pub struct CtxHandle { + pub remote: Option, + pub local: gss_ctx_id_t, +} + +impl CtxHandle { + pub fn empty() -> Box { + Box::new(CtxHandle { + remote: None, + local: ptr::null_mut(), + }) + } + + pub fn into_raw(b: Box) -> gss_ctx_id_t { + Box::into_raw(b) as gss_ctx_id_t + } + + /// # Safety + /// `p` must be null or a pointer previously produced by [`into_raw`]. + pub unsafe fn as_mut<'a>(p: gss_ctx_id_t) -> Option<&'a mut CtxHandle> { + unsafe { (p as *mut CtxHandle).as_mut() } + } + + /// # Safety + /// `p` must be a pointer previously produced by [`into_raw`] and not freed. + pub unsafe fn from_raw(p: gss_ctx_id_t) -> Box { + unsafe { Box::from_raw(p as *mut CtxHandle) } + } +} + +impl Drop for CtxHandle { + /// Safety net: release a local context if one is still held. The remote + /// context's daemon-side release is the explicit `gssi_delete_sec_context` + /// path. + fn drop(&mut self) { + if !self.local.is_null() { + let mut min: OM_uint32 = 0; + unsafe { sys::gss_delete_sec_context(&mut min, &mut self.local, ptr::null_mut()) }; + } + } +} + +// =========================================================================== +// Name handle +// =========================================================================== + +pub struct NameHandle { + pub mech_type: Option>, + pub remote: Option, + pub local: gss_name_t, +} + +impl NameHandle { + pub fn empty() -> Box { + Box::new(NameHandle { + mech_type: None, + remote: None, + local: ptr::null_mut(), + }) + } + + pub fn into_raw(b: Box) -> gss_name_t { + Box::into_raw(b) as gss_name_t + } + + /// # Safety + /// `p` must be null or a pointer previously produced by [`into_raw`]. + pub unsafe fn as_mut<'a>(p: gss_name_t) -> Option<&'a mut NameHandle> { + unsafe { (p as *mut NameHandle).as_mut() } + } + + /// # Safety + /// `p` must be a pointer previously produced by [`into_raw`] and not freed. + pub unsafe fn from_raw(p: gss_name_t) -> Box { + unsafe { Box::from_raw(p as *mut NameHandle) } + } + + pub fn mech_ptr(&self) -> gss_OID { + match &self.mech_type { + Some(o) => o.as_ptr(), + None => ptr::null_mut(), + } + } +} + +impl Drop for NameHandle { + fn drop(&mut self) { + if !self.local.is_null() { + let mut min: OM_uint32 = 0; + unsafe { sys::gss_release_name(&mut min, &mut self.local) }; + } + } +} + +// =========================================================================== +// gpp_* helpers +// =========================================================================== + +/// `gpp_wrap_sec_ctx_token`: prepend `htobe32(spmech.len) || spmech` to `token` +/// using the special form of `mech_type`. +/// +/// # Safety +/// `mech_type` must be null or a valid `gss_OID`. +pub unsafe fn wrap_sec_ctx_token(mech_type: gss_OID, token: &[u8]) -> Option> { + unsafe { + let sp = special::special_mech(mech_type as *const gss_OID_desc); + let sp_bytes = convert::oid_bytes(sp)?; + let mut out = Vec::with_capacity(4 + sp_bytes.len() + token.len()); + out.extend_from_slice(&(sp_bytes.len() as u32).to_be_bytes()); + out.extend_from_slice(sp_bytes); + out.extend_from_slice(token); + Some(out) + } +} + +/// `gpp_remote_to_local_ctx`: import the remote context's exported token into a +/// real local context (consuming the remote one on success). +/// +/// # Safety +/// `local` must point to a writable `gss_ctx_id_t`. +pub unsafe fn remote_to_local_ctx( + remote: &mut Option, + local: &mut gss_ctx_id_t, +) -> Status { + unsafe { + let token = match remote { + Some(c) => c.exported_context_token.as_slice().to_vec(), + None => return (consts::GSS_S_FAILURE, 0), + }; + if token.len() <= 4 { + return (consts::GSS_S_FAILURE, 0); + } + let mech_len = u32::from_be_bytes([token[0], token[1], token[2], token[3]]) as usize; + let hlen = 4 + mech_len; + if token.len() <= hlen { + return (consts::GSS_S_FAILURE, 0); + } + let mech_oid = convert::TmpOid::new(&token[4..hlen]); + let inner = &token[hlen..]; + + let wrapped = match wrap_sec_ctx_token(mech_oid.as_ptr(), inner) { + Some(w) => w, + None => return (consts::GSS_S_FAILURE, 0), + }; + let wrapbuf = convert::TmpBuf::new(&wrapped); + let mut min: OM_uint32 = 0; + let maj = sys::gss_import_sec_context(&mut min, wrapbuf.as_ptr(), local); + *remote = None; + (maj, min) + } +} + +/// Resolve a context payload to a usable local `gss_ctx_id_t`, importing the +/// remote context when only a daemon-side context exists. Used by the message +/// protection and context-lifecycle entry points. On failure, returns the +/// major status and the (unmapped) minor. +/// +/// # Safety +/// `context_handle` must be null or a pointer produced by [`CtxHandle::into_raw`]. +pub unsafe fn ensure_local_ctx(context_handle: gss_ctx_id_t) -> Result { + unsafe { + if context_handle.is_null() { + return Err((consts::GSS_S_CALL_INACCESSIBLE_READ, 0)); + } + let ctx = match CtxHandle::as_mut(context_handle) { + Some(c) => c, + None => return Err((consts::GSS_S_CALL_INACCESSIBLE_READ, 0)), + }; + if ctx.remote.is_some() && ctx.local.is_null() { + let (maj, min) = remote_to_local_ctx(&mut ctx.remote, &mut ctx.local); + if maj != COMPLETE { + return Err((maj, min)); + } + } + Ok(ctx.local) + } +} + +/// `gpp_name_to_local`: turn a remote `gssx_name` into a real local +/// `gss_name_t`, canonicalising for `mech_type` when one is given. Returns the +/// new name on success. +/// +/// # Safety +/// `mech_type` must be null or a valid `gss_OID`. +pub unsafe fn name_to_local(remote: &mut GssxName, mech_type: gss_OID) -> (u32, u32, gss_name_t) { + unsafe { + let (maj, min, disp, ntype) = gpm::display_name(remote); + if maj != COMPLETE { + return (maj, min, ptr::null_mut()); + } + let ntype_oid = match convert::name_type_static(&ntype) { + Some(o) => o, + None => return (consts::GSS_S_FAILURE, libc::ENOENT as u32, ptr::null_mut()), + }; + + let inbuf = convert::TmpBuf::new(&disp); + let mut tmpname: gss_name_t = ptr::null_mut(); + let mut min2: OM_uint32 = 0; + let maj2 = sys::gss_import_name(&mut min2, inbuf.as_ptr(), ntype_oid, &mut tmpname); + if maj2 != COMPLETE { + return (maj2, min2, ptr::null_mut()); + } + + let mut maj3 = COMPLETE; + let mut min3: OM_uint32 = 0; + if !mech_type.is_null() { + let sp = special::special_mech(mech_type as *const gss_OID_desc); + maj3 = sys::gss_canonicalize_name(&mut min3, tmpname, sp, ptr::null_mut()); + } + (maj3, min3, tmpname) + } +} + +/// `gpp_local_to_name`: turn a real local `gss_name_t` into a `gssx_name`. +/// +/// # Safety +/// `local` must be a valid `gss_name_t`. +pub unsafe fn local_to_name(local: gss_name_t) -> (u32, u32, Option) { + unsafe { + let mut buf = gss_buffer_desc { + length: 0, + value: ptr::null_mut(), + }; + let mut ntype: gss_OID = ptr::null_mut(); + let mut min: OM_uint32 = 0; + let maj = sys::gss_display_name(&mut min, local, &mut buf, &mut ntype); + if maj != COMPLETE { + return (maj, min, None); + } + let disp = convert::read_buffer(&mut buf as *mut _).to_vec(); + let nt = convert::oid_bytes(ntype) + .map(|b| b.to_vec()) + .unwrap_or_default(); + convert::release_buffer(&mut buf as *mut _); + let name = gpm::import_name(&disp, &nt); + (COMPLETE, 0, Some(name)) + } +} + +/// `gpp_creds_are_equal`: compare two `gssx_cred`s by desired name, element +/// count, and cred-handle reference (the only fields C checks). +pub fn creds_are_equal(a: Option<&GssxCred>, b: Option<&GssxCred>) -> bool { + match (a, b) { + (None, None) => return true, + (None, _) | (_, None) => return false, + _ => {} + } + let a = a.unwrap(); + let b = b.unwrap(); + if a.desired_name.display_name.as_slice() != b.desired_name.display_name.as_slice() { + return false; + } + if a.elements.len() != b.elements.len() { + return false; + } + a.cred_handle_reference.as_slice() == b.cred_handle_reference.as_slice() +} + +/// `gpp_store_remote_creds`: stash a remote cred in the local ccache. +pub fn store_remote_creds( + default_cred: bool, + store: &[(String, String)], + creds: &GssxCred, +) -> Status { + let ticket = gpm::encode_cred(creds); + let client = creds.desired_name.display_name.as_slice(); + match ccache::store_remote_cred(store, client, &ticket, default_cred) { + Ok(()) => (COMPLETE, 0), + Err(e) => (consts::GSS_S_FAILURE, e as u32), + } +} + +/// `gppint_retrieve_remote_creds`: fetch a stashed remote cred from the ccache. +pub fn retrieve_remote_creds( + ccache_name: Option<&str>, + name: Option<&GssxName>, +) -> (u32, u32, Option) { + let client = name.map(|n| n.display_name.as_slice()); + match ccache::retrieve_remote_cred(ccache_name, client) { + Ok(bytes) => match gpm::decode_cred(&bytes) { + Some(c) => (COMPLETE, 0, Some(c)), + None => (consts::GSS_S_FAILURE, libc::EIO as u32, None), + }, + Err(e) => (consts::GSS_S_FAILURE, e as u32, None), + } +} + +/// `get_local_def_creds`: acquire default local creds for the interposed mechs. +unsafe fn get_local_def_creds( + name_local: gss_name_t, + cred_usage: i32, + out_local: &mut gss_cred_id_t, +) -> Status { + unsafe { + let mut interposed = crate::gss_mech_interposer(oids::interposer() as gss_OID); + if interposed.is_null() { + return (consts::GSS_S_FAILURE, 0); + } + let mut special = special::special_available_mechs(interposed); + let mut min: OM_uint32 = 0; + if special.is_null() { + sys::gss_release_oid_set(&mut min, &mut interposed); + return (consts::GSS_S_FAILURE, 0); + } + let mut min2: OM_uint32 = 0; + let maj = sys::gss_acquire_cred( + &mut min2, + name_local, + 0, + special, + cred_usage, + out_local, + ptr::null_mut(), + ptr::null_mut(), + ); + let mut m: OM_uint32 = 0; + sys::gss_release_oid_set(&mut m, &mut special); + sys::gss_release_oid_set(&mut m, &mut interposed); + (maj, min2) + } +} + +/// `gppint_get_def_creds`: obtain default creds honouring the behavior matrix, +/// reusing the cred handle in `slot` or creating a new one. +/// +/// # Safety +/// Calls into the real mechglue and the daemon. +pub unsafe fn get_def_creds( + behavior: Behavior, + name: Option<&mut NameHandle>, + cred_usage: i32, + slot: &mut Option>, +) -> Status { + unsafe { + if slot.is_none() { + *slot = Some(CredHandle::new(true, None)); + } + let name_local = name.as_ref().map(|n| n.local).unwrap_or(ptr::null_mut()); + let name_remote = name.and_then(|n| n.remote.as_ref()); + + let cred = slot.as_mut().unwrap(); + let mut tmaj = COMPLETE; + let mut tmin = 0u32; + let mut maj = consts::GSS_S_FAILURE; + let mut min = 0u32; + + if behavior == Behavior::LocalOnly || behavior == Behavior::LocalFirst { + let (m, mi) = get_local_def_creds(name_local, cred_usage, &mut cred.local); + maj = m; + min = mi; + if maj == COMPLETE || behavior == Behavior::LocalOnly { + return finish_def_creds(maj, min, tmaj, tmin); + } + tmaj = maj; + tmin = min; + } + + if behavior != Behavior::LocalOnly { + let (rmaj, _, remote) = retrieve_remote_creds(None, name_remote); + let premote = if rmaj == COMPLETE { remote } else { None }; + + let acq = gpm::acquire_cred(premote.as_ref(), None, 0, &[], cred_usage, false); + maj = acq.major; + min = acq.minor; + if maj == COMPLETE { + cred.remote = acq.cred; + if let Some(p) = &premote + && !creds_are_equal(Some(p), cred.remote.as_ref()) + && let Some(rc) = &cred.remote + { + let (sm, smi) = store_remote_creds(cred.default_creds, &cred.store, rc); + maj = sm; + min = smi; + } + } + + if maj == COMPLETE { + return finish_def_creds(maj, min, tmaj, tmin); + } + + if behavior == Behavior::RemoteFirst { + let (m, mi) = get_local_def_creds(name_local, cred_usage, &mut cred.local); + maj = m; + min = mi; + } + } + + finish_def_creds(maj, min, tmaj, tmin) + } +} + +fn finish_def_creds(maj: u32, min: u32, tmaj: u32, tmin: u32) -> Status { + if maj != COMPLETE && tmaj != COMPLETE { + (tmaj, tmin) + } else { + (maj, min) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gssproxy_proto::gssx::{GssxCredElement, Opaque}; + + fn cred(name: &[u8], elements: usize, href: &[u8]) -> GssxCred { + let mut c = GssxCred::default(); + c.desired_name.display_name = Opaque::new(name.to_vec()); + c.elements = vec![GssxCredElement::default(); elements]; + c.cred_handle_reference = Opaque::new(href.to_vec()); + c + } + + #[test] + fn creds_are_equal_matches_c_fields() { + // None/None is equal; None/Some is not. + assert!(creds_are_equal(None, None)); + let a = cred(b"alice", 1, b"ref"); + assert!(!creds_are_equal(None, Some(&a))); + assert!(!creds_are_equal(Some(&a), None)); + + // Equal across the three compared fields. + let b = cred(b"alice", 1, b"ref"); + assert!(creds_are_equal(Some(&a), Some(&b))); + + // Differ in display name / element count / handle reference. + assert!(!creds_are_equal(Some(&a), Some(&cred(b"bob", 1, b"ref")))); + assert!(!creds_are_equal(Some(&a), Some(&cred(b"alice", 2, b"ref")))); + assert!(!creds_are_equal( + Some(&a), + Some(&cred(b"alice", 1, b"other")) + )); + } + + #[test] + fn wrap_sec_ctx_token_layout() { + // Use a real (non-special) krb5 OID so special_mech produces a prefixed + // special OID; the wrapper must be htobe32(len) || special_oid || token. + let real = gssapi_sys::consts::KRB5_MECH_OID; + let token = b"\x01\x02\x03\x04payload"; + let out = unsafe { + let t = convert::TmpOid::new(real); + wrap_sec_ctx_token(t.as_ptr(), token).expect("wrap") + }; + assert!(out.len() > 4 + token.len()); + let mech_len = u32::from_be_bytes([out[0], out[1], out[2], out[3]]) as usize; + assert_eq!(out.len(), 4 + mech_len + token.len()); + // The special mech embeds the real OID bytes as its suffix. + let sp = &out[4..4 + mech_len]; + assert!(sp.ends_with(real)); + assert_eq!(&out[4 + mech_len..], token); + } +} diff --git a/rust/gssproxy-interposer/src/lib.rs b/rust/gssproxy-interposer/src/lib.rs new file mode 100644 index 000000000..73825d4e8 --- /dev/null +++ b/rust/gssproxy-interposer/src/lib.rs @@ -0,0 +1,142 @@ +//! `proxymech.so`: a GSSAPI mechanism interposer plugin that proxies krb5/IAKERB +//! operations to the gssproxy daemon. +//! +//! This is the Rust port of `src/mechglue/*.c`. The MIT krb5 mechglue loads an +//! interposer by calling [`gss_mech_interposer`] (which returns the set of mech +//! OIDs we take over) and then resolves the per-operation `gssi_*` symbols we +//! export here by name. Each `gssi_*` entry point converts the GSSAPI call into +//! the gssx wire form and dispatches it to the daemon via `gssproxy-client` +//! (and/or performs it locally against the real mechanism, depending on the +//! configured [`behavior`]). +//! +//! Implemented so far: +//! - plugin entry/OID machinery: [`gss_mech_interposer`], +//! [`gssi_internal_release_oid`], the special-mech OID registry +//! ([`special`]), behavior/env handling ([`behavior`], [`env`]), and minor +//! status mapping ([`error`]). +//! +//! The per-operation `gssi_*` data path (init/accept/wrap/unwrap/mic/name/cred) +//! is being filled in incrementally. + +mod behavior; +mod context; +mod convert; +mod creds; +mod ctxlife; +mod env; +mod error; +mod handle; +mod logging; +mod mechstatus; +mod msgprot; +mod names; +mod oids; +mod special; + +use gssapi_sys::sys::{ + self, OM_uint32, gss_OID, gss_OID_set, gss_create_empty_oid_set, gss_release_oid_set, +}; + +// Re-export the foundation pieces so the (forthcoming) gssi_* handler modules +// and tests can use them via crate paths without `pub mod` churn later. +pub use behavior::Behavior; + +/// `GSS_S_COMPLETE`. +const GSS_S_COMPLETE: OM_uint32 = 0; + +/// Add one of our base OIDs to `set`, returning false on allocation failure. +unsafe fn add_member(set: *mut gss_OID_set, member: gss_OID) -> bool { + let mut minor: OM_uint32 = 0; + let maj = unsafe { sys::gss_add_oid_set_member(&mut minor, member, set) }; + maj == GSS_S_COMPLETE +} + +/// Entry point invoked by the mechglue to discover which mechanisms this +/// interposer takes over (C: `gss_mech_interposer`). +/// +/// Returns the krb5 family + IAKERB OIDs when (a) interposition is enabled and +/// (b) `mech_type` is the gssproxy interposer OID; otherwise `GSS_C_NO_OID_SET`. +/// +/// # Safety +/// Called by the C mechglue with a valid `gss_OID` (or null). Exported with C +/// ABI. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gss_mech_interposer(mech_type: gss_OID) -> gss_OID_set { + unsafe { + if !behavior::enabled() { + return std::ptr::null_mut(); + } + logging::init(); + if !oids::oid_equal(oids::interposer(), mech_type) { + tracing::trace!("gss_mech_interposer called for a non-gssproxy OID"); + return std::ptr::null_mut(); + } + tracing::debug!(behavior = ?behavior::get(), "interposer enabled; claiming krb5/IAKERB mechs"); + + let mut minor: OM_uint32 = 0; + let mut set: gss_OID_set = std::ptr::null_mut(); + if gss_create_empty_oid_set(&mut minor, &mut set) != GSS_S_COMPLETE { + return std::ptr::null_mut(); + } + + let base = oids::base(); + let ok = add_member(&mut set, &base.krb5 as *const _ as gss_OID) + && add_member(&mut set, &base.krb5_old as *const _ as gss_OID) + && add_member(&mut set, &base.krb5_wrong as *const _ as gss_OID) + && add_member(&mut set, &base.iakerb as *const _ as gss_OID); + + if !ok { + let mut min2: OM_uint32 = 0; + gss_release_oid_set(&mut min2, &mut set); + return std::ptr::null_mut(); + } + + // While we are here, seed the special-mech list from the mechs we proxy + // (C: gpp_init_special_available_mechs). + special::init_special_available_mechs(set); + + set + } +} + +/// `gssi_internal_release_oid`: claim ownership (and suppress release) of OIDs +/// that belong to us - the interposer OID itself and any registered +/// regular/special OID - so the mechglue does not try to free them. +/// +/// Returns `GSS_S_COMPLETE` (and nulls `*oid`) when the OID is ours, otherwise +/// `GSS_S_CONTINUE_NEEDED` so the mechglue keeps looking. +/// +/// # Safety +/// `minor_status` and `oid` must be valid pointers; `*oid` must be null or a +/// valid `gss_OID`. Exported with C ABI. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_internal_release_oid( + minor_status: *mut OM_uint32, + oid: *mut gss_OID, +) -> OM_uint32 { + unsafe { + if !minor_status.is_null() { + *minor_status = 0; + } + if oid.is_null() { + return sys::GSS_S_CONTINUE_NEEDED; + } + + let cur = *oid as *const sys::gss_OID_desc; + + // The static interposer OID (compared by identity, as in C). + if std::ptr::eq(cur, oids::interposer()) { + *oid = std::ptr::null_mut(); + return GSS_S_COMPLETE; + } + + if special::is_registered_ptr(cur) { + *oid = std::ptr::null_mut(); + return GSS_S_COMPLETE; + } + + // Not ours: let the mechglue continue (gpm_mech_is_static is handled by the + // real mechglue once the data path forwards static mech OIDs). + sys::GSS_S_CONTINUE_NEEDED + } +} diff --git a/rust/gssproxy-interposer/src/logging.rs b/rust/gssproxy-interposer/src/logging.rs new file mode 100644 index 000000000..ea6313025 --- /dev/null +++ b/rust/gssproxy-interposer/src/logging.rs @@ -0,0 +1,60 @@ +//! Tracing initialisation for the interposer. +//! +//! `proxymech.so` is loaded into arbitrary, possibly privileged, host processes. +//! Two rules follow from that: +//! +//! 1. **Off by default.** We never install a global subscriber unless the +//! operator explicitly opts in via the `GSSPROXY_LOG` environment variable +//! (read through `secure_getenv`, so it is ignored across a setuid/setgid +//! boundary). When unset, our `tracing` events are near-zero-cost no-ops +//! (or flow to the host's own subscriber, if it installed one). +//! +//! 2. **Install at most once, never panic.** Initialisation is guarded by a +//! `Once` and uses `try_init`, so repeated `gssi_*` entry points and a +//! host that already configured `tracing` are both handled gracefully. +//! +//! `GSSPROXY_LOG` takes an `EnvFilter` directive, e.g. `GSSPROXY_LOG=debug` or +//! `GSSPROXY_LOG=proxymech=trace`. + +use std::sync::Once; + +use gssapi_sys::sys::gss_buffer_t; + +use crate::env; + +static INIT: Once = Once::new(); + +/// Byte length of a `gss_buffer_t`, for logging GSS tokens by size only (never +/// their contents). Returns 0 for `GSS_C_NO_BUFFER` (null). +/// +/// # Safety +/// `buf` must be null or point to a valid `gss_buffer_desc`. +pub unsafe fn buf_len(buf: gss_buffer_t) -> usize { + if buf.is_null() { + 0 + } else { + unsafe { (*buf).length } + } +} + +/// Install the interposer's `tracing` subscriber if `GSSPROXY_LOG` is set. +/// Idempotent and cheap to call from every entry point. +pub fn init() { + INIT.call_once(|| { + let Some(directive) = env::get("GSSPROXY_LOG") else { + return; + }; + if directive.trim().is_empty() { + return; + } + + use tracing_subscriber::EnvFilter; + // ANSI colour is disabled: interposer logs commonly land in a host's + // journal/file rather than an interactive terminal. + let _ = tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new(directive)) + .with_writer(std::io::stderr) + .with_ansi(false) + .try_init(); + }); +} diff --git a/rust/gssproxy-interposer/src/mechstatus.rs b/rust/gssproxy-interposer/src/mechstatus.rs new file mode 100644 index 000000000..59078b3ab --- /dev/null +++ b/rust/gssproxy-interposer/src/mechstatus.rs @@ -0,0 +1,404 @@ +//! Mechanism inquiry and status entry points. Port of `gpp_indicate_mechs.c`, +//! `gpp_display_status.c`, and `gssi_mech_invoke` from `gpp_misc.c`. +//! +//! The `gpm_inquire_*` helpers in the C client operate against a process-global +//! mech table populated once via `GSSX_INDICATE_MECHS`. We mirror that with a +//! lazily-initialised cache here. + +use std::sync::OnceLock; + +use gssapi_sys::consts; +use gssapi_sys::sys::{self, OM_uint32, gss_OID, gss_OID_set, gss_buffer_t}; +use gssproxy_client::gpm; + +use crate::behavior::{self, Behavior}; +use crate::convert; +use crate::error::{map_error, unmap_error}; +use crate::special; + +const COMPLETE: u32 = 0; +const GSS_C_MECH_CODE: i32 = 2; + +unsafe extern "C" { + /// SPI entry point not bound by `libgssapi-sys` (its allowlist only covers + /// `gss_*`). Declared here for `gssi_mech_invoke`. + fn gssspi_mech_invoke( + minor_status: *mut OM_uint32, + desired_mech: gss_OID, + desired_object: gss_OID, + value: gss_buffer_t, + ) -> OM_uint32; +} + +// =========================================================================== +// Global mech cache (gpmint_indicate_mechs / global_mechs) +// =========================================================================== + +struct MechInfo { + mech: Vec, + name_types: Vec>, + mech_attrs: Vec>, + known_mech_attrs: Vec>, + saslname_sasl_mech_name: Vec, + saslname_mech_name: Vec, + saslname_mech_desc: Vec, +} + +struct MechCache { + ok: bool, + info: Vec, +} + +static CACHE: OnceLock = OnceLock::new(); + +fn cache() -> &'static MechCache { + CACHE.get_or_init(|| { + let (maj, _, res) = gpm::indicate_mechs(); + if maj != COMPLETE { + tracing::warn!(major = maj, "indicate_mechs failed; mech cache unavailable"); + return MechCache { + ok: false, + info: Vec::new(), + }; + } + let info: Vec = res + .mechs + .into_iter() + .map(|m| MechInfo { + mech: m.mech.as_slice().to_vec(), + name_types: m.name_types.iter().map(|o| o.as_slice().to_vec()).collect(), + mech_attrs: m.mech_attrs.iter().map(|o| o.as_slice().to_vec()).collect(), + known_mech_attrs: m + .known_mech_attrs + .iter() + .map(|o| o.as_slice().to_vec()) + .collect(), + saslname_sasl_mech_name: m.saslname_sasl_mech_name.as_slice().to_vec(), + saslname_mech_name: m.saslname_mech_name.as_slice().to_vec(), + saslname_mech_desc: m.saslname_mech_desc.as_slice().to_vec(), + }) + .collect(); + tracing::debug!(mechs = info.len(), "mech cache initialised from daemon"); + MechCache { ok: true, info } + }) +} + +/// Look up a mech in the cache. Mirrors `gpmint_init_global_mechs` + +/// per-mech search: `Err((GSS_S_FAILURE, EIO))` if the cache could not be +/// built, `Err((GSS_S_BAD_MECH, 0))` if the mech is unknown. +unsafe fn find_mech(mech_type: gss_OID) -> Result<&'static MechInfo, (u32, u32)> { + unsafe { + let c = cache(); + if !c.ok { + return Err((consts::GSS_S_FAILURE, libc::EIO as u32)); + } + let bytes = match convert::oid_bytes(mech_type) { + Some(b) => b, + None => return Err((consts::GSS_S_BAD_MECH, 0)), + }; + c.info + .iter() + .find(|m| m.mech.as_slice() == bytes) + .ok_or((consts::GSS_S_BAD_MECH, 0)) + } +} + +// =========================================================================== +// gssi_* entry points +// =========================================================================== + +/// `gssi_indicate_mechs`: never actually called; present for completeness. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_indicate_mechs( + minor_status: *mut OM_uint32, + _mech_set: *mut gss_OID_set, +) -> OM_uint32 { + unsafe { + if !minor_status.is_null() { + *minor_status = 0; + } + consts::GSS_S_FAILURE + } +} + +/// `gssi_inquire_names_for_mech`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_inquire_names_for_mech( + minor_status: *mut OM_uint32, + mech_type: gss_OID, + mech_names: *mut gss_OID_set, +) -> OM_uint32 { + unsafe { + let behavior = behavior::get(); + let mut tmaj = COMPLETE; + let mut tmin = 0u32; + let mut maj; + let mut min; + + if behavior == Behavior::LocalOnly || behavior == Behavior::LocalFirst { + let sp = special::special_mech(mech_type as *const _); + let mut m: OM_uint32 = 0; + maj = sys::gss_inquire_names_for_mech(&mut m, sp, mech_names); + min = m; + if maj == COMPLETE || behavior == Behavior::LocalOnly { + set_min(minor_status, min); + return maj; + } + tmaj = maj; + tmin = min; + } + + // Remote: served from the cached mech table. + let (rmaj, rmin) = match find_mech(mech_type) { + Ok(info) => { + *mech_names = convert::build_oid_set(&info.name_types); + (COMPLETE, 0) + } + Err(e) => e, + }; + maj = rmaj; + min = rmin; + if maj == COMPLETE || behavior == Behavior::RemoteOnly { + if maj != COMPLETE && tmaj != COMPLETE { + maj = tmaj; + min = tmin; + } + set_min(minor_status, min); + return maj; + } + + let sp = special::special_mech(mech_type as *const _); + let mut m: OM_uint32 = 0; + maj = sys::gss_inquire_names_for_mech(&mut m, sp, mech_names); + min = m; + if maj != COMPLETE && tmaj != COMPLETE { + maj = tmaj; + min = tmin; + } + set_min(minor_status, min); + maj + } +} + +/// `gssi_inquire_attrs_for_mech`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_inquire_attrs_for_mech( + minor_status: *mut OM_uint32, + mech: gss_OID, + mech_attrs: *mut gss_OID_set, + known_mech_attrs: *mut gss_OID_set, +) -> OM_uint32 { + unsafe { + let behavior = behavior::get(); + let mut tmaj = COMPLETE; + let mut tmin = 0u32; + let mut maj; + let mut min; + + if behavior == Behavior::LocalOnly || behavior == Behavior::LocalFirst { + let sp = special::special_mech(mech as *const _); + let mut m: OM_uint32 = 0; + maj = sys::gss_inquire_attrs_for_mech(&mut m, sp, mech_attrs, known_mech_attrs); + min = m; + if maj == COMPLETE || behavior == Behavior::LocalOnly { + set_min(minor_status, min); + return maj; + } + tmaj = maj; + tmin = min; + } + + let (rmaj, rmin) = match find_mech(mech) { + Ok(info) => { + if !mech_attrs.is_null() { + *mech_attrs = convert::build_oid_set(&info.mech_attrs); + } + if !known_mech_attrs.is_null() { + *known_mech_attrs = convert::build_oid_set(&info.known_mech_attrs); + } + (COMPLETE, 0) + } + Err(e) => e, + }; + maj = rmaj; + min = rmin; + if maj == COMPLETE || behavior == Behavior::RemoteOnly { + if maj != COMPLETE && tmaj != COMPLETE { + maj = tmaj; + min = tmin; + } + set_min(minor_status, min); + return maj; + } + + let sp = special::special_mech(mech as *const _); + let mut m: OM_uint32 = 0; + maj = sys::gss_inquire_attrs_for_mech(&mut m, sp, mech_attrs, known_mech_attrs); + min = m; + if maj != COMPLETE && tmaj != COMPLETE { + maj = tmaj; + min = tmin; + } + set_min(minor_status, min); + maj + } +} + +/// `gssi_inquire_saslname_for_mech`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_inquire_saslname_for_mech( + minor_status: *mut OM_uint32, + desired_mech: gss_OID, + sasl_mech_name: gss_buffer_t, + mech_name: gss_buffer_t, + mech_description: gss_buffer_t, +) -> OM_uint32 { + unsafe { + let behavior = behavior::get(); + let mut tmaj = COMPLETE; + let mut tmin = 0u32; + let mut maj; + let mut min; + + if behavior == Behavior::LocalOnly || behavior == Behavior::LocalFirst { + let sp = special::special_mech(desired_mech as *const _); + let mut m: OM_uint32 = 0; + maj = sys::gss_inquire_saslname_for_mech( + &mut m, + sp, + sasl_mech_name, + mech_name, + mech_description, + ); + min = m; + if maj == COMPLETE || behavior == Behavior::LocalOnly { + set_min(minor_status, min); + return maj; + } + tmaj = maj; + tmin = min; + } + + let (rmaj, rmin) = match find_mech(desired_mech) { + Ok(info) => { + convert::write_buffer(sasl_mech_name, &info.saslname_sasl_mech_name); + convert::write_buffer(mech_name, &info.saslname_mech_name); + convert::write_buffer(mech_description, &info.saslname_mech_desc); + (COMPLETE, 0) + } + Err(e) => e, + }; + maj = rmaj; + min = rmin; + if maj == COMPLETE || behavior == Behavior::RemoteOnly { + if maj != COMPLETE && tmaj != COMPLETE { + maj = tmaj; + min = tmin; + } + set_min(minor_status, min); + return maj; + } + + let sp = special::special_mech(desired_mech as *const _); + let mut m: OM_uint32 = 0; + maj = sys::gss_inquire_saslname_for_mech( + &mut m, + sp, + sasl_mech_name, + mech_name, + mech_description, + ); + min = m; + if maj != COMPLETE && tmaj != COMPLETE { + maj = tmaj; + min = tmin; + } + set_min(minor_status, min); + maj + } +} + +/// `gssi_inquire_mech_for_saslname`: not supported. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_inquire_mech_for_saslname( + _minor_status: *mut OM_uint32, + _sasl_mech_name: gss_buffer_t, + _mech_type: *mut gss_OID, +) -> OM_uint32 { + consts::GSS_S_UNAVAILABLE +} + +/// `gssi_display_status`: only minor (mech-code) statuses are handled. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_display_status( + minor_status: *mut OM_uint32, + status_value: OM_uint32, + status_type: i32, + _mech_type: gss_OID, + message_context: *mut OM_uint32, + status_string: gss_buffer_t, +) -> OM_uint32 { + unsafe { + if status_type != GSS_C_MECH_CODE { + return consts::GSS_S_BAD_STATUS; + } + + let val = unmap_error(status_value); + let mctx = if message_context.is_null() { + 0 + } else { + *message_context + }; + let (maj, min, text) = gpm::display_status(val, GSS_C_MECH_CODE, mctx); + + if maj == consts::GSS_S_UNAVAILABLE { + // Fall back to the local mechglue (no mech specified, as in C). + return sys::gss_display_status( + minor_status, + val, + GSS_C_MECH_CODE, + std::ptr::null_mut(), + message_context, + status_string, + ); + } + + if maj == COMPLETE { + convert::write_buffer(status_string, &text); + if !message_context.is_null() { + *message_context = 0; + } + } + if !minor_status.is_null() { + *minor_status = min; + } + maj + } +} + +/// `gssi_mech_invoke`: always bridged to the local mechanism. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_mech_invoke( + minor_status: *mut OM_uint32, + desired_mech: gss_OID, + desired_object: gss_OID, + value: gss_buffer_t, +) -> OM_uint32 { + unsafe { + let sp = special::special_mech(desired_mech as *const _); + let mut min: OM_uint32 = 0; + let maj = gssspi_mech_invoke(&mut min, sp, desired_object, value); + if !minor_status.is_null() { + *minor_status = map_error(min); + } + maj + } +} + +unsafe fn set_min(minor_status: *mut OM_uint32, min: u32) { + unsafe { + if !minor_status.is_null() { + *minor_status = map_error(min); + } + } +} diff --git a/rust/gssproxy-interposer/src/msgprot.rs b/rust/gssproxy-interposer/src/msgprot.rs new file mode 100644 index 000000000..a1b2c6855 --- /dev/null +++ b/rust/gssproxy-interposer/src/msgprot.rs @@ -0,0 +1,311 @@ +//! Message protection: `gssi_wrap` / `gssi_unwrap` / `gssi_get_mic` / +//! `gssi_verify_mic` and the iov/aead/size-limit variants. Port of +//! `gpp_priv_integ.c`. +//! +//! These are always handled locally: if the context lives only on the daemon, +//! it is first materialised into a real local context via +//! `gpp_remote_to_local_ctx`, then the real `gss_*` routine runs. + +use gssapi_sys::sys::{ + self, OM_uint32, gss_buffer_t, gss_ctx_id_t, gss_iov_buffer_desc, gss_qop_t, +}; + +use crate::error::map_error; +use crate::{handle, logging}; + +/// Ensure the context has a usable local handle, importing the remote one if +/// needed. Returns the resolved local `gss_ctx_id_t` or an error major status +/// (with `minor_status` already set). +unsafe fn ensure_local( + context_handle: gss_ctx_id_t, + minor_status: *mut OM_uint32, +) -> Result { + unsafe { + match handle::ensure_local_ctx(context_handle) { + Ok(l) => Ok(l), + Err((maj, min)) => { + if min != 0 && !minor_status.is_null() { + *minor_status = map_error(min); + } + Err(maj) + } + } + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_wrap( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + conf_req_flag: i32, + qop_req: gss_qop_t, + input_message_buffer: gss_buffer_t, + conf_state: *mut i32, + output_message_buffer: gss_buffer_t, +) -> OM_uint32 { + unsafe { + logging::init(); + let local = match ensure_local(context_handle, minor_status) { + Ok(l) => l, + Err(maj) => return maj, + }; + let maj = sys::gss_wrap( + minor_status, + local, + conf_req_flag, + qop_req, + input_message_buffer, + conf_state, + output_message_buffer, + ); + tracing::debug!( + input = logging::buf_len(input_message_buffer), + output = logging::buf_len(output_message_buffer), + major = maj, + "wrap" + ); + maj + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_wrap_size_limit( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + conf_req_flag: i32, + qop_req: gss_qop_t, + req_output_size: OM_uint32, + max_input_size: *mut OM_uint32, +) -> OM_uint32 { + unsafe { + let local = match ensure_local(context_handle, minor_status) { + Ok(l) => l, + Err(maj) => return maj, + }; + sys::gss_wrap_size_limit( + minor_status, + local, + conf_req_flag, + qop_req, + req_output_size, + max_input_size, + ) + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_wrap_iov( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + conf_req_flag: i32, + qop_req: gss_qop_t, + conf_state: *mut i32, + iov: *mut gss_iov_buffer_desc, + iov_count: i32, +) -> OM_uint32 { + unsafe { + let local = match ensure_local(context_handle, minor_status) { + Ok(l) => l, + Err(maj) => return maj, + }; + sys::gss_wrap_iov( + minor_status, + local, + conf_req_flag, + qop_req, + conf_state, + iov, + iov_count, + ) + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_wrap_iov_length( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + conf_req_flag: i32, + qop_req: gss_qop_t, + conf_state: *mut i32, + iov: *mut gss_iov_buffer_desc, + iov_count: i32, +) -> OM_uint32 { + unsafe { + let local = match ensure_local(context_handle, minor_status) { + Ok(l) => l, + Err(maj) => return maj, + }; + sys::gss_wrap_iov_length( + minor_status, + local, + conf_req_flag, + qop_req, + conf_state, + iov, + iov_count, + ) + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_wrap_aead( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + conf_req_flag: i32, + qop_req: gss_qop_t, + input_assoc_buffer: gss_buffer_t, + input_payload_buffer: gss_buffer_t, + conf_state: *mut i32, + output_message_buffer: gss_buffer_t, +) -> OM_uint32 { + unsafe { + let local = match ensure_local(context_handle, minor_status) { + Ok(l) => l, + Err(maj) => return maj, + }; + sys::gss_wrap_aead( + minor_status, + local, + conf_req_flag, + qop_req, + input_assoc_buffer, + input_payload_buffer, + conf_state, + output_message_buffer, + ) + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_unwrap( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + input_message_buffer: gss_buffer_t, + output_message_buffer: gss_buffer_t, + conf_state: *mut i32, + qop_state: *mut gss_qop_t, +) -> OM_uint32 { + unsafe { + logging::init(); + let local = match ensure_local(context_handle, minor_status) { + Ok(l) => l, + Err(maj) => return maj, + }; + let maj = sys::gss_unwrap( + minor_status, + local, + input_message_buffer, + output_message_buffer, + conf_state, + qop_state, + ); + tracing::debug!( + input = logging::buf_len(input_message_buffer), + output = logging::buf_len(output_message_buffer), + major = maj, + "unwrap" + ); + maj + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_unwrap_iov( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + conf_state: *mut i32, + qop_state: *mut gss_qop_t, + iov: *mut gss_iov_buffer_desc, + iov_count: i32, +) -> OM_uint32 { + unsafe { + let local = match ensure_local(context_handle, minor_status) { + Ok(l) => l, + Err(maj) => return maj, + }; + sys::gss_unwrap_iov(minor_status, local, conf_state, qop_state, iov, iov_count) + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_unwrap_aead( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + input_message_buffer: gss_buffer_t, + input_assoc_buffer: gss_buffer_t, + output_payload_buffer: gss_buffer_t, + conf_state: *mut i32, + qop_state: *mut gss_qop_t, +) -> OM_uint32 { + unsafe { + let local = match ensure_local(context_handle, minor_status) { + Ok(l) => l, + Err(maj) => return maj, + }; + sys::gss_unwrap_aead( + minor_status, + local, + input_message_buffer, + input_assoc_buffer, + output_payload_buffer, + conf_state, + qop_state, + ) + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_get_mic( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + qop_req: gss_qop_t, + message_buffer: gss_buffer_t, + message_token: gss_buffer_t, +) -> OM_uint32 { + unsafe { + logging::init(); + let local = match ensure_local(context_handle, minor_status) { + Ok(l) => l, + Err(maj) => return maj, + }; + let maj = sys::gss_get_mic(minor_status, local, qop_req, message_buffer, message_token); + tracing::debug!( + message = logging::buf_len(message_buffer), + token = logging::buf_len(message_token), + major = maj, + "get_mic" + ); + maj + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_verify_mic( + minor_status: *mut OM_uint32, + context_handle: gss_ctx_id_t, + message_buffer: gss_buffer_t, + message_token: gss_buffer_t, + qop_state: *mut gss_qop_t, +) -> OM_uint32 { + unsafe { + logging::init(); + let local = match ensure_local(context_handle, minor_status) { + Ok(l) => l, + Err(maj) => return maj, + }; + let maj = sys::gss_verify_mic( + minor_status, + local, + message_buffer, + message_token, + qop_state, + ); + tracing::debug!( + message = logging::buf_len(message_buffer), + token = logging::buf_len(message_token), + major = maj, + "verify_mic" + ); + maj + } +} diff --git a/rust/gssproxy-interposer/src/names.rs b/rust/gssproxy-interposer/src/names.rs new file mode 100644 index 000000000..e42968c9c --- /dev/null +++ b/rust/gssproxy-interposer/src/names.rs @@ -0,0 +1,599 @@ +//! `gssi_*` name operations. Port of `gpp_import_and_canon_name.c` (and the +//! name-related entries of `gpp_misc.c`). + +use std::ptr; + +use gssapi_sys::consts; +use gssapi_sys::sys::{self, OM_uint32, gss_OID, gss_buffer_set_t, gss_buffer_t, gss_name_t}; +use gssproxy_client::gpm; + +use crate::convert; +use crate::error::map_error; +use crate::handle::{NameHandle, OwnedOid, name_to_local}; +use crate::{logging, special}; + +const COMPLETE: u32 = 0; + +unsafe fn set_min(minor_status: *mut OM_uint32, min: u32) { + unsafe { + if !minor_status.is_null() { + *minor_status = map_error(min); + } + } +} + +/// `gssi_display_name`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_display_name( + minor_status: *mut OM_uint32, + input_name: gss_name_t, + output_name_buffer: gss_buffer_t, + output_name_type: *mut gss_OID, +) -> OM_uint32 { + unsafe { + logging::init(); + tracing::trace!("display_name"); + if !output_name_buffer.is_null() { + (*output_name_buffer).length = 0; + (*output_name_buffer).value = ptr::null_mut(); + } + if !output_name_type.is_null() { + *output_name_type = ptr::null_mut(); + } + + let name = match NameHandle::as_mut(input_name) { + Some(n) if !n.local.is_null() || n.remote.is_some() => n, + _ => return consts::GSS_S_BAD_NAME, + }; + + if !name.local.is_null() { + let mut min: OM_uint32 = 0; + let maj = + sys::gss_display_name(&mut min, name.local, output_name_buffer, output_name_type); + set_min(minor_status, min); + return maj; + } + + let remote = name.remote.as_mut().unwrap(); + let (maj, min, disp, ntype) = gpm::display_name(remote); + if maj != COMPLETE { + set_min(minor_status, min); + return maj; + } + if !output_name_buffer.is_null() { + convert::write_buffer(output_name_buffer, &disp); + } + if !output_name_type.is_null() { + match convert::name_type_static(&ntype) { + Some(o) => *output_name_type = o, + None => { + if !output_name_buffer.is_null() { + convert::release_buffer(output_name_buffer); + } + set_min(minor_status, libc::ENOENT as u32); + return consts::GSS_S_FAILURE; + } + } + } + set_min(minor_status, 0); + COMPLETE + } +} + +/// `gssi_display_name_ext`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_display_name_ext( + minor_status: *mut OM_uint32, + input_name: gss_name_t, + display_as_name_type: gss_OID, + display_name: gss_buffer_t, +) -> OM_uint32 { + unsafe { + let name = match NameHandle::as_mut(input_name) { + Some(n) if !n.local.is_null() || n.remote.is_some() => n, + _ => return consts::GSS_S_BAD_NAME, + }; + if name.local.is_null() { + set_min(minor_status, 0); + return consts::GSS_S_UNAVAILABLE; + } + let mut min: OM_uint32 = 0; + let maj = + sys::gss_display_name_ext(&mut min, name.local, display_as_name_type, display_name); + set_min(minor_status, min); + maj + } +} + +/// `gssi_import_name`: not supported by the interposer (always UNAVAILABLE). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_import_name( + _minor_status: *mut OM_uint32, + _input_name_buffer: gss_buffer_t, + _input_name_type: gss_OID, + _output_name: *mut gss_name_t, +) -> OM_uint32 { + consts::GSS_S_UNAVAILABLE +} + +/// `gssi_import_name_by_mech`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_import_name_by_mech( + minor_status: *mut OM_uint32, + mech_type: gss_OID, + input_name_buffer: gss_buffer_t, + input_name_type: gss_OID, + output_name: *mut gss_name_t, +) -> OM_uint32 { + unsafe { + logging::init(); + tracing::debug!("import_name_by_mech"); + if mech_type.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + // gpm_import_name requires a buffer and a name type. + if input_name_buffer.is_null() || input_name_type.is_null() { + set_min(minor_status, 0); + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + + let mut name = NameHandle::empty(); + name.mech_type = match OwnedOid::from_oid(mech_type) { + Some(o) => Some(o), + None => { + set_min(minor_status, libc::ENOMEM as u32); + return consts::GSS_S_FAILURE; + } + }; + let value = convert::read_buffer(input_name_buffer); + let ntype = convert::oid_bytes(input_name_type).unwrap_or(&[]); + name.remote = Some(gpm::import_name(value, ntype)); + + set_min(minor_status, 0); + *output_name = NameHandle::into_raw(name); + COMPLETE + } +} + +/// `gssi_duplicate_name`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_duplicate_name( + minor_status: *mut OM_uint32, + input_name: gss_name_t, + dest_name: *mut gss_name_t, +) -> OM_uint32 { + unsafe { + let in_name = match NameHandle::as_mut(input_name) { + Some(n) if !n.local.is_null() || n.remote.is_some() => n, + _ => return consts::GSS_S_BAD_NAME, + }; + + let mut out = NameHandle::empty(); + if let Some(o) = &in_name.mech_type { + out.mech_type = Some(OwnedOid::new( + convert::oid_bytes(o.as_ptr()).unwrap_or(&[]).to_vec(), + )); + } + + if let Some(r) = &in_name.remote { + out.remote = Some(r.clone()); + } else { + let mut min: OM_uint32 = 0; + let maj = sys::gss_duplicate_name(&mut min, in_name.local, &mut out.local); + if maj != COMPLETE { + set_min(minor_status, min); + return maj; + } + } + + set_min(minor_status, 0); + *dest_name = NameHandle::into_raw(out); + COMPLETE + } +} + +/// `gssi_inquire_name`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_inquire_name( + minor_status: *mut OM_uint32, + input_name: gss_name_t, + name_is_nm: *mut i32, + nm_mech: *mut gss_OID, + attrs: *mut gss_buffer_set_t, +) -> OM_uint32 { + unsafe { + let name = match NameHandle::as_mut(input_name) { + Some(n) if !n.local.is_null() || n.remote.is_some() => n, + _ => return consts::GSS_S_BAD_NAME, + }; + + if !name.local.is_null() { + let mut min: OM_uint32 = 0; + let maj = sys::gss_inquire_name(&mut min, name.local, name_is_nm, nm_mech, attrs); + set_min(minor_status, min); + return maj; + } + + let info = gpm::inquire_name(name.remote.as_ref().unwrap()); + set_min(minor_status, 0); + if !name_is_nm.is_null() && info.name_is_mn { + *name_is_nm = 1; + } + if !nm_mech.is_null() { + match convert::name_type_static(&info.name_type) { + Some(o) => *nm_mech = o, + None => { + set_min(minor_status, libc::ENOENT as u32); + return consts::GSS_S_FAILURE; + } + } + } + if !attrs.is_null() { + if info.attrs.is_empty() { + *attrs = ptr::null_mut(); + } else { + let mut min: OM_uint32 = 0; + let mut set: gss_buffer_set_t = ptr::null_mut(); + if sys::gss_create_empty_buffer_set(&mut min, &mut set) != COMPLETE { + set_min(minor_status, libc::ENOMEM as u32); + return consts::GSS_S_FAILURE; + } + for a in &info.attrs { + let tb = convert::TmpBuf::new(a); + sys::gss_add_buffer_set_member(&mut min, tb.as_ptr(), &mut set); + } + *attrs = set; + } + } + COMPLETE + } +} + +/// `gssi_release_name`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_release_name( + minor_status: *mut OM_uint32, + input_name: *mut gss_name_t, +) -> OM_uint32 { + unsafe { + if input_name.is_null() { + return consts::GSS_S_BAD_NAME; + } + match NameHandle::as_mut(*input_name) { + Some(n) if !n.local.is_null() || n.remote.is_some() => {} + _ => return consts::GSS_S_BAD_NAME, + } + // Drop releases the local name, the owned mech OID, and the remote name. + drop(NameHandle::from_raw(*input_name)); + *input_name = ptr::null_mut(); + set_min(minor_status, 0); + COMPLETE + } +} + +/// `gssi_compare_name`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_compare_name( + minor_status: *mut OM_uint32, + name1: gss_name_t, + name2: gss_name_t, + name_equal: *mut i32, +) -> OM_uint32 { + unsafe { + let gp1 = match NameHandle::as_mut(name1) { + Some(n) => n, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + let gp2 = match NameHandle::as_mut(name2) { + Some(n) => n, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + + if !gp1.local.is_null() || !gp2.local.is_null() { + if gp1.local.is_null() { + let mech = gp1.mech_ptr(); + let remote = match gp1.remote.as_mut() { + Some(r) => r, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + let (maj, min, local) = name_to_local(remote, mech); + if maj != COMPLETE { + set_min(minor_status, min); + return maj; + } + gp1.local = local; + } + if gp2.local.is_null() { + let mech = gp2.mech_ptr(); + let remote = match gp2.remote.as_mut() { + Some(r) => r, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + let (maj, min, local) = name_to_local(remote, mech); + if maj != COMPLETE { + set_min(minor_status, min); + return maj; + } + gp2.local = local; + } + let mut min: OM_uint32 = 0; + let maj = sys::gss_compare_name(&mut min, gp1.local, gp2.local, name_equal); + set_min(minor_status, min); + return maj; + } + + if gp1.remote.is_none() && gp2.remote.is_none() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + let (maj, min, equal) = + gpm::compare_name(gp1.remote.as_ref().unwrap(), gp2.remote.as_ref().unwrap()); + if !name_equal.is_null() { + *name_equal = if equal { 1 } else { 0 }; + } + set_min(minor_status, min); + maj + } +} + +/// `gssi_get_name_attribute`: local only. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn gssi_get_name_attribute( + minor_status: *mut OM_uint32, + input_name: gss_name_t, + attr: gss_buffer_t, + authenticated: *mut i32, + complete: *mut i32, + value: gss_buffer_t, + display_value: gss_buffer_t, + more: *mut i32, +) -> OM_uint32 { + unsafe { + let name = match NameHandle::as_mut(input_name) { + Some(n) if !n.local.is_null() || n.remote.is_some() => n, + _ => return consts::GSS_S_BAD_NAME, + }; + if name.local.is_null() { + set_min(minor_status, 0); + return consts::GSS_S_UNAVAILABLE; + } + let mut min: OM_uint32 = 0; + let maj = sys::gss_get_name_attribute( + &mut min, + name.local, + attr, + authenticated, + complete, + value, + display_value, + more, + ); + set_min(minor_status, min); + maj + } +} + +/// `gssi_set_name_attribute`: local only. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_set_name_attribute( + minor_status: *mut OM_uint32, + input_name: gss_name_t, + complete: i32, + attr: gss_buffer_t, + value: gss_buffer_t, +) -> OM_uint32 { + unsafe { + let name = match NameHandle::as_mut(input_name) { + Some(n) if !n.local.is_null() || n.remote.is_some() => n, + _ => return consts::GSS_S_BAD_NAME, + }; + if name.local.is_null() { + set_min(minor_status, 0); + return consts::GSS_S_UNAVAILABLE; + } + let mut min: OM_uint32 = 0; + let maj = sys::gss_set_name_attribute(&mut min, name.local, complete, attr, value); + set_min(minor_status, min); + maj + } +} + +/// `gssi_delete_name_attribute`: local only. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_delete_name_attribute( + minor_status: *mut OM_uint32, + input_name: gss_name_t, + attr: gss_buffer_t, +) -> OM_uint32 { + unsafe { + let name = match NameHandle::as_mut(input_name) { + Some(n) if !n.local.is_null() || n.remote.is_some() => n, + _ => return consts::GSS_S_BAD_NAME, + }; + if name.local.is_null() { + set_min(minor_status, 0); + return consts::GSS_S_UNAVAILABLE; + } + let mut min: OM_uint32 = 0; + let maj = sys::gss_delete_name_attribute(&mut min, name.local, attr); + set_min(minor_status, min); + maj + } +} + +/// `gssi_localname`: port of `gpp_misc.c`. Tries the daemon first (falling back +/// to local on `ENOTSUP`), otherwise the real local mechanism. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_localname( + minor_status: *mut OM_uint32, + name: gss_name_t, + mech_type: gss_OID, + localname: gss_buffer_t, +) -> OM_uint32 { + unsafe { + if !minor_status.is_null() { + *minor_status = 0; + } + if name.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + let gpname = match NameHandle::as_mut(name) { + Some(n) if !n.local.is_null() || n.remote.is_some() => n, + _ => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + + let mut maj = COMPLETE; + let mut min = 0u32; + + if let Some(remote) = gpname.remote.as_ref() { + let mech_bytes = convert::oid_bytes(mech_type).unwrap_or(&[]).to_vec(); + let (m, mi, out) = gpm::localname(remote, &mech_bytes); + if m == COMPLETE { + if let Some(buf) = out { + convert::write_buffer(localname, &buf); + } + set_min(minor_status, mi); + return m; + } else if m != consts::GSS_S_FAILURE || mi != libc::ENOTSUP as u32 { + set_min(minor_status, mi); + return m; + } + // ENOTSUP: the daemon can't map it; fall back to a local conversion. + } + + if gpname.local.is_null() + && let Some(r) = gpname.remote.as_mut() + { + let (nm, nmi, local) = name_to_local(r, mech_type); + if nm != COMPLETE { + set_min(minor_status, nmi); + return nm; + } + gpname.local = local; + } + + if !gpname.local.is_null() { + let sp = special::special_mech(mech_type as *const _); + maj = sys::gss_localname(&mut min, gpname.local, sp, localname); + } + + set_min(minor_status, min); + maj + } +} + +/// `gssi_authorize_localname`: local only (with remote->local conversion). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_authorize_localname( + minor_status: *mut OM_uint32, + name: gss_name_t, + local_user: gss_buffer_t, + local_nametype: gss_OID, +) -> OM_uint32 { + unsafe { + if !minor_status.is_null() { + *minor_status = 0; + } + if name.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + let gpname = match NameHandle::as_mut(name) { + Some(n) => n, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + + if gpname.local.is_null() { + let mech = gpname.mech_ptr(); + if let Some(r) = gpname.remote.as_mut() { + let (m, mi, local) = name_to_local(r, mech); + if m != COMPLETE { + set_min(minor_status, mi); + return m; + } + gpname.local = local; + } + } + + let mut min: OM_uint32 = 0; + let mut username: gss_name_t = ptr::null_mut(); + let maj = sys::gss_import_name(&mut min, local_user, local_nametype, &mut username); + if maj != COMPLETE { + set_min(minor_status, min); + return maj; + } + let maj = sys::gss_authorize_localname(&mut min, gpname.local, username); + set_min(minor_status, min); + let mut m: OM_uint32 = 0; + sys::gss_release_name(&mut m, &mut username); + maj + } +} + +/// `gssi_map_name_to_any`: local only (with remote->local conversion). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_map_name_to_any( + minor_status: *mut OM_uint32, + name: gss_name_t, + authenticated: i32, + type_id: gss_buffer_t, + output: *mut sys::gss_any_t, +) -> OM_uint32 { + unsafe { + if !minor_status.is_null() { + *minor_status = 0; + } + if name.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + let gpname = match NameHandle::as_mut(name) { + Some(n) => n, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + + if gpname.local.is_null() { + let mech = gpname.mech_ptr(); + if let Some(r) = gpname.remote.as_mut() { + let (m, mi, local) = name_to_local(r, mech); + if m != COMPLETE { + set_min(minor_status, mi); + return m; + } + gpname.local = local; + } + } + + let mut min: OM_uint32 = 0; + let maj = sys::gss_map_name_to_any(&mut min, gpname.local, authenticated, type_id, output); + set_min(minor_status, min); + maj + } +} + +/// `gssi_release_any_name_mapping`: local only. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gssi_release_any_name_mapping( + minor_status: *mut OM_uint32, + name: gss_name_t, + type_id: gss_buffer_t, + input: *mut sys::gss_any_t, +) -> OM_uint32 { + unsafe { + if !minor_status.is_null() { + *minor_status = 0; + } + if name.is_null() { + return consts::GSS_S_CALL_INACCESSIBLE_READ; + } + let gpname = match NameHandle::as_mut(name) { + Some(n) => n, + None => return consts::GSS_S_CALL_INACCESSIBLE_READ, + }; + if gpname.local.is_null() { + return consts::GSS_S_UNAVAILABLE; + } + let mut min: OM_uint32 = 0; + let maj = sys::gss_release_any_name_mapping(&mut min, gpname.local, type_id, input); + set_min(minor_status, min); + maj + } +} diff --git a/rust/gssproxy-interposer/src/oids.rs b/rust/gssproxy-interposer/src/oids.rs new file mode 100644 index 000000000..424a57c07 --- /dev/null +++ b/rust/gssproxy-interposer/src/oids.rs @@ -0,0 +1,317 @@ +//! Well-known and gssproxy-specific GSSAPI mechanism OIDs. +//! +//! Port of the OID `gss_OID_desc` constants at the top of +//! `src/mechglue/gss_plugin.c`. The descriptors are created once and stored in +//! a process-global `OnceLock` so their addresses are stable for the lifetime +//! of the process: the mechglue and our own `gssi_internal_release_oid` compare +//! some OIDs by pointer identity, exactly like the C code. + +use std::os::raw::c_void; +use std::sync::OnceLock; + +use gssapi_sys::sys::{OM_uint32, gss_OID_desc}; + +// DER element bytes for each mechanism OID (see gss_plugin.c). + +/// krb5 mech: 1.2.840.113554.1.2.2 +const KRB5: &[u8] = b"\x2a\x86\x48\x86\xf7\x12\x01\x02\x02"; +/// Old krb5 mech: 1.3.5.1.5.2 +const KRB5_OLD: &[u8] = b"\x2b\x05\x01\x05\x02"; +/// Incorrect krb5 mech OID emitted by MS: 1.2.840.48018.1.2.2 +const KRB5_WRONG: &[u8] = b"\x2a\x86\x48\x82\xf7\x12\x01\x02\x02"; +/// IAKERB mech: 1.3.6.1.5.2.5 +const IAKERB: &[u8] = b"\x2b\x06\x01\x05\x02\x05"; +/// gssproxy interposer mech: 2.16.840.1.113730.3.8.15.1 +const INTERPOSER: &[u8] = b"\x60\x86\x48\x01\x86\xf8\x42\x03\x08\x0f\x01"; + +/// The set of stable base OID descriptors. Raw pointers inside `gss_OID_desc` +/// are immutable after construction, so sharing across threads is sound. +pub struct BaseOids { + pub interposer: gss_OID_desc, + pub krb5: gss_OID_desc, + pub krb5_old: gss_OID_desc, + pub krb5_wrong: gss_OID_desc, + pub iakerb: gss_OID_desc, +} + +// SAFETY: the descriptors only ever point at `'static` const byte slices and +// are never mutated after initialisation. +unsafe impl Sync for BaseOids {} +unsafe impl Send for BaseOids {} + +static BASE: OnceLock = OnceLock::new(); + +fn desc(bytes: &'static [u8]) -> gss_OID_desc { + gss_OID_desc { + length: bytes.len() as OM_uint32, + elements: bytes.as_ptr() as *mut c_void, + } +} + +/// Accessor for the process-global base OID descriptors. +pub fn base() -> &'static BaseOids { + BASE.get_or_init(|| BaseOids { + interposer: desc(INTERPOSER), + krb5: desc(KRB5), + krb5_old: desc(KRB5_OLD), + krb5_wrong: desc(KRB5_WRONG), + iakerb: desc(IAKERB), + }) +} + +/// Pointer to the stable interposer OID descriptor (`gssproxy_mech_interposer`). +pub fn interposer() -> *const gss_OID_desc { + &base().interposer +} + +/// Borrow the DER bytes behind an OID descriptor pointer. +/// +/// # Safety +/// `oid` must be null or point to a valid `gss_OID_desc` whose `elements` +/// buffer is at least `length` bytes. +pub unsafe fn oid_bytes<'a>(oid: *const gss_OID_desc) -> Option<&'a [u8]> { + unsafe { + if oid.is_null() { + return None; + } + let d = &*oid; + if d.elements.is_null() { + return Some(&[]); + } + Some(std::slice::from_raw_parts( + d.elements as *const u8, + d.length as usize, + )) + } +} + +/// `gss_oid_equal` equivalent: compare two OIDs by length and bytes. +/// +/// # Safety +/// Both pointers must satisfy the contract of [`oid_bytes`]. +pub unsafe fn oid_equal(a: *const gss_OID_desc, b: *const gss_OID_desc) -> bool { + unsafe { + match (oid_bytes(a), oid_bytes(b)) { + (Some(x), Some(y)) => x == y, + _ => false, + } + } +} + +/// `gpp_is_krb5_oid`: true for any of the krb5/iakerb mech OIDs we proxy. +/// +/// Kept for parity with the C helper of the same name; not yet referenced by +/// the Rust data path. +/// +/// # Safety +/// `mech` must satisfy the contract of [`oid_bytes`]. +#[allow(dead_code)] +pub unsafe fn is_krb5_oid(mech: *const gss_OID_desc) -> bool { + unsafe { + let b = base(); + oid_equal(mech, &b.krb5) + || oid_equal(mech, &b.krb5_old) + || oid_equal(mech, &b.krb5_wrong) + || oid_equal(mech, &b.iakerb) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_oids_have_stable_addresses() { + let a = interposer(); + let b = interposer(); + assert_eq!(a, b, "interposer OID address must be stable"); + } + + #[test] + fn krb5_oid_recognised() { + let b = base(); + unsafe { + assert!(is_krb5_oid(&b.krb5)); + assert!(is_krb5_oid(&b.iakerb)); + assert!(!is_krb5_oid(&b.interposer)); + assert!(!is_krb5_oid(std::ptr::null())); + } + } + + #[test] + fn oid_bytes_match_der() { + let b = base(); + unsafe { + assert_eq!(oid_bytes(&b.krb5).unwrap(), KRB5); + assert_eq!(oid_bytes(&b.interposer).unwrap().len(), 11); + } + } + + /// Cross-check every OID against the independently-defined, C-derived + /// constants in `gssapi-sys` (which mirror `src/mechglue/gss_plugin.c` and + /// the upstream OID registry). Any divergence here is a wire/ABI bug. + #[test] + fn oids_match_gssapi_sys_constants() { + use gssapi_sys::consts; + let b = base(); + unsafe { + assert_eq!(oid_bytes(&b.krb5).unwrap(), consts::KRB5_MECH_OID); + assert_eq!(oid_bytes(&b.krb5_old).unwrap(), consts::KRB5_OLD_MECH_OID); + assert_eq!( + oid_bytes(&b.krb5_wrong).unwrap(), + consts::KRB5_WRONG_MECH_OID + ); + assert_eq!(oid_bytes(&b.iakerb).unwrap(), consts::IAKERB_MECH_OID); + assert_eq!( + oid_bytes(&b.interposer).unwrap(), + consts::GSSPROXY_INTERPOSER_OID + ); + } + } + + #[test] + fn distinct_oids_are_not_equal() { + let b = base(); + unsafe { + assert!(!oid_equal(&b.krb5, &b.krb5_old)); + assert!(!oid_equal(&b.krb5, &b.krb5_wrong)); + assert!(oid_equal(&b.krb5, &b.krb5)); + } + } +} + +#[cfg(test)] +mod prop_tests { + use super::*; + use proptest::prelude::*; + + fn with_oid(bytes: &[u8], f: impl FnOnce(*const gss_OID_desc) -> R) -> R { + let desc = gss_OID_desc { + length: bytes.len() as OM_uint32, + elements: if bytes.is_empty() { + std::ptr::null_mut() + } else { + bytes.as_ptr() as *mut c_void + }, + }; + f(&desc as *const gss_OID_desc) + } + + // Known mech OIDs, so the strategy can assert is_krb5_oid is *exactly* this + // set and nothing else. + const KNOWN_KRB5: &[&[u8]] = &[KRB5, KRB5_OLD, KRB5_WRONG, IAKERB]; + + proptest! { + #![proptest_config(ProptestConfig { + cases: 256, + failure_persistence: None, + ..ProptestConfig::default() + })] + + /// `oid_bytes` returns exactly the backing bytes for any length. + #[test] + fn oid_bytes_returns_input(bytes in prop::collection::vec(any::(), 0..40)) { + with_oid(&bytes, |oid| -> std::result::Result<(), TestCaseError> { + let got = unsafe { oids_oid_bytes_or_empty(oid) }; + prop_assert_eq!(got, &bytes[..]); + Ok(()) + })?; + } + + /// `oid_equal` is exactly byte-equality, and reflexive, for any inputs. + #[test] + fn oid_equal_is_byte_equality(a in prop::collection::vec(any::(), 0..32), + b in prop::collection::vec(any::(), 0..32)) { + with_oid(&a, |oa| with_oid(&b, |ob| -> std::result::Result<(), TestCaseError> { + unsafe { + prop_assert_eq!(oid_equal(oa, ob), a == b); + prop_assert!(oid_equal(oa, oa)); + prop_assert!(oid_equal(ob, ob)); + } + Ok(()) + }))?; + } + + /// `is_krb5_oid` is true for an arbitrary OID iff its bytes are one of + /// the four known krb5/iakerb OIDs. + #[test] + fn is_krb5_oid_matches_known_set(bytes in prop::collection::vec(any::(), 0..32)) { + with_oid(&bytes, |oid| -> std::result::Result<(), TestCaseError> { + let expected = KNOWN_KRB5.contains(&bytes.as_slice()); + prop_assert_eq!(unsafe { is_krb5_oid(oid) }, expected); + Ok(()) + })?; + } + } + + // Helper that treats a null-elements descriptor as the empty slice (matching + // oid_bytes), so the property test can compare against the original Vec. + unsafe fn oids_oid_bytes_or_empty<'a>(oid: *const gss_OID_desc) -> &'a [u8] { + unsafe { oid_bytes(oid).unwrap_or(&[]) } + } +} + +/// Kani bounded-model-checking harnesses. These prove the same invariants the +/// `prop_tests` sample, but exhaustively over all inputs within the bound. +/// They are FFI-free (no `gss_*`/`krb5_*` is reachable) so CBMC can model them. +/// Gated on `#[cfg(kani)]`, compiled only under `cargo kani`. See +/// `rust/docs/formal-verification.md`. +#[cfg(kani)] +mod kani_proofs { + use super::*; + + /// The known krb5/iakerb mech OIDs (max length 9), so a 9-byte arbitrary + /// array + arbitrary length covers every one of them by prefix. + const KNOWN_KRB5: [&[u8]; 4] = [KRB5, KRB5_OLD, KRB5_WRONG, IAKERB]; + + fn make_desc(bytes: &[u8]) -> gss_OID_desc { + gss_OID_desc { + length: bytes.len() as OM_uint32, + elements: if bytes.is_empty() { + std::ptr::null_mut() + } else { + bytes.as_ptr() as *mut c_void + }, + } + } + + /// `oid_equal` is exactly byte-equality and is reflexive, for every pair of + /// OIDs up to 9 bytes. + #[kani::proof] + #[kani::unwind(11)] + fn oid_equal_is_byte_equality() { + let a: [u8; 9] = kani::any(); + let la: usize = kani::any(); + kani::assume(la <= a.len()); + let b: [u8; 9] = kani::any(); + let lb: usize = kani::any(); + kani::assume(lb <= b.len()); + + let da = make_desc(&a[..la]); + let db = make_desc(&b[..lb]); + // SAFETY: both descriptors point at valid stack slices of their length. + unsafe { + assert!(oid_equal(&da, &db) == (a[..la] == b[..lb])); + assert!(oid_equal(&da, &da)); + assert!(oid_equal(&db, &db)); + } + } + + /// `is_krb5_oid` is true for an arbitrary OID (up to 9 bytes) iff its bytes + /// are one of the four known krb5/iakerb OIDs. + #[kani::proof] + #[kani::unwind(11)] + fn is_krb5_oid_matches_known_set() { + let bytes: [u8; 9] = kani::any(); + let len: usize = kani::any(); + kani::assume(len <= bytes.len()); + let slice = &bytes[..len]; + + let desc = make_desc(slice); + let expected = KNOWN_KRB5.iter().any(|known| *known == slice); + // SAFETY: desc points at a valid stack slice of `len` bytes. + unsafe { + assert!(is_krb5_oid(&desc) == expected); + } + } +} diff --git a/rust/gssproxy-interposer/src/special.rs b/rust/gssproxy-interposer/src/special.rs new file mode 100644 index 000000000..142caa7b4 --- /dev/null +++ b/rust/gssproxy-interposer/src/special.rs @@ -0,0 +1,498 @@ +//! The "special" mechanism OID machinery from `src/mechglue/gss_plugin.c`. +//! +//! gssproxy hides interposed mechanisms from the local mechglue by prefixing +//! each real mech OID with the interposer OID. These prefixed OIDs are the +//! "special" mechs; a real mech is recovered by stripping the prefix. The C +//! code keeps a process-global, add-only linked list of `(regular, special)` +//! pairs whose pointers are handed out to the mechglue and must stay valid for +//! the life of the process. +//! +//! We mirror that with a `Mutex`-guarded `Vec` of leaked entries: each entry's +//! `gss_OID_desc`s (and their element buffers) are intentionally leaked so the +//! pointers we return remain valid forever, just like the never-freed C list. + +use std::os::raw::c_void; +use std::ptr; +use std::sync::Mutex; + +use gssapi_sys::sys::{OM_uint32, gss_OID, gss_OID_desc}; + +use crate::oids; + +/// A leaked `(regular, special)` OID pair. The descriptors live at stable +/// heap addresses for the process lifetime. +struct SpecialEntry { + regular: *const gss_OID_desc, + special: *const gss_OID_desc, +} + +// SAFETY: entries are immutable after creation; the pointers reference leaked, +// never-mutated heap allocations. +unsafe impl Send for SpecialEntry {} + +static REGISTRY: Mutex> = Mutex::new(Vec::new()); + +/// Leak `bytes` plus a `gss_OID_desc` describing them, returning a stable +/// pointer to the descriptor. +fn leak_oid(bytes: &[u8]) -> *const gss_OID_desc { + let boxed: Box<[u8]> = bytes.to_vec().into_boxed_slice(); + let len = boxed.len(); + let elements = Box::into_raw(boxed) as *mut c_void; // leak element bytes + let desc = Box::new(gss_OID_desc { + length: len as OM_uint32, + elements, + }); + Box::into_raw(desc) as *const gss_OID_desc // leak descriptor +} + +/// `gpp_is_special_oid`: true if `mech` begins with the interposer OID prefix. +/// +/// # Safety +/// `mech` must satisfy the contract of [`oids::oid_bytes`]. +pub unsafe fn is_special_oid(mech: *const gss_OID_desc) -> bool { + unsafe { + let prefix = match oids::oid_bytes(oids::interposer()) { + Some(p) => p, + None => return false, + }; + match oids::oid_bytes(mech) { + Some(b) => b.len() >= prefix.len() && &b[..prefix.len()] == prefix, + None => false, + } + } +} + +/// `gpp_special_equal`: true if special OID `s` is `n` with the interposer +/// prefix stripped (i.e. `s == interposer ++ n`). +/// +/// # Safety +/// Both pointers must satisfy the contract of [`oids::oid_bytes`]. +unsafe fn special_equal(s: *const gss_OID_desc, n: *const gss_OID_desc) -> bool { + unsafe { + let base = match oids::oid_bytes(oids::interposer()) { + Some(p) => p.len(), + None => return false, + }; + let (sb, nb) = match (oids::oid_bytes(s), oids::oid_bytes(n)) { + (Some(sb), Some(nb)) => (sb, nb), + _ => return false, + }; + sb.len() >= base && sb.len() - base == nb.len() && &sb[base..] == nb + } +} + +/// `gpp_new_special_mech`: build a new special OID for the regular mech bytes +/// `nb` and append it to the registry, returning the stable special-OID +/// pointer. The caller must already hold the registry lock so that the +/// search-then-insert in [`special_mech`] is atomic - see the note there. +fn register_locked(reg: &mut Vec, nb: &[u8]) -> *const gss_OID_desc { + // SAFETY: the interposer descriptor is a valid 'static OID. + let prefix = unsafe { oids::oid_bytes(oids::interposer()) }.unwrap_or(&[]); + + let regular = leak_oid(nb); + let mut special_bytes = Vec::with_capacity(prefix.len() + nb.len()); + special_bytes.extend_from_slice(prefix); + special_bytes.extend_from_slice(nb); + let special = leak_oid(&special_bytes); + + reg.push(SpecialEntry { regular, special }); + special +} + +/// `gpp_special_mech`: map a real mech OID to its special form, registering a +/// new one if needed. `GSS_C_NO_OID` (null) returns the first known special +/// mech, or null if none exist yet. +/// +/// Unlike the C `gpp_special_mech` - which walks a lock-free list and may, when +/// two threads race on the same new mech, append two equivalent entries - we +/// hold the registry lock across the search and the insert so a given mech maps +/// to exactly one stable special pointer. This is purely internal mechglue +/// bookkeeping (special OIDs never reach the wire), so the stricter dedup has no +/// byte/ABI effect; it only avoids redundant leaked allocations under load. +/// +/// # Safety +/// `mech_type` must be null or satisfy the contract of [`oids::oid_bytes`]. +pub unsafe fn special_mech(mech_type: *const gss_OID_desc) -> gss_OID { + unsafe { + if is_special_oid(mech_type) { + return mech_type as gss_OID; + } + + let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + + if mech_type.is_null() { + return reg + .first() + .map(|e| e.special as gss_OID) + .unwrap_or(ptr::null_mut()); + } + + for e in reg.iter() { + if special_equal(e.special, mech_type) { + return e.special as gss_OID; + } + } + + match oids::oid_bytes(mech_type) { + Some(nb) => register_locked(&mut reg, nb) as gss_OID, + None => ptr::null_mut(), + } + } +} + +/// `gpp_unspecial_mech`: map a special mech OID back to its real form. If +/// `mech_type` is not special, or not known, it is returned unchanged. +/// +/// # Safety +/// `mech_type` must be null or satisfy the contract of [`oids::oid_bytes`]. +pub unsafe fn unspecial_mech(mech_type: *const gss_OID_desc) -> gss_OID { + unsafe { + if !is_special_oid(mech_type) { + return mech_type as gss_OID; + } + let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + for e in reg.iter() { + if oids::oid_equal(e.special, mech_type) { + return e.regular as gss_OID; + } + } + mech_type as gss_OID + } +} + +/// `gpp_init_special_available_mechs`: pre-register special OIDs for every mech +/// in `mechs` that we do not already track. +/// +/// # Safety +/// `mechs` must point to a valid `gss_OID_set_desc` for reads, or be null. +pub unsafe fn init_special_available_mechs(mechs: gssapi_sys::sys::gss_OID_set) { + unsafe { + if mechs.is_null() { + return; + } + let set = &*mechs; + // Hold the lock across the whole loop so the check-then-insert per mech is + // atomic (matching special_mech's stricter dedup). + let mut reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + for i in 0..set.count { + let m = set.elements.add(i) as *const gss_OID_desc; + if is_special_oid(m) { + continue; + } + if reg.iter().any(|e| special_equal(e.special, m)) { + continue; + } + if let Some(nb) = oids::oid_bytes(m) { + register_locked(&mut reg, nb); + } + } + } +} + +/// `gpp_special_available_mechs`: build a freshly-allocated `gss_OID_set` of +/// the special OIDs corresponding to every mech in `mechs`, registering new +/// special OIDs as needed. Returns `GSS_C_NO_OID_SET` (null) on failure or when +/// the resulting set would be empty (matching the C behaviour). +/// +/// # Safety +/// `mechs` must be null or point to a valid `gss_OID_set_desc`. +pub unsafe fn special_available_mechs( + mechs: gssapi_sys::sys::gss_OID_set, +) -> gssapi_sys::sys::gss_OID_set { + unsafe { + use gssapi_sys::sys; + let mut amechs: sys::gss_OID_set = ptr::null_mut(); + let mut min: OM_uint32 = 0; + if sys::gss_create_empty_oid_set(&mut min, &mut amechs) != 0 { + return ptr::null_mut(); + } + let mut ok = true; + if !mechs.is_null() { + let set = &*mechs; + for i in 0..set.count { + let m = set.elements.add(i) as *const gss_OID_desc; + // special_mech() returns m unchanged if it is already special, the + // existing matching special OID, or a freshly registered one - + // exactly the three cases in the C loop. + let sp = special_mech(m); + if sp.is_null() { + ok = false; + break; + } + if sys::gss_add_oid_set_member(&mut min, sp, &mut amechs) != 0 { + ok = false; + break; + } + } + } + let empty = amechs.is_null() || (*amechs).count == 0; + if !ok || empty { + let mut m2: OM_uint32 = 0; + sys::gss_release_oid_set(&mut m2, &mut amechs); + return ptr::null_mut(); + } + amechs + } +} + +/// True if `oid` is one of our registered regular/special OID descriptor +/// pointers (compared by identity, as in `gssi_internal_release_oid`). +pub fn is_registered_ptr(oid: *const gss_OID_desc) -> bool { + let reg = REGISTRY.lock().unwrap_or_else(|e| e.into_inner()); + reg.iter().any(|e| e.regular == oid || e.special == oid) +} + +#[cfg(test)] +mod tests { + use super::*; + use gssapi_sys::sys::gss_OID_desc; + use std::os::raw::c_void; + + fn make_oid(bytes: &'static [u8]) -> gss_OID_desc { + gss_OID_desc { + length: bytes.len() as OM_uint32, + elements: bytes.as_ptr() as *mut c_void, + } + } + + #[test] + fn special_round_trip() { + // 1.2.840.113554.1.2.2 (krb5) DER body. + let krb5 = make_oid(b"\x2a\x86\x48\x86\xf7\x12\x01\x02\x02"); + unsafe { + assert!(!is_special_oid(&krb5)); + let sp = special_mech(&krb5); + assert!(!sp.is_null()); + assert!(is_special_oid(sp)); + // Idempotent: same mech maps to the same special OID pointer. + let sp2 = special_mech(&krb5); + assert_eq!(sp, sp2); + // And we can strip back to the original bytes. + let back = unspecial_mech(sp); + assert_eq!( + oids::oid_bytes(back).unwrap(), + oids::oid_bytes(&krb5).unwrap() + ); + // Passing an already-special OID returns it unchanged. + assert_eq!(special_mech(sp), sp); + assert!(is_registered_ptr(sp)); + } + } + + #[test] + fn distinct_mechs_get_distinct_special_oids() { + let krb5 = make_oid(b"\x2a\x86\x48\x86\xf7\x12\x01\x02\x02"); + let iakerb = make_oid(b"\x2b\x06\x01\x05\x02\x05"); + unsafe { + let sp_krb5 = special_mech(&krb5); + let sp_iakerb = special_mech(&iakerb); + assert_ne!(sp_krb5, sp_iakerb); + // Each special OID strips back to its own original mech. + assert_eq!( + oids::oid_bytes(unspecial_mech(sp_krb5)).unwrap(), + oids::oid_bytes(&krb5).unwrap() + ); + assert_eq!( + oids::oid_bytes(unspecial_mech(sp_iakerb)).unwrap(), + oids::oid_bytes(&iakerb).unwrap() + ); + } + } + + #[test] + fn special_prefix_is_the_interposer_oid() { + let krb5 = make_oid(b"\x2a\x86\x48\x86\xf7\x12\x01\x02\x02"); + unsafe { + let sp = special_mech(&krb5); + let prefix = oids::oid_bytes(oids::interposer()).unwrap(); + let sp_bytes = oids::oid_bytes(sp).unwrap(); + assert!(sp_bytes.starts_with(prefix)); + assert_eq!(&sp_bytes[prefix.len()..], oids::oid_bytes(&krb5).unwrap()); + } + } + + #[test] + fn unspecial_of_unknown_returns_input() { + // A non-special, unregistered OID passes through unchanged. + let krb5 = make_oid(b"\x2a\x86\x48\x86\xf7\x12\x01\x02\x02"); + unsafe { + let p = &krb5 as *const gss_OID_desc; + assert_eq!(unspecial_mech(p), p as gss_OID); + assert!(!is_special_oid(p)); + } + } + + #[test] + fn null_oid_is_not_special() { + unsafe { + assert!(!is_special_oid(std::ptr::null())); + } + } +} + +#[cfg(test)] +mod prop_tests { + use super::*; + use proptest::prelude::*; + use std::collections::{HashMap, HashSet}; + use std::sync::Arc; + use std::thread; + + /// The interposer OID prefix, duplicated here so the strategy can exclude + /// inputs that would already look "special". + const INTERPOSER_PREFIX: &[u8] = b"\x60\x86\x48\x01\x86\xf8\x42\x03\x08\x0f\x01"; + + /// Build a stack `gss_OID_desc` over `bytes` and run `f` with a pointer to + /// it. The descriptor (and `bytes`) outlive the call, which is all the + /// `special.rs` functions require - they copy bytes when registering. + fn with_oid(bytes: &[u8], f: impl FnOnce(*const gss_OID_desc) -> R) -> R { + let desc = gss_OID_desc { + length: bytes.len() as OM_uint32, + elements: bytes.as_ptr() as *mut c_void, + }; + f(&desc as *const gss_OID_desc) + } + + fn current_len() -> usize { + REGISTRY.lock().unwrap_or_else(|e| e.into_inner()).len() + } + + fn non_special_bytes() -> impl Strategy> { + prop::collection::vec(any::(), 1..24) + .prop_filter("input must not already carry the interposer prefix", |b| { + !b.starts_with(INTERPOSER_PREFIX) + }) + } + + proptest! { + #![proptest_config(ProptestConfig { + cases: 200, + failure_persistence: None, + ..ProptestConfig::default() + })] + + /// A regular mech round-trips through special/unspecial: the special OID + /// is prefixed, registered, stable, and strips back to the input bytes. + #[test] + fn special_round_trip_arbitrary(bytes in non_special_bytes()) { + with_oid(&bytes, |oid| -> std::result::Result<(), TestCaseError> { + unsafe { + prop_assert!(!is_special_oid(oid)); + let sp = special_mech(oid); + prop_assert!(!sp.is_null()); + prop_assert!(is_special_oid(sp)); + prop_assert!(is_registered_ptr(sp)); + + // Idempotent: same bytes -> same stable special pointer. + let sp2 = special_mech(oid); + prop_assert_eq!(sp, sp2); + + // Layout: special == interposer-prefix ++ regular bytes. + let sp_bytes = oids::oid_bytes(sp).unwrap(); + let prefix = oids::oid_bytes(oids::interposer()).unwrap(); + prop_assert!(sp_bytes.starts_with(prefix)); + prop_assert_eq!(&sp_bytes[prefix.len()..], &bytes[..]); + + // Strips back to the original mech bytes. + let back = unspecial_mech(sp); + prop_assert_eq!(oids::oid_bytes(back).unwrap(), &bytes[..]); + } + Ok(()) + })?; + } + + /// For ANY non-null OID (special-looking or not), the bytes survive a + /// special->unspecial trip, and no input ever panics. + #[test] + fn unspecial_of_special_preserves_bytes(bytes in prop::collection::vec(any::(), 1..24)) { + with_oid(&bytes, |oid| -> std::result::Result<(), TestCaseError> { + unsafe { + let sp = special_mech(oid); + prop_assert!(is_special_oid(sp)); + let back = unspecial_mech(sp); + prop_assert_eq!(oids::oid_bytes(back).unwrap(), &bytes[..]); + } + Ok(()) + })?; + } + + /// Distinct regular mechs map to distinct special OIDs, each stripping + /// back to its own bytes. + #[test] + fn distinct_inputs_get_distinct_specials(a in non_special_bytes(), b in non_special_bytes()) { + prop_assume!(a != b); + with_oid(&a, |oa| with_oid(&b, |ob| -> std::result::Result<(), TestCaseError> { + unsafe { + let sa = special_mech(oa); + let sb = special_mech(ob); + prop_assert_ne!(sa, sb); + prop_assert_eq!(oids::oid_bytes(unspecial_mech(sa)).unwrap(), &a[..]); + prop_assert_eq!(oids::oid_bytes(unspecial_mech(sb)).unwrap(), &b[..]); + } + Ok(()) + }))?; + } + } + + /// Stress the add-only registry from many threads at once (the C code uses a + /// lock-free, never-freed singly linked list). Invariants under contention: + /// - the registry only ever grows (add-only); + /// - a given mech always resolves to the same stable special pointer, + /// regardless of which thread first registered it (dedup); + /// - distinct mechs get distinct special pointers; + /// - every special pointer still strips back to its mech afterwards. + #[test] + fn concurrent_registration_is_consistent() { + let mechs: Arc>> = Arc::new( + (0..24u8) + .map(|i| vec![0x77, 0x10, i, 0x42, i.wrapping_mul(13).wrapping_add(1)]) + .collect(), + ); + let before = current_len(); + + let mut handles = Vec::new(); + for t in 0..8usize { + let mechs = mechs.clone(); + handles.push(thread::spawn(move || { + let mut seen: Vec<(usize, usize)> = Vec::new(); + for round in 0..64usize { + let idx = (t.wrapping_mul(7).wrapping_add(round)) % mechs.len(); + let sp = with_oid(&mechs[idx], |oid| unsafe { special_mech(oid) }); + assert!(!sp.is_null(), "special_mech returned null under load"); + seen.push((idx, sp as usize)); + } + seen + })); + } + + let mut by_idx: HashMap = HashMap::new(); + for h in handles { + for (idx, sp) in h.join().unwrap() { + match by_idx.get(&idx) { + Some(&prev) => assert_eq!(prev, sp, "mech {idx} got two special pointers"), + None => { + by_idx.insert(idx, sp); + } + } + } + } + + // Add-only: never shrinks. + assert!(current_len() >= before, "registry must be add-only"); + // Every mech was observed and each got a unique pointer. + assert_eq!(by_idx.len(), mechs.len()); + let unique: HashSet = by_idx.values().copied().collect(); + assert_eq!( + unique.len(), + mechs.len(), + "distinct mechs must have distinct specials" + ); + + // Strips back correctly after the concurrent churn. + for (i, bytes) in mechs.iter().enumerate() { + let sp = *by_idx.get(&i).unwrap() as *const gss_OID_desc; + let back = unsafe { unspecial_mech(sp) }; + assert_eq!(unsafe { oids::oid_bytes(back).unwrap() }, &bytes[..]); + } + } +} diff --git a/rust/gssproxy-proto/Cargo.toml b/rust/gssproxy-proto/Cargo.toml new file mode 100644 index 000000000..eeceb655e --- /dev/null +++ b/rust/gssproxy-proto/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gssproxy-proto" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Byte-exact XDR/SunRPC codec for the gssproxy (gssx) wire protocol" + +[lib] +name = "gssproxy_proto" + +[lints] +workspace = true + +[dependencies] + +[dev-dependencies] +proptest = "1.11.0" diff --git a/rust/gssproxy-proto/src/frame.rs b/rust/gssproxy-proto/src/frame.rs new file mode 100644 index 000000000..dc97bdf39 --- /dev/null +++ b/rust/gssproxy-proto/src/frame.rs @@ -0,0 +1,89 @@ +//! SunRPC record-marking framing as used on the gssproxy Unix socket. +//! +//! gssproxy uses a single-fragment record marking scheme (see `gp_socket.c` +//! and `gpm_common.c`): every message is preceded by a 4-byte big-endian +//! length word whose top bit (`FRAGMENT_BIT`) marks the last fragment. Only a +//! single fragment is ever used, so the body length is the low 31 bits. + +use crate::rpc::{FRAGMENT_BIT, MAX_RPC_SIZE}; + +/// Error returned while parsing a record header/body. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameError { + /// The fragment header did not have the last-fragment bit set (multiple + /// fragments are not supported, matching the C daemon). + MultiFragment, + /// The advertised body length exceeds `MAX_RPC_SIZE`. + TooLarge(usize), +} + +impl std::fmt::Display for FrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FrameError::MultiFragment => write!(f, "multi-fragment records are not supported"), + FrameError::TooLarge(n) => write!(f, "record body too large: {n} bytes"), + } + } +} + +impl std::error::Error for FrameError {} + +/// Encode the 4-byte record-marking header for a body of `len` bytes. +pub fn encode_header(len: usize) -> [u8; 4] { + let marked = (len as u32) | FRAGMENT_BIT; + marked.to_be_bytes() +} + +/// Prepend a record header to `body`, returning the full frame to write. +pub fn frame(body: &[u8]) -> Vec { + let mut out = Vec::with_capacity(4 + body.len()); + out.extend_from_slice(&encode_header(body.len())); + out.extend_from_slice(body); + out +} + +/// Parse a record-marking header word, validating the last-fragment bit and +/// the size cap. Returns the body length in bytes. +pub fn parse_header(word: u32) -> Result { + if word & FRAGMENT_BIT == 0 { + return Err(FrameError::MultiFragment); + } + let len = (word & !FRAGMENT_BIT) as usize; + if len > MAX_RPC_SIZE { + return Err(FrameError::TooLarge(len)); + } + Ok(len) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn header_roundtrip() { + let h = encode_header(5); + let word = u32::from_be_bytes(h); + assert_eq!(parse_header(word).unwrap(), 5); + } + + #[test] + fn rejects_missing_fragment_bit() { + assert_eq!(parse_header(5), Err(FrameError::MultiFragment)); + } + + #[test] + fn rejects_oversized() { + let word = ((MAX_RPC_SIZE as u32) + 1) | FRAGMENT_BIT; + assert!(matches!(parse_header(word), Err(FrameError::TooLarge(_)))); + } + + #[test] + fn frame_prepends_header() { + let f = frame(b"hi"); + assert_eq!(&f[4..], b"hi"); + assert_eq!( + parse_header(u32::from_be_bytes([f[0], f[1], f[2], f[3]])).unwrap(), + 2 + ); + } +} diff --git a/rust/gssproxy-proto/src/gssx.rs b/rust/gssproxy-proto/src/gssx.rs new file mode 100644 index 000000000..96cbb0378 --- /dev/null +++ b/rust/gssproxy-proto/src/gssx.rs @@ -0,0 +1,495 @@ +//! The `gssx_*` data types, ported field-for-field from `rpcgen/gss_proxy.h` +//! and `rpcgen/gss_proxy_xdr.c`. +//! +//! Naming mirrors the C structs so the two can be cross-checked. The XDR +//! field order in every `encode`/`decode` matches the corresponding +//! `xdr_gssx_*` function exactly; this is what guarantees wire compatibility +//! with the C daemon and the C interposer. + +use crate::xdr::{Xdr, XdrDecoder, XdrEncoder, XdrResult, decode_array, encode_array}; + +/// XDR enum is encoded as a signed 4-byte integer; model gssx enums as i32. +impl Xdr for i32 { + fn encode(&self, e: &mut XdrEncoder) { + e.put_enum(*self); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + d.get_enum() + } +} + +/// Variable-length opaque/string value (`octet_string`, `gssx_buffer`, +/// `gssx_OID`, `utf8string` all share this wire form: `xdr_bytes`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Opaque(pub Vec); + +impl Opaque { + pub fn new(data: impl Into>) -> Self { + Opaque(data.into()) + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn as_slice(&self) -> &[u8] { + &self.0 + } +} + +impl From> for Opaque { + fn from(v: Vec) -> Self { + Opaque(v) + } +} + +impl From<&[u8]> for Opaque { + fn from(v: &[u8]) -> Self { + Opaque(v.to_vec()) + } +} + +impl Xdr for Opaque { + fn encode(&self, e: &mut XdrEncoder) { + e.put_opaque(&self.0); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(Opaque(d.get_opaque()?)) + } +} + +/// Semantic aliases matching the rpcgen typedefs. +pub type GssxBuffer = Opaque; +pub type GssxOid = Opaque; +pub type OctetString = Opaque; +pub type Utf8String = Opaque; +/// `gssx_OID_set` is an XDR array of OIDs. +pub type GssxOidSet = Vec; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GssxOption { + pub option: GssxBuffer, + pub value: GssxBuffer, +} + +impl Xdr for GssxOption { + fn encode(&self, e: &mut XdrEncoder) { + self.option.encode(e); + self.value.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(GssxOption { + option: Opaque::decode(d)?, + value: Opaque::decode(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GssxMechAttr { + pub attr: GssxOid, + pub name: GssxBuffer, + pub short_desc: GssxBuffer, + pub long_desc: GssxBuffer, + pub extensions: Vec, +} + +impl Xdr for GssxMechAttr { + fn encode(&self, e: &mut XdrEncoder) { + self.attr.encode(e); + self.name.encode(e); + self.short_desc.encode(e); + self.long_desc.encode(e); + encode_array(e, &self.extensions); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(GssxMechAttr { + attr: Opaque::decode(d)?, + name: Opaque::decode(d)?, + short_desc: Opaque::decode(d)?, + long_desc: Opaque::decode(d)?, + extensions: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GssxMechInfo { + pub mech: GssxOid, + pub name_types: GssxOidSet, + pub mech_attrs: GssxOidSet, + pub known_mech_attrs: GssxOidSet, + pub cred_options: GssxOidSet, + pub sec_ctx_options: GssxOidSet, + pub saslname_sasl_mech_name: GssxBuffer, + pub saslname_mech_name: GssxBuffer, + pub saslname_mech_desc: GssxBuffer, + pub extensions: Vec, +} + +impl Xdr for GssxMechInfo { + fn encode(&self, e: &mut XdrEncoder) { + self.mech.encode(e); + encode_array(e, &self.name_types); + encode_array(e, &self.mech_attrs); + encode_array(e, &self.known_mech_attrs); + encode_array(e, &self.cred_options); + encode_array(e, &self.sec_ctx_options); + self.saslname_sasl_mech_name.encode(e); + self.saslname_mech_name.encode(e); + self.saslname_mech_desc.encode(e); + encode_array(e, &self.extensions); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(GssxMechInfo { + mech: Opaque::decode(d)?, + name_types: decode_array(d)?, + mech_attrs: decode_array(d)?, + known_mech_attrs: decode_array(d)?, + cred_options: decode_array(d)?, + sec_ctx_options: decode_array(d)?, + saslname_sasl_mech_name: Opaque::decode(d)?, + saslname_mech_name: Opaque::decode(d)?, + saslname_mech_desc: Opaque::decode(d)?, + extensions: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GssxNameAttr { + pub attr: GssxBuffer, + pub value: GssxBuffer, + pub extensions: Vec, +} + +impl Xdr for GssxNameAttr { + fn encode(&self, e: &mut XdrEncoder) { + self.attr.encode(e); + self.value.encode(e); + encode_array(e, &self.extensions); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(GssxNameAttr { + attr: Opaque::decode(d)?, + value: Opaque::decode(d)?, + extensions: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GssxStatus { + pub major_status: u64, + pub mech: GssxOid, + pub minor_status: u64, + pub major_status_string: Utf8String, + pub minor_status_string: Utf8String, + pub server_ctx: OctetString, + pub options: Vec, +} + +impl Xdr for GssxStatus { + fn encode(&self, e: &mut XdrEncoder) { + self.major_status.encode(e); + self.mech.encode(e); + self.minor_status.encode(e); + self.major_status_string.encode(e); + self.minor_status_string.encode(e); + self.server_ctx.encode(e); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(GssxStatus { + major_status: u64::decode(d)?, + mech: Opaque::decode(d)?, + minor_status: u64::decode(d)?, + major_status_string: Opaque::decode(d)?, + minor_status_string: Opaque::decode(d)?, + server_ctx: Opaque::decode(d)?, + options: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GssxCallCtx { + pub locale: Utf8String, + pub server_ctx: OctetString, + pub options: Vec, +} + +impl Xdr for GssxCallCtx { + fn encode(&self, e: &mut XdrEncoder) { + self.locale.encode(e); + self.server_ctx.encode(e); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(GssxCallCtx { + locale: Opaque::decode(d)?, + server_ctx: Opaque::decode(d)?, + options: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GssxName { + pub display_name: GssxBuffer, + pub name_type: GssxOid, + pub exported_name: GssxBuffer, + pub exported_composite_name: GssxBuffer, + pub name_attributes: Vec, + pub extensions: Vec, +} + +impl Xdr for GssxName { + fn encode(&self, e: &mut XdrEncoder) { + self.display_name.encode(e); + self.name_type.encode(e); + self.exported_name.encode(e); + self.exported_composite_name.encode(e); + encode_array(e, &self.name_attributes); + encode_array(e, &self.extensions); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(GssxName { + display_name: Opaque::decode(d)?, + name_type: Opaque::decode(d)?, + exported_name: Opaque::decode(d)?, + exported_composite_name: Opaque::decode(d)?, + name_attributes: decode_array(d)?, + extensions: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GssxCredElement { + pub mn: GssxName, + pub mech: GssxOid, + pub cred_usage: i32, + pub initiator_time_rec: u64, + pub acceptor_time_rec: u64, + pub options: Vec, +} + +impl Xdr for GssxCredElement { + fn encode(&self, e: &mut XdrEncoder) { + self.mn.encode(e); + self.mech.encode(e); + self.cred_usage.encode(e); + self.initiator_time_rec.encode(e); + self.acceptor_time_rec.encode(e); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(GssxCredElement { + mn: GssxName::decode(d)?, + mech: Opaque::decode(d)?, + cred_usage: i32::decode(d)?, + initiator_time_rec: u64::decode(d)?, + acceptor_time_rec: u64::decode(d)?, + options: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GssxCred { + pub desired_name: GssxName, + pub elements: Vec, + pub cred_handle_reference: OctetString, + pub needs_release: bool, +} + +impl Xdr for GssxCred { + fn encode(&self, e: &mut XdrEncoder) { + self.desired_name.encode(e); + encode_array(e, &self.elements); + self.cred_handle_reference.encode(e); + self.needs_release.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(GssxCred { + desired_name: GssxName::decode(d)?, + elements: decode_array(d)?, + cred_handle_reference: Opaque::decode(d)?, + needs_release: bool::decode(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GssxCtx { + pub exported_context_token: GssxBuffer, + pub state: OctetString, + pub needs_release: bool, + pub mech: GssxOid, + pub src_name: GssxName, + pub targ_name: GssxName, + pub lifetime: u64, + pub ctx_flags: u64, + pub locally_initiated: bool, + pub open: bool, + pub options: Vec, +} + +impl Xdr for GssxCtx { + fn encode(&self, e: &mut XdrEncoder) { + self.exported_context_token.encode(e); + self.state.encode(e); + self.needs_release.encode(e); + self.mech.encode(e); + self.src_name.encode(e); + self.targ_name.encode(e); + self.lifetime.encode(e); + self.ctx_flags.encode(e); + self.locally_initiated.encode(e); + self.open.encode(e); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(GssxCtx { + exported_context_token: Opaque::decode(d)?, + state: Opaque::decode(d)?, + needs_release: bool::decode(d)?, + mech: Opaque::decode(d)?, + src_name: GssxName::decode(d)?, + targ_name: GssxName::decode(d)?, + lifetime: u64::decode(d)?, + ctx_flags: u64::decode(d)?, + locally_initiated: bool::decode(d)?, + open: bool::decode(d)?, + options: decode_array(d)?, + }) + } +} + +/// `gssx_handle` - a union discriminated by `handle_type`. +pub const GSSX_C_HANDLE_SEC_CTX: i32 = 0; +pub const GSSX_C_HANDLE_CRED: i32 = 1; + +// Mirrors the on-wire `gssx_handle` XDR union; variant sizes follow the +// protocol structs, so we intentionally keep them inline rather than boxing. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GssxHandle { + SecCtx(GssxCtx), + Cred(GssxCred), + /// Any other discriminant: opaque extensions blob. + Extensions { + handle_type: i32, + data: OctetString, + }, +} + +impl Xdr for GssxHandle { + fn encode(&self, e: &mut XdrEncoder) { + match self { + GssxHandle::SecCtx(ctx) => { + e.put_enum(GSSX_C_HANDLE_SEC_CTX); + ctx.encode(e); + } + GssxHandle::Cred(cred) => { + e.put_enum(GSSX_C_HANDLE_CRED); + cred.encode(e); + } + GssxHandle::Extensions { handle_type, data } => { + e.put_enum(*handle_type); + data.encode(e); + } + } + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + let handle_type = d.get_enum()?; + Ok(match handle_type { + GSSX_C_HANDLE_CRED => GssxHandle::Cred(GssxCred::decode(d)?), + GSSX_C_HANDLE_SEC_CTX => GssxHandle::SecCtx(GssxCtx::decode(d)?), + other => GssxHandle::Extensions { + handle_type: other, + data: Opaque::decode(d)?, + }, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GssxCb { + pub initiator_addrtype: u64, + pub initiator_address: GssxBuffer, + pub acceptor_addrtype: u64, + pub acceptor_address: GssxBuffer, + pub application_data: GssxBuffer, +} + +impl Xdr for GssxCb { + fn encode(&self, e: &mut XdrEncoder) { + self.initiator_addrtype.encode(e); + self.initiator_address.encode(e); + self.acceptor_addrtype.encode(e); + self.acceptor_address.encode(e); + self.application_data.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(GssxCb { + initiator_addrtype: u64::decode(d)?, + initiator_address: Opaque::decode(d)?, + acceptor_addrtype: u64::decode(d)?, + acceptor_address: Opaque::decode(d)?, + application_data: Opaque::decode(d)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(v: &T) { + let mut e = XdrEncoder::new(); + v.encode(&mut e); + let mut d = XdrDecoder::new(e.as_bytes()); + let got = T::decode(&mut d).unwrap(); + assert_eq!(&got, v); + assert_eq!(d.remaining(), 0, "decoder did not consume entire buffer"); + } + + #[test] + fn name_roundtrip() { + let n = GssxName { + display_name: Opaque::new(b"user@REALM".to_vec()), + name_type: Opaque::new(vec![0x2a, 0x86, 0x48]), + exported_name: Opaque::default(), + exported_composite_name: Opaque::default(), + name_attributes: vec![], + extensions: vec![GssxOption { + option: Opaque::new(b"k".to_vec()), + value: Opaque::new(b"v".to_vec()), + }], + }; + roundtrip(&n); + } + + #[test] + fn handle_union_variants() { + roundtrip(&GssxHandle::Cred(GssxCred::default())); + roundtrip(&GssxHandle::SecCtx(GssxCtx::default())); + roundtrip(&GssxHandle::Extensions { + handle_type: 7, + data: Opaque::new(b"x".to_vec()), + }); + } + + #[test] + fn ctx_roundtrip() { + let c = GssxCtx { + exported_context_token: Opaque::new(b"tok".to_vec()), + ctx_flags: 0x1122334455667788, + lifetime: 3600, + open: true, + locally_initiated: true, + ..Default::default() + }; + roundtrip(&c); + } +} diff --git a/rust/gssproxy-proto/src/lib.rs b/rust/gssproxy-proto/src/lib.rs new file mode 100644 index 000000000..5500787cd --- /dev/null +++ b/rust/gssproxy-proto/src/lib.rs @@ -0,0 +1,115 @@ +//! `gssproxy-proto`: a byte-exact, dependency-free implementation of the +//! gssproxy (gssx) wire protocol. +//! +//! This is a hand-written port of the rpcgen output (`rpcgen/gp_xdr.c`, +//! `rpcgen/gp_rpc_xdr.c`, `rpcgen/gss_proxy_xdr.c`). It is hand-written on +//! purpose: the available Rust XDR generators (`xdr-codec`, `xdrgen`) are +//! unmaintained and cannot parse the NFSv4-style `.x` specs this protocol is +//! derived from, so we mirror the C field order directly instead. +//! +//! Layers: +//! - [`xdr`]: primitive XDR encode/decode and the [`xdr::Xdr`] trait. +//! - [`rpc`]: the SunRPC message envelope (`gp_rpc_msg`). +//! - [`frame`]: SunRPC record-marking framing used on the Unix socket. +//! - [`gssx`]: the shared `gssx_*` data types. +//! - [`proc`]: per-procedure `gssx_arg_*`/`gssx_res_*` types + proc numbers. +//! - [`redact`]: helpers for logging secret-bearing buffers as lengths only. +//! +//! A full request on the wire is: `frame(rpc_call_header ++ xdr(arg))`, and the +//! reply is `frame(rpc_reply_header ++ xdr(res))`. + +pub mod frame; +pub mod gssx; +pub mod proc; +pub mod redact; +pub mod rpc; +pub mod xdr; + +/// Kani bounded-proof harnesses for the wire codec. Compiled only under +/// `cargo kani` (`--cfg kani`); see `rust/docs/formal-verification.md`. +#[cfg(kani)] +mod verification; + +pub use frame::{FrameError, frame, parse_header}; +pub use rpc::{GSSPROXY, GSSPROXYVERS, MAX_RPC_SIZE, Message}; +pub use xdr::{Xdr, XdrDecoder, XdrEncoder, XdrError, XdrResult}; + +/// Encode a complete client request body (RPC call header + procedure args) +/// ready to be wrapped by [`frame`]. +pub fn encode_request(xid: u32, proc_num: u32, arg: &A) -> Vec { + let mut e = XdrEncoder::new(); + Message::call(xid, proc_num).encode(&mut e); + arg.encode(&mut e); + e.into_bytes() +} + +/// Encode a complete successful reply body (RPC reply header + procedure +/// result) ready to be wrapped by [`frame`]. +pub fn encode_reply(xid: u32, res: &R) -> Vec { + let mut e = XdrEncoder::new(); + Message::reply_success(xid).encode(&mut e); + res.encode(&mut e); + e.into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proc::{ArgIndicateMechs, ArgInitSecContext, ResInitSecContext}; + + #[test] + fn request_envelope_then_arg() { + let arg = ArgInitSecContext::default(); + let body = encode_request(42, proc::GssxProc::InitSecContext as u32, &arg); + let mut d = XdrDecoder::new(&body); + let msg = Message::decode(&mut d).unwrap(); + assert_eq!(msg.xid, 42); + assert!(msg.is_call); + assert_eq!(msg.call.unwrap().proc_num, 8); + // Remaining bytes decode as the argument. + let got = ArgInitSecContext::decode(&mut d).unwrap(); + assert_eq!(got, arg); + } + + #[test] + fn golden_indicate_mechs_request() { + // Hand-computed byte vector for encode_request(xid=1, proc=1, default + // ArgIndicateMechs), derived directly from the XDR rules. This locks + // the envelope layout, crucially the program number 400112 = 0x61AF0. + let body = encode_request( + 1, + proc::GssxProc::IndicateMechs as u32, + &ArgIndicateMechs::default(), + ); + #[rustfmt::skip] + let expected: &[u8] = &[ + 0x00, 0x00, 0x00, 0x01, // xid = 1 + 0x00, 0x00, 0x00, 0x00, // msg type = CALL + 0x00, 0x00, 0x00, 0x02, // rpcvers = 2 + 0x00, 0x06, 0x1A, 0xF0, // prog = 400112 + 0x00, 0x00, 0x00, 0x01, // vers = 1 + 0x00, 0x00, 0x00, 0x01, // proc = 1 (INDICATE_MECHS) + 0x00, 0x00, 0x00, 0x00, // cred flavor = AUTH_NONE + 0x00, 0x00, 0x00, 0x00, // cred body length = 0 + 0x00, 0x00, 0x00, 0x00, // verf flavor = AUTH_NONE + 0x00, 0x00, 0x00, 0x00, // verf body length = 0 + // ArgIndicateMechs = call_ctx { locale="", server_ctx="", options=[] } + 0x00, 0x00, 0x00, 0x00, // locale length = 0 + 0x00, 0x00, 0x00, 0x00, // server_ctx length = 0 + 0x00, 0x00, 0x00, 0x00, // options count = 0 + ]; + assert_eq!(body, expected); + } + + #[test] + fn reply_envelope_then_res() { + let res = ResInitSecContext::default(); + let body = encode_reply(42, &res); + let mut d = XdrDecoder::new(&body); + let msg = Message::decode(&mut d).unwrap(); + assert_eq!(msg.xid, 42); + assert!(!msg.is_call); + let got = ResInitSecContext::decode(&mut d).unwrap(); + assert_eq!(got, res); + } +} diff --git a/rust/gssproxy-proto/src/proc.rs b/rust/gssproxy-proto/src/proc.rs new file mode 100644 index 000000000..1557d085b --- /dev/null +++ b/rust/gssproxy-proto/src/proc.rs @@ -0,0 +1,918 @@ +//! Per-procedure argument/result types (`gssx_arg_*` / `gssx_res_*`) and the +//! procedure number table, ported from `rpcgen/gss_proxy.h` and +//! `rpcgen/gss_proxy_xdr.c`. + +use crate::gssx::*; +use crate::xdr::{ + Xdr, XdrDecoder, XdrEncoder, XdrResult, decode_array, decode_optional, encode_array, + encode_optional, +}; + +/// GSSX procedure numbers (program 400112, version 1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum GssxProc { + IndicateMechs = 1, + GetCallContext = 2, + ImportAndCanonName = 3, + ExportCred = 4, + ImportCred = 5, + AcquireCred = 6, + StoreCred = 7, + InitSecContext = 8, + AcceptSecContext = 9, + ReleaseHandle = 10, + GetMic = 11, + VerifyMic = 12, + Wrap = 13, + Unwrap = 14, + WrapSizeLimit = 15, +} + +impl GssxProc { + pub const MIN: u32 = 1; + pub const MAX: u32 = 15; + + pub fn from_u32(v: u32) -> Option { + Some(match v { + 1 => GssxProc::IndicateMechs, + 2 => GssxProc::GetCallContext, + 3 => GssxProc::ImportAndCanonName, + 4 => GssxProc::ExportCred, + 5 => GssxProc::ImportCred, + 6 => GssxProc::AcquireCred, + 7 => GssxProc::StoreCred, + 8 => GssxProc::InitSecContext, + 9 => GssxProc::AcceptSecContext, + 10 => GssxProc::ReleaseHandle, + 11 => GssxProc::GetMic, + 12 => GssxProc::VerifyMic, + 13 => GssxProc::Wrap, + 14 => GssxProc::Unwrap, + 15 => GssxProc::WrapSizeLimit, + _ => return None, + }) + } +} + +// ---- release_handle (10) ---- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArgReleaseHandle { + pub call_ctx: GssxCallCtx, + pub cred_handle: GssxHandle, +} + +impl Xdr for ArgReleaseHandle { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + self.cred_handle.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgReleaseHandle { + call_ctx: GssxCallCtx::decode(d)?, + cred_handle: GssxHandle::decode(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResReleaseHandle { + pub status: GssxStatus, +} + +impl Xdr for ResReleaseHandle { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResReleaseHandle { + status: GssxStatus::decode(d)?, + }) + } +} + +// ---- indicate_mechs (1) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgIndicateMechs { + pub call_ctx: GssxCallCtx, +} + +impl Xdr for ArgIndicateMechs { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgIndicateMechs { + call_ctx: GssxCallCtx::decode(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResIndicateMechs { + pub status: GssxStatus, + pub mechs: Vec, + pub mech_attr_descs: Vec, + pub supported_extensions: Vec, + pub options: Vec, +} + +impl Xdr for ResIndicateMechs { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + encode_array(e, &self.mechs); + encode_array(e, &self.mech_attr_descs); + encode_array(e, &self.supported_extensions); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResIndicateMechs { + status: GssxStatus::decode(d)?, + mechs: decode_array(d)?, + mech_attr_descs: decode_array(d)?, + supported_extensions: decode_array(d)?, + options: decode_array(d)?, + }) + } +} + +// ---- import_and_canon_name (3) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgImportAndCanonName { + pub call_ctx: GssxCallCtx, + pub input_name: GssxName, + pub mech: GssxOid, + pub name_attributes: Vec, + pub options: Vec, +} + +impl Xdr for ArgImportAndCanonName { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + self.input_name.encode(e); + self.mech.encode(e); + encode_array(e, &self.name_attributes); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgImportAndCanonName { + call_ctx: GssxCallCtx::decode(d)?, + input_name: GssxName::decode(d)?, + mech: Opaque::decode(d)?, + name_attributes: decode_array(d)?, + options: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResImportAndCanonName { + pub status: GssxStatus, + pub output_name: Option, + pub options: Vec, +} + +impl Xdr for ResImportAndCanonName { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + encode_optional(e, &self.output_name); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResImportAndCanonName { + status: GssxStatus::decode(d)?, + output_name: decode_optional(d)?, + options: decode_array(d)?, + }) + } +} + +// ---- get_call_context (2) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgGetCallContext { + pub call_ctx: GssxCallCtx, + pub options: Vec, +} + +impl Xdr for ArgGetCallContext { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgGetCallContext { + call_ctx: GssxCallCtx::decode(d)?, + options: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResGetCallContext { + pub status: GssxStatus, + pub server_call_ctx: OctetString, + pub options: Vec, +} + +impl Xdr for ResGetCallContext { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + self.server_call_ctx.encode(e); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResGetCallContext { + status: GssxStatus::decode(d)?, + server_call_ctx: Opaque::decode(d)?, + options: decode_array(d)?, + }) + } +} + +// ---- acquire_cred (6) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgAcquireCred { + pub call_ctx: GssxCallCtx, + pub input_cred_handle: Option, + pub add_cred_to_input_handle: bool, + pub desired_name: Option, + pub time_req: u64, + pub desired_mechs: GssxOidSet, + pub cred_usage: i32, + pub initiator_time_req: u64, + pub acceptor_time_req: u64, + pub options: Vec, +} + +impl Xdr for ArgAcquireCred { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + encode_optional(e, &self.input_cred_handle); + self.add_cred_to_input_handle.encode(e); + encode_optional(e, &self.desired_name); + self.time_req.encode(e); + encode_array(e, &self.desired_mechs); + self.cred_usage.encode(e); + self.initiator_time_req.encode(e); + self.acceptor_time_req.encode(e); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgAcquireCred { + call_ctx: GssxCallCtx::decode(d)?, + input_cred_handle: decode_optional(d)?, + add_cred_to_input_handle: bool::decode(d)?, + desired_name: decode_optional(d)?, + time_req: u64::decode(d)?, + desired_mechs: decode_array(d)?, + cred_usage: i32::decode(d)?, + initiator_time_req: u64::decode(d)?, + acceptor_time_req: u64::decode(d)?, + options: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResAcquireCred { + pub status: GssxStatus, + pub output_cred_handle: Option, + pub options: Vec, +} + +impl Xdr for ResAcquireCred { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + encode_optional(e, &self.output_cred_handle); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResAcquireCred { + status: GssxStatus::decode(d)?, + output_cred_handle: decode_optional(d)?, + options: decode_array(d)?, + }) + } +} + +// ---- export_cred (4) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgExportCred { + pub call_ctx: GssxCallCtx, + pub input_cred_handle: GssxCred, + pub cred_usage: i32, + pub options: Vec, +} + +impl Xdr for ArgExportCred { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + self.input_cred_handle.encode(e); + self.cred_usage.encode(e); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgExportCred { + call_ctx: GssxCallCtx::decode(d)?, + input_cred_handle: GssxCred::decode(d)?, + cred_usage: i32::decode(d)?, + options: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResExportCred { + pub status: GssxStatus, + pub usage_exported: i32, + pub exported_handle: Option, + pub options: Vec, +} + +impl Xdr for ResExportCred { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + self.usage_exported.encode(e); + encode_optional(e, &self.exported_handle); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResExportCred { + status: GssxStatus::decode(d)?, + usage_exported: i32::decode(d)?, + exported_handle: decode_optional(d)?, + options: decode_array(d)?, + }) + } +} + +// ---- import_cred (5) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgImportCred { + pub call_ctx: GssxCallCtx, + pub exported_handle: OctetString, + pub options: Vec, +} + +impl Xdr for ArgImportCred { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + self.exported_handle.encode(e); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgImportCred { + call_ctx: GssxCallCtx::decode(d)?, + exported_handle: Opaque::decode(d)?, + options: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResImportCred { + pub status: GssxStatus, + pub output_cred_handle: Option, + pub options: Vec, +} + +impl Xdr for ResImportCred { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + encode_optional(e, &self.output_cred_handle); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResImportCred { + status: GssxStatus::decode(d)?, + output_cred_handle: decode_optional(d)?, + options: decode_array(d)?, + }) + } +} + +// ---- store_cred (7) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgStoreCred { + pub call_ctx: GssxCallCtx, + pub input_cred_handle: GssxCred, + pub cred_usage: i32, + pub desired_mech: GssxOid, + pub overwrite_cred: bool, + pub default_cred: bool, + pub options: Vec, +} + +impl Xdr for ArgStoreCred { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + self.input_cred_handle.encode(e); + self.cred_usage.encode(e); + self.desired_mech.encode(e); + self.overwrite_cred.encode(e); + self.default_cred.encode(e); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgStoreCred { + call_ctx: GssxCallCtx::decode(d)?, + input_cred_handle: GssxCred::decode(d)?, + cred_usage: i32::decode(d)?, + desired_mech: Opaque::decode(d)?, + overwrite_cred: bool::decode(d)?, + default_cred: bool::decode(d)?, + options: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResStoreCred { + pub status: GssxStatus, + pub elements_stored: GssxOidSet, + pub cred_usage_stored: i32, + pub options: Vec, +} + +impl Xdr for ResStoreCred { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + encode_array(e, &self.elements_stored); + self.cred_usage_stored.encode(e); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResStoreCred { + status: GssxStatus::decode(d)?, + elements_stored: decode_array(d)?, + cred_usage_stored: i32::decode(d)?, + options: decode_array(d)?, + }) + } +} + +// ---- init_sec_context (8) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgInitSecContext { + pub call_ctx: GssxCallCtx, + pub context_handle: Option, + pub cred_handle: Option, + pub target_name: Option, + pub mech_type: GssxOid, + pub req_flags: u64, + pub time_req: u64, + pub input_cb: Option, + pub input_token: Option, + pub options: Vec, +} + +impl Xdr for ArgInitSecContext { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + encode_optional(e, &self.context_handle); + encode_optional(e, &self.cred_handle); + encode_optional(e, &self.target_name); + self.mech_type.encode(e); + self.req_flags.encode(e); + self.time_req.encode(e); + encode_optional(e, &self.input_cb); + encode_optional(e, &self.input_token); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgInitSecContext { + call_ctx: GssxCallCtx::decode(d)?, + context_handle: decode_optional(d)?, + cred_handle: decode_optional(d)?, + target_name: decode_optional(d)?, + mech_type: Opaque::decode(d)?, + req_flags: u64::decode(d)?, + time_req: u64::decode(d)?, + input_cb: decode_optional(d)?, + input_token: decode_optional(d)?, + options: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResInitSecContext { + pub status: GssxStatus, + pub context_handle: Option, + pub output_token: Option, + pub options: Vec, +} + +impl Xdr for ResInitSecContext { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + encode_optional(e, &self.context_handle); + encode_optional(e, &self.output_token); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResInitSecContext { + status: GssxStatus::decode(d)?, + context_handle: decode_optional(d)?, + output_token: decode_optional(d)?, + options: decode_array(d)?, + }) + } +} + +// ---- accept_sec_context (9) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgAcceptSecContext { + pub call_ctx: GssxCallCtx, + pub context_handle: Option, + pub cred_handle: Option, + pub input_token: GssxBuffer, + pub input_cb: Option, + pub ret_deleg_cred: bool, + pub options: Vec, +} + +impl Xdr for ArgAcceptSecContext { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + encode_optional(e, &self.context_handle); + encode_optional(e, &self.cred_handle); + self.input_token.encode(e); + encode_optional(e, &self.input_cb); + self.ret_deleg_cred.encode(e); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgAcceptSecContext { + call_ctx: GssxCallCtx::decode(d)?, + context_handle: decode_optional(d)?, + cred_handle: decode_optional(d)?, + input_token: Opaque::decode(d)?, + input_cb: decode_optional(d)?, + ret_deleg_cred: bool::decode(d)?, + options: decode_array(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResAcceptSecContext { + pub status: GssxStatus, + pub context_handle: Option, + pub output_token: Option, + pub delegated_cred_handle: Option, + pub options: Vec, +} + +impl Xdr for ResAcceptSecContext { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + encode_optional(e, &self.context_handle); + encode_optional(e, &self.output_token); + encode_optional(e, &self.delegated_cred_handle); + encode_array(e, &self.options); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResAcceptSecContext { + status: GssxStatus::decode(d)?, + context_handle: decode_optional(d)?, + output_token: decode_optional(d)?, + delegated_cred_handle: decode_optional(d)?, + options: decode_array(d)?, + }) + } +} + +// ---- get_mic (11) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgGetMic { + pub call_ctx: GssxCallCtx, + pub context_handle: GssxCtx, + pub qop_req: u64, + pub message_buffer: GssxBuffer, +} + +impl Xdr for ArgGetMic { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + self.context_handle.encode(e); + self.qop_req.encode(e); + self.message_buffer.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgGetMic { + call_ctx: GssxCallCtx::decode(d)?, + context_handle: GssxCtx::decode(d)?, + qop_req: u64::decode(d)?, + message_buffer: Opaque::decode(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResGetMic { + pub status: GssxStatus, + pub context_handle: Option, + pub token_buffer: GssxBuffer, + pub qop_state: Option, +} + +impl Xdr for ResGetMic { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + encode_optional(e, &self.context_handle); + self.token_buffer.encode(e); + encode_optional(e, &self.qop_state); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResGetMic { + status: GssxStatus::decode(d)?, + context_handle: decode_optional(d)?, + token_buffer: Opaque::decode(d)?, + qop_state: decode_optional(d)?, + }) + } +} + +// ---- verify_mic (12) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgVerifyMic { + pub call_ctx: GssxCallCtx, + pub context_handle: GssxCtx, + pub message_buffer: GssxBuffer, + pub token_buffer: GssxBuffer, +} + +impl Xdr for ArgVerifyMic { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + self.context_handle.encode(e); + self.message_buffer.encode(e); + self.token_buffer.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgVerifyMic { + call_ctx: GssxCallCtx::decode(d)?, + context_handle: GssxCtx::decode(d)?, + message_buffer: Opaque::decode(d)?, + token_buffer: Opaque::decode(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResVerifyMic { + pub status: GssxStatus, + pub context_handle: Option, + pub qop_state: Option, +} + +impl Xdr for ResVerifyMic { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + encode_optional(e, &self.context_handle); + encode_optional(e, &self.qop_state); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResVerifyMic { + status: GssxStatus::decode(d)?, + context_handle: decode_optional(d)?, + qop_state: decode_optional(d)?, + }) + } +} + +// ---- wrap (13) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgWrap { + pub call_ctx: GssxCallCtx, + pub context_handle: GssxCtx, + pub conf_req: bool, + pub message_buffer: Vec, + pub qop_state: u64, +} + +impl Xdr for ArgWrap { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + self.context_handle.encode(e); + self.conf_req.encode(e); + encode_array(e, &self.message_buffer); + self.qop_state.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgWrap { + call_ctx: GssxCallCtx::decode(d)?, + context_handle: GssxCtx::decode(d)?, + conf_req: bool::decode(d)?, + message_buffer: decode_array(d)?, + qop_state: u64::decode(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResWrap { + pub status: GssxStatus, + pub context_handle: Option, + pub token_buffer: Vec, + pub conf_state: Option, + pub qop_state: Option, +} + +impl Xdr for ResWrap { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + encode_optional(e, &self.context_handle); + encode_array(e, &self.token_buffer); + encode_optional(e, &self.conf_state); + encode_optional(e, &self.qop_state); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResWrap { + status: GssxStatus::decode(d)?, + context_handle: decode_optional(d)?, + token_buffer: decode_array(d)?, + conf_state: decode_optional(d)?, + qop_state: decode_optional(d)?, + }) + } +} + +// ---- unwrap (14) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgUnwrap { + pub call_ctx: GssxCallCtx, + pub context_handle: GssxCtx, + pub token_buffer: Vec, + pub qop_state: u64, +} + +impl Xdr for ArgUnwrap { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + self.context_handle.encode(e); + encode_array(e, &self.token_buffer); + self.qop_state.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgUnwrap { + call_ctx: GssxCallCtx::decode(d)?, + context_handle: GssxCtx::decode(d)?, + token_buffer: decode_array(d)?, + qop_state: u64::decode(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResUnwrap { + pub status: GssxStatus, + pub context_handle: Option, + pub message_buffer: Vec, + pub conf_state: Option, + pub qop_state: Option, +} + +impl Xdr for ResUnwrap { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + encode_optional(e, &self.context_handle); + encode_array(e, &self.message_buffer); + encode_optional(e, &self.conf_state); + encode_optional(e, &self.qop_state); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResUnwrap { + status: GssxStatus::decode(d)?, + context_handle: decode_optional(d)?, + message_buffer: decode_array(d)?, + conf_state: decode_optional(d)?, + qop_state: decode_optional(d)?, + }) + } +} + +// ---- wrap_size_limit (15) ---- + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArgWrapSizeLimit { + pub call_ctx: GssxCallCtx, + pub context_handle: GssxCtx, + pub conf_req: bool, + pub qop_state: u64, + pub req_output_size: u64, +} + +impl Xdr for ArgWrapSizeLimit { + fn encode(&self, e: &mut XdrEncoder) { + self.call_ctx.encode(e); + self.context_handle.encode(e); + self.conf_req.encode(e); + self.qop_state.encode(e); + self.req_output_size.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ArgWrapSizeLimit { + call_ctx: GssxCallCtx::decode(d)?, + context_handle: GssxCtx::decode(d)?, + conf_req: bool::decode(d)?, + qop_state: u64::decode(d)?, + req_output_size: u64::decode(d)?, + }) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResWrapSizeLimit { + pub status: GssxStatus, + pub max_input_size: u64, +} + +impl Xdr for ResWrapSizeLimit { + fn encode(&self, e: &mut XdrEncoder) { + self.status.encode(e); + self.max_input_size.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(ResWrapSizeLimit { + status: GssxStatus::decode(d)?, + max_input_size: u64::decode(d)?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(v: &T) { + let mut e = XdrEncoder::new(); + v.encode(&mut e); + let mut d = XdrDecoder::new(e.as_bytes()); + let got = T::decode(&mut d).unwrap(); + assert_eq!(&got, v); + assert_eq!(d.remaining(), 0); + } + + #[test] + fn proc_numbers() { + assert_eq!(GssxProc::from_u32(8), Some(GssxProc::InitSecContext)); + assert_eq!(GssxProc::from_u32(0), None); + assert_eq!(GssxProc::from_u32(16), None); + assert_eq!(GssxProc::InitSecContext as u32, 8); + } + + #[test] + fn init_sec_context_roundtrip() { + let arg = ArgInitSecContext { + mech_type: Opaque::new(vec![1, 2, 3]), + req_flags: 0x3e, + input_token: Some(Opaque::new(b"token".to_vec())), + ..Default::default() + }; + roundtrip(&arg); + let res = ResInitSecContext { + status: GssxStatus { + major_status: 0, + ..Default::default() + }, + context_handle: Some(GssxCtx::default()), + output_token: Some(Opaque::new(b"out".to_vec())), + options: vec![], + }; + roundtrip(&res); + } + + #[test] + fn wrap_unwrap_roundtrip() { + let arg = ArgWrap { + conf_req: true, + message_buffer: vec![Opaque::new(b"hello".to_vec())], + qop_state: 0, + ..Default::default() + }; + roundtrip(&arg); + let res = ResWrap { + token_buffer: vec![Opaque::new(b"wrapped".to_vec())], + conf_state: Some(true), + qop_state: Some(0), + ..Default::default() + }; + roundtrip(&res); + } +} diff --git a/rust/gssproxy-proto/src/redact.rs b/rust/gssproxy-proto/src/redact.rs new file mode 100644 index 000000000..adefce71b --- /dev/null +++ b/rust/gssproxy-proto/src/redact.rs @@ -0,0 +1,80 @@ +//! Helpers for logging secret-bearing data safely. +//! +//! The gssx wire protocol carries credentials, GSS tokens, exported context +//! state, keytab-derived key material, channel bindings, and passwords. None of +//! that may ever reach a log sink in cleartext. These helpers let call sites +//! record the *shape* of a secret (its length) without its contents, so that +//! `tracing` fields stay useful for debugging while never leaking key material. +//! +//! This module is intentionally dependency-free (no `tracing`): `gssproxy-proto` +//! is the byte-exact, dependency-free codec crate, so it only provides the +//! formatting primitives and the consuming crates pass them to `tracing`. +//! +//! Usage in a `tracing` field: +//! ```ignore +//! tracing::debug!(token = %redact::len(&output_token), "init_sec_context done"); +//! // logs: token=<48 bytes> +//! ``` + +use core::fmt; + +/// A [`fmt::Display`]/[`fmt::Debug`] wrapper that renders only a byte length, +/// never the bytes themselves. Construct it with [`len`]/[`opt_len`]. +#[derive(Clone, Copy)] +pub struct Len(usize); + +impl fmt::Display for Len { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "<{} bytes>", self.0) + } +} + +impl fmt::Debug for Len { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +/// Summarise a secret-bearing byte slice as `` for logging. +pub fn len(bytes: &[u8]) -> Len { + Len(bytes.len()) +} + +/// Summarise an optional secret-bearing byte slice; absent renders as +/// `<0 bytes>`, matching an empty buffer. +pub fn opt_len(bytes: Option<&[u8]>) -> Len { + Len(bytes.map_or(0, <[u8]>::len)) +} + +/// Whether a secret is present (non-empty), for a boolean `present=` field that +/// reveals nothing about the contents. +pub fn present(bytes: &[u8]) -> bool { + !bytes.is_empty() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_only_length() { + assert_eq!(len(b"super-secret-token").to_string(), "<18 bytes>"); + assert_eq!(format!("{:?}", len(&[])), "<0 bytes>"); + assert_eq!(opt_len(None).to_string(), "<0 bytes>"); + assert_eq!(opt_len(Some(b"abcd")).to_string(), "<4 bytes>"); + } + + #[test] + fn never_contains_payload() { + let secret = b"AKIA-pretend-this-is-a-key"; + let rendered = format!("{} {:?}", len(secret), opt_len(Some(secret))); + assert!(!rendered.contains("AKIA")); + assert!(rendered.contains("26 bytes")); + } + + #[test] + fn present_reports_emptiness_only() { + assert!(present(b"x")); + assert!(!present(b"")); + } +} diff --git a/rust/gssproxy-proto/src/rpc.rs b/rust/gssproxy-proto/src/rpc.rs new file mode 100644 index 000000000..dc03b0a3d --- /dev/null +++ b/rust/gssproxy-proto/src/rpc.rs @@ -0,0 +1,315 @@ +//! SunRPC message envelope used by gssproxy (a private ONC RPC program). +//! +//! Port of `rpcgen/gp_rpc.h` + `rpcgen/gp_rpc_xdr.c`. The daemon program is +//! number `GSSPROXY` (400112), version `GSSPROXYVERS` (1); procedures are the +//! `GSSX_*` values 1..=15 (see `proc.rs`). + +use crate::xdr::{Xdr, XdrDecoder, XdrEncoder, XdrError, XdrResult}; + +/// RPC program number for gssproxy. +pub const GSSPROXY: u32 = 400112; +/// RPC program version. +pub const GSSPROXYVERS: u32 = 1; +/// RPC protocol version carried in the call header. +pub const RPC_VERS: u32 = 2; + +/// Maximum size of a single RPC message body (matches `MAX_RPC_SIZE`). +pub const MAX_RPC_SIZE: usize = 1024 * 1024; +/// Last-fragment marker in the record-marking length word. +pub const FRAGMENT_BIT: u32 = 1 << 31; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthFlavor { + None = 0, + Sys = 1, + Short = 2, + Dh = 3, + RpcSecGss = 6, +} + +impl AuthFlavor { + fn from_i32(v: i32) -> XdrResult { + Ok(match v { + 0 => AuthFlavor::None, + 1 => AuthFlavor::Sys, + 2 => AuthFlavor::Short, + 3 => AuthFlavor::Dh, + 6 => AuthFlavor::RpcSecGss, + _ => return Err(XdrError::InvalidValue("auth_flavor")), + }) + } +} + +/// `gp_rpc_opaque_auth` - flavour plus up to 400 bytes of body. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OpaqueAuth { + pub flavor: i32, + pub body: Vec, +} + +impl OpaqueAuth { + pub fn none() -> Self { + OpaqueAuth { + flavor: AuthFlavor::None as i32, + body: Vec::new(), + } + } +} + +impl Xdr for OpaqueAuth { + fn encode(&self, e: &mut XdrEncoder) { + e.put_enum(self.flavor); + e.put_opaque(&self.body); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + let flavor = d.get_enum()?; + // Validate against the known set, mirroring xdr_gp_rpc_auth_flavor. + let _ = AuthFlavor::from_i32(flavor)?; + let body = d.get_opaque()?; + if body.len() > 400 { + return Err(XdrError::TooLong); + } + Ok(OpaqueAuth { flavor, body }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallHeader { + pub rpcvers: u32, + pub prog: u32, + pub vers: u32, + pub proc_num: u32, + pub cred: OpaqueAuth, + pub verf: OpaqueAuth, +} + +impl CallHeader { + /// Build a client call header for a given procedure. + pub fn new(proc_num: u32) -> Self { + CallHeader { + rpcvers: RPC_VERS, + prog: GSSPROXY, + vers: GSSPROXYVERS, + proc_num, + cred: OpaqueAuth::none(), + verf: OpaqueAuth::none(), + } + } +} + +impl Xdr for CallHeader { + fn encode(&self, e: &mut XdrEncoder) { + e.put_u32(self.rpcvers); + e.put_u32(self.prog); + e.put_u32(self.vers); + e.put_u32(self.proc_num); + self.cred.encode(e); + self.verf.encode(e); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + Ok(CallHeader { + rpcvers: d.get_u32()?, + prog: d.get_u32()?, + vers: d.get_u32()?, + proc_num: d.get_u32()?, + cred: OpaqueAuth::decode(d)?, + verf: OpaqueAuth::decode(d)?, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcceptStatus { + Success = 0, + ProgUnavail = 1, + ProgMismatch = 2, + ProcUnavail = 3, + GarbageArgs = 4, + SystemErr = 5, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MismatchInfo { + pub low: u32, + pub high: u32, +} + +/// Reply header. The C side only ever needs the "accepted + success" path on +/// decode (anything else is an error), and the daemon only ever emits that +/// path on encode, so this captures the success case faithfully and reports +/// other cases as a discriminated status for completeness. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReplyBody { + /// MSG_ACCEPTED + SUCCESS: verifier, then the procedure result follows. + AcceptedSuccess { verf: OpaqueAuth }, + /// MSG_ACCEPTED + PROG_MISMATCH. + ProgMismatch { + verf: OpaqueAuth, + info: MismatchInfo, + }, + /// MSG_ACCEPTED with another accept status (no body). + AcceptedOther { verf: OpaqueAuth, status: i32 }, + /// MSG_DENIED with the raw reject discriminant and value. + Denied { reject_status: i32, value: i32 }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub xid: u32, + /// True for a CALL, false for a REPLY. + pub is_call: bool, + pub call: Option, + pub reply: Option, +} + +const MSG_CALL: i32 = 0; +const MSG_REPLY: i32 = 1; +const REPLY_ACCEPTED: i32 = 0; +const REPLY_DENIED: i32 = 1; +const ACCEPT_SUCCESS: i32 = 0; +const ACCEPT_PROG_MISMATCH: i32 = 2; + +impl Message { + /// Construct a CALL message for the given xid and procedure. + pub fn call(xid: u32, proc_num: u32) -> Self { + Message { + xid, + is_call: true, + call: Some(CallHeader::new(proc_num)), + reply: None, + } + } + + /// Construct a successful accepted REPLY message. + pub fn reply_success(xid: u32) -> Self { + Message { + xid, + is_call: false, + call: None, + reply: Some(ReplyBody::AcceptedSuccess { + verf: OpaqueAuth::none(), + }), + } + } + + /// Encode just the RPC envelope (the procedure args/results are appended by + /// the caller into the same encoder). + pub fn encode(&self, e: &mut XdrEncoder) { + e.put_u32(self.xid); + if self.is_call { + e.put_enum(MSG_CALL); + self.call + .as_ref() + .expect("call message without header") + .encode(e); + } else { + e.put_enum(MSG_REPLY); + match self.reply.as_ref().expect("reply message without body") { + ReplyBody::AcceptedSuccess { verf } => { + e.put_enum(REPLY_ACCEPTED); + verf.encode(e); + e.put_enum(ACCEPT_SUCCESS); + // GP_RPC_SUCCESS results follow inline (zero-length opaque + // discriminant body in C: xdr_opaque(.., 0) emits nothing). + } + ReplyBody::ProgMismatch { verf, info } => { + e.put_enum(REPLY_ACCEPTED); + verf.encode(e); + e.put_enum(ACCEPT_PROG_MISMATCH); + e.put_u32(info.low); + e.put_u32(info.high); + } + ReplyBody::AcceptedOther { verf, status } => { + e.put_enum(REPLY_ACCEPTED); + verf.encode(e); + e.put_enum(*status); + } + ReplyBody::Denied { + reject_status, + value, + } => { + e.put_enum(REPLY_DENIED); + e.put_enum(*reject_status); + e.put_enum(*value); + } + } + } + } + + /// Decode the RPC envelope. The decoder is left positioned at the start of + /// the procedure args/results. + pub fn decode(d: &mut XdrDecoder) -> XdrResult { + let xid = d.get_u32()?; + match d.get_enum()? { + MSG_CALL => Ok(Message { + xid, + is_call: true, + call: Some(CallHeader::decode(d)?), + reply: None, + }), + MSG_REPLY => { + let reply = match d.get_enum()? { + REPLY_ACCEPTED => { + let verf = OpaqueAuth::decode(d)?; + match d.get_enum()? { + ACCEPT_SUCCESS => ReplyBody::AcceptedSuccess { verf }, + ACCEPT_PROG_MISMATCH => ReplyBody::ProgMismatch { + verf, + info: MismatchInfo { + low: d.get_u32()?, + high: d.get_u32()?, + }, + }, + status => ReplyBody::AcceptedOther { verf, status }, + } + } + REPLY_DENIED => ReplyBody::Denied { + reject_status: d.get_enum()?, + value: d.get_enum()?, + }, + _ => return Err(XdrError::InvalidValue("reply_status")), + }; + Ok(Message { + xid, + is_call: false, + call: None, + reply: Some(reply), + }) + } + _ => Err(XdrError::InvalidValue("msg_type")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn call_header_roundtrip() { + let mut e = XdrEncoder::new(); + let msg = Message::call(0xdeadbeef, 8); + msg.encode(&mut e); + let mut d = XdrDecoder::new(e.as_bytes()); + let got = Message::decode(&mut d).unwrap(); + assert_eq!(got, msg); + let call = got.call.unwrap(); + assert_eq!(call.prog, GSSPROXY); + assert_eq!(call.vers, GSSPROXYVERS); + assert_eq!(call.proc_num, 8); + assert_eq!(call.rpcvers, RPC_VERS); + } + + #[test] + fn reply_success_roundtrip() { + let mut e = XdrEncoder::new(); + Message::reply_success(7).encode(&mut e); + let mut d = XdrDecoder::new(e.as_bytes()); + let got = Message::decode(&mut d).unwrap(); + assert_eq!(got.xid, 7); + assert!(!got.is_call); + assert!(matches!(got.reply, Some(ReplyBody::AcceptedSuccess { .. }))); + // No trailing bytes: a success reply envelope has no result body here. + assert_eq!(d.remaining(), 0); + } +} diff --git a/rust/gssproxy-proto/src/verification.rs b/rust/gssproxy-proto/src/verification.rs new file mode 100644 index 000000000..1328d3d3c --- /dev/null +++ b/rust/gssproxy-proto/src/verification.rs @@ -0,0 +1,123 @@ +//! Kani bounded-model-checking harnesses for the gssx wire codec. +//! +//! These complement `tests/proptest_proto.rs`: where proptest *samples* random +//! inputs, Kani *proves* the same safety and round-trip properties for every +//! input within the stated bounds (no panic, no arithmetic overflow, no +//! out-of-bounds access, no over-allocation, exact consumption). +//! +//! The whole module is gated on `#[cfg(kani)]`, so it is compiled only under +//! `cargo kani` and never affects normal builds. See +//! `rust/docs/formal-verification.md` for the rationale and how to run it. + +use crate::frame::{FrameError, encode_header, parse_header}; +use crate::gssx::Opaque; +use crate::rpc::{FRAGMENT_BIT, MAX_RPC_SIZE}; +use crate::xdr::{Xdr, XdrDecoder, XdrEncoder}; + +/// `u32` encodes to exactly 4 bytes and decodes back to the same value. +#[kani::proof] +#[kani::unwind(8)] +fn u32_round_trips() { + let v: u32 = kani::any(); + let mut e = XdrEncoder::new(); + v.encode(&mut e); + let bytes = e.into_bytes(); + assert!(bytes.len() == 4); + let mut d = XdrDecoder::new(&bytes); + assert!(u32::decode(&mut d).unwrap() == v); + assert!(d.remaining() == 0); +} + +/// `u64` is two big-endian 4-byte words (high first) and round-trips. +#[kani::proof] +#[kani::unwind(12)] +fn u64_round_trips() { + let v: u64 = kani::any(); + let mut e = XdrEncoder::new(); + v.encode(&mut e); + let bytes = e.into_bytes(); + assert!(bytes.len() == 8); + let mut d = XdrDecoder::new(&bytes); + assert!(u64::decode(&mut d).unwrap() == v); + assert!(d.remaining() == 0); +} + +/// `bool` round-trips and only ever encodes the canonical 0/1 words. +#[kani::proof] +#[kani::unwind(8)] +fn bool_round_trips() { + let v: bool = kani::any(); + let mut e = XdrEncoder::new(); + v.encode(&mut e); + let bytes = e.into_bytes(); + let mut d = XdrDecoder::new(&bytes); + assert!(bool::decode(&mut d).unwrap() == v); + assert!(d.remaining() == 0); +} + +/// Encoding an `Opaque` of bounded length and decoding it returns the same +/// bytes and leaves the decoder fully consumed (length prefix + padding). +#[kani::proof] +#[kani::unwind(12)] +fn opaque_round_trips() { + // Bound the payload so CBMC stays tractable; 5 bytes exercises both the + // length prefix and a non-trivial zero pad (5 -> 3 pad bytes). + let data: [u8; 5] = kani::any(); + let value = Opaque::new(data.to_vec()); + let mut e = XdrEncoder::new(); + value.encode(&mut e); + let bytes = e.into_bytes(); + let mut d = XdrDecoder::new(&bytes); + let decoded = Opaque::decode(&mut d).unwrap(); + assert!(decoded.as_slice() == &data[..]); + assert!(d.remaining() == 0); +} + +/// Decoding an `Opaque` from arbitrary bounded bytes never panics and never +/// over-allocates: the declared length is validated against the bytes actually +/// present before any copy, so a hostile length yields `Eof`, not a crash. +#[kani::proof] +#[kani::unwind(16)] +fn opaque_decode_is_panic_free() { + let bytes: [u8; 12] = kani::any(); + let mut d = XdrDecoder::new(&bytes); + match Opaque::decode(&mut d) { + Ok(o) => { + // A decoded value can never claim more bytes than were available. + assert!(o.as_slice().len() <= bytes.len()); + assert!(d.position() <= bytes.len()); + } + Err(_) => {} + } +} + +/// `parse_header` honours the fragment-bit / size-cap contract for *every* +/// 32-bit record-marking word. +#[kani::proof] +fn frame_parse_header_matches_spec() { + let word: u32 = kani::any(); + match parse_header(word) { + Ok(len) => { + assert!(word & FRAGMENT_BIT != 0); + assert!(len <= MAX_RPC_SIZE); + assert!(len as u32 == word & !FRAGMENT_BIT); + } + Err(FrameError::MultiFragment) => { + assert!(word & FRAGMENT_BIT == 0); + } + Err(FrameError::TooLarge(n)) => { + assert!(word & FRAGMENT_BIT != 0); + assert!(n > MAX_RPC_SIZE); + } + } +} + +/// Every in-range body length encodes to a header word that parses back to the +/// same length. +#[kani::proof] +fn frame_header_round_trips() { + let len: usize = kani::any(); + kani::assume(len <= MAX_RPC_SIZE); + let word = u32::from_be_bytes(encode_header(len)); + assert!(parse_header(word).unwrap() == len); +} diff --git a/rust/gssproxy-proto/src/xdr.rs b/rust/gssproxy-proto/src/xdr.rs new file mode 100644 index 000000000..6552d7b65 --- /dev/null +++ b/rust/gssproxy-proto/src/xdr.rs @@ -0,0 +1,297 @@ +//! Minimal XDR (RFC 4506) codec matching the behaviour of the SunRPC `xdr_*` +//! routines that the C gssproxy uses via rpcgen. +//! +//! The encoder/decoder are deliberately explicit so the byte layout can be +//! validated against the C implementation: +//! - integers are big-endian, encoded in 4-byte units; +//! - `uint64` is two 4-byte units, high word first (see `gp_xdr_uint64_t`); +//! - `bool`/`enum` occupy 4 bytes; +//! - variable opaque/string data is a 4-byte length followed by the bytes, +//! zero-padded up to the next 4-byte boundary; +//! - arrays are a 4-byte count followed by the elements; +//! - an optional ("pointer") value is a 4-byte bool followed by the value +//! when present. + +use std::fmt; + +/// Error returned while decoding an XDR stream. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum XdrError { + /// Not enough bytes left in the buffer to decode the requested item. + Eof, + /// A boolean/enum or discriminant held a value outside the allowed range. + InvalidValue(&'static str), + /// A length field exceeded the configured maximum. + TooLong, +} + +impl fmt::Display for XdrError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + XdrError::Eof => write!(f, "unexpected end of XDR stream"), + XdrError::InvalidValue(what) => write!(f, "invalid XDR value: {what}"), + XdrError::TooLong => write!(f, "XDR length exceeds maximum"), + } + } +} + +impl std::error::Error for XdrError {} + +/// Result type for decoding. +pub type XdrResult = Result; + +/// Append-only XDR encoder backed by a growable byte buffer. +#[derive(Default)] +pub struct XdrEncoder { + buf: Vec, +} + +impl XdrEncoder { + pub fn new() -> Self { + Self { buf: Vec::new() } + } + + pub fn with_capacity(cap: usize) -> Self { + Self { + buf: Vec::with_capacity(cap), + } + } + + /// Current encoded length in bytes (matches `xdr_getpos`). + pub fn position(&self) -> usize { + self.buf.len() + } + + pub fn into_bytes(self) -> Vec { + self.buf + } + + pub fn as_bytes(&self) -> &[u8] { + &self.buf + } + + pub fn put_u32(&mut self, v: u32) { + self.buf.extend_from_slice(&v.to_be_bytes()); + } + + pub fn put_i32(&mut self, v: i32) { + self.put_u32(v as u32); + } + + /// Matches `gp_xdr_uint64_t`: high 32 bits first, then low 32 bits. + pub fn put_u64(&mut self, v: u64) { + self.put_u32((v >> 32) as u32); + self.put_u32(v as u32); + } + + /// XDR bool is a 4-byte integer that is 1 (TRUE) or 0 (FALSE). + pub fn put_bool(&mut self, v: bool) { + self.put_u32(if v { 1 } else { 0 }); + } + + /// XDR enum is encoded like a signed 4-byte integer. + pub fn put_enum(&mut self, v: i32) { + self.put_i32(v); + } + + /// Variable-length opaque/string: length prefix, data, zero pad to 4 bytes. + pub fn put_opaque(&mut self, data: &[u8]) { + self.put_u32(data.len() as u32); + self.buf.extend_from_slice(data); + let pad = (4 - (data.len() % 4)) % 4; + for _ in 0..pad { + self.buf.push(0); + } + } +} + +/// Borrowing XDR decoder over a byte slice. +pub struct XdrDecoder<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> XdrDecoder<'a> { + pub fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + pub fn position(&self) -> usize { + self.pos + } + + pub fn remaining(&self) -> usize { + self.buf.len() - self.pos + } + + fn take(&mut self, n: usize) -> XdrResult<&'a [u8]> { + if self.remaining() < n { + return Err(XdrError::Eof); + } + let out = &self.buf[self.pos..self.pos + n]; + self.pos += n; + Ok(out) + } + + pub fn get_u32(&mut self) -> XdrResult { + let b = self.take(4)?; + Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + + pub fn get_i32(&mut self) -> XdrResult { + Ok(self.get_u32()? as i32) + } + + pub fn get_u64(&mut self) -> XdrResult { + let h = self.get_u32()? as u64; + let l = self.get_u32()? as u64; + Ok((h << 32) | l) + } + + pub fn get_bool(&mut self) -> XdrResult { + match self.get_u32()? { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(XdrError::InvalidValue("bool")), + } + } + + pub fn get_enum(&mut self) -> XdrResult { + self.get_i32() + } + + /// Reads a variable-length opaque/string and consumes the 4-byte padding. + pub fn get_opaque(&mut self) -> XdrResult> { + let len = self.get_u32()? as usize; + let data = self.take(len)?.to_vec(); + let pad = (4 - (len % 4)) % 4; + self.take(pad)?; + Ok(data) + } +} + +/// Trait for types that can be (de)serialized as XDR exactly like their C +/// rpcgen counterparts. +pub trait Xdr: Sized { + fn encode(&self, e: &mut XdrEncoder); + fn decode(d: &mut XdrDecoder) -> XdrResult; +} + +impl Xdr for u32 { + fn encode(&self, e: &mut XdrEncoder) { + e.put_u32(*self); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + d.get_u32() + } +} + +impl Xdr for u64 { + fn encode(&self, e: &mut XdrEncoder) { + e.put_u64(*self); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + d.get_u64() + } +} + +impl Xdr for bool { + fn encode(&self, e: &mut XdrEncoder) { + e.put_bool(*self); + } + fn decode(d: &mut XdrDecoder) -> XdrResult { + d.get_bool() + } +} + +/// XDR `array`: a 4-byte count followed by each element in order. +pub fn encode_array(e: &mut XdrEncoder, items: &[T]) { + e.put_u32(items.len() as u32); + for item in items { + item.encode(e); + } +} + +pub fn decode_array(d: &mut XdrDecoder) -> XdrResult> { + let count = d.get_u32()? as usize; + // Guard against absurd allocations from a corrupt length. + if count > d.remaining() + 1 { + return Err(XdrError::TooLong); + } + let mut out = Vec::with_capacity(count.min(1024)); + for _ in 0..count { + out.push(T::decode(d)?); + } + Ok(out) +} + +/// XDR optional ("pointer"): a 4-byte bool, then the value when present. +pub fn encode_optional(e: &mut XdrEncoder, value: &Option) { + match value { + Some(v) => { + e.put_bool(true); + v.encode(e); + } + None => e.put_bool(false), + } +} + +pub fn decode_optional(d: &mut XdrDecoder) -> XdrResult> { + if d.get_bool()? { + Ok(Some(T::decode(d)?)) + } else { + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn u32_roundtrip_and_layout() { + let mut e = XdrEncoder::new(); + e.put_u32(0x01020304); + assert_eq!(e.as_bytes(), &[0x01, 0x02, 0x03, 0x04]); + let mut d = XdrDecoder::new(e.as_bytes()); + assert_eq!(d.get_u32().unwrap(), 0x01020304); + } + + #[test] + fn u64_high_word_first() { + let mut e = XdrEncoder::new(); + e.put_u64(0x0102030405060708); + assert_eq!( + e.as_bytes(), + &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] + ); + let mut d = XdrDecoder::new(e.as_bytes()); + assert_eq!(d.get_u64().unwrap(), 0x0102030405060708); + } + + #[test] + fn opaque_is_length_prefixed_and_padded() { + let mut e = XdrEncoder::new(); + e.put_opaque(b"abc"); + // length 3, 'a','b','c', one pad byte -> 8 bytes total + assert_eq!(e.as_bytes(), &[0, 0, 0, 3, b'a', b'b', b'c', 0]); + let mut d = XdrDecoder::new(e.as_bytes()); + assert_eq!(d.get_opaque().unwrap(), b"abc"); + assert_eq!(d.remaining(), 0); + } + + #[test] + fn empty_opaque_has_no_padding() { + let mut e = XdrEncoder::new(); + e.put_opaque(b""); + assert_eq!(e.as_bytes(), &[0, 0, 0, 0]); + } + + #[test] + fn bool_values() { + let mut e = XdrEncoder::new(); + e.put_bool(true); + e.put_bool(false); + assert_eq!(e.as_bytes(), &[0, 0, 0, 1, 0, 0, 0, 0]); + } +} diff --git a/rust/gssproxy-proto/tests/proptest_proto.rs b/rust/gssproxy-proto/tests/proptest_proto.rs new file mode 100644 index 000000000..dde313470 --- /dev/null +++ b/rust/gssproxy-proto/tests/proptest_proto.rs @@ -0,0 +1,1113 @@ +//! Property-based and "chaos" fuzz tests for the gssx wire codec. +//! +//! These use `proptest` to validate two classes of invariant against the +//! hand-ported C behaviour: +//! +//! 1. Round-trip fidelity: for every `gssx_*` type and every `gssx_arg_*` / +//! `gssx_res_*` procedure, `decode(encode(v)) == v` and the decoder +//! consumes exactly the bytes the encoder produced. This is what +//! guarantees the Rust codec stays byte-compatible with rpcgen's +//! `xdr_gssx_*`. +//! +//! 2. Robustness on hostile input: decoding arbitrary / truncated / biased +//! byte streams must never panic, never over-allocate, and never hang +//! (matching the C `xdrmem_*` routines which fail cleanly on short or +//! corrupt buffers). When a decode happens to succeed, re-encoding it +//! must reproduce a canonical, self-consistent buffer. + +use gssproxy_proto::frame::{FrameError, encode_header, parse_header}; +use gssproxy_proto::gssx::*; +use gssproxy_proto::proc::*; +use gssproxy_proto::rpc::{ + CallHeader, FRAGMENT_BIT, MAX_RPC_SIZE, Message, MismatchInfo, OpaqueAuth, ReplyBody, +}; +use gssproxy_proto::xdr::{Xdr, XdrDecoder, XdrEncoder}; +use proptest::prelude::*; + +// Keep generated structures small but non-trivial so the test matrix stays +// fast while still exercising arrays, optionals and padding. +const MAXB: usize = 16; // opaque/string byte cap +const MAXV: usize = 2; // array/element cap + +// ---- leaf + shared gssx strategies ----------------------------------------- + +fn opaque() -> impl Strategy { + prop::collection::vec(any::(), 0..=MAXB).prop_map(Opaque) +} + +fn oid_set() -> impl Strategy> { + prop::collection::vec(opaque(), 0..=MAXV) +} + +fn option() -> impl Strategy { + (opaque(), opaque()).prop_map(|(option, value)| GssxOption { option, value }) +} + +fn options() -> impl Strategy> { + prop::collection::vec(option(), 0..=MAXV) +} + +fn name_attr() -> impl Strategy { + (opaque(), opaque(), options()).prop_map(|(attr, value, extensions)| GssxNameAttr { + attr, + value, + extensions, + }) +} + +fn name() -> impl Strategy { + ( + opaque(), + opaque(), + opaque(), + opaque(), + prop::collection::vec(name_attr(), 0..=MAXV), + options(), + ) + .prop_map( + |( + display_name, + name_type, + exported_name, + exported_composite_name, + name_attributes, + extensions, + )| { + GssxName { + display_name, + name_type, + exported_name, + exported_composite_name, + name_attributes, + extensions, + } + }, + ) +} + +fn status() -> impl Strategy { + ( + any::(), + opaque(), + any::(), + opaque(), + opaque(), + opaque(), + options(), + ) + .prop_map( + |( + major_status, + mech, + minor_status, + major_status_string, + minor_status_string, + server_ctx, + options, + )| GssxStatus { + major_status, + mech, + minor_status, + major_status_string, + minor_status_string, + server_ctx, + options, + }, + ) +} + +fn call_ctx() -> impl Strategy { + (opaque(), opaque(), options()).prop_map(|(locale, server_ctx, options)| GssxCallCtx { + locale, + server_ctx, + options, + }) +} + +fn cb() -> impl Strategy { + (any::(), opaque(), any::(), opaque(), opaque()).prop_map( + |( + initiator_addrtype, + initiator_address, + acceptor_addrtype, + acceptor_address, + application_data, + )| { + GssxCb { + initiator_addrtype, + initiator_address, + acceptor_addrtype, + acceptor_address, + application_data, + } + }, + ) +} + +fn ctx() -> impl Strategy { + ( + (opaque(), opaque(), any::(), opaque(), name()), + ( + name(), + any::(), + any::(), + any::(), + any::(), + options(), + ), + ) + .prop_map( + |( + (exported_context_token, state, needs_release, mech, src_name), + (targ_name, lifetime, ctx_flags, locally_initiated, open, options), + )| GssxCtx { + exported_context_token, + state, + needs_release, + mech, + src_name, + targ_name, + lifetime, + ctx_flags, + locally_initiated, + open, + options, + }, + ) +} + +fn cred_element() -> impl Strategy { + ( + name(), + opaque(), + any::(), + any::(), + any::(), + options(), + ) + .prop_map( + |(mn, mech, cred_usage, initiator_time_rec, acceptor_time_rec, options)| { + GssxCredElement { + mn, + mech, + cred_usage, + initiator_time_rec, + acceptor_time_rec, + options, + } + }, + ) +} + +fn cred() -> impl Strategy { + ( + name(), + prop::collection::vec(cred_element(), 0..=MAXV), + opaque(), + any::(), + ) + .prop_map( + |(desired_name, elements, cred_handle_reference, needs_release)| GssxCred { + desired_name, + elements, + cred_handle_reference, + needs_release, + }, + ) +} + +fn handle() -> impl Strategy { + prop_oneof![ + ctx().prop_map(GssxHandle::SecCtx), + cred().prop_map(GssxHandle::Cred), + // Any discriminant other than the two known union arms decodes back to + // the opaque `Extensions` variant, so exclude 0/1 to keep round-trip. + ( + any::().prop_filter("not a known handle discriminant", |t| *t != 0 && *t != 1), + opaque() + ) + .prop_map(|(handle_type, data)| GssxHandle::Extensions { handle_type, data }), + ] +} + +fn mech_attr() -> impl Strategy { + (opaque(), opaque(), opaque(), opaque(), options()).prop_map( + |(attr, name, short_desc, long_desc, extensions)| GssxMechAttr { + attr, + name, + short_desc, + long_desc, + extensions, + }, + ) +} + +fn mech_info() -> impl Strategy { + ( + (opaque(), oid_set(), oid_set(), oid_set(), oid_set()), + (oid_set(), opaque(), opaque(), opaque(), options()), + ) + .prop_map( + |( + (mech, name_types, mech_attrs, known_mech_attrs, cred_options), + ( + sec_ctx_options, + saslname_sasl_mech_name, + saslname_mech_name, + saslname_mech_desc, + extensions, + ), + )| GssxMechInfo { + mech, + name_types, + mech_attrs, + known_mech_attrs, + cred_options, + sec_ctx_options, + saslname_sasl_mech_name, + saslname_mech_name, + saslname_mech_desc, + extensions, + }, + ) +} + +// ---- per-procedure strategies ---------------------------------------------- + +fn arg_release_handle() -> impl Strategy { + (call_ctx(), handle()).prop_map(|(call_ctx, cred_handle)| ArgReleaseHandle { + call_ctx, + cred_handle, + }) +} + +fn res_release_handle() -> impl Strategy { + status().prop_map(|status| ResReleaseHandle { status }) +} + +fn arg_indicate_mechs() -> impl Strategy { + call_ctx().prop_map(|call_ctx| ArgIndicateMechs { call_ctx }) +} + +fn res_indicate_mechs() -> impl Strategy { + ( + status(), + prop::collection::vec(mech_info(), 0..=MAXV), + prop::collection::vec(mech_attr(), 0..=MAXV), + prop::collection::vec(opaque(), 0..=MAXV), + options(), + ) + .prop_map( + |(status, mechs, mech_attr_descs, supported_extensions, options)| ResIndicateMechs { + status, + mechs, + mech_attr_descs, + supported_extensions, + options, + }, + ) +} + +fn arg_import_canon() -> impl Strategy { + ( + call_ctx(), + name(), + opaque(), + prop::collection::vec(name_attr(), 0..=MAXV), + options(), + ) + .prop_map(|(call_ctx, input_name, mech, name_attributes, options)| { + ArgImportAndCanonName { + call_ctx, + input_name, + mech, + name_attributes, + options, + } + }) +} + +fn res_import_canon() -> impl Strategy { + (status(), prop::option::of(name()), options()).prop_map(|(status, output_name, options)| { + ResImportAndCanonName { + status, + output_name, + options, + } + }) +} + +fn arg_get_call_context() -> impl Strategy { + (call_ctx(), options()).prop_map(|(call_ctx, options)| ArgGetCallContext { call_ctx, options }) +} + +fn res_get_call_context() -> impl Strategy { + (status(), opaque(), options()).prop_map(|(status, server_call_ctx, options)| { + ResGetCallContext { + status, + server_call_ctx, + options, + } + }) +} + +fn arg_acquire_cred() -> impl Strategy { + ( + ( + call_ctx(), + prop::option::of(cred()), + any::(), + prop::option::of(name()), + any::(), + ), + ( + oid_set(), + any::(), + any::(), + any::(), + options(), + ), + ) + .prop_map( + |( + (call_ctx, input_cred_handle, add_cred_to_input_handle, desired_name, time_req), + (desired_mechs, cred_usage, initiator_time_req, acceptor_time_req, options), + )| ArgAcquireCred { + call_ctx, + input_cred_handle, + add_cred_to_input_handle, + desired_name, + time_req, + desired_mechs, + cred_usage, + initiator_time_req, + acceptor_time_req, + options, + }, + ) +} + +fn res_acquire_cred() -> impl Strategy { + (status(), prop::option::of(cred()), options()).prop_map( + |(status, output_cred_handle, options)| ResAcquireCred { + status, + output_cred_handle, + options, + }, + ) +} + +fn arg_export_cred() -> impl Strategy { + (call_ctx(), cred(), any::(), options()).prop_map( + |(call_ctx, input_cred_handle, cred_usage, options)| ArgExportCred { + call_ctx, + input_cred_handle, + cred_usage, + options, + }, + ) +} + +fn res_export_cred() -> impl Strategy { + ( + status(), + any::(), + prop::option::of(opaque()), + options(), + ) + .prop_map( + |(status, usage_exported, exported_handle, options)| ResExportCred { + status, + usage_exported, + exported_handle, + options, + }, + ) +} + +fn arg_import_cred() -> impl Strategy { + (call_ctx(), opaque(), options()).prop_map(|(call_ctx, exported_handle, options)| { + ArgImportCred { + call_ctx, + exported_handle, + options, + } + }) +} + +fn res_import_cred() -> impl Strategy { + (status(), prop::option::of(cred()), options()).prop_map( + |(status, output_cred_handle, options)| ResImportCred { + status, + output_cred_handle, + options, + }, + ) +} + +fn arg_store_cred() -> impl Strategy { + ( + call_ctx(), + cred(), + any::(), + opaque(), + any::(), + any::(), + options(), + ) + .prop_map( + |( + call_ctx, + input_cred_handle, + cred_usage, + desired_mech, + overwrite_cred, + default_cred, + options, + )| { + ArgStoreCred { + call_ctx, + input_cred_handle, + cred_usage, + desired_mech, + overwrite_cred, + default_cred, + options, + } + }, + ) +} + +fn res_store_cred() -> impl Strategy { + (status(), oid_set(), any::(), options()).prop_map( + |(status, elements_stored, cred_usage_stored, options)| ResStoreCred { + status, + elements_stored, + cred_usage_stored, + options, + }, + ) +} + +fn arg_init_sec_context() -> impl Strategy { + ( + ( + call_ctx(), + prop::option::of(ctx()), + prop::option::of(cred()), + prop::option::of(name()), + opaque(), + ), + ( + any::(), + any::(), + prop::option::of(cb()), + prop::option::of(opaque()), + options(), + ), + ) + .prop_map( + |( + (call_ctx, context_handle, cred_handle, target_name, mech_type), + (req_flags, time_req, input_cb, input_token, options), + )| ArgInitSecContext { + call_ctx, + context_handle, + cred_handle, + target_name, + mech_type, + req_flags, + time_req, + input_cb, + input_token, + options, + }, + ) +} + +fn res_init_sec_context() -> impl Strategy { + ( + status(), + prop::option::of(ctx()), + prop::option::of(opaque()), + options(), + ) + .prop_map( + |(status, context_handle, output_token, options)| ResInitSecContext { + status, + context_handle, + output_token, + options, + }, + ) +} + +fn arg_accept_sec_context() -> impl Strategy { + ( + call_ctx(), + prop::option::of(ctx()), + prop::option::of(cred()), + opaque(), + prop::option::of(cb()), + any::(), + options(), + ) + .prop_map( + |( + call_ctx, + context_handle, + cred_handle, + input_token, + input_cb, + ret_deleg_cred, + options, + )| { + ArgAcceptSecContext { + call_ctx, + context_handle, + cred_handle, + input_token, + input_cb, + ret_deleg_cred, + options, + } + }, + ) +} + +fn res_accept_sec_context() -> impl Strategy { + ( + status(), + prop::option::of(ctx()), + prop::option::of(opaque()), + prop::option::of(cred()), + options(), + ) + .prop_map( + |(status, context_handle, output_token, delegated_cred_handle, options)| { + ResAcceptSecContext { + status, + context_handle, + output_token, + delegated_cred_handle, + options, + } + }, + ) +} + +fn arg_get_mic() -> impl Strategy { + (call_ctx(), ctx(), any::(), opaque()).prop_map( + |(call_ctx, context_handle, qop_req, message_buffer)| ArgGetMic { + call_ctx, + context_handle, + qop_req, + message_buffer, + }, + ) +} + +fn res_get_mic() -> impl Strategy { + ( + status(), + prop::option::of(ctx()), + opaque(), + prop::option::of(any::()), + ) + .prop_map( + |(status, context_handle, token_buffer, qop_state)| ResGetMic { + status, + context_handle, + token_buffer, + qop_state, + }, + ) +} + +fn arg_verify_mic() -> impl Strategy { + (call_ctx(), ctx(), opaque(), opaque()).prop_map( + |(call_ctx, context_handle, message_buffer, token_buffer)| ArgVerifyMic { + call_ctx, + context_handle, + message_buffer, + token_buffer, + }, + ) +} + +fn res_verify_mic() -> impl Strategy { + ( + status(), + prop::option::of(ctx()), + prop::option::of(any::()), + ) + .prop_map(|(status, context_handle, qop_state)| ResVerifyMic { + status, + context_handle, + qop_state, + }) +} + +fn arg_wrap() -> impl Strategy { + ( + call_ctx(), + ctx(), + any::(), + prop::collection::vec(opaque(), 0..=MAXV), + any::(), + ) + .prop_map( + |(call_ctx, context_handle, conf_req, message_buffer, qop_state)| ArgWrap { + call_ctx, + context_handle, + conf_req, + message_buffer, + qop_state, + }, + ) +} + +fn res_wrap() -> impl Strategy { + ( + status(), + prop::option::of(ctx()), + prop::collection::vec(opaque(), 0..=MAXV), + prop::option::of(any::()), + prop::option::of(any::()), + ) + .prop_map( + |(status, context_handle, token_buffer, conf_state, qop_state)| ResWrap { + status, + context_handle, + token_buffer, + conf_state, + qop_state, + }, + ) +} + +fn arg_unwrap() -> impl Strategy { + ( + call_ctx(), + ctx(), + prop::collection::vec(opaque(), 0..=MAXV), + any::(), + ) + .prop_map( + |(call_ctx, context_handle, token_buffer, qop_state)| ArgUnwrap { + call_ctx, + context_handle, + token_buffer, + qop_state, + }, + ) +} + +fn res_unwrap() -> impl Strategy { + ( + status(), + prop::option::of(ctx()), + prop::collection::vec(opaque(), 0..=MAXV), + prop::option::of(any::()), + prop::option::of(any::()), + ) + .prop_map( + |(status, context_handle, message_buffer, conf_state, qop_state)| ResUnwrap { + status, + context_handle, + message_buffer, + conf_state, + qop_state, + }, + ) +} + +fn arg_wrap_size_limit() -> impl Strategy { + (call_ctx(), ctx(), any::(), any::(), any::()).prop_map( + |(call_ctx, context_handle, conf_req, qop_state, req_output_size)| ArgWrapSizeLimit { + call_ctx, + context_handle, + conf_req, + qop_state, + req_output_size, + }, + ) +} + +fn res_wrap_size_limit() -> impl Strategy { + (status(), any::()).prop_map(|(status, max_input_size)| ResWrapSizeLimit { + status, + max_input_size, + }) +} + +// ---- RPC envelope strategies ------------------------------------------------ + +fn opaque_auth() -> impl Strategy { + ( + prop_oneof![Just(0i32), Just(1), Just(2), Just(3), Just(6)], + prop::collection::vec(any::(), 0..=MAXB), + ) + .prop_map(|(flavor, body)| OpaqueAuth { flavor, body }) +} + +fn call_header() -> impl Strategy { + ( + any::(), + any::(), + any::(), + any::(), + opaque_auth(), + opaque_auth(), + ) + .prop_map(|(rpcvers, prog, vers, proc_num, cred, verf)| CallHeader { + rpcvers, + prog, + vers, + proc_num, + cred, + verf, + }) +} + +fn reply_body() -> impl Strategy { + prop_oneof![ + opaque_auth().prop_map(|verf| ReplyBody::AcceptedSuccess { verf }), + (opaque_auth(), any::(), any::()).prop_map(|(verf, low, high)| { + ReplyBody::ProgMismatch { + verf, + info: MismatchInfo { low, high }, + } + }), + ( + opaque_auth(), + any::().prop_filter("not success/mismatch", |s| *s != 0 && *s != 2) + ) + .prop_map(|(verf, status)| ReplyBody::AcceptedOther { verf, status }), + (any::(), any::()).prop_map(|(reject_status, value)| ReplyBody::Denied { + reject_status, + value + }), + ] +} + +fn message() -> impl Strategy { + prop_oneof![ + (any::(), call_header()).prop_map(|(xid, ch)| Message { + xid, + is_call: true, + call: Some(ch), + reply: None, + }), + (any::(), reply_body()).prop_map(|(xid, rb)| Message { + xid, + is_call: false, + call: None, + reply: Some(rb), + }), + ] +} + +// ---- helpers --------------------------------------------------------------- + +/// Assert the full encode/decode round-trip for a single value, including that +/// the decoder consumes exactly the produced bytes (no over/under-read). +fn rt(v: T) { + let mut e = XdrEncoder::new(); + v.encode(&mut e); + let bytes = e.into_bytes(); + let mut d = XdrDecoder::new(&bytes); + let got = T::decode(&mut d).expect("decode of self-encoded value must succeed"); + assert_eq!(got, v, "round-trip changed the value"); + assert_eq!( + d.remaining(), + 0, + "decoder left {} trailing bytes after a self-encoded value", + d.remaining() + ); +} + +/// Decode arbitrary bytes as `T`. Must never panic. When decode succeeds, the +/// canonical re-encoding must decode back to an identical value and be fully +/// consumed (idempotence of decode∘encode). +fn fuzz_decode(bytes: &[u8]) { + let mut d = XdrDecoder::new(bytes); + if let Ok(v1) = T::decode(&mut d) { + let mut e = XdrEncoder::new(); + v1.encode(&mut e); + let b2 = e.into_bytes(); + let mut d2 = XdrDecoder::new(&b2); + let v2 = T::decode(&mut d2).expect("re-decode of canonical encoding must succeed"); + assert_eq!(v1, v2, "decode/encode is not idempotent"); + assert_eq!(d2.remaining(), 0, "canonical re-encode left trailing bytes"); + } +} + +/// Same robustness contract for the RPC envelope, which is not an `Xdr` impl. +fn fuzz_message(bytes: &[u8]) { + let mut d = XdrDecoder::new(bytes); + if let Ok(v1) = Message::decode(&mut d) { + let consumed = d.position(); + let mut e = XdrEncoder::new(); + v1.encode(&mut e); + let b2 = e.into_bytes(); + assert_eq!( + b2.len(), + consumed, + "envelope re-encode length differs from consumed length: {v1:?}" + ); + let mut d2 = XdrDecoder::new(&b2); + let v2 = Message::decode(&mut d2).expect("re-decode of envelope must succeed"); + assert_eq!(v1, v2, "envelope decode/encode is not idempotent"); + assert_eq!(d2.position(), b2.len()); + } +} + +/// Byte stream of 4-byte big-endian words biased toward small values, so that +/// length/count/bool/enum discriminants are frequently in range and the fuzzer +/// reaches deep into nested decoders instead of bailing at the first word. +fn fuzzy_words() -> impl Strategy> { + prop::collection::vec( + prop_oneof![ + 6 => 0u32..6u32, + 2 => 0u32..256u32, + 1 => any::(), + ], + 0..96usize, + ) + .prop_map(|words| { + let mut b = Vec::with_capacity(words.len() * 4); + for w in words { + b.extend_from_slice(&w.to_be_bytes()); + } + b + }) +} + +// ---- round-trip property tests --------------------------------------------- + +macro_rules! roundtrip_tests { + ($($name:ident => $strat:expr_2021;)*) => { + proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + $( + #[test] + fn $name(v in $strat) { + rt(v); + } + )* + } + }; +} + +roundtrip_tests! { + rt_opaque => opaque(); + rt_option => option(); + rt_name_attr => name_attr(); + rt_name => name(); + rt_status => status(); + rt_call_ctx => call_ctx(); + rt_cb => cb(); + rt_ctx => ctx(); + rt_cred_element => cred_element(); + rt_cred => cred(); + rt_handle => handle(); + rt_mech_attr => mech_attr(); + rt_mech_info => mech_info(); + rt_arg_release_handle => arg_release_handle(); + rt_res_release_handle => res_release_handle(); + rt_arg_indicate_mechs => arg_indicate_mechs(); + rt_res_indicate_mechs => res_indicate_mechs(); + rt_arg_import_canon => arg_import_canon(); + rt_res_import_canon => res_import_canon(); + rt_arg_get_callctx => arg_get_call_context(); + rt_res_get_callctx => res_get_call_context(); + rt_arg_acquire_cred => arg_acquire_cred(); + rt_res_acquire_cred => res_acquire_cred(); + rt_arg_export_cred => arg_export_cred(); + rt_res_export_cred => res_export_cred(); + rt_arg_import_cred => arg_import_cred(); + rt_res_import_cred => res_import_cred(); + rt_arg_store_cred => arg_store_cred(); + rt_res_store_cred => res_store_cred(); + rt_arg_init_sec_ctx => arg_init_sec_context(); + rt_res_init_sec_ctx => res_init_sec_context(); + rt_arg_accept_sec_ctx => arg_accept_sec_context(); + rt_res_accept_sec_ctx => res_accept_sec_context(); + rt_arg_get_mic => arg_get_mic(); + rt_res_get_mic => res_get_mic(); + rt_arg_verify_mic => arg_verify_mic(); + rt_res_verify_mic => res_verify_mic(); + rt_arg_wrap => arg_wrap(); + rt_res_wrap => res_wrap(); + rt_arg_unwrap => arg_unwrap(); + rt_res_unwrap => res_unwrap(); + rt_arg_wrap_size => arg_wrap_size_limit(); + rt_res_wrap_size => res_wrap_size_limit(); + rt_opaque_auth => opaque_auth(); + rt_call_header => call_header(); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + /// The RPC envelope (`Message`) uses inherent encode/decode rather than the + /// `Xdr` trait, so it gets its own round-trip property. + #[test] + fn rt_message(m in message()) { + let mut e = XdrEncoder::new(); + m.encode(&mut e); + let bytes = e.into_bytes(); + let mut d = XdrDecoder::new(&bytes); + let got = Message::decode(&mut d).expect("decode of self-encoded message must succeed"); + prop_assert_eq!(got, m); + prop_assert_eq!(d.remaining(), 0); + } +} + +// ---- chaos / robustness tests ---------------------------------------------- + +/// Run every top-level decoder over the same hostile byte stream and assert the +/// no-panic + idempotence contract for each. +fn fuzz_all(bytes: &[u8]) { + // Primitives + shared gssx types. + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + // RPC envelope pieces. + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_message(bytes); + // Every procedure arg/res. + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); + fuzz_decode::(bytes); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(400))] + + /// Structured-random (word-aligned, small-biased) bytes never break any + /// decoder and round-trip when accepted. + #[test] + fn biased_bytes_never_panic(bytes in fuzzy_words()) { + fuzz_all(&bytes); + } + + /// Fully unstructured bytes (including non-aligned lengths) never break any + /// decoder either. + #[test] + fn raw_bytes_never_panic(bytes in prop::collection::vec(any::(), 0..512)) { + fuzz_all(&bytes); + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// Every truncation of a valid encoding decodes cleanly (Ok/Err, no panic), + /// mirroring partial reads off the socket. + #[test] + fn truncated_encodings_never_panic(a in arg_init_sec_context(), m in message()) { + let mut e = XdrEncoder::new(); + a.encode(&mut e); + let b = e.into_bytes(); + for cut in 0..=b.len() { + let mut d = XdrDecoder::new(&b[..cut]); + let _ = ArgInitSecContext::decode(&mut d); + } + + let mut e2 = XdrEncoder::new(); + m.encode(&mut e2); + let b2 = e2.into_bytes(); + for cut in 0..=b2.len() { + let mut d = XdrDecoder::new(&b2[..cut]); + let _ = Message::decode(&mut d); + } + } + + /// A valid encoding with arbitrary trailing garbage still decodes to the + /// original value and reports the correct number of consumed bytes. + #[test] + fn trailing_garbage_is_ignored(a in arg_wrap(), tail in prop::collection::vec(any::(), 0..32)) { + let mut e = XdrEncoder::new(); + a.encode(&mut e); + let n = e.position(); + let mut bytes = e.into_bytes(); + bytes.extend_from_slice(&tail); + let mut d = XdrDecoder::new(&bytes); + let got = ArgWrap::decode(&mut d).expect("valid prefix must decode"); + prop_assert_eq!(got, a); + prop_assert_eq!(d.position(), n); + } +} + +// ---- framing fuzz ---------------------------------------------------------- + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + /// `parse_header` accepts/rejects exactly per the record-marking rules and + /// never panics for any 32-bit word. + #[test] + fn parse_header_matches_rules(word in any::()) { + match parse_header(word) { + Ok(len) => { + prop_assert!(word & FRAGMENT_BIT != 0); + prop_assert!(len <= MAX_RPC_SIZE); + prop_assert_eq!(len as u32, word & !FRAGMENT_BIT); + } + Err(FrameError::MultiFragment) => { + prop_assert_eq!(word & FRAGMENT_BIT, 0); + } + Err(FrameError::TooLarge(n)) => { + prop_assert!(word & FRAGMENT_BIT != 0); + prop_assert!(n > MAX_RPC_SIZE); + } + } + } + + /// Header encode/parse round-trips for every in-range body length. + #[test] + fn header_roundtrip_in_range(len in 0usize..=MAX_RPC_SIZE) { + let word = u32::from_be_bytes(encode_header(len)); + prop_assert_eq!(parse_header(word).expect("in-range length must parse"), len); + } +} diff --git a/rust/gssproxy-server/Cargo.toml b/rust/gssproxy-server/Cargo.toml new file mode 100644 index 000000000..4fd17d545 --- /dev/null +++ b/rust/gssproxy-server/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "gssproxy-server" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "The gssproxy daemon, reimplemented in Rust over the system GSSAPI" + +[lib] +name = "gssproxy_server" +path = "src/lib.rs" + +[[bin]] +name = "gssproxy" +path = "src/main.rs" + +[dependencies] +gssproxy-proto.workspace = true +gssapi-sys.workspace = true +libc.workspace = true +tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "signal"] } +rust-ini = "0.21" +tracing.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +proptest = "1.11.0" diff --git a/rust/gssproxy-server/src/call.rs b/rust/gssproxy-server/src/call.rs new file mode 100644 index 000000000..0f8c78fbf --- /dev/null +++ b/rust/gssproxy-server/src/call.rs @@ -0,0 +1,121 @@ +//! Per-connection call context: the peer's credentials (from `SO_PEERCRED`), +//! the socket it connected on, the resolved program path, and the matched +//! service. Mirrors `struct gp_call_ctx` / `gp_creds_match_conn` in +//! `src/gp_creds.c`. + +use std::sync::Arc; + +use gssapi_sys::seal::CredHandle; + +use crate::config::{Config, Service}; + +/// Everything a handler needs to know about the connection making a request. +#[derive(Debug, Clone)] +pub struct CallContext { + /// Peer uid from `SO_PEERCRED`. + pub uid: u32, + /// Peer gid from `SO_PEERCRED`. + pub gid: u32, + /// Peer pid from `SO_PEERCRED` (`None` if the OS did not provide it). + pub pid: Option, + /// The socket path the peer connected on. + pub socket: String, + /// The peer's executable path (`/proc//exe`), used for `program` + /// matching. `None` if it could not be resolved. + pub program: Option, + /// The matched service, if any. + pub service: Option, + /// The per-service sealing handle, populated by the server once a service + /// is matched. Used to seal/unseal exported credential handles. + pub creds: Option>, +} + +impl CallContext { + /// Resolve the service for a connection, mirroring `gp_creds_match_conn`. + pub fn resolve( + config: &Config, + socket: &str, + uid: u32, + gid: u32, + pid: Option, + ) -> CallContext { + let program = pid.and_then(program_for_pid); + let service = config + .match_service(uid, socket, program.as_deref()) + .cloned(); + CallContext { + uid, + gid, + pid, + socket: socket.to_string(), + program, + service, + creds: None, + } + } + + /// A context with no resolved peer/service, used where peer credentials are + /// unavailable (e.g. tests). + pub fn anonymous(socket: &str) -> CallContext { + CallContext { + uid: u32::MAX, + gid: u32::MAX, + pid: None, + socket: socket.to_string(), + program: None, + service: None, + creds: None, + } + } +} + +/// Resolve the executable backing a pid via `/proc//exe`, matching +/// `get_program()` in `src/gp_socket.c`. +fn program_for_pid(pid: i32) -> Option { + if pid <= 0 { + return None; + } + std::fs::read_link(format!("/proc/{pid}/exe")) + .ok() + .map(|p| p.to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config_with_service(socket: &str, euid: u32) -> Config { + let text = format!( + "[gssproxy]\n[service/test]\n mechs = krb5\n euid = {euid}\n cred_store = keytab:/tmp/x\n" + ); + Config::parse_str(&text, socket).expect("parse config") + } + + #[test] + fn resolves_matching_service_by_uid() { + let cfg = config_with_service("/run/gp.sock", 1000); + let ctx = CallContext::resolve(&cfg, "/run/gp.sock", 1000, 1000, None); + assert!(ctx.service.is_some(), "service should match euid 1000"); + assert_eq!(ctx.service.unwrap().name, "test"); + } + + #[test] + fn no_service_for_other_uid() { + let cfg = config_with_service("/run/gp.sock", 1000); + let ctx = CallContext::resolve(&cfg, "/run/gp.sock", 4242, 4242, None); + assert!( + ctx.service.is_none(), + "no service should match a foreign uid" + ); + } + + #[test] + fn no_service_on_wrong_socket() { + let cfg = config_with_service("/run/gp.sock", 1000); + let ctx = CallContext::resolve(&cfg, "/run/other.sock", 1000, 1000, None); + assert!( + ctx.service.is_none(), + "default-socket service must not match a different socket" + ); + } +} diff --git a/rust/gssproxy-server/src/config.rs b/rust/gssproxy-server/src/config.rs new file mode 100644 index 000000000..449235aa7 --- /dev/null +++ b/rust/gssproxy-server/src/config.rs @@ -0,0 +1,761 @@ +//! `gssproxy.conf` parser and service/euid matching, ported from +//! `src/gp_config.c`. +//! +//! Parsing uses the `rust-ini` crate (which preserves key order and supports +//! repeated keys via `get_all`, needed for `cred_store`); the schema, defaults, +//! and validation rules mirror the C loader. + +use ini::Ini; + +/// Compiled-in default socket path (autotools `GP_SOCKET_NAME`). +pub const GP_SOCKET_NAME: &str = "/var/lib/gssproxy/default.sock"; + +/// `gp_service.mechs` bitmask bit for krb5 (`GP_CRED_KRB5`). +pub const GP_CRED_KRB5: u32 = 0x01; + +// GSS_C_* credential usage values (gssapi.h). +pub const GSS_C_BOTH: i32 = 0; +pub const GSS_C_INITIATE: i32 = 1; +pub const GSS_C_ACCEPT: i32 = 2; + +// Request/return flag bits, used by filter_flags/enforce_flags. +const GSS_C_DELEG_FLAG: u32 = 1; + +const DEFAULT_FILTERED_FLAGS: u32 = GSS_C_DELEG_FLAG; +const DEFAULT_ENFORCED_FLAGS: u32 = 0; +const DEFAULT_MIN_LIFETIME: u32 = 15; + +/// Flag names accepted in `filter_flags`/`enforce_flags`. The `INTEGRITIY` +/// spelling is intentionally preserved from the C table for compatibility. +const FLAG_NAMES: &[(&str, u32)] = &[ + ("DELEGATE", 0x01), + ("MUTUAL_AUTH", 0x02), + ("REPLAY_DETECT", 0x04), + ("SEQUENCE", 0x08), + ("CONFIDENTIALITY", 0x10), + ("INTEGRITIY", 0x20), + ("ANONYMOUS", 0x40), +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigError { + Parse(String), + Invalid(String), +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigError::Parse(m) => write!(f, "config parse error: {m}"), + ConfigError::Invalid(m) => write!(f, "invalid config: {m}"), + } + } +} + +impl std::error::Error for ConfigError {} + +type Result = std::result::Result; + +/// A single `[service/...]` section. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Service { + pub name: String, + pub euid: u32, + pub any_uid: bool, + pub allow_proto_trans: bool, + pub allow_const_deleg: bool, + pub allow_cc_sync: bool, + pub trusted: bool, + pub kernel_nfsd: bool, + pub impersonate: bool, + pub socket: Option, + pub selinux_context: Option, + pub cred_usage: i32, + pub filter_flags: u32, + pub enforce_flags: u32, + pub min_lifetime: u32, + pub program: Option, + pub mechs: u32, + pub krb5_principal: Option, + /// `cred_store` entries as `(key, value)`, split on the first `:`. + pub krb5_store: Vec<(String, String)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + pub socket_name: String, + pub num_workers: i32, + pub proxy_user: Option, + pub debug_level: i32, + pub services: Vec, +} + +impl Config { + /// A configuration with no services, listening on `socket_name`. Used as a + /// fallback where no config is available (e.g. tests); `match_service` + /// always returns `None`. + pub fn empty(socket_name: &str) -> Config { + Config { + socket_name: socket_name.to_string(), + num_workers: 0, + proxy_user: None, + debug_level: 0, + services: Vec::new(), + } + } + + /// Load and parse a configuration file, using `socket_name` as the default + /// socket for services that do not set their own. + pub fn parse_file(path: &std::path::Path, socket_name: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| ConfigError::Parse(format!("{}: {e}", path.display())))?; + Config::parse_str(&content, socket_name) + } + + /// Parse a configuration from INI text, using `socket_name` as the default + /// socket for services that do not set their own. + pub fn parse_str(content: &str, socket_name: &str) -> Result { + let ini = Ini::load_from_str(content).map_err(|e| ConfigError::Parse(e.to_string()))?; + + let mut cfg = Config { + socket_name: socket_name.to_string(), + num_workers: 0, + proxy_user: None, + debug_level: 0, + services: Vec::new(), + }; + + // Global [gssproxy] section. + if let Some(g) = ini.section(Some("gssproxy")) { + if let Some(v) = g.get("debug") + && gp_boolean_is_true(v) + && cfg.debug_level == 0 + { + cfg.debug_level = 1; + } + if let Some(v) = g.get("debug_level") + && let Ok(n) = v.trim().parse::() + { + cfg.debug_level = n; + } + if let Some(v) = g.get("run_as_user") { + cfg.proxy_user = Some(v.to_string()); + } + if let Some(v) = g.get("worker threads") + && let Ok(n) = v.trim().parse::() + { + cfg.num_workers = n; + } + } + + // Service sections, in file order. + for sec in ini.sections().flatten() { + let Some(name) = sec.strip_prefix("service/") else { + continue; + }; + if let Some(props) = ini.section(Some(sec)) + && let Some(svc) = parse_service(name, props)? + { + cfg.services.push(svc); + } + } + + if cfg.services.is_empty() { + return Err(ConfigError::Invalid( + "No service sections configured!".to_string(), + )); + } + + check_services(&cfg)?; + Ok(cfg) + } + + /// Match a connection to a service, mirroring `gp_creds_match_conn`: first + /// service whose euid (or `any_uid`), program, and socket all match wins. + /// + /// SELinux context matching is not yet plumbed through and is treated as + /// always-matching here. + pub fn match_service(&self, uid: u32, socket: &str, program: Option<&str>) -> Option<&Service> { + self.services.iter().find(|svc| { + if !svc.any_uid && svc.euid != uid { + return false; + } + if let Some(p) = &svc.program + && program != Some(p.as_str()) + { + return false; + } + match &svc.socket { + Some(s) => socket == s, + None => socket == self.socket_name, + } + }) + } +} + +/// Parse one service section. Returns `Ok(None)` when the service should be +/// silently ignored (no usable mechs), matching the C loader. +fn parse_service(name: &str, props: &ini::Properties) -> Result> { + let mut svc = Service { + name: name.to_string(), + euid: 0, + any_uid: false, + allow_proto_trans: false, + allow_const_deleg: false, + allow_cc_sync: false, + trusted: false, + kernel_nfsd: false, + impersonate: false, + socket: None, + selinux_context: None, + cred_usage: GSS_C_BOTH, + filter_flags: DEFAULT_FILTERED_FLAGS, + enforce_flags: DEFAULT_ENFORCED_FLAGS, + min_lifetime: DEFAULT_MIN_LIFETIME, + program: None, + mechs: 0, + krb5_principal: None, + krb5_store: Vec::new(), + }; + + // euid: integer, or a username resolved via getpwnam. Mandatory. + match props.get("euid") { + None => { + return Err(ConfigError::Invalid(format!( + "Option 'euid' is missing from [service/{name}]." + ))); + } + Some(v) => { + let v = v.trim(); + svc.euid = match v.parse::() { + Ok(n) => n, + Err(_) => lookup_uid(v).ok_or_else(|| { + ConfigError::Invalid(format!("Unknown euid user '{v}' in [service/{name}].")) + })?, + }; + } + } + + svc.any_uid = bool_opt(props, "allow_any_uid"); + svc.allow_proto_trans = bool_opt(props, "allow_protocol_transition"); + svc.allow_const_deleg = bool_opt(props, "allow_constrained_delegation"); + svc.allow_cc_sync = bool_opt(props, "allow_client_ccache_sync"); + svc.trusted = bool_opt(props, "trusted"); + svc.kernel_nfsd = bool_opt(props, "kernel_nfsd"); + svc.impersonate = bool_opt(props, "impersonate"); + + if let Some(v) = props.get("socket") { + svc.socket = Some(v.to_string()); + } + + // mechs: mandatory; only krb5 is supported. + let mechs = props.get("mechs").ok_or_else(|| { + ConfigError::Invalid(format!("Option 'mechs' is missing from [service/{name}].")) + })?; + for token in mechs.split([',', ' ']).filter(|t| !t.is_empty()) { + if token == "krb5" { + parse_krb5_cfg(&mut svc, props)?; + svc.mechs |= GP_CRED_KRB5; + } + // Unknown mechs are ignored (logged in the C daemon). + } + if svc.mechs == 0 { + // No usable mechs: ignore this service. + return Ok(None); + } + + if let Some(v) = props.get("selinux_context") { + svc.selinux_context = Some(v.to_string()); + } + + if let Some(v) = props.get("cred_usage") { + svc.cred_usage = match v.to_ascii_lowercase().as_str() { + "initiate" => GSS_C_INITIATE, + "accept" => GSS_C_ACCEPT, + "both" => GSS_C_BOTH, + _ => { + return Err(ConfigError::Invalid(format!( + "Invalid value '{v}' for cred_usage in [service/{name}]." + ))); + } + }; + } + + if let Some(v) = props.get("filter_flags") { + parse_flags(v, &mut svc.filter_flags)?; + } + if let Some(v) = props.get("enforce_flags") { + parse_flags(v, &mut svc.enforce_flags)?; + } + + if let Some(v) = props.get("program") { + svc.program = Some(v.to_string()); + } + + if let Some(v) = props.get("min_lifetime") + && let Ok(n) = v.trim().parse::() + && n >= 0 + { + svc.min_lifetime = n as u32; + } + + Ok(Some(svc)) +} + +fn parse_krb5_cfg(svc: &mut Service, props: &ini::Properties) -> Result<()> { + if let Some(v) = props.get("krb5_principal") { + svc.krb5_principal = Some(v.to_string()); + } + + // Reject the long-deprecated standalone keytab/ccache options. + for dep in ["krb5_keytab", "krb5_ccache", "krb5_client_keytab"] { + if props.get(dep).is_some() { + return Err(ConfigError::Invalid(format!( + "\"{dep}\" is deprecated, please use \"cred_store\"." + ))); + } + } + + for entry in props.get_all("cred_store").flat_map(|v| v.split(',')) { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let Some((key, value)) = entry.split_once(':') else { + return Err(ConfigError::Invalid(format!( + "Invalid cred_store value, no ':' separator found in [{entry}]." + ))); + }; + svc.krb5_store.push((key.to_string(), value.to_string())); + } + Ok(()) +} + +/// Port of `check_services`: program paths must be absolute and free of `|`, +/// and no two services may collide on (socket, program, selinux, euid/any_uid). +fn check_services(cfg: &Config) -> Result<()> { + let sock_of = |svc: &Service| -> String { + svc.socket + .clone() + .unwrap_or_else(|| cfg.socket_name.clone()) + }; + + for (i, isvc) in cfg.services.iter().enumerate() { + if let Some(prog) = &isvc.program { + if !prog.starts_with('/') { + return Err(ConfigError::Invalid( + "Program paths must be absolute!".to_string(), + )); + } + if prog.contains('|') { + return Err(ConfigError::Invalid( + "The character '|' is invalid in program paths!".to_string(), + )); + } + } + + for jsvc in &cfg.services[..i] { + if sock_of(isvc) != sock_of(jsvc) + || isvc.program != jsvc.program + || isvc.selinux_context != jsvc.selinux_context + { + continue; + } + if jsvc.any_uid { + return Err(ConfigError::Invalid(format!( + "{} sets allow_any_uid with the same socket, selinux_context, and program as {}!", + jsvc.name, isvc.name + ))); + } else if jsvc.euid == isvc.euid { + return Err(ConfigError::Invalid(format!( + "socket, selinux_context, euid, and program for {} and {} should not match!", + isvc.name, jsvc.name + ))); + } + } + } + Ok(()) +} + +/// `parse_flags`: tokens are `+NAME`/`-NAME` (names from `FLAG_NAMES`, or a +/// numeric value); a token without a `+`/`-` qualifier is ignored. +fn parse_flags(value: &str, storage: &mut u32) -> Result<()> { + for token in value.split([',', ' ']).filter(|t| !t.is_empty()) { + let (add, name) = match token.as_bytes()[0] { + b'+' => (true, &token[1..]), + b'-' => (false, &token[1..]), + _ => continue, + }; + let flagval = FLAG_NAMES + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(name)) + .map(|(_, v)| *v) + .or_else(|| parse_numeric_flag(name)); + let Some(flagval) = flagval else { + continue; + }; + if add { + *storage |= flagval; + } else { + *storage &= !flagval; + } + } + Ok(()) +} + +fn parse_numeric_flag(s: &str) -> Option { + let parsed = if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + u32::from_str_radix(hex, 16) + } else { + s.parse::() + }; + match parsed { + Ok(v) if v != 0 && v != u32::MAX => Some(v), + _ => None, + } +} + +fn bool_opt(props: &ini::Properties, key: &str) -> bool { + props.get(key).map(gp_boolean_is_true).unwrap_or(false) +} + +/// `gp_boolean_is_true`: true for `1`/`on`/`true`/`yes` (case-insensitive). +fn gp_boolean_is_true(s: &str) -> bool { + let s = s.trim(); + s.eq_ignore_ascii_case("1") + || s.eq_ignore_ascii_case("on") + || s.eq_ignore_ascii_case("true") + || s.eq_ignore_ascii_case("yes") +} + +fn lookup_uid(name: &str) -> Option { + let cname = std::ffi::CString::new(name).ok()?; + unsafe { + let pw = libc::getpwnam(cname.as_ptr()); + if pw.is_null() { + None + } else { + Some((*pw).pw_uid as u32) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOCK: &str = GP_SOCKET_NAME; + + #[test] + fn parses_basic_service() { + let cfg = Config::parse_str( + "[gssproxy]\n\ + debug_level = 2\n\ + [service/nfs-server]\n\ + mechs = krb5\n\ + euid = 0\n\ + cred_store = keytab:/etc/krb5.keytab\n\ + cred_store = ccache:FILE:/var/lib/gssproxy/clients/krb5cc_%U\n\ + trusted = yes\n\ + kernel_nfsd = yes\n", + SOCK, + ) + .unwrap(); + + assert_eq!(cfg.debug_level, 2); + assert_eq!(cfg.services.len(), 1); + let svc = &cfg.services[0]; + assert_eq!(svc.name, "nfs-server"); + assert_eq!(svc.euid, 0); + assert!(svc.trusted); + assert!(svc.kernel_nfsd); + assert_eq!(svc.mechs, GP_CRED_KRB5); + assert_eq!(svc.cred_usage, GSS_C_BOTH); + assert_eq!( + svc.krb5_store, + vec![ + ("keytab".to_string(), "/etc/krb5.keytab".to_string()), + ( + "ccache".to_string(), + "FILE:/var/lib/gssproxy/clients/krb5cc_%U".to_string() + ), + ] + ); + } + + #[test] + fn cred_store_value_keeps_colons() { + // The first colon splits key from value; later colons stay in the value. + let cfg = Config::parse_str( + "[service/x]\nmechs = krb5\neuid = 5\ncred_store = ccache:FILE:/tmp/cc\n", + SOCK, + ) + .unwrap(); + assert_eq!( + cfg.services[0].krb5_store, + vec![("ccache".to_string(), "FILE:/tmp/cc".to_string())] + ); + } + + #[test] + fn missing_euid_is_fatal() { + let err = Config::parse_str("[service/x]\nmechs = krb5\n", SOCK).unwrap_err(); + assert!(matches!(err, ConfigError::Invalid(_))); + } + + #[test] + fn missing_mechs_is_fatal() { + let err = Config::parse_str("[service/x]\neuid = 0\n", SOCK).unwrap_err(); + assert!(matches!(err, ConfigError::Invalid(_))); + } + + #[test] + fn no_services_is_fatal() { + let err = Config::parse_str("[gssproxy]\ndebug = true\n", SOCK).unwrap_err(); + assert!(matches!(err, ConfigError::Invalid(_))); + } + + #[test] + fn cred_usage_and_flags() { + let cfg = Config::parse_str( + "[service/x]\n\ + mechs = krb5\n\ + euid = 1000\n\ + cred_usage = initiate\n\ + filter_flags = +MUTUAL_AUTH, -DELEGATE\n\ + enforce_flags = +CONFIDENTIALITY\n", + SOCK, + ) + .unwrap(); + let svc = &cfg.services[0]; + assert_eq!(svc.cred_usage, GSS_C_INITIATE); + // default DELEG removed, MUTUAL added. + assert_eq!(svc.filter_flags, 0x02); + assert_eq!(svc.enforce_flags, 0x10); + } + + #[test] + fn duplicate_euid_socket_program_is_fatal() { + let err = Config::parse_str( + "[service/a]\nmechs = krb5\neuid = 0\n\ + [service/b]\nmechs = krb5\neuid = 0\n", + SOCK, + ) + .unwrap_err(); + assert!(matches!(err, ConfigError::Invalid(_))); + } + + #[test] + fn distinct_euid_ok_and_matches() { + let cfg = Config::parse_str( + "[service/a]\nmechs = krb5\neuid = 0\n\ + [service/b]\nmechs = krb5\neuid = 1000\n", + SOCK, + ) + .unwrap(); + assert_eq!(cfg.services.len(), 2); + assert_eq!(cfg.match_service(1000, SOCK, None).unwrap().name, "b"); + assert_eq!(cfg.match_service(0, SOCK, None).unwrap().name, "a"); + assert!(cfg.match_service(42, SOCK, None).is_none()); + } + + #[test] + fn any_uid_matches_anyone() { + let cfg = Config::parse_str( + "[service/any]\nmechs = krb5\neuid = 0\nallow_any_uid = yes\n", + SOCK, + ) + .unwrap(); + assert_eq!(cfg.match_service(12345, SOCK, None).unwrap().name, "any"); + } + + #[test] + fn service_without_mechs_token_is_ignored() { + // A mechs value with no recognized token yields no services -> fatal. + let err = Config::parse_str("[service/x]\neuid = 0\nmechs = bogus\n", SOCK).unwrap_err(); + assert!(matches!(err, ConfigError::Invalid(_))); + } + + #[test] + fn relative_program_is_fatal() { + let err = Config::parse_str( + "[service/x]\nmechs = krb5\neuid = 0\nprogram = relative/path\n", + SOCK, + ) + .unwrap_err(); + assert!(matches!(err, ConfigError::Invalid(_))); + } +} + +#[cfg(test)] +mod prop_tests { + use super::*; + use proptest::prelude::*; + + const SOCK: &str = GP_SOCKET_NAME; + + /// Random INI-ish text built from a vocabulary of real gssproxy config + /// lines plus arbitrary printable noise. Heavily weighted toward valid + /// lines so the parser's success paths are exercised, not just rejection. + fn config_text() -> impl Strategy { + let line = prop_oneof![ + Just("[gssproxy]".to_string()), + Just("[service/x]".to_string()), + Just("[service/y]".to_string()), + Just("debug = true".to_string()), + Just("debug_level = 3".to_string()), + Just("euid = 0".to_string()), + Just("euid = 1000".to_string()), + Just("euid = notanumber".to_string()), + Just("mechs = krb5".to_string()), + Just("mechs = bogus".to_string()), + Just("mechs =".to_string()), + Just("cred_usage = initiate".to_string()), + Just("cred_usage = accept".to_string()), + Just("cred_usage = bogus".to_string()), + Just("filter_flags = +DELEGATE -MUTUAL_AUTH 0x10".to_string()), + Just("enforce_flags = +CONFIDENTIALITY".to_string()), + Just("program = /usr/sbin/x".to_string()), + Just("program = rel|ative".to_string()), + Just("program = relative/path".to_string()), + Just("socket = /run/x.sock".to_string()), + Just("cred_store = keytab:/etc/krb5.keytab".to_string()), + Just("cred_store = nocolon".to_string()), + Just("allow_any_uid = yes".to_string()), + Just("trusted = yes".to_string()), + Just("min_lifetime = 42".to_string()), + Just("krb5_keytab = /deprecated".to_string()), + Just(String::new()), + "[ -~]{0,24}".prop_map(|s| s), + ]; + prop::collection::vec(line, 0..28).prop_map(|lines| lines.join("\n")) + } + + proptest! { + #![proptest_config(ProptestConfig { + cases: 512, + failure_persistence: None, + ..ProptestConfig::default() + })] + + /// Parsing arbitrary text never panics, is deterministic, and any Ok + /// config satisfies every structural guarantee the C loader enforces. + #[test] + fn parse_never_panics_and_holds_invariants(text in config_text()) { + let a = Config::parse_str(&text, SOCK); + let b = Config::parse_str(&text, SOCK); + prop_assert!(a == b, "parse is not deterministic"); + + if let Ok(cfg) = &a { + prop_assert!(!cfg.services.is_empty(), "Ok config must have services"); + for svc in &cfg.services { + // Only krb5 is supported; a service with no usable mech is + // dropped, so every surviving service has the krb5 bit. + prop_assert_eq!(svc.mechs, GP_CRED_KRB5); + prop_assert!( + [GSS_C_BOTH, GSS_C_INITIATE, GSS_C_ACCEPT].contains(&svc.cred_usage) + ); + if let Some(p) = &svc.program { + prop_assert!(p.starts_with('/'), "program path must be absolute"); + prop_assert!(!p.contains('|'), "program path must not contain '|'"); + } + // A kept service always matches a connection bearing its own + // (euid, socket, program) coordinates. + let eff = svc.socket.clone().unwrap_or_else(|| cfg.socket_name.clone()); + let m = cfg.match_service(svc.euid, &eff, svc.program.as_deref()); + prop_assert!(m.is_some(), "service must match its own coordinates"); + let chosen = m.unwrap(); + prop_assert!(chosen.any_uid || chosen.euid == svc.euid); + } + } + } + } + + // ---- match_service property (independent of the parser) ----------------- + + fn arb_service() -> impl Strategy { + ( + any::(), + any::(), + prop::option::of("/[a-z]{1,6}"), + prop::option::of("[a-z]{1,6}"), + prop::option::of("[a-z]{1,6}"), + ) + .prop_map( + |(euid, any_uid, program, socket, selinux_context)| Service { + name: "s".to_string(), + euid, + any_uid, + allow_proto_trans: false, + allow_const_deleg: false, + allow_cc_sync: false, + trusted: false, + kernel_nfsd: false, + impersonate: false, + socket, + selinux_context, + cred_usage: GSS_C_BOTH, + filter_flags: 0, + enforce_flags: 0, + min_lifetime: DEFAULT_MIN_LIFETIME, + program, + mechs: GP_CRED_KRB5, + krb5_principal: None, + krb5_store: Vec::new(), + }, + ) + } + + fn arb_config() -> impl Strategy { + ("/[a-z]{1,6}", prop::collection::vec(arb_service(), 0..5)).prop_map( + |(socket_name, services)| Config { + socket_name, + num_workers: 0, + proxy_user: None, + debug_level: 0, + services, + }, + ) + } + + proptest! { + #![proptest_config(ProptestConfig { + cases: 512, + failure_persistence: None, + ..ProptestConfig::default() + })] + + /// `match_service` is exactly "first service satisfying the euid/program/ + /// socket predicate", never panics, and the chosen service really + /// matches the query. + #[test] + fn match_service_is_first_match( + cfg in arb_config(), + uid in any::(), + socket in "[a-z/]{1,8}", + program in prop::option::of("[a-z]{1,6}"), + ) { + let pred = |svc: &Service| -> bool { + if !svc.any_uid && svc.euid != uid { + return false; + } + if let Some(p) = &svc.program + && program.as_deref() != Some(p.as_str()) { + return false; + } + match &svc.socket { + Some(s) => socket == *s, + None => socket == cfg.socket_name, + } + }; + + let got = cfg.match_service(uid, &socket, program.as_deref()); + let expected = cfg.services.iter().find(|s| pred(s)); + prop_assert_eq!(got, expected); + + if let Some(svc) = got { + prop_assert!(pred(svc), "returned service must satisfy the match predicate"); + } + } + } +} diff --git a/rust/gssproxy-server/src/conv.rs b/rust/gssproxy-server/src/conv.rs new file mode 100644 index 000000000..4d4a234f0 --- /dev/null +++ b/rust/gssproxy-server/src/conv.rs @@ -0,0 +1,269 @@ +//! Conversions between the gssx wire types (`gssproxy-proto`) and live GSSAPI +//! handles/values (`gssapi-sys`). Ported from `src/gp_conv.c`. + +use gssapi_sys::seal::CredHandle; +use gssapi_sys::sys; +use gssapi_sys::wrap::{self, Context, Cred, Name}; +use gssapi_sys::{consts, wrap::GssError}; +use gssproxy_proto::gssx::{GssxCred, GssxCredElement, GssxCtx, GssxName, GssxStatus, Opaque}; + +/// Exported-context representation, mirroring `enum exp_ctx_types` in +/// `gp_export.c`. The Linux lucid (kernel) form is not yet implemented. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExpCtxType { + Default, + Partial, + Lucid, +} + +/// `gp_get_exported_context_type`: inspect the `exported_context_type` option. +pub fn exported_context_type(options: &[gssproxy_proto::gssx::GssxOption]) -> ExpCtxType { + // sizeof() in the C macros includes the trailing NUL. + const KEY: &[u8] = b"exported_context_type\0"; + const LUCID: &[u8] = b"linux_lucid_v1\0"; + for opt in options { + if opt.option.as_slice() == KEY { + return if opt.value.as_slice() == LUCID { + ExpCtxType::Lucid + } else { + ExpCtxType::Partial + }; + } + } + ExpCtxType::Default +} + +/// `gssx_cred_usage` enum values (see `x-files/gss_proxy.x`). Note these differ +/// from the GSS_C_* usage values. +pub const GSSX_C_INITIATE: i32 = 1; +pub const GSSX_C_ACCEPT: i32 = 2; +pub const GSSX_C_BOTH: i32 = 3; + +// GSS_C_* credential usage values (gssapi.h). +const GSS_C_BOTH: i32 = 0; +const GSS_C_INITIATE: i32 = 1; +const GSS_C_ACCEPT: i32 = 2; + +/// `gp_conv_cred_usage_to_gssx`. +pub fn cred_usage_to_gssx(usage: i32) -> i32 { + match usage { + GSS_C_BOTH => GSSX_C_BOTH, + GSS_C_INITIATE => GSSX_C_INITIATE, + GSS_C_ACCEPT => GSSX_C_ACCEPT, + _ => 0, + } +} + +/// `gp_conv_gssx_to_cred_usage`. +pub fn gssx_to_cred_usage(usage: i32) -> i32 { + match usage { + GSSX_C_BOTH => GSS_C_BOTH, + GSSX_C_INITIATE => GSS_C_INITIATE, + GSSX_C_ACCEPT => GSS_C_ACCEPT, + _ => 0, + } +} + +/// `gp_conv_name_to_gssx`: serialize a live name into its wire form. +pub fn name_to_gssx(name: &Name) -> wrap::Result { + let (display, name_type) = name.display()?; + let mut out = GssxName { + display_name: Opaque::new(display), + name_type: Opaque::new(name_type), + ..Default::default() + }; + if let Some(exported) = name.export()? { + out.exported_name = Opaque::new(exported); + } + if let Some(composite) = name.export_composite()? { + out.exported_composite_name = Opaque::new(composite); + } + Ok(out) +} + +/// `gp_conv_gssx_to_name`: reconstruct a live name from its wire form. +/// +/// When a display name is present we (re-)import it so the original form is +/// preserved; otherwise we import the exported (mechanism) name blob. +pub fn gssx_to_name(g: &GssxName) -> wrap::Result { + if !g.display_name.is_empty() { + let name_type = if g.name_type.is_empty() { + None + } else { + Some(g.name_type.as_slice()) + }; + Name::import(g.display_name.as_slice(), name_type) + } else { + Name::import_exported(g.exported_name.as_slice()) + } +} + +/// `gp_export_gssx_cred`: serialize a live credential into its wire form, +/// sealing the opaque `cred_handle_reference` with the per-service key. +/// +/// Consumes `cred` (the C daemon releases it once serialized, staying +/// stateless). Mechanisms that fail `inquire_cred_by_mech` are skipped, exactly +/// like the C "skip any offender" loop. +pub fn export_gssx_cred(handle: &CredHandle, cred: Cred) -> wrap::Result { + let info = cred.inquire()?; + + let desired_name = match &info.name { + Some(n) => name_to_gssx(n)?, + None => GssxName::default(), + }; + + let mut elements = Vec::with_capacity(info.mechs.len()); + for mech in &info.mechs { + let by = match cred.inquire_by_mech(mech) { + Ok(b) => b, + Err(_) => continue, + }; + let mn = match &by.name { + Some(n) => name_to_gssx(n)?, + None => GssxName::default(), + }; + elements.push(GssxCredElement { + mn, + mech: Opaque::new(mech.clone()), + cred_usage: cred_usage_to_gssx(by.usage), + initiator_time_rec: by.initiator_lifetime as u64, + acceptor_time_rec: by.acceptor_lifetime as u64, + options: Vec::new(), + }); + } + + let token = cred.export_token()?; + let sealed = handle.seal(&token).map_err(seal_error)?; + + Ok(GssxCred { + desired_name, + elements, + cred_handle_reference: Opaque::new(sealed), + needs_release: false, + }) +} + +/// `gp_import_gssx_cred`: reconstruct a live credential from its wire form by +/// unsealing the opaque handle reference. A decrypt failure is treated as "no +/// credential" (`Ok(None)`), mirroring the C "allow re-issuance" behavior. +pub fn import_gssx_cred(handle: &CredHandle, cred: &GssxCred) -> wrap::Result> { + let sealed = cred.cred_handle_reference.as_slice(); + if sealed.is_empty() { + return Ok(None); + } + let token = match handle.unseal(sealed) { + Ok(t) => t, + Err(_) => return Ok(None), + }; + Ok(Some(Cred::import_token(&token)?)) +} + +/// `gp_export_ctx_id_to_gssx`: serialize a (possibly partial) security context. +/// +/// Consumes `ctx` (`gss_export_sec_context` invalidates the handle). For +/// `Partial`, `partial_mech` overrides the inquired mech and the context is +/// flagged locally-initiated/not-open, matching the C `EXP_CTX_PARTIAL` path. +/// The `Lucid` (kernel) form is not implemented. +pub fn export_gssx_ctx( + ctx: Context, + exp_type: ExpCtxType, + partial_mech: Option<&[u8]>, +) -> wrap::Result { + let mut out = GssxCtx { + needs_release: false, + ..Default::default() + }; + + match ctx.inquire() { + Ok(info) => { + out.mech = Opaque::new(info.mech); + if let Some(n) = &info.src_name { + out.src_name = name_to_gssx(n)?; + } + if let Some(n) = &info.targ_name { + out.targ_name = name_to_gssx(n)?; + } + out.lifetime = info.lifetime as u64; + out.ctx_flags = info.flags as u64; + out.locally_initiated = info.locally_initiated; + out.open = info.open; + } + Err(e) => { + // A partial (continue-needed) context may not inquire; carry on. + if exp_type != ExpCtxType::Partial { + return Err(e); + } + } + } + + match exp_type { + ExpCtxType::Partial => { + out.mech = partial_mech + .map(|m| Opaque::new(m.to_vec())) + .unwrap_or_default(); + out.locally_initiated = true; + out.open = false; + out.exported_context_token = Opaque::new(ctx.export()?); + } + ExpCtxType::Default => { + out.exported_context_token = Opaque::new(ctx.export()?); + } + ExpCtxType::Lucid => { + return Err(GssError { + major: consts::GSS_S_FAILURE, + minor: libc::ENOSYS as u32, + messages: vec!["linux_lucid_v1 context export is not implemented".to_string()], + }); + } + } + + Ok(out) +} + +/// `gp_import_gssx_to_ctx_id` (DEFAULT type): reconstruct a live context from +/// its exported token. +pub fn import_gssx_ctx(ctx: &GssxCtx) -> wrap::Result { + Context::import(ctx.exported_context_token.as_slice()) +} + +fn seal_error(e: gssapi_sys::seal::SealError) -> GssError { + GssError { + major: consts::GSS_S_FAILURE, + minor: 0, + messages: vec![format!("cred handle sealing failed: {e}")], + } +} + +/// `gp_conv_status_to_gssx`: render a major/minor status pair (for an optional +/// mechanism OID) into a `gssx_status`. +pub fn status_to_gssx(major: u32, minor: u32, mech: Option<&[u8]>) -> GssxStatus { + let mut status = GssxStatus { + major_status: major as u64, + minor_status: minor as u64, + ..Default::default() + }; + if let Some(m) = mech + && !m.is_empty() + { + status.mech = Opaque::new(m.to_vec()); + } + if major != 0 { + status.major_status_string = status_string(major, sys::GSS_C_GSS_CODE as i32, mech); + } + if minor != 0 { + status.minor_status_string = status_string(minor, sys::GSS_C_MECH_CODE as i32, mech); + } + status +} + +/// Render a status code into a NUL-terminated utf8string buffer, matching the +/// `len = strlen + 1` convention `gp_conv.c` uses on the wire. +fn status_string(code: u32, code_type: i32, mech: Option<&[u8]>) -> Opaque { + let parts = wrap::display_status(code, code_type, mech); + if parts.is_empty() { + return Opaque::default(); + } + let mut bytes = parts.join(", ").into_bytes(); + bytes.push(0); + Opaque::new(bytes) +} diff --git a/rust/gssproxy-server/src/creds.rs b/rust/gssproxy-server/src/creds.rs new file mode 100644 index 000000000..2a8f2fdbd --- /dev/null +++ b/rust/gssproxy-server/src/creds.rs @@ -0,0 +1,613 @@ +//! Credential acquisition, ported from `src/gp_creds.c` and the krb5 paths of +//! `gp_add_krb5_creds` / `gp_get_cred_environment` / `gp_check_cred`. +//! +//! Only the krb5 mechanism is supported. Both the non-impersonation `ACQ_NORMAL` +//! path and the s4u2self impersonation / constrained-delegation paths +//! (`impersonate = yes` services and `ACQ_IMPNAME` acquisitions) are +//! implemented, mirroring the impersonation sub-blocks of `gp_add_krb5_creds` +//! and `gp_cred_allowed`. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use gssapi_sys::seal::CredHandle; +use gssapi_sys::wrap::{self, Cred, Name}; +use gssapi_sys::{consts, sys}; +use gssproxy_proto::gssx::GssxName; + +/// `GSS_C_INDEFINITE` (`gssapi.h`); bindgen emits this macro as the unused +/// `_GSS_C_INDEFINITE`, so we restate the value here. +const GSS_C_INDEFINITE: u32 = 0xffff_ffff; + +use crate::call::CallContext; +use crate::config::{GP_CRED_KRB5, Service}; +use crate::conv; + +// GSS_C_* credential usage values (gssapi.h). +const GSS_C_BOTH: i32 = 0; +const GSS_C_INITIATE: i32 = 1; +const GSS_C_ACCEPT: i32 = 2; + +/// Acquisition type, mirroring `enum gp_aqcuire_cred_type`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcquireType { + Normal, + ImpName, +} + +/// A GSSAPI major/minor pair for failed acquisitions. +#[derive(Debug, Clone)] +pub struct AcqError { + pub major: u32, + pub minor: u32, +} + +impl AcqError { + fn new(major: u32, minor: u32) -> AcqError { + AcqError { major, minor } + } +} + +/// Per-service sealing-key registry. Handles are derived lazily on first use and +/// cached for the daemon's lifetime (keyed by service name), matching the C +/// daemon's one-shot `gp_service_get_creds_handle`. +#[derive(Default)] +pub struct CredsRegistry { + handles: Mutex>>, +} + +impl CredsRegistry { + pub fn new() -> CredsRegistry { + CredsRegistry::default() + } + + /// Return (deriving + caching if necessary) the sealing handle for a service. + pub fn get_or_init(&self, svc: &Service) -> Option> { + let mut map = self.handles.lock().unwrap(); + if let Some(h) = map.get(&svc.name) { + return Some(h.clone()); + } + let keytab = svc + .krb5_store + .iter() + .find(|(k, _)| k == "keytab") + .map(|(_, v)| v.as_str()); + let handle = Arc::new(CredHandle::new(keytab).ok()?); + map.insert(svc.name.clone(), handle.clone()); + Some(handle) + } +} + +/// `gp_creds_allowed_mech`: whether the service permits `mech` (only krb5 is +/// supported). +pub fn allowed_mech(svc: &Service, mech: &[u8]) -> bool { + (svc.mechs & GP_CRED_KRB5) != 0 && mech == consts::KRB5_MECH_OID +} + +/// `try_impersonate`: whether this acquisition would require impersonation +/// (s4u2self), which this port does not implement. +fn try_impersonate(svc: &Service, cred_usage: i32, acquire_type: AcquireType) -> bool { + if acquire_type == AcquireType::ImpName && (svc.allow_proto_trans || svc.trusted) { + return true; + } + if svc.impersonate && (cred_usage == GSS_C_INITIATE || cred_usage == GSS_C_BOTH) { + return true; + } + false +} + +/// `get_formatted_string`: expand `%U` (target uid), `%u` (target username) and +/// `%%` in a cred_store value. +fn get_formatted_string(orig: &str, target_uid: u32) -> Option { + let mut out = String::with_capacity(orig.len()); + let mut chars = orig.chars().peekable(); + let mut username: Option = None; + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + match chars.next() { + Some('%') => out.push('%'), + Some('U') => out.push_str(&target_uid.to_string()), + Some('u') => { + let u = match &username { + Some(u) => u.clone(), + None => { + let u = uid_to_name(target_uid)?; + username = Some(u.clone()); + u + } + }; + out.push_str(&u); + } + _ => return None, + } + } + Some(out) +} + +fn uid_to_name(uid: u32) -> Option { + unsafe { + let pw = libc::getpwuid(uid as libc::uid_t); + if pw.is_null() { + return None; + } + let name = (*pw).pw_name; + if name.is_null() { + return None; + } + Some( + std::ffi::CStr::from_ptr(name) + .to_string_lossy() + .into_owned(), + ) + } +} + +/// `atol`-style leading-integer parse of a uid display name. +fn parse_leading_uid(s: &[u8]) -> u32 { + let mut v: u32 = 0; + for &b in s { + if b.is_ascii_digit() { + v = v.wrapping_mul(10).wrapping_add((b - b'0') as u32); + } else if v == 0 && (b == b' ' || b == b'\t') { + continue; + } else { + break; + } + } + v +} + +struct CredEnv { + requested_name: Option, + cred_usage: i32, + cred_store: Vec<(String, String)>, + /// Name of the private `MEMORY:` ccache this environment created (if any), + /// so the caller can destroy it once the acquired credential is no longer + /// needed. Mirrors the C daemon's per-request `destroy_callback`. + mem_ccache: Option, +} + +/// RAII guard that destroys a per-request `MEMORY:` credential cache on drop, +/// mirroring `safe_free_mem_ccache` in `gp_creds.c`. Without this, a later +/// acquisition reusing the same thread-keyed ccache name would observe a stale +/// principal and fail with `KG_CCACHE_NOMATCH`. +pub struct MemCcacheGuard(String); + +impl Drop for MemCcacheGuard { + fn drop(&mut self) { + wrap::destroy_ccache(&self.0); + } +} + +/// `gp_get_cred_environment` (krb5 path, impersonation sub-block omitted). On +/// error returns the C errno value, which the caller maps to `GSS_S_FAILURE`. +fn get_cred_environment( + ctx: &CallContext, + svc: &Service, + desired_name: Option<&GssxName>, + cred_usage: i32, +) -> Result { + let mut target_uid = ctx.uid; + let mut usage = cred_usage; + let mut user_requested = false; + let mut use_service_keytab = false; + let mut requested_name: Option = None; + + if svc.cred_usage != GSS_C_BOTH { + if usage == GSS_C_BOTH { + usage = svc.cred_usage; + } else if svc.cred_usage != usage { + return Err(libc::EACCES); + } + } + + if let Some(dn) = desired_name { + let name_type = dn.name_type.as_slice(); + if svc.trusted + && svc.euid == target_uid + && (name_type == consts::NT_STRING_UID_NAME_OID + || name_type == consts::NT_MACHINE_UID_NAME_OID) + { + target_uid = parse_leading_uid(dn.display_name.as_slice()); + user_requested = true; + } else { + if svc.euid != target_uid { + user_requested = true; + } else { + use_service_keytab = true; + } + requested_name = Some(conv::gssx_to_name(dn).map_err(|_| libc::EINVAL)?); + } + } else if svc.trusted && svc.euid == target_uid { + use_service_keytab = true; + } else if svc.euid != target_uid { + user_requested = true; + } + + // Impersonation case (only for initiation): use the service keytab to + // acquire the initial credential, then make the s4u2self dance toward the + // target user, identified by uid. Mirrors the `user_requested` block in + // `gp_get_cred_environment`. + if user_requested && try_impersonate(svc, usage, AcquireType::Normal) { + use_service_keytab = true; + let username = uid_to_name(target_uid).ok_or(libc::ENOENT)?; + // C imports with GSS_C_NT_USER_NAME and length == strlen(str) (no NUL). + requested_name = Some( + Name::import(username.as_bytes(), Some(consts::NT_USER_NAME_OID)) + .map_err(|_| libc::ENOMEM)?, + ); + } + + if use_service_keytab + && requested_name.is_none() + && let Some(principal) = &svc.krb5_principal + { + // The C daemon imports with length strlen+1 (trailing NUL included). + let mut bytes = principal.clone().into_bytes(); + bytes.push(0); + requested_name = Some( + Name::import(&bytes, Some(consts::KRB5_NT_PRINCIPAL_NAME_OID)) + .map_err(|_| libc::EINVAL)?, + ); + } + + if svc.krb5_store.is_empty() { + return Ok(CredEnv { + requested_name, + cred_usage: usage, + cred_store: Vec::new(), + mem_ccache: None, + }); + } + + let mut store: Vec<(String, String)> = Vec::with_capacity(svc.krb5_store.len() + 2); + let mut k_idx: Option = None; + let mut ck_idx: Option = None; + let mut cc_idx: Option = None; + for (key, value) in &svc.krb5_store { + let formatted = get_formatted_string(value, target_uid).ok_or(libc::ENOMEM)?; + match key.as_str() { + "client_keytab" => ck_idx = Some(store.len()), + "keytab" => k_idx = Some(store.len()), + "ccache" => cc_idx = Some(store.len()), + _ => {} + } + store.push((key.clone(), formatted)); + } + + if use_service_keytab { + match k_idx { + None => { + // A service may legitimately define only the client keytab. + if ck_idx.is_some() { + return Ok(CredEnv { + requested_name, + cred_usage: usage, + cred_store: store, + mem_ccache: None, + }); + } + return Err(libc::EINVAL); + } + Some(k) => { + let keytab_value = store[k].1.clone(); + match ck_idx { + Some(ck) => store[ck].1 = keytab_value, + None => store.push(("client_keytab".to_string(), keytab_value)), + } + } + } + } + + let mem_ccache = ensure_segregated_ccache(&mut store, cc_idx); + + Ok(CredEnv { + requested_name, + cred_usage: usage, + cred_store: store, + mem_ccache, + }) +} + +/// `ensure_segregated_ccache`: when no ccache was configured, add a private +/// in-memory one so concurrent acquisitions do not collide. Returns the ccache +/// name when one was created, so the caller can destroy it after use (the C +/// daemon registers a per-request `destroy_callback` for the same reason). +fn ensure_segregated_ccache( + store: &mut Vec<(String, String)>, + cc_idx: Option, +) -> Option { + if cc_idx.is_some() { + return None; + } + let tid = unsafe { libc::syscall(libc::SYS_gettid) }; + let name = format!("MEMORY:internal_{tid}"); + store.push(("ccache".to_string(), name.clone())); + Some(name) +} + +/// `gp_check_cred`: validate an input credential. `Ok(())` means reuse it. +fn check_cred( + svc: &Service, + in_cred: &Cred, + desired_name: Option<&GssxName>, + cred_usage: i32, +) -> Result<(), u32> { + let info = in_cred.inquire().map_err(|e| e.major)?; + + let present = info + .mechs + .iter() + .any(|m| m.as_slice() == consts::KRB5_MECH_OID); + if !present { + return Err(consts::GSS_S_CRED_UNAVAIL); + } + + if let Some(dn) = desired_name { + let req = conv::gssx_to_name(dn).map_err(|e| e.major)?; + let check = info.name.as_ref().ok_or(consts::GSS_S_CRED_UNAVAIL)?; + if !req.compare(check).map_err(|e| e.major)? { + return Err(consts::GSS_S_CRED_UNAVAIL); + } + } + + match cred_usage { + GSS_C_ACCEPT if info.usage == GSS_C_INITIATE => return Err(consts::GSS_S_NO_CRED), + GSS_C_INITIATE if info.usage == GSS_C_ACCEPT => return Err(consts::GSS_S_NO_CRED), + GSS_C_BOTH if info.usage != GSS_C_BOTH => return Err(consts::GSS_S_NO_CRED), + _ => {} + } + + if info.lifetime == 0 { + return Err(consts::GSS_S_CREDENTIALS_EXPIRED); + } + if svc.min_lifetime != 0 && info.lifetime < svc.min_lifetime { + return Err(consts::GSS_S_CREDENTIALS_EXPIRED); + } + Ok(()) +} + +/// `gp_cred_allowed`: decide whether `cred` may be used against `target`. +/// +/// Trusted / impersonate / const-deleg services are always allowed. For other +/// services we inspect the credential for an impersonator entry (constrained +/// delegation) via `gss_inquire_cred_by_oid`; if one is present we reject the +/// use unless the target *is* the impersonator itself (the "self" case). +pub fn cred_allowed(svc: &Service, cred: Option<&Cred>, target: &Name) -> Result<(), u32> { + let Some(cred) = cred else { + return Err(consts::GSS_S_CRED_UNAVAIL); + }; + if svc.trusted || svc.impersonate || svc.allow_const_deleg { + return Ok(()); + } + + let impersonator = get_impersonator_name(cred)?; + + // No impersonator entry: a normal credential, always allowed. + let Some(impersonator) = impersonator else { + return Ok(()); + }; + + // An impersonator entry is present: only allowed when the target is the + // impersonator itself (otherwise it is unauthorized constrained delegation). + check_impersonator_name(target, &impersonator) +} + +/// `get_impersonator_name`: return the impersonator principal recorded on a +/// (constrained-delegation) credential, or `None` for a normal credential. +fn get_impersonator_name(cred: &Cred) -> Result>, u32> { + let bufs = cred + .inquire_by_oid(consts::KRB5_GET_CRED_IMPERSONATOR_OID) + .map_err(|e| e.major)?; + match bufs.into_iter().next() { + Some(b) if !b.is_empty() => Ok(Some(b)), + _ => Ok(None), + } +} + +/// `check_impersonator_name`: canonicalize `target` to krb5, render its display +/// name, and compare it byte-for-byte against `impersonator`. Returns `Ok(())` +/// on a match ("self"), `GSS_S_UNAUTHORIZED` otherwise. +fn check_impersonator_name(target: &Name, impersonator: &[u8]) -> Result<(), u32> { + let canon = target + .canonicalize(consts::KRB5_MECH_OID) + .map_err(|e| e.major)?; + let (display, _name_type) = canon.display().map_err(|e| e.major)?; + if display == impersonator { + Ok(()) + } else { + Err(consts::GSS_S_UNAUTHORIZED) + } +} + +/// `gp_add_krb5_creds` (krb5, non-impersonation). Returns the freshly acquired +/// credential (or `None` when the valid input credential should be reused +/// as-is) together with a guard that destroys the private per-request `MEMORY:` +/// ccache once dropped. The caller must hold the guard until the credential has +/// finished being used (e.g. through `init`/`accept_sec_context` or the cred +/// export), then drop it. +pub fn add_krb5_creds( + ctx: &CallContext, + svc: &Service, + acquire_type: AcquireType, + in_cred: Option<&Cred>, + desired_name: Option<&GssxName>, + cred_usage: i32, +) -> Result<(Option, Option), AcqError> { + if let Some(inc) = in_cred + && acquire_type != AcquireType::ImpName + { + match check_cred(svc, inc, desired_name, cred_usage) { + Ok(()) => return Ok((None, None)), + Err(maj) + if maj == consts::GSS_S_CREDENTIALS_EXPIRED + || maj == consts::GSS_S_NO_CRED + || maj == consts::GSS_S_DEFECTIVE_CREDENTIAL => {} + Err(_) => return Err(AcqError::new(consts::GSS_S_CRED_UNAVAIL, 0)), + } + } + + let env = if acquire_type == AcquireType::Normal { + get_cred_environment(ctx, svc, desired_name, cred_usage) + .map_err(|e| AcqError::new(consts::GSS_S_CRED_UNAVAIL, e as u32))? + } else { + // ACQ_IMPNAME: just resolve the requested name; the cred store is unused. + let requested_name = match desired_name { + Some(dn) => Some(conv::gssx_to_name(dn).map_err(|e| AcqError::new(e.major, e.minor))?), + None => None, + }; + CredEnv { + requested_name, + cred_usage, + cred_store: Vec::new(), + mem_ccache: None, + } + }; + + // The private MEMORY ccache (if any) must live until the acquired + // credential has finished being used; hand a guard back to the caller. + let guard = env.mem_ccache.map(MemCcacheGuard); + let usage = env.cred_usage; + let mechs: [&[u8]; 1] = [consts::KRB5_MECH_OID]; + + if !try_impersonate(svc, usage, acquire_type) { + let cred = wrap::acquire_cred_from( + env.requested_name.as_ref(), + GSS_C_INDEFINITE, + &mechs, + usage, + &env.cred_store, + ) + .map_err(|e| AcqError::new(e.major, e.minor))?; + return Ok((Some(cred), guard)); + } + + let cred = impersonate_acquire( + svc, + acquire_type, + in_cred, + env.requested_name.as_ref(), + usage, + &env.cred_store, + &mechs, + )?; + Ok((Some(cred), guard)) +} + +/// The s4u2self / constrained-delegation acquisition, ported from the +/// `impersonation` branch of `gp_add_krb5_creds`. Returns the credential to use +/// for the impersonated user. +#[allow(clippy::too_many_arguments)] +fn impersonate_acquire( + svc: &Service, + acquire_type: AcquireType, + in_cred: Option<&Cred>, + req_name: Option<&Name>, + cred_usage: i32, + cred_store: &[(String, String)], + mechs: &[&[u8]], +) -> Result { + // `input_cred` is the credential we impersonate *with*: for ACQ_NORMAL the + // service ("impersonator") credential we acquire here, for ACQ_IMPNAME the + // caller-supplied credential. `impersonator_owned` keeps the ACQ_NORMAL + // credential alive for the rest of the function. + let impersonator_owned: Option; + let input_cred: &Cred = match acquire_type { + AcquireType::Normal => { + let host_principal = match &svc.krb5_principal { + Some(p) => { + // C imports with length strlen+1 (trailing NUL included). + let mut bytes = p.clone().into_bytes(); + bytes.push(0); + Some( + Name::import(&bytes, Some(consts::KRB5_NT_PRINCIPAL_NAME_OID)) + .map_err(|e| AcqError::new(e.major, e.minor))?, + ) + } + None => None, + }; + + let impersonator = wrap::acquire_cred_from( + host_principal.as_ref(), + GSS_C_INDEFINITE, + mechs, + GSS_C_BOTH, + cred_store, + ) + .map_err(|e| AcqError::new(e.major, e.minor))?; + + // If the impersonator credential already names the requested client, + // we do not need to impersonate (and MIT errors on self-S4U2Self): + // acquire the client credential directly and return it. + if let Some(req) = req_name + && let Ok(info) = impersonator.inquire() + && let Some(comp) = &info.name + && req.compare(comp).unwrap_or(false) + && let Ok(user_cred) = wrap::acquire_cred_from( + Some(req), + GSS_C_INDEFINITE, + mechs, + cred_usage, + cred_store, + ) + { + return Ok(user_cred); + } + // Fall through on failure, matching the C daemon. + + impersonator_owned = Some(impersonator); + impersonator_owned.as_ref().unwrap() + } + AcquireType::ImpName => { + // No impersonator credential is acquired on this path. + in_cred.ok_or_else(|| AcqError::new(consts::GSS_S_FAILURE, libc::EFAULT as u32))? + } + }; + + // The S4U2Self target is the impersonator/input credential's own name. + let target_name = input_cred + .inquire() + .map_err(|e| AcqError::new(e.major, e.minor))? + .name + .ok_or_else(|| AcqError::new(consts::GSS_S_FAILURE, 0))?; + + // S4U2Self: obtain a credential for the requested user. + let user_cred = wrap::acquire_cred_impersonate_name( + input_cred, + req_name, + GSS_C_INDEFINITE, + mechs, + GSS_C_INITIATE, + ) + .map_err(|e| AcqError::new(e.major, e.minor))?; + + if acquire_type == AcquireType::ImpName { + // For ACQ_IMPNAME we are done: hand back the impersonated credential. + return Ok(user_cred); + } + + // Acquire credentials for the impersonated user "to self": initiate a + // context with the impersonated credential toward the impersonator, then + // accept it to extract the (constrained-delegation) credential. + let init = wrap::init_sec_context( + Some(&user_cred), + None, + &target_name, + consts::KRB5_MECH_OID, + sys::GSS_C_REPLAY_FLAG | sys::GSS_C_SEQUENCE_FLAG, + GSS_C_INDEFINITE, + None, + &[], + ) + .map_err(|e| AcqError::new(e.major, e.minor))?; + + let accept = wrap::accept_sec_context(None, Some(input_cred), &init.output, None, true) + .map_err(|e| AcqError::new(e.major, e.minor))?; + + accept + .delegated_cred + .ok_or_else(|| AcqError::new(consts::GSS_S_FAILURE, 0)) +} diff --git a/rust/gssproxy-server/src/dispatch.rs b/rust/gssproxy-server/src/dispatch.rs new file mode 100644 index 000000000..f8f0a4bce --- /dev/null +++ b/rust/gssproxy-server/src/dispatch.rs @@ -0,0 +1,163 @@ +//! RPC envelope handling and per-procedure routing. +//! +//! Decodes the SunRPC call envelope, validates program/version/procedure +//! (emitting the standard RPC accept-status replies on mismatch), decodes the +//! procedure argument, runs the handler, and encodes the reply body (envelope + +//! result) ready to be record-marked by the caller. + +use gssproxy_proto::proc::*; +use gssproxy_proto::rpc::{GSSPROXY, GSSPROXYVERS, Message, MismatchInfo, OpaqueAuth, ReplyBody}; +use gssproxy_proto::xdr::{Xdr, XdrDecoder, XdrEncoder, XdrResult}; + +use crate::call::CallContext; +use crate::handlers; + +// RPC accept_stat values (rpc.h). +const PROG_UNAVAIL: i32 = 1; +const PROC_UNAVAIL: i32 = 3; +const GARBAGE_ARGS: i32 = 4; + +/// Process one decoded request body, returning the reply body to frame, or +/// `None` if the message isn't a well-formed call we should answer. +pub fn handle_request(ctx: &CallContext, body: &[u8]) -> Option> { + let mut d = XdrDecoder::new(body); + let msg = Message::decode(&mut d).ok()?; + if !msg.is_call { + tracing::debug!("dropping non-call RPC message"); + return None; + } + let call = msg.call.as_ref()?; + let xid = msg.xid; + + if call.prog != GSSPROXY { + tracing::warn!(xid, prog = call.prog, "unknown RPC program"); + return Some(reply_accept_other(xid, PROG_UNAVAIL)); + } + if call.vers != GSSPROXYVERS { + tracing::warn!(xid, vers = call.vers, "RPC program version mismatch"); + return Some(reply_prog_mismatch(xid)); + } + let proc = match GssxProc::from_u32(call.proc_num) { + Some(p) => p, + None => { + tracing::warn!(xid, proc = call.proc_num, "unknown gssproxy procedure"); + return Some(reply_accept_other(xid, PROC_UNAVAIL)); + } + }; + + // One span per request: every handler log line is attributed to the peer, + // the procedure, and the xid without each handler re-logging that context. + let span = tracing::debug_span!( + "rpc", + ?proc, + xid, + uid = ctx.uid, + pid = ctx.pid, + service = ctx + .service + .as_ref() + .map(|s| s.name.as_str()) + .unwrap_or("") + ); + let _enter = span.enter(); + tracing::debug!(req_len = body.len(), "handling request"); + + match encode_proc_reply(ctx, proc, xid, &mut d) { + Ok(bytes) => { + tracing::debug!(res_len = bytes.len(), "request handled"); + Some(bytes) + } + // Argument failed to decode: RPC GARBAGE_ARGS. + Err(e) => { + tracing::warn!(error = %e, "failed to decode procedure arguments (GARBAGE_ARGS)"); + Some(reply_accept_other(xid, GARBAGE_ARGS)) + } + } +} + +macro_rules! run { + ($ctx:expr_2021, $d:expr_2021, $xid:expr_2021, $arg:ty, $handler:path) => {{ + let arg = <$arg as Xdr>::decode($d)?; + Ok(gssproxy_proto::encode_reply($xid, &$handler($ctx, arg))) + }}; +} + +fn encode_proc_reply( + ctx: &CallContext, + proc: GssxProc, + xid: u32, + d: &mut XdrDecoder, +) -> XdrResult> { + match proc { + GssxProc::IndicateMechs => run!(ctx, d, xid, ArgIndicateMechs, handlers::indicate_mechs), + GssxProc::GetCallContext => { + run!(ctx, d, xid, ArgGetCallContext, handlers::get_call_context) + } + GssxProc::ImportAndCanonName => { + run!( + ctx, + d, + xid, + ArgImportAndCanonName, + handlers::import_and_canon_name + ) + } + GssxProc::ExportCred => run!(ctx, d, xid, ArgExportCred, handlers::export_cred), + GssxProc::ImportCred => run!(ctx, d, xid, ArgImportCred, handlers::import_cred), + GssxProc::AcquireCred => run!(ctx, d, xid, ArgAcquireCred, handlers::acquire_cred), + GssxProc::StoreCred => run!(ctx, d, xid, ArgStoreCred, handlers::store_cred), + GssxProc::InitSecContext => { + run!(ctx, d, xid, ArgInitSecContext, handlers::init_sec_context) + } + GssxProc::AcceptSecContext => { + run!( + ctx, + d, + xid, + ArgAcceptSecContext, + handlers::accept_sec_context + ) + } + GssxProc::ReleaseHandle => run!(ctx, d, xid, ArgReleaseHandle, handlers::release_handle), + GssxProc::GetMic => run!(ctx, d, xid, ArgGetMic, handlers::get_mic), + GssxProc::VerifyMic => run!(ctx, d, xid, ArgVerifyMic, handlers::verify_mic), + GssxProc::Wrap => run!(ctx, d, xid, ArgWrap, handlers::wrap_msg), + GssxProc::Unwrap => run!(ctx, d, xid, ArgUnwrap, handlers::unwrap_msg), + GssxProc::WrapSizeLimit => run!(ctx, d, xid, ArgWrapSizeLimit, handlers::wrap_size_limit), + } +} + +fn reply_accept_other(xid: u32, status: i32) -> Vec { + encode_reply_body( + xid, + ReplyBody::AcceptedOther { + verf: OpaqueAuth::none(), + status, + }, + ) +} + +fn reply_prog_mismatch(xid: u32) -> Vec { + encode_reply_body( + xid, + ReplyBody::ProgMismatch { + verf: OpaqueAuth::none(), + info: MismatchInfo { + low: GSSPROXYVERS, + high: GSSPROXYVERS, + }, + }, + ) +} + +fn encode_reply_body(xid: u32, reply: ReplyBody) -> Vec { + let msg = Message { + xid, + is_call: false, + call: None, + reply: Some(reply), + }; + let mut e = XdrEncoder::new(); + msg.encode(&mut e); + e.into_bytes() +} diff --git a/rust/gssproxy-server/src/extract.rs b/rust/gssproxy-server/src/extract.rs new file mode 100644 index 000000000..2a1c694d0 --- /dev/null +++ b/rust/gssproxy-server/src/extract.rs @@ -0,0 +1,95 @@ +//! `--extract-ccache`: decrypt a gssproxy-sealed credential stashed in a krb5 +//! ccache and store it as a usable credential. Port of `src/extract_ccache.c`. +//! +//! gssproxy stores the encrypted credential blob as the "ticket" of a credential +//! whose server principal is the well-known [`GPKRB_SRV_NAME`]. The blob is an +//! XDR-encoded `gssx_cred` whose `cred_handle_reference` is the per-service +//! sealed `gss_export_cred` token. We decode it, unseal it with the default +//! keytab's sealing key, re-import the credential, and store it (into `dest` or +//! the default ccache). + +use std::ffi::CString; +use std::ptr; + +use gssapi_sys::krb5; +use gssapi_sys::seal::CredHandle; +use gssapi_sys::wrap::{self, Cred}; +use gssproxy_proto::gssx::GssxCred; +use gssproxy_proto::{Xdr, XdrDecoder}; + +/// The special server principal under which gssproxy stores the encrypted +/// credential in a ccache (`GPKRB_SRV_NAME` in `src/gp_common.h`). +const GPKRB_SRV_NAME: &str = "Encrypted/Credentials/v1@X-GSSPROXY:"; + +/// Extract the gssproxy-encrypted credential from `ccache_name` and store it in +/// `dest` (or the default ccache when `None`). +pub fn extract_ccache(ccache_name: &str, dest: Option<&str>) -> Result<(), String> { + let blob = read_sealed_blob(ccache_name)?; + + let mut d = XdrDecoder::new(&blob); + let xcred = GssxCred::decode(&mut d).map_err(|e| format!("decoding gssx_cred: {e:?}"))?; + + let handle = CredHandle::new(None).map_err(|e| format!("deriving sealing key: {e}"))?; + let token = handle + .unseal(xcred.cred_handle_reference.as_slice()) + .map_err(|e| format!("decrypting credential handle: {e}"))?; + + let cred = Cred::import_token(&token).map_err(|e| format!("importing credential: {e}"))?; + wrap::store_cred_into(&cred, dest).map_err(|e| format!("storing credential: {e}"))?; + Ok(()) +} + +/// Read the XDR-encoded `gssx_cred` blob gssproxy stashes in the ccache as the +/// ticket of a credential for the [`GPKRB_SRV_NAME`] server principal. +fn read_sealed_blob(ccache_name: &str) -> Result, String> { + unsafe { + let mut ctx: krb5::krb5_context = ptr::null_mut(); + if krb5::krb5_init_context(&mut ctx) != 0 { + return Err("krb5_init_context failed".into()); + } + let r = read_sealed_blob_inner(ctx, ccache_name); + krb5::krb5_free_context(ctx); + r + } +} + +unsafe fn read_sealed_blob_inner( + ctx: krb5::krb5_context, + ccache_name: &str, +) -> Result, String> { + unsafe { + let cc_cname = CString::new(ccache_name).map_err(|_| "invalid ccache name".to_string())?; + let srv_cname = CString::new(GPKRB_SRV_NAME).unwrap(); + + let mut ccache: krb5::krb5_ccache = ptr::null_mut(); + if krb5::krb5_cc_resolve(ctx, cc_cname.as_ptr(), &mut ccache) != 0 { + return Err(format!("cannot resolve ccache {ccache_name}")); + } + + let mut mcreds: krb5::krb5_creds = std::mem::zeroed(); + let mut creds: krb5::krb5_creds = std::mem::zeroed(); + + let result: Result, String> = 'work: { + if krb5::krb5_cc_get_principal(ctx, ccache, &mut mcreds.client) != 0 { + break 'work Err("cannot read ccache principal".into()); + } + if krb5::krb5_parse_name(ctx, srv_cname.as_ptr(), &mut mcreds.server) != 0 { + break 'work Err("cannot parse gssproxy server principal".into()); + } + if krb5::krb5_cc_retrieve_cred(ctx, ccache, 0, &mut mcreds, &mut creds) != 0 { + break 'work Err("no gssproxy credential in ccache".into()); + } + let t = &creds.ticket; + if t.data.is_null() || t.length == 0 { + break 'work Err("empty gssproxy credential ticket".into()); + } + Ok(std::slice::from_raw_parts(t.data as *const u8, t.length as usize).to_vec()) + }; + + krb5::krb5_free_cred_contents(ctx, &mut creds); + krb5::krb5_free_cred_contents(ctx, &mut mcreds); + krb5::krb5_cc_close(ctx, ccache); + + result + } +} diff --git a/rust/gssproxy-server/src/handlers.rs b/rust/gssproxy-server/src/handlers.rs new file mode 100644 index 000000000..77f007a6a --- /dev/null +++ b/rust/gssproxy-server/src/handlers.rs @@ -0,0 +1,723 @@ +//! Per-procedure handlers. Each takes the decoded `gssx_arg_*` and returns the +//! `gssx_res_*`, mirroring the `gp_rpc_*` functions in `src/`. +//! +//! Implemented so far: `indicate_mechs` (1) and `import_and_canon_name` (3). +//! The remaining procedures return a faithful failure result (correct result +//! shape, `GSS_S_FAILURE` status) until they are ported. + +use gssapi_sys::consts; +use gssapi_sys::wrap::{self, Cred, GssError}; +use gssproxy_proto::gssx::*; +use gssproxy_proto::proc::*; +use gssproxy_proto::redact; + +use crate::call::CallContext; +use crate::config::Service; +use crate::conv::{self, ExpCtxType}; +use crate::creds::{self, AcquireType}; + +// GSS_C_* credential usage values (gssapi.h). +const GSS_C_INITIATE: i32 = 1; +const GSS_C_ACCEPT: i32 = 2; +/// `GSS_C_DELEG_FLAG`. +const GSS_C_DELEG_FLAG: u32 = 1; + +/// `gp_filter_flags`: apply the service's enforced/filtered request flags. +fn filter_flags(svc: &Service, mut flags: u32) -> u32 { + flags |= svc.enforce_flags; + flags &= !svc.filter_flags; + flags +} + +/// Borrow a `gssx_cb` as [`wrap::ChannelBindings`]. +fn to_cb(c: &GssxCb) -> wrap::ChannelBindings<'_> { + wrap::ChannelBindings { + initiator_addrtype: c.initiator_addrtype as u32, + initiator_address: c.initiator_address.as_slice(), + acceptor_addrtype: c.acceptor_addrtype as u32, + acceptor_address: c.acceptor_address.as_slice(), + application_data: c.application_data.as_slice(), + } +} + +fn status_err(e: &GssError) -> GssxStatus { + conv::status_to_gssx(e.major, e.minor, None) +} + +/// `GSS_S_FAILURE` / `EINVAL`, used when required call context is missing. +fn invalid() -> GssError { + GssError { + major: consts::GSS_S_FAILURE, + minor: EINVAL, + messages: Vec::new(), + } +} + +/// The `localname` special option key, matched/emitted exactly as the C daemon +/// does: `sizeof("localname")` includes the trailing NUL, so the wire key is +/// the 10-byte `b"localname\0"`. +const LOCALNAME_OPTION: &[u8] = b"localname\0"; + +const EINVAL: u32 = 22; + +fn success(mech: Option<&[u8]>) -> GssxStatus { + conv::status_to_gssx(0, 0, mech) +} + +fn oids_to_gssx(oids: &[Vec]) -> GssxOidSet { + oids.iter().map(|o| Opaque::new(o.clone())).collect() +} + +fn find_option<'a>(options: &'a [GssxOption], key: &[u8]) -> Option<&'a GssxOption> { + options.iter().find(|o| o.option.as_slice() == key) +} + +// ---- indicate_mechs (1) ---- + +pub fn indicate_mechs(_ctx: &CallContext, _arg: ArgIndicateMechs) -> ResIndicateMechs { + let mut res = ResIndicateMechs::default(); + res.status = match build_indicate_mechs(&mut res) { + Ok(()) => success(None), + Err(e) => conv::status_to_gssx(e.major, e.minor, None), + }; + res +} + +fn build_indicate_mechs(res: &mut ResIndicateMechs) -> wrap::Result<()> { + let mechs = wrap::indicate_mechs()?; + // Accumulate the union of all mechanisms' attributes, in first-seen order, + // matching the attr_set the C handler builds with gss_add_oid_set_member. + let mut attr_set: Vec> = Vec::new(); + + for mech in &mechs { + // A mechanism whose name-types can't be inquired is skipped (the C code + // logs the offender and drops it from the list). + let name_types = match wrap::inquire_names_for_mech(mech) { + Ok(nt) => nt, + Err(_) => continue, + }; + let (mech_attrs, known_mech_attrs) = wrap::inquire_attrs_for_mech(mech)?; + for a in mech_attrs.iter().chain(known_mech_attrs.iter()) { + if !attr_set.contains(a) { + attr_set.push(a.clone()); + } + } + let (sasl, mech_name, mech_desc) = wrap::inquire_saslname_for_mech(mech)?; + + res.mechs.push(GssxMechInfo { + mech: Opaque::new(mech.clone()), + name_types: oids_to_gssx(&name_types), + mech_attrs: oids_to_gssx(&mech_attrs), + known_mech_attrs: oids_to_gssx(&known_mech_attrs), + saslname_sasl_mech_name: Opaque::new(sasl), + saslname_mech_name: Opaque::new(mech_name), + saslname_mech_desc: Opaque::new(mech_desc), + ..Default::default() + }); + } + + for attr in &attr_set { + let (name, short_desc, long_desc) = wrap::display_mech_attr(attr)?; + res.mech_attr_descs.push(GssxMechAttr { + attr: Opaque::new(attr.clone()), + name: Opaque::new(name), + short_desc: Opaque::new(short_desc), + long_desc: Opaque::new(long_desc), + ..Default::default() + }); + } + Ok(()) +} + +// ---- import_and_canon_name (3) ---- + +pub fn import_and_canon_name( + _ctx: &CallContext, + arg: ArgImportAndCanonName, +) -> ResImportAndCanonName { + let mut res = ResImportAndCanonName::default(); + let mech = if arg.mech.is_empty() { + None + } else { + Some(arg.mech.as_slice()) + }; + res.status = match build_import_and_canon_name(&arg, &mut res) { + Ok(()) => success(mech), + Err(e) => conv::status_to_gssx(e.major, e.minor, mech), + }; + res +} + +fn build_import_and_canon_name( + arg: &ArgImportAndCanonName, + res: &mut ResImportAndCanonName, +) -> wrap::Result<()> { + if arg.input_name.display_name.is_empty() && arg.input_name.exported_name.is_empty() { + return Err(GssError { + major: consts::GSS_S_FAILURE, + minor: EINVAL, + messages: Vec::new(), + }); + } + + let import_name = conv::gssx_to_name(&arg.input_name)?; + let mech = if arg.mech.is_empty() { + None + } else { + Some(arg.mech.as_slice()) + }; + + // gss_localname is exposed via the special "localname" option. + if find_option(&arg.options, LOCALNAME_OPTION).is_some() { + let localname = import_name.localname(mech)?; + res.options.push(GssxOption { + option: Opaque::new(LOCALNAME_OPTION.to_vec()), + value: Opaque::new(localname), + }); + return Ok(()); + } + + let output_name = match mech { + Some(m) => import_name.canonicalize(m)?, + None => import_name, + }; + res.output_name = Some(conv::name_to_gssx(&output_name)?); + Ok(()) +} + +// ---- not-yet-implemented procedures ---- +// +// These return the correct result shape with a GSS_S_FAILURE status so the +// daemon stays wire-valid while the remaining handlers are ported. + +// `get_call_context`, `export_cred`, `import_cred` and `store_cred` are +// `GP_EXEC_UNUSED_FUNC` stubs in the C daemon (`src/gp_rpc_process.c`): they +// return RPC success with a zero-initialized result, i.e. `GSS_S_COMPLETE` and +// an empty body. We return the same default-zeroed result so the bytes on the +// wire match the C oracle exactly (a `GSS_S_FAILURE` here would not). + +pub fn get_call_context(_ctx: &CallContext, _arg: ArgGetCallContext) -> ResGetCallContext { + ResGetCallContext::default() +} + +// `gp_export_cred`, `gp_import_cred`, and `gp_store_cred` are `GP_EXEC_UNUSED_FUNC` +// in the C daemon: they leave the (zero-initialized) result untouched and return +// success. A defaulted result encodes byte-identically (COMPLETE status, no +// handle, empty option/oid sets). + +pub fn export_cred(_ctx: &CallContext, _arg: ArgExportCred) -> ResExportCred { + ResExportCred::default() +} + +pub fn import_cred(_ctx: &CallContext, _arg: ArgImportCred) -> ResImportCred { + ResImportCred::default() +} + +pub fn store_cred(_ctx: &CallContext, _arg: ArgStoreCred) -> ResStoreCred { + ResStoreCred::default() +} + +/// `gp_acquire_cred`: acquire a krb5 credential for the matched service. +/// +/// Both the normal (`ACQ_NORMAL`) and impersonating (`ACQ_IMPNAME` / s4u2self) +/// paths are handled by [`creds::add_krb5_creds`]. +pub fn acquire_cred(ctx: &CallContext, arg: ArgAcquireCred) -> ResAcquireCred { + let (major, minor, output, mech) = acquire_cred_inner(ctx, &arg); + tracing::debug!( + major, + minor, + cred_acquired = output.is_some(), + "acquire_cred" + ); + ResAcquireCred { + status: conv::status_to_gssx(major, minor, mech.as_deref()), + output_cred_handle: output, + options: Vec::new(), + } +} + +/// `gp_get_acquire_type`: inspect the `acquire_type` option. `None` mirrors the +/// C `-1` ("invalid") return. +fn get_acquire_type(arg: &ArgAcquireCred) -> Option { + // sizeof() in the C macros includes the trailing NUL. + const KEY: &[u8] = b"acquire_type\0"; + const IMPERSONATE: &[u8] = b"impersonate_name\0"; + for opt in &arg.options { + if opt.option.as_slice() == KEY { + return if opt.value.as_slice() == IMPERSONATE { + Some(AcquireType::ImpName) + } else { + None + }; + } + } + Some(AcquireType::Normal) +} + +fn acquire_cred_inner( + ctx: &CallContext, + arg: &ArgAcquireCred, +) -> (u32, u32, Option, Option>) { + let Some(svc) = &ctx.service else { + return (consts::GSS_S_FAILURE, EINVAL, None, None); + }; + let Some(handle) = ctx.creds.as_deref() else { + return (consts::GSS_S_FAILURE, EINVAL, None, None); + }; + + let mut in_cred: Option = None; + let mut acquire_type = AcquireType::Normal; + if let Some(ic) = &arg.input_cred_handle { + match conv::import_gssx_cred(handle, ic) { + Ok(c) => in_cred = c, + Err(e) => return (e.major, e.minor, None, None), + } + match get_acquire_type(arg) { + Some(t) => acquire_type = t, + None => return (consts::GSS_S_FAILURE, EINVAL, None, None), + } + } + + // A specified mech list must include an allowed (krb5) mech; otherwise an + // empty desired_mechs falls back to the supported set (krb5). + if !arg.desired_mechs.is_empty() + && !arg + .desired_mechs + .iter() + .any(|m| creds::allowed_mech(svc, m.as_slice())) + { + return (consts::GSS_S_NO_CRED, 0, None, None); + } + + let mech = Some(consts::KRB5_MECH_OID.to_vec()); + let cred_usage = conv::gssx_to_cred_usage(arg.cred_usage); + + // `_ccache_guard` keeps the per-request MEMORY ccache alive until after the + // acquired credential is exported below, then destroys it. + let (acquired, _ccache_guard) = match creds::add_krb5_creds( + ctx, + svc, + acquire_type, + in_cred.as_ref(), + arg.desired_name.as_ref(), + cred_usage, + ) { + Ok(a) => a, + Err(e) => return (e.major, e.minor, None, mech), + }; + + // Reproduce the C pointer dance: when adding to the input handle, or when + // no separate cred was acquired, reuse the input handle bytes verbatim. + let (reuse_input, final_cred): (bool, Option) = if arg.add_cred_to_input_handle { + if in_cred.is_some() || acquired.is_some() { + (true, None) + } else { + return (consts::GSS_S_NO_CRED, 0, None, mech); + } + } else if let Some(c) = acquired { + (false, Some(c)) + } else if in_cred.is_some() { + (true, None) + } else { + return (consts::GSS_S_NO_CRED, 0, None, mech); + }; + + if reuse_input { + return (0, 0, arg.input_cred_handle.clone(), mech); + } + + match conv::export_gssx_cred(handle, final_cred.expect("acquired cred")) { + Ok(g) => (0, 0, Some(g), mech), + Err(e) => (e.major, e.minor, None, mech), + } +} + +/// `gp_init_sec_context`. The cc-sync (`gp_check_sync_creds`) path is omitted; +/// services without `allow_client_ccache_sync` never trigger it. +pub fn init_sec_context(ctx: &CallContext, arg: ArgInitSecContext) -> ResInitSecContext { + let mut res = ResInitSecContext::default(); + let mech = arg.mech_type.as_slice().to_vec(); + let status_mech = if mech.is_empty() { + None + } else { + Some(mech.as_slice()) + }; + match init_inner(ctx, &arg) { + Ok((continue_needed, handle, token)) => { + let major = if continue_needed { + gssapi_sys::sys::GSS_S_CONTINUE_NEEDED + } else { + 0 + }; + res.status = conv::status_to_gssx(major, 0, status_mech); + res.context_handle = Some(handle); + res.output_token = token; + } + Err(e) => res.status = conv::status_to_gssx(e.major, e.minor, status_mech), + } + // Token contents are secret; only their lengths are recorded. + tracing::debug!( + input_token = %redact::opt_len(arg.input_token.as_ref().map(|b| b.as_slice())), + output_token = %redact::opt_len(res.output_token.as_ref().map(|b| b.as_slice())), + major = res.status.major_status, + minor = res.status.minor_status, + "init_sec_context" + ); + res +} + +fn init_inner( + ctx: &CallContext, + arg: &ArgInitSecContext, +) -> Result<(bool, GssxCtx, Option), GssError> { + let svc = ctx.service.as_ref().ok_or_else(invalid)?; + let handle = ctx.creds.as_deref().ok_or_else(invalid)?; + + let mut exp_type = conv::exported_context_type(&arg.call_ctx.options); + + let existing = match &arg.context_handle { + Some(g) => Some(conv::import_gssx_ctx(g)?), + None => None, + }; + + let mut cred = match &arg.cred_handle { + Some(g) => conv::import_gssx_cred(handle, g)?, + None => None, + }; + + let target = match &arg.target_name { + Some(g) => conv::gssx_to_name(g)?, + None => return Err(invalid()), + }; + + let mech = arg.mech_type.as_slice(); + + // Keeps the per-request MEMORY ccache alive until init_sec_context (below) + // has finished using the freshly acquired credential. + let mut _ccache_guard = None; + if cred.is_none() { + if mech == consts::KRB5_MECH_OID { + let (acquired, guard) = + creds::add_krb5_creds(ctx, svc, AcquireType::Normal, None, None, GSS_C_INITIATE) + .map_err(|e| GssError { + major: e.major, + minor: e.minor, + messages: Vec::new(), + })?; + cred = acquired; + _ccache_guard = guard; + } else { + return Err(GssError { + major: consts::GSS_S_NO_CRED, + minor: 0, + messages: Vec::new(), + }); + } + } + + if let Err(major) = creds::cred_allowed(svc, cred.as_ref(), &target) { + return Err(GssError { + major, + minor: 0, + messages: Vec::new(), + }); + } + + let req_flags = filter_flags(svc, arg.req_flags as u32); + let cb = arg.input_cb.as_ref().map(to_cb); + let input = arg + .input_token + .as_ref() + .map(|b| b.as_slice()) + .unwrap_or(&[]); + + let r = wrap::init_sec_context( + cred.as_ref(), + existing, + &target, + mech, + req_flags, + arg.time_req as u32, + cb.as_ref(), + input, + )?; + + if r.continue_needed { + exp_type = ExpCtxType::Partial; + } + + let handle_out = conv::export_gssx_ctx(r.context, exp_type, Some(mech))?; + let token = if r.output.is_empty() { + None + } else { + Some(Opaque::new(r.output)) + }; + Ok((r.continue_needed, handle_out, token)) +} + +/// `gp_accept_sec_context`. The cc-sync and `linux_creds_v1` export paths are +/// omitted (default `EXP_CREDS_NO_CREDS`, no `allow_client_ccache_sync`). +pub fn accept_sec_context(ctx: &CallContext, arg: ArgAcceptSecContext) -> ResAcceptSecContext { + let mut res = ResAcceptSecContext::default(); + match accept_inner(ctx, &arg) { + Ok((continue_needed, handle, token, deleg, mech)) => { + let major = if continue_needed { + gssapi_sys::sys::GSS_S_CONTINUE_NEEDED + } else { + 0 + }; + let status_mech = if mech.is_empty() { + None + } else { + Some(mech.as_slice()) + }; + res.status = conv::status_to_gssx(major, 0, status_mech); + res.context_handle = Some(handle); + res.output_token = Some(token); + res.delegated_cred_handle = deleg; + } + Err(e) => res.status = conv::status_to_gssx(e.major, e.minor, None), + } + tracing::debug!( + input_token = %redact::len(arg.input_token.as_slice()), + output_token = %redact::opt_len(res.output_token.as_ref().map(|b| b.as_slice())), + delegated = res.delegated_cred_handle.is_some(), + major = res.status.major_status, + minor = res.status.minor_status, + "accept_sec_context" + ); + res +} + +#[allow(clippy::type_complexity)] +fn accept_inner( + ctx: &CallContext, + arg: &ArgAcceptSecContext, +) -> Result<(bool, GssxCtx, GssxBuffer, Option, Vec), GssError> { + let svc = ctx.service.as_ref().ok_or_else(invalid)?; + let handle = ctx.creds.as_deref().ok_or_else(invalid)?; + + let mut exp_type = conv::exported_context_type(&arg.call_ctx.options); + + let existing = match &arg.context_handle { + Some(g) => Some(conv::import_gssx_ctx(g)?), + None => None, + }; + + let mut cred = match &arg.cred_handle { + Some(g) => conv::import_gssx_cred(handle, g)?, + None => None, + }; + + let mut _ccache_guard = None; + if cred.is_none() { + let (acquired, guard) = + creds::add_krb5_creds(ctx, svc, AcquireType::Normal, None, None, GSS_C_ACCEPT) + .map_err(|e| GssError { + major: e.major, + minor: e.minor, + messages: Vec::new(), + })?; + cred = acquired; + _ccache_guard = guard; + } + + let cb = arg.input_cb.as_ref().map(to_cb); + let r = wrap::accept_sec_context( + existing, + cred.as_ref(), + arg.input_token.as_slice(), + cb.as_ref(), + arg.ret_deleg_cred, + )?; + + if r.continue_needed { + exp_type = ExpCtxType::Partial; + } + + let mech = r.mech.clone(); + let output = r.output.clone(); + let ret_flags = r.ret_flags; + let delegated = r.delegated_cred; + + let handle_out = conv::export_gssx_ctx(r.context, exp_type, Some(&mech))?; + + let deleg = if ret_flags & GSS_C_DELEG_FLAG != 0 && arg.ret_deleg_cred { + match delegated { + Some(dch) => Some(conv::export_gssx_cred(handle, dch)?), + None => None, + } + } else { + None + }; + + Ok(( + r.continue_needed, + handle_out, + Opaque::new(output), + deleg, + mech, + )) +} + +/// The daemon is stateless (every handle is returned with `needs_release = +/// false`), so a client should never need to release anything. Mirror the C +/// handler: `GSS_S_UNAVAILABLE` for the known handle types, and +/// `GSS_S_CALL_BAD_STRUCTURE` for anything else. +pub fn release_handle(_ctx: &CallContext, arg: ArgReleaseHandle) -> ResReleaseHandle { + let major = match arg.cred_handle { + GssxHandle::SecCtx(_) | GssxHandle::Cred(_) => consts::GSS_S_UNAVAILABLE, + GssxHandle::Extensions { .. } => consts::GSS_S_CALL_BAD_STRUCTURE, + }; + ResReleaseHandle { + status: conv::status_to_gssx(major, 0, None), + } +} + +/// `gp_get_mic`. +pub fn get_mic(_ctx: &CallContext, arg: ArgGetMic) -> ResGetMic { + let mut res = ResGetMic::default(); + let exp_type = conv::exported_context_type(&arg.call_ctx.options); + let inner = || -> Result<(GssxCtx, Vec), GssError> { + let context = conv::import_gssx_ctx(&arg.context_handle)?; + let token = context.get_mic(arg.qop_req as u32, arg.message_buffer.as_slice())?; + let handle = conv::export_gssx_ctx(context, exp_type, None)?; + Ok((handle, token)) + }; + match inner() { + Ok((handle, token)) => { + res.status = success(None); + res.context_handle = Some(handle); + res.token_buffer = Opaque::new(token); + res.qop_state = Some(arg.qop_req); + } + Err(e) => res.status = status_err(&e), + } + tracing::debug!( + message = %redact::len(arg.message_buffer.as_slice()), + token = %redact::len(res.token_buffer.as_slice()), + major = res.status.major_status, + "get_mic" + ); + res +} + +/// `gp_verify_mic`. +pub fn verify_mic(_ctx: &CallContext, arg: ArgVerifyMic) -> ResVerifyMic { + let mut res = ResVerifyMic::default(); + let exp_type = conv::exported_context_type(&arg.call_ctx.options); + let inner = || -> Result<(GssxCtx, u32), GssError> { + let context = conv::import_gssx_ctx(&arg.context_handle)?; + let qop = context.verify_mic(arg.message_buffer.as_slice(), arg.token_buffer.as_slice())?; + let handle = conv::export_gssx_ctx(context, exp_type, None)?; + Ok((handle, qop)) + }; + match inner() { + Ok((handle, qop)) => { + res.status = success(None); + res.context_handle = Some(handle); + res.qop_state = Some(qop as u64); + } + Err(e) => res.status = status_err(&e), + } + tracing::debug!( + message = %redact::len(arg.message_buffer.as_slice()), + token = %redact::len(arg.token_buffer.as_slice()), + major = res.status.major_status, + "verify_mic" + ); + res +} + +/// `gp_wrap`. +pub fn wrap_msg(_ctx: &CallContext, arg: ArgWrap) -> ResWrap { + let mut res = ResWrap::default(); + let exp_type = conv::exported_context_type(&arg.call_ctx.options); + let inner = || -> Result<(GssxCtx, Vec, bool), GssError> { + let context = conv::import_gssx_ctx(&arg.context_handle)?; + let input = arg + .message_buffer + .first() + .map(|b| b.as_slice()) + .unwrap_or(&[]); + let (token, conf) = context.wrap(arg.conf_req, arg.qop_state as u32, input)?; + let handle = conv::export_gssx_ctx(context, exp_type, None)?; + Ok((handle, token, conf)) + }; + match inner() { + Ok((handle, token, conf)) => { + res.status = success(None); + res.context_handle = Some(handle); + // The C handler echoes back the *input* qop_state. + res.qop_state = Some(arg.qop_state); + res.conf_state = Some(conf); + res.token_buffer = vec![Opaque::new(token)]; + } + Err(e) => res.status = status_err(&e), + } + tracing::debug!( + message = %redact::opt_len(arg.message_buffer.first().map(|b| b.as_slice())), + token = %redact::opt_len(res.token_buffer.first().map(|b| b.as_slice())), + conf = ?res.conf_state, + major = res.status.major_status, + "wrap" + ); + res +} + +/// `gp_unwrap`. +pub fn unwrap_msg(_ctx: &CallContext, arg: ArgUnwrap) -> ResUnwrap { + let mut res = ResUnwrap::default(); + let exp_type = conv::exported_context_type(&arg.call_ctx.options); + let inner = || -> Result<(GssxCtx, Vec, bool), GssError> { + let context = conv::import_gssx_ctx(&arg.context_handle)?; + let input = arg + .token_buffer + .first() + .map(|b| b.as_slice()) + .unwrap_or(&[]); + let (message, conf, _qop) = context.unwrap(input)?; + let handle = conv::export_gssx_ctx(context, exp_type, None)?; + Ok((handle, message, conf)) + }; + match inner() { + Ok((handle, message, conf)) => { + res.status = success(None); + res.context_handle = Some(handle); + // The C handler echoes back the *input* qop_state. + res.qop_state = Some(arg.qop_state); + res.conf_state = Some(conf); + res.message_buffer = vec![Opaque::new(message)]; + } + Err(e) => res.status = status_err(&e), + } + tracing::debug!( + token = %redact::opt_len(arg.token_buffer.first().map(|b| b.as_slice())), + message = %redact::opt_len(res.message_buffer.first().map(|b| b.as_slice())), + conf = ?res.conf_state, + major = res.status.major_status, + "unwrap" + ); + res +} + +/// `gp_wrap_size_limit`. +pub fn wrap_size_limit(_ctx: &CallContext, arg: ArgWrapSizeLimit) -> ResWrapSizeLimit { + let mut res = ResWrapSizeLimit::default(); + let inner = || -> Result { + let context = conv::import_gssx_ctx(&arg.context_handle)?; + context.wrap_size_limit( + arg.conf_req, + arg.qop_state as u32, + arg.req_output_size as u32, + ) + }; + match inner() { + Ok(max) => { + res.status = success(None); + res.max_input_size = max as u64; + } + Err(e) => res.status = status_err(&e), + } + res +} diff --git a/rust/gssproxy-server/src/lib.rs b/rust/gssproxy-server/src/lib.rs new file mode 100644 index 000000000..082910b1f --- /dev/null +++ b/rust/gssproxy-server/src/lib.rs @@ -0,0 +1,18 @@ +//! The gssproxy daemon (Rust port), exposed as a library so the binary and the +//! integration tests share the same modules. +//! +//! Layers: +//! - [`config`]: `gssproxy.conf` parsing and service/euid matching. +//! - [`conv`]: conversions between the gssx wire types and live GSSAPI handles. +//! - [`handlers`]: per-procedure GSSAPI logic (`gp_rpc_*` ports). +//! - [`dispatch`]: RPC envelope validation and per-procedure routing. +//! - [`server`]: the Unix-socket listener and SunRPC record-marking loop. + +pub mod call; +pub mod config; +pub mod conv; +pub mod creds; +pub mod dispatch; +pub mod extract; +pub mod handlers; +pub mod server; diff --git a/rust/gssproxy-server/src/main.rs b/rust/gssproxy-server/src/main.rs new file mode 100644 index 000000000..dbf28e4d9 --- /dev/null +++ b/rust/gssproxy-server/src/main.rs @@ -0,0 +1,555 @@ +//! The gssproxy daemon binary (Rust port). +//! +//! Mirrors the command-line surface of the C daemon (`src/gssproxy.c`, which +//! uses popt): `gssproxy [-D|--daemon] [-i|--interactive] [-c|--config FILE] +//! [-C|--configdir DIR] [-s|--socket PATH] [-u|--userproxy] [-d|--debug] +//! [--debug-level N] [--syslog-status] [--idle-timeout N] [--version] +//! [-h|--help]`, plus the hidden `--extract-ccache SRC [--into-ccache DST]` +//! admin utility (port of `src/extract_ccache.c`). +//! +//! It loads `gssproxy.conf`, binds the Unix socket, prints "Initialization +//! complete." once it is ready to accept connections, and reloads the +//! configuration on `SIGHUP` (logging "New config loaded successfully."). + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use gssproxy_server::{config::Config, server}; + +/// Compiled-in default config path (autotools `GSSCONF`). +const DEFAULT_CONF: &str = "/etc/gssproxy/gssproxy.conf"; +/// Compiled-in default socket path (autotools `GP_SOCKET_NAME`). +const DEFAULT_SOCKET: &str = "/var/lib/gssproxy/default.sock"; + +const USAGE: &str = "Usage: gssproxy [-D|--daemon] [-i|--interactive] \ +[-c|--config FILE] [-C|--configdir DIR] [-s|--socket PATH] [-u|--userproxy] \ +[-d|--debug] [--debug-level N] [--syslog-status] [--idle-timeout N] \ +[--version] [-h|--help]"; + +/// Default user-mode idle timeout in seconds (C `opt_idle_timeout`). +const DEFAULT_IDLE_TIMEOUT: i32 = 1000; + +/// Parsed command-line arguments. Several flags are accepted for CLI +/// compatibility with the C daemon even where the behaviour they toggle is not +/// yet implemented in the Rust port (recorded here, applied where possible). +#[derive(Debug, Clone, PartialEq, Eq)] +struct Args { + socket: String, + config: PathBuf, + config_dir: Option, + interactive: bool, + daemon: bool, + debug: bool, + debug_level: i32, + syslog_status: bool, + userproxy: bool, + idle_timeout: i32, +} + +impl Args { + fn defaults() -> Args { + Args { + socket: DEFAULT_SOCKET.to_string(), + config: PathBuf::from(DEFAULT_CONF), + config_dir: None, + interactive: false, + daemon: false, + debug: false, + debug_level: 0, + syslog_status: false, + userproxy: false, + idle_timeout: DEFAULT_IDLE_TIMEOUT, + } + } +} + +/// Outcome of parsing argv, separated from process side-effects so it is unit +/// testable. The ordering of the terminal outcomes mirrors `src/gssproxy.c`: +/// an unknown option or `--help` short-circuits during the popt parse loop, +/// while `--version`, `--extract-ccache`, and the `-D`+`-i` conflict are honored +/// (in that order) only after a fully successful parse. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Parsed { + Run(Args), + Help, + Version, + /// `--extract-ccache SRC [--into-ccache DST]`: run the ccache extractor. + ExtractCcache { + source: String, + dest: Option, + }, + /// `-D` and `-i` given together: the C daemon prints a message and exits 0. + DaemonInteractiveConflict, + Error(String), +} + +fn take_value( + name: &str, + inline: Option, + argv: &[String], + i: &mut usize, +) -> std::result::Result { + if let Some(v) = inline { + return Ok(v); + } + *i += 1; + argv.get(*i) + .cloned() + .ok_or_else(|| format!("option {name} requires an argument")) +} + +fn parse_args_from>(args: I) -> Parsed { + let argv: Vec = args.into_iter().collect(); + let mut a = Args::defaults(); + // popt sets all option flags during the parse loop, then `main` applies the + // version/extract/conflict precedence afterwards, so we accumulate rather + // than short-circuit on these (unlike `--help`, which popt's autohelp + // handles inline). + let mut want_version = false; + let mut extract_ccache: Option = None; + let mut into_ccache: Option = None; + let mut i = 0; + + while i < argv.len() { + let arg = argv[i].clone(); + // Split `--opt=value` / `-s=value` into name and inline value. + let (name, inline) = match arg.split_once('=') { + Some((n, v)) => (n.to_string(), Some(v.to_string())), + None => (arg.clone(), None), + }; + + macro_rules! value { + () => { + match take_value(&name, inline.clone(), &argv, &mut i) { + Ok(v) => v, + Err(e) => return Parsed::Error(e), + } + }; + } + + match name.as_str() { + "-i" | "--interactive" => a.interactive = true, + "-D" | "--daemon" => a.daemon = true, + "-s" | "--socket" => a.socket = value!(), + "-c" | "--config" => a.config = PathBuf::from(value!()), + // Drop-in config directories are not consulted yet; accept + record. + "-C" | "--configdir" => a.config_dir = Some(value!()), + "-u" | "--userproxy" => a.userproxy = true, + "-d" | "--debug" => a.debug = true, + "--debug-level" => { + a.debug_level = match value!().parse() { + Ok(n) => n, + Err(_) => return Parsed::Error("--debug-level expects an integer".into()), + }; + } + "--syslog-status" => a.syslog_status = true, + "--idle-timeout" => { + a.idle_timeout = match value!().parse() { + Ok(n) => n, + Err(_) => return Parsed::Error("--idle-timeout expects an integer".into()), + }; + } + // Hidden admin options (POPT_ARGFLAG_DOC_HIDDEN in the C daemon). + "--extract-ccache" => extract_ccache = Some(value!()), + "--into-ccache" => into_ccache = Some(value!()), + "--version" => want_version = true, + "-h" | "--help" | "--usage" | "-?" => return Parsed::Help, + other => return Parsed::Error(format!("unknown option '{other}'")), + } + i += 1; + } + + // Terminal-outcome precedence, mirroring src/gssproxy.c: version first, then + // the (hidden) ccache extractor, then the daemon/interactive conflict. + if want_version { + return Parsed::Version; + } + if let Some(source) = extract_ccache { + return Parsed::ExtractCcache { + source, + dest: into_ccache, + }; + } + if a.daemon && a.interactive { + return Parsed::DaemonInteractiveConflict; + } + + Parsed::Run(a) +} + +/// Install the global `tracing` subscriber, writing human-readable events to +/// stderr (matching where the C daemon logs). The verbosity follows, in order +/// of precedence: the `RUST_LOG` environment variable, then the C-compatible +/// `-d`/`--debug-level` flags, defaulting to `info` (which still emits the +/// lifecycle lines the test suite waits for). +/// +/// Idempotent: uses `try_init`, so a second call (or a host that already set a +/// subscriber) is a no-op rather than a panic. +fn init_tracing(debug: bool, debug_level: i32) { + use tracing_subscriber::EnvFilter; + + // `--debug-level` raises verbosity: >=2 -> trace, >=1 (or -d) -> debug. + let level = if debug_level >= 2 { + "trace" + } else if debug || debug_level >= 1 { + "debug" + } else { + "info" + }; + + // RUST_LOG wins outright; otherwise keep third-party crates (tokio, mio) at + // `info` and only turn our own crates up to the requested level. + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new(format!( + "info,gssproxy_server={level},gssproxy_client={level}" + )) + }); + + // The daemon logs to stderr (and, under the test harness, into a log file); + // neither is an interactive terminal, so suppress ANSI colour codes to keep + // the output as plain, greppable text in every environment. + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .with_ansi(false) + .try_init(); +} + +fn load_config(path: &Path, socket: &str) -> Config { + match Config::parse_file(path, socket) { + Ok(cfg) => cfg, + Err(e) => { + tracing::error!(path = %path.display(), error = %e, "failed to load config"); + std::process::exit(1); + } + } +} + +fn run(args: Args) { + init_tracing(args.debug, args.debug_level); + + let _ = ( + args.interactive, + args.daemon, + args.syslog_status, + args.userproxy, + &args.config_dir, + args.idle_timeout, + ); + // Daemonization and userproxy mode are not implemented; the daemon always + // runs in the foreground. + tracing::debug!( + socket = %args.socket, + config = %args.config.display(), + daemon = args.daemon, + interactive = args.interactive, + userproxy = args.userproxy, + idle_timeout = args.idle_timeout, + "starting gssproxy daemon" + ); + + // Validate the configuration up front (matches the C daemon, which refuses + // to start with an unparsable or empty config). + let config = load_config(&args.config, &args.socket); + let shared = Arc::new(Mutex::new(config)); + + let runtime = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + tracing::error!(error = %e, "failed to start tokio runtime"); + std::process::exit(1); + } + }; + + let result = runtime.block_on(async move { + server::run(args.socket.clone(), args.config.clone(), shared).await + }); + + if let Err(e) = result { + tracing::error!(error = %e, "daemon exited with error"); + std::process::exit(1); + } +} + +fn main() { + match parse_args_from(std::env::args().skip(1)) { + Parsed::Run(args) => run(args), + Parsed::Help => { + println!("{USAGE}"); + std::process::exit(0); + } + Parsed::Version => { + // Bare version string, matching the C daemon's `puts(VERSION)`. + println!("{}", env!("CARGO_PKG_VERSION")); + std::process::exit(0); + } + Parsed::ExtractCcache { source, dest } => { + match gssproxy_server::extract::extract_ccache(&source, dest.as_deref()) { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!("gssproxy: extract-ccache failed: {e}"); + std::process::exit(1); + } + } + } + Parsed::DaemonInteractiveConflict => { + // The C daemon prints this to stderr and exits 0. + eprintln!("Option -i|--interactive is not allowed together with -D|--daemon"); + eprintln!("{USAGE}"); + std::process::exit(0); + } + Parsed::Error(msg) => { + eprintln!("gssproxy: {msg}"); + eprintln!("{USAGE}"); + std::process::exit(1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Parsed { + parse_args_from(args.iter().map(|s| s.to_string())) + } + + fn run_args(args: &[&str]) -> Args { + match parse(args) { + Parsed::Run(a) => a, + other => panic!("expected Run, got {other:?}"), + } + } + + #[test] + fn defaults_when_no_args() { + let a = run_args(&[]); + assert_eq!(a.socket, DEFAULT_SOCKET); + assert_eq!(a.config, PathBuf::from(DEFAULT_CONF)); + assert!(!a.interactive && !a.daemon && !a.debug); + } + + #[test] + fn parses_test_harness_invocation() { + // The exact form the upstream suite uses: `gssproxy -i -s SOCK -c CONF`. + let a = run_args(&["-i", "-s", "/run/gp.sock", "-c", "/etc/gp.conf"]); + assert!(a.interactive); + assert_eq!(a.socket, "/run/gp.sock"); + assert_eq!(a.config, PathBuf::from("/etc/gp.conf")); + } + + #[test] + fn long_options_and_inline_values() { + let a = run_args(&["--socket=/s", "--config=/c", "--interactive"]); + assert_eq!(a.socket, "/s"); + assert_eq!(a.config, PathBuf::from("/c")); + assert!(a.interactive); + } + + #[test] + fn config_dir_matches_c_capital_c_flag() { + // C uses -C/--configdir for the config directory (and -d for debug). + let a = run_args(&["-C", "/etc/gssproxy.d"]); + assert_eq!(a.config_dir.as_deref(), Some("/etc/gssproxy.d")); + let a = run_args(&["--configdir=/x"]); + assert_eq!(a.config_dir.as_deref(), Some("/x")); + } + + #[test] + fn debug_and_daemon_flags() { + let a = run_args(&["-d", "-D"]); + assert!(a.debug); + assert!(a.daemon); + let a = run_args(&["--debug-level", "3"]); + assert_eq!(a.debug_level, 3); + let a = run_args(&["--syslog-status", "-u"]); + assert!(a.syslog_status); + assert!(a.userproxy); + } + + #[test] + fn version_and_help() { + assert_eq!(parse(&["--version"]), Parsed::Version); + assert_eq!(parse(&["-h"]), Parsed::Help); + assert_eq!(parse(&["--help"]), Parsed::Help); + assert_eq!(parse(&["--usage"]), Parsed::Help); + assert_eq!(parse(&["-?"]), Parsed::Help); + } + + #[test] + fn unknown_option_is_error() { + assert!(matches!(parse(&["--bogus"]), Parsed::Error(_))); + assert!(matches!(parse(&["-z"]), Parsed::Error(_))); + } + + #[test] + fn missing_option_argument_is_error() { + assert!(matches!(parse(&["-s"]), Parsed::Error(_))); + assert!(matches!(parse(&["--config"]), Parsed::Error(_))); + assert!(matches!( + parse(&["--debug-level", "notanint"]), + Parsed::Error(_) + )); + } + + #[test] + fn idle_timeout_parses_like_c() { + assert_eq!(run_args(&[]).idle_timeout, DEFAULT_IDLE_TIMEOUT); + assert_eq!(run_args(&["--idle-timeout", "42"]).idle_timeout, 42); + assert_eq!(run_args(&["--idle-timeout=7"]).idle_timeout, 7); + assert!(matches!(parse(&["--idle-timeout", "x"]), Parsed::Error(_))); + assert!(matches!(parse(&["--idle-timeout"]), Parsed::Error(_))); + } + + #[test] + fn extract_ccache_options() { + assert_eq!( + parse(&["--extract-ccache", "FILE:/tmp/cc"]), + Parsed::ExtractCcache { + source: "FILE:/tmp/cc".into(), + dest: None + } + ); + assert_eq!( + parse(&["--extract-ccache=FILE:/a", "--into-ccache=FILE:/b"]), + Parsed::ExtractCcache { + source: "FILE:/a".into(), + dest: Some("FILE:/b".into()), + } + ); + } + + #[test] + fn daemon_interactive_conflict_matches_c() { + // C prints a message and exits 0 when -D and -i are combined. + assert_eq!(parse(&["-D", "-i"]), Parsed::DaemonInteractiveConflict); + assert_eq!( + parse(&["--daemon", "--interactive"]), + Parsed::DaemonInteractiveConflict + ); + // Either alone is fine. + assert!(matches!(parse(&["-D"]), Parsed::Run(_))); + assert!(matches!(parse(&["-i"]), Parsed::Run(_))); + } + + #[test] + fn version_precedence_matches_popt() { + // popt parses the whole argv before honoring --version, so an unknown + // option anywhere still errors (it does not short-circuit on version). + assert_eq!(parse(&["--version"]), Parsed::Version); + assert_eq!(parse(&["-D", "--version"]), Parsed::Version); + assert!(matches!(parse(&["--version", "--bogus"]), Parsed::Error(_))); + // version wins over the -D/-i conflict and over extract-ccache. + assert_eq!(parse(&["-D", "-i", "--version"]), Parsed::Version); + assert_eq!( + parse(&["--version", "--extract-ccache", "FILE:/x"]), + Parsed::Version + ); + } +} + +#[cfg(test)] +mod prop_tests { + use super::*; + use proptest::prelude::*; + + /// Value-less flags that, on their own, always yield a `Run`. + fn known_flag() -> impl Strategy { + prop_oneof![ + Just("-i"), + Just("--interactive"), + Just("-D"), + Just("--daemon"), + Just("-u"), + Just("--userproxy"), + Just("-d"), + Just("--debug"), + Just("--syslog-status"), + ] + } + + /// A grab-bag of real flags, values, terminal tokens, and junk. + fn token() -> impl Strategy { + prop_oneof![ + known_flag().prop_map(str::to_string), + Just("-s".to_string()), + Just("--socket".to_string()), + Just("-c".to_string()), + Just("--config".to_string()), + Just("-C".to_string()), + Just("--configdir".to_string()), + Just("--debug-level".to_string()), + Just("--socket=/s".to_string()), + Just("--debug-level=7".to_string()), + Just("--debug-level=x".to_string()), + Just("--version".to_string()), + Just("-h".to_string()), + Just("--help".to_string()), + Just("-?".to_string()), + Just("/some/path".to_string()), + Just("5".to_string()), + Just("--unknown".to_string()), + Just(String::new()), + "[ -~]{0,8}".prop_map(|s| s), + ] + } + + proptest! { + #![proptest_config(ProptestConfig { + cases: 1024, + failure_persistence: None, + ..ProptestConfig::default() + })] + + /// No argv - however malformed - makes the parser panic, and parsing is + /// deterministic. + #[test] + fn parse_never_panics_and_is_deterministic(argv in prop::collection::vec(token(), 0..10)) { + let a = parse_args_from(argv.clone()); + let b = parse_args_from(argv.clone()); + prop_assert_eq!(a, b); + } + + /// Any sequence of value-less known flags parses to `Run`, with each + /// boolean set iff its flag (short or long) is present - except that + /// `-D` and `-i` together trigger the daemon/interactive conflict. + #[test] + fn known_flags_only_always_run(flags in prop::collection::vec(known_flag(), 0..8)) { + let argv: Vec = flags.iter().map(|s| s.to_string()).collect(); + let has = |s: &str, l: &str| flags.iter().any(|f| *f == s || *f == l); + let interactive = has("-i", "--interactive"); + let daemon = has("-D", "--daemon"); + match parse_args_from(argv) { + _ if interactive && daemon => { + prop_assert_eq!( + parse_args_from(flags.iter().map(|s| s.to_string()).collect::>()), + Parsed::DaemonInteractiveConflict + ); + } + Parsed::Run(a) => { + prop_assert_eq!(a.interactive, interactive); + prop_assert_eq!(a.daemon, daemon); + prop_assert_eq!(a.userproxy, has("-u", "--userproxy")); + prop_assert_eq!(a.debug, has("-d", "--debug")); + prop_assert_eq!(a.syslog_status, flags.contains(&"--syslog-status")); + } + other => prop_assert!(false, "value-less flags must Run, got {:?}", other), + } + } + + /// `--help` short-circuits inline (popt autohelp) and an unknown leading + /// token errors, regardless of what follows. `--version`, by contrast, + /// is only honored after a fully successful parse, so it does NOT + /// short-circuit past a later bad option. + #[test] + fn leading_token_decides_outcome(rest in prop::collection::vec(token(), 0..6)) { + let with = |head: &str| { + let mut v = vec![head.to_string()]; + v.extend(rest.iter().cloned()); + parse_args_from(v) + }; + prop_assert_eq!(with("-h"), Parsed::Help); + prop_assert!(matches!(with("--definitely-not-a-flag"), Parsed::Error(_))); + } + } +} diff --git a/rust/gssproxy-server/src/server.rs b/rust/gssproxy-server/src/server.rs new file mode 100644 index 000000000..41638a8c4 --- /dev/null +++ b/rust/gssproxy-server/src/server.rs @@ -0,0 +1,220 @@ +//! Unix-socket listener and SunRPC record-marking framing loop. +//! +//! GSSAPI is synchronous, so the per-request dispatch runs on tokio's blocking +//! pool while the socket I/O stays async. + +use std::collections::{HashMap, HashSet}; +use std::io::{self, ErrorKind}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use gssproxy_proto::{frame, parse_header}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::signal::unix::{SignalKind, signal}; +use tokio::task::JoinHandle; + +use crate::call::CallContext; +use crate::config::Config; +use crate::creds::CredsRegistry; +use crate::dispatch; + +/// Serve the daemon: bind the main socket plus every per-service `socket`, then +/// reload the configuration (and reconcile the set of bound sockets) on every +/// `SIGHUP`. +/// +/// `config` is shared (and is swapped out on reload); each connection resolves +/// its service against the current configuration at accept time. Mirroring the +/// C daemon, services may declare their own `socket`, so the daemon listens on +/// the union of the main socket and all service sockets. +pub async fn run( + main_socket: String, + config_path: PathBuf, + config: Arc>, +) -> io::Result<()> { + // Per-service sealing keys, derived lazily and shared for the daemon's + // lifetime (a config reload keeps the existing handles, keyed by name). + let registry = Arc::new(CredsRegistry::new()); + + // Active listeners, keyed by socket path. + let mut listeners: HashMap> = HashMap::new(); + reconcile_listeners(&main_socket, &config, ®istry, &mut listeners); + + // Readiness signal. The test suite (the Rust `tests/cli.rs` and the upstream + // `gssproxy_reload` in tests/testlib.py) waits for this exact substring on + // the daemon's stderr/log before it starts driving requests, and an init + // system treats it as the "ready" marker. Emit it as a bare, unconditional + // stderr line - never through `tracing` - so it is always written verbatim, + // independent of the active log filter, ANSI decoration, or subscriber state. + eprintln!("Initialization complete."); + + let mut hup = match signal(SignalKind::hangup()) { + Ok(s) => s, + Err(e) => { + // Without a reload handler we can still serve indefinitely. + tracing::warn!(error = %e, "cannot install SIGHUP handler; running without config reload"); + std::future::pending::<()>().await; + unreachable!() + } + }; + + while hup.recv().await.is_some() { + tracing::debug!("SIGHUP received, reloading configuration"); + match Config::parse_file(&config_path, &main_socket) { + Ok(cfg) => { + *config.lock().unwrap() = cfg; + reconcile_listeners(&main_socket, &config, ®istry, &mut listeners); + // Reload-complete signal: same contract as the readiness line + // above (the harness waits for this exact substring after a + // SIGHUP), so it is also a bare, unconditional stderr write. + eprintln!("New config loaded successfully."); + } + Err(e) => tracing::error!(error = %e, "config reload failed"), + } + } + + Ok(()) +} + +/// Bring the set of bound sockets in line with the current configuration: bind +/// any newly required socket and tear down any socket no longer referenced. +fn reconcile_listeners( + main_socket: &str, + config: &Arc>, + registry: &Arc, + listeners: &mut HashMap>, +) { + let desired: HashSet = { + let guard = config.lock().unwrap(); + let mut set = HashSet::new(); + set.insert(main_socket.to_string()); + for svc in &guard.services { + if let Some(sock) = &svc.socket { + set.insert(sock.clone()); + } + } + set + }; + + // Tear down listeners that are no longer wanted (the main socket always is). + let stale: Vec = listeners + .keys() + .filter(|path| !desired.contains(*path)) + .cloned() + .collect(); + for path in stale { + if let Some(handle) = listeners.remove(&path) { + handle.abort(); + let _ = std::fs::remove_file(&path); + tracing::debug!(socket = %path, "stopped listening on socket no longer in config"); + } + } + + // Bind any sockets we are not yet listening on. + for path in desired { + if listeners.contains_key(&path) { + continue; + } + match spawn_listener(path.clone(), config.clone(), registry.clone()) { + Ok(handle) => { + tracing::debug!(socket = %path, "listening on socket"); + listeners.insert(path, handle); + } + Err(e) => tracing::error!(socket = %path, error = %e, "failed to bind socket"), + } + } +} + +/// Bind `path` and spawn an accept loop for it. Each accepted connection is +/// served on its own task and tagged with `path` so service matching keys on +/// the socket the client used. +fn spawn_listener( + path: String, + config: Arc>, + registry: Arc, +) -> io::Result> { + // Best-effort removal of a stale socket from a previous run. + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path)?; + + Ok(tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((stream, _addr)) => { + let ctx = resolve_context(&stream, &config, ®istry, &path); + tokio::spawn(async move { + if let Err(e) = handle_conn(stream, ctx).await { + tracing::debug!(error = %e, "connection error"); + } + }); + } + Err(e) => { + tracing::error!(socket = %path, error = %e, "accept error; shutting down listener"); + break; + } + } + } + })) +} + +/// Resolve the per-connection [`CallContext`] from the peer credentials, then +/// attach the matched service's sealing handle. +fn resolve_context( + stream: &UnixStream, + config: &Arc>, + registry: &Arc, + socket: &str, +) -> CallContext { + let mut ctx = match stream.peer_cred() { + Ok(cred) => { + let guard = config.lock().unwrap(); + CallContext::resolve(&guard, socket, cred.uid(), cred.gid(), cred.pid()) + } + Err(e) => { + tracing::warn!(socket = %socket, error = %e, "failed to read peer credentials; treating peer as anonymous"); + CallContext::anonymous(socket) + } + }; + if let Some(svc) = &ctx.service { + ctx.creds = registry.get_or_init(svc); + } + tracing::debug!( + socket = %ctx.socket, + uid = ctx.uid, + gid = ctx.gid, + pid = ctx.pid, + program = ctx.program.as_deref().unwrap_or("?"), + service = ctx.service.as_ref().map(|s| s.name.as_str()).unwrap_or(""), + "accepted connection" + ); + ctx +} + +async fn handle_conn(mut stream: UnixStream, ctx: CallContext) -> io::Result<()> { + let ctx = Arc::new(ctx); + loop { + let mut header = [0u8; 4]; + match stream.read_exact(&mut header).await { + Ok(_) => {} + // Clean client disconnect between requests. + Err(e) if e.kind() == ErrorKind::UnexpectedEof => return Ok(()), + Err(e) => return Err(e), + } + + let len = parse_header(u32::from_be_bytes(header)) + .map_err(|e| io::Error::new(ErrorKind::InvalidData, e.to_string()))?; + + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await?; + + let ctx = ctx.clone(); + let reply = tokio::task::spawn_blocking(move || dispatch::handle_request(&ctx, &body)) + .await + .map_err(|e| io::Error::other(e.to_string()))?; + + if let Some(reply_body) = reply { + stream.write_all(&frame(&reply_body)).await?; + stream.flush().await?; + } + } +} diff --git a/rust/gssproxy-server/tests/cli.rs b/rust/gssproxy-server/tests/cli.rs new file mode 100644 index 000000000..04cba47aa --- /dev/null +++ b/rust/gssproxy-server/tests/cli.rs @@ -0,0 +1,138 @@ +//! End-to-end CLI tests that exercise the built `gssproxy` daemon binary. +//! +//! These complement the pure-parser unit tests in `main.rs` by checking the +//! real process behaviour (exit codes, output, socket binding, the +//! "Initialization complete." readiness line the test harness waits for). The +//! C-vs-Rust parity for the shared flags is validated separately in +//! `nix/cli-tests.nix`, which has both binaries available. + +use std::io::{BufRead, BufReader, Read}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +fn bin() -> &'static str { + env!("CARGO_BIN_EXE_gssproxy") +} + +fn unique() -> u64 { + static N: AtomicU64 = AtomicU64::new(0); + N.fetch_add(1, Ordering::Relaxed) +} + +fn tmpdir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("gp-cli-{tag}-{}-{}", std::process::id(), unique())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +#[test] +fn version_exits_zero_with_output() { + let out = Command::new(bin()).arg("--version").output().unwrap(); + assert!(out.status.success(), "--version should exit 0"); + assert!(!out.stdout.is_empty(), "--version should print something"); + let s = String::from_utf8_lossy(&out.stdout); + assert!( + s.contains(env!("CARGO_PKG_VERSION")), + "version output: {s:?}" + ); +} + +#[test] +fn help_exits_zero() { + for flag in ["-h", "--help", "--usage"] { + let out = Command::new(bin()).arg(flag).output().unwrap(); + assert!(out.status.success(), "{flag} should exit 0"); + } +} + +#[test] +fn unknown_option_exits_nonzero_with_usage() { + let out = Command::new(bin()) + .arg("--definitely-not-a-flag") + .output() + .unwrap(); + assert!(!out.status.success(), "unknown option should fail"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("Usage:"), "stderr should show usage: {err:?}"); +} + +#[test] +fn missing_config_exits_nonzero() { + let dir = tmpdir("noconf"); + let out = Command::new(bin()) + .args([ + "-i", + "-s", + dir.join("s.sock").to_str().unwrap(), + "-c", + dir.join("does-not-exist.conf").to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(!out.status.success(), "missing config should fail to start"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn starts_and_prints_initialization_complete() { + let dir = tmpdir("init"); + let conf = dir.join("gssproxy.conf"); + let sock = dir.join("default.sock"); + // Minimal valid config: a single krb5 service (the loader requires at + // least one service section). The daemon binds the socket and reports + // readiness without needing a live KDC. + std::fs::write(&conf, "[service/test]\n mechs = krb5\n euid = 0\n").unwrap(); + + let mut child = Command::new(bin()) + .args([ + "-i", + "-s", + sock.to_str().unwrap(), + "-c", + conf.to_str().unwrap(), + ]) + .stderr(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + + let stderr = child.stderr.take().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + let mut r = BufReader::new(stderr); + let mut line = String::new(); + loop { + line.clear(); + match r.read_line(&mut line) { + Ok(0) => break, + Ok(_) => { + if line.contains("Initialization complete.") { + let _ = tx.send(true); + } + } + Err(_) => break, + } + } + // Drain remaining output so the pipe doesn't block the child on exit. + let mut sink = Vec::new(); + let _ = r.read_to_end(&mut sink); + }); + + let ready = rx.recv_timeout(Duration::from_secs(15)).unwrap_or(false); + + // Give the socket a brief moment to appear after the readiness line. + let deadline = Instant::now() + Duration::from_secs(2); + while !sock.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + let socket_bound = sock.exists(); + + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + + assert!(ready, "daemon should print 'Initialization complete.'"); + assert!(socket_bound, "daemon should bind the requested socket path"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/rust/gssproxy-server/tests/indicate_mechs.rs b/rust/gssproxy-server/tests/indicate_mechs.rs new file mode 100644 index 000000000..36a2100fc --- /dev/null +++ b/rust/gssproxy-server/tests/indicate_mechs.rs @@ -0,0 +1,163 @@ +//! End-to-end tests of the daemon's socket path: bind the listener, then drive +//! real requests over the Unix socket and decode the framed replies. This +//! exercises the whole pipeline (listener -> SunRPC framing -> envelope +//! validation -> dispatch -> handler -> encode), which the upstream test suite +//! cannot reach until more procedures are ported. + +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use gssproxy_server::config::Config; + +use gssproxy_proto::frame::{encode_header, parse_header}; +use gssproxy_proto::proc::{ + ArgGetCallContext, ArgIndicateMechs, ArgStoreCred, GssxProc, ResGetCallContext, + ResIndicateMechs, ResStoreCred, +}; +use gssproxy_proto::rpc::ReplyBody; +use gssproxy_proto::{Message, Xdr, XdrDecoder, encode_request}; + +/// DER encoding of the krb5 mechanism OID 1.2.840.113554.1.2.2. +const KRB5_MECH_OID: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x12, 0x01, 0x02, 0x02]; + +fn unique_socket_path() -> PathBuf { + let mut p = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + p.push(format!( + "gssproxy-rs-test-{}-{}.sock", + std::process::id(), + nanos + )); + p +} + +/// Start a daemon listener on a fresh socket and return a connected client. +fn connect_daemon() -> UnixStream { + let socket = unique_socket_path(); + let socket_for_server = socket.to_string_lossy().into_owned(); + let config = Arc::new(Mutex::new(Config::empty(&socket.to_string_lossy()))); + + // Detached listener thread; torn down when the test process exits. The + // config path is never read here (no SIGHUP is sent during the test). + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build runtime"); + let _ = rt.block_on(gssproxy_server::server::run( + socket_for_server, + PathBuf::from("/nonexistent/gssproxy.conf"), + config, + )); + }); + + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match UnixStream::connect(&socket) { + Ok(s) => return s, + Err(_) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(20)); + } + Err(e) => panic!("daemon never came up: {e}"), + } + } +} + +fn read_frame(stream: &mut UnixStream) -> Vec { + let mut header = [0u8; 4]; + stream.read_exact(&mut header).expect("read record header"); + let len = parse_header(u32::from_be_bytes(header)).expect("valid record header"); + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).expect("read record body"); + body +} + +/// Send one request and return the decoder positioned at the result body, after +/// validating the reply envelope (xid echo + MSG_ACCEPTED/SUCCESS). +fn round_trip(stream: &mut UnixStream, xid: u32, proc: GssxProc, arg: &A) -> Vec { + let body = encode_request(xid, proc as u32, arg); + let mut request = encode_header(body.len()).to_vec(); + request.extend_from_slice(&body); + stream.write_all(&request).expect("write request"); + stream.flush().expect("flush request"); + read_frame(stream) +} + +fn expect_success(reply: &[u8], xid: u32) -> XdrDecoder<'_> { + let mut d = XdrDecoder::new(reply); + let msg = Message::decode(&mut d).expect("decode reply envelope"); + assert_eq!(msg.xid, xid, "reply xid must echo the request xid"); + assert!(!msg.is_call, "reply must be a REPLY, not a CALL"); + assert!( + matches!(msg.reply, Some(ReplyBody::AcceptedSuccess { .. })), + "reply must be MSG_ACCEPTED + SUCCESS, got {:?}", + msg.reply + ); + d +} + +#[test] +fn indicate_mechs_round_trips_over_socket() { + let mut stream = connect_daemon(); + let xid = 0x1234_5678; + let reply = round_trip( + &mut stream, + xid, + GssxProc::IndicateMechs, + &ArgIndicateMechs::default(), + ); + let mut d = expect_success(&reply, xid); + + let res = ResIndicateMechs::decode(&mut d).expect("decode indicate_mechs result"); + assert_eq!(d.remaining(), 0, "no trailing bytes after the result"); + + // The build sandbox always has MIT krb5 compiled in, so indicate_mechs must + // succeed and advertise the krb5 mechanism. + assert_eq!( + res.status.major_status, 0, + "indicate_mechs failed: major=0x{:x}", + res.status.major_status + ); + assert!( + res.mechs.iter().any(|m| m.mech.0 == KRB5_MECH_OID), + "krb5 mechanism OID missing from indicate_mechs result" + ); +} + +/// `get_call_context` and `store_cred` are `GP_EXEC_UNUSED_FUNC` stubs in the C +/// daemon: they return `GSS_S_COMPLETE` with a zero-initialized result. Verify +/// the Rust daemon matches that on the wire (success + empty default body). +#[test] +fn unused_stub_procs_return_zeroed_success() { + let mut stream = connect_daemon(); + + let xid = 0xaaaa_0001; + let reply = round_trip( + &mut stream, + xid, + GssxProc::GetCallContext, + &ArgGetCallContext::default(), + ); + let mut d = expect_success(&reply, xid); + let res = ResGetCallContext::decode(&mut d).expect("decode get_call_context result"); + assert_eq!(d.remaining(), 0); + assert_eq!(res, ResGetCallContext::default(), "must be zeroed-success"); + + let xid = 0xaaaa_0002; + let reply = round_trip( + &mut stream, + xid, + GssxProc::StoreCred, + &ArgStoreCred::default(), + ); + let mut d = expect_success(&reply, xid); + let res = ResStoreCred::decode(&mut d).expect("decode store_cred result"); + assert_eq!(d.remaining(), 0); + assert_eq!(res, ResStoreCred::default(), "must be zeroed-success"); +} diff --git a/tests/runtests.py b/tests/runtests.py index 81a1de354..f7812824a 100755 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -12,7 +12,7 @@ from testlib import * def check_exec(name): - env = {'PATH': '/sbin:/bin:/usr/sbin:/usr/bin:/usr/lib/mit/sbin'} + env = {'PATH': testlib.TESTPATH} ret = subprocess.call(["which", name], stdout=subprocess.DEVNULL, env=env) if ret != 0: print(f"Executable '{name}' not found in {env['PATH']}", @@ -126,5 +126,11 @@ def runtests_main(testfiles): testfiles = [f for f in os.listdir(os.path.dirname(sys.argv[0])) \ if f.endswith(".py") and f.startswith("t_")] + # GSSPROXY_TEST_SKIP lets a caller exclude specific test files (comma + # separated, e.g. "t_impersonate.py"). Used when validating an external + # daemon that does not yet implement every feature exercised by the suite. + skip = set(filter(None, os.environ.get("GSSPROXY_TEST_SKIP", "").split(","))) + if skip: + testfiles = [f for f in testfiles if f not in skip] testfiles.sort() runtests_main(testfiles) diff --git a/tests/t_basic.py b/tests/t_basic.py index 4e634e7cd..4cc3bb977 100755 --- a/tests/t_basic.py +++ b/tests/t_basic.py @@ -60,7 +60,7 @@ def run(testdir, env, conf, expected_failure=False): stdin=pipe0[0], stdout=pipe1[1], stderr=init_logfile, env=clienv, preexec_fn=os.setsid, shell=True, - executable="/bin/bash") + executable=testlib.BASH) print("PID: %d\n" % p1.pid) print("Attach and start debugging, then press enter to start t_init.") input() @@ -69,7 +69,7 @@ def run(testdir, env, conf, expected_failure=False): stdin=pipe1[0], stdout=pipe0[1], stderr=accept_logfile, env=svcenv, preexec_fn=os.setsid, shell=True, - executable="/bin/bash") + executable=testlib.BASH) print("To resume tests if hung, kill pid %d\n" % p2.pid) p2.wait() @@ -83,7 +83,7 @@ def run(testdir, env, conf, expected_failure=False): stdin=pipe1[0], stdout=pipe0[1], stderr=accept_logfile, env=svcenv, preexec_fn=os.setsid, shell=True, - executable="/bin/bash") + executable=testlib.BASH) print("PID: %d\n" % p2.pid) print("Attach and start debugging, then press enter to start t_init.") input() @@ -92,7 +92,7 @@ def run(testdir, env, conf, expected_failure=False): stdin=pipe0[0], stdout=pipe1[1], stderr=init_logfile, env=clienv, preexec_fn=os.setsid, shell=True, - executable="/bin/bash") + executable=testlib.BASH) print("To resume tests if hung, kill pid %d\n" % p1.pid) p1.wait() @@ -109,12 +109,12 @@ def run(testdir, env, conf, expected_failure=False): stdin=pipe1[0], stdout=pipe0[1], stderr=accept_logfile, env=svcenv, preexec_fn=os.setsid, shell=True, - executable="/bin/bash") + executable=testlib.BASH) p1 = subprocess.Popen(init_cmd, stdin=pipe0[0], stdout=pipe1[1], stderr=init_logfile, env=clienv, preexec_fn=os.setsid, shell=True, - executable="/bin/bash") + executable=testlib.BASH) try: p1.wait(testlib.testcase_wait) diff --git a/tests/testlib.py b/tests/testlib.py index 468276c94..64f4fa7a0 100755 --- a/tests/testlib.py +++ b/tests/testlib.py @@ -22,6 +22,14 @@ valgrind_cmd = "valgrind", "--track-origins=yes" valgrind_everywhere = False +# The suite historically assumes an FHS layout (tools under /bin, /usr/bin, +# /usr/lib/mit/sbin, ...) and a system shell at /bin/bash. To allow running in +# sandboxed/non-FHS environments such as Nix builds, these can be overridden +# through the environment; the defaults reproduce the previous behavior. +_FHS_PATH = "/sbin:/bin:/usr/sbin:/usr/bin:/usr/lib/mit/sbin" +TESTPATH = (os.environ.get("PATH", "") + ":" + _FHS_PATH).strip(":") +BASH = os.environ.get("GSSPROXY_TEST_BASH", "/bin/bash") + try: from colorama import Fore, Style @@ -107,7 +115,7 @@ def run_testcase_cmd(env, conf, cmd, name, expected_failure=False, wait=True): p1 = subprocess.Popen(run_cmd, stderr=subprocess.STDOUT, stdout=logfile, env=testenv, preexec_fn=os.setsid, shell=True, - executable="/bin/bash") + executable=BASH) if not wait: cmd_index += 1 @@ -134,7 +142,7 @@ def rundebug_cmd(env, conf, cmd, name, expected_failure=False): run_cmd = debug_cmd + cmd returncode = subprocess.call(run_cmd, env=env, shell=True, - executable="/bin/bash") + executable=BASH) print_return(returncode, cmd_index, "(%d) %s" % (cmd_index, name), expected_failure) @@ -162,7 +170,15 @@ def setup_wrappers(base): with open(hosts_file, 'w+') as f: f.write('127.0.0.9 %s' % WRAP_HOSTNAME) - wenv = {'LD_PRELOAD': 'libsocket_wrapper.so libnss_wrapper.so', + # The loader resolves these by SONAME via the default search path. In + # environments where the wrapper libraries are not on that path (e.g. Nix), + # absolute paths can be supplied through the environment. + socket_wrapper_lib = os.environ.get('GSSPROXY_TEST_SOCKET_WRAPPER_LIB', + 'libsocket_wrapper.so') + nss_wrapper_lib = os.environ.get('GSSPROXY_TEST_NSS_WRAPPER_LIB', + 'libnss_wrapper.so') + + wenv = {'LD_PRELOAD': '%s %s' % (socket_wrapper_lib, nss_wrapper_lib), 'SOCKET_WRAPPER_DIR': wrapdir, 'SOCKET_WRAPPER_DEFAULT_IFACE': '9', 'NSS_WRAPPER_HOSTNAME': WRAP_HOSTNAME, @@ -290,9 +306,14 @@ def write_ldap_krb5_config(testdir): os.makedirs(kdcdir) # Template LDAP config files - # Different distros do LDAP naming differently + # Different distros do LDAP naming differently; an explicit directory can + # also be provided through the environment for non-FHS layouts (e.g. Nix). schemadir = None - for path in ["/etc/openldap/schema", "/etc/ldap/schema"]: + schemadirs = ["/etc/openldap/schema", "/etc/ldap/schema"] + env_schemadir = os.environ.get("GSSPROXY_TEST_OPENLDAP_SCHEMA_DIR") + if env_schemadir: + schemadirs.insert(0, env_schemadir) + for path in schemadirs: if os.path.exists(path): schemadir = path break @@ -300,9 +321,13 @@ def write_ldap_krb5_config(testdir): raise ValueError("Did not find LDAP schemas; is openldap installed?") k5schema = None - for path in ["/usr/share/doc/krb5-server-ldap*/kerberos.schema", + k5schemas = ["/usr/share/doc/krb5-server-ldap*/kerberos.schema", "/usr/share/kerberos/ldap/kerberos.schema", - "/usr/share/doc/krb5-kdc-ldap/kerberos.schema.gz"]: + "/usr/share/doc/krb5-kdc-ldap/kerberos.schema.gz"] + env_k5schema = os.environ.get("GSSPROXY_TEST_KRB5_LDAP_SCHEMA") + if env_k5schema: + k5schemas.insert(0, env_k5schema) + for path in k5schemas: pathlist = glob.glob(path) if len(pathlist) > 0: k5schema = pathlist[0] @@ -368,7 +393,7 @@ def setup_ldap(testdir, wrapenv): stashfile = os.path.join(testdir, "ldap_passwd") krb5conf = os.path.join(testdir, 'krb5.conf') - ldapenv = {'PATH': '/sbin:/bin:/usr/sbin:/usr/bin:/usr/lib/mit/sbin', + ldapenv = {'PATH': TESTPATH, 'KRB5_CONFIG': krb5conf} ldapenv.update(wrapenv) @@ -412,7 +437,7 @@ def setup_kdc(testdir, wrapenv): kdcstash = os.path.join(kdcdir, KDC_STASH) kdcdb = os.path.join(kdcdir, KDC_DBNAME) - kdcenv = {'PATH': '/sbin:/bin:/usr/sbin:/usr/bin:/usr/lib/mit/sbin', + kdcenv = {'PATH': TESTPATH, 'KRB5_CONFIG': krb5conf, 'KRB5_KDC_PROFILE': kdcconf} kdcenv.update(wrapenv) @@ -613,17 +638,11 @@ def setup_gssapi_env(testdir, wrapenv): libgssapi_lib = os.path.join(libgssapi_dir, os.path.basename(lib)) libgssapi_conf = os.path.join(libgssapi_mechd_dir, 'gssproxy-mech.conf') - # horrible, horrible hack to load our own configuration later - with open(lib, 'rb') as f: - data = binascii.hexlify(f.read()) - with open(libgssapi_lib, 'wb') as f: - data = data.replace(binascii.hexlify(b'/etc/gss/mech.d'), - binascii.hexlify( - libgssapi_mechd_dir.encode("utf-8"))) - f.write(binascii.unhexlify(data)) - - shutil.copy('.libs/proxymech.so', libgssapi_dir) - proxymech = os.path.join(libgssapi_dir, 'proxymech.so') + # GSSPROXY_TEST_PROXYMECH lets the suite point at an externally built + # interposer (e.g. the Rust libproxymech.so) instead of the in-tree C build. + proxymech_src = os.environ.get("GSSPROXY_TEST_PROXYMECH", '.libs/proxymech.so') + shutil.copy(proxymech_src, libgssapi_dir) + proxymech = os.path.join(libgssapi_dir, os.path.basename(proxymech_src)) t = Template(MECH_CONF_TEMPLATE) text = t.substitute({'PROXYMECH': proxymech}) @@ -634,6 +653,28 @@ def setup_gssapi_env(testdir, wrapenv): gssapi_env = dict() gssapi_env.update(wrapenv) + # The interposer mech config has to be loaded by libgssapi. The historical + # approach binary-patches the embedded "/etc/gss/mech.d" path in a private + # copy of libgssapi and LD_PRELOADs it. That only works when the embedded + # path is exactly that long, which is not the case on distributions that + # build krb5 with a non-FHS prefix (e.g. Nix). When requested, use the + # GSS_MECH_CONFIG environment variable instead, which MIT krb5 honors to + # load a mechanism config file directly. + if os.environ.get("GSSPROXY_TEST_USE_GSS_MECH_CONFIG"): + gssapi_env['GSS_MECH_CONFIG'] = libgssapi_conf + if 'LD_PRELOAD' in wrapenv: + gssapi_env['LD_PRELOAD'] = wrapenv['LD_PRELOAD'] + return gssapi_env + + # horrible, horrible hack to load our own configuration later + with open(lib, 'rb') as f: + data = binascii.hexlify(f.read()) + with open(libgssapi_lib, 'wb') as f: + data = data.replace(binascii.hexlify(b'/etc/gss/mech.d'), + binascii.hexlify( + libgssapi_mechd_dir.encode("utf-8"))) + f.write(binascii.unhexlify(data)) + # then augment preload if any ld_pre = '' if 'LD_PRELOAD' in wrapenv: @@ -746,7 +787,10 @@ def setup_gssproxy(testdir, env): socket = os.path.join(gssproxy, 'gp.sock') conf = os.path.join(gssproxy, 'gp.conf') - cmd = "./gssproxy -i -s " + socket + " -c " + conf + # GSSPROXY_TEST_DAEMON lets the suite launch an externally built daemon + # (e.g. the Rust gssproxy binary) instead of the in-tree ./gssproxy. + gpbin = os.environ.get("GSSPROXY_TEST_DAEMON", "./gssproxy") + cmd = gpbin + " -i -s " + socket + " -c " + conf full_command = valgrind_cmd + cmd @@ -756,7 +800,7 @@ def setup_gssproxy(testdir, env): gproc = subprocess.Popen(full_command, stdout=logfile, stderr=logfile, env=gpenv, preexec_fn=os.setsid, shell=True, - executable="/bin/bash") + executable=BASH) if debug_gssproxy: print("PID: %d" % (gproc.pid))