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 @@
[](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{