diff --git a/.claude/skills/releng/SKILL.md b/.claude/skills/releng/SKILL.md index 0a7718a..59de09a 100644 --- a/.claude/skills/releng/SKILL.md +++ b/.claude/skills/releng/SKILL.md @@ -40,7 +40,7 @@ If signing fails with `Inappropriate ioctl for device`, ask the user to unlock t ## 4. Release workflow -The tag triggers `.github/workflows/release.yml`: test -> create-release (DRAFT, checks the tag matches `Cargo.toml`) -> upload-binaries (linux musl x86_64/aarch64 with rustls, x86_64/aarch64 apple-darwin; each archive holds `mairu` and its attestation `mairu.sigstore.json`) and upload-debs (attested too) -> publish-crate (crates.io trusted publishing, runs last). +The tag triggers `.github/workflows/release.yml`: test -> create-release (DRAFT, checks the tag matches `Cargo.toml`) -> upload-binaries (linux musl x86_64/aarch64 with rustls, x86_64/aarch64 apple-darwin; each archive holds `mairu` and its attestation `mairu.sigstore.json`) and upload-debs (attested too), both after windows-hello (builds `mairu-windows-hello.exe` for x86_64/aarch64-pc-windows-msvc, attests it, uploads `mairu-windows-hello-.zip`; the Linux archives and debs ship the matching `.exe` and record its SHA-256) -> publish-crate (crates.io trusted publishing, runs last). Watch it (`gh run watch`). (user) The user publishes the draft release. diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index f8089ba..908a970 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -64,6 +64,21 @@ jobs: - run: cargo build --target ${{ matrix.target }} --no-default-features --features platform-helper --bin mairu-platform-helper - run: cargo clippy --target ${{ matrix.target }} --no-default-features --features platform-helper --all-targets -- -D warnings + windows-hello-helper: + name: cargo build (Windows Hello helper) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + target: [x86_64-pc-windows-msvc, aarch64-pc-windows-msvc] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + with: { target: "${{ matrix.target }}" } + - run: rustup component add clippy + - run: cargo build --target ${{ matrix.target }} --no-default-features --features windows-hello-helper --bin mairu-windows-hello + - run: cargo clippy --target ${{ matrix.target }} --no-default-features --features windows-hello-helper --all-targets -- -D warnings + linux-hardened: name: linux-hardened runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2062584..648d3ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,17 +44,60 @@ jobs: env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' - upload-binaries: + # Linux archives and debs ship the helper of the matching Windows architecture, so it builds first. + windows-hello: needs: create-release + strategy: + matrix: + target: [x86_64-pc-windows-msvc, aarch64-pc-windows-msvc] + runs-on: windows-latest + permissions: + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + with: { target: "${{ matrix.target }}" } + - run: "cargo build --release --target ${{ matrix.target }} --no-default-features --features windows-hello-helper --bin mairu-windows-hello" + - uses: actions/attest-build-provenance@v4 + id: attest + with: + subject-path: "target/${{ matrix.target }}/release/mairu-windows-hello.exe" + - name: create archive + shell: bash + env: + TARGET: "${{ matrix.target }}" + BUNDLE_PATH: "${{ steps.attest.outputs.bundle-path }}" + run: | + mkdir -p dist + cp "target/${TARGET}/release/mairu-windows-hello.exe" dist/ + cp "$BUNDLE_PATH" dist/mairu-windows-hello.exe.sigstore.json + cd dist + 7z a "../mairu-windows-hello-${TARGET}.zip" mairu-windows-hello.exe mairu-windows-hello.exe.sigstore.json + - uses: actions/upload-artifact@v4 + with: + name: "mairu-windows-hello-${{ matrix.target }}" + path: "target/${{ matrix.target }}/release/mairu-windows-hello.exe" + if-no-files-found: error + - run: 'gh release upload "${{ github.ref_name }}" "mairu-windows-hello-${{ matrix.target }}.zip"' + shell: bash + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + + upload-binaries: + needs: [create-release, windows-hello] strategy: matrix: include: - target: x86_64-unknown-linux-musl os: ubuntu-latest features: rustls + windows-hello: x86_64-pc-windows-msvc - target: aarch64-unknown-linux-musl os: ubuntu-latest features: rustls + windows-hello: aarch64-pc-windows-msvc - target: x86_64-apple-darwin os: macos-latest - target: aarch64-apple-darwin @@ -71,6 +114,15 @@ jobs: - uses: taiki-e/setup-cross-toolchain-action@v1 with: target: "${{ matrix.target }}" + - uses: actions/download-artifact@v4 + if: "${{ matrix.windows-hello }}" + with: + name: "mairu-windows-hello-${{ matrix.windows-hello }}" + path: target/windows-hello + - name: pin the Windows Hello helper + if: "${{ matrix.windows-hello }}" + shell: bash + run: echo "MAIRU_WINDOWS_HELLO_HELPER_SHA256=$(sha256sum target/windows-hello/mairu-windows-hello.exe | cut -d' ' -f1)" >> "$GITHUB_ENV" - run: "cargo build --release --target ${{ matrix.target }} --no-default-features --features ${{ matrix.features || 'default' }}" - uses: actions/attest-build-provenance@v4 id: attest @@ -83,23 +135,30 @@ jobs: BUNDLE_PATH: "${{ steps.attest.outputs.bundle-path }}" run: | mkdir -p dist + files=(mairu mairu.sigstore.json) cp "target/${TARGET}/release/mairu" dist/ cp "$BUNDLE_PATH" dist/mairu.sigstore.json - tar -C dist -czf "mairu-${TARGET}.tar.gz" mairu mairu.sigstore.json + if [ -e target/windows-hello/mairu-windows-hello.exe ]; then + install -m 0755 target/windows-hello/mairu-windows-hello.exe dist/ + files+=(mairu-windows-hello.exe) + fi + tar -C dist -czf "mairu-${TARGET}.tar.gz" "${files[@]}" - run: 'gh release upload "${{ github.ref_name }}" "mairu-${{ matrix.target }}.tar.gz"' shell: bash env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' upload-debs: - needs: create-release + needs: [create-release, windows-hello] strategy: matrix: include: - target: x86_64-unknown-linux-musl os: ubuntu-latest + windows-hello: x86_64-pc-windows-msvc - target: aarch64-unknown-linux-musl os: ubuntu-latest + windows-hello: aarch64-pc-windows-msvc runs-on: ${{ matrix.os }} permissions: contents: write @@ -120,8 +179,15 @@ jobs: echo '[target.${{ matrix.target }}]' >> .cargo/config echo 'strip = { path = "${{ matrix.target }}-strip" }' >> .cargo/config echo 'objcopy = { path = "${{ matrix.target }}-objcopy" }' >> .cargo/config + - uses: actions/download-artifact@v4 + with: + name: "mairu-windows-hello-${{ matrix.windows-hello }}" + path: target/windows-hello + - name: pin the Windows Hello helper + shell: bash + run: echo "MAIRU_WINDOWS_HELLO_HELPER_SHA256=$(sha256sum target/windows-hello/mairu-windows-hello.exe | cut -d' ' -f1)" >> "$GITHUB_ENV" - run: 'cargo install cargo-deb --locked' - - run: 'cargo deb --target=${{ matrix.target }}' + - run: 'cargo deb --target=${{ matrix.target }} --variant=release' - uses: actions/attest-build-provenance@v4 with: subject-path: "target/${{ matrix.target }}/debian/*.deb" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a395d6..7dfa59e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,12 +21,14 @@ - A hardened agent can approve requests by a signature from your SSH agent at `$SSH_AUTH_SOCK`, including one forwarded with `ssh -A`: register public keys as `ssh_agent_keys` in `authorization.json` and approve the file once. Each challenge is single use and bound to the requesting process and request; FIDO `sk-` keys must assert user presence, and optionally user verification. A stale or unreachable socket falls back to polkit. Once registered keys are approved, the next policy change needs a signature by one of them. `mairu doctor` shows whether `SSH_AUTH_SOCK` is reachable and which registered keys it holds. - `mairu login` opens the authorize URL in your browser, and clients post desktop notifications when a `mairu exec` needs a login, when an AI agent's request is refused, and when the policy is in lockdown. Each client picks `auto`, `agent` (the agent's helper, through the desktop portal or `xdg-open` and `org.freedesktop.Notifications` on the client's session), `command` (your own program) or `none`, through `--no-browser`, `MAIRU_OPEN_URL`/`MAIRU_NOTIFY` (`*_COMMAND`), or `open_url`/`notify` in `config.json`. `auto` honors `$BROWSER`, and over SSH without a display nothing is shown. `mairu doctor` shows the modes and what the agent can deliver. - On WSL2, the agent tells the integrated profile (Windows interop on, where any program running as you can become root through `wsl.exe -u root`) from the isolated one. `mairu harden` warns and shows how to turn interop off, `mairu doctor` shows the WSL version, interop, profile and SSH agent, and policies can refuse roles while interop is on with `context.wsl_interop`. With interop on, `agent` mode opens URLs in the Windows default browser. WSL1 cannot be hardened. +- On WSL2 with interop on, a hardened agent can approve requests and policy changes by a signature from a Windows Hello key (PIN, fingerprint or face): `mairu policy enroll-windows-hello` creates the key and prints the `windows_hello_keys` entry for `authorization.json`. Linux release archives and the Debian package ship the signer `mairu-windows-hello.exe` and pin its SHA-256; builds from source use `windows_hello_helper` in `config.json` and `windows_hello_helper_sha256` in `authorization.json`. Cancelling the Windows Hello dialog denies the request. `mairu doctor` shows the helper and the key. - The agent runs platform services in a child process, `mairu platform-helper`, that holds no tokens. The agent and the helper verify each other, the helper exits with the agent, and a helper that died is restarted within 30 seconds; meanwhile those services fail closed. `mairu doctor` shows the helper's state and capabilities. ### Miscellaneous - macOS binaries are now published per architecture as `mairu-x86_64-apple-darwin.tar.gz` and `mairu-aarch64-apple-darwin.tar.gz`. The universal binary `mairu-universal-apple-darwin.tar.gz` is no longer published. - Debug builds use a separate `dev` profile (`mairu-dev-agent.sock`, `~/.local/state/mairu-dev`), so a development build no longer talks to the installed agent. `MAIRU_PROFILE` overrides it. +- Releases carry `mairu-windows-hello-x86_64-pc-windows-msvc.zip` and `mairu-windows-hello-aarch64-pc-windows-msvc.zip`, and Linux archives grow by the Windows Hello helper. New `windows-hello-helper` Cargo feature builds it: `cargo build --target x86_64-pc-windows-msvc --no-default-features --features windows-hello-helper --bin mairu-windows-hello`. - New `platform-helper` Cargo feature builds a standalone `mairu-platform-helper` binary without the client and agent: `cargo build --no-default-features --features platform-helper --bin mairu-platform-helper`. - The client and agent are behind a new default `cli` Cargo feature. `native-tls` and `rustls` imply it, so existing build commands such as `--no-default-features --features rustls` still build the full CLI. diff --git a/Cargo.lock b/Cargo.lock index c0079e0..61e8376 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2375,6 +2375,7 @@ dependencies = [ "prost-types", "rand 0.9.3", "reqwest 0.12.26", + "rsa", "rustix", "secrecy", "serde", @@ -2400,6 +2401,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "url", + "windows", "zbus", "zbus_polkit", "zeroize", @@ -5025,6 +5027,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -5038,6 +5061,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -5066,6 +5100,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -5155,6 +5199,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index ca990df..bce15f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,15 @@ assets = [ ] maintainer-scripts = "pkg/debian/" +# Release debs, with the Windows Hello helper the release workflow puts in target/windows-hello/. +[package.metadata.deb.variants.release] +name = "mairu" +assets = [ + ["target/release/mairu", "usr/bin/", "755"], + ["pkg/polkit/co.0w0.mairu.policy", "usr/share/polkit-1/actions/", "644"], + ["target/windows-hello/mairu-windows-hello.exe", "usr/lib/mairu/", "755"], +] + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [build-dependencies] shadow-rs = { version = "2", default-features = false, features = ["build"] } @@ -65,6 +74,7 @@ cli = [ "dep:oauth2", "dep:process_path", "dep:reqwest", + "dep:rsa", "dep:serde_with", "dep:sigstore-trust-root", "dep:sigstore-verify", @@ -84,6 +94,8 @@ rustls = ["cli", "reqwest/rustls-tls-native-roots", "sigstore-trust-root?/rustls platform-services = ["dep:zbus", "dep:zbus_polkit", "tokio/process"] # The standalone helper binary, built without `cli`. platform-helper = ["platform-services"] +# `mairu-windows-hello.exe`, built for Windows without `cli`. +windows-hello-helper = ["dep:windows"] [[bin]] name = "mairu" @@ -95,6 +107,11 @@ name = "mairu-platform-helper" path = "src/bin/mairu_platform_helper.rs" required-features = ["platform-helper"] +[[bin]] +name = "mairu-windows-hello" +path = "src/bin/mairu_windows_hello.rs" +required-features = ["windows-hello-helper"] + [dependencies] anyhow = { version = "1.0.100", features = ["backtrace"] } async-recursion = { version = "1.1.1", optional = true } @@ -122,7 +139,6 @@ hyper = { version = "1.8.1", features = ["http1", "server"], optional = true } hyper-util = { version = "0.1.19", optional = true } indoc = { version = "2.0.7", optional = true } libc = "0.2.178" -nix = { version = "0.30.1", features = ["resource", "process", "ptrace", "mman", "fs", "signal", "hostname", "user", "socket", "time"] } oauth2 = { version = "5.0.0", default-features = false, features = ["reqwest"], optional = true } once_cell = { version = "1.21.3", features = ["parking_lot"] } parking_lot = "0.12.5" @@ -153,6 +169,11 @@ shadow-rs = { version = "2", default-features = false } # ssh-agent presence: SSHSIG verification and the agent protocol. ssh-encoding = { version = "0.2.0", features = ["std"], optional = true } ssh-key = { version = "0.6.7", default-features = false, features = ["std", "ed25519", "p256", "p384", "p521", "rsa"], optional = true } +# windows-hello presence: RSA PKCS #1 v1.5 verification. +rsa = { version = "0.9.10", default-features = false, features = ["std", "sha2"], optional = true } + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.30.1", features = ["resource", "process", "ptrace", "mman", "fs", "signal", "hostname", "user", "socket", "time"] } # `mairu harden --from` verifies release attestations. [target.'cfg(target_os = "linux")'.dependencies] @@ -166,6 +187,9 @@ zbus_polkit = { version = "6.0.0", default-features = false, features = ["tokio" [target.'cfg(any(target_os = "freebsd", target_os = "dragonfly"))'.dependencies] rustix = { version = "1.1.2", default-features = false, features = ["std", "process"] } +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62.2", default-features = false, features = ["std", "Foundation", "Security_Credentials", "Security_Cryptography", "Security_Cryptography_Core", "Storage_Streams", "Win32_Foundation", "Win32_UI_WindowsAndMessaging"], optional = true } + [dev-dependencies] # Signature fixtures for sk- keys, which no SSH agent library signs without hardware. ed25519-dalek = "2.2.0" diff --git a/README.md b/README.md index acfc9f0..52cfc37 100644 --- a/README.md +++ b/README.md @@ -534,6 +534,8 @@ Root can replace the hardened copy and read the agent's memory, so hardening pro Packages install `/usr/bin/mairu` as `root:mairu 2755` with `cap_ipc_lock` (setcap after chgrp, which clears file capabilities), create `/var/lib/mairu` and `/var/lib/mairu/users` as `root:mairu 2770`, and install [pkg/polkit/co.0w0.mairu.policy](./pkg/polkit/co.0w0.mairu.policy) into `/usr/share/polkit-1/actions/`. [pkg/debian/postinst](./pkg/debian/postinst) does this for the Debian package; [pkg/sysusers.d](./pkg/sysusers.d/mairu.conf) and [pkg/tmpfiles.d](./pkg/tmpfiles.d/mairu.conf) declare the group and directories for systemd distributions. +A package can ship the Windows Hello helper of the same release (from `mairu-windows-hello--pc-windows-msvc.zip`, or built with `--features windows-hello-helper`) as `/usr/lib/mairu/mairu-windows-hello.exe`, mode 0755, and build `mairu` with `MAIRU_WINDOWS_HELLO_HELPER_SHA256` set to its SHA-256, so that the client runs it without further setup. + ### WSL2 mairu hardens a WSL2 distribution as any Linux host, with one limit: with Windows interop on, which is the WSL default, any program running as you can become root through `wsl.exe -u root` without a prompt, and bypass mairu. `harden` and `doctor` say so on every WSL2 host. @@ -542,7 +544,7 @@ mairu hardens a WSL2 distribution as any Linux host, with one limit: with Window |---|---|---| | Strength | A guardrail against over-eager tools such as AI agents, and against accidents | As native Linux against code inside the distribution | | Opening URLs (`agent` mode) | The Windows default browser, through `rundll32.exe url.dll,FileProtocolHandler` | Printed | -| Approving | Your Windows OpenSSH agent through a relay, or `pkttyagent` | `ssh -A` from Windows into the distribution, or `pkttyagent` | +| Approving | Windows Hello, your Windows OpenSSH agent through a relay, or `pkttyagent` | `ssh -A` from Windows into the distribution, or `pkttyagent` | To switch to the isolated profile, add to `/etc/wsl.conf` and restart the distribution from Windows with `wsl.exe --terminate `: @@ -554,7 +556,7 @@ appendWindowsPath = false Even then, the Windows account and every other distribution with interop on can become root in this one (`wsl.exe -d -u root`); they are outside what mairu protects against. -- `mairu doctor` shows the WSL version, whether interop is on, the profile, and whether `$SSH_AUTH_SOCK` reaches an SSH agent. +- `mairu doctor` shows the WSL version, whether interop is on, the profile, whether `$SSH_AUTH_SOCK` reaches an SSH agent, and the Windows Hello helper and key. - `context.wsl_interop` is present only on WSL2, and is true while interop is on. Refuse sensitive accounts with it: ```cedar @@ -564,6 +566,7 @@ Even then, the Windows account and every other distribution with interop on can ``` - To approve with your Windows OpenSSH agent in the integrated profile, relay it into the distribution, with [npiperelay](https://github.com/jstarks/npiperelay) and `socat`, or with [wsl2-ssh-agent](https://github.com/mame/wsl2-ssh-agent), and register its key as `ssh_agent_keys` (see [SSH agent](#ssh-agent)). A Windows agent that asks for Windows Hello before each signature works too; mairu sees an ordinary signature. +- In the integrated profile, Windows Hello can approve requests and policy changes; see [Windows Hello](#windows-hello). - WSLg provides no notification daemon. For Windows toasts, use `command` mode with a helper such as [wsl-notify-send](https://github.com/stuartleeks/wsl-notify-send): ```sh @@ -579,6 +582,49 @@ Even then, the Windows account and every other distribution with interop on can - [WSL-Hello-sudo](https://github.com/nullpo-head/WSL-Hello-sudo) (`pam_wsl_hello`) backs `sudo` with Windows Hello, which closes the `sudo` path to root (see [Root](#root)) but not the interop one. - WSL1 is not supported: `harden` refuses it, and a hardened copy there runs as misconfigured. +#### Windows Hello + +With interop on, a hardened agent can approve requests by a signature from a Windows Hello key: the PIN, fingerprint or face you use for Windows. The key is created by Windows Hello on your TPM, and every signature asks for the gesture. `mairu-windows-hello.exe` signs the agent's challenge on the Windows side, and the agent verifies the signature against the public key registered in `authorization.json`; nothing the helper says counts on its own. + +Linux release archives ship `mairu-windows-hello.exe` next to `mairu`, and the Debian package installs it as `/usr/lib/mairu/mairu-windows-hello.exe`; `mairu harden` copies it next to the hardened copy. These builds pin the helper's SHA-256, and run it only while it matches. + +1. Create the key and print its entry: + + ```sh + mairu policy enroll-windows-hello + ``` + +2. Add the entry to `windows_hello_keys` in `~/.config/mairu/authorization.json`: + + ```jsonc + { + "version": 1, + // Default: the key-based methods with registered keys, then "platform". + "methods": ["windows-hello", "platform"], + "windows_hello_keys": [ + {"name": "desktop", "public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."} + ] + } + ``` + +3. Approve it with `mairu policy approve`. The first registration is approved through polkit; from then on, only Windows Hello approves the next policy change. + +When a request needs a fresh check, mairu prints the request and `Approve in the Windows Hello dialog …`, and Windows shows the dialog. Cancelling it, or letting it time out, denies the request; no other method asks instead. `context.presence.method` is `"windows-hello"` for a request Windows Hello approved, so a policy can require it, as with [the SSH agent](#ssh-agent). + +Builds from source (`cargo install`, AUR `mairu`) ship no helper. Download `mairu-windows-hello--pc-windows-msvc.zip` of the same release, point `windows_hello_helper` in `~/.config/mairu/config.json` at the `.exe`, and pin its SHA-256 in `authorization.json` before enrolling, approved like any other setting: + +```jsonc +// ~/.config/mairu/config.json +{"windows_hello_helper": "/mnt/c/Users/me/AppData/Local/mairu/mairu-windows-hello.exe"} +``` + +```jsonc +// ~/.config/mairu/authorization.json +{"version": 1, "windows_hello_helper_sha256": ""} +``` + +A hardened mairu refuses to run a helper that matches no pin, because a replaced helper could have your Windows Hello gesture sign something else, such as a policy approval. It cannot forge a signature. + ## Credential Server API If you don't have the AWS SSO instance, you need to run your own credential vending server to serve AWS credentials for your Mairu users. You may use a known compatible implementations, or implement your own. diff --git a/build.rs b/build.rs index fb82cc6..a394103 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,11 @@ fn main() -> Result<(), Box> { // minimum_versions() names its own input, which turns off Cargo's default of rerunning on any change. println!("cargo:rerun-if-changed=proto"); + windows_hello_helper_sha256()?; + // On Windows only mairu-windows-hello builds, and the library is empty. + if std::env::var("CARGO_CFG_TARGET_FAMILY").as_deref() != Ok("unix") { + return Ok(()); + } tonic_prost_build::configure() .message_attribute("mairu.Credentials", "#[derive(zeroize::ZeroizeOnDrop)]") .field_attribute("mairu.Credentials.expiration", "#[zeroize(skip)]") @@ -58,3 +63,18 @@ fn minimum_versions() -> Result<(), Box> { } Ok(()) } + +/// Re-exports `MAIRU_WINDOWS_HELLO_HELPER_SHA256`, the SHA-256 of the helper a release ships, after checking its form. +fn windows_hello_helper_sha256() -> Result<(), Box> { + const NAME: &str = "MAIRU_WINDOWS_HELLO_HELPER_SHA256"; + println!("cargo:rerun-if-env-changed={NAME}"); + let Ok(value) = std::env::var(NAME) else { + return Ok(()); + }; + let value = value.trim().to_ascii_lowercase(); + if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(format!("{NAME} is not a hex SHA-256: {value:?}").into()); + } + println!("cargo:rustc-env={NAME}={value}"); + Ok(()) +} diff --git a/docs/specs/016-windows-hello.md b/docs/specs/016-windows-hello.md index 5dd65e2..274b6e0 100644 --- a/docs/specs/016-windows-hello.md +++ b/docs/specs/016-windows-hello.md @@ -313,21 +313,37 @@ Q-W6 helper run from the Linux filesystem (open) ## Current Status -Spec drafted; awaiting review before implementation. Implementors MUST keep this section updated as they work. +Implemented on `hardening/windows-hello`. The Linux side is verified with unit tests and in Docker with faked WSL2 and a fake helper; the Windows helper type-checks for both Windows targets but has not run on Windows, and Q-W6 stays open (see Updates). Implementors MUST keep this section updated as they work. ### Checklist -- [ ] Windows Hello method: agent verification, client relay (`src/windows_hello.rs`) -- [ ] `mairu-windows-hello.exe` as a feature-gated `[[bin]]`, with the dialog brought to the front -- [ ] `mairu policy enroll-windows-hello` -- [ ] Proto: `windows_hello_signature`; `windows-hello` challenge values -- [ ] Settings: `windows_hello_keys`, `windows-hello` method; `UserConfig.windows_hello_helper` -- [ ] `doctor`: helper reachability and enrollment -- [ ] Shipping in Linux release archives and debs; SHA-256 pin (`MAIRU_WINDOWS_HELLO_HELPER_SHA256`, `windows_hello_helper_sha256`) checked before each run -- [ ] Release workflow: Windows helper builds first in the same run; Linux tarballs and debs ship them +- [x] Windows Hello method: agent verification, client relay (`src/windows_hello.rs`) +- [x] `mairu-windows-hello.exe` as a feature-gated `[[bin]]`, with the dialog brought to the front. Type-checked only; not yet run on Windows. +- [x] `mairu policy enroll-windows-hello` +- [x] Proto: `windows_hello_signature`; `windows-hello` challenge values +- [x] Settings: `windows_hello_keys`, `windows-hello` method; `UserConfig.windows_hello_helper` +- [x] `doctor`: helper reachability and enrollment +- [x] Shipping in Linux release archives and debs; SHA-256 pin (`MAIRU_WINDOWS_HELLO_HELPER_SHA256`, `windows_hello_helper_sha256`) checked before each run +- [x] Release workflow: Windows helper builds first in the same run; Linux tarballs and debs ship them. Not yet run on GitHub Actions. - [x] Settle Q-W2, Q-W3 and Q-W4 on a WSL2 host - [ ] Settle Q-W6 on a WSL2 host +- [ ] Manual checks on a Windows host (Tests): enrollment, approval, the dialog in the foreground, cancellation ### Updates - 2026-10-29: Split out of hardening.md, policy.md and platform-helper.md. +- 2026-10-31: Implemented. + - The helper (`src/bin/mairu_windows_hello.rs`, `windows` 0.62) has three commands: `public-key` (`KeyCredentialManager::OpenAsync` of `co.0w0.mairu`, SubjectPublicKeyInfo), `create` (`RequestCreateAsync` with `FailIfExists`; the client falls back to `public-key` when it exists) and `sign` (base64 statement on stdin, `RequestSignAsync` over the bytes themselves). Output is one base64 line; exit statuses: 2 cancelled (`UserCanceled`, `UserPrefersPassword`), 3 no key credential, 4 exists, 5 Windows Hello not set up, 1 other failures. A thread brings the dialog forward with `SetForegroundWindow` on the `Credential Dialog Xaml Host` window class, as WSL-Hello-sudo does. + - The package builds for Windows without `cli`: `nix` moved to `cfg(unix)` dependencies, `src/lib.rs` is `#![cfg(unix)]`, and `build.rs` compiles no protos for non-Unix targets, so the Windows runners need no `protoc`. Verified here: `cargo check` and `cargo clippy --all-targets -D warnings` with `--no-default-features --features windows-hello-helper` for `x86_64-pc-windows-msvc` and `aarch64-pc-windows-msvc` (Rust 1.96 and 1.99.1). It also links for `x86_64-pc-windows-gnu` with mingw-w64 in Docker (a 320 KB console PE importing `RoGetActivationFactory`, `FindWindowW` and `SetForegroundWindow`). Not verified: the MSVC builds (CI's `windows-hello-helper` job and the release job build them on `windows-latest`), and running it. + - Agent side (`src/authorization.rs`, `src/windows_hello.rs`): the ssh-agent challenge flow takes a method. Challenges record their method; a response is verified only against a challenge of its own kind. Verification is RSA PKCS #1 v1.5 with SHA-256 over the statement, against each registered key (`rsa` crate); the Q-W2 vector verifies, and its PSS counterpart, another key and an altered statement do not. Keys must be RSA-2048. + - With several key-based methods in the order, one retry carries one response: a signature is processed only by the method of its challenge; a decline covers its own method and every key-based method before it in the order, so the next one asks and no declined one asks again. Default order: `ssh-agent`, then `windows-hello`, then `platform`, as in the settings comment above. + - Deviation, cancellation: the client still answers a cancelled or timed-out dialog, with `declined = "cancelled"`, and the agent denies (`REASON_DENIED`) without trying another method. Without that answer the outstanding challenge kept its human check open for its 60 s, and a `mairu policy approve` right after a cancellation was refused as "a credential check is pending". + - Deviation, pin delivery: `AuthorizationChallenge.windows_hello_helper_sha256 = 6` carries the pin in force from the agent, since the client cannot tell by itself whether the settings are approved. A build's recorded pin wins over it. + - Deviation, anchored pin: `ApprovalAnchor.windows_hello_helper_sha256 = 4` (and `windows_hello_helper_sha256` in the integrity record JSON, omitted when empty) records the approved pin. While the files are unapproved or in lockdown, which is always the case when approving a change, the effective settings take the pin from the anchor, as they take the keys; otherwise a build without a recorded pin could never approve a change through Windows Hello. A record that carries a pin cannot be read by mairu builds before this change. + - Client relay: `ssh_agent_presence::Relay` dispatches challenges by method; the Windows Hello side declines `no-helper` off WSL2 with interop, without a helper (`windows_hello_helper`, else `crate::helpers::locate`), or when a hardened client finds no pin or a mismatch (re-hashed right before each run of the helper; an unhardened client logs a warning and runs it). It runs `public-key` first, and declines `no-registered-key` when that key's `wh:SHA256:` fingerprint is not in the challenge, so no dialog opens for an unregistered key. The helper runs through `hardening::command()` without the marker gid, with the challenge's remaining lifetime as its timeout. The notice ends with `:: mairu :: Approve in the Windows Hello dialog to authorize …`. + - `mairu policy enroll-windows-hello [--name]` takes the pin in force from `GetPolicy`'s effective settings, refuses an unpinned helper when hardened, and prints the `windows_hello_keys` entry (default name: the host name), or says the key is registered already. `mairu policy check` lists registered Windows Hello keys. + - `doctor` gains a `windows-hello` section on WSL2 with interop: the helper and where it comes from, its SHA-256, the pin and its source, the key credential's fingerprint (`public-key`, which shows no dialog), and the registered keys. Warnings: registered keys without interop, without a helper, with an unpinned or mismatched helper, or with no matching key credential. + - Release workflow: a `windows-hello` job (`windows-latest`, both targets) builds, attests and uploads `mairu-windows-hello-.zip` (the `.exe` and its `.sigstore.json`), and passes the `.exe` as a workflow artifact; `upload-binaries` (Linux entries) and `upload-debs` need it, set `MAIRU_WINDOWS_HELLO_HELPER_SHA256`, and ship it (tarball, and the deb's new `release` variant, which installs it as `/usr/lib/mairu/mairu-windows-hello.exe`, 0755). CI gains a `windows-hello-helper` job building and linting both targets. YAML parses; actionlint reports only findings that predate this change. + - Verified in Docker (scenario `016-windows-hello`, 71 checks), with WSL2 faked as in 015 and a shell script speaking the helper protocol with an OpenSSL RSA key: `harden` copying the shipped helper (root-owned, 0755, in the manifest); doctor and enrollment refusing an unpinned helper without running it; the pin approved through polkit; enrollment printing the entry; the first registration approved through polkit and anchoring `windows-hello`, the key and the pin; Admin vended through a verified signature with polkit saying NO, the helper running without the marker gid, the terminal notice; a cancelled dialog denying with no polkit prompt; a signature by another key and a replayed signature refused; no key credential, a tampered helper and no WSL declining to polkit (and refusing Admin, naming the decline); the next policy change approved through Windows Hello; the `config.json` override; a build pin (`MAIRU_WINDOWS_HELLO_HELPER_SHA256` at build time) shown and used; the release deb listing the helper, and an unhardened mairu from it finding `/usr/lib/mairu/mairu-windows-hello.exe`. + - Earlier Docker scenarios re-run after this change, all passing: 003 (20), 004 (49), 005-install (120), 005-ci-script (34), 006 (41), 007 (52), 008 (65), 009 (26), 010 (35), 011 (56), 012 (104), 013 (74), 014 (66), 015 (50). + - Needs a real Windows/WSL2 host: Q-W6 (running the `.exe` from the Linux filesystem through `\\wsl.localhost\…`, SmartScreen, Mark-of-the-Web; the `%LOCALAPPDATA%` copy fallback is not implemented); the helper's Windows Hello calls, status codes and SPKI format on a real key; the dialog coming to the front from a console process; `RequestCreateAsync` from WSL; the release workflow on GitHub Actions (MSVC builds for both targets, 7-Zip, artifact hand-off); and the manual checks under Tests. diff --git a/proto/mairu.proto b/proto/mairu.proto index f8e7239..2945147 100644 --- a/proto/mairu.proto +++ b/proto/mairu.proto @@ -335,10 +335,13 @@ message AuthorizationError { message AuthorizationChallenge { bytes challenge_id = 1; // 16 random bytes bytes statement = 2; // serialized AuthorizationStatement; signed byte-for-byte - // Acceptable registered keys: OpenSSH "SHA256:..." fingerprints for ssh-agent. + // Acceptable registered keys: OpenSSH "SHA256:..." fingerprints for ssh-agent, + // "wh:SHA256:..." (SHA-256 of the SubjectPublicKeyInfo) for Windows Hello. repeated string key_fingerprints = 3; google.protobuf.Timestamp expires_at = 4; - string method = 5; // "ssh-agent" + string method = 5; // "ssh-agent" | "windows-hello" + // windows-hello: the hex SHA-256 of the helper the approved settings pin; empty when they pin none. + string windows_hello_helper_sha256 = 6; } // What a challenge's signature approves. @@ -358,7 +361,10 @@ message AuthorizationResponse { bytes challenge_id = 1; oneof result { bytes sshsig = 2; // binary SSHSIG blob - string declined = 3; // why the client could not sign: "no-ssh-agent", "no-registered-key", "sign-failed" + // Why the client could not sign: "no-ssh-agent", "no-helper", "no-registered-key", "sign-failed", or + // "cancelled" when the human cancelled the prompt, which denies the request. + string declined = 3; + bytes windows_hello_signature = 4; // RSA PKCS #1 v1.5 SHA-256 signature over the statement } } diff --git a/proto/mairu_platform.proto b/proto/mairu_platform.proto index e995a05..cbda1d4 100644 --- a/proto/mairu_platform.proto +++ b/proto/mairu_platform.proto @@ -142,6 +142,7 @@ message ApprovalAnchor { repeated string methods = 1; // e.g. "ssh-agent", "windows-hello", "platform" repeated string ssh_agent_keys = 2; // OpenSSH public key lines repeated bytes windows_hello_keys = 3; // DER SubjectPublicKeyInfo + string windows_hello_helper_sha256 = 4; // the approved helper pin, hex; empty for none } message RecordIntegrityRequest { diff --git a/src/authorization.rs b/src/authorization.rs index 4f33087..c3ec40d 100644 --- a/src/authorization.rs +++ b/src/authorization.rs @@ -12,6 +12,7 @@ const HOLD_DURATION: std::time::Duration = std::time::Duration::from_secs(60); /// The presence method backed by `PlatformServices::authorize`. pub const METHOD_PLATFORM: &str = "platform"; pub const METHOD_SSH_AGENT: &str = crate::ssh_agent_presence::METHOD; +pub const METHOD_WINDOWS_HELLO: &str = crate::windows_hello::METHOD; const COMMAND_DETAIL_LIMIT: usize = 512; const COMMAND_LINE_LIMIT: usize = 4096; @@ -351,7 +352,9 @@ impl Authorizer { let mut methods = vec![]; for method in &verified.effective.settings.methods { let usable = match method.as_str() { - METHOD_SSH_AGENT => !verified.effective.ssh_agent_keys.is_empty(), + METHOD_SSH_AGENT | METHOD_WINDOWS_HELLO => { + KeySet::of(method, &verified.effective).is_some() + } METHOD_PLATFORM => self.shared.platform_usable().await.is_ok(), _ => false, }; @@ -499,10 +502,17 @@ impl Authorizer { .effective .clone(); let response = context.and_then(|c| c.authorization_response.as_ref()); + let mut answer = Answer::new( + response, + shared.take_challenge(response, peer.pid, peer.start_time), + ); let mut tried: Vec = vec![]; for method in effective.settings.methods.iter().map(String::as_str) { match method { - METHOD_SSH_AGENT if !effective.ssh_agent_keys.is_empty() => { + METHOD_SSH_AGENT | METHOD_WINDOWS_HELLO => { + let Some(keys) = KeySet::of(method, &effective) else { + continue; + }; let binding = crate::ssh_agent_presence::Binding { pid: peer.pid, start_time: peer.start_time, @@ -511,18 +521,18 @@ impl Authorizer { rolespec: None, request_digest: unmark_digest(labels, context), }; - let challenge = shared.take_challenge(response, peer.pid, peer.start_time); - match shared.ssh_agent_check( + match shared.key_check( + keys, binding, - response, - challenge, - &effective.ssh_agent_keys, + &mut answer, + effective.settings.windows_hello_helper_sha256.as_deref(), || begin_check().map(Some), ) { Ok(KeyCheck::Verified { .. }) => return remove(method), Ok(KeyCheck::Declined(reason)) => { tried.push(format!("{method}: declined ({reason})")); } + Ok(KeyCheck::Skipped) => {} Err(refusal) => { tracing::info!(pid = peer.pid, session = ?session, tools = ?labels, reason = ?refusal.reason(), detail = refusal.detail, "Tool marks kept"); return Err(refusal.into_status()); @@ -1040,12 +1050,18 @@ impl Shared { Refusal::new(Reason::CheckPending, Checks::approval_pending(&approval)) }) }; + let response = pending.response.as_ref(); + let mut answer = Answer::new( + response, + self.take_challenge(response, pending.pid, pending.start_time), + ); for method in effective.settings.methods.iter().map(String::as_str) { let keys = match method { METHOD_PLATFORM => None, - METHOD_SSH_AGENT if !effective.ssh_agent_keys.is_empty() => { - Some(&effective.ssh_agent_keys) - } + METHOD_SSH_AGENT | METHOD_WINDOWS_HELLO => match KeySet::of(method, &effective) { + Some(keys) => Some(keys), + None => continue, + }, _ => continue, }; let presence = crate::policy::Presence::fresh(method); @@ -1058,13 +1074,11 @@ impl Shared { None => crate::ssh_agent_presence::ACTION_ASSUME_ROLE, Some(_) => crate::ssh_agent_presence::ACTION_USE_CREDENTIALS, }; - let response = pending.response.as_ref(); - let challenge = self.take_challenge(response, pending.pid, pending.start_time); - match self.ssh_agent_check( - pending.binding(action), - response, - challenge, + match self.key_check( keys, + pending.binding(action), + &mut answer, + effective.settings.windows_hello_helper_sha256.as_deref(), || begin_check().map(Some), )? { KeyCheck::Verified { fingerprint, .. } => { @@ -1072,8 +1086,10 @@ impl Shared { pid = pending.pid, action, key = fingerprint, - "Verified a signature from the SSH agent" + "{}", + keys.verified_message() ); + drop(answer); self.record(&pending, &lease_key, method); // A response answers one challenge; the other gate asks anew. pending.response = None; @@ -1089,12 +1105,14 @@ impl Shared { tried.push(format!("{method}: declined ({reason})")); continue; } + KeyCheck::Skipped => continue, } } if let Err(e) = self.platform_usable().await { tried.push(format!("{method}: {e}")); continue; } + drop(answer.challenge.take()); let _check = begin_check()?; let resp = self.prompt(&pending, caller).await; use crate::proto::platform::authorize_response::Result as R; @@ -1175,49 +1193,87 @@ impl Shared { .take_for(&response.challenge_id, pid, start_time) } - /// The ssh-agent method for one request: verifies a signature over `challenge`, which `response` answers, or - /// issues a challenge when there is no response. `begin` starts the check a challenge stands for until it is - /// answered or expires. - fn ssh_agent_check( + /// A key-based method for one request: verifies the signature `answer` carries for a challenge of this + /// method, or issues a challenge when there is nothing to verify. `begin` starts the check a challenge stands for + /// until it is answered or expires. + fn key_check( &self, + keys: KeySet<'_>, binding: crate::ssh_agent_presence::Binding, - response: Option<&crate::proto::AuthorizationResponse>, - challenge: Option>, - keys: &[crate::ssh_agent_presence::RegisteredKey], + answer: &mut Answer<'_>, + windows_hello_helper_sha256: Option<&str>, begin: impl FnOnce() -> Result, Refusal>, ) -> Result { use crate::proto::authorization_error::Reason; use crate::proto::authorization_response::Result as R; - match response.and_then(|r| r.result.as_ref()) { - Some(R::Declined(reason)) => { - drop(challenge); - Ok(KeyCheck::Declined(reason.clone())) + let method = keys.method(); + match answer.response.and_then(|r| r.result.as_ref()) { + // The human said no to the prompt of a challenge, which no other method may then ask again. + Some(R::Declined(reason)) + if reason == crate::ssh_agent_presence::DECLINED_CANCELLED => + { + drop(answer.challenge.take()); + let detail = match answer.method { + Some(METHOD_WINDOWS_HELLO) => { + "the Windows Hello dialog was cancelled or timed out".to_owned() + } + Some(m) => format!("the {m} prompt was cancelled"), + None => "the prompt was cancelled".to_owned(), + }; + return Err(Refusal::new(Reason::Denied, detail)); } - Some(R::Sshsig(sshsig)) => { - let Some(challenge) = challenge else { + Some(R::Declined(reason)) if answer.declines(method) => { + return Ok(KeyCheck::Declined(reason.clone())); + } + Some(signature @ (R::Sshsig(_) | R::WindowsHelloSignature(_))) => { + let Some(answered) = answer.method else { return Err(Refusal::new( Reason::InvalidResponse, "the challenge is unknown, expired or already used", )); }; - match challenge.verify(&binding, self.clock.now(), sshsig, keys) { - Ok(key) => Ok(KeyCheck::Verified { - fingerprint: key.fingerprint(), - }), - Err(e) => Err(Refusal::new(Reason::InvalidResponse, e)), + if answered != method { + return Ok(KeyCheck::Skipped); } + let Some(challenge) = answer.challenge.take() else { + return Err(Refusal::new( + Reason::InvalidResponse, + "the challenge was already answered", + )); + }; + let now = self.clock.now(); + let verified = match (keys, signature) { + (KeySet::SshAgent(keys), R::Sshsig(sshsig)) => challenge + .verify(&binding, now, sshsig, keys) + .map(|k| k.fingerprint()), + (KeySet::WindowsHello(keys), R::WindowsHelloSignature(sig)) => { + crate::windows_hello::verify(&challenge, &binding, now, sig, keys) + .map(|k| k.fingerprint()) + } + _ => Err(format!("the response does not answer a {method} challenge")), + }; + return match verified { + Ok(fingerprint) => Ok(KeyCheck::Verified { fingerprint }), + Err(e) => Err(Refusal::new(Reason::InvalidResponse, e)), + }; } - None => { - drop(challenge); - let guard = begin()?; - let (challenge, evicted) = - self.challenges - .lock() - .issue(binding, keys, self.clock.now(), guard); - drop(evicted); - Err(Refusal::challenge(challenge)) - } + _ => {} + } + drop(answer.challenge.take()); + let guard = begin()?; + let (mut challenge, evicted) = self.challenges.lock().issue( + binding, + method, + keys.fingerprints(), + self.clock.now(), + guard, + ); + drop(evicted); + if let KeySet::WindowsHello(_) = keys { + challenge.windows_hello_helper_sha256 = + windows_hello_helper_sha256.unwrap_or_default().to_owned(); } + Err(Refusal::challenge(challenge)) } /// Concurrent identical requests from one process at the same gate share one prompt. Dropping every waiter @@ -1290,8 +1346,105 @@ impl Shared { /// What the ssh-agent method came to, short of a refusal. #[derive(Debug)] enum KeyCheck { - Verified { fingerprint: String }, + Verified { + fingerprint: String, + }, Declined(String), + /// The request answers a challenge of another method. + Skipped, +} + +/// The registered keys of a key-based method. +#[derive(Debug, Clone, Copy)] +enum KeySet<'a> { + SshAgent(&'a [crate::ssh_agent_presence::RegisteredKey]), + WindowsHello(&'a [crate::windows_hello::RegisteredKey]), +} + +impl<'a> KeySet<'a> { + /// The keys of `method` in force; `None` when it has none. + fn of(method: &str, effective: &'a crate::policy::files::Effective) -> Option { + Self::from_keys( + method, + &effective.ssh_agent_keys, + &effective.windows_hello_keys, + ) + } + + fn from_keys( + method: &str, + ssh_agent: &'a [crate::ssh_agent_presence::RegisteredKey], + windows_hello: &'a [crate::windows_hello::RegisteredKey], + ) -> Option { + match method { + METHOD_SSH_AGENT if !ssh_agent.is_empty() => Some(KeySet::SshAgent(ssh_agent)), + METHOD_WINDOWS_HELLO if !windows_hello.is_empty() => { + Some(KeySet::WindowsHello(windows_hello)) + } + _ => None, + } + } + + fn method(&self) -> &'static str { + match self { + KeySet::SshAgent(_) => METHOD_SSH_AGENT, + KeySet::WindowsHello(_) => METHOD_WINDOWS_HELLO, + } + } + + fn fingerprints(&self) -> Vec { + match self { + KeySet::SshAgent(keys) => crate::ssh_agent_presence::fingerprints(keys), + KeySet::WindowsHello(keys) => crate::windows_hello::fingerprints(keys), + } + } + + fn verified_message(&self) -> &'static str { + match self { + KeySet::SshAgent(_) => "Verified a signature from the SSH agent", + KeySet::WindowsHello(_) => "Verified a Windows Hello signature", + } + } +} + +/// The response a retried request carries, and the challenge it answers, taken once for every key-based method. +struct Answer<'r> { + response: Option<&'r crate::proto::AuthorizationResponse>, + challenge: Option>, + /// The method of the answered challenge. + method: Option<&'static str>, + /// Whether the declined method has been passed in the method order. + passed: bool, +} + +impl<'r> Answer<'r> { + /// A declined challenge is dropped at once, ending its check before the next method asks. + fn new( + response: Option<&'r crate::proto::AuthorizationResponse>, + challenge: Option>, + ) -> Self { + let method = challenge.as_ref().map(|c| c.method()); + let challenge = challenge.filter(|_| crate::ssh_agent_presence::is_signed(response)); + Self { + response, + challenge, + method, + passed: false, + } + } + + /// Whether a decline covers `method`: challenges are issued in method order, so a decline covers its own method + /// and every one before it. A decline of an unknown challenge covers all. + fn declines(&mut self, method: &str) -> bool { + match self.method { + None => true, + Some(_) if self.passed => false, + Some(declined) => { + self.passed = declined == method; + true + } + } + } } /// Human checks waiting for an answer. A policy approval and credential checks are never pending together, so a @@ -1490,14 +1643,23 @@ impl Authorizer { }; let mut tried = vec![]; let mut allowed = false; + let anchored_ssh_agent = + crate::policy::settings::EffectiveSettings::anchored_keys(&decision.anchor); + let anchored_windows_hello = + crate::policy::settings::EffectiveSettings::anchored_windows_hello_keys( + &decision.anchor, + ); + let helper_sha256 = + Some(decision.anchor.windows_hello_helper_sha256.clone()).filter(|s| !s.is_empty()); + let mut answer = Answer::new(response, challenge.take()); for method in &decision.anchor.methods { - if method == METHOD_SSH_AGENT { - let keys = - crate::policy::settings::EffectiveSettings::anchored_keys(&decision.anchor); - if keys.is_empty() { + if method == METHOD_SSH_AGENT || method == METHOD_WINDOWS_HELLO { + let Some(keys) = + KeySet::from_keys(method, &anchored_ssh_agent, &anchored_windows_hello) + else { tried.push(format!("{method}: the approval anchor holds no key")); continue; - } + }; let binding = crate::ssh_agent_presence::Binding { pid: peer.pid, start_time: peer.start_time, @@ -1511,7 +1673,7 @@ impl Authorizer { ), }; // An outstanding approval challenge keeps the approval pending. - match shared.ssh_agent_check(binding, response, challenge.take(), &keys, || { + match shared.key_check(keys, binding, &mut answer, helper_sha256.as_deref(), || { Ok(approval_guard.take()) }) { Ok(KeyCheck::Verified { fingerprint, .. }) => { @@ -1519,7 +1681,8 @@ impl Authorizer { pid = peer.pid, origin, key = fingerprint, - "Verified a signature from the SSH agent for the policy approval" + "{} for the policy approval", + keys.verified_message() ); allowed = true; break; @@ -1528,6 +1691,7 @@ impl Authorizer { tried.push(format!("{method}: declined ({reason})")); continue; } + Ok(KeyCheck::Skipped) => continue, Err(refusal) => { if refusal.reason() != Reason::Challenge { tracing::info!(pid = peer.pid, origin, reason = ?refusal.reason(), detail = refusal.detail, "Policy approval refused"); @@ -3789,7 +3953,8 @@ mod tests { ) -> Fixture { let key = crate::ssh_agent_presence::test_support::key(); let mut effective = crate::policy::files::Effective::with_engine(engine); - effective.settings.methods = crate::policy::settings::default_methods(true); + effective.settings.methods = + crate::policy::settings::default_methods(&[crate::ssh_agent_presence::METHOD]); effective.settings.ssh_agent_keys = vec![crate::policy::settings::SshAgentKeyEntry { key: key.to_openssh(), require_user_verification: false, @@ -3985,6 +4150,171 @@ mod tests { assert!(refusal.detail.contains("platform"), "{refusal:?}"); } + /// The built-in policy with an ssh-agent key, a Windows Hello key or both, in the default order. + fn keys_fixture(ssh_agent: bool, windows_hello: bool) -> Fixture { + let mut effective = crate::policy::files::Effective::with_engine( + crate::policy::Engine::builtin(true).unwrap(), + ); + let mut keyed = vec![]; + if ssh_agent { + let key = crate::ssh_agent_presence::test_support::key(); + effective.settings.ssh_agent_keys = vec![crate::policy::settings::SshAgentKeyEntry { + key: key.to_openssh(), + require_user_verification: false, + }]; + effective.ssh_agent_keys = vec![key]; + keyed.push(crate::ssh_agent_presence::METHOD); + } + if windows_hello { + let key = crate::windows_hello::test_support::key(); + effective.settings.windows_hello_keys = + vec![crate::policy::settings::WindowsHelloKeyEntry { + name: key.name.clone(), + public_key: key.to_base64(), + }]; + effective.settings.windows_hello_helper_sha256 = Some("ab".repeat(32)); + effective.windows_hello_keys = vec![key]; + keyed.push(crate::windows_hello::METHOD); + } + effective.settings.methods = crate::policy::settings::default_methods(&keyed); + fixture_with_policy( + crate::policy::files::PolicyFiles::fixed(effective), + MockServices::answering(R::Allowed, ""), + ) + } + + #[tokio::test] + async fn test_windows_hello_challenge() { + use crate::windows_hello::test_support::signed; + let f = keys_fixture(false, true); + assert_eq!( + f.authorizer.methods().await, + vec!["windows-hello", "platform"] + ); + let ctx = || { + Some(crate::proto::ClientContext { + intent: Some(crate::proto::client_context::Intent::CredentialProcess( + crate::proto::CredentialProcessIntent {}, + )), + cwd: b"/home/alice".to_vec(), + ..Default::default() + }) + }; + let retry = |r| f.pending(10, None, "admin", ctx().map(|c| responding(c, r))); + let c = challenge_of(f.decide(f.pending(10, None, "admin", ctx())).await); + assert_eq!(c.method, "windows-hello"); + assert_eq!( + c.key_fingerprints, + vec![crate::windows_hello::test_support::key().fingerprint()] + ); + assert_eq!(c.windows_hello_helper_sha256, "ab".repeat(32)); + let authorized = f.decide(retry(signed(&c))).await.unwrap(); + assert_eq!( + authorized.presence, + crate::policy::Presence::fresh("windows-hello") + ); + + let invalid = |r: Result| { + assert_eq!(r.unwrap_err().reason(), Reason::InvalidResponse); + }; + // Replayed. + invalid(f.decide(retry(signed(&c))).await); + // A signature over another statement. + let c = challenge_of(f.decide(f.pending(10, None, "admin", ctx())).await); + let mut other = c.clone(); + other.statement = b"x".to_vec(); + let mut response = signed(&other); + response.challenge_id = c.challenge_id.clone(); + invalid(f.decide(retry(response)).await); + // An SSHSIG for a windows-hello challenge. + let c = challenge_of(f.decide(f.pending(10, None, "admin", ctx())).await); + invalid( + f.decide(retry(crate::ssh_agent_presence::test_support::signed(&c))) + .await, + ); + assert_eq!(f.services.prompts(), 0); + } + + #[tokio::test] + async fn test_key_methods_in_order() { + use crate::ssh_agent_presence::test_support::declined; + let f = keys_fixture(true, true); + assert_eq!( + f.authorizer.methods().await, + vec!["ssh-agent", "windows-hello", "platform"] + ); + let ctx = exec_context("terraform apply", false); + let decide = |r: Option| { + let ctx = ctx.clone(); + let f = &f; + async move { + let ctx = match r { + Some(r) => responding(ctx, r), + None => ctx, + }; + f.decide(f.pending(10, Some(TTY), "admin", Some(ctx))).await + } + }; + // ssh-agent first; its decline leads to windows-hello, whose signature permits. + let c = challenge_of(decide(None).await); + assert_eq!(c.method, "ssh-agent"); + let c = challenge_of(decide(Some(declined(&c))).await); + assert_eq!(c.method, "windows-hello"); + let authorized = decide(Some(crate::windows_hello::test_support::signed(&c))) + .await + .unwrap(); + assert_eq!(authorized.presence.method.as_deref(), Some("windows-hello")); + assert_eq!(f.services.prompts(), 0); + + // Declining windows-hello too moves on to the platform, not back to ssh-agent. + let f = keys_fixture(true, true); + let decide = |r: Option| { + let ctx = ctx.clone(); + let f = &f; + async move { + let ctx = match r { + Some(r) => responding(ctx, r), + None => ctx, + }; + f.decide(f.pending(10, Some(TTY), "admin", Some(ctx))).await + } + }; + let c = challenge_of(decide(None).await); + let c = challenge_of(decide(Some(declined(&c))).await); + assert_eq!(c.method, "windows-hello"); + let authorized = decide(Some(declined(&c))).await.unwrap(); + assert_eq!(authorized.presence.method.as_deref(), Some("platform")); + assert_eq!(f.services.prompts(), 1); + assert!(f.authorizer.shared.challenges.lock().is_empty()); + + // A cancelled prompt denies, with no other method asking, and ends the check. + let f = keys_fixture(true, true); + let decide = |r: Option| { + let ctx = ctx.clone(); + let f = &f; + async move { + let ctx = match r { + Some(r) => responding(ctx, r), + None => ctx, + }; + f.decide(f.pending(10, Some(TTY), "admin", Some(ctx))).await + } + }; + let c = challenge_of(decide(None).await); + let c = challenge_of(decide(Some(declined(&c))).await); + let cancelled = crate::proto::AuthorizationResponse { + challenge_id: c.challenge_id.clone(), + result: Some(crate::proto::authorization_response::Result::Declined( + crate::ssh_agent_presence::DECLINED_CANCELLED.to_owned(), + )), + }; + let refusal = decide(Some(cancelled)).await.unwrap_err(); + assert_eq!(refusal.reason(), Reason::Denied); + assert!(refusal.detail.contains("Windows Hello"), "{refusal:?}"); + assert_eq!(f.services.prompts(), 0); + assert!(f.authorizer.shared.challenges.lock().is_empty()); + } + #[tokio::test] async fn test_ssh_agent_prefetch_never_challenged() { let f = key_fixture( @@ -4145,7 +4475,7 @@ mod tests { record.approval = Some(crate::proto::platform::ApprovalAnchor { methods: vec!["ssh-agent".to_owned()], ssh_agent_keys: vec![key.to_anchor_line()], - windows_hello_keys: vec![], + ..Default::default() }); services.stores.strong.lock().record = Some(record); let f = dir_fixture(&dir, services.clone()).await; diff --git a/src/bin/mairu_windows_hello.rs b/src/bin/mairu_windows_hello.rs new file mode 100644 index 0000000..17dde0d --- /dev/null +++ b/src/bin/mairu_windows_hello.rs @@ -0,0 +1,256 @@ +//! `mairu-windows-hello.exe`: signs mairu's challenges with a Windows Hello key credential. mairu runs it through +//! WSL interop and verifies every signature itself, so nothing this program prints is trusted on its own. +//! +//! ```text +//! mairu-windows-hello.exe public-key prints the key's SubjectPublicKeyInfo, base64 +//! mairu-windows-hello.exe create creates the key, then prints as public-key +//! mairu-windows-hello.exe sign reads base64 data on stdin, prints the signature, base64 +//! ``` + +// Exit statuses, as src/windows_hello.rs reads them. +const EXIT_FAILED: u8 = 1; +const EXIT_CANCELLED: u8 = 2; +const EXIT_NOT_FOUND: u8 = 3; +const EXIT_EXISTS: u8 = 4; +const EXIT_UNSUPPORTED: u8 = 5; +const EXIT_USAGE: u8 = 64; + +/// Statements are a few hundred bytes. +const MAX_INPUT: u64 = 64 * 1024; + +fn main() -> std::process::ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let command = match args.as_slice() { + [c] if matches!(c.as_str(), "public-key" | "create" | "sign") => c.clone(), + [v] if v == "--version" => { + println!("mairu-windows-hello {}", env!("CARGO_PKG_VERSION")); + return std::process::ExitCode::SUCCESS; + } + _ => { + eprintln!("usage: mairu-windows-hello.exe public-key|create|sign"); + return std::process::ExitCode::from(EXIT_USAGE); + } + }; + let result = match command.as_str() { + "sign" => read_input().and_then(|data| hello::sign(&data)), + "create" => hello::create(), + _ => hello::public_key(), + }; + match result { + Ok(bytes) => { + use base64::Engine; + println!( + "{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ); + std::process::ExitCode::SUCCESS + } + Err(Failure(status, message)) => { + eprintln!("mairu-windows-hello: {message}"); + std::process::ExitCode::from(status) + } + } +} + +#[derive(Debug)] +struct Failure(u8, String); + +fn read_input() -> Result, Failure> { + use base64::Engine; + use std::io::Read; + let mut input = String::new(); + std::io::stdin() + .take(MAX_INPUT) + .read_to_string(&mut input) + .map_err(|e| Failure(EXIT_FAILED, format!("failed to read stdin: {e}")))?; + base64::engine::general_purpose::STANDARD + .decode(input.trim()) + .map_err(|e| Failure(EXIT_FAILED, format!("stdin is not base64: {e}"))) +} + +#[cfg(windows)] +mod hello { + use super::Failure; + use windows::Security::Credentials::{ + KeyCredential, KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus, + }; + + const KEY_NAME: &str = "co.0w0.mairu"; + /// The window class of the Windows Hello dialog. + const DIALOG_CLASS: windows::core::PCWSTR = windows::core::w!("Credential Dialog Xaml Host"); + const FOCUS_ATTEMPTS: u32 = 100; + const FOCUS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100); + + fn failed(what: &str, e: windows::core::Error) -> Failure { + Failure(super::EXIT_FAILED, format!("{what}: {e}")) + } + + fn status_failure(what: &str, status: KeyCredentialStatus) -> Failure { + let code = match status { + KeyCredentialStatus::UserCanceled | KeyCredentialStatus::UserPrefersPassword => { + super::EXIT_CANCELLED + } + KeyCredentialStatus::NotFound => super::EXIT_NOT_FOUND, + KeyCredentialStatus::CredentialAlreadyExists => super::EXIT_EXISTS, + _ => super::EXIT_FAILED, + }; + let name = match status { + KeyCredentialStatus::UserCanceled => "cancelled", + KeyCredentialStatus::UserPrefersPassword => "the user chose a password", + KeyCredentialStatus::NotFound => "no key credential", + KeyCredentialStatus::CredentialAlreadyExists => "the key credential exists", + KeyCredentialStatus::SecurityDeviceLocked => "the security device is locked", + _ => "unknown error", + }; + Failure(code, format!("{what}: {name} (status {})", status.0)) + } + + fn ensure_supported() -> Result<(), Failure> { + let supported = KeyCredentialManager::IsSupportedAsync() + .and_then(|op| op.join()) + .map_err(|e| failed("IsSupportedAsync", e))?; + if supported { + Ok(()) + } else { + Err(Failure( + super::EXIT_UNSUPPORTED, + "Windows Hello is not set up for this account".to_owned(), + )) + } + } + + fn open() -> Result { + let result = KeyCredentialManager::OpenAsync(&KEY_NAME.into()) + .and_then(|op| op.join()) + .map_err(|e| failed("OpenAsync", e))?; + let status = result.Status().map_err(|e| failed("OpenAsync", e))?; + if status != KeyCredentialStatus::Success { + return Err(status_failure("OpenAsync", status)); + } + result.Credential().map_err(|e| failed("OpenAsync", e)) + } + + fn spki(credential: &KeyCredential) -> Result, Failure> { + let buffer = credential + .RetrievePublicKeyWithBlobType( + windows::Security::Cryptography::Core::CryptographicPublicKeyBlobType::X509SubjectPublicKeyInfo, + ) + .map_err(|e| failed("RetrievePublicKey", e))?; + to_vec(&buffer) + } + + fn to_vec(buffer: &windows::Storage::Streams::IBuffer) -> Result, Failure> { + let mut bytes = windows::core::Array::::new(); + windows::Security::Cryptography::CryptographicBuffer::CopyToByteArray(buffer, &mut bytes) + .map_err(|e| failed("CopyToByteArray", e))?; + Ok(bytes.to_vec()) + } + + pub fn public_key() -> Result, Failure> { + ensure_supported()?; + spki(&open()?) + } + + pub fn create() -> Result, Failure> { + ensure_supported()?; + let focus = focus_dialog(); + let result = KeyCredentialManager::RequestCreateAsync( + &KEY_NAME.into(), + KeyCredentialCreationOption::FailIfExists, + ) + .and_then(|op| op.join()) + .map_err(|e| failed("RequestCreateAsync", e)); + drop(focus); + let result = result?; + let status = result + .Status() + .map_err(|e| failed("RequestCreateAsync", e))?; + if status != KeyCredentialStatus::Success { + return Err(status_failure("RequestCreateAsync", status)); + } + spki( + &result + .Credential() + .map_err(|e| failed("RequestCreateAsync", e))?, + ) + } + + /// Windows hashes `data` itself (SHA-256, PKCS #1 v1.5), so it is passed as it is. + pub fn sign(data: &[u8]) -> Result, Failure> { + ensure_supported()?; + let credential = open()?; + let buffer = + windows::Security::Cryptography::CryptographicBuffer::CreateFromByteArray(data) + .map_err(|e| failed("CreateFromByteArray", e))?; + let focus = focus_dialog(); + let result = credential + .RequestSignAsync(&buffer) + .and_then(|op| op.join()) + .map_err(|e| failed("RequestSignAsync", e)); + drop(focus); + let result = result?; + let status = result.Status().map_err(|e| failed("RequestSignAsync", e))?; + if status != KeyCredentialStatus::Success { + return Err(status_failure("RequestSignAsync", status)); + } + to_vec(&result.Result().map_err(|e| failed("RequestSignAsync", e))?) + } + + /// Stops looking for the dialog when dropped. + struct Focus(std::sync::Arc); + + impl Drop for Focus { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::Relaxed); + } + } + + /// A console process owns no foreground window, so the dialog opens behind the terminal unless brought forward. + fn focus_dialog() -> Focus { + let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop = done.clone(); + std::thread::spawn(move || { + for _ in 0..FOCUS_ATTEMPTS { + if stop.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + // SAFETY: FindWindowW reads a static string; SetForegroundWindow takes a window handle by value. + unsafe { + if let Ok(hwnd) = + windows::Win32::UI::WindowsAndMessaging::FindWindowW(DIALOG_CLASS, None) + && !hwnd.is_invalid() + { + let _ = windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow(hwnd); + return; + } + } + std::thread::sleep(FOCUS_INTERVAL); + } + }); + Focus(done) + } +} + +#[cfg(not(windows))] +mod hello { + use super::Failure; + + fn unsupported() -> Result, Failure> { + Err(Failure( + super::EXIT_UNSUPPORTED, + "runs on Windows only".to_owned(), + )) + } + + pub fn public_key() -> Result, Failure> { + unsupported() + } + + pub fn create() -> Result, Failure> { + unsupported() + } + + pub fn sign(_data: &[u8]) -> Result, Failure> { + unsupported() + } +} diff --git a/src/cmd/authorization.rs b/src/cmd/authorization.rs index 59636e6..389a0f3 100644 --- a/src/cmd/authorization.rs +++ b/src/cmd/authorization.rs @@ -52,11 +52,11 @@ pub fn context_lines( } /// The lines of "What the human sees" before the SSH agent is asked to sign with the key named `comment`. -pub fn ssh_agent_lines( +pub fn signer_lines( server: &str, role: &str, context: &crate::proto::ClientContext, - comment: &str, + signer: crate::ssh_agent_presence::Signer<'_>, caller_arn: Option<&str>, tools: Option<&str>, ) -> String { @@ -67,9 +67,7 @@ pub fn ssh_agent_lines( context, caller_arn, tools, - &format!( - "Approve the signature on your SSH agent ({comment}) to authorize {role} on {server}" - ), + &format!("{} to authorize {role} on {server}", signer.instruction()), ) } @@ -174,9 +172,11 @@ pub async fn assume_role_with_notice( let req = req.clone(); let relay = relay.clone(); async move { - let signature_notice = |refusal: &crate::proto::AuthorizationError, comment: &str| { - ssh_agent_notice(&req, refusal, comment) - }; + let signature_notice = + |refusal: &crate::proto::AuthorizationError, + signer: crate::ssh_agent_presence::Signer<'_>| { + signer_notice(&req, refusal, signer) + }; relay .with_challenges(signature_notice, |response| { let mut agent = agent.clone(); @@ -236,16 +236,16 @@ pub async fn assume_role_quietly( resp } -fn ssh_agent_notice( +fn signer_notice( req: &crate::proto::AssumeRoleRequest, refusal: &crate::proto::AuthorizationError, - comment: &str, + signer: crate::ssh_agent_presence::Signer<'_>, ) -> String { - ssh_agent_lines( + signer_lines( &req.server_id, &req.rolespec().role, &req.client_context.clone().unwrap_or_default(), - comment, + signer, Some(&refusal.caller_arn), tools_summary(&refusal.tools).as_deref(), ) diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index 4305a79..19660ca 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -89,9 +89,10 @@ pub async fn run(_args: &DoctorArgs) -> Result<(), anyhow::Error> { matches!(state, crate::hardening::HardeningState::Hardened { .. }), |s| s.agent_hardening() == crate::proto::HardeningState::Hardened, ); - let (policy_warnings, ssh_agent_keys) = print_policy(agent_hardened, agent_status.as_ref()); + let (policy_warnings, keys) = print_policy(agent_hardened, agent_status.as_ref()); warnings.extend(policy_warnings); - warnings.extend(ssh_agent::report(&ssh_agent_keys).await); + warnings.extend(ssh_agent::report(&keys.ssh_agent).await); + warnings.extend(windows_hello::report(&keys).await); #[cfg(target_os = "linux")] warnings.extend(headless::report(agent_status.as_ref())); @@ -200,39 +201,38 @@ fn print_agent_status( } } +/// The keys and helper pin in force, as the client computes them. +#[derive(Debug, Default)] +pub struct Keys { + pub ssh_agent: Vec, + pub windows_hello: Vec, + pub windows_hello_helper_sha256: Option, +} + +impl Keys { + pub fn of(effective: &crate::policy::files::Effective) -> Self { + Self { + ssh_agent: effective.ssh_agent_keys.clone(), + windows_hello: effective.windows_hello_keys.clone(), + windows_hello_helper_sha256: effective.settings.windows_hello_helper_sha256.clone(), + } + } +} + /// The policy files evaluated here, for the state the agent reports, or as unapproved without an agent. Returns -/// warnings and the keys registered for the ssh-agent method. +/// warnings and the keys registered for the key-based methods. fn print_policy( hardened: bool, status: Option<&crate::proto::GetAgentStatusResponse>, -) -> (Vec, Vec) { +) -> (Vec, Keys) { use crate::policy::settings::PolicyState; let mut warnings = vec![]; let dir = crate::config::config_dir(); - let (policy, settings) = crate::policy::files::PolicyFiles::read_contents(&dir); - let state = match status.map(|s| s.policy_integrity()) { - Some(crate::proto::PolicyIntegrity::Approved) => Some(PolicyState::Approved), - Some(crate::proto::PolicyIntegrity::Unapproved) => Some(PolicyState::Unapproved), - Some(crate::proto::PolicyIntegrity::Tampered) => Some(PolicyState::Tampered), - Some(crate::proto::PolicyIntegrity::Absent) => Some(PolicyState::Absent), - _ => None, - }; - let built = match state { - Some(state) => crate::policy::files::Effective::build_for( - state, - hardened, - &policy, - &settings, - &status - .and_then(|s| s.approval_anchor.clone()) - .unwrap_or_else(crate::policy::integrity_state::default_anchor), - ), - None => crate::policy::files::Effective::build(hardened, &policy, &settings), - }; - let mut keys = vec![]; + let (state, built) = crate::policy::files::Effective::for_status(&dir, hardened, status); + let mut keys = Keys::default(); match built { Ok(effective) => { - keys = effective.ssh_agent_keys.clone(); + keys = Keys::of(&effective); let own = effective .engine .policy_ids() @@ -294,6 +294,97 @@ fn print_policy( (warnings, keys) } +/// Whether the Windows Hello helper can run here, matches its pin, and holds a registered key. +mod windows_hello { + /// Prints the windows-hello section when Windows Hello is in play here; returns warnings. + pub async fn report(keys: &super::Keys) -> Vec { + let mut warnings = vec![]; + let interop = crate::windows_hello::interop_available(); + if !interop { + if !keys.windows_hello.is_empty() { + warnings.push("windows_hello_keys are registered, and Windows Hello needs WSL2 with interop, which this is not, so the next method asks instead".to_owned()); + } + return warnings; + } + println!("windows-hello"); + let config = crate::config::UserConfig::load().await.unwrap_or_default(); + let source = if config.windows_hello_helper.is_some() { + "windows_hello_helper" + } else { + "shipped" + }; + let Some(path) = crate::windows_hello::Helper::locate(&config) else { + println!( + " helper not found (no {} next to mairu, no windows_hello_helper in config.json)", + crate::windows_hello::HELPER_FILE_NAME + ); + print_registered(keys, None); + if !keys.windows_hello.is_empty() { + warnings.push(format!("windows_hello_keys are registered, and there is no {}, so the windows-hello method declines; set windows_hello_helper in config.json to the helper from the release", crate::windows_hello::HELPER_FILE_NAME)); + } + return warnings; + }; + let helper = crate::windows_hello::Helper { + path, + pin: crate::windows_hello::Pin::resolve(keys.windows_hello_helper_sha256.as_deref()), + hardened: crate::hardening::Hardening::marker().is_some(), + }; + println!(" helper {} ({source})", helper.path.display()); + match crate::windows_hello::digest(&helper.path) { + Ok(actual) => println!(" sha256 {actual}"), + Err(e) => println!(" sha256 unreadable ({e})"), + } + println!(" pin {}", helper.pin); + if let Err(e) = helper.check() { + println!(" check {e}"); + print_registered(keys, None); + warnings.push(format!("the Windows Hello helper is not run: {e}")); + return warnings; + } + let key = match helper.public_key().await { + Ok(spki) => { + let fingerprint = crate::windows_hello::fingerprint(&spki); + println!(" key {fingerprint}"); + Some(fingerprint) + } + Err(e) if e.is_not_enrolled() => { + println!(" key none; `mairu policy enroll-windows-hello` creates it"); + None + } + Err(e) => { + println!(" key unknown; the helper failed ({e})"); + warnings.push(format!( + "the Windows Hello helper at {} failed: {e}", + helper.path.display() + )); + None + } + }; + print_registered(keys, key.as_deref()); + if !keys.windows_hello.is_empty() + && !key + .as_deref() + .is_some_and(|k| keys.windows_hello.iter().any(|r| r.fingerprint() == k)) + { + warnings.push("no registered windows_hello_keys entry matches the Windows Hello key here, so the windows-hello method declines and the next method asks".to_owned()); + } + warnings + } + + fn print_registered(keys: &super::Keys, held: Option<&str>) { + if keys.windows_hello.is_empty() { + println!(" registered none"); + } + for key in &keys.windows_hello { + let state = match held { + Some(k) if k == key.fingerprint() => " (this Windows Hello key)", + _ => "", + }; + println!(" registered {} {}{state}", key.fingerprint(), key.name); + } + } +} + /// Whether `$SSH_AUTH_SOCK` reaches an SSH agent, and which registered keys it holds. mod ssh_agent { /// Prints the ssh-agent section; returns warnings. diff --git a/src/cmd/lock.rs b/src/cmd/lock.rs index e4dddf2..b38ffdb 100644 --- a/src/cmd/lock.rs +++ b/src/cmd/lock.rs @@ -110,11 +110,13 @@ pub async fn run_unlock(args: &UnlockArgs) -> Result<(), anyhow::Error> { ) }; let relay = crate::ssh_agent_presence::Relay::from_env(); - let signature_notice = |_: &crate::proto::AuthorizationError, comment: &str| { - format!( - ":: {product} :: Approve the signature on your SSH agent ({comment}) to remove the tool marks of {tty} ({labels})\n" - ) - }; + let signature_notice = + |_: &crate::proto::AuthorizationError, signer: crate::ssh_agent_presence::Signer<'_>| { + format!( + ":: {product} :: {} to remove the tool marks of {tty} ({labels})\n", + signer.instruction() + ) + }; let resp = crate::polkit_tty_agent::with_fallback(notice, |_| { let agent = agent.clone(); let req = req.clone(); diff --git a/src/cmd/policy.rs b/src/cmd/policy.rs index 8f18728..29fef84 100644 --- a/src/cmd/policy.rs +++ b/src/cmd/policy.rs @@ -29,6 +29,12 @@ enum PolicyCommand { Explain(ExplainArgs), /// Print the Cedar schema policies are validated against Schema, + /// Create the Windows Hello key (WSL2 with interop) and print its windows_hello_keys entry + EnrollWindowsHello { + /// Name of the entry; defaults to the host name + #[arg(long)] + name: Option, + }, } #[derive(clap::Args)] @@ -77,9 +83,82 @@ pub fn run(args: &PolicyArgs) -> Result<(), anyhow::Error> { print!("{}", crate::policy::schema_text()); Ok(()) } + PolicyCommand::EnrollWindowsHello { name } => enroll_windows_hello(name.as_deref()), } } +#[tokio::main] +async fn enroll_windows_hello(name: Option<&str>) -> Result<(), anyhow::Error> { + if !crate::windows_hello::interop_available() { + anyhow::bail!("Windows Hello needs WSL2 with interop enabled"); + } + let config = crate::config::UserConfig::load().await?; + let Some(path) = crate::windows_hello::Helper::locate(&config) else { + anyhow::bail!( + "no {} next to mairu; set windows_hello_helper in {} to the helper from the release", + crate::windows_hello::HELPER_FILE_NAME, + crate::config::UserConfig::path().display() + ); + }; + // The agent's settings in force carry the approved pin and keys. + let settings: serde_json::Value = match get_policy("").await { + Ok(resp) => serde_json::from_str(&resp.effective_settings_json).unwrap_or_default(), + Err(e) => { + tracing::warn!(err = %e, "Failed to read the settings in force from the agent"); + Default::default() + } + }; + let helper = crate::windows_hello::Helper { + path, + pin: crate::windows_hello::Pin::resolve( + settings + .get("windows_hello_helper_sha256") + .and_then(|v| v.as_str()), + ), + hardened: crate::hardening::Hardening::marker().is_some(), + }; + helper.check().map_err(anyhow::Error::msg)?; + println!( + ":: mairu :: Approve in the Windows Hello dialog to create the mairu key, if it does not exist yet" + ); + let spki = helper + .create() + .await + .map_err(|e| anyhow::anyhow!("{} failed: {e}", helper.path.display()))?; + let name = match name { + Some(name) => name.to_owned(), + None => nix::unistd::gethostname() + .ok() + .and_then(|h| h.into_string().ok()) + .unwrap_or_else(|| "windows-hello".to_owned()), + }; + let key = crate::windows_hello::RegisteredKey::from_der(&name, &spki) + .map_err(|e| anyhow::anyhow!("the helper returned an unusable key: {e}"))?; + println!("Windows Hello key {}", key.fingerprint()); + let registered = settings + .get("windows_hello_keys") + .and_then(|v| v.as_array()) + .is_some_and(|keys| { + keys.iter().any(|k| { + k.get("public_key").and_then(|p| p.as_str()) == Some(key.to_base64().as_str()) + }) + }); + if registered { + println!("It is already registered in the settings in force."); + return Ok(()); + } + let entry = crate::policy::settings::WindowsHelloKeyEntry { + name, + public_key: key.to_base64(), + }; + println!( + "Add this entry to windows_hello_keys in {}, then run `mairu policy approve`:\n\n {}\n", + crate::policy::files::PolicyFiles::settings_path(&crate::config::config_dir()).display(), + serde_json::to_string(&entry)? + ); + Ok(()) +} + fn init(force: bool) -> Result<(), anyhow::Error> { let dir = crate::config::config_dir(); let files = [ @@ -183,6 +262,11 @@ fn check() -> Result<(), anyhow::Error> { ("methods", s.methods.is_some()), ("approval_methods", s.approval_methods.is_some()), ("ssh_agent_keys", s.ssh_agent_keys.is_some()), + ("windows_hello_keys", s.windows_hello_keys.is_some()), + ( + "windows_hello_helper_sha256", + s.windows_hello_helper_sha256.is_some(), + ), ("sts", s.sts.is_some()), ("network", s.network.is_some()), ] @@ -202,6 +286,9 @@ fn check() -> Result<(), anyhow::Error> { key.public.algorithm().as_str() ); } + for key in s.windows_hello_registered_keys() { + println!(" windows-hello key {} {}", key.fingerprint(), key.name); + } if !relaxing.is_empty() { warnings.push(format!( "{} in {} apply only once approved", @@ -432,11 +519,13 @@ async fn approve(confirm: Option<&str>) -> Result<(), anyhow::Error> { ) }; let relay = crate::ssh_agent_presence::Relay::from_env(); - let signature_notice = |_: &crate::proto::AuthorizationError, comment: &str| { - format!( - ":: {product} :: Policy approval required: {approval_digest}\n:: {product} :: Approve the signature on your SSH agent ({comment}) to approve this policy (not for AWS credentials)\n" - ) - }; + let signature_notice = + |_: &crate::proto::AuthorizationError, signer: crate::ssh_agent_presence::Signer<'_>| { + format!( + ":: {product} :: Policy approval required: {approval_digest}\n:: {product} :: {} to approve this policy (not for AWS credentials)\n", + signer.instruction() + ) + }; let resp = crate::polkit_tty_agent::with_fallback(notice, |attempt| { let agent = agent.clone(); let req = req.clone(); diff --git a/src/config.rs b/src/config.rs index 05c6819..165f1cb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -181,6 +181,9 @@ pub struct UserConfig { /// How this client posts desktop notifications. #[serde(default)] pub notify: ConvenienceConfig, + /// `mairu-windows-hello.exe` to run instead of the one shipped next to mairu; required when none is shipped. + #[serde(default)] + pub windows_hello_helper: Option, } /// `open_url` and `notify` in config.json. `command` is an argv, required when `mode` is `command`. diff --git a/src/lib.rs b/src/lib.rs index e640f0b..bf6beb5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +// Windows builds only mairu-windows-hello, which uses none of the library. +#![cfg(unix)] // async_trait and async_recursion expansions trip this lint on code we do not write. #![allow(clippy::double_must_use)] @@ -83,6 +85,8 @@ pub mod polkit_tty_agent; pub mod ssh_agent_presence; #[cfg(feature = "cli")] pub mod tool_detection; +#[cfg(feature = "cli")] +pub mod windows_hello; #[cfg(any(feature = "cli", feature = "platform-services"))] pub mod cmd; diff --git a/src/platform/integrity.rs b/src/platform/integrity.rs index 262bdc3..2901ee1 100644 --- a/src/platform/integrity.rs +++ b/src/platform/integrity.rs @@ -89,6 +89,8 @@ struct AnchorJson { methods: Vec, ssh_agent_keys: Vec, windows_hello_keys: Vec, + #[serde(default, skip_serializing_if = "String::is_empty")] + windows_hello_helper_sha256: String, } impl crate::proto::platform::IntegrityRecord { @@ -113,6 +115,7 @@ impl crate::proto::platform::IntegrityRecord { .iter() .map(|k| base64::engine::general_purpose::STANDARD.encode(k)) .collect(), + windows_hello_helper_sha256: approval.windows_hello_helper_sha256, }, }; serde_json::to_string(&json).unwrap_or_default() @@ -149,6 +152,7 @@ impl crate::proto::platform::IntegrityRecord { .map_err(|e| e.to_string()) }) .collect::>()?, + windows_hello_helper_sha256: json.approval.windows_hello_helper_sha256, }), generation: json.generation, server_digests: json @@ -171,6 +175,7 @@ mod tests { methods: vec!["platform".to_owned()], ssh_agent_keys: vec!["ssh-ed25519 AAAA alice".to_owned()], windows_hello_keys: vec![vec![0x30, 0x01]], + windows_hello_helper_sha256: "ab".repeat(32), }), generation: 7, server_digests: [("contoso".to_owned(), vec![0x01; 32])].into(), diff --git a/src/policy/files.rs b/src/policy/files.rs index 05cc9f0..902f326 100644 --- a/src/policy/files.rs +++ b/src/policy/files.rs @@ -15,6 +15,8 @@ pub struct Effective { pub labels: Vec, /// `settings.ssh_agent_keys`, parsed. pub ssh_agent_keys: Vec, + /// `settings.windows_hello_keys`, parsed. + pub windows_hello_keys: Vec, pub state: super::settings::PolicyState, /// Content that is ignored, such as dropped policies. pub notes: Vec, @@ -40,12 +42,47 @@ impl Effective { engine, labels: settings.labels(), ssh_agent_keys: settings.registered_keys(), + windows_hello_keys: settings.windows_hello_registered_keys(), settings, state: super::settings::PolicyState::Absent, notes: vec![], } } + /// What a client computes for the files in `dir`: in the state the agent reports, or as if no store held a + /// record without an agent. Returns the reported state too. + pub fn for_status( + dir: &std::path::Path, + hardened: bool, + status: Option<&crate::proto::GetAgentStatusResponse>, + ) -> ( + Option, + Result, + ) { + use super::settings::PolicyState; + let (policy, settings) = PolicyFiles::read_contents(dir); + let state = match status.map(|s| s.policy_integrity()) { + Some(crate::proto::PolicyIntegrity::Approved) => Some(PolicyState::Approved), + Some(crate::proto::PolicyIntegrity::Unapproved) => Some(PolicyState::Unapproved), + Some(crate::proto::PolicyIntegrity::Tampered) => Some(PolicyState::Tampered), + Some(crate::proto::PolicyIntegrity::Absent) => Some(PolicyState::Absent), + _ => None, + }; + let built = match state { + Some(state) => Self::build_for( + state, + hardened, + &policy, + &settings, + &status + .and_then(|s| s.approval_anchor.clone()) + .unwrap_or_else(super::integrity_state::default_anchor), + ), + None => Self::build(hardened, &policy, &settings), + }; + (state, built) + } + /// The files as if no store held a record: `ABSENT` or `UNAPPROVED`. pub fn build( hardened: bool, @@ -149,6 +186,7 @@ impl Effective { engine, labels: settings.labels(), ssh_agent_keys: settings.registered_keys(), + windows_hello_keys: settings.windows_hello_registered_keys(), settings, state: PolicyState::Approved, notes, @@ -202,6 +240,11 @@ impl Effective { ("methods", s.methods.is_some()), ("approval_methods", s.approval_methods.is_some()), ("ssh_agent_keys", s.ssh_agent_keys.is_some()), + ("windows_hello_keys", s.windows_hello_keys.is_some()), + ( + "windows_hello_helper_sha256", + s.windows_hello_helper_sha256.is_some(), + ), ("sts", s.sts.is_some()), ("network", s.network.is_some()), ] @@ -225,6 +268,7 @@ impl Effective { engine: super::Engine::with_user_policies(base, &forbids)?, labels: settings.labels(), ssh_agent_keys: settings.registered_keys(), + windows_hello_keys: settings.windows_hello_registered_keys(), settings, state, notes, diff --git a/src/policy/integrity_state.rs b/src/policy/integrity_state.rs index 1000dbc..1fd8784 100644 --- a/src/policy/integrity_state.rs +++ b/src/policy/integrity_state.rs @@ -116,6 +116,7 @@ pub fn default_anchor() -> crate::proto::platform::ApprovalAnchor { methods: vec![crate::authorization::METHOD_PLATFORM.to_owned()], ssh_agent_keys: vec![], windows_hello_keys: vec![], + windows_hello_helper_sha256: String::new(), } } diff --git a/src/policy/mairu.cedarschema b/src/policy/mairu.cedarschema index c6a7970..095ca92 100644 --- a/src/policy/mairu.cedarschema +++ b/src/policy/mairu.cedarschema @@ -27,7 +27,7 @@ namespace Mairu { // "none"; "request": checked for this very request; "process": the lease of this running // `mairu exec`; "session": a grant for this terminal session. scope: String, - // The method that performed the check, "platform" or "ssh-agent"; absent when not verified. + // The method that performed the check, "platform", "ssh-agent" or "windows-hello"; absent when not verified. method?: String, // Seconds since the covering human check; 0 when not verified. age: Long, diff --git a/src/policy/settings.rs b/src/policy/settings.rs index df5fd85..4204c29 100644 --- a/src/policy/settings.rs +++ b/src/policy/settings.rs @@ -14,6 +14,30 @@ pub struct AuthorizationSettings { pub sts: Option, pub network: Option, pub ssh_agent_keys: Option>, + pub windows_hello_keys: Option>, + pub windows_hello_helper_sha256: Option, +} + +/// A key registered for the `windows-hello` method. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub struct WindowsHelloKeyEntry { + pub name: String, + /// A base64 SubjectPublicKeyInfo of an RSA-2048 key. + pub public_key: String, +} + +impl WindowsHelloKeyEntry { + pub fn parse(&self) -> Result { + crate::windows_hello::RegisteredKey::parse(&self.name, &self.public_key) + } + + fn from_key(key: &crate::windows_hello::RegisteredKey) -> Self { + Self { + name: key.name.clone(), + public_key: key.to_base64(), + } + } } /// A key registered for the `ssh-agent` method. @@ -105,17 +129,30 @@ impl AuthorizationSettings { .parse() .map_err(|e| format!("ssh_agent_keys[{i}]: {e}"))?; } + for (i, entry) in self.windows_hello_keys.iter().flatten().enumerate() { + entry + .parse() + .map_err(|e| format!("windows_hello_keys[{i}]: {e}"))?; + } // An anchor naming a method without keys could approve nothing until an administrator resets it. - if !self.has_keys() - && self - .approval_methods - .iter() - .flatten() - .any(|m| m == crate::ssh_agent_presence::METHOD) + let keyed = self.keyed_methods(); + for (method, field) in [ + (crate::ssh_agent_presence::METHOD, "ssh_agent_keys"), + (crate::windows_hello::METHOD, "windows_hello_keys"), + ] { + if !keyed.contains(&method) + && self.approval_methods.iter().flatten().any(|m| m == method) + { + return Err(format!( + "approval_methods: {method:?} needs at least one key in {field}" + )); + } + } + if let Some(pin) = &self.windows_hello_helper_sha256 + && (pin.len() != 64 || !pin.bytes().all(|b| b.is_ascii_hexdigit())) { return Err(format!( - "approval_methods: {:?} needs at least one key in ssh_agent_keys", - crate::ssh_agent_presence::METHOD + "windows_hello_helper_sha256: {pin:?} is not a hex SHA-256" )); } for label in self.tool_detection.iter().flat_map(|t| t.keys()) { @@ -144,7 +181,32 @@ impl AuthorizationSettings { } fn has_keys(&self) -> bool { - self.ssh_agent_keys.as_ref().is_some_and(|k| !k.is_empty()) + !self.keyed_methods().is_empty() + } + + /// The key-based methods with registered keys, in their default order. + fn keyed_methods(&self) -> Vec<&'static str> { + let mut methods = vec![]; + if self.ssh_agent_keys.as_ref().is_some_and(|k| !k.is_empty()) { + methods.push(crate::ssh_agent_presence::METHOD); + } + if self + .windows_hello_keys + .as_ref() + .is_some_and(|k| !k.is_empty()) + { + methods.push(crate::windows_hello::METHOD); + } + methods + } + + /// The registered Windows Hello keys; entries that do not parse are skipped, as validation refuses them. + pub fn windows_hello_registered_keys(&self) -> Vec { + self.windows_hello_keys + .iter() + .flatten() + .filter_map(|e| e.parse().ok()) + .collect() } /// The registered keys; entries that do not parse are skipped, as validation refuses them. @@ -160,7 +222,7 @@ impl AuthorizationSettings { pub fn approval_methods_or_default(&self) -> Vec { self.approval_methods .clone() - .unwrap_or_else(|| default_approval_methods(self.has_keys())) + .unwrap_or_else(|| default_approval_methods(&self.keyed_methods())) } /// What `mairu policy check` warns about beyond validation. @@ -181,6 +243,7 @@ impl AuthorizationSettings { /// The presence methods this binary knows. const KNOWN_METHODS: &[&str] = &[ crate::ssh_agent_presence::METHOD, + crate::windows_hello::METHOD, crate::authorization::METHOD_PLATFORM, ]; @@ -261,22 +324,22 @@ impl PolicyState { } /// `approval_methods` when the file has none: the key-based methods with registered keys, else `["platform"]`. -pub fn default_approval_methods(keys_registered: bool) -> Vec { - if keys_registered { - vec![crate::ssh_agent_presence::METHOD.to_owned()] - } else { +pub fn default_approval_methods(keyed: &[&str]) -> Vec { + if keyed.is_empty() { vec![crate::authorization::METHOD_PLATFORM.to_owned()] + } else { + keyed.iter().map(|m| (*m).to_owned()).collect() } } /// `methods` when the file has none: the key-based methods with registered keys, then `"platform"`. -pub fn default_methods(keys_registered: bool) -> Vec { - let mut methods = vec![]; - if keys_registered { - methods.push(crate::ssh_agent_presence::METHOD.to_owned()); - } - methods.push(crate::authorization::METHOD_PLATFORM.to_owned()); - methods +pub fn default_methods(keyed: &[&str]) -> Vec { + keyed + .iter() + .copied() + .chain([crate::authorization::METHOD_PLATFORM]) + .map(str::to_owned) + .collect() } /// The settings in force. @@ -300,27 +363,35 @@ pub struct EffectiveSettings { /// From an approved file; otherwise the keys of the approval anchor in force. #[serde(skip_serializing_if = "Vec::is_empty")] pub ssh_agent_keys: Vec, + /// From an approved file; otherwise the keys of the approval anchor in force. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub windows_hello_keys: Vec, + /// From an approved file; otherwise the pin of the approval anchor in force. + #[serde(skip_serializing_if = "Option::is_none")] + pub windows_hello_helper_sha256: Option, } impl Default for EffectiveSettings { fn default() -> Self { Self { version: 1, - methods: default_methods(false), + methods: default_methods(&[]), approval_methods: vec![], tool_detection: Default::default(), grant_retention_secs: DEFAULT_GRANT_RETENTION_SECS, sts: None, network: None, ssh_agent_keys: vec![], + windows_hello_keys: vec![], + windows_hello_helper_sha256: None, } } } impl EffectiveSettings { - /// An approved file as it is. Otherwise the defaults with the keys of the approval anchor in force, plus what - /// can only tighten: label additions and a shorter grant retention. `methods`, `approval_methods`, - /// `ssh_agent_keys`, `sts` and `network` are never taken from a file that is not approved. + /// An approved file as it is. Otherwise the defaults with the keys and helper pin of the approval anchor in + /// force, plus what can only tighten: label additions and a shorter grant retention. `methods`, `approval_methods`, the keys, + /// `windows_hello_helper_sha256`, `sts` and `network` are never taken from a file that is not approved. pub fn effective( state: PolicyState, file: Option<&AuthorizationSettings>, @@ -332,7 +403,13 @@ impl EffectiveSettings { .iter() .map(SshAgentKeyEntry::from_key) .collect(); - settings.methods = default_methods(!settings.ssh_agent_keys.is_empty()); + settings.windows_hello_keys = Self::anchored_windows_hello_keys(anchor) + .iter() + .map(WindowsHelloKeyEntry::from_key) + .collect(); + settings.windows_hello_helper_sha256 = + Some(anchor.windows_hello_helper_sha256.clone()).filter(|s| !s.is_empty()); + settings.methods = default_methods(&settings.keyed_methods()); } let Some(file) = file else { return settings; @@ -341,10 +418,15 @@ impl EffectiveSettings { PolicyState::Absent => {} PolicyState::Approved => { settings.ssh_agent_keys = file.ssh_agent_keys.clone().unwrap_or_default(); + settings.windows_hello_keys = file.windows_hello_keys.clone().unwrap_or_default(); + settings.windows_hello_helper_sha256 = file + .windows_hello_helper_sha256 + .as_deref() + .map(str::to_ascii_lowercase); settings.methods = file .methods .clone() - .unwrap_or_else(|| default_methods(file.has_keys())); + .unwrap_or_else(|| default_methods(&file.keyed_methods())); settings.approval_methods = file.approval_methods_or_default(); if let Some(tool_detection) = &file.tool_detection { settings.tool_detection = tool_detection.clone(); @@ -371,7 +453,7 @@ impl EffectiveSettings { /// of those methods. pub fn anchor(file: Option<&AuthorizationSettings>) -> crate::proto::platform::ApprovalAnchor { let methods = file.map_or_else( - || default_approval_methods(false), + || default_approval_methods(&[]), AuthorizationSettings::approval_methods_or_default, ); let ssh_agent_keys = if methods @@ -386,13 +468,49 @@ impl EffectiveSettings { } else { vec![] }; + let windows_hello_keys = if methods.iter().any(|m| m == crate::windows_hello::METHOD) { + file.map(|f| f.windows_hello_registered_keys()) + .unwrap_or_default() + .iter() + .map(|k| k.spki().to_vec()) + .collect() + } else { + vec![] + }; crate::proto::platform::ApprovalAnchor { methods, ssh_agent_keys, - windows_hello_keys: vec![], + windows_hello_keys, + windows_hello_helper_sha256: file + .and_then(|f| f.windows_hello_helper_sha256.as_deref()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(), } } + /// The anchor's Windows Hello keys; keys that do not parse are skipped. + pub fn anchored_windows_hello_keys( + anchor: &crate::proto::platform::ApprovalAnchor, + ) -> Vec { + anchor + .windows_hello_keys + .iter() + .enumerate() + .filter_map(|(i, der)| { + match crate::windows_hello::RegisteredKey::from_der(&format!("anchored-{i}"), der) { + Ok(key) => Some(key), + Err(e) => { + tracing::warn!( + err = e, + "Ignoring an approval anchor key that does not parse" + ); + None + } + } + }) + .collect() + } + /// The anchor's keys; lines that do not parse are skipped. pub fn anchored_keys( anchor: &crate::proto::platform::ApprovalAnchor, @@ -422,6 +540,25 @@ impl EffectiveSettings { .collect() } + pub fn windows_hello_registered_keys(&self) -> Vec { + self.windows_hello_keys + .iter() + .filter_map(|e| e.parse().ok()) + .collect() + } + + /// The key-based methods with registered keys, in their default order. + fn keyed_methods(&self) -> Vec<&'static str> { + let mut methods = vec![]; + if !self.ssh_agent_keys.is_empty() { + methods.push(crate::ssh_agent_presence::METHOD); + } + if !self.windows_hello_keys.is_empty() { + methods.push(crate::windows_hello::METHOD); + } + methods + } + pub fn labels(&self) -> Vec { crate::tool_detection::ToolLabel::effective(&self.tool_detection) } @@ -502,6 +639,8 @@ XLz4KNOFkg== sts: None, network: None, ssh_agent_keys: None, + windows_hello_keys: None, + windows_hello_helper_sha256: None, } ); } @@ -575,6 +714,87 @@ XLz4KNOFkg== ); } + #[test] + fn test_windows_hello_keys() { + let key = crate::windows_hello::test_support::key(); + let entry = serde_json::json!({"name": "desktop", "public_key": key.to_base64()}); + let pin = "AB".repeat(32); + let file = parse(serde_json::json!({ + "version": 1, + "windows_hello_keys": [entry], + "windows_hello_helper_sha256": pin, + })) + .unwrap(); + let spkis = |keys: Vec| { + keys.iter().map(|k| k.spki().to_vec()).collect::>() + }; + assert_eq!( + spkis(file.windows_hello_registered_keys()), + vec![key.spki().to_vec()] + ); + + let s = EffectiveSettings::effective(PolicyState::Approved, Some(&file), &anchor()); + assert_eq!(s.methods, vec!["windows-hello", "platform"]); + assert_eq!(s.approval_methods, vec!["windows-hello"]); + assert_eq!( + spkis(s.windows_hello_registered_keys()), + vec![key.spki().to_vec()] + ); + assert_eq!( + s.windows_hello_helper_sha256.as_deref(), + Some("ab".repeat(32).as_str()) + ); + let anchored = EffectiveSettings::anchor(Some(&file)); + assert_eq!(anchored.methods, vec!["windows-hello"]); + assert_eq!(anchored.windows_hello_keys, vec![key.spki().to_vec()]); + assert!(anchored.ssh_agent_keys.is_empty()); + assert_eq!( + EffectiveSettings::anchored_windows_hello_keys(&anchored)[0].spki(), + key.spki() + ); + + // Not approved: the anchor's keys and pin count, the file's do not. + assert_eq!(anchored.windows_hello_helper_sha256, "ab".repeat(32)); + let s = EffectiveSettings::effective(PolicyState::Unapproved, Some(&file), &anchored); + assert_eq!(s.methods, vec!["windows-hello", "platform"]); + assert_eq!(s.windows_hello_keys.len(), 1); + assert_eq!( + s.windows_hello_helper_sha256.as_deref(), + Some("ab".repeat(32).as_str()) + ); + let s = EffectiveSettings::effective(PolicyState::Unapproved, Some(&file), &anchor()); + assert_eq!(s.methods, vec!["platform"]); + assert!(s.windows_hello_keys.is_empty()); + assert_eq!(s.windows_hello_helper_sha256, None); + + // Both kinds registered: ssh-agent first. + let both = parse(serde_json::json!({ + "version": 1, + "ssh_agent_keys": [{"key": ED25519}], + "windows_hello_keys": [{"name": "desktop", "public_key": key.to_base64()}], + })) + .unwrap(); + let s = EffectiveSettings::effective(PolicyState::Approved, Some(&both), &anchor()); + assert_eq!(s.methods, vec!["ssh-agent", "windows-hello", "platform"]); + assert_eq!(s.approval_methods, vec!["ssh-agent", "windows-hello"]); + + let rejects = |v: serde_json::Value| parse(v).unwrap_err(); + assert!(rejects(serde_json::json!({"version": 1, "windows_hello_keys": [{"name": "x", "public_key": "AAAA"}]})).contains("windows_hello_keys[0]")); + assert!(rejects(serde_json::json!({"version": 1, "windows_hello_keys": [{"name": "x", "public_key": key.to_base64(), "comment": "x"}]})).contains("unknown field")); + assert!( + rejects(serde_json::json!({"version": 1, "approval_methods": ["windows-hello"], "ssh_agent_keys": [{"key": ED25519}]})) + .contains("needs at least one key in windows_hello_keys") + ); + assert!( + rejects(serde_json::json!({"version": 1, "windows_hello_helper_sha256": "abc"})) + .contains("not a hex SHA-256") + ); + assert!( + parse(serde_json::json!({"version": 1, "methods": ["windows-hello", "platform"]})) + .is_ok() + ); + } + #[test] fn test_parse_rejects() { let rejects = |patch: &dyn Fn(&mut serde_json::Value)| { diff --git a/src/proto.rs b/src/proto.rs index aaabe9e..81237b7 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -255,7 +255,7 @@ impl AuthorizationError { } Reason::PresenceRequired => "a fresh approval is required", Reason::CheckPending => "another human check is pending", - Reason::Challenge => "a signature from your SSH agent is required", + Reason::Challenge => "a signature from your SSH agent or Windows Hello is required", Reason::InvalidResponse => "the signature was refused", }; let mut message = summary.to_owned(); diff --git a/src/ssh_agent_presence.rs b/src/ssh_agent_presence.rs index 272f12c..9a836c1 100644 --- a/src/ssh_agent_presence.rs +++ b/src/ssh_agent_presence.rs @@ -14,6 +14,8 @@ pub const ACTION_UNMARK_TOOLS: &str = "unmark-tools"; pub const DECLINED_NO_SSH_AGENT: &str = "no-ssh-agent"; pub const DECLINED_NO_REGISTERED_KEY: &str = "no-registered-key"; pub const DECLINED_SIGN_FAILED: &str = "sign-failed"; +/// The human cancelled the prompt; the agent denies the request instead of trying another method. +pub const DECLINED_CANCELLED: &str = "cancelled"; pub const CHALLENGE_LIFETIME: std::time::Duration = std::time::Duration::from_secs(60); const MAX_CHALLENGES_PER_PEER: usize = 4; @@ -109,6 +111,11 @@ impl RegisteredKey { } } +/// The fingerprints a challenge lists for `keys`. +pub fn fingerprints(keys: &[RegisteredKey]) -> Vec { + keys.iter().map(RegisteredKey::fingerprint).collect() +} + /// `ssh-key` checks the sk- signature but not its flags byte, which leads the trailer it keeps after the signature. fn sk_flags(sig: &ssh_key::SshSig) -> Option { let bytes = sig.signature().as_bytes(); @@ -130,6 +137,7 @@ pub struct Binding { /// An outstanding challenge, holding `G` until it is answered, expires or is dropped. #[derive(Debug)] pub struct PendingChallenge { + method: &'static str, statement: Vec, binding: Binding, expires: std::time::Duration, @@ -145,16 +153,18 @@ impl PendingChallenge { self.binding.action } - /// Checks the challenge against the retried request and the signature against `keys`. Returns the key that - /// signed. - pub fn verify<'k>( - &self, - binding: &Binding, - now: std::time::Duration, - sshsig: &[u8], - keys: &'k [RegisteredKey], - ) -> Result<&'k RegisteredKey, String> { - use ssh_encoding::{Decode, Reader}; + /// The presence method the challenge was issued for. + pub fn method(&self) -> &'static str { + self.method + } + + /// The serialized statement a signature must cover. + pub fn statement(&self) -> &[u8] { + &self.statement + } + + /// Checks that the challenge is live and was issued for the retried request. + pub fn check(&self, binding: &Binding, now: std::time::Duration) -> Result<(), String> { if now >= self.expires { return Err("the challenge expired".to_owned()); } @@ -166,6 +176,26 @@ impl PendingChallenge { { return Err("the challenge was issued for another request".to_owned()); } + Ok(()) + } + + /// Checks the challenge against the retried request and the signature against `keys`. Returns the key that + /// signed. + pub fn verify<'k>( + &self, + binding: &Binding, + now: std::time::Duration, + sshsig: &[u8], + keys: &'k [RegisteredKey], + ) -> Result<&'k RegisteredKey, String> { + use ssh_encoding::{Decode, Reader}; + self.check(binding, now)?; + if self.method != METHOD { + return Err(format!( + "an SSHSIG does not answer a {} challenge", + self.method + )); + } let mut reader = sshsig; let sig = ssh_key::SshSig::decode(&mut reader) .map_err(|e| format!("the signature is not a valid SSHSIG: {e}"))?; @@ -211,12 +241,14 @@ impl Challenges { self.pending.is_empty() } - /// Issues a challenge for `binding` that `keys` may answer. Keeps at most a few per peer, evicting the oldest. - /// Returns the challenge and what was evicted, for the caller to drop outside any lock. + /// Issues a challenge of `method` for `binding` that the keys of `key_fingerprints` may answer. Keeps at most a + /// few per peer, evicting the oldest. Returns the challenge and what was evicted, for the caller to drop outside + /// any lock. pub fn issue( &mut self, binding: Binding, - keys: &[RegisteredKey], + method: &'static str, + key_fingerprints: Vec, now: std::time::Duration, guard: Option, ) -> ( @@ -260,13 +292,15 @@ impl Challenges { let challenge = crate::proto::AuthorizationChallenge { challenge_id: challenge_id.to_vec(), statement: statement.clone(), - key_fingerprints: keys.iter().map(RegisteredKey::fingerprint).collect(), + key_fingerprints, expires_at: Some(expires_at), - method: METHOD.to_owned(), + method: method.to_owned(), + windows_hello_helper_sha256: String::new(), }; self.pending.insert( challenge_id, PendingChallenge { + method, statement, binding, expires: now + CHALLENGE_LIFETIME, @@ -331,18 +365,41 @@ pub fn set_response( /// Whether a request carries a signature, so that the agent will not be asking anyone else. pub fn is_signed(response: Option<&crate::proto::AuthorizationResponse>) -> bool { + use crate::proto::authorization_response::Result as R; matches!( response.and_then(|r| r.result.as_ref()), - Some(crate::proto::authorization_response::Result::Sshsig(_)) + Some(R::Sshsig(_) | R::WindowsHelloSignature(_)) ) } -/// The client's side: answers challenges through the SSH agent at `$SSH_AUTH_SOCK`. A decline sticks for the rest -/// of the command, so that a refused or unreachable SSH agent is not asked again after falling back. +/// Who is about to ask the human, for the lines printed before. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Signer<'a> { + /// The SSH agent, signing with the key of this comment. + SshAgent(&'a str), + WindowsHello, +} + +impl Signer<'_> { + /// What the human does, such as "Approve the signature on your SSH agent (me@laptop)". + pub fn instruction(&self) -> String { + match self { + Signer::SshAgent(comment) => { + format!("Approve the signature on your SSH agent ({comment})") + } + Signer::WindowsHello => "Approve in the Windows Hello dialog".to_owned(), + } + } +} + +/// The client's side: answers `ssh-agent` challenges through the SSH agent at `$SSH_AUTH_SOCK`, and `windows-hello` +/// challenges through the Windows Hello helper. A decline sticks for the rest of the command, so that a refused or +/// unreachable signer is not asked again after falling back. #[derive(Debug)] pub struct Relay { socket: Option, declined: parking_lot::Mutex>, + windows_hello: crate::windows_hello::Relay, } /// At most this many challenges are answered for one RPC; each gate may ask once. @@ -359,18 +416,26 @@ impl Relay { } pub fn new(socket: Option) -> std::sync::Arc { + Self::with_windows_hello(socket, crate::windows_hello::Relay::detect()) + } + + pub fn with_windows_hello( + socket: Option, + windows_hello: crate::windows_hello::Relay, + ) -> std::sync::Arc { std::sync::Arc::new(Self { socket, declined: Default::default(), + windows_hello, }) } /// Runs `rpc` with the response to each challenge it is refused with, until it is not refused with one. - /// `notice` gives the lines to print before asking the SSH agent, from the refusal and the key's comment; empty - /// prints nothing. + /// `notice` gives the lines to print before asking the human, from the refusal and the signer; empty prints + /// nothing. pub async fn with_challenges(&self, notice: N, mut rpc: F) -> tonic::Result where - N: Fn(&crate::proto::AuthorizationError, &str) -> String, + N: Fn(&crate::proto::AuthorizationError, Signer<'_>) -> String, F: FnMut(Option) -> Fut, Fut: std::future::Future>, { @@ -396,25 +461,43 @@ impl Relay { }; rounds += 1; response = Some( - self.respond(&challenge, |comment| notice(&refusal, comment)) + self.respond(&challenge, |signer| notice(&refusal, signer)) .await, ); } } - /// Signs the challenge's statement with the first registered key the SSH agent holds, or declines. + /// Signs the challenge's statement with the signer of its method, or declines. pub async fn respond( &self, challenge: &crate::proto::AuthorizationChallenge, - notice: impl FnOnce(&str) -> String, + notice: impl FnOnce(Signer<'_>) -> String, ) -> crate::proto::AuthorizationResponse { use crate::proto::authorization_response::Result as R; - let result = match self.sign(challenge, notice).await { - Ok(sshsig) => R::Sshsig(sshsig), - Err(declined) => { - *self.declined.lock() = Some(declined); - R::Declined(declined.to_owned()) + let result = match challenge.method.as_str() { + crate::windows_hello::METHOD => { + match self + .windows_hello + .sign(challenge, || notice(Signer::WindowsHello)) + .await + { + Ok(signature) => R::WindowsHelloSignature(signature), + Err(crate::windows_hello::Unsigned::Declined(declined)) => { + R::Declined(declined.to_owned()) + } + Err(crate::windows_hello::Unsigned::Denied(detail)) => { + tracing::info!(detail, "Windows Hello did not sign"); + R::Declined(DECLINED_CANCELLED.to_owned()) + } + } } + _ => match self.sign(challenge, notice).await { + Ok(sshsig) => R::Sshsig(sshsig), + Err(declined) => { + *self.declined.lock() = Some(declined); + R::Declined(declined.to_owned()) + } + }, }; crate::proto::AuthorizationResponse { challenge_id: challenge.challenge_id.clone(), @@ -425,7 +508,7 @@ impl Relay { async fn sign( &self, challenge: &crate::proto::AuthorizationChallenge, - notice: impl FnOnce(&str) -> String, + notice: impl FnOnce(Signer<'_>) -> String, ) -> Result, &'static str> { if let Some(declined) = *self.declined.lock() { return Err(declined); @@ -458,7 +541,7 @@ impl Relay { tracing::info!("The SSH agent holds no registered key; declining the challenge"); return Err(DECLINED_NO_REGISTERED_KEY); }; - let notice = notice(&identity.comment); + let notice = notice(Signer::SshAgent(&identity.comment)); if !notice.is_empty() { crate::terminal::send(¬ice).await; } @@ -846,7 +929,9 @@ mod tests { keys: &[RegisteredKey], binding: Binding, ) -> crate::proto::AuthorizationChallenge { - store.issue(binding, keys, NOW, None).0 + store + .issue(binding, METHOD, fingerprints(keys), NOW, None) + .0 } #[test] @@ -1034,7 +1119,7 @@ mod tests { let mut ids = vec![]; for n in 0..6u32 { let at = NOW + std::time::Duration::from_secs(n.into()); - let (c, evicted) = store.issue(binding(1), &keys, at, Some(n)); + let (c, evicted) = store.issue(binding(1), METHOD, fingerprints(&keys), at, Some(n)); ids.push(c.challenge_id); let evicted: Vec = evicted.into_iter().filter_map(|c| c.guard).collect(); match n { @@ -1045,7 +1130,8 @@ mod tests { assert_eq!(store.len(), 4); store.issue( binding(2), - &keys, + METHOD, + fingerprints(&keys), NOW + std::time::Duration::from_secs(6), None, ); @@ -1133,11 +1219,14 @@ mod tests { let comment = parking_lot::Mutex::new(String::new()); let resp = relay .respond(&c, |k| { - *comment.lock() = k.to_owned(); + *comment.lock() = k.instruction(); String::new() }) .await; - assert_eq!(*comment.lock(), "me@laptop"); + assert_eq!( + *comment.lock(), + "Approve the signature on your SSH agent (me@laptop)" + ); assert_eq!(resp.challenge_id, c.challenge_id); let Some(crate::proto::authorization_response::Result::Sshsig(sig)) = resp.result else { panic!("{resp:?}"); diff --git a/src/windows_hello.rs b/src/windows_hello.rs new file mode 100644 index 0000000..bb1d8c8 --- /dev/null +++ b/src/windows_hello.rs @@ -0,0 +1,925 @@ +//! The `windows-hello` presence method on WSL2. The agent issues the same challenges as for `ssh-agent`, and +//! verifies their signatures against registered Windows Hello public keys; the client has `mairu-windows-hello.exe` +//! sign them through WSL interop, after checking the helper against its pinned SHA-256. + +pub const METHOD: &str = "windows-hello"; +/// The helper as release archives ship it next to `mairu`. +pub const HELPER_FILE_NAME: &str = "mairu-windows-hello.exe"; +pub const DECLINED_NO_HELPER: &str = "no-helper"; + +/// The SHA-256 of the helper this build ships, recorded by release builds. +pub const BUILD_HELPER_SHA256: Option<&str> = option_env!("MAIRU_WINDOWS_HELLO_HELPER_SHA256"); + +const FINGERPRINT_PREFIX: &str = "wh:SHA256:"; +const KEY_BITS: usize = 2048; + +// The helper's exit statuses, as src/bin/mairu_windows_hello.rs sets them. +const EXIT_CANCELLED: i32 = 2; +const EXIT_NOT_FOUND: i32 = 3; +const EXIT_EXISTS: i32 = 4; +const EXIT_UNSUPPORTED: i32 = 5; + +/// For the helper's commands that show no dialog. +const QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +/// For `create`, which waits for the human. +pub const CREATE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); +const MAX_OUTPUT: usize = 64 * 1024; + +/// A Windows Hello public key registered in `windows_hello_keys`: an RSA-2048 SubjectPublicKeyInfo. +#[derive(Clone, PartialEq, Eq)] +pub struct RegisteredKey { + pub name: String, + spki: Vec, + public: rsa::RsaPublicKey, +} + +impl std::fmt::Debug for RegisteredKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RegisteredKey") + .field("name", &self.name) + .field("fingerprint", &self.fingerprint()) + .finish() + } +} + +impl RegisteredKey { + /// A base64 SubjectPublicKeyInfo, as `windows_hello_keys[].public_key` holds it. + pub fn parse(name: &str, public_key: &str) -> Result { + use base64::Engine; + let der = base64::engine::general_purpose::STANDARD + .decode(public_key.trim()) + .map_err(|e| format!("public_key is not base64: {e}"))?; + Self::from_der(name, &der) + } + + /// A DER SubjectPublicKeyInfo, as approval anchors record it. + pub fn from_der(name: &str, der: &[u8]) -> Result { + use rsa::pkcs8::DecodePublicKey; + use rsa::traits::PublicKeyParts; + let public = rsa::RsaPublicKey::from_public_key_der(der) + .map_err(|e| format!("public_key is not an RSA SubjectPublicKeyInfo: {e}"))?; + let bits = public.n().bits(); + if bits != KEY_BITS { + return Err(format!( + "public_key is RSA-{bits}; Windows Hello keys are RSA-{KEY_BITS}" + )); + } + Ok(Self { + name: name.to_owned(), + spki: der.to_vec(), + public, + }) + } + + pub fn spki(&self) -> &[u8] { + &self.spki + } + + pub fn to_base64(&self) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(&self.spki) + } + + /// `wh:SHA256:...`, the SHA-256 of the SubjectPublicKeyInfo. + pub fn fingerprint(&self) -> String { + fingerprint(&self.spki) + } + + /// RSA PKCS #1 v1.5 with SHA-256 over `message` itself, as KeyCredential.RequestSignAsync signs. + pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), String> { + use rsa::signature::Verifier; + let signature = rsa::pkcs1v15::Signature::try_from(signature) + .map_err(|e| format!("the signature is malformed: {e}"))?; + rsa::pkcs1v15::VerifyingKey::::new(self.public.clone()) + .verify(message, &signature) + .map_err(|_| "the signature does not verify".to_owned()) + } +} + +/// `wh:SHA256:` of a DER SubjectPublicKeyInfo, in the style of OpenSSH fingerprints. +pub fn fingerprint(spki: &[u8]) -> String { + use base64::Engine; + use sha2::Digest; + format!( + "{FINGERPRINT_PREFIX}{}", + base64::engine::general_purpose::STANDARD_NO_PAD.encode(sha2::Sha256::digest(spki)) + ) +} + +/// The fingerprints a challenge lists for `keys`. +pub fn fingerprints(keys: &[RegisteredKey]) -> Vec { + keys.iter().map(RegisteredKey::fingerprint).collect() +} + +/// Checks the challenge against the retried request and the signature against `keys`. Returns the key that signed. +pub fn verify<'k, G>( + challenge: &crate::ssh_agent_presence::PendingChallenge, + binding: &crate::ssh_agent_presence::Binding, + now: std::time::Duration, + signature: &[u8], + keys: &'k [RegisteredKey], +) -> Result<&'k RegisteredKey, String> { + challenge.check(binding, now)?; + if challenge.method() != METHOD { + return Err(format!( + "a Windows Hello signature does not answer a {} challenge", + challenge.method() + )); + } + keys.iter() + .find(|k| k.verify(challenge.statement(), signature).is_ok()) + .ok_or_else(|| "the signature does not verify with a registered key".to_owned()) +} + +/// Where the expected SHA-256 of the helper comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Pin { + /// Recorded by the release build that ships the helper. + Build(String), + /// `windows_hello_helper_sha256` of the approved settings. + Settings(String), + None, +} + +impl Pin { + /// The build's pin, else the approved settings' one (empty for none). + pub fn resolve(settings: Option<&str>) -> Self { + if let Some(pin) = BUILD_HELPER_SHA256 { + return Pin::Build(pin.to_owned()); + } + match settings.map(str::trim).filter(|s| !s.is_empty()) { + Some(pin) => Pin::Settings(pin.to_ascii_lowercase()), + None => Pin::None, + } + } + + pub fn expected(&self) -> Option<&str> { + match self { + Pin::Build(pin) | Pin::Settings(pin) => Some(pin), + Pin::None => None, + } + } +} + +impl std::fmt::Display for Pin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Pin::Build(pin) => write!(f, "{pin} (recorded by this build)"), + Pin::Settings(pin) => write!(f, "{pin} (windows_hello_helper_sha256)"), + Pin::None => write!(f, "none"), + } + } +} + +/// The hex SHA-256 of a file. +pub fn digest(path: &std::path::Path) -> std::io::Result { + use sha2::Digest; + let content = std::fs::read(path)?; + Ok(crate::platform::integrity::hex(&sha2::Sha256::digest( + content, + ))) +} + +#[derive(Debug, thiserror::Error)] +pub enum RunError { + #[error("{0}")] + Refused(String), + #[error("failed to run: {0}")] + Spawn(std::io::Error), + #[error("timed out")] + Timeout, + #[error("exited with {}: {stderr}", code.map_or_else(|| "a signal".to_owned(), |c| format!("status {c}")))] + Exit { code: Option, stderr: String }, + #[error("malformed output: {0}")] + Malformed(String), +} + +impl RunError { + fn code(&self) -> Option { + match self { + RunError::Exit { code, .. } => *code, + _ => None, + } + } + + /// The key credential does not exist, or Windows Hello is not set up. + pub fn is_not_enrolled(&self) -> bool { + matches!(self.code(), Some(EXIT_NOT_FOUND | EXIT_UNSUPPORTED)) + } + + pub fn is_cancelled(&self) -> bool { + self.code() == Some(EXIT_CANCELLED) + } +} + +/// `mairu-windows-hello.exe` at `path`, run only while it matches `pin`, or with a warning when not hardened. +#[derive(Debug, Clone)] +pub struct Helper { + pub path: std::path::PathBuf, + pub pin: Pin, + pub hardened: bool, +} + +impl Helper { + /// `windows_hello_helper` of `config`, else the helper shipped next to this mairu. + pub fn locate(config: &crate::config::UserConfig) -> Option { + match &config.windows_hello_helper { + Some(path) if path.is_absolute() => Some(path.clone()), + Some(path) => { + tracing::warn!(path = %path.display(), "Ignoring windows_hello_helper: not an absolute path"); + None + } + None => crate::helpers::locate(HELPER_FILE_NAME), + } + } + + /// Compares the file's SHA-256 with the pin. A hardened client refuses a mismatch or a missing pin. + pub fn check(&self) -> Result<(), String> { + let actual = + digest(&self.path).map_err(|e| format!("cannot read {}: {e}", self.path.display()))?; + let problem = match self.pin.expected() { + Some(expected) if expected == actual => return Ok(()), + Some(expected) => format!("its SHA-256 {actual} does not match the pinned {expected}"), + None => format!( + "no SHA-256 is pinned for it (this build records none; set windows_hello_helper_sha256 to {actual} in {} and approve it)", + crate::policy::files::SETTINGS_FILE_NAME + ), + }; + if self.hardened { + return Err(format!( + "refusing to run {}: {problem}", + self.path.display() + )); + } + match self.pin { + Pin::None => { + tracing::debug!(path = %self.path.display(), problem, "Running an unpinned Windows Hello helper") + } + _ => { + tracing::warn!(path = %self.path.display(), problem, "Running a Windows Hello helper that does not match its pin") + } + } + Ok(()) + } + + /// Runs `command` with `input` on stdin and returns its stdout decoded from base64. Checks the pin first. + pub async fn run( + &self, + command: &str, + input: Option<&[u8]>, + timeout: std::time::Duration, + ) -> Result, RunError> { + use base64::Engine; + use tokio::io::AsyncWriteExt; + self.check().map_err(RunError::Refused)?; + let mut cmd = crate::hardening::command(&self.path); + cmd.arg(command) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let mut child = tokio::process::Command::from(cmd) + .kill_on_drop(true) + .spawn() + .map_err(RunError::Spawn)?; + let mut stdin = child.stdin.take(); + let run = async move { + if let (Some(stdin), Some(input)) = (stdin.as_mut(), input) { + let line = format!( + "{}\n", + base64::engine::general_purpose::STANDARD.encode(input) + ); + stdin.write_all(line.as_bytes()).await?; + } + drop(stdin); + child.wait_with_output().await + }; + let output = tokio::time::timeout(timeout, run) + .await + .map_err(|_| RunError::Timeout)? + .map_err(RunError::Spawn)?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(RunError::Exit { + code: output.status.code(), + stderr: stderr.trim().chars().take(512).collect(), + }); + } + if output.stdout.len() > MAX_OUTPUT { + return Err(RunError::Malformed(format!( + "{} bytes", + output.stdout.len() + ))); + } + let stdout = String::from_utf8(output.stdout) + .map_err(|_| RunError::Malformed("not UTF-8".to_owned()))?; + base64::engine::general_purpose::STANDARD + .decode(stdout.trim()) + .map_err(|e| RunError::Malformed(e.to_string())) + } + + /// The SubjectPublicKeyInfo of the key credential, without asking the human. + pub async fn public_key(&self) -> Result, RunError> { + self.run("public-key", None, QUERY_TIMEOUT).await + } + + /// Creates the key credential, or returns the existing one's public key. + pub async fn create(&self) -> Result, RunError> { + match self.run("create", None, CREATE_TIMEOUT).await { + Err(e) if e.code() == Some(EXIT_EXISTS) => self.public_key().await, + other => other, + } + } + + /// Has Windows Hello sign `data`; the human passes the dialog. + pub async fn sign( + &self, + data: &[u8], + timeout: std::time::Duration, + ) -> Result, RunError> { + self.run("sign", Some(data), timeout).await + } +} + +/// Whether this is WSL2 with interop enabled, the only place the helper can run. +pub fn interop_available() -> bool { + #[cfg(target_os = "linux")] + { + crate::platform::process::linux::wsl::Wsl::detect() + .and_then(crate::platform::process::linux::wsl::Wsl::interop_enabled) + .unwrap_or(false) + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + +/// Why a challenge was not signed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Unsigned { + /// Retry with `declined`, so the agent moves on to the next method. + Declined(&'static str), + /// The human cancelled or let the dialog time out: the agent is told, and denies the request. + Denied(String), +} + +#[derive(Debug)] +enum Source { + /// WSL2 interop, `windows_hello_helper` and the shipped helper, looked up when a challenge arrives. + Detect, + Fixed { + helper: Option, + hardened: bool, + }, +} + +/// The client's side: answers `windows-hello` challenges through the helper. A decline sticks for the rest of the +/// command. +#[derive(Debug)] +pub struct Relay { + source: Source, + declined: parking_lot::Mutex>, +} + +impl Relay { + pub fn detect() -> Self { + Self { + source: Source::Detect, + declined: Default::default(), + } + } + + /// A helper at `helper`, regardless of WSL. + pub fn fixed(helper: Option, hardened: bool) -> Self { + Self { + source: Source::Fixed { helper, hardened }, + declined: Default::default(), + } + } + + async fn helper(&self) -> Option<(std::path::PathBuf, bool)> { + match &self.source { + Source::Fixed { helper, hardened } => helper.clone().map(|h| (h, *hardened)), + Source::Detect => { + if !interop_available() { + tracing::debug!( + "Not on WSL2 with interop; declining the Windows Hello challenge" + ); + return None; + } + let config = crate::config::UserConfig::load().await.unwrap_or_else(|e| { + tracing::warn!(err = %e, "Failed to read config.json"); + Default::default() + }); + let helper = Helper::locate(&config); + if helper.is_none() { + tracing::info!( + "No {HELPER_FILE_NAME} next to mairu and no windows_hello_helper; declining the Windows Hello challenge" + ); + } + Some((helper?, crate::hardening::Hardening::marker().is_some())) + } + } + } + + /// Signs the challenge's statement with the Windows Hello key when it is a registered one. `notice` gives the + /// lines printed before the dialog opens. + pub async fn sign( + &self, + challenge: &crate::proto::AuthorizationChallenge, + notice: impl FnOnce() -> String, + ) -> Result, Unsigned> { + if let Some(declined) = *self.declined.lock() { + return Err(Unsigned::Declined(declined)); + } + let result = self.try_sign(challenge, notice).await; + if let Err(Unsigned::Declined(declined)) = &result { + *self.declined.lock() = Some(declined); + } + result + } + + async fn try_sign( + &self, + challenge: &crate::proto::AuthorizationChallenge, + notice: impl FnOnce() -> String, + ) -> Result, Unsigned> { + use crate::ssh_agent_presence::{DECLINED_NO_REGISTERED_KEY, DECLINED_SIGN_FAILED}; + let Some((path, hardened)) = self.helper().await else { + return Err(Unsigned::Declined(DECLINED_NO_HELPER)); + }; + let helper = Helper { + path, + pin: Pin::resolve(Some(&challenge.windows_hello_helper_sha256)), + hardened, + }; + let spki = match helper.public_key().await { + Ok(spki) => spki, + Err(e) if e.is_not_enrolled() => { + tracing::info!(err = %e, "No Windows Hello key credential; declining the challenge"); + return Err(Unsigned::Declined(DECLINED_NO_REGISTERED_KEY)); + } + Err(e) => { + tracing::warn!(helper = %helper.path.display(), err = %e, "The Windows Hello helper failed; declining the challenge"); + return Err(Unsigned::Declined(DECLINED_NO_HELPER)); + } + }; + if !challenge.key_fingerprints.contains(&fingerprint(&spki)) { + tracing::info!( + "The Windows Hello key credential is not registered; declining the challenge" + ); + return Err(Unsigned::Declined(DECLINED_NO_REGISTERED_KEY)); + } + let notice = notice(); + if !notice.is_empty() { + crate::terminal::send(¬ice).await; + } + match helper + .sign(&challenge.statement, remaining(challenge)) + .await + { + Ok(signature) => Ok(signature), + Err(e) if e.is_cancelled() => Err(Unsigned::Denied( + "the Windows Hello dialog was cancelled".to_owned(), + )), + Err(RunError::Timeout) => Err(Unsigned::Denied( + "the Windows Hello dialog timed out".to_owned(), + )), + Err(e) if e.is_not_enrolled() => Err(Unsigned::Declined(DECLINED_NO_REGISTERED_KEY)), + Err(e @ RunError::Refused(_)) => { + tracing::warn!(err = %e, "Declining the Windows Hello challenge"); + Err(Unsigned::Declined(DECLINED_NO_HELPER)) + } + Err(e) => { + tracing::warn!(err = %e, "Windows Hello did not sign; declining the challenge"); + Err(Unsigned::Declined(DECLINED_SIGN_FAILED)) + } + } + } +} + +/// The challenge's remaining lifetime, for the dialog. +fn remaining(challenge: &crate::proto::AuthorizationChallenge) -> std::time::Duration { + let lifetime = crate::ssh_agent_presence::CHALLENGE_LIFETIME; + challenge + .expires_at + .and_then(|t| std::time::SystemTime::try_from(t).ok()) + .and_then(|t| t.duration_since(std::time::SystemTime::now()).ok()) + .map_or(lifetime, |d| d.min(lifetime)) + .max(std::time::Duration::from_secs(1)) +} + +/// A registered key and its signatures, for tests elsewhere. +#[cfg(test)] +pub mod test_support { + /// PKCS #8 DER of an RSA-2048 key, base64. + pub const PRIVATE_KEY: &str = "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC869jDZL09C2QqJKYT4SOtE50LSVsObls1eOzdtFl/0DEUvF2GWHavbBb26Pdjy8L94G0L+pKsfxvajLYUeqrlljz6JRUePb7EL2awJnMNq8TanP+f7cY1/EvbTSAMjefRXZoayMmpreXxHf0PU0fC7FHayuO94huhLqaEQQUi/n0rLygpr229gqdVWsF2lfnoEfke3x7CqeSkwYOMbnjP1JvDEaZMn1UHIuGzL3D171bDowobURlURcyNvRr/BGKUkqZWa5GExV4qh0Qx2G4aJIXOsW+uumKnu2RJY8Gz9oC6OC0xpe1UzKNa/NPrHEyrjBm9mDNRzGZ96Bi46M+tAgMBAAECggEADxlDUPRtamJNbk/RBSA2d3fsknEZqajYuciRF7P4/i3PqmJWkCzkSWdYW4HpeayYiC1GUdGEJOygt4IM8EyQeXpdI7nQewMAytGmAZiFE/fhKv6msGuyrnBcSKnckJxLxXAXZ6IZ+elnyfI1XiLwBoxYC2YxNuUKA8MEdGf05uOv6V502fa+2SnGlc4mhz1nQakn63B8AvmYuHi/yeNkqJvScplIwJcL4T9D3yv+KNCKWLMZX6/9B6X5tXvHlL/KRM9irY3Sm/aLV5PBbVzM/zZXou1J8pp1DX7R74Yr4QSRx2YLkQBsEMCKRitnFL9DMJODIZkj6GA6QRXd3PhtEQKBgQD+vGt8BXKX3kwyJyDLJf+yC3+FB9Fp82V8i7qkb4tPXT1/vwL7TCNFNffV1hPWvmab52nxUKOdUpGGmyjPiwtOCbv1EMb22u4cVeTGyukjDxkAWZy2lAP6zQh7Bba4qlYWvi6q7QpQtasUcrCuvALcPnmTdiw/SykSUy/+lWuD3QKBgQC929ND6uCBd7Y7VPk4zby76hcvNPuG3kRPwRN4A7g1ukSOIgHFohpPtUC9z9g5ErujeT1bkRJnEaAR689+GWyiYz3Ygpp9twSDei9cwmg7OAsNWyGoKXDonsufEOm7U+urICDfjsP0o4AVQdRczChck/3/q1K1YN5dU/oM7PdmEQKBgDAWuePL03pACPpe0J4JKmAeM5CVjT8HJ91/M1jfhdkRrQ50rMdr9CyYp5TCuTQM9anw4dsgcgK9F6PlnqqvgQ7WX5k/9Y+cfN70iB/PJnvr2bhrMl51StsJ4dP3nCr4XRAmDwFNhmj+IrHVhO03qQKS+LiR0oCQW8yNz72rz4QxAoGBAK2t2qXWx2moSVwdb583FeARVQQqQ64sxfDvfw/a1zjPHMeo9FYG5GrmNENvTy1mpZ/12Nta6qq+xN+gCEAioe4rOvhFAW7fbZGYxJrmfNMvgWzGYROkVVV3bm4k2qwzKPNygKAByeCRcSoA/nOlj4ahG0o3P4INC1305FnOrMMBAoGBAMviyNG65WdrOyE0+lo1MfWBtacrWY3MX2OBerooboGfM+RHtsUUpDb13OjTzeDpDo+G0ubmvJASzxotv7zhr9gy8/YS0vK3JPKgPulPPklejSvRplMi7REmt+9xUc6zuvgkkYPJEEkFqN62rYaw6qvJvokLwFpwYKAWH3Dmk5YJ"; + + fn private() -> rsa::RsaPrivateKey { + use base64::Engine; + use rsa::pkcs8::DecodePrivateKey; + let der = base64::engine::general_purpose::STANDARD + .decode(PRIVATE_KEY) + .unwrap(); + rsa::RsaPrivateKey::from_pkcs8_der(&der).unwrap() + } + + pub fn key() -> super::RegisteredKey { + use rsa::pkcs8::EncodePublicKey; + let der = private().to_public_key().to_public_key_der().unwrap(); + super::RegisteredKey::from_der("test", der.as_bytes()).unwrap() + } + + /// RSA PKCS #1 v1.5 with SHA-256, as Windows Hello signs. + pub fn sign_bytes(data: &[u8]) -> Vec { + use rsa::signature::{SignatureEncoding, Signer}; + rsa::pkcs1v15::SigningKey::::new(private()) + .sign(data) + .to_vec() + } + + pub fn signed( + challenge: &crate::proto::AuthorizationChallenge, + ) -> crate::proto::AuthorizationResponse { + crate::proto::AuthorizationResponse { + challenge_id: challenge.challenge_id.clone(), + result: Some( + crate::proto::authorization_response::Result::WindowsHelloSignature(sign_bytes( + &challenge.statement, + )), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Q-W2: from a Windows Hello key on Windows 11 25H2. + const QW2_SPKI: &str = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzXqNwEiMhTbmSIZQbFz7qICqSP/pTwGiwHPzu5pUh6uIK72wZGT2NgfmkahtC0wZBW2VXw9Z37fCLdYdAhtIl7dU//58lktKckkwfXCKkYQ4QD/K1R5CvnceXUuqIgwR3qxJCV1KW1Kv7CvH7NiZK1VaiAtqbPNOe4Uilk14rntMfH0OhPfS6HiTg7UmRoaYsR4q70owKnC6ahHBYILwDY6X4h3Lip8k9sOUUGVsE2CSCON78jejJqism166CNYsP99nlundVddlhh2m3BhcYoqObg7WVNGqZH/ovQQluuh0oktVGsrvvBpTFs7LKoHZMHt/YWSF+A1CSUue+PpFaQIDAQAB"; + const QW2_DATA: &[u8] = b"mairu Q-W2 test vector: hello"; + const QW2_SIGNATURE: &str = "yQ3hK8Zppf577e0I86Kr1jmsENGG5By1w6viC81BSRsbkmP+TFTDCcuF1Wmp3qPkCD6E9fpJoVL8nM6YFEAETsLDdCbGRSodBGV8konQrp3AZCcLN768hU6UtJ0VNOooZ7aJh3RvyMd3hQ2RdWaRyR15gPESvlWNRVN+Q9G65A+iGgEFyIyH7O6pLeJIT8t1JazKyzfSCKT37BZiOd3WVwolruy7vKHV96+i5XWGF6mkrx+F2ArjwBnnRTuUWfu6ntswzh2zc0lSS8obKzHCX1hSEfQlwbCZjiTcTqhmSjRtOiGUroq4UoRtIOTHvdTVs35iYRJ5i6uvcrVHVf8oBA=="; + /// The test key's RSA-PSS (SHA-256, 32-byte salt) signature over [`PSS_DATA`], from OpenSSL. + const PSS_DATA: &[u8] = b"mairu PSS fixture"; + const PSS_SIGNATURE: &str = "JaJuI/daeqxBA8s3txr/a+WEqe9az7E6VvGQ4qjlgK2n6sQJsf8puwxaabyUFc9KZYG7lcSua5RKTHC5jCqDhJUXD7KUHO3tbgxe/FDR4F16rdXaPna4w4GNHb2O/FNBCgggjsjREpiNXIrTChChE0I0xJ/WtI4HXg3/gJvJ3rHdkuJeYs6q5QTBd5rkT6aZVCFBX7k6G26N0KW7NG91fEayxSjp36Eg8hffWbZn9pPg6IXwRV8a37rscCTZoiPcqQj7lSshQLUnzWSRnLgkhTocnGKCNWcgvW5O3O6/ouWDpmjWeOIbMLR31//TlIvNRfEvCoBUru2172iAOxv2NQ=="; + const RSA_1024_SPKI: &str = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDV1vcfc5FbYFCP4NLad7Cb2aMROlVletkow+G6LAKMq5i1irWQ5OM3aDzdWb+b4+JdHlXb/YaOJQsx7zFEcvw4Ybsh5lQPzh+Omlm9EHCiNm5J5pFIi5YWXRgac97pwKX2YgKdD7mRkqv4jS5CwNhBXQkF4ruhS0rnlUxG8CQjSQIDAQAB"; + const P256_SPKI: &str = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEcgCBEbv8eo0ixfkV7AEwi7RInSj2V24dyw44tvg1Rz1XQ69sAm++wfLk15e6yl+MGlUyabN+5n5ZLyw6WQ0KjA=="; + + const NOW: std::time::Duration = std::time::Duration::from_secs(1000); + + fn b64(s: &str) -> Vec { + use base64::Engine; + base64::engine::general_purpose::STANDARD.decode(s).unwrap() + } + + fn binding(pid: u32) -> crate::ssh_agent_presence::Binding { + crate::ssh_agent_presence::Binding { + pid, + start_time: 7, + action: crate::ssh_agent_presence::ACTION_ASSUME_ROLE, + server_id: "contoso".to_owned(), + rolespec: None, + request_digest: [1; 32], + } + } + + #[test] + fn test_q_w2_vector() { + let key = RegisteredKey::parse("desktop", QW2_SPKI).unwrap(); + key.verify(QW2_DATA, &b64(QW2_SIGNATURE)).unwrap(); + assert!( + key.verify(b"mairu Q-W2 test vector: hellO", &b64(QW2_SIGNATURE)) + .is_err() + ); + assert!( + test_support::key() + .verify(QW2_DATA, &b64(QW2_SIGNATURE)) + .is_err() + ); + assert_eq!(key.to_base64(), QW2_SPKI); + assert!(key.fingerprint().starts_with("wh:SHA256:")); + assert_eq!(key.fingerprint(), fingerprint(&b64(QW2_SPKI))); + assert!(!key.fingerprint().ends_with('=')); + } + + #[test] + fn test_pss_refused() { + let key = test_support::key(); + key.verify(PSS_DATA, &test_support::sign_bytes(PSS_DATA)) + .unwrap(); + assert!(key.verify(PSS_DATA, &b64(PSS_SIGNATURE)).is_err()); + } + + #[test] + fn test_parse_refusals() { + assert!( + RegisteredKey::parse("x", "not base64!") + .unwrap_err() + .contains("base64") + ); + assert!( + RegisteredKey::parse("x", "AAAA") + .unwrap_err() + .contains("RSA") + ); + assert!( + RegisteredKey::parse("x", RSA_1024_SPKI) + .unwrap_err() + .contains("RSA-1024") + ); + assert!(RegisteredKey::parse("x", P256_SPKI).is_err()); + } + + #[test] + fn test_verify_challenge() { + let key = test_support::key(); + let keys = [key.clone()]; + let mut store = crate::ssh_agent_presence::Challenges::<()>::new([0; 16]); + let issue = |store: &mut crate::ssh_agent_presence::Challenges<()>, method| { + store + .issue(binding(1), method, fingerprints(&keys), NOW, None) + .0 + }; + let c = issue(&mut store, METHOD); + assert_eq!(c.method, "windows-hello"); + assert_eq!(c.key_fingerprints, vec![key.fingerprint()]); + let signature = test_support::sign_bytes(&c.statement); + let pending = store.take(&c.challenge_id).unwrap(); + assert_eq!( + verify(&pending, &binding(1), NOW, &signature, &keys).unwrap(), + &key + ); + assert!(store.take(&c.challenge_id).is_none()); + + // Refusals: another process, expiry, another key, an altered statement, an ssh-agent challenge. + assert!( + verify(&pending, &binding(2), NOW, &signature, &keys) + .unwrap_err() + .contains("another process") + ); + assert!( + verify( + &pending, + &binding(1), + NOW + crate::ssh_agent_presence::CHALLENGE_LIFETIME, + &signature, + &keys + ) + .unwrap_err() + .contains("expired") + ); + let other = RegisteredKey::parse("desktop", QW2_SPKI).unwrap(); + assert!(verify(&pending, &binding(1), NOW, &signature, &[other]).is_err()); + let mut altered = c.statement.clone(); + altered[0] ^= 1; + assert!( + verify( + &pending, + &binding(1), + NOW, + &test_support::sign_bytes(&altered), + &keys + ) + .is_err() + ); + let ssh = issue(&mut store, crate::ssh_agent_presence::METHOD); + let pending = store.take(&ssh.challenge_id).unwrap(); + assert!( + verify( + &pending, + &binding(1), + NOW, + &test_support::sign_bytes(&ssh.statement), + &keys + ) + .unwrap_err() + .contains("ssh-agent challenge") + ); + } + + #[test] + fn test_pin() { + if BUILD_HELPER_SHA256.is_some() { + return; + } + assert_eq!(Pin::resolve(None), Pin::None); + assert_eq!(Pin::resolve(Some("")), Pin::None); + assert_eq!(Pin::resolve(Some("ABCD")), Pin::Settings("abcd".to_owned())); + } + + /// A fake helper: `public-key` prints `spki`, `sign` saves stdin and prints `sign.out`, or exits with the + /// status in `sign.status`. + struct FakeHelper { + dir: std::path::PathBuf, + } + + impl FakeHelper { + fn new(name: &str, spki: &[u8]) -> Self { + use base64::Engine; + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "mairu-windows-hello-test-{}-{name}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("spki"), + base64::engine::general_purpose::STANDARD.encode(spki), + ) + .unwrap(); + let script = format!( + "#!/bin/sh\nd='{}'\ncase \"$1\" in\n public-key) cat \"$d/spki\" ;;\n sign) cat >\"$d/stdin\"; [ -e \"$d/sign.status\" ] && exit \"$(cat \"$d/sign.status\")\"; cat \"$d/sign.out\" ;;\n *) exit 64 ;;\nesac\n", + dir.display() + ); + let path = dir.join(HELPER_FILE_NAME); + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + Self { dir } + } + + fn path(&self) -> std::path::PathBuf { + self.dir.join(HELPER_FILE_NAME) + } + + fn respond(&self, signature: &[u8]) { + use base64::Engine; + std::fs::write( + self.dir.join("sign.out"), + base64::engine::general_purpose::STANDARD.encode(signature), + ) + .unwrap(); + } + + fn exit(&self, status: i32) { + std::fs::write(self.dir.join("sign.status"), status.to_string()).unwrap(); + } + + fn stdin(&self) -> Option { + std::fs::read_to_string(self.dir.join("stdin")).ok() + } + } + + impl Drop for FakeHelper { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + fn challenge( + store: &mut crate::ssh_agent_presence::Challenges<()>, + keys: &[RegisteredKey], + ) -> crate::proto::AuthorizationChallenge { + store + .issue(binding(1), METHOD, fingerprints(keys), NOW, None) + .0 + } + + #[tokio::test] + async fn test_relay_signs() { + use base64::Engine; + let key = test_support::key(); + let keys = [key.clone()]; + let fake = FakeHelper::new("signs", key.spki()); + let mut store = crate::ssh_agent_presence::Challenges::<()>::new([0; 16]); + let c = challenge(&mut store, &keys); + fake.respond(&test_support::sign_bytes(&c.statement)); + let relay = Relay::fixed(Some(fake.path()), false); + let noticed = std::sync::atomic::AtomicBool::new(false); + let signature = relay + .sign(&c, || { + noticed.store(true, std::sync::atomic::Ordering::SeqCst); + String::new() + }) + .await + .unwrap(); + assert!(noticed.load(std::sync::atomic::Ordering::SeqCst)); + assert_eq!( + fake.stdin().unwrap().trim(), + base64::engine::general_purpose::STANDARD.encode(&c.statement) + ); + let pending = store.take(&c.challenge_id).unwrap(); + verify(&pending, &binding(1), NOW, &signature, &keys).unwrap(); + } + + #[tokio::test] + async fn test_relay_declines() { + let key = test_support::key(); + let keys = [key.clone()]; + let mut store = crate::ssh_agent_presence::Challenges::<()>::new([0; 16]); + let c = challenge(&mut store, &keys); + + let missing = Relay::fixed(None, false); + assert_eq!( + missing.sign(&c, String::new).await, + Err(Unsigned::Declined(DECLINED_NO_HELPER)) + ); + + // Another key credential than the registered one: nothing is shown. + let other = RegisteredKey::parse("desktop", QW2_SPKI).unwrap(); + let fake = FakeHelper::new("unregistered", other.spki()); + let relay = Relay::fixed(Some(fake.path()), false); + assert_eq!( + relay.sign(&c, || panic!("noticed")).await, + Err(Unsigned::Declined( + crate::ssh_agent_presence::DECLINED_NO_REGISTERED_KEY + )) + ); + assert!(fake.stdin().is_none()); + // The decline sticks. + drop(fake); + assert_eq!( + relay.sign(&c, String::new).await, + Err(Unsigned::Declined( + crate::ssh_agent_presence::DECLINED_NO_REGISTERED_KEY + )) + ); + + let fake = FakeHelper::new("fails", key.spki()); + fake.exit(1); + assert_eq!( + Relay::fixed(Some(fake.path()), false) + .sign(&c, String::new) + .await, + Err(Unsigned::Declined( + crate::ssh_agent_presence::DECLINED_SIGN_FAILED + )) + ); + } + + #[tokio::test] + async fn test_relay_cancelled() { + let key = test_support::key(); + let mut store = crate::ssh_agent_presence::Challenges::<()>::new([0; 16]); + let c = challenge(&mut store, std::slice::from_ref(&key)); + let fake = FakeHelper::new("cancelled", key.spki()); + fake.exit(EXIT_CANCELLED); + let relay = Relay::fixed(Some(fake.path()), false); + assert!(matches!( + relay.sign(&c, String::new).await, + Err(Unsigned::Denied(d)) if d.contains("cancelled") + )); + // A cancellation is no decline; the next challenge asks again. + assert!(matches!( + relay.sign(&c, String::new).await, + Err(Unsigned::Denied(_)) + )); + } + + #[tokio::test] + async fn test_relay_pin() { + let key = test_support::key(); + let mut store = crate::ssh_agent_presence::Challenges::<()>::new([0; 16]); + let mut c = challenge(&mut store, std::slice::from_ref(&key)); + let fake = FakeHelper::new("pin", key.spki()); + fake.respond(&test_support::sign_bytes(&c.statement)); + let actual = digest(&fake.path()).unwrap(); + + let helper = |pin: Pin, hardened| Helper { + path: fake.path(), + pin, + hardened, + }; + helper(Pin::Settings(actual.clone()), true).check().unwrap(); + assert!( + helper(Pin::Settings("00".repeat(32)), true) + .check() + .unwrap_err() + .contains("does not match") + ); + assert!( + helper(Pin::None, true) + .check() + .unwrap_err() + .contains("no SHA-256 is pinned") + ); + helper(Pin::Settings("00".repeat(32)), false) + .check() + .unwrap(); + helper(Pin::None, false).check().unwrap(); + + if BUILD_HELPER_SHA256.is_some() { + return; + } + // A hardened client runs the helper only while it matches the approved pin. + assert_eq!( + Relay::fixed(Some(fake.path()), true) + .sign(&c, String::new) + .await, + Err(Unsigned::Declined(DECLINED_NO_HELPER)) + ); + c.windows_hello_helper_sha256 = actual; + Relay::fixed(Some(fake.path()), true) + .sign(&c, String::new) + .await + .unwrap(); + c.windows_hello_helper_sha256 = "00".repeat(32); + assert_eq!( + Relay::fixed(Some(fake.path()), true) + .sign(&c, String::new) + .await, + Err(Unsigned::Declined(DECLINED_NO_HELPER)) + ); + } +}