diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b594d6e..dfd53610 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,53 @@ jobs: - name: Run unit tests shell: pwsh run: go test ./... + - name: Run Pester tests + shell: pwsh + run: Invoke-Pester -Path scripts/windows/test -Output Detailed + + # Check that binary can be built natively on Windows ARM64 + build-windows-arm64: + name: Build Windows ARM64 + runs-on: windows-11-arm + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: Install Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: 'go.mod' + - name: Install dependencies + run: go mod download + - name: Build Windows ARM64 + shell: pwsh + run: go build -v -o opkssh-arm64.exe + - name: Test binary works + shell: pwsh + run: | + .\opkssh-arm64.exe --version + + # Run Windows ARM64 unit tests + test-windows-arm64: + name: 'Windows ARM64 Tests' + runs-on: windows-11-arm + timeout-minutes: 8 + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: Install Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: 'go.mod' + - name: Install dependencies + run: go mod download + - name: Run unit tests + shell: pwsh + run: go test ./... # Run integration tests test: diff --git a/.github/workflows/gha-windows.yml b/.github/workflows/gha-windows.yml new file mode 100644 index 00000000..76a33cb3 --- /dev/null +++ b/.github/workflows/gha-windows.yml @@ -0,0 +1,159 @@ +name: Test GitHub Provider Windows + +on: + push: + +jobs: + build: + name: Test on ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + permissions: + id-token: write + contents: read + timeout-minutes: ${{ matrix.timeout }} + + strategy: + fail-fast: false + matrix: + include: + - name: Windows Server 2022 + runner: windows-2022 + timeout: 10 + cache: true + - name: Windows Server 2025 + runner: windows-2025 + timeout: 10 + cache: true + - name: Windows 11 ARM64 + runner: windows-11-arm + timeout: 15 + cache: false + + steps: + - name: Install OpenSSH Server + run: | + Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 + + - name: Create default OpenSSH config files + run: | + Start-Service sshd + Start-Sleep -Seconds 2 + Stop-Service sshd + + - name: Enable OpenSSH Server logs + run: | + $sshdConfig = "$env:ProgramData\ssh\sshd_config" + (Get-Content $sshdConfig -Raw).Replace('#SyslogFacility AUTH', 'SyslogFacility LOCAL0').Replace('#LogLevel INFO', 'LogLevel Debug3') | + Set-Content $sshdConfig + + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Cache Go modules + if: matrix.cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Install Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: 'go.mod' + + - name: Install dependencies + run: go mod download + + - name: Build opkssh + run: go build -v -o opkssh.exe + + - name: Install opkssh with local binary + run: | + powershell -ExecutionPolicy Bypass -File "scripts/windows/Install-OpksshServer.ps1" ` + -InstallFrom "$PWD\opkssh.exe" ` + -NoSshdRestart ` + -Verbose + + - name: Verify system PATH after install + run: | + $installDir = 'C:\Program Files\opkssh' + $regPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $systemPath = (Get-ItemProperty -Path $regPath -Name Path).Path + $folders = $systemPath -split [IO.Path]::PathSeparator + $found = $folders | Where-Object { $_.TrimEnd([IO.Path]::DirectorySeparatorChar) -eq $installDir } + if (-not $found) { + throw "FAIL: '$installDir' was NOT found in the system PATH after install" + } + Write-Host "PASS: '$installDir' is in the system PATH after install" + + - name: Add GitHub provider to opkssh configuration + run: | + $providersPath = 'C:\ProgramData\opk\providers' + if ((Get-Content -Path $providersPath -Raw) -notmatch "`r?`n$") { + Add-Content -Path $providersPath -Value '' + } + Add-Content -Path $providersPath -Value 'https://token.actions.githubusercontent.com github oidc' + + - name: Add current repository to policy + run: | + & 'C:\Program Files\opkssh\opkssh.exe' add runneradmin "repo:${env:GITHUB_REPOSITORY}:ref:${env:GITHUB_REF}" https://token.actions.githubusercontent.com + + - name: Start SSH service + run: Start-Service sshd + + - name: Test SSH connection without opkssh (should fail) + run: | + ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir 2>&1 + if ($LASTEXITCODE -eq 0) { throw "SSH should have failed but succeeded" } + Write-Host "SSH correctly rejected without opkssh (exit code: $LASTEXITCODE)" + exit 0 + + - name: Login with GitHub OIDC + run: | + & 'C:\Program Files\opkssh\opkssh.exe' login github --print-id-token + + - name: Test SSH connection with opkssh (should pass) + run: | + ssh -o StrictHostKeyChecking=no -o PasswordAuthentication=no runneradmin@localhost dir + + - name: Debug - Dump opkssh config + run: | + Get-ChildItem 'C:\ProgramData\opk' -Recurse -ErrorAction Continue + Get-Content 'C:\ProgramData\opk\providers' -ErrorAction Continue + Get-Content 'C:\ProgramData\opk\auth_id' -ErrorAction Continue + if: always() + + - name: Debug - Dump opkssh logs + run: | + Get-Content 'C:\ProgramData\opk\logs\opkssh.log' -ErrorAction Continue + if: always() + + - name: Debug - Dump sshd logs + run: | + Get-Content 'C:\ProgramData\ssh\logs\sshd.log' -ErrorAction Continue + if: always() + + - name: Uninstall opkssh + run: | + powershell -ExecutionPolicy Bypass -File "scripts/windows/Uninstall-OpksshServer.ps1" ` + -Force ` + -NoSshdRestart ` + -Verbose + + - name: Verify system PATH after uninstall + run: | + $installDir = 'C:\Program Files\opkssh' + $regPath = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $systemPath = (Get-ItemProperty -Path $regPath -Name Path).Path + $folders = $systemPath -split [IO.Path]::PathSeparator + $found = $folders | Where-Object { $_.TrimEnd([IO.Path]::DirectorySeparatorChar) -eq $installDir } + if ($found) { + throw "FAIL: '$installDir' is still in the system PATH after uninstall" + } + Write-Host "PASS: '$installDir' was correctly removed from the system PATH after uninstall" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 31a354e1..12d055b7 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,10 +20,6 @@ builds: goarch: - amd64 - arm64 - # Ignore some builds until they are not tested - ignore: - - goos: windows - goarch: arm64 # Make binaries available with the same naming format as before archives: @@ -77,3 +73,7 @@ changelog: release: draft: true make_latest: true + extra_files: + - glob: scripts/windows/Install-OpksshServer.ps1 + - glob: scripts/windows/Uninstall-OpksshServer.ps1 + - glob: scripts/windows/Test-OpksshInstallation.ps1 diff --git a/README.md b/README.md index 9f9d4741..56e60c93 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ [![Go Coverage](https://github.com/openpubkey/opkssh/wiki/coverage.svg)](https://raw.githack.com/wiki/openpubkey/opkssh/coverage.html) **opkssh** is a tool which enables ssh to be used with OpenID Connect allowing SSH access to be managed via identities like `alice@example.com` instead of long-lived SSH keys. -It does not replace SSH, but instead generates SSH public keys containing PK Tokens and configures sshd to verify them. These PK Tokens contain standard [OpenID Connect ID Tokens](https://openid.net/specs/openid-connect-core-1_0.html). This protocol builds on the [OpenPubkey](https://github.com/openpubkey/openpubkey/blob/main/README.md) which adds user public keys to OpenID Connect without breaking compatibility with existing OpenID Provider. +It does not replace SSH, but instead generates SSH public keys containing PK Tokens and configures sshd to verify them. These PK Tokens contain standard [OpenID Connect ID Tokens](https://openid.net/specs/openid-connect-core-1_0.html). This protocol builds on [OpenPubkey](https://github.com/openpubkey/openpubkey/blob/main/README.md) which adds user public keys to OpenID Connect without breaking compatibility with existing OpenID Providers. -Currently opkssh is compatible with Google, Microsoft/Azure, Gitlab, hello.dev, and Authelia OpenID Providers (OP). See below for the entire list. If you have a gmail, microsoft or a gitlab account you can ssh with that account. +Currently opkssh is compatible with Google, Microsoft/Azure, GitLab, hello.dev, Authelia, Authentik, Keycloak, Zitadel, PocketID, AWS Cognito, and Kanidm OpenID Providers (OP). See below for the entire list. If you have a Gmail, Microsoft, or a GitLab account you can ssh with that account. To ssh with opkssh you first need to download the opkssh binary and then run: @@ -70,7 +70,8 @@ To install manually, download the opkssh binary and run it: |🐧 Linux (ARM64/aarch64) | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-linux-arm64](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-linux-arm64) | |🍎 macOS (x86_64) | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-osx-amd64](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-osx-amd64) | |🍎 macOS (ARM64/aarch64) | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-osx-arm64](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-osx-arm64) | -| ⊞ Win | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-amd64.exe](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-amd64.exe) | +| ⊞ Win (x86_64) | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-amd64.exe](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-amd64.exe) | +| ⊞ Win (ARM64) | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-arm64.exe](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-arm64.exe) | To install on Windows run: @@ -120,6 +121,20 @@ This works because SSH sends the public key written by opkssh in `~/.ssh/id_ecds sftp root@example.com ``` +### Logging out + +To remove opkssh-generated SSH keys and certificates: + +```cmd +opkssh logout +``` + +This removes the SSH key pair that was generated by `opkssh login`. To remove a specific key: + +```cmd +opkssh logout -i ~/.ssh/opkssh_server_group1 +``` + ### Custom key name
@@ -143,9 +158,9 @@ We recommend specifying `-o "IdentitiesOnly=yes"` as it tells ssh to only use th
-### Installing on a Server +### Installing on a Linux Server -To configure a linux server to use opkssh simply run (with root level privileges): +To configure a Linux server to use opkssh simply run (with root level privileges): ```bash wget -qO- "https://raw.githubusercontent.com/openpubkey/opkssh/main/scripts/install-linux.sh" | sudo bash @@ -153,6 +168,33 @@ wget -qO- "https://raw.githubusercontent.com/openpubkey/opkssh/main/scripts/inst This downloads the opkssh binary, installs it as `/usr/local/bin/opkssh`, and then configures ssh to use opkssh as an additional authentication mechanism. +### Installing on a Windows Server + +To configure a Windows server to use opkssh, download and run the installer script in an elevated PowerShell terminal: + +```powershell +Invoke-WebRequest -Uri "https://github.com/openpubkey/opkssh/releases/latest/download/Install-OpksshServer.ps1" -OutFile Install-OpksshServer.ps1 +.\Install-OpksshServer.ps1 +``` + +This downloads the opkssh binary, configures `sshd_config` for `AuthorizedKeysCommand`, sets up the correct NTFS ACLs on all configuration files, and restarts sshd. + +To uninstall: + +```powershell +Invoke-WebRequest -Uri "https://github.com/openpubkey/opkssh/releases/latest/download/Uninstall-OpksshServer.ps1" -OutFile Uninstall-OpksshServer.ps1 +.\Uninstall-OpksshServer.ps1 +``` + +To validate the installation: + +```powershell +Invoke-WebRequest -Uri "https://github.com/openpubkey/opkssh/releases/latest/download/Test-OpksshInstallation.ps1" -OutFile Test-OpksshInstallation.ps1 +.\Test-OpksshInstallation.ps1 +``` + +On Windows, the configuration files are located at `%ProgramData%\opk\` (typically `C:\ProgramData\opk\`). + To allow a user, `alice@gmail.com`, to ssh to your server as `root`, run: ```bash @@ -189,7 +231,7 @@ Second, we use the `AuthorizedKeysCommand` configuration option in `sshd_config` | --------- | -------- | ------- | ----------------------- | | Linux | ✅ | ✅ | Ubuntu 24.04.1 LTS | | macOS | ✅ | ✅ | macOS 15.3.2 (Sequoia) | -| Windows11 | ✅ | ✅ | Windows 11 | +| Windows 11 | ✅ | ✅ | Windows 11 | ### Server support @@ -198,14 +240,17 @@ Second, we use the `AuthorizedKeysCommand` configuration option in `sshd_config` | Linux | ✅ | ✅ | Ubuntu 24.04.1 LTS | - | | Linux | ✅ | ✅ | Centos 9 | - | | Linux | ✅ | ✅ | Arch Linux | - | -| Linux | ✅ | ✅ | opensuse leap 16 | - | +| Linux | ✅ | ✅ | openSUSE Leap 16 | - | | macOS | ❌ | ❌ | - | Likely | -| Windows11 | ❌ | ❌ | - | Likely | +| Windows Server | ✅ | ✅ | Windows Server 2022 | - | +| Windows Server | ✅ | ✅ | Windows Server 2025 | - | +| Windows 11 | ✅ | ✅ | Windows 11 ARM64 | - | ## Server Configuration All opkssh configuration files are space delimited and live on the server. -Below we discuss our basic policy system, to read how to configure complex policies rules see our [documentation on our policy plugin system](docs/policyplugins.md). Using the policy plugin system you can enforce any policy rule that be computed on a [Turing Machine](https://en.wikipedia.org/wiki/Turing_machine). +On Linux, configuration files are stored under `/etc/opk/`. On Windows, they are stored under `%ProgramData%\opk\` (typically `C:\ProgramData\opk\`). +Below we discuss our basic policy system, to read how to configure complex policies rules see our [documentation on our policy plugin system](docs/policyplugins.md). Using the policy plugin system you can enforce any policy rule that can be computed on a [Turing Machine](https://en.wikipedia.org/wiki/Turing_machine). ### `/etc/opk/providers` @@ -465,7 +510,7 @@ Instead of using the `opkssh login --provider` flag you can also configure the p The OPKSSH_PROVIDERS variable follow the standard format with `;` delimiting each provider and `,` delimiting fields with a provider for instance: `{alias},{issuer},{client_id},{client_secret},{scope};{alias},{issuer},{client_id},{client_secret},{scope}...` -You can set them in your [`.bashrc` file](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html) so you don't have to type custom settings each time you run `opk login`. +You can set them in your [`.bashrc` file](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html) so you don't have to type custom settings each time you run `opkssh login`. ```bash export OPKSSH_DEFAULT=WEBCHOOSER @@ -525,6 +570,43 @@ opkssh add root alice@example.com https://authentik.local/application/o/opkssh/ Do not use Confidential/Secret mode **only** client ID is needed. +## Additional Commands + +### Inspect + +To inspect and view details of an opkssh-generated SSH key or certificate: + +```cmd +opkssh inspect ~/.ssh/id_ecdsa.pub +``` + +### Audit + +To validate policy file entries against provider definitions: + +```cmd +opkssh audit +``` + +This checks for inconsistencies between your `auth_id` and `providers` files. See [docs/audit.md](docs/audit.md) for more details. + +### Permissions + +To check and fix filesystem permissions required by opkssh: + +```cmd +opkssh permissions check +opkssh permissions fix +``` + +For non-interactive use (e.g., in scripts), use: + +```cmd +opkssh permissions install +``` + +For full CLI reference, see [docs/cli/](docs/cli/opkssh.md). + ## Developing For a complete developers guide see [CONTRIBUTING.md](CONTRIBUTING.md) @@ -561,14 +643,17 @@ For integration tests run: ## More information ### Documentation +- [docs/cli/](docs/cli/opkssh.md) CLI reference documentation for all opkssh commands. - [docs/config.md](docs/config.md) Documentation of opkssh configuration files. +- [docs/audit.md](docs/audit.md) Documentation of the audit command. - [docs/policyplugins.md](docs/policyplugins.md) Documentation of opkssh policy plugins and how to use them to implement complex policies. - [scripts/installing.md](scripts/installing.md) Documentation of the server install script that opkssh uses to configure an SSH server to accept opkssh SSH certificates. Explains how to manually install opkssh on a server. ### Guides - [CONTRIBUTING.md](https://github.com/openpubkey/opkssh/blob/main/CONTRIBUTING.md) Guide to contributing to opkssh (includes developer help). - [docs/gitlab-selfhosted.md](docs/gitlab-selfhosted.md) Guide on configuring and using a self hosted GitLab instance with opkssh. -- [docs/paramiko.md](docs/paramiko.md) Guide to using the python SSH paramiko library with opkssh. +- [docs/paramiko.md](docs/paramiko.md) Guide to using the Python SSH paramiko library with opkssh. - [docs/putty.md](docs/putty.md) Guide to using PuTTY with opkssh. - [docs/aws-ec2.md](docs/aws-ec2.md) Guide to get opkssh working on AWS EC2. -- [docs/github-actions.md](docs/github-actions.md) Guide to SSHing via GitHub Actions. \ No newline at end of file +- [docs/github-actions.md](docs/github-actions.md) Guide to SSHing via GitHub Actions. +- [docs/opkssh-and-sssd.md](docs/opkssh-and-sssd.md) Guide on using opkssh with SSSD. \ No newline at end of file diff --git a/commands/permissions.go b/commands/permissions.go index e883bbec..88be4da5 100644 --- a/commands/permissions.go +++ b/commands/permissions.go @@ -363,41 +363,27 @@ func (p *PermissionsCmd) Fix() error { // Verify ACLs after changes and apply ACE fixes on Windows if needed if runtime.GOOS == "windows" { - // Pre-resolve commonly used SIDs to avoid repeated lookups and use SID-based trustees - adminSID, _, _ := files.ResolveAccountToSID("Administrators") - systemSID, _, _ := files.ResolveAccountToSID("SYSTEM") - - report, err := p.FileSystem.VerifyACL(systemPolicy, files.ExpectedACLFromPerm(sp)) + expected := files.ExpectedACLFromPerm(sp) + report, err := p.FileSystem.VerifyACL(systemPolicy, expected) if err != nil { errorsFound = append(errorsFound, "acl verify: "+err.Error()) } else { - // Ensure Administrators and SYSTEM have full control; if missing, apply via ApplyACE - needAdmin := true - needSystem := true - for _, a := range report.ACEs { - if a.Principal == "Administrators" && strings.Contains(a.Rights, "GENERIC_ALL") { - needAdmin = false - } - if a.Principal == "SYSTEM" && strings.Contains(a.Rights, "GENERIC_ALL") { - needSystem = false - } - } - if needAdmin { - ace := files.ACE{Principal: "Administrators", Rights: "GENERIC_ALL", Type: "allow"} - if len(adminSID) > 0 { - ace.PrincipalSID = adminSID - } - if err := p.FileSystem.ApplyACE(systemPolicy, ace); err != nil { - errorsFound = append(errorsFound, "apply ACE Administrators:F: "+err.Error()) - } - } - if needSystem { - ace := files.ACE{Principal: "SYSTEM", Rights: "GENERIC_ALL", Type: "allow"} - if len(systemSID) > 0 { - ace.PrincipalSID = systemSID + for _, reqACE := range expected.RequiredACEs { + found := false + for _, a := range report.ACEs { + if a.Principal == reqACE.Principal && strings.Contains(a.Rights, reqACE.Rights) { + found = true + break + } } - if err := p.FileSystem.ApplyACE(systemPolicy, ace); err != nil { - errorsFound = append(errorsFound, "apply ACE SYSTEM:F: "+err.Error()) + if !found { + ace := files.ACE{Principal: reqACE.Principal, Rights: reqACE.Rights, Type: reqACE.Type} + if sid, _, _ := files.ResolveAccountToSID(reqACE.Principal); len(sid) > 0 { + ace.PrincipalSID = sid + } + if err := p.FileSystem.ApplyACE(systemPolicy, ace); err != nil { + errorsFound = append(errorsFound, fmt.Sprintf("apply ACE %s:%s: %s", reqACE.Principal, reqACE.Rights, err.Error())) + } } } } @@ -442,20 +428,24 @@ func (p *PermissionsCmd) Fix() error { } // On Windows, ensure ACLs for plugin files as well if runtime.GOOS == "windows" { - if report, err := p.FileSystem.VerifyACL(path, files.ExpectedACLFromPerm(pf)); err == nil { - needAdmin := true - for _, a := range report.ACEs { - if a.Principal == "Administrators" && strings.Contains(a.Rights, "GENERIC_ALL") { - needAdmin = false - } - } - if needAdmin { - ace := files.ACE{Principal: "Administrators", Rights: "GENERIC_ALL", Type: "allow"} - if adminSID, _, _ := files.ResolveAccountToSID("Administrators"); len(adminSID) > 0 { - ace.PrincipalSID = adminSID + pfExpected := files.ExpectedACLFromPerm(pf) + if report, err := p.FileSystem.VerifyACL(path, pfExpected); err == nil { + for _, reqACE := range pfExpected.RequiredACEs { + found := false + for _, a := range report.ACEs { + if a.Principal == reqACE.Principal && strings.Contains(a.Rights, reqACE.Rights) { + found = true + break + } } - if err := p.FileSystem.ApplyACE(path, ace); err != nil { - errorsFound = append(errorsFound, "apply ACE Administrators:F for "+path+": "+err.Error()) + if !found { + ace := files.ACE{Principal: reqACE.Principal, Rights: reqACE.Rights, Type: reqACE.Type} + if sid, _, _ := files.ResolveAccountToSID(reqACE.Principal); len(sid) > 0 { + ace.PrincipalSID = sid + } + if err := p.FileSystem.ApplyACE(path, ace); err != nil { + errorsFound = append(errorsFound, fmt.Sprintf("apply ACE %s:%s for %s: %s", reqACE.Principal, reqACE.Rights, path, err.Error())) + } } } } else { diff --git a/commands/permissions_fix_windows_test.go b/commands/permissions_fix_windows_test.go index 8a899b21..065852fb 100644 --- a/commands/permissions_fix_windows_test.go +++ b/commands/permissions_fix_windows_test.go @@ -13,7 +13,7 @@ import ( "github.com/spf13/afero" ) -func TestRunPermissionsFix_AppliesAdminACE_Windows(t *testing.T) { +func TestRunPermissionsFix_AppliesRequiredACEs_Windows(t *testing.T) { // Setup in-memory fs with system policy file mem := afero.NewMemMapFs() systemPolicy := policy.SystemDefaultPolicyPath @@ -43,20 +43,95 @@ func TestRunPermissionsFix_AppliesAdminACE_Windows(t *testing.T) { } if len(mfs.Applied) < 2 { - t.Fatalf("expected at least 2 ApplyACE calls for Admin and SYSTEM, got %d", len(mfs.Applied)) + t.Fatalf("expected at least 2 ApplyACE calls for Administrators and opksshuser, got %d", len(mfs.Applied)) } // check principals present - var foundAdmin, foundSystem bool + var foundAdmin, foundOpksshuser bool for _, a := range mfs.Applied { - if a.Principal == "Administrators" { + if a.Principal == "Administrators" && a.Rights == "GENERIC_ALL" { foundAdmin = true } + if a.Principal == "opksshuser" && a.Rights == "GENERIC_READ" { + foundOpksshuser = true + } + } + if !foundAdmin { + t.Fatalf("expected ApplyACE for Administrators:GENERIC_ALL, got: %+v", mfs.Applied) + } + if !foundOpksshuser { + t.Fatalf("expected ApplyACE for opksshuser:GENERIC_READ, got: %+v", mfs.Applied) + } +} + +func TestRunPermissionsFix_SkipsExistingACEs_Windows(t *testing.T) { + // Setup in-memory fs with system policy file, ACEs already present + mem := afero.NewMemMapFs() + systemPolicy := policy.SystemDefaultPolicyPath + afero.WriteFile(mem, systemPolicy, []byte("x"), 0o644) + + mfs := &mockFileSystem{ + fs: mem, + aclReport: files.ACLReport{ + Path: systemPolicy, + Exists: true, + ACEs: []files.ACE{ + {Principal: "Administrators", Rights: "GENERIC_ALL", Type: "allow"}, + {Principal: "opksshuser", Rights: "GENERIC_READ", Type: "allow"}, + }, + }, + } + + p := &PermissionsCmd{ + FileSystem: mfs, + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + IsElevatedFn: func() (bool, error) { return true, nil }, + ConfirmPrompt: func(prompt string, in io.Reader) (bool, error) { return true, nil }, + Yes: true, + } + + err := p.Fix() + if err != nil { + t.Fatalf("Fix failed: %v", err) + } + + // No ACEs should have been applied since they already exist + for _, a := range mfs.Applied { if a.Principal == "SYSTEM" { - foundSystem = true + t.Fatalf("should not apply SYSTEM ACE, but got: %+v", mfs.Applied) } } - if !foundAdmin || !foundSystem { - t.Fatalf("expected ApplyACE for Administrators and SYSTEM, got: %+v", mfs.Applied) +} + +func TestRunPermissionsFix_NoSystemACE_Windows(t *testing.T) { + // Verify that SYSTEM:F ACE is never applied + mem := afero.NewMemMapFs() + systemPolicy := policy.SystemDefaultPolicyPath + afero.WriteFile(mem, systemPolicy, []byte("x"), 0o644) + + mfs := &mockFileSystem{ + fs: mem, + aclReport: files.ACLReport{Path: systemPolicy, Exists: true, ACEs: []files.ACE{}}, + } + + p := &PermissionsCmd{ + FileSystem: mfs, + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + IsElevatedFn: func() (bool, error) { return true, nil }, + ConfirmPrompt: func(prompt string, in io.Reader) (bool, error) { return true, nil }, + Yes: true, + } + + err := p.Fix() + if err != nil { + t.Fatalf("Fix failed: %v", err) + } + + for _, a := range mfs.Applied { + if a.Principal == "SYSTEM" { + t.Fatalf("SYSTEM ACE should not be applied, but got: %+v", mfs.Applied) + } } } diff --git a/commands/readhome_windows.go b/commands/readhome_windows.go index 86f70a7c..05a0751f 100644 --- a/commands/readhome_windows.go +++ b/commands/readhome_windows.go @@ -19,10 +19,91 @@ package commands import ( - "errors" + "bytes" + "fmt" + "os" + "os/user" + "path/filepath" + "regexp" + "strings" + + "github.com/openpubkey/opkssh/policy/files" + "github.com/spf13/afero" ) -// ReadHome is not currently supported on Windows +// ReadHome reads the home policy file for the user with the specified +// username on Windows. It verifies file ownership via the file's ACL +// owner SID to ensure the policy file belongs to the expected user. func ReadHome(username string) ([]byte, error) { - return nil, errors.New("readhome not supported on windows") + // Validate username: allow alphanumeric, dash, dot, underscore + if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_\-.]+$`, username); !matched { + return nil, fmt.Errorf("%s is not a valid Windows username", username) + } + + // Look up the user to get their SID and home directory + userObj, err := user.Lookup(username) + if err != nil { + // On Windows, user.Lookup may need DOMAIN\user format, but we + // only attempt lookup using the provided username string. + return nil, fmt.Errorf("failed to find user %s: %w", username, err) + } + + homePolicyPath := filepath.Join(userObj.HomeDir, ".opk", "auth_id") + + // Verify file exists + if _, err := os.Stat(homePolicyPath); err != nil { + return nil, fmt.Errorf("failed to access %s: %w", homePolicyPath, err) + } + + // Resolve the expected owner SID from the user object + expectedSID, _, err := files.ResolveAccountToSID(username) + if err != nil { + // Try with the full username which may include domain prefix + expectedSID, _, err = files.ResolveAccountToSID(userObj.Username) + if err != nil { + return nil, fmt.Errorf("failed to resolve SID for user %s: %w", username, err) + } + } + + // Verify file ownership via ACL: check the file owner SID matches the user + fs := afero.NewOsFs() + verifier := files.NewDefaultACLVerifier(fs) + report, err := verifier.VerifyACL(homePolicyPath, files.ExpectedACLFromPerm(files.RequiredPerms.HomePolicy)) + if err != nil { + return nil, fmt.Errorf("failed to verify ACL on %s: %w", homePolicyPath, err) + } + + // Compare owner SIDs + if len(report.OwnerSID) == 0 { + return nil, fmt.Errorf("could not determine file owner for %s", homePolicyPath) + } + if !bytes.Equal(report.OwnerSID, expectedSID) { + // Convert SIDs to string form for a readable error message + expectedSIDStr, sidErr := files.ConvertSidToString(expectedSID) + if sidErr != nil { + expectedSIDStr = userObj.Uid // fallback to user.User.Uid (SID on Windows) + } + actualSIDStr := report.OwnerSIDStr + if actualSIDStr == "" { + actualSIDStr = "" + } + ownerName := report.Owner + if ownerName == "" { + ownerName = actualSIDStr + } + return nil, fmt.Errorf("unsafe file ownership on %s: expected owner %s (SID %s) got %s (SID %s)", + homePolicyPath, username, expectedSIDStr, ownerName, actualSIDStr) + } + + // Verify there are no ACL problems flagged + if len(report.Problems) > 0 { + return nil, fmt.Errorf("ACL problems on %s: %s", homePolicyPath, strings.Join(report.Problems, "; ")) + } + + // Read and return file contents + content, err := os.ReadFile(homePolicyPath) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", homePolicyPath, err) + } + return content, nil } diff --git a/commands/readhome_windows_test.go b/commands/readhome_windows_test.go new file mode 100644 index 00000000..447fd7b5 --- /dev/null +++ b/commands/readhome_windows_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 OpenPubkey +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build windows + +package commands + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReadHome_InvalidUsername(t *testing.T) { + tests := []struct { + name string + username string + }{ + {name: "empty string", username: ""}, + {name: "contains backslash", username: `DOMAIN\user`}, + {name: "contains space", username: "user name"}, + {name: "contains slash", username: "user/name"}, + {name: "shell injection", username: "user;rm -rf /"}, + {name: "path traversal", username: "../etc/passwd"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ReadHome(tt.username) + require.Error(t, err) + require.Contains(t, err.Error(), "not a valid Windows username") + }) + } +} + +func TestReadHome_NonexistentUser(t *testing.T) { + _, err := ReadHome("nonexistent_user_abc123xyz") + require.Error(t, err) + require.Contains(t, err.Error(), "failed to find user") +} + +func TestReadHome_ValidUsernameFormat(t *testing.T) { + // These usernames are syntactically valid but the users don't exist, + // so they should fail at user lookup, not at validation. + tests := []struct { + name string + username string + }{ + {name: "simple", username: "testuser"}, + {name: "with dot", username: "test.user"}, + {name: "with dash", username: "test-user"}, + {name: "with underscore", username: "test_user"}, + {name: "alphanumeric", username: "user123"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ReadHome(tt.username) + require.Error(t, err) + // Should fail at user lookup, not validation + require.NotContains(t, err.Error(), "not a valid Windows username") + }) + } +} diff --git a/docs/config.md b/docs/config.md index 055aacfc..0a592ace 100644 --- a/docs/config.md +++ b/docs/config.md @@ -61,7 +61,7 @@ providers: ``` -## Server config `/etc/opk/config.yml` +## Server config `/etc/opk/config.yml` (Linux) or `%ProgramData%\opk\config.yml` (Windows) This is the config file for opkssh when used on the SSH server. It supports setting additional environment variables when `opkssh verify` is called. @@ -98,7 +98,7 @@ sudo chown root:opksshuser /etc/opk/config.yml sudo chmod 640 /etc/opk/config.yml ``` -## Allowed OpenID Providers: `/etc/opk/providers` +## Allowed OpenID Providers: `/etc/opk/providers` (Linux) or `%ProgramData%\opk\providers` (Windows) This file functions as an access control list that enables admins to determine the OpenID Providers and Client IDs they wish to use. This file contains a list of allowed OPKSSH OPs (OpenID Providers) and the associated client ID. @@ -121,7 +121,7 @@ https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096c https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h ``` -## Authorized identities files: `/etc/opk/auth_id` and `/home/{USER}/.opk/auth_id` +## Authorized identities files: `/etc/opk/auth_id` and `/home/{USER}/.opk/auth_id` (Linux) or `%ProgramData%\opk\auth_id` (Windows) These files contain the policies to determine which identities can assume what linux user accounts. Linux user accounts are typically referred to in SSH as *principals* and we use this terminology. @@ -133,7 +133,7 @@ We support email "wildcard" validation using the `oidc-match-end:email:` prefix. - This matching is **case-insensitive**. - Use with care, as allowing a domain grants access to all users at that domain. -### System authorized identity file `/etc/opk/auth_id` +### System authorized identity file `/etc/opk/auth_id` (Linux) or `%ProgramData%\opk\auth_id` (Windows) This is a server wide policy file. @@ -186,7 +186,9 @@ sudo chmod 640 /etc/opk/auth_id **Note:** The permissions for the system authorized identity file are different than the home authorized identity file. -### Home authorized identity file `/home/{USER}/.opk/auth_id` +### Home authorized identity file `/home/{USER}/.opk/auth_id` (Linux) + +> **Note:** Per-user home policy is not yet supported on Windows. This is user/principal specific permissions. That is, if it is in `/home/alice/.opk/auth_id` it can only specify who can assume the principal `alice` on the server. diff --git a/internal/sysdetails/openssh.go b/internal/sysdetails/openssh.go index cb12f4fb..33b42589 100644 --- a/internal/sysdetails/openssh.go +++ b/internal/sysdetails/openssh.go @@ -56,6 +56,15 @@ func GetOpenSSHVersion() string { if output, err := cmd.CombinedOutput(); err == nil && len(strings.TrimSpace(string(output))) > 0 { return strings.TrimSpace(string(output)) } + + case OSTypeWindows: + // For Windows, try ssh.exe in PATH + cmd := exec.Command("ssh.exe", "-V") + output, err := cmd.CombinedOutput() + if err == nil && len(strings.TrimSpace(string(output))) > 0 { + return strings.TrimSpace(string(output)) + } + default: log.Printf("Warning: Could not determine OpenSSH version using OS-specific methods for %s", osType) } diff --git a/internal/sysdetails/os.go b/internal/sysdetails/os.go index 69e354f3..ee779162 100644 --- a/internal/sysdetails/os.go +++ b/internal/sysdetails/os.go @@ -18,6 +18,7 @@ package sysdetails import ( "os" + "runtime" "strings" ) @@ -31,10 +32,16 @@ const ( OSTypeDebian OSType = "debian" OSTypeArch OSType = "arch" OSTypeSUSE OSType = "suse" + OSTypeWindows OSType = "windows" ) // DetectOS determines the type of operating system. func DetectOS() OSType { + // Check for Windows using runtime.GOOS + if runtime.GOOS == "windows" { + return OSTypeWindows + } + // Check for RedHat-based systems if _, err := os.Stat("/etc/redhat-release"); err == nil { return OSTypeRHEL diff --git a/logpath_unix.go b/logpath_unix.go new file mode 100644 index 00000000..d0ae251c --- /dev/null +++ b/logpath_unix.go @@ -0,0 +1,26 @@ +//go:build !windows +// +build !windows + +// Copyright 2026 OpenPubkey +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +// GetLogFilePath returns the path to the opkssh log file. +// On Unix-like systems, this is /var/log/opkssh.log +func GetLogFilePath() string { + return "/var/log/opkssh.log" +} diff --git a/logpath_windows.go b/logpath_windows.go new file mode 100644 index 00000000..b5c30d8d --- /dev/null +++ b/logpath_windows.go @@ -0,0 +1,35 @@ +//go:build windows +// +build windows + +// Copyright 2026 OpenPubkey +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" +) + +// GetLogFilePath returns the path to the opkssh log file. +// On Windows, this is %ProgramData%\opk\logs\opkssh.log +func GetLogFilePath() string { + programData := os.Getenv("ProgramData") + if programData == "" { + programData = `C:\ProgramData` + } + return filepath.Join(programData, "opk", "logs", "opkssh.log") +} diff --git a/main.go b/main.go index af1bfca9..c79488c0 100644 --- a/main.go +++ b/main.go @@ -21,11 +21,11 @@ package main import ( "context" - "errors" "fmt" "log" "os" "os/signal" + "path/filepath" "regexp" "runtime" "strings" @@ -48,8 +48,7 @@ import ( var ( // These can be overridden at build time using ldflags. For example: // go build -v -o /usr/local/bin/opkssh -ldflags "-X main.Version=version" - Version = "unversioned" - logFilePathServer = "/var/log/opkssh.log" // Remember if you change this, change it in the install script as well + Version = "unversioned" ) func main() { @@ -313,11 +312,16 @@ Arguments: ctx := context.Background() // Setup logger - logFile, err := os.OpenFile(logFilePathServer, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0660) // Owner and group can read/write + logFilePath := GetLogFilePath() + logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0660) // Owner and group can read/write if err != nil { fmt.Fprintf(os.Stderr, "Error opening log file: %v\n", err) // It could be very difficult to figure out what is going on if the log file was deleted. Hopefully this message saves someone an hour of debugging. - fmt.Fprintf(os.Stderr, "Check if log exists at %v, if it does not create it with permissions: chown root:opksshuser %v; chmod 660 %v\n", logFilePathServer, logFilePathServer, logFilePathServer) + if runtime.GOOS == "windows" { + fmt.Fprintf(os.Stderr, "Check if the log file exists at %v. If it does not, create it and ensure the account running sshd has read/write access (for example, via the file's Security properties or using icacls). The log directory may be under %%ProgramData%%.\n", logFilePath) + } else { + fmt.Fprintf(os.Stderr, "Check if log exists at %v, if it does not create it with permissions: chown root:opksshuser %v; chmod 660 %v\n", logFilePath, logFilePath, logFilePath) + } } else { defer logFile.Close() log.SetOutput(logFile) @@ -335,10 +339,10 @@ Arguments: typArg := args[2] extraArgs := args[3:] - providerPolicyPath := "/etc/opk/providers" + providerPolicyPath := filepath.Join(policy.GetSystemConfigBasePath(), "providers") providerPolicy, err := policy.NewProviderFileLoader().LoadProviderPolicy(providerPolicyPath) if err != nil { - log.Println("Failed to open /etc/opk/providers:", err) + log.Printf("Failed to open %s: %v\n", providerPolicyPath, err) return err } @@ -367,7 +371,8 @@ Arguments: } }, } - verifyCmd.Flags().StringVar(&serverConfigPathArg, "config-path", "/etc/opk/config.yml", "Path to the server config file. Default: /etc/opk/config.yml.") + defaultConfigPath := filepath.Join(policy.GetSystemConfigBasePath(), "config.yml") + verifyCmd.Flags().StringVar(&serverConfigPathArg, "config-path", defaultConfigPath, fmt.Sprintf("Path to the server config file. Default: %s", defaultConfigPath)) rootCmd.AddCommand(verifyCmd) auditCmd := &cobra.Command{ @@ -543,24 +548,33 @@ func checkOpenSSHVersion() { } } -func isOpenSSHVersion8Dot1OrGreater(opensshVersionStr string) (bool, error) { - // To handle versions like 9.9p1; we only need the initial numeric part for the comparison - re, err := regexp.Compile(`^(\d+(?:\.\d+)*).*`) +func isOpenSSHVersion8Dot1OrGreater(opensshVersion string) (bool, error) { + // Extract version number from various formats: + // - "OpenSSH_9.5p1" -> "9.5" + // - "OpenSSH_for_Windows_9.5p2, LibreSSL 3.8.2" -> "9.5" + + // First, get the part before comma (to handle LibreSSL suffix on Windows) + opensshVersion = strings.Split(opensshVersion, ",")[0] + + // Try to extract version using regex that handles both Unix and Windows formats + // Matches: "OpenSSH_9.5", "OpenSSH_for_Windows_9.5", etc. + re, err := regexp.Compile(`OpenSSH[_a-zA-Z]*[_](\d+\.\d+)`) if err != nil { fmt.Println("Error compiling regex:", err) return false, err } - opensshVersion := strings.TrimPrefix( - strings.Split(opensshVersionStr, ", ")[0], - "OpenSSH_", - ) - matches := re.FindStringSubmatch(opensshVersion) - if len(matches) <= 0 { - fmt.Println("Invalid OpenSSH version") - return false, errors.New("invalid OpenSSH version") + if len(matches) < 2 { + // If regex didn't match, try a simpler approach: find any version pattern + simpleRe := regexp.MustCompile(`(\d+\.\d+)`) + matches = simpleRe.FindStringSubmatch(opensshVersion) + + if len(matches) < 2 { + log.Printf("Invalid OpenSSH version format: %s", opensshVersion) + return false, fmt.Errorf("invalid OpenSSH version format: %s", opensshVersion) + } } version := "v" + matches[1] // semver requires that version strings start with 'v' diff --git a/policy/enforcer.go b/policy/enforcer.go index 20696402..6f1e2c5f 100644 --- a/policy/enforcer.go +++ b/policy/enforcer.go @@ -22,6 +22,7 @@ import ( "fmt" "log" "os" + "path/filepath" "strings" "github.com/openpubkey/openpubkey/pktoken" @@ -103,8 +104,11 @@ func (s *checkedClaims) UnmarshalJSON(data []byte) error { return nil } -// The default location for policy plugins -const pluginPolicyDir = "/etc/opk/policy.d" +// GetPluginPolicyDir returns the default location for policy plugins. +// On Unix: /etc/opk/policy.d, On Windows: %ProgramData%\opk\policy.d +func GetPluginPolicyDir() string { + return filepath.Join(GetSystemConfigBasePath(), "policy.d") +} // EscapedSplit splits a string by a separator while ignoring the separator in quoted sections. // This is useful for strings that may contain the separator character as part of the string @@ -184,6 +188,7 @@ func (p *Enforcer) CheckPolicy(principalDesired string, pkt *pktoken.PKToken, us } pluginPolicy := plugins.NewPolicyPluginEnforcer() + pluginPolicyDir := GetPluginPolicyDir() results, err := pluginPolicy.CheckPolicies(pluginPolicyDir, pkt, userInfoJson, principalDesired, sshCert, keyType, extraArgs) if err != nil { diff --git a/policy/files/acl.go b/policy/files/acl.go index 60121f1f..6c136ec8 100644 --- a/policy/files/acl.go +++ b/policy/files/acl.go @@ -22,6 +22,17 @@ type ACE struct { type ExpectedACL struct { Owner string Mode fs.FileMode // expected mode bits; 0 means ignore + + // RequiredACEs lists ACE expectations that must be present. + // Used on Windows to verify that opksshuser has been granted read access. + RequiredACEs []ExpectedACE +} + +// ExpectedACE describes a single required ACE. +type ExpectedACE struct { + Principal string // e.g. "Administrators", "SYSTEM", "opksshuser" + Rights string // e.g. "GENERIC_ALL", "GENERIC_READ" + Type string // "allow" } // ACLReport is the structured result from verifying ACLs/ownership for a path diff --git a/policy/files/fileperms_ops_windows_acl.go b/policy/files/fileperms_ops_windows_acl.go index c69005bf..fbe1cd20 100644 --- a/policy/files/fileperms_ops_windows_acl.go +++ b/policy/files/fileperms_ops_windows_acl.go @@ -89,13 +89,10 @@ func (w *WindowsACLFilePermsOps) Chown(path string, owner string, group string) } } - // Ensure Administrators and SYSTEM have full control via ApplyACE + // Ensure Administrators have full control via ApplyACE if err := w.ApplyACE(path, ACE{Principal: "Administrators", Rights: "GENERIC_ALL", Type: "allow"}); err != nil { return fmt.Errorf("ensure admin ACE failed: %v", err) } - if err := w.ApplyACE(path, ACE{Principal: "SYSTEM", Rights: "GENERIC_ALL", Type: "allow"}); err != nil { - return fmt.Errorf("ensure system ACE failed: %v", err) - } return nil } diff --git a/policy/files/perminfo.go b/policy/files/perminfo.go index 7278c632..6a97367c 100644 --- a/policy/files/perminfo.go +++ b/policy/files/perminfo.go @@ -19,6 +19,7 @@ package files import ( "fmt" "io/fs" + "runtime" ) // PermInfo describes the expected filesystem permissions for a given resource @@ -57,8 +58,21 @@ func (p PermInfo) String() string { // ExpectedACLFromPerm builds an ExpectedACL from a PermInfo. func ExpectedACLFromPerm(pi PermInfo) ExpectedACL { - return ExpectedACL{ + ea := ExpectedACL{ Owner: pi.Owner, Mode: pi.Mode, } + if runtime.GOOS == "windows" { + if pi.Owner != "" { + ea.RequiredACEs = append(ea.RequiredACEs, ExpectedACE{ + Principal: pi.Owner, Rights: "GENERIC_ALL", Type: "allow", + }) + } + if pi.Group != "" { + ea.RequiredACEs = append(ea.RequiredACEs, ExpectedACE{ + Principal: pi.Group, Rights: "GENERIC_READ", Type: "allow", + }) + } + } + return ea } diff --git a/policy/files/perminfo_windows.go b/policy/files/perminfo_windows.go index a5b37b1d..db1b4220 100644 --- a/policy/files/perminfo_windows.go +++ b/policy/files/perminfo_windows.go @@ -44,7 +44,7 @@ var RequiredPerms = struct { SystemPolicy: PermInfo{ Mode: ModeSystemPerms, // 0o640 Owner: "Administrators", - Group: "", + Group: "opksshuser", MustExist: true, }, HomePolicy: PermInfo{ @@ -56,25 +56,25 @@ var RequiredPerms = struct { Providers: PermInfo{ Mode: ModeSystemPerms, // 0o640 Owner: "Administrators", - Group: "", + Group: "opksshuser", MustExist: false, }, Config: PermInfo{ Mode: ModeSystemPerms, // 0o640 Owner: "Administrators", - Group: "", + Group: "opksshuser", MustExist: false, }, PluginsDir: PermInfo{ Mode: 0o750, Owner: "Administrators", - Group: "", + Group: "opksshuser", MustExist: false, }, PluginFile: PermInfo{ Mode: ModeSystemPerms, // 0o640 Owner: "Administrators", - Group: "", + Group: "opksshuser", MustExist: false, }, } diff --git a/policy/files/perminfo_windows_test.go b/policy/files/perminfo_windows_test.go new file mode 100644 index 00000000..94a12a86 --- /dev/null +++ b/policy/files/perminfo_windows_test.go @@ -0,0 +1,111 @@ +//go:build windows +// +build windows + +package files + +import ( + "testing" +) + +func TestExpectedACLFromPerm_PopulatesRequiredACEs(t *testing.T) { + pi := PermInfo{ + Mode: 0o640, + Owner: "Administrators", + Group: "opksshuser", + } + + ea := ExpectedACLFromPerm(pi) + + if ea.Owner != "Administrators" { + t.Fatalf("expected Owner=Administrators, got %q", ea.Owner) + } + if ea.Mode != 0o640 { + t.Fatalf("expected Mode=0o640, got %o", ea.Mode) + } + if len(ea.RequiredACEs) != 2 { + t.Fatalf("expected 2 RequiredACEs, got %d: %+v", len(ea.RequiredACEs), ea.RequiredACEs) + } + + // First ACE: owner gets GENERIC_ALL + if ea.RequiredACEs[0].Principal != "Administrators" { + t.Errorf("RequiredACEs[0].Principal = %q, want Administrators", ea.RequiredACEs[0].Principal) + } + if ea.RequiredACEs[0].Rights != "GENERIC_ALL" { + t.Errorf("RequiredACEs[0].Rights = %q, want GENERIC_ALL", ea.RequiredACEs[0].Rights) + } + if ea.RequiredACEs[0].Type != "allow" { + t.Errorf("RequiredACEs[0].Type = %q, want allow", ea.RequiredACEs[0].Type) + } + + // Second ACE: group gets GENERIC_READ + if ea.RequiredACEs[1].Principal != "opksshuser" { + t.Errorf("RequiredACEs[1].Principal = %q, want opksshuser", ea.RequiredACEs[1].Principal) + } + if ea.RequiredACEs[1].Rights != "GENERIC_READ" { + t.Errorf("RequiredACEs[1].Rights = %q, want GENERIC_READ", ea.RequiredACEs[1].Rights) + } + if ea.RequiredACEs[1].Type != "allow" { + t.Errorf("RequiredACEs[1].Type = %q, want allow", ea.RequiredACEs[1].Type) + } +} + +func TestExpectedACLFromPerm_EmptyGroupNoGroupACE(t *testing.T) { + pi := PermInfo{ + Mode: 0o600, + Owner: "Administrators", + Group: "", + } + + ea := ExpectedACLFromPerm(pi) + + if len(ea.RequiredACEs) != 1 { + t.Fatalf("expected 1 RequiredACE (owner only), got %d: %+v", len(ea.RequiredACEs), ea.RequiredACEs) + } + if ea.RequiredACEs[0].Principal != "Administrators" { + t.Errorf("RequiredACEs[0].Principal = %q, want Administrators", ea.RequiredACEs[0].Principal) + } +} + +func TestExpectedACLFromPerm_EmptyOwnerAndGroup(t *testing.T) { + pi := PermInfo{ + Mode: 0o600, + Owner: "", + Group: "", + } + + ea := ExpectedACLFromPerm(pi) + + if len(ea.RequiredACEs) != 0 { + t.Fatalf("expected 0 RequiredACEs for empty owner/group, got %d: %+v", len(ea.RequiredACEs), ea.RequiredACEs) + } +} + +func TestRequiredPerms_SystemEntriesHaveOpksshuser(t *testing.T) { + entries := []struct { + name string + pi PermInfo + }{ + {"SystemPolicy", RequiredPerms.SystemPolicy}, + {"Providers", RequiredPerms.Providers}, + {"Config", RequiredPerms.Config}, + {"PluginsDir", RequiredPerms.PluginsDir}, + {"PluginFile", RequiredPerms.PluginFile}, + } + + for _, e := range entries { + t.Run(e.name, func(t *testing.T) { + if e.pi.Group != "opksshuser" { + t.Errorf("%s.Group = %q, want opksshuser", e.name, e.pi.Group) + } + if e.pi.Owner != "Administrators" { + t.Errorf("%s.Owner = %q, want Administrators", e.name, e.pi.Owner) + } + }) + } +} + +func TestRequiredPerms_HomePolicyHasNoGroup(t *testing.T) { + if RequiredPerms.HomePolicy.Group != "" { + t.Errorf("HomePolicy.Group = %q, want empty", RequiredPerms.HomePolicy.Group) + } +} diff --git a/policy/files/permschecker_windows.go b/policy/files/permschecker_windows.go index e8c2fa39..972a1218 100644 --- a/policy/files/permschecker_windows.go +++ b/policy/files/permschecker_windows.go @@ -31,7 +31,7 @@ import ( // 3. A file without the read-only attribute will always show as 0666, not 0640 // // For security on Windows, we rely on: -// - NTFS ACLs set by the installer (Administrators and SYSTEM only) +// - NTFS ACLs set by the installer (Administrators full control, opksshuser read) // - File system level security rather than permission bits // // This function validates the file exists and is accessible, but skips diff --git a/policy/files/table.go b/policy/files/table.go index fd071810..efcc0fb8 100644 --- a/policy/files/table.go +++ b/policy/files/table.go @@ -29,6 +29,10 @@ type Table struct { // NewTable creates a new Table from the given content. func NewTable(content []byte) *Table { + // Strip UTF-8 BOM if present + if len(content) >= 3 && content[0] == 0xEF && content[1] == 0xBB && content[2] == 0xBF { + content = content[3:] + } table := [][]string{} rows := strings.Split(string(content), "\n") for _, row := range rows { diff --git a/policy/files/table_test.go b/policy/files/table_test.go index acabb3ef..585cac45 100644 --- a/policy/files/table_test.go +++ b/policy/files/table_test.go @@ -78,6 +78,15 @@ func TestToTable(t *testing.T) { reverse: "https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h\n" + "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h\n", }, + { + name: "input with UTF-8 BOM", + input: "\xEF\xBB\xBF# Issuer Client-ID expiration-policy\n" + + "https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h\n", + output: [][]string{ + {"https://accounts.google.com", "206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com", "24h"}, + }, + reverse: "https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/policy/multipolicyloader_windows.go b/policy/multipolicyloader_windows.go index 90716418..51845a6f 100644 --- a/policy/multipolicyloader_windows.go +++ b/policy/multipolicyloader_windows.go @@ -24,8 +24,9 @@ import ( ) // ReadWithSudoScript on Windows does not use sudo (which doesn't exist). -// On Windows, the sshd service runs as LocalSystem which has full access to -// read user home directories, so we don't need privilege escalation. +// On Windows, home policy files are not supported. The verify process runs as +// the dedicated opksshuser account, which only has read access to system policy +// files. There is no privilege escalation mechanism on Windows. // This function just returns an error indicating home policy reading failed // and we should rely on the system policy. func ReadWithSudoScript(h *HomePolicyLoader, username string) ([]byte, error) { diff --git a/scripts/windows/Install-OpksshServer.ps1 b/scripts/windows/Install-OpksshServer.ps1 new file mode 100644 index 00000000..a84198da --- /dev/null +++ b/scripts/windows/Install-OpksshServer.ps1 @@ -0,0 +1,1121 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator + +<# +.SYNOPSIS + Installs and configures opkssh on Windows Server 2022 with OpenSSH Server. + +.DESCRIPTION + This script downloads and installs the opkssh binary, creates necessary configuration + files and directories, and configures the OpenSSH Server to use opkssh for + authentication via OpenID Connect. + +.PARAMETER NoSshdRestart + Do not restart the sshd service after installation. + You must manually restart the service for changes to take effect. + +.PARAMETER OverwriteConfig + Overwrite existing AuthorizedKeysCommand configuration in sshd_config. + Use this if the script detects existing configuration that conflicts. + +.PARAMETER InstallFrom + Path to a local opkssh.exe file to install instead of downloading from GitHub. + +.PARAMETER InstallVersion + Specific version to install from GitHub (e.g., "v0.10.0"). + Default is "latest". + +.PARAMETER InstallDir + Directory where opkssh.exe will be installed. + Default is "C:\Program Files\opkssh". + +.PARAMETER ConfigPath + Directory where opkssh configuration files will be created. + Default is "C:\ProgramData\opk". + +.PARAMETER GitHubRepo + GitHub repository to download from (format: owner/repo). + Default is "openpubkey/opkssh". + +.EXAMPLE + .\Install-OpksshServer.ps1 + + Basic installation with default settings. + +.EXAMPLE + .\Install-OpksshServer.ps1 -InstallFrom "C:\Downloads\opkssh.exe" + + Install from a local file instead of downloading. + +.EXAMPLE + .\Install-OpksshServer.ps1 -InstallVersion "v0.10.0" -Verbose + + Install a specific version with verbose output. + +.NOTES + Author: OpenPubkey Project + Requires: Windows Server 2022 (or Windows 10/11 with OpenSSH Server installed) + Requires: PowerShell 5.1 or higher + Requires: Administrator privileges + Requires: OpenSSH Server installed and configured +#> + +[CmdletBinding(SupportsShouldProcess=$true)] +param( + [Parameter(HelpMessage="Do not restart sshd service after installation")] + [switch]$NoSshdRestart, + + [Parameter(HelpMessage="Overwrite existing AuthorizedKeysCommand configuration")] + [switch]$OverwriteConfig, + + [Parameter(HelpMessage="Path to local opkssh.exe file")] + [ValidateScript({ + if ($_ -and -not (Test-Path $_)) { + throw "File not found: $_" + } + $true + })] + [string]$InstallFrom = "", + + [Parameter(HelpMessage="Version to install (e.g., 'v0.10.0' or 'latest')")] + [string]$InstallVersion = "latest", + + [Parameter(HelpMessage="Installation directory for opkssh.exe")] + [string]$InstallDir = "C:\Program Files\opkssh", + + [Parameter(HelpMessage="Configuration directory path")] + [string]$ConfigPath = "C:\ProgramData\opk", + + [Parameter(HelpMessage="GitHub repository (owner/repo)")] + [string]$GitHubRepo = "openpubkey/opkssh" +) + +#region Helper Functions + +function Write-Log { + <# + .SYNOPSIS + Writes a message to the console and optionally to a log file. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [string]$Message, + + [Parameter()] + [ValidateSet('Info', 'Warning', 'Error', 'Success', 'Verbose')] + [string]$Level = 'Info', + + [Parameter()] + [string]$LogFile + ) + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $logMessage = "[$timestamp] [$Level] $Message" + + switch ($Level) { + 'Info' { Write-Host $Message } + 'Warning' { Write-Warning $Message } + 'Error' { Write-Error $Message } + 'Success' { Write-Host $Message -ForegroundColor Green } + 'Verbose' { Write-Verbose $Message } + } + + if ($LogFile -and (Test-Path (Split-Path $LogFile -Parent))) { + Add-Content -Path $LogFile -Value $logMessage + } +} + +function Test-Prerequisites { + <# + .SYNOPSIS + Verifies that all prerequisites are met for installation. + #> + [CmdletBinding()] + param() + + Write-Verbose "Checking prerequisites..." + + # Check PowerShell version + if ($PSVersionTable.PSVersion.Major -lt 5) { + throw "PowerShell 5.1 or higher is required. Current version: $($PSVersionTable.PSVersion)" + } + Write-Verbose " PowerShell version: $($PSVersionTable.PSVersion)" + + # Check if running as Administrator + $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) + $isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + + if (-not $isAdmin) { + throw "This script must be run as Administrator. Please restart PowerShell with elevated privileges." + } + Write-Verbose " Running with Administrator privileges: OK" + + # Check OpenSSH Server capability + Write-Verbose " Checking OpenSSH Server installation..." + $sshCapability = Get-WindowsCapability -Online -Name "OpenSSH.Server*" -ErrorAction SilentlyContinue + + if (-not $sshCapability) { + throw "OpenSSH Server capability not found. This script requires Windows Server 2019 or later, or Windows 10/11." + } + + if ($sshCapability.State -ne 'Installed') { + throw "OpenSSH Server is not installed. Install it using: Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0" + } + Write-Verbose " OpenSSH Server is installed" + + # Check sshd service + $sshdService = Get-Service -Name sshd -ErrorAction SilentlyContinue + if (-not $sshdService) { + throw "sshd service not found. OpenSSH Server may not be properly configured." + } + Write-Verbose " sshd service found: $($sshdService.Status)" + + # Verify sshd_config exists + $sshdConfigPath = "C:\ProgramData\ssh\sshd_config" + if (-not (Test-Path $sshdConfigPath)) { + throw "sshd_config not found at $sshdConfigPath" + } + Write-Verbose " sshd_config found at: $sshdConfigPath" + + Write-Verbose "All prerequisites met." + return $true +} + +function Get-SystemArchitecture { + <# + .SYNOPSIS + Determines the CPU architecture of the system. + #> + [CmdletBinding()] + [OutputType([string])] + param() + + $arch = $env:PROCESSOR_ARCHITECTURE + Write-Verbose "Detected processor architecture: $arch" + + switch ($arch) { + "AMD64" { + Write-Verbose "Using architecture: amd64" + return "amd64" + } + "ARM64" { + Write-Verbose "Using architecture: arm64" + return "arm64" + } + default { + throw "Unsupported CPU architecture: $arch. Supported architectures are AMD64 and ARM64." + } + } +} + +function Test-OpksshVersion { + <# + .SYNOPSIS + Validates that the requested version is supported by this script. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [string]$Version + ) + + if ($Version -eq "latest") { + Write-Verbose "Installing latest version" + return $true + } + + # Minimum supported version + $minVersionString = "0.10.0" + $minVersion = [version]$minVersionString + + # Parse requested version (allow optional prerelease/build suffixes) + $versionMatch = [regex]::Match($Version, '^v?(?\d+\.\d+\.\d+)(?[-+].+)?$') + if (-not $versionMatch.Success) { + throw "Invalid version format: $Version. Use format 'v0.10.0' or '0.10.0' (optionally with -suffix)" + } + + $versionString = $versionMatch.Groups['core'].Value + + try { + $requestedVersion = [version]$versionString + } catch { + throw "Invalid version format: $Version. Use format 'v0.10.0' or '0.10.0' (optionally with -suffix)" + } + + if ($requestedVersion -lt $minVersion) { + throw @" +Installing opkssh $Version with this script is not supported. +Minimum supported version is v$minVersionString. +For older versions, please use the installation script from that release. +"@ + } + + Write-Verbose "Version $Version is supported" + return $true +} + +function New-OpksshUser { + <# + .SYNOPSIS + Creates a dedicated local user account for running AuthorizedKeysCommand. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory=$true)] + [string]$Username + ) + + Write-Verbose "Checking if user '$Username' exists..." + $existingUser = Get-LocalUser -Name $Username -ErrorAction SilentlyContinue + + if ($existingUser) { + Write-Verbose "User '$Username' already exists" + return $true + } + + if ($PSCmdlet.ShouldProcess($Username, "Create local user")) { + Write-Verbose "Creating local user: $Username" + + # Generate a random password + Add-Type -AssemblyName 'System.Web' + $password = [System.Web.Security.Membership]::GeneratePassword(32, 10) + $securePassword = ConvertTo-SecureString $password -AsPlainText -Force + + # Create user with security settings + try { + New-LocalUser -Name $Username ` + -Password $securePassword ` + -Description "OpenPubkey SSH verification user" ` + -UserMayNotChangePassword ` + -PasswordNeverExpires ` + -AccountNeverExpires ` + -ErrorAction Stop | Out-Null + + Write-Log "Created user: $Username" -Level Success + + # Deny interactive logon for the service account using secedit + try { + $tempCfg = [System.IO.Path]::GetTempFileName() + $tempDb = [System.IO.Path]::GetTempFileName() + try { + secedit /export /cfg $tempCfg | Out-Null + $content = Get-Content $tempCfg -Raw + if ($content -match 'SeDenyInteractiveLogonRight\s*=\s*(.*)') { + $existing = $Matches[1] + $content = $content -replace "SeDenyInteractiveLogonRight\s*=\s*.*", + "SeDenyInteractiveLogonRight = $existing,$Username" + } else { + $content = $content -replace '\[Privilege Rights\]', + "[Privilege Rights]`r`nSeDenyInteractiveLogonRight = $Username" + } + Set-Content $tempCfg $content + secedit /configure /db $tempDb /cfg $tempCfg /areas USER_RIGHTS | Out-Null + Write-Log "Denied interactive logon for user: $Username" -Level Success + } finally { + Remove-Item $tempCfg, $tempDb -ErrorAction SilentlyContinue + } + } catch { + Write-Warning "Could not automatically deny interactive logon for '$Username': $($_.Exception.Message)" + Write-Warning "Manual step required: Deny interactive logon rights for user '$Username' via Local Security Policy" + } + + } catch { + throw "Failed to create user '$Username': $($_.Exception.Message)" + } + } + + return $true +} + +function Install-OpksshBinary { + <# + .SYNOPSIS + Downloads or copies the opkssh binary to the installation directory. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory=$true)] + [string]$InstallDir, + + [Parameter()] + [string]$LocalFile, + + [Parameter(Mandatory=$true)] + [string]$Version, + + [Parameter(Mandatory=$true)] + [string]$Architecture, + + [Parameter(Mandatory=$true)] + [string]$GitHubRepo + ) + + $binaryName = "opkssh.exe" + $binaryPath = Join-Path $InstallDir $binaryName + + # Create installation directory if it doesn't exist + if (-not (Test-Path $InstallDir)) { + Write-Verbose "Creating installation directory: $InstallDir" + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + } + + if ($LocalFile) { + # Install from local file + Write-Log "Installing from local file: $LocalFile" + + if (-not (Test-Path $LocalFile)) { + throw "Local file not found: $LocalFile" + } + + if ($PSCmdlet.ShouldProcess($LocalFile, "Copy to $binaryPath")) { + Copy-Item $LocalFile $binaryPath -Force + Write-Verbose "Copied $LocalFile to $binaryPath" + } + } else { + # Download from GitHub + if ($Version -eq "latest") { + $downloadUrl = "https://github.com/$GitHubRepo/releases/latest/download/opkssh-windows-$Architecture.exe" + } else { + $downloadUrl = "https://github.com/$GitHubRepo/releases/download/$Version/opkssh-windows-$Architecture.exe" + } + + Write-Log "Downloading opkssh version $Version from GitHub..." + Write-Verbose "Download URL: $downloadUrl" + + if ($PSCmdlet.ShouldProcess($downloadUrl, "Download to $binaryPath")) { + try { + # Use TLS 1.2 + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + # Download with progress + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $downloadUrl -OutFile $binaryPath -UseBasicParsing -ErrorAction Stop + $ProgressPreference = 'Continue' + + Write-Verbose "Downloaded to: $binaryPath" + } catch { + throw "Failed to download opkssh binary: $($_.Exception.Message)" + } + } + } + + # Verify the binary exists and is executable + if (-not (Test-Path $binaryPath)) { + throw "Installation failed: Binary not found at $binaryPath" + } + + $fileInfo = Get-Item $binaryPath + Write-Verbose "Binary size: $($fileInfo.Length) bytes" + + # Test that the binary is valid by running --version + try { + $versionOutput = & $binaryPath --version 2>&1 + Write-Verbose "Binary version: $versionOutput" + } catch { + Write-Warning "Could not verify binary version: $($_.Exception.Message)" + } + + Write-Log "Installed opkssh to: $binaryPath" -Level Success + return $binaryPath +} + +function Install-UninstallScript { + <# + .SYNOPSIS + Downloads the uninstall script and places it in the installation directory. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory=$true)] + [string]$InstallDir, + + [Parameter(Mandatory=$true)] + [string]$Version, + + [Parameter(Mandatory=$true)] + [string]$GitHubRepo + ) + + $scriptName = "Uninstall-OpksshServer.ps1" + $scriptPath = Join-Path $InstallDir $scriptName + + # Download from GitHub + if ($Version -eq "latest") { + $downloadUrl = "https://github.com/$GitHubRepo/releases/latest/download/$scriptName" + } else { + $downloadUrl = "https://github.com/$GitHubRepo/releases/download/$Version/$scriptName" + } + + Write-Log "Downloading uninstall script..." + Write-Verbose "Download URL: $downloadUrl" + + if ($PSCmdlet.ShouldProcess($downloadUrl, "Download to $scriptPath")) { + try { + # Use TLS 1.2 + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + # Download with progress + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $downloadUrl -OutFile $scriptPath -UseBasicParsing -ErrorAction Stop + $ProgressPreference = 'Continue' + + Write-Log "Installed uninstall script to: $scriptPath" -Level Success + Write-Verbose "Downloaded to: $scriptPath" + } catch { + # Non-fatal error - uninstall script is optional + Write-Warning "Could not download uninstall script: $($_.Exception.Message)" + Write-Warning "You can manually download it from: $downloadUrl" + return $null + } + } + + return $scriptPath +} + +function New-OpksshConfiguration { + <# + .SYNOPSIS + Creates opkssh configuration directory structure and files. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory=$true)] + [string]$ConfigPath, + + [Parameter(Mandatory=$true)] + [string]$AuthCmdUser + ) + + Write-Log "Configuring opkssh at: $ConfigPath" + + # Define directory structure + $directories = @( + $ConfigPath, + (Join-Path $ConfigPath "policy.d"), + (Join-Path $ConfigPath "logs") + ) + + # Create directories + foreach ($dir in $directories) { + if (-not (Test-Path $dir)) { + if ($PSCmdlet.ShouldProcess($dir, "Create directory")) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + Write-Verbose " Created directory: $dir" + } + } else { + Write-Verbose " Directory exists: $dir" + } + } + + # Define configuration files + $authIdPath = Join-Path $ConfigPath "auth_id" + $configYmlPath = Join-Path $ConfigPath "config.yml" + $providersPath = Join-Path $ConfigPath "providers" + + # Create auth_id if it doesn't exist + if (-not (Test-Path $authIdPath)) { + if ($PSCmdlet.ShouldProcess($authIdPath, "Create auth_id file")) { + New-Item -ItemType File -Path $authIdPath -Force | Out-Null + Write-Verbose " Created file: auth_id" + } + } else { + Write-Verbose " File exists: auth_id" + } + + # Create config.yml if it doesn't exist + if (-not (Test-Path $configYmlPath)) { + if ($PSCmdlet.ShouldProcess($configYmlPath, "Create config.yml file")) { + New-Item -ItemType File -Path $configYmlPath -Force | Out-Null + Write-Verbose " Created file: config.yml" + } + } else { + Write-Verbose " File exists: config.yml" + } + + # Create or update providers file + if (-not (Test-Path $providersPath)) { + $providersContent = @" +# Issuer Client-ID expiration-policy +https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h +https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h +https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h +https://issuer.hello.coop app_xejobTKEsDNSRd5vofKB2iay_2rN 24h +"@ + + if ($PSCmdlet.ShouldProcess($providersPath, "Create providers file")) { + [System.IO.File]::WriteAllText($providersPath, $providersContent, [System.Text.UTF8Encoding]::new($false)) + Write-Verbose " Created file: providers" + } + } else { + $existingContent = Get-Content $providersPath -Raw + if ([string]::IsNullOrWhiteSpace($existingContent)) { + Write-Warning " The providers file exists but is empty. Keeping it empty." + } else { + Write-Verbose " The providers file is not empty. Keeping existing values." + } + } + + # Grant opksshuser read access to config directory (inherited by files) + $configAcl = Get-Acl $ConfigPath + $readRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $AuthCmdUser, "ReadAndExecute", "ContainerInherit,ObjectInherit", "None", "Allow") + $configAcl.AddAccessRule($readRule) + Set-Acl -Path $ConfigPath -AclObject $configAcl + Write-Verbose " Granted $AuthCmdUser read access to $ConfigPath" + + # Grant opksshuser write access to logs directory + $logsDir = Join-Path $ConfigPath "logs" + $logsAcl = Get-Acl $logsDir + $writeRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $AuthCmdUser, "Modify", "ContainerInherit,ObjectInherit", "None", "Allow") + $logsAcl.AddAccessRule($writeRule) + Set-Acl -Path $logsDir -AclObject $logsAcl + Write-Verbose " Granted $AuthCmdUser write access to $logsDir" + + Write-Log "Configuration created successfully" -Level Success + return $true +} + +function Set-SshdConfiguration { + <# + .SYNOPSIS + Configures sshd_config to use opkssh for AuthorizedKeysCommand. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory=$true)] + [string]$BinaryPath, + + [Parameter(Mandatory=$true)] + [string]$AuthCmdUser, + + [Parameter()] + [bool]$OverwriteConfig = $false, + + [Parameter()] + [string]$SshdConfigPath = "C:\ProgramData\ssh\sshd_config" + ) + + Write-Log "Configuring OpenSSH Server..." + Write-Verbose " sshd_config path: $sshdConfigPath" + + if (-not (Test-Path $sshdConfigPath)) { + throw "sshd_config not found at: $sshdConfigPath" + } + + # Read current configuration + $configLines = Get-Content $sshdConfigPath + + # Prepare new configuration lines + # Note: Windows paths with spaces must be quoted + $quotedBinaryPath = "`"$BinaryPath`"" + $authKeyCmdLine = "AuthorizedKeysCommand $quotedBinaryPath verify %u %k %t" + $authKeyUserLine = "AuthorizedKeysCommandUser $AuthCmdUser" + + # Check for existing AuthorizedKeysCommand configuration + $hasAuthKeyCmd = $configLines | Where-Object { + $_ -match '^\s*AuthorizedKeysCommand\s+' -and $_ -notmatch '^\s*#' + } + $hasAuthKeyUser = $configLines | Where-Object { + $_ -match '^\s*AuthorizedKeysCommandUser\s+' -and $_ -notmatch '^\s*#' + } + + $matchesAuthKeyCmd = $hasAuthKeyCmd | Where-Object { $_.Trim() -eq $authKeyCmdLine } + $matchesAuthKeyUser = $hasAuthKeyUser | Where-Object { $_.Trim() -eq $authKeyUserLine } + + if ($matchesAuthKeyCmd -and $matchesAuthKeyUser) { + Write-Log " Existing opkssh sshd_config entries already match desired configuration" -Level Success + return $true + } + + if (($hasAuthKeyCmd -or $hasAuthKeyUser) -and -not $OverwriteConfig) { + Write-Warning "Existing AuthorizedKeysCommand configuration detected:" + $hasAuthKeyCmd | ForEach-Object { Write-Warning " $_" } + $hasAuthKeyUser | ForEach-Object { Write-Warning " $_" } + Write-Warning "" + Write-Warning "To overwrite this configuration, run the script with -OverwriteConfig" + return $false + } + + # Backup existing configuration + $timestamp = Get-Date -Format "yyyyMMddHHmmss" + $backupPath = "$sshdConfigPath.backup.$timestamp" + + if ($PSCmdlet.ShouldProcess($sshdConfigPath, "Create backup at $backupPath")) { + Copy-Item $sshdConfigPath $backupPath -Force + Write-Verbose " Created backup: $backupPath" + } + + # Process configuration + $newConfigLines = @() + + foreach ($line in $configLines) { + if ($line -match '^\s*AuthorizedKeysCommand\s+') { + # Comment out existing AuthorizedKeysCommand + $newConfigLines += "# $line" + Write-Verbose " Commented out: $line" + } elseif ($line -match '^\s*AuthorizedKeysCommandUser\s+') { + # Comment out existing AuthorizedKeysCommandUser + $newConfigLines += "# $line" + Write-Verbose " Commented out: $line" + } else { + $newConfigLines += $line + } + } + + # Add opkssh configuration at the end + $newConfigLines += "" + $newConfigLines += "# opkssh configuration - added by Install-OpksshServer.ps1 on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" + $newConfigLines += $authKeyCmdLine + $newConfigLines += $authKeyUserLine + + # Write new configuration + if ($PSCmdlet.ShouldProcess($sshdConfigPath, "Update sshd_config")) { + Set-Content -Path $sshdConfigPath -Value $newConfigLines -Encoding ascii -Force + Write-Verbose " Updated sshd_config" + } + + # Validate configuration by attempting to parse it + # Note: OpenSSH on Windows doesn't have a built-in config test like sshd -t + # We'll do a basic sanity check + Write-Verbose " Validating configuration..." + $finalConfig = Get-Content $sshdConfigPath -Raw + if ($finalConfig -match [regex]::Escape($authKeyCmdLine)) { + Write-Log " OpenSSH Server configured successfully" -Level Success + return $true + } else { + Write-Error "Configuration validation failed" + if ($PSCmdlet.ShouldProcess($backupPath, "Restore from backup")) { + Copy-Item $backupPath $sshdConfigPath -Force + Write-Warning "Restored configuration from backup" + } + return $false + } +} + +function Restart-SshdService { + <# + .SYNOPSIS + Restarts the OpenSSH Server (sshd) service. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter()] + [bool]$NoRestart = $false + ) + + if ($NoRestart) { + Write-Warning "Skipping sshd service restart (NoRestart parameter specified)" + Write-Warning "You must manually restart the sshd service for changes to take effect:" + Write-Warning " Restart-Service sshd" + return $true + } + + Write-Log "Restarting sshd service..." + + if ($PSCmdlet.ShouldProcess("sshd", "Restart service")) { + try { + Restart-Service sshd -Force -ErrorAction Stop + + # Wait a moment for service to stabilize + Start-Sleep -Seconds 2 + + # Verify service is running + $service = Get-Service sshd + if ($service.Status -eq 'Running') { + Write-Log " sshd service restarted successfully" -Level Success + + # Ensure service starts automatically + $startType = (Get-Service sshd).StartType + if ($startType -ne 'Automatic') { + Set-Service sshd -StartupType Automatic + Write-Verbose " Set sshd service to start automatically" + } + + return $true + } else { + throw "Service is in state: $($service.Status)" + } + } catch { + Write-Error "Failed to restart sshd service: $($_.Exception.Message)" + Write-Warning "Please restart the service manually: Restart-Service sshd" + return $false + } + } + + return $true +} + +function Add-OpksshToPath { + <# + .SYNOPSIS + Adds opkssh installation directory to the system PATH without expanding environment variables. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Parameter(Mandatory=$true)] + [string]$InstallDir + ) + + if ($PSCmdlet.ShouldProcess("System PATH", "Add $InstallDir")) { + try { + # Use Registry to preserve environment variable expansion (e.g., %SystemRoot%) + $keyName = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($keyName, $true) + try { + # Get current PATH without expanding environment variables + $currentPathFolders = $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') -split [IO.Path]::PathSeparator + + # Check if already in PATH (case-insensitive) + $normalizedInstallDir = $InstallDir.TrimEnd([IO.Path]::DirectorySeparatorChar) + $alreadyInPath = $currentPathFolders | Where-Object { + $_.TrimEnd([IO.Path]::DirectorySeparatorChar) -eq $normalizedInstallDir + } + + if ($alreadyInPath) { + Write-Verbose "Installation directory already in PATH" + return $true + } + + # Add new folder to the current PATH + # Preserve original order while removing duplicates (case-insensitive) + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::InvariantCultureIgnoreCase) + $orderedPathFolders = New-Object 'System.Collections.Generic.List[string]' + + foreach ($folder in $currentPathFolders) { + $normalized = $folder.TrimEnd([IO.Path]::DirectorySeparatorChar).Trim() + if ($normalized -ne '' -and $seen.Add($normalized)) { + [void]$orderedPathFolders.Add($normalized) + } + } + + # Append install directory if it's not already present + if ($seen.Add($normalizedInstallDir)) { + [void]$orderedPathFolders.Add($normalizedInstallDir) + Write-Verbose "Adding installation directory to PATH" + } else { + Write-Verbose "Installation directory already in PATH" + } + + # Build new PATH and save it + $newPath = [string]::Join([IO.Path]::PathSeparator, $orderedPathFolders) + $key.SetValue('Path', $newPath, 'ExpandString') + + # Broadcast WM_SETTINGCHANGE so that Explorer (and new console windows) pick up the change + if (-not ('Win32.NativeMethods' -as [type])) { + Add-Type -Namespace Win32 -Name NativeMethods -MemberDefinition @' + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] + public static extern IntPtr SendMessageTimeout( + IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, + uint fuFlags, uint uTimeout, out UIntPtr lpdwResult); +'@ + } + $HWND_BROADCAST = [IntPtr]0xffff + $WM_SETTINGCHANGE = 0x1a + $result = [UIntPtr]::Zero + [Win32.NativeMethods]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) | Out-Null + Write-Verbose " Broadcast WM_SETTINGCHANGE to notify running processes" + + Write-Log " Added to system PATH: $InstallDir" -Level Success + return $true + } finally { + if ($null -ne $key) { + $key.Dispose() + } + } + } catch { + Write-Warning "Failed to add to PATH: $($_.Exception.Message)" + Write-Warning "You can manually add '$InstallDir' to your PATH" + return $false + } + } + + return $true +} + +function Write-InstallationLog { + <# + .SYNOPSIS + Logs installation details to a file. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [string]$LogPath, + + [Parameter(Mandatory=$true)] + [string]$BinaryPath, + + [Parameter(Mandatory=$true)] + [hashtable]$InstallParams + ) + + # Ensure log directory exists + $logDir = Split-Path $LogPath -Parent + if (-not (Test-Path $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + } + + # Get version from binary + try { + $version = & $BinaryPath --version 2>&1 + } catch { + $version = "Unknown (failed to execute --version)" + } + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + + $logEntry = @" + +======================================== +opkssh Installation Log +======================================== +Timestamp: $timestamp +Version: $version +Binary Path: $BinaryPath +Install Version Parameter: $($InstallParams.InstallVersion) +Local Install File: $($InstallParams.InstallFrom) +SSH Restarted: $(-not $InstallParams.NoRestart) +Configuration Path: $($InstallParams.ConfigPath) +PowerShell Version: $($PSVersionTable.PSVersion) +OS Version: $([System.Environment]::OSVersion.VersionString) +Computer Name: $env:COMPUTERNAME +User: $env:USERNAME +======================================== + +"@ + + try { + Add-Content -Path $LogPath -Value $logEntry -ErrorAction Stop + Write-Verbose "Installation logged to: $LogPath" + } catch { + Write-Warning "Failed to write installation log: $($_.Exception.Message)" + } +} + +#endregion Helper Functions + +#region Main Installation Logic + +function Install-OpksshServer { + <# + .SYNOPSIS + Main installation function. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param() + + $ErrorActionPreference = 'Stop' + + # The AuthorizedKeysCommand user is always 'opksshuser'. + # This matches the Go-side permissions model which hard-codes this user. + $AuthCmdUser = "opksshuser" + + try { + Write-Host "" + Write-Host "========================================" -ForegroundColor Cyan + Write-Host " opkssh Installation for Windows" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "" + + # Step 1: Verify prerequisites + Write-Host "[1/11] Checking prerequisites..." -ForegroundColor Yellow + Test-Prerequisites | Out-Null + Write-Host " Prerequisites OK" -ForegroundColor Green + Write-Host "" + + # Step 2: Get system architecture + Write-Host "[2/11] Detecting system architecture..." -ForegroundColor Yellow + $arch = Get-SystemArchitecture + Write-Host " Architecture: $arch" -ForegroundColor Green + Write-Host "" + + # Step 3: Validate version + Write-Host "[3/11] Validating opkssh version..." -ForegroundColor Yellow + Test-OpksshVersion -Version $InstallVersion | Out-Null + Write-Host " Version OK: $InstallVersion" -ForegroundColor Green + Write-Host "" + + # Step 4: Create user account (if needed) + Write-Host "[4/11] Configuring authentication user..." -ForegroundColor Yellow + New-OpksshUser -Username $AuthCmdUser | Out-Null + Write-Host " Auth user: $AuthCmdUser" -ForegroundColor Green + Write-Host "" + + # Step 5: Install binary + Write-Host "[5/11] Installing opkssh binary..." -ForegroundColor Yellow + $binaryPath = Install-OpksshBinary -InstallDir $InstallDir ` + -LocalFile $InstallFrom ` + -Version $InstallVersion ` + -Architecture $arch ` + -GitHubRepo $GitHubRepo + Write-Host " Installed: $binaryPath" -ForegroundColor Green + + # Grant opksshuser read+execute access to the binary directory + $binAcl = Get-Acl $InstallDir + $execRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $AuthCmdUser, "ReadAndExecute", "ContainerInherit,ObjectInherit", "None", "Allow") + $binAcl.AddAccessRule($execRule) + Set-Acl -Path $InstallDir -AclObject $binAcl + Write-Verbose " Granted $AuthCmdUser read+execute access to $InstallDir" + + Write-Host "" + + # Step 6: Install uninstall script + Write-Host "[6/11] Installing uninstall script..." -ForegroundColor Yellow + $uninstallPath = Install-UninstallScript -InstallDir $InstallDir ` + -Version $InstallVersion ` + -GitHubRepo $GitHubRepo + if ($uninstallPath) { + Write-Host " Installed: $uninstallPath" -ForegroundColor Green + } else { + Write-Host " Uninstall script not available (optional)" -ForegroundColor Yellow + } + Write-Host "" + + # Step 7: Create configuration + Write-Host "[7/11] Creating configuration..." -ForegroundColor Yellow + New-OpksshConfiguration -ConfigPath $ConfigPath -AuthCmdUser $AuthCmdUser | Out-Null + Write-Host " Configuration: $ConfigPath" -ForegroundColor Green + Write-Host "" + + # Step 8: Configure permissions using opkssh + Write-Host "[8/11] Configuring file permissions..." -ForegroundColor Yellow + $permArgs = @("permissions", "install") + if ($Verbose) { + $permArgs += "-v" + } + + try { + & $binaryPath $permArgs + + if ($LASTEXITCODE -ne 0) { + Write-Warning "Permission configuration returned non-zero exit code but continuing..." + } else { + Write-Host " Permissions configured successfully" -ForegroundColor Green + } + } catch { + Write-Warning "Failed to configure permissions: $($_.Exception.Message)" + Write-Warning "You may need to run: & '$binaryPath' permissions fix" + } + Write-Host "" + + # Step 9: Configure sshd + Write-Host "[9/11] Configuring OpenSSH Server..." -ForegroundColor Yellow + $sshdConfigResult = Set-SshdConfiguration -BinaryPath $binaryPath ` + -AuthCmdUser $AuthCmdUser ` + -OverwriteConfig $OverwriteConfig + if (-not $sshdConfigResult) { + throw "Failed to configure sshd_config" + } + Write-Host " sshd_config updated" -ForegroundColor Green + Write-Host "" + + # Step 10: Add to PATH + # Must happen BEFORE restarting sshd so that the service process + # picks up the new PATH. Older OpenSSH versions (8.x, shipped with + # Windows Server 2022) cache the system environment at service start; + # SSH sessions inherit that cached PATH. + Write-Host "[10/11] Adding to system PATH..." -ForegroundColor Yellow + Add-OpksshToPath -InstallDir $InstallDir | Out-Null + Write-Host "" + + # Step 11: Restart sshd service and log + Write-Host "[11/11] Restarting OpenSSH Server..." -ForegroundColor Yellow + Restart-SshdService -NoRestart $NoSshdRestart | Out-Null + if (-not $NoSshdRestart) { + Write-Host " Service restarted" -ForegroundColor Green + } else { + Write-Host " Service restart skipped" -ForegroundColor Yellow + } + Write-Host "" + + $logPath = Join-Path $ConfigPath "logs\opkssh-install.log" + Write-InstallationLog -LogPath $logPath ` + -BinaryPath $binaryPath ` + -InstallParams @{ + InstallVersion = $InstallVersion + InstallFrom = $InstallFrom + NoRestart = $NoSshdRestart + ConfigPath = $ConfigPath + } + Write-Host " Installation log: $logPath" -ForegroundColor Green + Write-Host "" + + # Success message + Write-Host "========================================" -ForegroundColor Green + Write-Host " Installation Successful!" -ForegroundColor Green + Write-Host "========================================" -ForegroundColor Green + Write-Host "" + Write-Host "Next steps:" -ForegroundColor Cyan + Write-Host " 1. Authorize users to access this server:" -ForegroundColor White + Write-Host " & '$binaryPath' add " -ForegroundColor Gray + Write-Host "" + Write-Host " 2. Example - Allow alice@gmail.com to SSH as 'Administrator':" -ForegroundColor White + Write-Host " & '$binaryPath' add Administrator alice@gmail.com google" -ForegroundColor Gray + Write-Host "" + + if ($uninstallPath) { + Write-Host " 3. To uninstall opkssh:" -ForegroundColor White + Write-Host " & '$uninstallPath'" -ForegroundColor Gray + Write-Host "" + } + + Write-Host "Documentation: https://github.com/openpubkey/opkssh" -ForegroundColor White + Write-Host "" + + } catch { + Write-Host "" + Write-Host "========================================" -ForegroundColor Red + Write-Host " Installation Failed" -ForegroundColor Red + Write-Host "========================================" -ForegroundColor Red + Write-Host "" + Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red + Write-Host "" + Write-Host "Stack Trace:" -ForegroundColor Red + Write-Host $_.ScriptStackTrace -ForegroundColor Gray + Write-Host "" + + # Log error + $errorLogPath = Join-Path $ConfigPath "logs\opkssh-install-error.log" + try { + $errorDir = Split-Path $errorLogPath -Parent + if (-not (Test-Path $errorDir)) { + New-Item -ItemType Directory -Path $errorDir -Force | Out-Null + } + + $errorDetails = @" + +======================================== +Installation Error Log +======================================== +Timestamp: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") +Error Message: $($_.Exception.Message) +Stack Trace: +$($_.ScriptStackTrace) +======================================== + +"@ + Add-Content -Path $errorLogPath -Value $errorDetails + Write-Host "Error details logged to: $errorLogPath" -ForegroundColor Yellow + } catch { + # Ignore logging errors + } + + throw + } +} + +#endregion Main Installation Logic + +# Execute main installation (only when not dot-sourced) +if ($MyInvocation.InvocationName -ne '.') { + try { + Install-OpksshServer + } + catch { + # Final catch to improve error display. + $_.Exception | Write-Host -ForegroundColor Red + } +} diff --git a/scripts/windows/Test-OpksshInstallation.ps1 b/scripts/windows/Test-OpksshInstallation.ps1 new file mode 100644 index 00000000..c0deb787 --- /dev/null +++ b/scripts/windows/Test-OpksshInstallation.ps1 @@ -0,0 +1,372 @@ +#Requires -Version 5.1 + +<# +.SYNOPSIS + Tests the opkssh installation on Windows Server. + +.DESCRIPTION + This script validates that opkssh has been correctly installed and configured + on a Windows Server. It performs various checks to ensure all components are + in place and properly configured. + +.PARAMETER Verbose + Show detailed information about each test. + +.EXAMPLE + .\Test-OpksshInstallation.ps1 + +.EXAMPLE + .\Test-OpksshInstallation.ps1 -Verbose + +.NOTES + This script can be run by any user (does not require Administrator privileges). +#> + +[CmdletBinding()] +param() + +# Test results tracking +$script:PassedTests = 0 +$script:FailedTests = 0 +$script:WarningTests = 0 +$script:TotalTests = 0 + +function Write-TestResult { + <# + .SYNOPSIS + Writes a formatted test result. + #> + param( + [Parameter(Mandatory=$true)] + [string]$TestName, + + [Parameter(Mandatory=$true)] + [ValidateSet('Pass', 'Fail', 'Warning', 'Info')] + [string]$Result, + + [Parameter()] + [string]$Message = "" + ) + + $script:TotalTests++ + + $symbol = switch ($Result) { + 'Pass' { "PASS"; $script:PassedTests++; $color = 'Green' } + 'Fail' { "FAIL"; $script:FailedTests++; $color = 'Red' } + 'Warning' { "WARN"; $script:WarningTests++; $color = 'Yellow' } + 'Info' { "INFO"; $color = 'Cyan' } + } + + $resultText = "[$symbol] $TestName" + Write-Host $resultText -ForegroundColor $color + + if ($Message) { + Write-Host " $Message" -ForegroundColor Gray + } +} + +function Test-BinaryInstallation { + <# + .SYNOPSIS + Tests if opkssh binary is installed. + #> + param() + + Write-Host "`nBinary Installation:" -ForegroundColor Cyan + + # Test 1: Binary exists + $binaryPath = "C:\Program Files\opkssh\opkssh.exe" + if (Test-Path $binaryPath) { + Write-TestResult -TestName "Binary exists" -Result Pass -Message $binaryPath + + # Test 2: Binary is executable + try { + $version = & $binaryPath --version 2>&1 + Write-TestResult -TestName "Binary is executable" -Result Pass -Message "Version: $version" + } catch { + Write-TestResult -TestName "Binary is executable" -Result Fail -Message $_.Exception.Message + } + + # Test 3: Binary size check + $fileSize = (Get-Item $binaryPath).Length + if ($fileSize -gt 1MB) { + Write-TestResult -TestName "Binary size reasonable" -Result Pass -Message "$([math]::Round($fileSize/1MB, 2)) MB" + } else { + Write-TestResult -TestName "Binary size reasonable" -Result Warning -Message "File seems small: $([math]::Round($fileSize/1KB, 2)) KB" + } + + } else { + Write-TestResult -TestName "Binary exists" -Result Fail -Message "Not found at $binaryPath" + Write-TestResult -TestName "Binary is executable" -Result Fail -Message "Skipped (binary not found)" + Write-TestResult -TestName "Binary size reasonable" -Result Fail -Message "Skipped (binary not found)" + } + + # Test 4: Binary in PATH + $pathDirs = $env:Path -split ';' + if ($pathDirs -contains "C:\Program Files\opkssh") { + Write-TestResult -TestName "Binary in system PATH" -Result Pass + } else { + Write-TestResult -TestName "Binary in system PATH" -Result Warning -Message "Not in PATH (you may need to restart your shell)" + } +} + +function Test-ConfigurationFiles { + <# + .SYNOPSIS + Tests if configuration files and directories exist. + #> + param() + + Write-Host "`nConfiguration Files:" -ForegroundColor Cyan + + $configBase = "C:\ProgramData\opk" + + # Test 1: Config directory exists + if (Test-Path $configBase) { + Write-TestResult -TestName "Config directory exists" -Result Pass -Message $configBase + } else { + Write-TestResult -TestName "Config directory exists" -Result Fail -Message "Not found at $configBase" + return + } + + # Test 2: Required files + $requiredFiles = @{ + 'auth_id' = Join-Path $configBase "auth_id" + 'providers' = Join-Path $configBase "providers" + 'config.yml' = Join-Path $configBase "config.yml" + } + + foreach ($file in $requiredFiles.GetEnumerator()) { + if (Test-Path $file.Value) { + Write-TestResult -TestName "$($file.Key) file exists" -Result Pass + } else { + Write-TestResult -TestName "$($file.Key) file exists" -Result Fail -Message "Not found at $($file.Value)" + } + } + + # Test 3: Required directories + $requiredDirs = @{ + 'policy.d' = Join-Path $configBase "policy.d" + 'logs' = Join-Path $configBase "logs" + } + + foreach ($dir in $requiredDirs.GetEnumerator()) { + if (Test-Path $dir.Value) { + Write-TestResult -TestName "$($dir.Key) directory exists" -Result Pass + } else { + Write-TestResult -TestName "$($dir.Key) directory exists" -Result Warning -Message "Not found at $($dir.Value)" + } + } + + # Test 4: Providers file content + $providersPath = Join-Path $configBase "providers" + if (Test-Path $providersPath) { + $providersContent = Get-Content $providersPath -Raw + if ($providersContent -match 'accounts.google.com|login.microsoftonline.com') { + Write-TestResult -TestName "Providers file has content" -Result Pass + } else { + Write-TestResult -TestName "Providers file has content" -Result Warning -Message "File exists but may be empty" + } + } +} + +function Test-SshdConfiguration { + <# + .SYNOPSIS + Tests OpenSSH Server configuration. + #> + param() + + Write-Host "`nOpenSSH Server Configuration:" -ForegroundColor Cyan + + # Test 1: sshd service exists + $sshdService = Get-Service -Name sshd -ErrorAction SilentlyContinue + if ($sshdService) { + Write-TestResult -TestName "sshd service exists" -Result Pass + + # Test 2: sshd service is running + if ($sshdService.Status -eq 'Running') { + Write-TestResult -TestName "sshd service is running" -Result Pass + } else { + Write-TestResult -TestName "sshd service is running" -Result Warning -Message "Service is $($sshdService.Status)" + } + + # Test 3: sshd service startup type + if ($sshdService.StartType -eq 'Automatic') { + Write-TestResult -TestName "sshd starts automatically" -Result Pass + } else { + Write-TestResult -TestName "sshd starts automatically" -Result Warning -Message "StartType is $($sshdService.StartType)" + } + } else { + Write-TestResult -TestName "sshd service exists" -Result Fail -Message "Service not found" + Write-TestResult -TestName "sshd service is running" -Result Fail -Message "Skipped (service not found)" + Write-TestResult -TestName "sshd starts automatically" -Result Fail -Message "Skipped (service not found)" + } + + # Test 4: sshd_config exists + $sshdConfigPath = "C:\ProgramData\ssh\sshd_config" + if (Test-Path $sshdConfigPath) { + Write-TestResult -TestName "sshd_config exists" -Result Pass + + # Test 5: sshd_config has AuthorizedKeysCommand + $configContent = Get-Content $sshdConfigPath -Raw + if ($configContent -match 'AuthorizedKeysCommand.*opkssh.*verify') { + Write-TestResult -TestName "AuthorizedKeysCommand configured" -Result Pass + } else { + Write-TestResult -TestName "AuthorizedKeysCommand configured" -Result Fail -Message "opkssh not found in AuthorizedKeysCommand" + } + + # Test 6: sshd_config has AuthorizedKeysCommandUser + if ($configContent -match 'AuthorizedKeysCommandUser') { + Write-TestResult -TestName "AuthorizedKeysCommandUser configured" -Result Pass + } else { + Write-TestResult -TestName "AuthorizedKeysCommandUser configured" -Result Fail -Message "Not found in sshd_config" + } + + # Test 7: Backup exists + $backupFiles = Get-ChildItem "C:\ProgramData\ssh\sshd_config.backup.*" -ErrorAction SilentlyContinue + if ($backupFiles) { + $latestBackup = $backupFiles | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + Write-TestResult -TestName "sshd_config backup exists" -Result Pass -Message "Latest: $($latestBackup.Name)" + } else { + Write-TestResult -TestName "sshd_config backup exists" -Result Warning -Message "No backup found" + } + } else { + Write-TestResult -TestName "sshd_config exists" -Result Fail -Message "Not found at $sshdConfigPath" + } +} + +function Test-Permissions { + <# + .SYNOPSIS + Tests file permissions (requires admin). + #> + param() + + Write-Host "`nFile Permissions:" -ForegroundColor Cyan + + # Check if running as admin + $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) + $isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + + if (-not $isAdmin) { + Write-TestResult -TestName "Permission checks" -Result Warning -Message "Run as Administrator for detailed permission checks" + return + } + + $configPath = "C:\ProgramData\opk" + + if (Test-Path $configPath) { + try { + $acl = Get-Acl $configPath + + # Check if SYSTEM has access + $systemAccess = $acl.Access | Where-Object { $_.IdentityReference -like "*SYSTEM*" } + if ($systemAccess) { + Write-TestResult -TestName "SYSTEM has access" -Result Pass + } else { + Write-TestResult -TestName "SYSTEM has access" -Result Warning -Message "SYSTEM access not found" + } + + # Check if Administrators have access + $adminAccess = $acl.Access | Where-Object { $_.IdentityReference -like "*Administrators*" } + if ($adminAccess) { + Write-TestResult -TestName "Administrators have access" -Result Pass + } else { + Write-TestResult -TestName "Administrators have access" -Result Warning -Message "Administrators access not found" + } + + } catch { + Write-TestResult -TestName "Permission checks" -Result Warning -Message $_.Exception.Message + } + } +} + +function Test-InstallationLog { + <# + .SYNOPSIS + Checks if installation log exists. + #> + param() + + Write-Host "`nInstallation Logs:" -ForegroundColor Cyan + + $logPath = "C:\ProgramData\opk\logs\opkssh-install.log" + if (Test-Path $logPath) { + $logInfo = Get-Item $logPath + Write-TestResult -TestName "Installation log exists" -Result Pass -Message "Last modified: $($logInfo.LastWriteTime)" + + # Show last few lines + $lastLines = Get-Content $logPath -Tail 5 + if ($Verbose) { + Write-Host "`n Last log entries:" -ForegroundColor Gray + $lastLines | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } + } + } else { + Write-TestResult -TestName "Installation log exists" -Result Warning -Message "Not found at $logPath" + } + + # Check for error log + $errorLogPath = "C:\ProgramData\opk\logs\opkssh-install-error.log" + if (Test-Path $errorLogPath) { + Write-TestResult -TestName "Error log check" -Result Warning -Message "Error log exists - installation may have had issues" + } else { + Write-TestResult -TestName "Error log check" -Result Pass -Message "No error log found" + } +} + +function Test-NetworkConnectivity { + <# + .SYNOPSIS + Tests network connectivity to OpenID Providers. + #> + param() + + Write-Host "`nNetwork Connectivity:" -ForegroundColor Cyan + + $providers = @( + @{ Name = "Google"; Url = "https://accounts.google.com/.well-known/openid-configuration" } + @{ Name = "Microsoft"; Url = "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration" } + @{ Name = "GitLab"; Url = "https://gitlab.com/.well-known/openid-configuration" } + ) + + foreach ($provider in $providers) { + try { + $null = Invoke-WebRequest -Uri $provider.Url -Method Head -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop + Write-TestResult -TestName "$($provider.Name) reachable" -Result Pass + } catch { + Write-TestResult -TestName "$($provider.Name) reachable" -Result Warning -Message "Cannot reach $($provider.Url)" + } + } +} + +# Main execution +Write-Host "" +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " opkssh Installation Test Suite" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan + +Test-BinaryInstallation +Test-ConfigurationFiles +Test-SshdConfiguration +Test-Permissions +Test-InstallationLog +Test-NetworkConnectivity + +# Summary +Write-Host "`n========================================" -ForegroundColor Cyan +Write-Host " Test Summary" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Total Tests: $script:TotalTests" -ForegroundColor White +Write-Host "Passed: $script:PassedTests" -ForegroundColor Green +Write-Host "Failed: $script:FailedTests" -ForegroundColor Red +Write-Host "Warnings: $script:WarningTests" -ForegroundColor Yellow +Write-Host "" + +if ($script:FailedTests -eq 0) { + Write-Host "All critical tests passed!" -ForegroundColor Green + exit 0 +} else { + Write-Host "Some tests failed. Please review the results above." -ForegroundColor Red + exit 1 +} diff --git a/scripts/windows/Uninstall-OpksshServer.ps1 b/scripts/windows/Uninstall-OpksshServer.ps1 new file mode 100644 index 00000000..5ff4c155 --- /dev/null +++ b/scripts/windows/Uninstall-OpksshServer.ps1 @@ -0,0 +1,466 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator + +<# +.SYNOPSIS + Uninstalls opkssh from Windows Server. + +.DESCRIPTION + This script removes opkssh from a Windows Server installation by: + - Restoring the original sshd_config + - Removing the opkssh binary + - Removing configuration files + - Removing the opkssh user (if created) + - Removing from system PATH + +.PARAMETER KeepConfig + Keep configuration files in C:\ProgramData\opk\ for potential reinstallation. + +.PARAMETER KeepLogs + Keep log files in C:\ProgramData\opk\logs\. + +.PARAMETER NoSshdRestart + Do not restart the sshd service after uninstallation. + +.PARAMETER Force + Skip confirmation prompts. + +.EXAMPLE + .\Uninstall-OpksshServer.ps1 + +.EXAMPLE + .\Uninstall-OpksshServer.ps1 -KeepConfig -KeepLogs + +.EXAMPLE + .\Uninstall-OpksshServer.ps1 -Force + +.NOTES + Requires Administrator privileges. +#> + +[CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='High')] +param( + [Parameter(HelpMessage="Keep configuration files")] + [switch]$KeepConfig, + + [Parameter(HelpMessage="Keep log files")] + [switch]$KeepLogs, + + [Parameter(HelpMessage="Do not restart sshd service")] + [switch]$NoSshdRestart, + + [Parameter(HelpMessage="Skip confirmation prompts")] + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +function Write-UninstallLog { + <# + .SYNOPSIS + Writes a message to the console and optionally to a log file. + #> + param( + [Parameter(Mandatory=$true)] + [string]$Message, + + [Parameter()] + [ValidateSet('Info', 'Warning', 'Error', 'Success')] + [string]$Level = 'Info' + ) + + switch ($Level) { + 'Info' { Write-Host $Message } + 'Warning' { Write-Warning $Message } + 'Error' { Write-Error $Message } + 'Success' { Write-Host $Message -ForegroundColor Green } + } +} + +function Restore-SshdConfiguration { + <# + .SYNOPSIS + Restores the original sshd_config from backup. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param() + + $sshdConfigPath = "C:\ProgramData\ssh\sshd_config" + + if (-not (Test-Path $sshdConfigPath)) { + Write-UninstallLog "sshd_config not found at $sshdConfigPath" -Level Warning + return $false + } + + # Find the most recent backup + $backupFiles = Get-ChildItem "C:\ProgramData\ssh\sshd_config.backup.*" -ErrorAction SilentlyContinue + + if (-not $backupFiles) { + Write-UninstallLog "No sshd_config backup found. Manual configuration restoration required." -Level Warning + Write-UninstallLog "Please edit $sshdConfigPath and remove the AuthorizedKeysCommand lines." -Level Warning + return $false + } + + $latestBackup = $backupFiles | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + + if ($PSCmdlet.ShouldProcess($sshdConfigPath, "Restore from backup: $($latestBackup.Name)")) { + try { + Copy-Item $latestBackup.FullName $sshdConfigPath -Force + Write-UninstallLog " Restored sshd_config from $($latestBackup.Name)" -Level Success + return $true + } catch { + Write-UninstallLog "Failed to restore sshd_config: $($_.Exception.Message)" -Level Error + return $false + } + } + + return $true +} + +function Remove-OpksshBinary { + <# + .SYNOPSIS + Removes the opkssh binary and installation directory. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param() + + $installDir = "C:\Program Files\opkssh" + + if (-not (Test-Path $installDir)) { + Write-Verbose "Installation directory not found: $installDir" + return $true + } + + # Check if this script is running from the installation directory + $scriptPath = $PSCommandPath + $scriptInInstallDir = $scriptPath -and (Split-Path $scriptPath -Parent) -eq $installDir + + if ($PSCmdlet.ShouldProcess($installDir, "Remove directory and contents")) { + try { + if ($scriptInInstallDir) { + # If this script is in the install directory, schedule it for deletion + # and remove other files now + Write-UninstallLog " Scheduling uninstall script for deletion after exit" -Level Info + + # Remove all files except this script + Get-ChildItem $installDir -File | Where-Object { $_.FullName -ne $scriptPath } | ForEach-Object { + Remove-Item $_.FullName -Force -ErrorAction Stop + } + + # Create a self-delete PowerShell script + $cleanupScript = @" +Start-Sleep -Seconds 2 +Remove-Item -Path '$scriptPath' -Force -ErrorAction SilentlyContinue +Remove-Item -Path '$installDir' -Force -ErrorAction SilentlyContinue +Remove-Item -Path `$PSCommandPath -Force -ErrorAction SilentlyContinue +"@ + $cleanupPath = [System.IO.Path]::GetTempFileName() + ".ps1" + $cleanupScript | Out-File -FilePath $cleanupPath -Encoding UTF8 -Force + + # Schedule the cleanup script to run in a new PowerShell process + Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", "`"$cleanupPath`"" -WindowStyle Hidden + + Write-UninstallLog " Removed opkssh binary from $installDir (cleanup pending)" -Level Success + } else { + # Script is not in the install directory, safe to delete everything + Remove-Item $installDir -Recurse -Force -ErrorAction Stop + Write-UninstallLog " Removed opkssh binary from $installDir" -Level Success + } + return $true + } catch { + Write-UninstallLog "Failed to remove $installDir`: $($_.Exception.Message)" -Level Error + return $false + } + } + + return $true +} + +function Remove-OpksshConfiguration { + <# + .SYNOPSIS + Removes opkssh configuration files and directories. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [bool]$KeepConfig = $false, + [bool]$KeepLogs = $false + ) + + $configPath = "C:\ProgramData\opk" + + if (-not (Test-Path $configPath)) { + Write-Verbose "Configuration directory not found: $configPath" + return $true + } + + if ($KeepConfig) { + Write-UninstallLog " Keeping configuration files (KeepConfig specified)" -Level Warning + + # Only remove logs if KeepLogs is not set + if (-not $KeepLogs) { + $logsPath = Join-Path $configPath "logs" + if (Test-Path $logsPath) { + if ($PSCmdlet.ShouldProcess($logsPath, "Remove logs directory")) { + try { + Remove-Item $logsPath -Recurse -Force -ErrorAction Stop + Write-UninstallLog " Removed logs from $logsPath" -Level Success + } catch { + Write-UninstallLog "Failed to remove logs: $($_.Exception.Message)" -Level Warning + } + } + } + } + + return $true + } + + # Create a final backup of configuration before removing + if ($PSCmdlet.ShouldProcess($configPath, "Backup before removal")) { + try { + $timestamp = Get-Date -Format "yyyyMMddHHmmss" + $backupPath = "C:\ProgramData\opk-backup-$timestamp" + Copy-Item $configPath $backupPath -Recurse -Force + Write-UninstallLog " Created backup at $backupPath" -Level Info + } catch { + Write-UninstallLog "Failed to create backup: $($_.Exception.Message)" -Level Warning + } + } + + if ($PSCmdlet.ShouldProcess($configPath, "Remove configuration directory")) { + try { + Remove-Item $configPath -Recurse -Force -ErrorAction Stop + Write-UninstallLog " Removed configuration from $configPath" -Level Success + return $true + } catch { + Write-UninstallLog "Failed to remove $configPath`: $($_.Exception.Message)" -Level Error + return $false + } + } + + return $true +} + +function Remove-OpksshUser { + <# + .SYNOPSIS + Removes the opkssh user account if it exists. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param() + + $username = "opksshuser" + + $user = Get-LocalUser -Name $username -ErrorAction SilentlyContinue + + if (-not $user) { + Write-Verbose "User '$username' does not exist" + return $true + } + + if ($PSCmdlet.ShouldProcess($username, "Remove local user")) { + try { + Remove-LocalUser -Name $username -ErrorAction Stop + Write-UninstallLog " Removed user: $username" -Level Success + return $true + } catch { + Write-UninstallLog "Failed to remove user '$username': $($_.Exception.Message)" -Level Warning + return $false + } + } + + return $true +} + +function Remove-OpksshFromPath { + <# + .SYNOPSIS + Removes opkssh installation directory from system PATH without expanding environment variables. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param() + + $installDir = "C:\Program Files\opkssh" + + if ($PSCmdlet.ShouldProcess("System PATH", "Remove $installDir")) { + try { + # Use Registry to preserve environment variable expansion (e.g., %SystemRoot%) + $keyName = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($keyName, $true) + try { + # Get current PATH without expanding environment variables + $currentPathFolders = $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') -split [IO.Path]::PathSeparator + + # Normalize folder to remove + $normalizedInstallDir = $installDir.TrimEnd([IO.Path]::DirectorySeparatorChar) + + # Check if in PATH + $foundInPath = $currentPathFolders | Where-Object { + $_.TrimEnd([IO.Path]::DirectorySeparatorChar) -eq $normalizedInstallDir + } + + if (-not $foundInPath) { + Write-Verbose "Installation directory not in PATH" + return $true + } + + # Filter out the folder to remove (case-insensitive), preserving original order + $filteredPathFolders = $currentPathFolders | + Where-Object { + $normalizedFolder = $_.TrimEnd([IO.Path]::DirectorySeparatorChar) + $normalizedFolder -ne $normalizedInstallDir + } + + # Build new PATH and save it + $newPath = $filteredPathFolders -join [IO.Path]::PathSeparator + $key.SetValue('Path', $newPath, 'ExpandString') + + Write-UninstallLog " Removed from system PATH" -Level Success + return $true + } finally { + if ($null -ne $key) { + $key.Dispose() + } + } + } catch { + Write-UninstallLog "Failed to update PATH: $($_.Exception.Message)" -Level Warning + return $false + } + } + + return $true +} + +function Restart-SshdService { + <# + .SYNOPSIS + Restarts the OpenSSH Server service. + #> + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [bool]$NoRestart = $false + ) + + if ($NoRestart) { + Write-Warning "Skipping sshd service restart (NoRestart specified)" + Write-Warning "You must manually restart the sshd service:" + Write-Warning " Restart-Service sshd" + return $true + } + + if ($PSCmdlet.ShouldProcess("sshd", "Restart service")) { + try { + Restart-Service sshd -Force -ErrorAction Stop + + Start-Sleep -Seconds 2 + + $service = Get-Service sshd + if ($service.Status -eq 'Running') { + Write-UninstallLog " sshd service restarted successfully" -Level Success + return $true + } else { + throw "Service is in state: $($service.Status)" + } + } catch { + Write-UninstallLog "Failed to restart sshd service: $($_.Exception.Message)" -Level Warning + Write-Warning "Please restart the service manually: Restart-Service sshd" + return $false + } + } + + return $true +} + +# Main uninstallation logic +try { + Write-Host "" + Write-Host "========================================" -ForegroundColor Cyan + Write-Host " opkssh Uninstallation for Windows" -ForegroundColor Cyan + Write-Host "========================================" -ForegroundColor Cyan + Write-Host "" + + # Confirmation (unless -Force is specified) + if (-not $Force) { + Write-Host "This will remove opkssh from your system." -ForegroundColor Yellow + Write-Host "" + Write-Host "The following items will be removed:" -ForegroundColor Yellow + Write-Host " - opkssh binary (C:\Program Files\opkssh\)" -ForegroundColor White + + if (-not $KeepConfig) { + Write-Host " - Configuration files (C:\ProgramData\opk\)" -ForegroundColor White + } else { + Write-Host " - Configuration files (KEPT - KeepConfig specified)" -ForegroundColor Green + } + + Write-Host " - sshd_config modifications (restored from backup)" -ForegroundColor White + Write-Host " - opksshuser account (if exists)" -ForegroundColor White + Write-Host " - System PATH entry" -ForegroundColor White + Write-Host "" + + $confirmation = Read-Host "Are you sure you want to continue? (yes/no)" + if ($confirmation -ne 'yes') { + Write-Host "Uninstallation cancelled." -ForegroundColor Yellow + exit 0 + } + Write-Host "" + } + + # Step 1: Restore sshd configuration + Write-Host "[1/6] Restoring sshd_config..." -ForegroundColor Yellow + Restore-SshdConfiguration | Out-Null + Write-Host "" + + # Step 2: Remove binary + Write-Host "[2/6] Removing opkssh binary..." -ForegroundColor Yellow + Remove-OpksshBinary | Out-Null + Write-Host "" + + # Step 3: Remove configuration + Write-Host "[3/6] Removing configuration..." -ForegroundColor Yellow + Remove-OpksshConfiguration -KeepConfig $KeepConfig -KeepLogs $KeepLogs | Out-Null + Write-Host "" + + # Step 4: Remove user + Write-Host "[4/6] Removing opkssh user..." -ForegroundColor Yellow + Remove-OpksshUser | Out-Null + Write-Host "" + + # Step 5: Remove from PATH + Write-Host "[5/6] Removing from system PATH..." -ForegroundColor Yellow + Remove-OpksshFromPath | Out-Null + Write-Host "" + + # Step 6: Restart sshd + Write-Host "[6/6] Restarting sshd service..." -ForegroundColor Yellow + Restart-SshdService -NoRestart $NoSshdRestart | Out-Null + Write-Host "" + + # Success message + Write-Host "========================================" -ForegroundColor Green + Write-Host " Uninstallation Complete!" -ForegroundColor Green + Write-Host "========================================" -ForegroundColor Green + Write-Host "" + + if ($KeepConfig) { + Write-Host "Configuration files preserved at: C:\ProgramData\opk\" -ForegroundColor Cyan + Write-Host "" + } + + Write-Host "opkssh has been removed from your system." -ForegroundColor White + Write-Host "" + +} catch { + Write-Host "" + Write-Host "========================================" -ForegroundColor Red + Write-Host " Uninstallation Failed" -ForegroundColor Red + Write-Host "========================================" -ForegroundColor Red + Write-Host "" + Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red + Write-Host "" + Write-Host "Stack Trace:" -ForegroundColor Red + Write-Host $_.ScriptStackTrace -ForegroundColor Gray + Write-Host "" + + throw +} diff --git a/scripts/windows/test/Install-OpksshServer.Tests.ps1 b/scripts/windows/test/Install-OpksshServer.Tests.ps1 new file mode 100644 index 00000000..6715649f --- /dev/null +++ b/scripts/windows/test/Install-OpksshServer.Tests.ps1 @@ -0,0 +1,85 @@ +# Requires -Version 5.1 + +BeforeAll { + $scriptPath = Join-Path $PSScriptRoot "..\Install-OpksshServer.ps1" + + # Use AST parsing to extract only the functions we need for testing + # without executing the script (which has #Requires -RunAsAdministrator) + $scriptContent = Get-Content -Path $scriptPath -Raw + + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($scriptContent, [ref]$tokens, [ref]$errors) + + $functionsToLoad = @('Set-SshdConfiguration', 'Write-Log') + foreach ($funcName in $functionsToLoad) { + $funcAst = $ast.Find( + { + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $funcName + }.GetNewClosure(), + $true + ) + + if (-not $funcAst) { + throw "Could not find function '$funcName' in $scriptPath." + } + + . ([scriptblock]::Create($funcAst.Extent.Text)) + } +} + +Describe "Set-SshdConfiguration" { + # Tests that Set-SshdConfiguration correctly detects, preserves, and + # updates AuthorizedKeysCommand/AuthorizedKeysCommandUser in sshd_config. + It "returns true when sshd_config already matches desired configuration" { + $tempPath = Join-Path $env:TEMP "sshd_config.test.$([guid]::NewGuid().ToString())" + $binaryPath = "C:\Program Files\opkssh\opkssh.exe" + $authUser = "opksshuser" + $quotedBinary = "`"$binaryPath`"" + + @( + "# Comment", + "AuthorizedKeysCommand $quotedBinary verify %u %k %t", + "AuthorizedKeysCommandUser $authUser" + ) | Set-Content -Path $tempPath -Force + + $result = Set-SshdConfiguration -BinaryPath $binaryPath -AuthCmdUser $authUser -SshdConfigPath $tempPath + $result | Should -BeTrue + + $final = Get-Content -Path $tempPath -Raw + $final | Should -Match $([regex]::Escape("AuthorizedKeysCommand $quotedBinary verify %u %k %t")) + $final | Should -Match $([regex]::Escape("AuthorizedKeysCommandUser $authUser")) + } + + It "returns false when a different AuthorizedKeysCommand is present and overwrite is not set" { + $tempPath = Join-Path $env:TEMP "sshd_config.test.$([guid]::NewGuid().ToString())" + @( + 'AuthorizedKeysCommand "C:\Other\opkssh.exe" verify %u %k %t', + 'AuthorizedKeysCommandUser otheruser' + ) | Set-Content -Path $tempPath -Force + + $result = Set-SshdConfiguration -BinaryPath "C:\Program Files\opkssh\opkssh.exe" -AuthCmdUser "opksshuser" -SshdConfigPath $tempPath + $result | Should -BeFalse + } + + It "overwrites existing configuration when -OverwriteConfig is set" { + $tempPath = Join-Path $env:TEMP "sshd_config.test.$([guid]::NewGuid().ToString())" + @( + 'AuthorizedKeysCommand "C:\Other\opkssh.exe" verify %u %k %t', + 'AuthorizedKeysCommandUser otheruser' + ) | Set-Content -Path $tempPath -Force + + $binaryPath = "C:\Program Files\opkssh\opkssh.exe" + $authUser = "opksshuser" + $quotedBinary = "`"$binaryPath`"" + + $result = Set-SshdConfiguration -BinaryPath $binaryPath -AuthCmdUser $authUser -OverwriteConfig $true -SshdConfigPath $tempPath + $result | Should -BeTrue + + $final = Get-Content -Path $tempPath -Raw + $final | Should -Match $([regex]::Escape("AuthorizedKeysCommand $quotedBinary verify %u %k %t")) + $final | Should -Match $([regex]::Escape("AuthorizedKeysCommandUser $authUser")) + } +} \ No newline at end of file diff --git a/sshcert/sshcert.go b/sshcert/sshcert.go index 31398173..2b3e7f93 100644 --- a/sshcert/sshcert.go +++ b/sshcert/sshcert.go @@ -87,7 +87,7 @@ func NewFromAuthorizedKey(certType string, certB64 string) (*SshCertSmuggler, er } else { sshCert, ok := certPubkey.(*ssh.Certificate) if !ok { - return nil, fmt.Errorf("parsed SSH authorized_key is not an SSH certificate") + return nil, fmt.Errorf("parsed SSH authorized_key is not an SSH certificate (got type %T, cert type %q)", certPubkey, certType) } opkcert := &SshCertSmuggler{ SshCert: sshCert, diff --git a/test/integration/add_test.go b/test/integration/add_test.go index 9b780866..733863d5 100644 --- a/test/integration/add_test.go +++ b/test/integration/add_test.go @@ -22,6 +22,7 @@ import ( "fmt" "io" "path" + "runtime" "strings" "testing" @@ -84,6 +85,10 @@ func CreateAuthIdFile(t *testing.T, container testcontainers.Container, filePath } func TestAdd(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Docker-based integration tests are not supported on Windows") + } + // Test adding an allowed principal to an opkssh policy issuer := fmt.Sprintf("http://oidc.local:%s/", issuerPort) diff --git a/test/integration/openssh_version_test.go b/test/integration/openssh_version_test.go index 21a537d5..3f96caac 100644 --- a/test/integration/openssh_version_test.go +++ b/test/integration/openssh_version_test.go @@ -21,6 +21,7 @@ package integration import ( "fmt" "io" + "runtime" "strings" "testing" "unicode" @@ -39,6 +40,10 @@ type OpenSSHVersionTest struct { } func TestOpenSSHVersionDetection(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Docker-based integration tests are not supported on Windows") + } + tests := []OpenSSHVersionTest{ { name: "Debian/Ubuntu", diff --git a/test/integration/ssh_test.go b/test/integration/ssh_test.go index 30729627..fb2acdff 100644 --- a/test/integration/ssh_test.go +++ b/test/integration/ssh_test.go @@ -33,6 +33,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strconv" "strings" "testing" @@ -228,6 +229,10 @@ func createZitadelOPKSshProvider(oidcContainerMappedPort int, authCallbackServer // Test cleanup functions are registered to cleanup the containers after the // test finishes. func spawnTestContainers(t *testing.T) (oidcContainer *testprovider.ExampleOpContainer, authCallbackRedirectPort int, serverContainer *ssh_server.SshServerContainer) { + if runtime.GOOS == "windows" { + t.Skip("Docker-based integration tests are not supported on Windows") + } + // Create local Docker network so that the example OIDC container and the // linux container (with SSH) can communicate with each other newNetwork, err := testcontainers.GenericNetwork(TestCtx, testcontainers.GenericNetworkRequest{