diff --git a/.github/actions/build-appimage/action.yml b/.github/actions/build-appimage/action.yml index 339c2a6..e663598 100644 --- a/.github/actions/build-appimage/action.yml +++ b/.github/actions/build-appimage/action.yml @@ -21,14 +21,25 @@ inputs: required: false default: 'OpenSSH-GUI' appimagetool-tag: - description: 'Release tag of appimagetool to download' + description: 'Release tag of appimagetool to download (must match appimagetool-sha256)' required: false - default: 'continuous' + default: '1.9.1' + appimagetool-sha256: + description: 'Expected SHA-256 of appimagetool-x86_64.AppImage for the given tag' + required: false + default: 'ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0' + update-information: + description: 'zsync update information string (empty disables delta-update embedding)' + required: false + default: 'gh-releases-zsync|frequency403|OpenSSH-GUI|latest|OpenSSH-GUI-x86_64.AppImage.zsync' outputs: appimage-name: description: 'Filename of the produced AppImage' value: ${{ steps.build.outputs.appimage-name }} + appimage-zsync-name: + description: 'Filename of the produced .zsync file (empty if update-information was not set)' + value: ${{ steps.build.outputs.appimage-zsync-name }} runs: using: composite @@ -38,27 +49,34 @@ runs: run: | set -euo pipefail sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends librsvg2-bin appstream + sudo apt-get install -y --no-install-recommends librsvg2-bin appstream zsync - name: Download appimagetool shell: bash + env: + TOOL_TAG: ${{ inputs.appimagetool-tag }} + TOOL_SHA256: ${{ inputs.appimagetool-sha256 }} run: | set -euo pipefail - TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/${{ inputs.appimagetool-tag }}/appimagetool-x86_64.AppImage" + TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/${TOOL_TAG}/appimagetool-x86_64.AppImage" echo "::notice::Downloading appimagetool from $TOOL_URL" wget --progress=dot:giga "$TOOL_URL" -O appimagetool + # The tool is executed with access to the build output - verify it before running it. + echo "${TOOL_SHA256} appimagetool" | sha256sum --check --strict chmod +x appimagetool - name: Assemble and build AppImage id: build shell: bash + env: + BINARY_PATH: ${{ inputs.binary-path }} + VERSION: ${{ inputs.version }} + IS_NIGHTLY: ${{ inputs.is-nightly }} + ASSET_PREFIX: ${{ inputs.asset-prefix }} + UPDATE_INFO: ${{ inputs.update-information }} run: | set -euo pipefail - BINARY_PATH="${{ inputs.binary-path }}" - VERSION="${{ inputs.version }}" - IS_NIGHTLY="${{ inputs.is-nightly }}" - ASSET_PREFIX="${{ inputs.asset-prefix }}" APP_ID="io.github.frequency403.openssh_gui" # Validate all required source files exist before doing any work @@ -116,7 +134,14 @@ runs: # Assemble AppImage (--appimage-extract-and-run bypasses FUSE requirement) APPIMAGE_NAME="${ASSET_PREFIX}-x86_64.AppImage" - ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir "$APPIMAGE_NAME" + + if [[ -n "$UPDATE_INFO" ]]; then + ARCH=x86_64 ./appimagetool --appimage-extract-and-run \ + -u "$UPDATE_INFO" \ + AppDir "$APPIMAGE_NAME" + else + ARCH=x86_64 ./appimagetool --appimage-extract-and-run AppDir "$APPIMAGE_NAME" + fi if [[ ! -f "$APPIMAGE_NAME" ]]; then echo "::error::appimagetool did not produce $APPIMAGE_NAME" @@ -124,4 +149,14 @@ runs: fi echo "appimage-name=$APPIMAGE_NAME" >> "$GITHUB_OUTPUT" + + ZSYNC_NAME="${APPIMAGE_NAME}.zsync" + if [[ -n "$UPDATE_INFO" ]]; then + if [[ ! -f "$ZSYNC_NAME" ]]; then + echo "::error::update-information was set but $ZSYNC_NAME was not produced (zsyncmake missing on PATH?)" + exit 1 + fi + echo "appimage-zsync-name=$ZSYNC_NAME" >> "$GITHUB_OUTPUT" + fi + echo "::notice::AppImage built successfully: $APPIMAGE_NAME" \ No newline at end of file diff --git a/.github/actions/deploy-aur/action.yml b/.github/actions/deploy-aur/action.yml index aeb1e97..e670932 100644 --- a/.github/actions/deploy-aur/action.yml +++ b/.github/actions/deploy-aur/action.yml @@ -41,7 +41,7 @@ runs: - name: Deploy via AUR Action (primary) id: aur_action continue-on-error: true - uses: KSXGitHub/github-actions-deploy-aur@v4.1.3 + uses: KSXGitHub/github-actions-deploy-aur@da03e160361ce01bf087e790b6ffd196d7dccff7 # v4.1.3 with: pkgname: ${{ inputs.pkgname }} pkgbuild: ${{ inputs.pkgbuild }} @@ -56,15 +56,19 @@ runs: - name: Setup SSH key for fallback if: steps.aur_action.outcome == 'failure' shell: bash + env: + SSH_PRIVATE_KEY: ${{ inputs.ssh_private_key }} run: | + set -euo pipefail mkdir -p ~/.ssh - echo "${{ inputs.ssh_private_key }}" > ~/.ssh/aur_key - chmod 600 ~/.ssh/aur_key + chmod 700 ~/.ssh + # Passed via env: inline expression expansion would place the key into the generated script file. + (umask 077 && printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/aur_key) cat >> ~/.ssh/config << 'EOF' Host aur.archlinux.org IdentityFile ~/.ssh/aur_key User aur - StrictHostKeyChecking no + StrictHostKeyChecking accept-new EOF - name: Deploy via manual push (fallback) @@ -72,22 +76,31 @@ runs: continue-on-error: true if: steps.aur_action.outcome == 'failure' shell: bash + # Inputs are passed via env instead of inline expression expansion to prevent script injection + # (e.g. through commit messages). + env: + PKGNAME: ${{ inputs.pkgname }} + PKGBUILD_PATH: ${{ inputs.pkgbuild }} + COMMIT_USERNAME: ${{ inputs.commit_username }} + COMMIT_EMAIL: ${{ inputs.commit_email }} + COMMIT_MESSAGE: ${{ inputs.commit_message }} + FORCE_PUSH: ${{ inputs.force_push }} + REGENERATE_SRCINFO: ${{ inputs.regenerate_srcinfo }} + UPDPKGSUMS: ${{ inputs.updpkgsums }} run: | set -euo pipefail echo "::warning::AUR Action failed, falling back to manual push" - PKGBUILD_DIR="$(dirname "${{ inputs.pkgbuild }}")" - cd /tmp rm -rf aur-deploy mkdir aur-deploy cd aur-deploy - git clone ssh://aur@aur.archlinux.org/${{ inputs.pkgname }}.git . + git clone "ssh://aur@aur.archlinux.org/${PKGNAME}.git" . git checkout -B master origin/master - cp "$GITHUB_WORKSPACE/${{ inputs.pkgbuild }}" ./PKGBUILD + cp "$GITHUB_WORKSPACE/$PKGBUILD_PATH" ./PKGBUILD - if [[ "${{ inputs.updpkgsums }}" == "true" ]]; then + if [[ "$UPDPKGSUMS" == "true" ]]; then if command -v updpkgsums >/dev/null 2>&1; then updpkgsums PKGBUILD else @@ -95,24 +108,24 @@ runs: fi fi - if [[ "${{ inputs.regenerate_srcinfo }}" == "true" ]]; then + if [[ "$REGENERATE_SRCINFO" == "true" ]]; then docker run --rm -v "$(pwd)":/pkg archlinux:latest \ bash -c "useradd -m builder \ && cp /pkg/PKGBUILD /home/builder/ \ && su builder -c 'cd /home/builder && makepkg --printsrcinfo > /pkg/.SRCINFO'" fi - git config user.name "${{ inputs.commit_username }}" - git config user.email "${{ inputs.commit_email }}" + git config user.name "$COMMIT_USERNAME" + git config user.email "$COMMIT_EMAIL" git add PKGBUILD - if [[ "${{ inputs.regenerate_srcinfo }}" == "true" ]]; then + if [[ "$REGENERATE_SRCINFO" == "true" ]]; then git add .SRCINFO fi - git commit -m "${{ inputs.commit_message }}" || true + git commit -m "$COMMIT_MESSAGE" || true - if [[ "${{ inputs.force_push }}" == "true" ]]; then + if [[ "$FORCE_PUSH" == "true" ]]; then git push origin master --force else git push origin master @@ -121,6 +134,8 @@ runs: - name: Verify deployment succeeded if: steps.aur_action.outcome == 'failure' && steps.aur_manual.outcome == 'failure' shell: bash + env: + PKGNAME: ${{ inputs.pkgname }} run: | - echo "::error::Both deployment paths failed for ${{ inputs.pkgname }}" + echo "::error::Both deployment paths failed for $PKGNAME" exit 1 \ No newline at end of file diff --git a/.github/actions/determine-version/action.yml b/.github/actions/determine-version/action.yml index 9060a79..b045d7d 100644 --- a/.github/actions/determine-version/action.yml +++ b/.github/actions/determine-version/action.yml @@ -32,28 +32,30 @@ runs: - name: Extract Version id: extract shell: bash + env: + FILE: ${{ inputs.file-name }} + SUFFIX: ${{ inputs.suffix }} + PROPERTY: ${{ inputs.property }} run: | set -euo pipefail - FILE="${{ inputs.file-name }}" - SUFFIX="${{ inputs.suffix }}" if [[ ! -f "$FILE" ]]; then echo "::error::$FILE not found" exit 1 fi - BUILD_VERSION="$(dotnet msbuild "$FILE" -nologo -getProperty:${{ inputs.property }} -p:VersionSuffix="$SUFFIX" | tr -d '\r')" + BUILD_VERSION="$(dotnet msbuild "$FILE" -nologo -getProperty:"$PROPERTY" -p:VersionSuffix="$SUFFIX" | tr -d '\r')" if [[ -z "$BUILD_VERSION" ]]; then - echo "::error::${{ inputs.property }} not found in $FILE" + echo "::error::$PROPERTY not found in $FILE" exit 1 fi VERSION="${BUILD_VERSION%%-*}" if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::Invalid Property ${{ inputs.property }}: $VERSION" + echo "::error::Invalid Property $PROPERTY: $VERSION" exit 1 fi diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index 0f5854f..abb589d 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout merge commit - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 ref: ${{ github.event.pull_request.merge_commit_sha }} @@ -35,7 +35,7 @@ jobs: - name: Create Tag Action id: create_tag_action - uses: rickstaa/action-create-tag@v1 + uses: rickstaa/action-create-tag@a1c7777fcb2fee4f19b0f283ba888afa11678b72 # v1 with: commit_sha: ${{ github.event.pull_request.merge_commit_sha }} github_token: ${{ secrets.PAT_TOKEN }} diff --git a/.github/workflows/build-and-package.yml b/.github/workflows/build-and-package.yml index 34bcfd8..f28772d 100644 --- a/.github/workflows/build-and-package.yml +++ b/.github/workflows/build-and-package.yml @@ -11,6 +11,9 @@ on: type: string default: 'OpenSSH-GUI' +permissions: + contents: read + jobs: build: name: Build for ${{ matrix.target }} @@ -29,10 +32,10 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup .NET environment - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 with: global-json-file: global.json cache: true @@ -47,6 +50,8 @@ jobs: - name: Publish application shell: bash + env: + VERSION: ${{ inputs.version }} run: | set -euo pipefail dotnet publish OpenSSH_GUI/OpenSSH_GUI.csproj \ @@ -56,14 +61,16 @@ jobs: -p:PublishSingleFile=true \ -p:PublishReadyToRun=true \ -p:IncludeNativeLibrariesForSelfExtract=true \ - -p:Version="${{ inputs.version }}" + -p:Version="$VERSION" - name: Rename artifact for distribution id: rename shell: bash + env: + ASSET_NAME_PREFIX: ${{ inputs.asset_name_prefix }} run: | set -euo pipefail - ASSET_NAME="${{ inputs.asset_name_prefix }}-${{ matrix.target }}${{ matrix.asset_extension }}" + ASSET_NAME="${ASSET_NAME_PREFIX}-${{ matrix.target }}${{ matrix.asset_extension }}" SOURCE="./publish/OpenSSH_GUI${{ matrix.asset_extension }}" if [[ ! -f "$SOURCE" ]]; then echo "::error::Expected build output not found: $SOURCE" @@ -83,15 +90,23 @@ jobs: - name: Upload AppImage if: matrix.target == 'linux-x64' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ steps.appimage.outputs.appimage-name }} path: ${{ steps.appimage.outputs.appimage-name }} if-no-files-found: error + + - name: Upload AppImage zsync file + if: matrix.target == 'linux-x64' && steps.appimage.outputs.appimage-zsync-name != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ${{ steps.appimage.outputs.appimage-zsync-name }} + path: ${{ steps.appimage.outputs.appimage-zsync-name }} + if-no-files-found: error - name: Upload .desktop file if: matrix.target == 'linux-x64' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: io.github.frequency403.openssh_gui.desktop path: AppDir/usr/share/applications/io.github.frequency403.openssh_gui.desktop @@ -99,14 +114,14 @@ jobs: - name: Upload app icon if: matrix.target == 'linux-x64' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: appicon path: AppDir/appicon.png if-no-files-found: error - name: Upload platform binary - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ steps.rename.outputs.ASSET_NAME }} path: ./publish/${{ steps.rename.outputs.ASSET_NAME }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 266d51a..a84c6c0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,8 +4,6 @@ on: push: tags: - 'v[0-9]*.[0-9]*.[0-9]*' - branches: - - master permissions: contents: write @@ -29,7 +27,7 @@ jobs: tag: ${{ steps.version.outputs.tag }} steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: '1' ref: ${{ github.ref }} @@ -54,13 +52,13 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: '1' ref: ${{ github.ref }} - name: Download Windows binary - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: OpenSSH-GUI-win-x64.exe path: ./publish @@ -98,7 +96,7 @@ jobs: run: makensis installer.nsi - name: Upload installer artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: OpenSSH-GUI-${{ needs.prepare.outputs.version }}-Setup path: OpenSSH-GUI-${{ needs.prepare.outputs.version }}-Setup.exe @@ -113,10 +111,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Download all build artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: artifacts/ @@ -151,6 +149,7 @@ jobs: "OpenSSH-GUI-${VERSION}-Setup.exe" \ "OpenSSH-GUI-osx-x64" \ "OpenSSH-GUI-x86_64.AppImage" \ + "OpenSSH-GUI-x86_64.AppImage.zsync" \ "appicon.png" \ "io.github.frequency403.openssh_gui.desktop" do @@ -164,7 +163,7 @@ jobs: ls -lah release-assets/ - name: Publish GitHub Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: tag_name: ${{ needs.prepare.outputs.tag }} files: release-assets/* @@ -172,7 +171,7 @@ jobs: token: ${{ secrets.PAT_TOKEN }} - name: Trigger deployment - uses: peter-evans/repository-dispatch@v4 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4 with: event-type: deploy token: ${{ secrets.PAT_TOKEN }} diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index 9c5de6f..c3fc4e1 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -15,15 +15,20 @@ jobs: steps: - name: Checkout PKGBUILD only - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ github.event.client_payload.tag }} - name: Update PKGBUILD version + env: + VERSION: ${{ github.event.client_payload.version }} run: | set -euo pipefail - VERSION="${{ github.event.client_payload.version }}" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid version in dispatch payload: $VERSION" + exit 1 + fi sed -i "s/^pkgver=.*/pkgver=$VERSION/" openssh-gui-bin/PKGBUILD sed -i "s/^pkgrel=.*/pkgrel=1/" openssh-gui-bin/PKGBUILD @@ -48,7 +53,7 @@ jobs: timeout-minutes: 10 steps: - name: Update Winget manifest - uses: vedantmgoyal9/winget-releaser@main + uses: vedantmgoyal9/winget-releaser@a8fff44b123f115ce4c21543edf8b677376fc7e0 # main with: max-versions-to-keep: 5 identifier: frequency403.OpenSSHGUI diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index e95fa7e..6f811d5 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -8,6 +8,9 @@ on: concurrency: group: staging-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: aur_publish: name: Update GIT AUR package @@ -15,14 +18,14 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: '1' sparse-checkout: | openssh-gui-git/PKGBUILD ref: ${{ github.ref }} - name: Deploy - uses: KSXGitHub/github-actions-deploy-aur@v4.1.3 + uses: KSXGitHub/github-actions-deploy-aur@da03e160361ce01bf087e790b6ffd196d7dccff7 # v4.1.3 with: pkgname: openssh-gui-git pkgbuild: openssh-gui-git/PKGBUILD diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 76f7331..bf0b8d6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,9 @@ concurrency: group: tests-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true +permissions: + contents: read + jobs: test: name: ${{matrix.os}}-tests @@ -23,12 +26,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ github.ref }} - name: Setup .NET environment - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 with: global-json-file: global.json cache: true diff --git a/.github/workflows/version-guard.yml b/.github/workflows/version-guard.yml index a69e16c..6af375a 100644 --- a/.github/workflows/version-guard.yml +++ b/.github/workflows/version-guard.yml @@ -10,6 +10,9 @@ on: - reopened - edited +permissions: + contents: read + jobs: validate-version: runs-on: ubuntu-latest @@ -19,7 +22,7 @@ jobs: steps: - name: Checkout target (master) - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: master fetch-depth: 0 @@ -30,7 +33,7 @@ jobs: - name: Checkout PR branch - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} @@ -42,23 +45,25 @@ jobs: - name: Compare versions shell: bash + # The release version is read from the PR branch - pass it via env, never inline into scripts. + env: + MASTER: ${{ steps.master_version.outputs.version }} + RELEASE: ${{ steps.release_version.outputs.version }} run: | - MASTER="${{ steps.master_version.outputs.version }}" - RELEASE="${{ steps.release_version.outputs.version }}" - echo "Master: $MASTER" echo "Release: $RELEASE" python3 - << 'PYEOF' + import os import sys from packaging.version import Version - master = Version("${{ steps.master_version.outputs.version }}") - release = Version("${{ steps.release_version.outputs.version }}") + master = Version(os.environ["MASTER"]) + release = Version(os.environ["RELEASE"]) if release <= master: print(f"Release version {release} must be greater than master {master}") sys.exit(1) print("Version check passed") - PYEOF \ No newline at end of file + PYEOF diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8606e1a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,246 @@ +# AGENTS.md + +Instructions for AI coding agents working in this repository. Paths are relative to the repo root. Security issues are reported privately as described in `SECURITY.md` (GitHub Private Security Advisory preferred). Do not open public issues or PRs that disclose an unfixed vulnerability. + +## Project overview + +OpenSSH-GUI is a cross-platform desktop app (Avalonia, .NET 10) for managing SSH keys and SSH files. It can: +- find, load, generate, re-encrypt, rename, convert (OpenSSH/PuTTY) and delete SSH keys; +- edit `known_hosts` and `authorized_keys` locally, and on remote servers over SFTP; +- verify server host keys against `known_hosts` (TOFU); +- parse `ssh_config`/`sshd_config` and expose them through `Microsoft.Extensions.Configuration`. + +### Solution layout (`OpenSSH_GUI.slnx`) + +| Project | Purpose | +|---|---| +| `OpenSSH_GUI/` | The Avalonia app: `Program.cs` (host and bootstrap), `App.axaml(.cs)`, `Views/`, `ViewModels/`, `Services/` (e.g. `HostKeyTrustPrompt`), `Converters/`, `Extensions/DependencyInjectionExtensions.cs` (app service registrations), `Logging/Enricher/`, `Resources/{Controls,Styles}`, resx strings | +| `OpenSSH_GUI.Core/` | Domain and services: keys (`Lib/Keys`), host keys and TOFU (`Lib/HostKeys`, `Services/KnownHostKeyStore.cs`, `Services/ServerConnectionService.cs`), `known_hosts`/`authorized_keys` models, `ServerConnection` (SSH.NET + SFTP), configuration, MVVM base classes (`MVVM/`, `Resources/Wrapper/WindowBase.cs`), DI helpers (`Extensions/DependencyInjectionExtensions.cs`) | +| `OpenSSH_GUI.Dialogs/` | Modal dialogs: `IMessageBoxProvider`/`MessageBoxProvider`, `MessageBoxDialog`, `SecureInputDialog`, `ValidatedInputDialog`. Plain Avalonia windows, no ViewModels, no ReactiveUI | +| `OpenSSH_GUI.SshConfig/` | Pure library, no Avalonia dependency: `ssh_config(5)` parser and serializer (with `Include`), `SshWildcardMatcher`, `SshTokenExpander`, `SshConfigFileService`, and the `AddSshConfig(...)` configuration provider | +| `OpenSSH_GUI.Tests/` | xUnit v3 tests for Core and SshConfig, plus a trivial headless smoke test | + +Reference graph: App → Core → (Dialogs, SshConfig). Tests reference all four. + +`images/` is a build input (`../images/openssh-gui.ico`, ``, AppImage and NSIS icons from `images/openssh-gui.svg`). Do not move or rename those files. + +## Tech stack + +- **Platform:** .NET 10 (`net10.0` via `Directory.Build.props`). `global.json` requires SDK >= 10.0.100 (`rollForward: latestMajor`, `allowPrerelease: false`). This is a minimum, not a pin: the highest installed non-preview SDK is used, and CI's `actions/setup-dotnet` reads `global.json`. `LangVersion=default`, so on SDK 10 C# 14 features are in use: `extension(...)` blocks, the `field` keyword, `System.Threading.Lock`. +- **UI:** Avalonia 12.1 with the Fluent theme (Compact), DataGrid, Material.Icons, Xaml.Behaviors. +- **MVVM:** ReactiveUI, ReactiveUI.SourceGenerators (`[Reactive]`, `[ObservableAsProperty]`, `[ReactiveCommand]`), ReactiveUI.Validation, ReactiveUI.Avalonia, System.Reactive. **No CommunityToolkit.Mvvm.** +- **Infrastructure:** Microsoft.Extensions.Hosting / DI / Options (DataAnnotations validation), Serilog (console sink in DEBUG only, plus a daily rolling file). +- **SSH:** SSH.NET 2025.1.0, SshNet.Keygen, SshNet.PuttyKeyFile. +- **Tests:** xunit.v3, Avalonia.Headless.XUnit, NSubstitute, Shouldly, coverlet.collector. Tests run in VSTest mode (Microsoft.NET.Test.Sdk + xunit.runner.visualstudio; no Microsoft.Testing.Platform opt-in in `global.json`). `--filter` and `--collect` below depend on this. The test project is `OutputType=Exe`, `SelfContained=true`. + +## Commands + +Run from the repo root. **Always pass `OpenSSH_GUI.slnx` or an explicit `.csproj` path.** The root has no project file, and `OpenSSH_GUI.Tests/` contains two project files, so bare `dotnet build`/`dotnet run` in either place fails (MSB1011 in the Tests folder). + +```bash +dotnet restore OpenSSH_GUI.slnx +dotnet build OpenSSH_GUI.slnx --configuration Release --no-restore +dotnet test OpenSSH_GUI.slnx --configuration Release --no-build --verbosity normal # same as CI + +dotnet run --project OpenSSH_GUI # run the app +dotnet test OpenSSH_GUI.Tests/OpenSSH_GUI.Tests.csproj # tests only +dotnet test OpenSSH_GUI.slnx --filter "FullyQualifiedName~KnownHostKeyStoreTests" +dotnet test OpenSSH_GUI.slnx --collect:"XPlat Code Coverage" + +# Release-style publish (RIDs: linux-x64, win-x64, osx-x64) +dotnet publish OpenSSH_GUI/OpenSSH_GUI.csproj -c Release -r linux-x64 --output ./publish \ + -p:PublishSingleFile=true -p:PublishReadyToRun=true -p:IncludeNativeLibrariesForSelfExtract=true +``` + +CI (`build-and-package.yml`) additionally runs `dotnet restore --runtime ` first and passes `-p:Version=`. All RIDs are cross-published on `ubuntu-latest`. The app is self-contained by default (`true` in `OpenSSH_GUI.csproj`). The output `publish/OpenSSH_GUI[.exe]` is renamed to `OpenSSH-GUI-[.exe]`. Only linux-x64 also produces the AppImage, its `.zsync`, `appicon.png` and the `.desktop` file; the osx-x64 asset is a raw binary, not an `.app` bundle. + +There are no test traits or categories; filter by name. + +## Architecture and key patterns + +### Startup (`OpenSSH_GUI/Program.cs`) +1. Writes the default config if it is missing. +2. Reads a bootstrap config without DI to get the log level and creates a `LoggingLevelSwitch` from it. +3. Builds the generic host: + - `AddMutableConfiguration(...)` (JSON app config); + - `ConfigureAppConfiguration`: `AddSshConfig(...)` for `config` and `sshd_config`, the app JSON again via `PhysicalFileProvider`, an in-memory `RUNNING_VERSION`; + - `services.AddSingleton(levelSwitch)` (the instance Serilog uses); + - `RegisterOpenSshGuiServices()`; + - `UseSerilog` (`MinimumLevel.ControlledBy(levelSwitch)`). +4. Starts Avalonia with `App` resolved **from DI** (`App` is a singleton, `Application` forwards to it), using `UseReactiveUI(... WithExceptionHandler(ExceptionHandler))`. + +`App.OnFrameworkInitializationCompleted`: +- initializes SSH.NET logging (`SshNetLoggingConfiguration.InitializeLogging`); +- renders the app icons into `AppIconStore`, then calls `Freeze()` (`AddBitmap`/`AddWindowIcon` throw afterwards); +- applies theme, font size and log level from config (`IMutableConfiguration.Current`); +- resolves `MainWindow` with `ResolveViewAsync`; +- runs `SshKeyManager.InitialSearchAsync` when the window opens. + +### Dependency injection +- App service registrations live in `OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs` (`RegisterOpenSshGuiServices`). Exceptions: `Program.cs` registers the bootstrap `LoggingLevelSwitch`, and Core's `AddMutableConfiguration()` registers options (with `ValidateOnStart`), `JsonFileConfigurationWriter` and `IMutableConfiguration`. Core has no `AddCore()`. +- Services are mostly singletons (`SshKeyManager`, `ServerConnectionService`, `KnownHostKeyStore`, writer, backup, generator, `DirectoryCrawler`). `SshKeyFile` and `IMessageBoxProvider` are transient. `FileSystemAnalyzer` is a hosted service. +- **Views and ViewModels** are registered with `services.RegisterViewWithViewModel(lifetime)` (`OpenSSH_GUI.Core/Extensions/DependencyInjectionExtensions.cs`): + - Both are **keyed services**, keyed by `typeof(T).Name`. Default lifetime is `Transient`; only the `MainWindow`/`MainWindowViewModel` pair is `Singleton`. + - The view is built with `ActivatorUtilities`, then its public `required` properties (`Logger`, `Services`, `AppIconStore` on `WindowBase`) are filled from DI when available. + - The VM is resolved in `WindowBase.WindowInitialize` via `GetRequiredKeyedService(typeof(TViewModel).Name)`, which also sets `ViewModel.GetTopLevel`. + - **Naming is enforced at startup** (`InvalidOperationException` otherwise): `XWindow` ↔ `XViewModel` (e.g. `ApplicationSettingsWindow`/`ApplicationSettingsViewModel`), or a VM name that starts with the view name and ends in `ViewModel` (e.g. `FileInfoWindow`/`FileInfoWindowViewModel`). Identical view and VM names are rejected. +- **Opening windows:** `serviceProvider.ResolveViewAsync()` or `ResolveViewAsync(param)`. Each call creates a DI scope, calls `InitializeAsync`, checks `IsInitialized`, and disposes the scope on `Window.Closed`. There is no ViewLocator, no routing, no `ViewModelViewHost`. +- **Runtime parameters** go through `InitializeAsync(TParam)` (VM derives from `ViewModelBase`, view from `WindowBase`; e.g. `ExportWindow` uses `(string WindowTitle, string Export)`, `FileInfoWindow` uses `SshKeyFileSource`), `Load(...)` (`SshKeyFile`) or `WithCredentials(...)` (`ServerConnection`). Never through constructors. +- `Window` (singleton) and `IDialogHost` (transient) both forward to the keyed `MainWindow`, which owns dialogs. Use `IMessageBoxProvider`; never construct dialogs from ViewModels. + +### MVVM +- `ViewModelBase` (`OpenSSH_GUI.Core/MVVM/ViewModelBase.cs`): `ReactiveObject`, `IActivatableViewModel`, `IDisposable`, `IAsyncDisposable`, `IInitializableViewModel`. Provides `CompositeDisposable Disposables`, `ViewModelActivator`, `Close` (a source-generated `[Reactive]` `EventHandler` property, not a C# event) / `RequestClose()`, `IsInitialized`, `BooleanSubmitCommand`, `OpenFlyoutCommand`, `GetTopLevel`. + - Overrides of `InitializeAsync` must call the base implementation (it sets `IsInitialized` and activates the `Activator`), otherwise `ResolveViewAsync` throws `InvalidOperationException` ("ViewModel not properly initialized"). `ViewModelBase` seals the parameterless overload. + - `CloseOnBooleanSubmit` defaults to `true`, so executing `BooleanSubmitCommand` closes the window. +- `WindowBase` / `WindowBase` (`OpenSSH_GUI.Core/Resources/Wrapper/WindowBase.cs`) derive from ReactiveUI.Avalonia `ReactiveWindow`, resolve the keyed VM, wire `Close`, and handle the theme-dependent icon. +- Code-behind is mostly just a constructor calling `InitializeComponent()`; views are marked `[UsedImplicitly]`. Exceptions: `AddKeyWindow` binds validation in `WhenActivated` (`BindValidation`), and `MainWindow` takes `ILogger` via its constructor and implements `IDialogHost` (`OpenSSH_GUI.Core/Interfaces/Hosts/IDialogHost.cs`). +- Use source-generated reactive properties on `partial` classes and records. Dispose subscriptions with `.DisposeWith(Disposables)`. Marshal to the UI thread with `ObserveOn(AvaloniaScheduler.Instance)`. + +### Configuration +- App config is `ApplicationConfiguration` (`OpenSSH_GUI.Core/Configuration/`): `required` properties with DataAnnotations, validated on start. +- JSON uses the source-generated `SourceGenerationContext`, declared at the bottom of `OpenSSH_GUI.Core/Configuration/ApplicationConfiguration.cs`. Add new serializable types there as `[JsonSerializable(typeof(X))]`. +- Read app config via `IMutableConfiguration.Current` or `IOptionsMonitor` (`MutableConfiguration` wraps the monitor). Write only through `IMutableConfiguration` (`ExecuteConfigurationUpdateAsync` / `SetPropertyValueAsync`), which writes a temp file (`Path.GetTempFileName()`) and moves it over the target under a semaphore. React to the `ConfigurationChanged` event. +- SSH config is **not** registered as options: inject `IConfiguration` and call `GetSection("SshConfig").Get()` (as in `DirectoryCrawler`, `ConnectToServerViewModel`). + +### Logging +- Inject `ILogger` (MEL), backed by Serilog. SSH.NET logs are bridged by `SshNetLoggingConfiguration.InitializeLogging`. +- Known issue: `LoggingLevelSwitch` is registered twice (see Known quirks), so the instance `App` and `ApplicationSettingsViewModel` change is not the one Serilog uses. Do not assume the settings window controls the Serilog level. + +### Errors +- `SshKeyManager` operations return `KeyManagerOperationResult` (Success, Failure, Conflict, Cancelled) instead of throwing. +- Services log, then rethrow. +- `ExceptionHandler` is the ReactiveUI global handler. + +## Coding conventions + +- **Nullable** is enabled solution-wide (`Directory.Build.props`). Use `?` annotations, never `[CanBeNull]`-style attributes. +- **Primary constructors** for injected dependencies in new code (examples: `KeyFileWriterService`, `SshKeyGenerator`, `HostKeyTrustPrompt`, `EditKnownHostsWindowViewModel`). Many existing classes use classic constructors (`SshKeyManager`, `ServerConnectionService`, `KnownHostKeyStore` (which also has a `(logger, path)` overload for tests), `KeyFileBackupService`, and 6 of 8 ViewModels). Convert only when already touching them. Constructors take only injected dependencies. +- **Register services in DI.** Do not `new` up services inside classes; value and resource objects are fine. +- **Central Package Management** in `Directory.Packages.props` (`ManagePackageVersionsCentrally`, `CentralPackageTransitivePinningEnabled`, `NoWarn` NU1507). To add a package: + 1. Add `` to `Directory.Packages.props`. + 2. Add a **version-less** `` in the csproj. + 3. For analyzers/source generators use child elements `all` and `runtime; build; native; contentfiles; analyzers; buildtransitive` (as in `OpenSSH_GUI.Core.csproj`). +- **Compiled bindings** are on by default (`AvaloniaUseCompiledBindingsByDefault=true`). Every view `.axaml` must declare `x:DataType="viewModels:..."`. Converters go in `OpenSSH_GUI/Converters/Converter.cs` (static `FuncValueConverter`s) or `OpenSSH_GUI.Core/Resources/Converter/`. +- **Strings:** + - Use resx (`OpenSSH_GUI/Resources/StringsAndTexts.resx`) for user-facing text, and add every new key to **both** `StringsAndTexts.resx` and `StringsAndTexts.de.resx`. Known gap: `ConnectToServerStatusBase` is missing from `de.resx` (129 vs 128 keys). + - Window-specific keys use the window-name prefix (e.g. `MainWindow...`); shared keys (e.g. `Error`, `CancelAndClose`, `SaveAndClose`, `HostKeyUnknownTitle`) may be unprefixed. + - The generated class lives in `OpenSSH_GUI.Resources`: `using OpenSSH_GUI.Resources;` in C#; `xmlns:openSshGui="clr-namespace:OpenSSH_GUI.Resources"` and `{x:Static openSshGui:StringsAndTexts.Key}` in XAML. +- **Namespaces and files:** file-scoped namespaces matching the folder. Views in `Views/*Window.axaml`, ViewModels in `ViewModels/*ViewModel.cs`, reusable controls in `OpenSSH_GUI/Resources/Controls/`. +- **Async:** pass `CancellationToken` through; use `IAsyncEnumerable` with `[EnumeratorCancellation]`. `ValueTask` is used in `SshKeyManager`, the generator, `InitializeAsync` and `ResolveViewAsync`; dialog (`IMessageBoxProvider`), config (`IMutableConfiguration`), trust-prompt (`IHostKeyTrustPrompt`) interfaces and `BooleanSubmitAsync` use `Task`. Match the signature of the interface you implement. +- **Code style** follows `OpenSSH_GUI.sln.DotSettings` (no `.editorconfig`): expression bodies where possible; `string.Empty` instead of `""`; no `this.`; remove redundant parentheses; joined attribute lists (`[A, B]`); wrap limit 182; object and collection initializers always chopped. +- **Documentation:** `OpenSSH_GUI.SshConfig` sets `GenerateDocumentationFile=true`, so missing public XML docs are flagged there. Dialogs carries XML docs by convention only. Keep both documented. +- **SshConfig:** document-model types (`SshConfigDocument`, `SshBlock` and subtypes, `SshLineItem` and subtypes, `SshHostSettings`, `SshMatchCriterion`) are immutable records using `ImmutableArray`, with static `Create` factories on the concrete block and line types. `SshConfiguration` is a mutable bindable class (`Global`, `Hosts`, `Blocks` as `List<>`). Option records (`SshConfigParserOptions`, `SshSerializerOptions`) have `Default` presets. Lookup tables use `FrozenSet`/`FrozenDictionary`. Parser, serializer, wildcard matcher, token expander and `SshConfigFileService` are static and not in DI. + +## Security-sensitive areas and invariants (do not break) + +Changes here need tests and careful review. + +1. **Host key verification (TOFU):** `Lib/Misc/ServerConnection.cs`, `Services/ServerConnectionService.cs`, `Services/KnownHostKeyStore.cs`, `Lib/HostKeys/`. + - `ServerConnection` defaults to rejecting every key (validator fallback `_ => false`). **Both** `SshClient` and `SftpClient` must use the validator; both must connect in `ConnectToServerAsync`. + - The only public factory is `WithCredentials(credentials, hostKeyValidator)`, with a required validator. Do not add a public constructor or overload without one. `ServerConnection.Empty` is a shared sentinel and must never be disposed (`DisposeCurrentConnection` skips it). + - Only `HostKeyVerificationStatus.Trusted` may accept a key. `Mismatch` and `Revoked` throw `HostKeyVerificationException`: no prompt, no override, no auto-add. + - Only `Unknown` goes to `IHostKeyTrustPrompt`. `IKnownHostKeyStore.Add` runs only after explicit user confirmation, then the connection is verified again. + - `Verify` classification: `Mismatch` only for an entry with the same key type and a different blob; a known host presenting a different key type is `Unknown` (TOFU prompt). `@revoked` is matched by exact blob. `@cert-authority` lines are ignored; invalid base64 lines are skipped. Hashed hosts use `|1|salt|hash` (HMAC-SHA1). + - Keep the `CryptographicOperations.FixedTimeEquals` comparisons for key blobs and hashed hosts. + - Keep `HostKeyInfo.GetKnownHostsName` canonicalization (lowercase host, `[host]:port` when port ≠ 22), matching OpenSSH. +2. **Local file permissions** (`Services/KeyFileWriterService.cs`, `KnownHostKeyStore`, `KnownHostsFile`, `Services/Hosted/FileSystemAnalyzer.cs`, `SshConfigFilesExtension.ValidateDirectories`): + - Private keys: `FileMode.CreateNew` (or `Create` only when overwrite is explicit), `FileShare.None`, `0600` on Unix applied on **both** create and overwrite. + - Never write key material with `File.WriteAllText` or similar. `SshConfigSerializer` writes with `File.WriteAllText(Async)` (umask-default mode, no atomic replace); never use it for key files. + - `0600` file / `0700` directory for `known_hosts` applies only when `KnownHostKeyStore.Add` / `KnownHostsFile` create them. `ValidateDirectories` creates `~/.ssh` as `0700`. Existing file modes are never tightened. + - Known gap: at startup `FileSystemAnalyzer` pre-creates missing `authorized_keys`, `known_hosts`, `config`, `sshd_config` with `new FileStream(path, FileMode.OpenOrCreate, FileAccess.Write)` (no `UnixCreateMode`, umask default, typically 0644) and tries to create `/etc/ssh` as `0755`. +3. **Passphrase handling:** + - Passphrases stay **in-process**. Never shell out to `ssh-keygen -P/-N` or put a secret on a command line. `SshKeyManager.ChangePasswordOfKeyAsync` re-encrypts from the decrypted `PrivateKeyFile`. + - Keep passphrase bytes in `SshKeyFilePassword` (a `ReactiveBufferWriter`). + - Library boundaries (`PrivateKeyFile`, `SshKeyEncryptionAes256`, `PasswordAuthenticationMethod`) require strings. Convert from bytes only at the call site, as late as possible; never store the string in a field or log it. Known gap: `PasswordConnectionCredentials` stores the SSH login password as a `string`. + - Clear temporary buffers: `ArrayPool.Return(buf, clearArray: true)`, and `.Clear()` on `stackalloc` spans. Known gap: `KeyFileWriterService.WriteToFile` does not clear its `stackalloc byte[256]` buffer (only the rented buffer is cleared); fix with `buffer[..writtenBytes].Clear()` in `finally` when touching it. + - Dispose `SecureInputResult` (`using var`); its `Dispose` zeroes the memory. + - Do not log passphrases or key material. +4. **Backup and restore** (`SshKeyManager`, `KeyFileBackupService`): new or changed mutating key operations must run under the `SshKeyManager` semaphore (and abort if acquisition fails), be bracketed by `BeginOperationLog()` / `EndOperationLog(errorsOccurred)`, back up files first and restore on failure. + - Fully implemented only in `ChangePasswordOfKeyAsync` and `RenameKeyAsync`. Known gaps: `TryDeleteKeyAsync` makes no backup and continues deleting when the semaphore wait fails; `GenerateNewKey` has no operation log or backup; `ChangeFormatOfKeyAsync` backs up before acquiring the semaphore. + - Backups are `FileInfo.CopyTo` copies named `.bak` in `~/.ssh//` (directory `0700`), next to `operation_log.log`. Do not log key material: the log stays on disk when `errorsOccurred` is true. The directory is deleted best-effort on success; cleanup exceptions are swallowed. +5. **Config-provided keys:** keys from `ssh_config` `IdentityFile` (`SshKeyFileSource.FromConfig`, `ProvidedByConfig`) stay read-only in the UI (`FileChangesAllowed`, `CanChangeFileName`). +6. **SFTP and remote files** (`ServerConnection`): + - Remote paths are relative to the SFTP home directory (`.ssh/`). + - `RemoteSshDirectoryMode = 700` and `RemoteSshFileMode = 600` are SSH.NET "decimal-octal" literals. **Do not change them to `0x1C0` or other octal forms.** They are applied only when the server OS is Unix/MacOSX and only on newly created entries; existing remote modes are left unchanged. + - Use SFTP for remote file access. The only remote shell commands are the fixed strings `uname -s` and `ver` in `GetServerOsAsync`. Never interpolate user or file data into `CreateCommand`. + +## Testing guidelines + +- **Location:** `OpenSSH_GUI.Tests/`, folders mirror the source (`Core/Extensions`, `Core/Lib/Keys`, `Core/MVVM`, `Core/Services`, `SshConfig/`, `ViewModels/`). Namespaces like `OpenSSH_GUI.Tests.Core.Services`. Note: `Core/Extensions/ServiceCollectionExtensionsTests.cs` holds class `DependencyInjectionExtensionsTests`. +- **Preferred conventions for new tests:** classes `Tests`, `public sealed class`; methods `Method_Scenario_ExpectedResult`. Existing tests mostly use plain `public class` (only `KeyFileWriterServiceTests`, `KnownHostKeyStoreTests`, `SshKeyManagerPasswordTests` are sealed) and some `X_Tests` method names. +- **Attributes:** `[AvaloniaFact]` / `[AvaloniaTheory]` are required for anything touching Avalonia or ReactiveUI schedulers; existing tests use them even for pure logic. Plain `[Fact]`/`[Theory]` are fine for pure logic (as in `KeyFileWriterServiceTests`, `KnownHostKeyStoreTests`). Data via `[InlineData]`. +- **Headless bootstrap:** `OpenSSH_GUI.Tests/App.axaml.cs` (`[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]`, `UseReactiveUI` + `UseHeadless`). `ReactiveUiInitFixture.cs` and `TestAssemblySetup.cs` are commented-out dead code. +- **Assertions:** Shouldly (`ShouldBe`, `Should.ThrowAsync`); mocks with NSubstitute; loggers via `NullLogger.Instance`. +- **File system:** use `Directory.CreateTempSubdirectory(...)` and delete it in `Dispose()`. Never touch the real `~/.ssh`; `KnownHostKeyStore` has a `(logger, path)` constructor for tests. +- **Platform-specific:** skip Unix-only permission tests with `Assert.SkipWhen(OperatingSystem.IsWindows(), "...")` and wrap the code in `#pragma warning disable CA1416`. +- **Cancellation:** `TestContext.Current.CancellationToken`. +- **Fixtures:** `OpenSSH_GUI.Tests/Assets/Testfiles/`, embedded as `OpenSSH_GUI.Tests.Assets.Testfiles.`. +- **Real keys:** generate with SshNet.Keygen (`SshKey.Generate`); see `BasicSshKeyFileInformationTests`, `SshKeyManagerPasswordTests`. +- **Cross-platform:** CI runs tests on Ubuntu, Windows and macOS; tests must pass on all three. +- **Coverage gaps:** no tests for `ServerConnection`, `KnownHostsFile`, `AuthorizedKeysFile`, `KeyFileBackupService`, `DirectoryCrawler`, the Dialogs project, or app ViewModels (`ViewModels/_SmokeTests.cs` is only a clipboard round-trip on a bare `Window`). Add tests when you change these areas. + +## Branches, versioning, commits and PRs + +- **Branches:** + - `master`: releases. It is also the GitHub default branch, so PRs may open against it; retarget day-to-day PRs to `development`. + - `development`: integration and default work target. + - `release/vX.Y.Z`: release PRs into `master` (`version-guard.yml` and `auto-tag.yml` only check the `release/` prefix). + - Feature branches (`feature/...`) get PRs into `development`. +- **Version:** single source is `` in `Directory.Build.props` (currently `3.2.4`). It is read by `.github/actions/determine-version` (`dotnet msbuild OpenSSH_GUI/OpenSSH_GUI.csproj -getProperty:Version`) and must be plain `X.Y.Z`. `version-guard.yml` runs on PRs into `master` whose head branch starts with `release/` and fails unless the version is greater than master's. Never tag by hand: `auto-tag.yml` tags `vX.Y.Z` when the release PR is merged. +- **Commits:** free-form imperative summaries (no Conventional Commits). Merged PRs appear as single squash commits titled ` (#NN)`. Security work uses a `Security: ` prefix; an issue reference may lead the title (e.g. `#37 ...`). Direct commits on `development` are free-form. +- **PRs:** fill out `.github/PULL_REQUEST_TEMPLATE.md` and reference the issue (`Closes #123`). Keep PRs small and focused. `dotnet build` and `dotnet test` must pass with no new warnings. + +## CI and release (`.github/`) + +- **Workflows:** + - `test.yml` ("Unit Tests"): `pull_request` (opened/reopened/synchronize, any base) and `workflow_call` from `build.yml`. **No push trigger**, so direct pushes are not tested. Matrix: `ubuntu-latest`, `windows-latest`, `macOS-latest`. Runs restore/build/test on `OpenSSH_GUI.slnx` (Release). + - `build.yml`: push of a tag matching `v[0-9]*.[0-9]*.[0-9]*` (created by `auto-tag.yml` with `secrets.PAT_TOKEN` so it triggers this workflow). Steps: tests, `build-and-package.yml`, NSIS installer (`installer/OpenSSH-GUI.nsi`), GitHub Release (the raw `OpenSSH-GUI-win-x64.exe` is removed in favor of the installer), then a `repository_dispatch` `deploy` with payload `{tag, version}`. + - `deploy-release.yml`: `repository_dispatch: deploy`. Publishes AUR `openssh-gui-bin` via composite `.github/actions/deploy-aur` (KSX action plus manual-push fallback), then Winget (`frequency403.OpenSSHGUI`, runs on `ubuntu-slim`). + - `staging.yml`: any push to `development` that changes `openssh-gui-git/PKGBUILD` republishes the AUR `-git` package (KSX action directly, reusing the push's head commit message). Edit that file deliberately. + - `version-guard.yml`, `auto-tag.yml`: see Versioning. +- **Permissions and secrets:** workflows default to `contents: read`; only `build.yml` (`contents: write`) and `auto-tag.yml` (`contents: write`, `pull-requests: write`) have write access. Secrets: `PAT_TOKEN`, `AUR_SSH_PRIVATE_KEY`, `WINGET_PUBLISH_TOKEN`. Do not expose them to PR-controlled code. `version-guard.yml` checks out untrusted PR head code; keep it read-only and secret-free. +- **Pinned actions:** every third-party `uses:` (including inside `.github/actions/*`) is pinned to a commit SHA, mostly with a version comment (`winget-releaser` is `# main`). Local composite actions are referenced by path. `build-appimage` downloads appimagetool 1.9.1 and verifies it with `sha256sum --check --strict`; update the `appimagetool-tag` and `appimagetool-sha256` inputs together. Keep everything pinned. +- **Release asset names are a contract.** Keep in sync: + - `build.yml` expected assets: `OpenSSH-GUI-linux-x64`, `OpenSSH-GUI-${VERSION}-Setup.exe`, `OpenSSH-GUI-osx-x64`, `OpenSSH-GUI-x86_64.AppImage`, `OpenSSH-GUI-x86_64.AppImage.zsync`, `appicon.png`, `io.github.frequency403.openssh_gui.desktop`; and its `asset_name_prefix: OpenSSH-GUI`; + - `OutFile` in `installer/OpenSSH-GUI.nsi`; + - the publish output name `OpenSSH_GUI[.exe]` expected by `build-and-package.yml`; + - the zsync string `gh-releases-zsync|frequency403|OpenSSH-GUI|latest|OpenSSH-GUI-x86_64.AppImage.zsync` in `.github/actions/build-appimage`; + - `openssh-gui-bin/PKGBUILD` source URLs (including `LICENSE` from raw.githubusercontent at tag `v${pkgver}`); + - the winget `Setup\.exe$` regex; + - `images/openssh-gui.svg` (AppImage and NSIS icons). + + The released `.desktop` file is generated by `appstreamcli make-desktop-file` from `appimage/io.github.frequency403.openssh_gui.metainfo.xml` (its app id), not taken from the repo-root `openssh-gui.desktop` (which is only copied to publish output). +- **Injected by CI, not manual:** metainfo (`` placeholder), NSIS (`@@VERSION@@`, `@@SOURCE_EXE@@`) and AppImage versions. `openssh-gui-bin` `pkgver`/`pkgrel` are rewritten by `deploy-release.yml` (repo value 3.1.6 is stale by design). `openssh-gui-git` derives its version in `pkgver()` and builds `#branch=development`. `CFBundleVersion` (1.0.0) in `OpenSSH_GUI.csproj` is static and not updated by CI. + +## Known quirks (don't "fix" these unasked, and don't trip over them) + +- `LoggingLevelSwitch` is registered both in `Program.cs` (`services.AddSingleton(levelSwitch)`, the instance Serilog uses) and in `OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs:36` (`AddSingleton()`, a new instance that wins resolution). Runtime level changes from `App.ApplyConfiguration` / `ApplicationSettingsViewModel` may therefore not reach Serilog. +- `OpenSSH_GUI.Tests/OpenSSH_GUI.Testss.csproj` is a stray Avalonia template file: not in the solution, inline `Version="11.3.12"` Avalonia references (CPM uses 12.1.0), and an `ApplicationManifest` (`app.manifest`) that does not exist. +- `OpenSSH_GUI.Dialogs.csproj` has machine-specific `` entries; they appear unused (Dialogs has no ReactiveUI code). +- `OpenSSH_GUI.csproj` names `OpenSSH-GUI.snk`, but the file is not tracked and signing is not enabled. +- `CONTRIBUTING.md` lists ".NET SDK 8.0+ (or .NET 10 for latest)"; SDK >= 10.0.100 is required. Its bare `dotnet run` fails from the repo root (use `dotnet run --project OpenSSH_GUI`), and its `dotnet publish ... --self-contained false` contradicts `true`. +- `NoWarn=AVLN3001` (`Directory.Build.props`) is intentional: `App` and `MainWindow` are XAML classes without parameterless constructors because they are built from DI. + +## Do / Don't checklist + +**Do** +- Build and test through `OpenSSH_GUI.slnx` or an explicit `.csproj` path. +- Register new services in `RegisterOpenSshGuiServices` and inject them with primary constructors. +- Add windows with `RegisterViewWithViewModel` using valid names, open them with `ResolveViewAsync`, set `x:DataType` in the XAML, and call base `InitializeAsync` in overrides. +- Pass runtime data through `InitializeAsync(TParam)`. +- Add UI strings to both `.resx` files. +- Add package versions only in `Directory.Packages.props`. +- Use `IMessageBoxProvider` for dialogs and `SecureInputDialog` (`ShowSecureInputAsync`) for secrets. +- Write cross-platform tests in temp directories, with Unix-only skips where needed. +- Keep all security invariants intact, and add tests when you touch them. + +**Don't** +- Accept host keys by default, auto-add on `Mismatch`/`Revoked`, bypass `IHostKeyTrustPrompt`, or add a `ServerConnection` factory without a validator. +- Write key files without `CreateNew`/`0600`, or loosen directory modes (`0700`). +- Pass passphrases to external processes, log them, or keep them in long-lived strings. +- Skip backup and restore, the semaphore, or the operation log in new or changed mutating key operations. +- Allow edits to `ProvidedByConfig` keys. +- "Fix" the SFTP `700`/`600` decimal-octal constants, or interpolate data into remote shell commands. +- Add `Version=` to `PackageReference`s, add a ViewLocator or routing, or `new` up services inside classes. +- Tag releases manually, bump the version outside `Directory.Build.props`, or rename release assets without updating every consumer. +- Unpin GitHub Actions from commit SHAs, or expose secrets to PR-controlled code. diff --git a/Directory.Build.props b/Directory.Build.props index 4851674..0e96742 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ enable default https://github.com/frequency403/OpenSSH-GUI - 3.2.4 + 3.3.0 true false diff --git a/Directory.Packages.props b/Directory.Packages.props index 82606a2..432be66 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,17 +17,17 @@ - - - - - - - - - - - + + + + + + + + + + + @@ -39,7 +39,7 @@ - + diff --git a/OpenSSH_GUI.Core/Extensions/SshConfigFilesExtension.cs b/OpenSSH_GUI.Core/Extensions/SshConfigFilesExtension.cs index 1e87942..958e9f8 100644 --- a/OpenSSH_GUI.Core/Extensions/SshConfigFilesExtension.cs +++ b/OpenSSH_GUI.Core/Extensions/SshConfigFilesExtension.cs @@ -35,7 +35,16 @@ public static void ValidateDirectories(ILogger? logger = null) try { if (!Directory.Exists(GetRootSshPath())) Directory.CreateDirectory(GetRootSshPath()); - if (!Directory.Exists(GetBaseSshPath())) Directory.CreateDirectory(GetBaseSshPath()); + if (!Directory.Exists(GetBaseSshPath())) + { + // OpenSSH expects the user's .ssh directory to be accessible by the owner only. + if (OperatingSystem.IsWindows()) + Directory.CreateDirectory(GetBaseSshPath()); + else + Directory.CreateDirectory( + GetBaseSshPath(), + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } } catch (Exception e) { diff --git a/OpenSSH_GUI.Core/Interfaces/IHostKeyTrustPrompt.cs b/OpenSSH_GUI.Core/Interfaces/IHostKeyTrustPrompt.cs new file mode 100644 index 0000000..884cdce --- /dev/null +++ b/OpenSSH_GUI.Core/Interfaces/IHostKeyTrustPrompt.cs @@ -0,0 +1,12 @@ +using OpenSSH_GUI.Core.Lib.HostKeys; + +namespace OpenSSH_GUI.Core.Interfaces; + +/// +/// Asks the user whether an unknown server host key should be trusted (trust on first use). +/// +public interface IHostKeyTrustPrompt +{ + /// true if the user confirmed the fingerprint and the key should be trusted. + Task ConfirmUnknownHostKeyAsync(HostKeyInfo hostKey); +} diff --git a/OpenSSH_GUI.Core/Interfaces/IKnownHostKeyStore.cs b/OpenSSH_GUI.Core/Interfaces/IKnownHostKeyStore.cs new file mode 100644 index 0000000..9781e26 --- /dev/null +++ b/OpenSSH_GUI.Core/Interfaces/IKnownHostKeyStore.cs @@ -0,0 +1,19 @@ +using OpenSSH_GUI.Core.Lib.HostKeys; + +namespace OpenSSH_GUI.Core.Interfaces; + +/// +/// Verifies server host keys against the user's known_hosts file and records newly trusted keys. +/// +public interface IKnownHostKeyStore +{ + /// + /// Checks the presented host key against the known_hosts entries of the host. + /// + HostKeyVerificationStatus Verify(HostKeyInfo hostKey); + + /// + /// Appends the host key to known_hosts. + /// + void Add(HostKeyInfo hostKey); +} diff --git a/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyInfo.cs b/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyInfo.cs new file mode 100644 index 0000000..e690c65 --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyInfo.cs @@ -0,0 +1,49 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; + +namespace OpenSSH_GUI.Core.Lib.HostKeys; + +/// +/// A server host key as presented during the SSH key exchange. +/// +/// The host name the connection was made to. +/// The port the connection was made to. +/// The public key in SSH wire format. +public sealed record HostKeyInfo(string Host, int Port, byte[] KeyBlob) +{ + /// + /// The key type encoded in the key blob, e.g. ssh-ed25519. + /// + public string KeyType { get; } = ReadKeyType(KeyBlob) ?? string.Empty; + + /// + /// The fingerprint in OpenSSH notation, e.g. SHA256:.... + /// + public string FingerprintSha256 => + $"SHA256:{Convert.ToBase64String(SHA256.HashData(KeyBlob)).TrimEnd('=')}"; + + /// + /// The host name as written to and matched against known_hosts + /// ([host]:port for non-default ports). + /// + public string KnownHostsName => GetKnownHostsName(Host, Port); + + public static string GetKnownHostsName(string host, int port) + { + // OpenSSH canonicalizes host names to lower case before looking them up in known_hosts. + var name = host.ToLowerInvariant(); + return port == 22 ? name : $"[{name}]:{port}"; + } + + /// + /// Reads the leading key type string of an SSH wire format public key. + /// + public static string? ReadKeyType(ReadOnlySpan keyBlob) + { + if (keyBlob.Length < sizeof(uint)) return null; + var length = BinaryPrimitives.ReadUInt32BigEndian(keyBlob); + if (length == 0 || length > keyBlob.Length - sizeof(uint)) return null; + return Encoding.ASCII.GetString(keyBlob.Slice(sizeof(uint), (int)length)); + } +} diff --git a/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationException.cs b/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationException.cs new file mode 100644 index 0000000..3409594 --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationException.cs @@ -0,0 +1,26 @@ +namespace OpenSSH_GUI.Core.Lib.HostKeys; + +/// +/// Thrown when a connection is aborted because the server host key could not be verified. +/// +public sealed class HostKeyVerificationException(HostKeyInfo hostKey, HostKeyVerificationStatus status) + : Exception(CreateMessage(hostKey, status)) +{ + public HostKeyInfo HostKey { get; } = hostKey; + + public HostKeyVerificationStatus Status { get; } = status; + + private static string CreateMessage(HostKeyInfo hostKey, HostKeyVerificationStatus status) => status switch + { + HostKeyVerificationStatus.Mismatch => + $"The {hostKey.KeyType} host key of {hostKey.KnownHostsName} does not match the key in known_hosts " + + $"({hostKey.FingerprintSha256}). Someone could be eavesdropping on the connection (man-in-the-middle " + + "attack), or the host key has been changed. Connection aborted.", + HostKeyVerificationStatus.Revoked => + $"The {hostKey.KeyType} host key of {hostKey.KnownHostsName} ({hostKey.FingerprintSha256}) " + + "is marked as revoked in known_hosts. Connection aborted.", + _ => + $"The authenticity of host {hostKey.KnownHostsName} ({hostKey.KeyType} {hostKey.FingerprintSha256}) " + + "was not confirmed. Connection aborted." + }; +} diff --git a/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationStatus.cs b/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationStatus.cs new file mode 100644 index 0000000..96959c1 --- /dev/null +++ b/OpenSSH_GUI.Core/Lib/HostKeys/HostKeyVerificationStatus.cs @@ -0,0 +1,19 @@ +namespace OpenSSH_GUI.Core.Lib.HostKeys; + +/// +/// Result of checking a server host key against the local known_hosts file. +/// +public enum HostKeyVerificationStatus +{ + /// No entry exists for the host and key type. + Unknown, + + /// An entry for the host matches the presented key. + Trusted, + + /// The host is known with a different key of the same type - possible man-in-the-middle attack. + Mismatch, + + /// The presented key is marked as @revoked. + Revoked +} diff --git a/OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs b/OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs index 3af05d0..08528b0 100644 --- a/OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs +++ b/OpenSSH_GUI.Core/Lib/Misc/ServerConnection.cs @@ -1,18 +1,28 @@ using System.Reactive.Disposables; using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; +using System.Text; using OpenSSH_GUI.Core.Enums; using OpenSSH_GUI.Core.Extensions; using OpenSSH_GUI.Core.Lib.AuthorizedKeys; +using OpenSSH_GUI.Core.Lib.HostKeys; using OpenSSH_GUI.Core.Lib.KnownHosts; using ReactiveUI; using ReactiveUI.SourceGenerators; using Renci.SshNet; +using Renci.SshNet.Common; namespace OpenSSH_GUI.Core.Lib.Misc; public sealed partial class ServerConnection : ReactiveObject, IDisposable { + private const string RemoteSshDirectory = ".ssh"; + + // SftpClient.ChangePermissions expects the octal digits written as a decimal number (e.g. 700 => rwx------). + private const short RemoteSshDirectoryMode = 700; + + private const short RemoteSshFileMode = 600; + private readonly CompositeDisposable _disposables = new(); [ObservableAsProperty(ReadOnly = true)] @@ -21,44 +31,39 @@ public sealed partial class ServerConnection : ReactiveObject, IDisposable [Reactive(SetModifier = AccessModifier.Private)] private DateTime _connectionTime = DateTime.Now; - [ObservableAsProperty(ReadOnly = true)] - private string _createEmptyFileCommand = string.Empty; - [Reactive(SetModifier = AccessModifier.Private)] private bool _isConnected; [ObservableAsProperty(ReadOnly = true)] private string _lineSeparator = string.Empty; - [ObservableAsProperty(ReadOnly = true)] - private string _readContentsCommand = string.Empty; - [Reactive(SetModifier = AccessModifier.Private)] private PlatformID _serverOs = PlatformID.Other; - private ServerConnection(ConnectionCredentials? credentials = null) + private ServerConnection(ConnectionCredentials? credentials = null, + Func? hostKeyValidator = null) { ConnectionCredentials = credentials ?? ConnectionCredentials.Empty; - ClientConnection = new SshClient(ConnectionCredentials.GetConnectionInfo()) + var connectionInfo = ConnectionCredentials.GetConnectionInfo(); + ClientConnection = new SshClient(connectionInfo) { KeepAliveInterval = TimeSpan.FromSeconds(10) }; + FileTransferConnection = new SftpClient(connectionInfo); + + // Without a validator every host key would be accepted - reject unless explicitly verified. + hostKeyValidator ??= _ => false; + EventHandler onHostKeyReceived = (_, args) => + args.CanTrust = hostKeyValidator( + new HostKeyInfo(ConnectionCredentials.Hostname, ConnectionCredentials.Port, args.HostKey)); + ClientConnection.HostKeyReceived += onHostKeyReceived; + FileTransferConnection.HostKeyReceived += onHostKeyReceived; _connectionStringHelper = this.WhenAnyValue(obj => obj.IsConnected) .Select(c => c ? $"{ConnectionCredentials.Username}@{ConnectionCredentials.Hostname}" : string.Empty) .ToProperty(this, obj => obj.ConnectionString) .DisposeWith(_disposables); - _readContentsCommandHelper = this.WhenAnyValue(obj => obj.ServerOs) - .Select(c => c == PlatformID.Win32NT ? "type" : "cat") - .ToProperty(this, obj => obj.ReadContentsCommand) - .DisposeWith(_disposables); - - _createEmptyFileCommandHelper = this.WhenAnyValue(obj => obj.ServerOs) - .Select(c => c == PlatformID.Win32NT ? "echo. >" : "touch") - .ToProperty(this, obj => obj.CreateEmptyFileCommand) - .DisposeWith(_disposables); - _lineSeparatorHelper = this.WhenAnyValue(obj => obj.ServerOs) .Select(e => e.GetLineSeparator()) .ToProperty(this, obj => obj.LineSeparator) @@ -79,18 +84,43 @@ private SshClient ClientConnection init => this.RaiseAndSetIfChanged(ref field, value); } + /// + /// SFTP channel used for all remote file access. Transferring file contents over SFTP avoids + /// building shell command lines from file paths or file contents. + /// + private SftpClient FileTransferConnection + { + get; + init => this.RaiseAndSetIfChanged(ref field, value); + } + /// - public void Dispose() { _disposables.Dispose(); } + public void Dispose() + { + _disposables.Dispose(); + FileTransferConnection.Dispose(); + ClientConnection.Dispose(); + } - public static ServerConnection WithCredentials(ConnectionCredentials credentials) => new(credentials); + /// + /// Creates a connection for the given credentials. + /// + /// The credentials used to authenticate. + /// + /// Decides whether the host key presented by the server is trusted. The connection is aborted + /// during the key exchange when it returns false. + /// + public static ServerConnection WithCredentials(ConnectionCredentials credentials, + Func hostKeyValidator) => new(credentials, hostKeyValidator); public async ValueTask ConnectToServerAsync(CancellationToken token = default) { await ClientConnection.ConnectAsync(token); - IsConnected = ClientConnection.IsConnected; + if (ClientConnection.IsConnected) + await FileTransferConnection.ConnectAsync(token); + IsConnected = ClientConnection.IsConnected && FileTransferConnection.IsConnected; if (!IsConnected) return ServerOs != PlatformID.Other && IsConnected; ServerOs = await GetServerOsAsync(token); - await CheckForFilesAndCreateThemIfTheyNotExistAsync(token); ConnectionTime = DateTime.Now; return ServerOs != PlatformID.Other && IsConnected; } @@ -99,6 +129,7 @@ public ValueTask DisconnectFromServerAsync(CancellationToken token = defau { try { + FileTransferConnection.Disconnect(); ClientConnection.Disconnect(); IsConnected = ClientConnection.IsConnected; return ValueTask.FromResult(true); @@ -113,12 +144,8 @@ public async ValueTask GetKnownHostsFromServerAsync(Cancellation { if (!IsConnected) throw new InvalidOperationException("No connection to get known hosts from"); - var path = await ResolveRemoteEnvVariablesAsync( - SshConfigFiles.Known_Hosts.GetPathOfFile(false, ServerOs), - token); - using var command = ClientConnection.CreateCommand($"{ReadContentsCommand} {path}"); - await command.ExecuteAsync(token); - return await KnownHostsFile.InitializeAsync(command.OutputStream, true, false, token); + var content = await ReadRemoteFileAsync(SshConfigFiles.Known_Hosts, token); + return await KnownHostsFile.InitializeAsync(content, true, true, token); } public async ValueTask WriteKnownHostsToServerAsync(KnownHostsFile knownHostsFile, @@ -127,12 +154,9 @@ public async ValueTask WriteKnownHostsToServerAsync(KnownHostsFile knownHo if (!knownHostsFile.KnownHosts.Any(e => e.ChangesMade)) return false; if (!IsConnected) return false; - var path = await ResolveRemoteEnvVariablesAsync( - SshConfigFiles.Known_Hosts.GetPathOfFile(false, ServerOs), token); var content = await knownHostsFile.GetUpdatedContentsAsync(ServerOs); - using var command = ClientConnection.CreateCommand(BuildRemoteWriteCommand(ServerOs, content, path)); - await command.ExecuteAsync(token); - return command.ExitStatus == 0; + await WriteRemoteFileAsync(SshConfigFiles.Known_Hosts, content, token); + return true; } public async ValueTask GetAuthorizedKeysFromServerAsync(CancellationToken token = default) @@ -140,11 +164,8 @@ public async ValueTask GetAuthorizedKeysFromServerAsync(Canc if (!IsConnected) throw new InvalidOperationException("No connection to get authorized keys from"); - var path = await ResolveRemoteEnvVariablesAsync( - SshConfigFiles.Authorized_Keys.GetPathOfFile(false, ServerOs), token); - using var command = ClientConnection.CreateCommand($"{ReadContentsCommand} {path}"); - await command.ExecuteAsync(token); - return await AuthorizedKeysFile.ParseAsync(command.OutputStream, token); + await using var content = await ReadRemoteFileAsync(SshConfigFiles.Authorized_Keys, token); + return await AuthorizedKeysFile.ParseAsync(content, token); } public async ValueTask WriteAuthorizedKeysChangesToServerAsync(AuthorizedKeysFile authorizedKeysFile, @@ -153,62 +174,57 @@ public async ValueTask WriteAuthorizedKeysChangesToServerAsync(AuthorizedK if (!authorizedKeysFile.ChangesMade) return false; if (!IsConnected) return false; - var path = await ResolveRemoteEnvVariablesAsync( - SshConfigFiles.Authorized_Keys.GetPathOfFile(false, ServerOs), token); var content = authorizedKeysFile.ExportFileContent(ServerOs); - using var command = ClientConnection.CreateCommand(BuildRemoteWriteCommand(ServerOs, content, path)); - await command.ExecuteAsync(token); - return command.ExitStatus == 0; + await WriteRemoteFileAsync(SshConfigFiles.Authorized_Keys, content, token); + return true; } - private async ValueTask ResolveRemoteEnvVariablesAsync(string originalPath, - CancellationToken token = default) + /// + /// Returns the SFTP path of the given file inside the remote user's SSH directory. + /// The path is relative to the SFTP working directory, which is the user's home directory + /// on both OpenSSH for Unix and OpenSSH for Windows. + /// + private static string GetRemotePath(SshConfigFiles file) => + $"{RemoteSshDirectory}/{Enum.GetName(file)!.ToLowerInvariant()}"; + + /// + /// Reads the given remote file via SFTP. A missing file yields an empty stream. + /// + private async ValueTask ReadRemoteFileAsync(SshConfigFiles file, CancellationToken token) { - if (!IsConnected) return originalPath; - var parts = originalPath.Split('%', StringSplitOptions.RemoveEmptyEntries); - var result = string.Empty; - foreach (var part in parts) - if (part.Contains('\\') || part.Contains('/')) - { - result += part.Trim(); - } - else - { - var cmdText = ServerOs is PlatformID.Unix or PlatformID.MacOSX - ? $"echo ${part}" - : $"echo %{part}%"; - using var command = ClientConnection.CreateCommand(cmdText); - await command.ExecuteAsync(token); - result += command.Result.Trim(); - } - - return result; + var path = GetRemotePath(file); + var content = new MemoryStream(); + if (await FileTransferConnection.ExistsAsync(path, token)) + await FileTransferConnection.DownloadFileAsync(path, content, token); + content.Seek(0, SeekOrigin.Begin); + return content; } - private async ValueTask CheckForFilesAndCreateThemIfTheyNotExistAsync(CancellationToken token = default) + /// + /// Replaces the contents of the given remote file via SFTP. Missing files and the + /// SSH directory are created with owner-only permissions on Unix hosts. + /// + private async ValueTask WriteRemoteFileAsync(SshConfigFiles file, string content, CancellationToken token) { - if (!ClientConnection.IsConnected) return; - - var authKeyPath = SshConfigFiles.Authorized_Keys.GetPathOfFile(false); - var knownHostPath = SshConfigFiles.Known_Hosts.GetPathOfFile(false); - - using var authorizedKeysFileCheck = ClientConnection.CreateCommand($"{ReadContentsCommand} {authKeyPath}"); - await authorizedKeysFileCheck.ExecuteAsync(token); + var path = GetRemotePath(file); + var isUnix = ServerOs is PlatformID.Unix or PlatformID.MacOSX; - using var knownHostsFileCheck = ClientConnection.CreateCommand($"{ReadContentsCommand} {knownHostPath}"); - await knownHostsFileCheck.ExecuteAsync(token); - - if (authorizedKeysFileCheck.ExitStatus != 0) + if (!await FileTransferConnection.ExistsAsync(RemoteSshDirectory, token)) { - using var createAuthCmd = ClientConnection.CreateCommand($"{CreateEmptyFileCommand} {authKeyPath}"); - await createAuthCmd.ExecuteAsync(token); + await FileTransferConnection.CreateDirectoryAsync(RemoteSshDirectory, token); + if (isUnix) FileTransferConnection.ChangePermissions(RemoteSshDirectory, RemoteSshDirectoryMode); } - if (knownHostsFileCheck.ExitStatus != 0) + var isNewFile = !await FileTransferConnection.ExistsAsync(path, token); + + await using (var remoteFile = + await FileTransferConnection.OpenAsync(path, FileMode.Create, FileAccess.Write, token)) { - using var createKnownCmd = ClientConnection.CreateCommand($"{CreateEmptyFileCommand} {knownHostPath}"); - await createKnownCmd.ExecuteAsync(token); + var bytes = new UTF8Encoding(false).GetBytes(content); + await remoteFile.WriteAsync(bytes, token); } + + if (isNewFile && isUnix) FileTransferConnection.ChangePermissions(path, RemoteSshFileMode); } private async ValueTask GetServerOsAsync(CancellationToken token = default) @@ -228,53 +244,4 @@ private async ValueTask GetServerOsAsync(CancellationToken token = d return PlatformID.Other; } - - /// - /// Builds a platform-appropriate shell command to write the given content to a file on the remote host. - /// - /// The of the remote host. - /// The content to write into the file. - /// The full remote path of the target file. - /// If true, appends to the file instead of overwriting it. - /// A shell command string ready to be executed on the remote host. - /// - /// Thrown when no write command can be constructed for the given . - /// - private static string BuildRemoteWriteCommand(PlatformID platformId, string content, string filePath, - bool append = false) - { - var redirectOperator = append ? ">>" : ">"; - - return platformId is PlatformID.Unix or PlatformID.MacOSX - ? BuildUnixCommand(content, filePath, redirectOperator) - : BuildWindowsCommand(content, filePath, redirectOperator); - } - - /// - /// Builds a Unix shell write command using printf for reliable, escape-safe output. - /// - /// The content to write. - /// The target file path on the remote host. - /// Shell redirect operator (> or >>). - /// A Unix shell command string. - private static string BuildUnixCommand(string content, string filePath, string redirectOperator) - { - var escaped = content.Replace("'", "'\\''"); - return $"printf '%s' '{escaped}' {redirectOperator} '{filePath}'"; - } - - /// - /// Builds a Windows shell write command using PowerShell's Set-Content or Add-Content - /// for reliable Unicode-safe file writing. - /// - /// The content to write. - /// The target file path on the remote host. - /// Shell redirect operator (> or >>), used to determine append mode. - /// A PowerShell command string. - private static string BuildWindowsCommand(string content, string filePath, string redirectOperator) - { - var escaped = content.Replace("'", "''"); - var cmdlet = redirectOperator == ">>" ? "Add-Content" : "Set-Content"; - return $"powershell -Command \"{cmdlet} -Path '{filePath}' -Value '{escaped}' -NoNewline -Encoding UTF8\""; - } -} \ No newline at end of file +} diff --git a/OpenSSH_GUI.Core/Services/KeyFileBackupService.cs b/OpenSSH_GUI.Core/Services/KeyFileBackupService.cs index d1485af..72e209c 100644 --- a/OpenSSH_GUI.Core/Services/KeyFileBackupService.cs +++ b/OpenSSH_GUI.Core/Services/KeyFileBackupService.cs @@ -19,6 +19,9 @@ public sealed class KeyFileBackupService : IKeyFileBackupService, IDisposable { private const string BackupFileExtension = "bak"; + private const UnixFileMode OwnerOnlyDirectoryMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + private static readonly string BackupDirectory = Path.Combine(SshConfigFilesExtension.GetBaseSshPath(), AppDomain.CurrentDomain.FriendlyName); @@ -76,8 +79,16 @@ public void BeginOperationLog() { if (_operationLogger is not null) return; - if (!Directory.Exists(BackupDirectory)) + // The directory holds copies of private keys - restrict it to the owner. + if (OperatingSystem.IsWindows()) + { Directory.CreateDirectory(BackupDirectory); + } + else + { + Directory.CreateDirectory(BackupDirectory, OwnerOnlyDirectoryMode); + File.SetUnixFileMode(BackupDirectory, OwnerOnlyDirectoryMode); + } var operationLogFile = Path.Combine(BackupDirectory, Path.ChangeExtension("operation_log", "log")); _loggerFactory = new SerilogLoggerFactory( diff --git a/OpenSSH_GUI.Core/Services/KeyFileWriterService.cs b/OpenSSH_GUI.Core/Services/KeyFileWriterService.cs index cd088e4..6f5d3a9 100644 --- a/OpenSSH_GUI.Core/Services/KeyFileWriterService.cs +++ b/OpenSSH_GUI.Core/Services/KeyFileWriterService.cs @@ -15,6 +15,8 @@ namespace OpenSSH_GUI.Core.Services; /// public class KeyFileWriterService(ILogger logger) : IKeyFileWriterService { + private const UnixFileMode OwnerOnlyFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + /// public async ValueTask WriteToFile(string filePath, string content, bool overwrite = false, Encoding? encoding = null) @@ -36,22 +38,29 @@ public async ValueTask WriteToFile(string filePath, string content, throw new IOException("File already exists"); } + // Create truncates an existing file, CreateNew closes the race between the existence check and the open. var options = new FileStreamOptions { BufferSize = 0, - Access = FileAccess.ReadWrite, - Mode = FileMode.OpenOrCreate, - Share = FileShare.ReadWrite + Access = FileAccess.Write, + Mode = overwrite ? FileMode.Create : FileMode.CreateNew, + Share = FileShare.None }; if (!OperatingSystem.IsWindows()) { - options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + options.UnixCreateMode = OwnerOnlyFileMode; } await using var fileStream = fileInfo.Open(options); logger.LogDebug("Opened file {filePath}", filePath); + if (!OperatingSystem.IsWindows()) + { + // UnixCreateMode only applies to newly created files - enforce 0600 on overwritten files as well. + File.SetUnixFileMode(fileStream.SafeFileHandle, OwnerOnlyFileMode); + } + byte[]? rented = null; var maxByteCount = encoding.GetMaxByteCount(content.Length); var buffer = maxByteCount <= 256 diff --git a/OpenSSH_GUI.Core/Services/KnownHostKeyStore.cs b/OpenSSH_GUI.Core/Services/KnownHostKeyStore.cs new file mode 100644 index 0000000..7311bb1 --- /dev/null +++ b/OpenSSH_GUI.Core/Services/KnownHostKeyStore.cs @@ -0,0 +1,168 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Enums; +using OpenSSH_GUI.Core.Extensions; +using OpenSSH_GUI.Core.Interfaces; +using OpenSSH_GUI.Core.Lib.HostKeys; +using OpenSSH_GUI.SshConfig.Parsers; + +namespace OpenSSH_GUI.Core.Services; + +/// +/// backed by the user's known_hosts file. +/// Supports plain and hashed (|1|salt|hash) host names, [host]:port entries, +/// wildcard and negated patterns as well as the @revoked marker. +/// @cert-authority entries are ignored. +/// +public sealed class KnownHostKeyStore : IKnownHostKeyStore +{ + private const string HashedHostPrefix = "|1|"; + private const string RevokedMarker = "@revoked"; + + private readonly Lock _fileLock = new(); + private readonly string _knownHostsPath; + private readonly ILogger _logger; + + public KnownHostKeyStore(ILogger logger) + : this(logger, SshConfigFiles.Known_Hosts.GetPathOfFile()) { } + + public KnownHostKeyStore(ILogger logger, string knownHostsPath) + { + _logger = logger; + _knownHostsPath = knownHostsPath; + } + + /// + public HostKeyVerificationStatus Verify(HostKeyInfo hostKey) + { + string[] lines; + lock (_fileLock) + { + if (!File.Exists(_knownHostsPath)) return HostKeyVerificationStatus.Unknown; + lines = File.ReadAllLines(_knownHostsPath); + } + + var hostName = hostKey.KnownHostsName; + var trusted = false; + var mismatch = false; + + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + if (line.Length == 0 || line[0] == '#') continue; + + var fields = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + string? marker = null; + if (fields[0][0] == '@') + { + marker = fields[0]; + fields = fields[1..]; + } + + if (fields.Length < 3) continue; + if (!HostPatternMatches(hostName, fields[0])) continue; + + byte[] storedKey; + try + { + storedKey = Convert.FromBase64String(fields[2]); + } + catch (FormatException) + { + continue; + } + + var sameKey = CryptographicOperations.FixedTimeEquals(storedKey, hostKey.KeyBlob); + switch (marker) + { + case RevokedMarker when sameKey: + return HostKeyVerificationStatus.Revoked; + case null when sameKey: + trusted = true; + break; + case null when string.Equals(HostKeyInfo.ReadKeyType(storedKey), hostKey.KeyType, StringComparison.Ordinal): + mismatch = true; + break; + } + } + + if (trusted) return HostKeyVerificationStatus.Trusted; + if (mismatch) + { + _logger.LogWarning( + "Host key mismatch for {host}: {keyType} {fingerprint}", hostName, hostKey.KeyType, + hostKey.FingerprintSha256); + return HostKeyVerificationStatus.Mismatch; + } + + return HostKeyVerificationStatus.Unknown; + } + + /// + public void Add(HostKeyInfo hostKey) + { + var entry = $"{hostKey.KnownHostsName} {hostKey.KeyType} {Convert.ToBase64String(hostKey.KeyBlob)}\n"; + lock (_fileLock) + { + var directory = Path.GetDirectoryName(_knownHostsPath); + if (!string.IsNullOrEmpty(directory)) + { + if (OperatingSystem.IsWindows()) + Directory.CreateDirectory(directory); + else + Directory.CreateDirectory( + directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + var options = new FileStreamOptions + { + Access = FileAccess.ReadWrite, + Mode = FileMode.OpenOrCreate, + Share = FileShare.Read + }; + if (!OperatingSystem.IsWindows()) + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + using var stream = new FileStream(_knownHostsPath, options); + if (stream.Length > 0) + { + stream.Seek(-1, SeekOrigin.End); + if (stream.ReadByte() != '\n') entry = "\n" + entry; + } + + stream.Seek(0, SeekOrigin.End); + stream.Write(Encoding.ASCII.GetBytes(entry)); + } + + _logger.LogInformation( + "Added {keyType} host key {fingerprint} for {host} to {path}", hostKey.KeyType, + hostKey.FingerprintSha256, hostKey.KnownHostsName, _knownHostsPath); + } + + private static bool HostPatternMatches(string hostName, string patterns) + { + if (patterns.StartsWith(HashedHostPrefix, StringComparison.Ordinal)) + return HashedHostMatches(hostName, patterns); + + return SshWildcardMatcher.Matches(hostName, patterns.Split(',')); + } + + private static bool HashedHostMatches(string hostName, string hashedEntry) + { + // |1|| + var parts = hashedEntry.Split('|'); + if (parts.Length != 4) return false; + try + { + var salt = Convert.FromBase64String(parts[2]); + var expected = Convert.FromBase64String(parts[3]); + var actual = HMACSHA1.HashData(salt, Encoding.UTF8.GetBytes(hostName)); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + catch (FormatException) + { + return false; + } + } +} diff --git a/OpenSSH_GUI.Core/Services/ServerConnectionService.cs b/OpenSSH_GUI.Core/Services/ServerConnectionService.cs index ad73a81..daef204 100644 --- a/OpenSSH_GUI.Core/Services/ServerConnectionService.cs +++ b/OpenSSH_GUI.Core/Services/ServerConnectionService.cs @@ -2,6 +2,8 @@ using System.Reactive.Disposables.Fluent; using System.Reactive.Linq; using Microsoft.Extensions.Logging; +using OpenSSH_GUI.Core.Interfaces; +using OpenSSH_GUI.Core.Lib.HostKeys; using OpenSSH_GUI.Core.Lib.Misc; using ReactiveUI; using ReactiveUI.SourceGenerators; @@ -12,6 +14,10 @@ public sealed partial class ServerConnectionService : ReactiveObject, IDisposabl { private readonly CompositeDisposable _disposables = new(); + private readonly IHostKeyTrustPrompt _hostKeyTrustPrompt; + + private readonly IKnownHostKeyStore _knownHostKeyStore; + private readonly ILogger _logger; /// @@ -36,9 +42,12 @@ public sealed partial class ServerConnectionService : ReactiveObject, IDisposabl [Reactive(SetModifier = AccessModifier.Private)] private ServerConnection _serverConnection = ServerConnection.Empty; - public ServerConnectionService(ILogger logger) + public ServerConnectionService(ILogger logger, IKnownHostKeyStore knownHostKeyStore, + IHostKeyTrustPrompt hostKeyTrustPrompt) { _logger = logger; + _knownHostKeyStore = knownHostKeyStore; + _hostKeyTrustPrompt = hostKeyTrustPrompt; _isConnectedHelper = this.WhenAnyValue(vm => vm.ServerConnection) .Select(e => e.WhenAnyValue(sc => sc.IsConnected)) @@ -73,8 +82,21 @@ public async ValueTask EstablishConnection(ConnectionCredentials connectio { try { - ServerConnection = ServerConnection.WithCredentials(connectionCredentials); - return await ServerConnection.ConnectToServerAsync(token); + var (connected, rejectedKey) = await TryConnectAsync(connectionCredentials, token); + if (rejectedKey is null) return connected; + + if (rejectedKey.Status is not HostKeyVerificationStatus.Unknown) + throw new HostKeyVerificationException(rejectedKey.HostKey, rejectedKey.Status); + + // Trust on first use: the user has to confirm the fingerprint before the key is stored. + if (!await _hostKeyTrustPrompt.ConfirmUnknownHostKeyAsync(rejectedKey.HostKey)) + throw new HostKeyVerificationException(rejectedKey.HostKey, rejectedKey.Status); + + _knownHostKeyStore.Add(rejectedKey.HostKey); + (connected, rejectedKey) = await TryConnectAsync(connectionCredentials, token); + return rejectedKey is null + ? connected + : throw new HostKeyVerificationException(rejectedKey.HostKey, rejectedKey.Status); } catch (Exception e) { @@ -83,6 +105,50 @@ public async ValueTask EstablishConnection(ConnectionCredentials connectio } } + /// + /// Connects with host key verification against known_hosts. + /// + /// + /// The connection result, or the rejected host key if the connection was aborted + /// because the host key could not be verified. + /// + private async ValueTask<(bool Connected, RejectedHostKey? RejectedKey)> TryConnectAsync( + ConnectionCredentials connectionCredentials, CancellationToken token) + { + RejectedHostKey? rejectedKey = null; + DisposeCurrentConnection(); + ServerConnection = ServerConnection.WithCredentials( + connectionCredentials, hostKey => + { + var status = _knownHostKeyStore.Verify(hostKey); + if (status is HostKeyVerificationStatus.Trusted) return true; + rejectedKey ??= new RejectedHostKey(hostKey, status); + return false; + }); + + try + { + return (await ServerConnection.ConnectToServerAsync(token), null); + } + catch (Exception e) when (rejectedKey is not null) + { + _logger.LogWarning( + e, "Host key {fingerprint} of {host} rejected: {status}", rejectedKey.HostKey.FingerprintSha256, + rejectedKey.HostKey.KnownHostsName, rejectedKey.Status); + DisposeCurrentConnection(); + ServerConnection = ServerConnection.Empty; + return (false, rejectedKey); + } + } + + private void DisposeCurrentConnection() + { + if (!ReferenceEquals(ServerConnection, ServerConnection.Empty)) + ServerConnection.Dispose(); + } + + private sealed record RejectedHostKey(HostKeyInfo HostKey, HostKeyVerificationStatus Status); + /// /// Closes the current connection to the server if a connection exists. /// diff --git a/OpenSSH_GUI.Core/Services/SshKeyManager.cs b/OpenSSH_GUI.Core/Services/SshKeyManager.cs index abbcfd2..2b86522 100644 --- a/OpenSSH_GUI.Core/Services/SshKeyManager.cs +++ b/OpenSSH_GUI.Core/Services/SshKeyManager.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Diagnostics; using System.Text; using JetBrains.Annotations; using Microsoft.Extensions.Logging; @@ -11,6 +10,8 @@ using ReactiveUI.SourceGenerators; using Renci.SshNet; using SshNet.Keygen; +using SshNet.Keygen.Extensions; +using SshNet.Keygen.SshKeyEncryption; namespace OpenSSH_GUI.Core.Services; @@ -73,8 +74,8 @@ public async ValueTask InitialSearchAsync(CancellationToken token = default) /// /// Changes the password of an SSH key file, handling both OpenSSH and PuTTY formats transparently. - /// If the key is in PuTTY format, it will be temporarily converted to OpenSSH, the password changed, - /// and then converted back to the original format. + /// The already decrypted key is re-encrypted in-process and written back in its original format. + /// An empty removes the encryption. /// /// The SSH key file whose password should be changed. /// The new password to set, encoded using . @@ -87,7 +88,7 @@ public async ValueTask InitialSearchAsync(CancellationToken token = default) /// Thrown if the resolved key file path is null or whitespace. /// Thrown if the internal semaphore could not be acquired within 5 seconds. /// - /// Thrown if ssh-keygen exits with a non-zero code, or if intermediate key file operations fail. + /// Thrown if writing or reloading the re-encrypted key file fails. /// On failure, all modified files are restored from backup. /// public async ValueTask ChangePasswordOfKeyAsync(SshKeyFile key, @@ -99,7 +100,6 @@ public async ValueTask ChangePasswordOfKeyAsync(SshKe var semaphoreAcquired = false; var errorsOccured = false; BackedUpFile[] backupFiles = []; - string[] additionalDeleteFiles = []; var keyFilePath = string.Empty; try { @@ -117,60 +117,17 @@ public async ValueTask ChangePasswordOfKeyAsync(SshKe semaphoreAcquired = true; backupFiles = _backupService.BackupFiles(key.KeyFiles).ToArray(); - if (key.Format is { } and not SshKeyFormat.OpenSSH) - { - Log(LogLevel.Debug, "Detected PuTTY key {key} - need to change format first", keyFilePath); - additionalDeleteFiles = (await _keyFileWriterService.WriteToFileInSpecificFormat( - SshKeyFormat.OpenSSH, - key.Password.ToSshKeyEncryption(), privateKeyFile, keyFilePath, true)).ToArray(); - - keyFilePath = additionalDeleteFiles.First(e => string.IsNullOrWhiteSpace(Path.GetExtension(e))); - Log(LogLevel.Debug, "New file path: {newFilePath}", keyFilePath); - } + // Re-encrypt in-process: the decrypted key is already loaded, so no passphrase has to be + // handed to an external process (command lines are readable by other local users). + var format = key.Format ?? SshKeyFormat.OpenSSH; + var newEncryption = CreateEncryption(newPassword.Span, encoding, format); + var privateKeyContent = format is SshKeyFormat.OpenSSH + ? privateKeyFile.ToOpenSshFormat(newEncryption) + : privateKeyFile.ToPuttyFormat(newEncryption, format); - using var process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = "ssh-keygen", - Arguments = - $"-p -f {keyFilePath} -P \"{key.Password.GetPasswordString()}\" -N \"{encoding.GetString(newPassword.Span)}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - if (process.Start()) - { - await process.WaitForExitAsync(token); - if (process.ExitCode != 0) - { - var message = await process.StandardError.ReadToEndAsync(token); - Log( - LogLevel.Error, "ssh-keygen exited with code {exitCode} and message: {message}", - process.ExitCode, message); - throw new Exception($"ssh-keygen exited with code {process.ExitCode}"); - } - - var output = await process.StandardOutput.ReadToEndAsync(token); - Log(LogLevel.Debug, "ssh-keygen exited without errors and output: {message}", output); - } - - if (key.Format is { } format and not SshKeyFormat.OpenSSH) - { - var keyFile = _keyFactory.Create(); - keyFile.Load(SshKeyFileSource.FromDisk(keyFilePath), newPassword.Span); - Log( - LogLevel.Debug, - "Changes to the password were made in OpenSSH Format - need to change format to Putty again"); - keyFilePath = (await _keyFileWriterService.WriteToFileInSpecificFormat( - format, keyFile.Password.ToSshKeyEncryption(), - keyFile.PrivateKeyFile ?? throw new Exception("Private key file not found"), keyFilePath, - true)).First(); - - Log(LogLevel.Debug, "New file path: {newFilePath}", keyFilePath); - foreach (var deleteFile in additionalDeleteFiles) File.Delete(deleteFile); - } + // The public key does not change with the passphrase, only the private key file is rewritten. + await _keyFileWriterService.WriteToFile(keyFilePath, privateKeyContent, true); + Log(LogLevel.Debug, "Re-encrypted private key {key}", keyFilePath); key.Load(SshKeyFileSource.FromDisk(keyFilePath), newPassword.Span); Log(LogLevel.Debug, "Successfully changed password of key {key}", keyFilePath); @@ -193,6 +150,16 @@ public async ValueTask ChangePasswordOfKeyAsync(SshKe } } + private static ISshKeyEncryption CreateEncryption(ReadOnlySpan password, Encoding encoding, + SshKeyFormat format) + { + return password.IsEmpty + ? new SshKeyEncryptionNone() + : new SshKeyEncryptionAes256( + encoding.GetString(password), + format is SshKeyFormat.PuTTYv3 ? new PuttyV3Encryption() : null); + } + /// /// Attempts to delete all files associated with the given SSH key. /// Unlike , this method does not throw on failure — diff --git a/OpenSSH_GUI.Tests/Core/Services/KeyFileWriterServiceTests.cs b/OpenSSH_GUI.Tests/Core/Services/KeyFileWriterServiceTests.cs new file mode 100644 index 0000000..4988f1e --- /dev/null +++ b/OpenSSH_GUI.Tests/Core/Services/KeyFileWriterServiceTests.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Logging.Abstractions; +using OpenSSH_GUI.Core.Services; +using Shouldly; +using Xunit; + +namespace OpenSSH_GUI.Tests.Core.Services; + +public sealed class KeyFileWriterServiceTests : IDisposable +{ + private readonly string _directory = Directory.CreateTempSubdirectory("keyfilewriter").FullName; + private readonly KeyFileWriterService _service = new(NullLogger.Instance); + + public void Dispose() { Directory.Delete(_directory, true); } + + [Fact] + public async Task WriteToFile_Overwrite_TruncatesLongerExistingContent() + { + var path = Path.Combine(_directory, "id_test"); + await File.WriteAllTextAsync(path, new string('x', 4096), TestContext.Current.CancellationToken); + + await _service.WriteToFile(path, "short", true); + + (await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken)).ShouldBe("short"); + } + + [Fact] + public async Task WriteToFile_WithoutOverwrite_ThrowsAndKeepsExistingContent() + { + var path = Path.Combine(_directory, "id_test"); + await File.WriteAllTextAsync(path, "original", TestContext.Current.CancellationToken); + + await Should.ThrowAsync(async () => await _service.WriteToFile(path, "new")); + + (await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken)).ShouldBe("original"); + } + + [Fact] + public async Task WriteToFile_Overwrite_RestrictsExistingFileToOwner() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix file modes only"); + var path = Path.Combine(_directory, "id_test"); + await File.WriteAllTextAsync(path, "public", TestContext.Current.CancellationToken); +#pragma warning disable CA1416 + File.SetUnixFileMode( + path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead); + + await _service.WriteToFile(path, "private", true); + + File.GetUnixFileMode(path).ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite); +#pragma warning restore CA1416 + } + + [Fact] + public async Task WriteToFile_NewFile_IsCreatedOwnerOnly() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix file modes only"); + var path = Path.Combine(_directory, "id_new"); + + await _service.WriteToFile(path, "private"); + +#pragma warning disable CA1416 + File.GetUnixFileMode(path).ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite); +#pragma warning restore CA1416 + } +} diff --git a/OpenSSH_GUI.Tests/Core/Services/KnownHostKeyStoreTests.cs b/OpenSSH_GUI.Tests/Core/Services/KnownHostKeyStoreTests.cs new file mode 100644 index 0000000..6f104b3 --- /dev/null +++ b/OpenSSH_GUI.Tests/Core/Services/KnownHostKeyStoreTests.cs @@ -0,0 +1,154 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using OpenSSH_GUI.Core.Lib.HostKeys; +using OpenSSH_GUI.Core.Services; +using Shouldly; +using Xunit; + +namespace OpenSSH_GUI.Tests.Core.Services; + +public sealed class KnownHostKeyStoreTests : IDisposable +{ + private readonly string _directory = Directory.CreateTempSubdirectory("knownhosts").FullName; + private readonly string _path; + private readonly KnownHostKeyStore _store; + + public KnownHostKeyStoreTests() + { + _path = Path.Combine(_directory, "known_hosts"); + _store = new KnownHostKeyStore(NullLogger.Instance, _path); + } + + public void Dispose() { Directory.Delete(_directory, true); } + + private static byte[] CreateKeyBlob(string keyType, byte fill) + { + var type = Encoding.ASCII.GetBytes(keyType); + var blob = new byte[4 + type.Length + 32]; + blob[3] = (byte)type.Length; + type.CopyTo(blob, 4); + blob.AsSpan(4 + type.Length).Fill(fill); + return blob; + } + + private static readonly byte[] Ed25519Key = CreateKeyBlob("ssh-ed25519", 1); + private static readonly byte[] OtherEd25519Key = CreateKeyBlob("ssh-ed25519", 2); + private static readonly byte[] EcdsaKey = CreateKeyBlob("ecdsa-sha2-nistp256", 3); + + private void WriteKnownHosts(params string[] lines) => File.WriteAllLines(_path, lines); + + private static string Entry(string hosts, byte[] key, string? marker = null) => + $"{(marker is null ? string.Empty : marker + " ")}{hosts} {HostKeyInfo.ReadKeyType(key)} {Convert.ToBase64String(key)}"; + + [Fact] + public void Verify_MissingFile_ReturnsUnknown() + { + _store.Verify(new HostKeyInfo("example.com", 22, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Unknown); + } + + [Theory] + [InlineData("example.com", 22, "example.com")] + [InlineData("EXAMPLE.com", 22, "other.org,example.com")] + [InlineData("example.com", 2222, "[example.com]:2222")] + [InlineData("host1.example.com", 22, "*.example.com")] + public void Verify_MatchingEntry_ReturnsTrusted(string host, int port, string pattern) + { + WriteKnownHosts("# comment", string.Empty, Entry(pattern, Ed25519Key)); + + _store.Verify(new HostKeyInfo(host, port, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Trusted); + } + + [Fact] + public void Verify_HashedEntry_ReturnsTrusted() + { + var salt = RandomNumberGenerator.GetBytes(20); + var hash = HMACSHA1.HashData(salt, Encoding.UTF8.GetBytes("example.com")); + WriteKnownHosts(Entry($"|1|{Convert.ToBase64String(salt)}|{Convert.ToBase64String(hash)}", Ed25519Key)); + + _store.Verify(new HostKeyInfo("example.com", 22, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Trusted); + _store.Verify(new HostKeyInfo("example.org", 22, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Unknown); + } + + [Fact] + public void Verify_DefaultPortEntry_DoesNotMatchOtherPort() + { + WriteKnownHosts(Entry("example.com", Ed25519Key)); + + _store.Verify(new HostKeyInfo("example.com", 2222, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Unknown); + } + + [Fact] + public void Verify_NegatedPattern_ReturnsUnknown() + { + WriteKnownHosts(Entry("*.example.com,!evil.example.com", Ed25519Key)); + + _store.Verify(new HostKeyInfo("evil.example.com", 22, Ed25519Key)) + .ShouldBe(HostKeyVerificationStatus.Unknown); + } + + [Fact] + public void Verify_SameTypeDifferentKey_ReturnsMismatch() + { + WriteKnownHosts(Entry("example.com", Ed25519Key)); + + _store.Verify(new HostKeyInfo("example.com", 22, OtherEd25519Key)) + .ShouldBe(HostKeyVerificationStatus.Mismatch); + } + + [Fact] + public void Verify_OnlyOtherKeyTypeKnown_ReturnsUnknown() + { + WriteKnownHosts(Entry("example.com", EcdsaKey)); + + _store.Verify(new HostKeyInfo("example.com", 22, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Unknown); + } + + [Fact] + public void Verify_RevokedKey_ReturnsRevokedEvenIfTrusted() + { + WriteKnownHosts(Entry("example.com", Ed25519Key), Entry("*", Ed25519Key, "@revoked")); + + _store.Verify(new HostKeyInfo("example.com", 22, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Revoked); + } + + [Fact] + public void Verify_CertAuthorityEntry_IsIgnored() + { + WriteKnownHosts(Entry("example.com", Ed25519Key, "@cert-authority")); + + _store.Verify(new HostKeyInfo("example.com", 22, Ed25519Key)).ShouldBe(HostKeyVerificationStatus.Unknown); + } + + [Fact] + public void Add_AppendsEntryThatVerifiesAsTrusted() + { + File.WriteAllText(_path, Entry("other.org", EcdsaKey)); // no trailing newline + var hostKey = new HostKeyInfo("Example.com", 2222, Ed25519Key); + + _store.Add(hostKey); + + _store.Verify(hostKey).ShouldBe(HostKeyVerificationStatus.Trusted); + File.ReadAllLines(_path).ShouldBe([Entry("other.org", EcdsaKey), Entry("[example.com]:2222", Ed25519Key)]); + } + + [Fact] + public void Add_NewFile_IsCreatedOwnerOnly() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix file modes only"); + + _store.Add(new HostKeyInfo("example.com", 22, Ed25519Key)); + +#pragma warning disable CA1416 + File.GetUnixFileMode(_path).ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite); +#pragma warning restore CA1416 + } + + [Fact] + public void HostKeyInfo_FingerprintMatchesOpenSshNotation() + { + var expected = "SHA256:" + Convert.ToBase64String(SHA256.HashData(Ed25519Key)).TrimEnd('='); + + new HostKeyInfo("example.com", 22, Ed25519Key).FingerprintSha256.ShouldBe(expected); + } +} diff --git a/OpenSSH_GUI.Tests/Core/Services/SshKeyManagerPasswordTests.cs b/OpenSSH_GUI.Tests/Core/Services/SshKeyManagerPasswordTests.cs new file mode 100644 index 0000000..ddc8d95 --- /dev/null +++ b/OpenSSH_GUI.Tests/Core/Services/SshKeyManagerPasswordTests.cs @@ -0,0 +1,84 @@ +using System.Text; +using Avalonia.Headless.XUnit; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using OpenSSH_GUI.Core.Interfaces; +using OpenSSH_GUI.Core.Lib.Keys; +using OpenSSH_GUI.Core.Lib.Misc; +using OpenSSH_GUI.Core.Services; +using Renci.SshNet; +using Renci.SshNet.Common; +using Shouldly; +using SshNet.Keygen; +using SshNet.Keygen.Extensions; +using SshNet.Keygen.SshKeyEncryption; +using Xunit; + +namespace OpenSSH_GUI.Tests.Core.Services; + +public sealed class SshKeyManagerPasswordTests : IDisposable +{ + private readonly string _directory = Directory.CreateTempSubdirectory("keymanager").FullName; + + public void Dispose() { Directory.Delete(_directory, true); } + + private static SshKeyManager CreateManager() + { + var backupService = Substitute.For(); + backupService.BackupFiles(Arg.Any()).Returns([]); + return new SshKeyManager( + NullLogger.Instance, + Substitute.For(), + Substitute.For(), + Substitute.For(), + new KeyFileWriterService(NullLogger.Instance), + backupService); + } + + private SshKeyFile LoadKey(string? passphrase, out string path) + { + path = Path.Combine(_directory, "id_test"); + var info = new SshKeyGenerateInfo(SshKeyType.ED25519) { Comment = "test" }; + if (passphrase is not null) info.Encryption = new SshKeyEncryptionAes256(passphrase); + var generated = SshKey.Generate(path, FileMode.Create, info); + File.WriteAllText(path + ".pub", generated.ToOpenSshPublicFormat()); + + var key = new SshKeyFile(Substitute.For>()); + if (passphrase is null) + key.Load(SshKeyFileSource.FromDisk(path)); + else + key.Load(SshKeyFileSource.FromDisk(path), Encoding.UTF8.GetBytes(passphrase)); + return key; + } + + [AvaloniaTheory] + [InlineData("new-passphrase")] + [InlineData("with \"quotes\" -N and spaces")] + public async Task ChangePasswordOfKeyAsync_EncryptsKeyWithNewPassphrase(string newPassphrase) + { + using var manager = CreateManager(); + var key = LoadKey(null, out var path); + key.PrivateKeyFile.ShouldNotBeNull(); + + var result = await manager.ChangePasswordOfKeyAsync(key, Encoding.UTF8.GetBytes(newPassphrase)); + + result.IsSuccess.ShouldBeTrue(); + Should.Throw(() => new PrivateKeyFile(path)); + Should.NotThrow(() => new PrivateKeyFile(path, newPassphrase)); + } + + [AvaloniaFact] + public async Task ChangePasswordOfKeyAsync_EmptyPassword_RemovesEncryption() + { + using var manager = CreateManager(); + var key = LoadKey("old-passphrase", out var path); + key.PrivateKeyFile.ShouldNotBeNull(); + + var result = await manager.ChangePasswordOfKeyAsync(key, ReadOnlyMemory.Empty); + + result.IsSuccess.ShouldBeTrue(); + + Should.NotThrow(() => new PrivateKeyFile(path)); + } +} diff --git a/OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs b/OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs index caebb19..b5723ef 100644 --- a/OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs +++ b/OpenSSH_GUI/Extensions/DependencyInjectionExtensions.cs @@ -15,6 +15,7 @@ using OpenSSH_GUI.Core.Services.Hosted; using OpenSSH_GUI.Dialogs.Interfaces; using OpenSSH_GUI.Dialogs.Services; +using OpenSSH_GUI.Services; using OpenSSH_GUI.ViewModels; using OpenSSH_GUI.Views; using Serilog.Core; @@ -34,6 +35,8 @@ internal IHostBuilder RegisterOpenSshGuiServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/OpenSSH_GUI/Resources/StringsAndTexts.Designer.cs b/OpenSSH_GUI/Resources/StringsAndTexts.Designer.cs index 42a90b8..f6e4648 100644 --- a/OpenSSH_GUI/Resources/StringsAndTexts.Designer.cs +++ b/OpenSSH_GUI/Resources/StringsAndTexts.Designer.cs @@ -806,5 +806,17 @@ public static string ApplicationSettingsLookupPaths { return ResourceManager.GetString("ApplicationSettingsLookupPaths", resourceCulture); } } + + public static string HostKeyUnknownTitle { + get { + return ResourceManager.GetString("HostKeyUnknownTitle", resourceCulture); + } + } + + public static string HostKeyUnknownText { + get { + return ResourceManager.GetString("HostKeyUnknownText", resourceCulture); + } + } } } diff --git a/OpenSSH_GUI/Resources/StringsAndTexts.de.resx b/OpenSSH_GUI/Resources/StringsAndTexts.de.resx index 239021b..6f2b024 100644 --- a/OpenSSH_GUI/Resources/StringsAndTexts.de.resx +++ b/OpenSSH_GUI/Resources/StringsAndTexts.de.resx @@ -393,4 +393,15 @@ Suchpfade + + Unbekannter Host-Schlüssel + + + Die Echtheit des Hosts '{0}' kann nicht bestätigt werden. + +{1}-Fingerprint: +{2} + +Fahren Sie nur fort, wenn Sie diesen Fingerprint überprüft haben. Diesem Host vertrauen und den Schlüssel in known_hosts aufnehmen? + \ No newline at end of file diff --git a/OpenSSH_GUI/Resources/StringsAndTexts.resx b/OpenSSH_GUI/Resources/StringsAndTexts.resx index 75f14d8..88384f0 100644 --- a/OpenSSH_GUI/Resources/StringsAndTexts.resx +++ b/OpenSSH_GUI/Resources/StringsAndTexts.resx @@ -406,4 +406,15 @@ Lookup Paths + + Unknown host key + + + The authenticity of host '{0}' can't be established. + +{1} key fingerprint: +{2} + +Only continue if you have verified this fingerprint. Trust this host and add the key to known_hosts? + \ No newline at end of file diff --git a/OpenSSH_GUI/Services/HostKeyTrustPrompt.cs b/OpenSSH_GUI/Services/HostKeyTrustPrompt.cs new file mode 100644 index 0000000..75ba31f --- /dev/null +++ b/OpenSSH_GUI/Services/HostKeyTrustPrompt.cs @@ -0,0 +1,28 @@ +using Avalonia.Threading; +using Material.Icons; +using OpenSSH_GUI.Core.Interfaces; +using OpenSSH_GUI.Core.Lib.HostKeys; +using OpenSSH_GUI.Dialogs.Enums; +using OpenSSH_GUI.Dialogs.Interfaces; +using OpenSSH_GUI.Resources; + +namespace OpenSSH_GUI.Services; + +/// +/// Shows the fingerprint of an unknown server host key and lets the user decide whether to trust it. +/// +public sealed class HostKeyTrustPrompt(IMessageBoxProvider messageBoxProvider) : IHostKeyTrustPrompt +{ + /// + public async Task ConfirmUnknownHostKeyAsync(HostKeyInfo hostKey) + { + var result = await Dispatcher.UIThread.InvokeAsync(() => messageBoxProvider.ShowMessageBoxAsync( + StringsAndTexts.HostKeyUnknownTitle, + string.Format( + StringsAndTexts.HostKeyUnknownText, hostKey.KnownHostsName, hostKey.KeyType, + hostKey.FingerprintSha256), + MessageBoxButtons.YesNo, + MaterialIconKind.ShieldAlertOutline)); + return result is MessageBoxResult.Yes; + } +}