pax_global_header00006660000000000000000000000064147730721140014520gustar00rootroot0000000000000052 comment=fd59ad413fd6ef35bd356cc9c5063b1dd1e2bbec opkssh-0.4.0/000077500000000000000000000000001477307211400130305ustar00rootroot00000000000000opkssh-0.4.0/.gitattributes000066400000000000000000000001021477307211400157140ustar00rootroot00000000000000# Auto detect text files and perform LF normalization * text=auto opkssh-0.4.0/.github/000077500000000000000000000000001477307211400143705ustar00rootroot00000000000000opkssh-0.4.0/.github/release-drafter-config.yml000066400000000000000000000020411477307211400214200ustar00rootroot00000000000000name-template: "v$RESOLVED_VERSION" tag-template: "v$RESOLVED_VERSION" categories: - title: "πŸš€ Features" labels: - "feat" - "feature" - "enhancement" - title: "πŸ› Bug Fixes" labels: - "fix" - "bugfix" - "bug" - title: "🧰 Maintenance" labels: - "chore" change-template: "- $TITLE @$AUTHOR (#$NUMBER)" change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. version-resolver: major: labels: - "major" minor: labels: - "minor" patch: labels: - "patch" default: minor template: | ## Changes $CHANGES autolabeler: - label: "chore" files: - "*.md" branch: - '/docs{0,1}\/.+/' - '/tests{0,1}\/.+/' title: - "/docs/i" - "/test/i" - label: "bug" branch: - '/fix\/.+/' - '/revert\/.+/' title: - "/fix/i" - "/revert/i" - label: "feature" branch: - '/feature\/.+/' - '/feat\/.+/' title: - "/feat/i" opkssh-0.4.0/.github/workflows/000077500000000000000000000000001477307211400164255ustar00rootroot00000000000000opkssh-0.4.0/.github/workflows/ci.yml000066400000000000000000000024251477307211400175460ustar00rootroot00000000000000name: CI # Runs CI for pull requests and pushes to main on: pull_request: push: branches: - main # schedule: # - cron: 0 14 * * MON-FRI # Every weekday at 14:00 UTC jobs: # Check that binary can be built build: name: Build runs-on: ubuntu-latest timeout-minutes: 5 strategy: matrix: go-version: [1.23.x] steps: - name: Checkout uses: actions/checkout@v4 - name: Install Go uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} - name: Install dependencies run: go mod download - name: Build run: go build -v -o /dev/null # Run integration tests test: needs: build name: 'Integration Tests' runs-on: ubuntu-latest timeout-minutes: 8 strategy: matrix: os: [ubuntu, centos] env: OS_TYPE: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@v4 - name: Install Go uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: Install Docker uses: docker/setup-buildx-action@v2 - name: Install dependencies run: go mod download - name: Run integration tests run: go test -tags=integration ./test/integration -timeout=15m -count=1 -parallel=2 -v opkssh-0.4.0/.github/workflows/go.yml000066400000000000000000000014121477307211400175530ustar00rootroot00000000000000name: Go Checks on: pull_request: paths: - "**.go" - "go.mod" - "go.sum" jobs: golangci-linter: name: Run golangci linter runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: golangci-lint uses: golangci/golangci-lint-action@v6 with: version: v1.64.7 gotest: name: Run Tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: Download dependencies run: go mod download - name: Test run: go test ./...opkssh-0.4.0/.github/workflows/release-drafter.yml000066400000000000000000000007511477307211400222200ustar00rootroot00000000000000name: Release Drafter on: push: branches: - main pull_request: types: [opened, reopened, synchronize] permissions: contents: read jobs: update_release_draft: permissions: contents: write pull-requests: write runs-on: ubuntu-latest steps: - uses: release-drafter/release-drafter@v6 with: config-name: release-drafter-config.yml publish: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} opkssh-0.4.0/.github/workflows/release-opkssh.yml000066400000000000000000000023751477307211400221040ustar00rootroot00000000000000name: Build and Upload Release on: release: types: [published] permissions: contents: write jobs: build_and_upload: name: Build and Upload Binaries runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.23.x' - name: Extract version from tag run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - name: Build binaries run: | GOOS=linux GOARCH=amd64 go build -ldflags="-X main.Version=${VERSION}" -o opkssh-linux-amd64 GOOS=darwin GOARCH=amd64 go build -ldflags="-X main.Version=${VERSION}" -o opkssh-osx-amd64 GOOS=windows GOARCH=amd64 go build -ldflags="-X main.Version=${VERSION}" -o opkssh-windows-amd64.exe - name: Upload Release Assets uses: softprops/action-gh-release@v1 with: files: | opkssh-linux-amd64 opkssh-osx-amd64 opkssh-windows-amd64.exe tag_name: ${{ github.event.release.tag_name }} name: Release ${{ github.event.release.tag_name }} body: ${{ github.event.release.body }} draft: false prerelease: false opkssh-0.4.0/.github/workflows/staging.yml000066400000000000000000000011371477307211400206060ustar00rootroot00000000000000name: Go Checks on: push: branches: [ "main" ] permissions: contents: write pages: write jobs: codecov: name: Push to main test runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: Test run: go test ./... - name: Update coverage report uses: ncruces/go-coverage-report@v0 with: report: true chart: true amend: true if: github.event_name == 'push' continue-on-error: true opkssh-0.4.0/.gitignore000066400000000000000000000011061477307211400150160ustar00rootroot00000000000000# If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) # vendor/ # Go workspace file go.work configs/ .vscode/ # For policy files that exist while testing auth_id opk-ssh opk-ssh-login *DS_Store opkssh-0.4.0/.golangci.yml000066400000000000000000000004101477307211400154070ustar00rootroot00000000000000linters: enable: # golangci-lint defaults # ref: https://golangci-lint.run/usage/linters/#enabled-by-default - errcheck - gosimple - govet - ineffassign - staticcheck - unused # additional - misspell - gofmt fast: true run: timeout: 5mopkssh-0.4.0/CODE-OF-CONDUCT.md000066400000000000000000000002331477307211400154610ustar00rootroot00000000000000# OpenPubkey Code of Conduct Please see our [OpenPubkey Community Code of Conduct](https://github.com/openpubkey/community/blob/main/CODE-OF-CONDUCT.md). opkssh-0.4.0/CONTRIBUTING.md000066400000000000000000000116701477307211400152660ustar00rootroot00000000000000# Contributing to OpenPubkey Welcome to OpenPubkey SSH! We are so excited you are here. Thank you for your interest in contributing your time and expertise to the project. The following document details contribution guidelines. # Getting Started Whether you're addressing an open issue (or filing a new one), fixing a typo in our documentation, adding to core capabilities of the project, or introducing a new use case, anyone from the community is welcome here at OpenPubkey. ## Include Licensing at the Top of Each File At the top of each file in your commit, please ensure the following is captured in a comment: ` SPDX-License-Identifier: Apache-2.0 ` ## Sign Off on Your Commits Contributors are required to sign off on their commits. A sign off certifies that you wrote the associated change or have permission to submit it as an open-source patch. All submissions are bound by the [Developer's Certificate of Origin 1.1](https://developercertificate.org/) and [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ``` Developer's Certificate of Origin 1.1 By making a contribution to this project, I certify that: (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. (d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. ``` Your sign off can be added manually to your commit, i.e., `Signed-off-by: Jane Doe `. Then, you can create a signed off commit using the flag `-s` or `--signoff`: `$ git commit -s -m "This is my signed off commit."`. To verify that your commit was signed off, check your latest log output: ``` $ git log -1 commit Author: Jane Doe Date: Thurs Nov 9 06:14:13 2023 -0400 This is my signed off commit. Signed-off-by: Jane Doe ``` ## Pull Request (PR) Process OpenPubkey is managed from the `main` branch. To ensure your contribution is reviewed, all pull requests must be made against the `main` branch. PRs must include a brief summary of what the change is, any issues associated with the change, and any fixes the change addresses. Please include the relevant link(s) for any fixed issues. Pull requests do not have to pass all automated checks before being opened, but all checks must pass before merging. This can be useful if you need help figuring out why a required check is failing. Our automated PR checks verify that: 1. All unit tests pass, which can be done locally by running `go test ./...`. 2. The code has been formatted correctly, according to `go fmt`. 3. There are no obvious errors, according to `go vet`. ## Testing OpenPubkey Locally To build OpenPubkey, ensure you have Go version `>= 1.20` installed. To verify which version you have installed, try `go version`. To run the [Google example](https://github.com/openpubkey/openpubkey/tree/main/examples/google): 1. Navigate to the `examples/google/` directory. 2. Execute `go build` 3. Execute `./google login` to generate a valid PK token using Google as your OIDC provider. 4. Execute `./google sign` to use the PK token generated in (3) to sign a verifiable message. # Contributing Roles Contributors include anyone in the technical community who contributes code, documentation, or other technical artifacts to the OpenPubkey project. Committers are Contributors who have earned the ability to modify (β€œcommit”) source code, documentation or other technical artifacts in a project’s repository. Note that Committers are still required to submit pull requests. A Contributor may become a Committer by a majority approval of the existing Committers. A Committer may be removed by a majority approval of the other existing Committers. # Current Committers The Committers of OpenPubkey are: 1. Ethan Heilman (@EthanHeilman) 2. Jonny Stoten (@jonnystoten) 3. Lucie Mugnier (@lgmugnier) # Copyright By contributing to this repository, you agree to license your work under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). Any work contributed where you are not the original author must display a license header with the original author(s) and source.opkssh-0.4.0/LICENSE000066400000000000000000000260741477307211400140460ustar00rootroot00000000000000Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] 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. opkssh-0.4.0/README.md000066400000000000000000000302771477307211400143200ustar00rootroot00000000000000# opkssh (OpenPubkey SSH) [![Go Coverage](https://github.com/openpubkey/opkssh/wiki/coverage.svg)](https://raw.githack.com/wiki/openpubkey/opkssh/coverage.html) **opkssh** is a tool which enables ssh to be used with OpenID Connect allowing SSH access management via identities like `alice@example.com` instead of long-lived SSH keys. It does not replace ssh, but rather generates ssh public keys that contain PK Tokens and configures sshd to verify the PK Token in the ssh public key. These PK Tokens contain standard OpenID Connect ID Tokens. 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. Currently opkssh is compatible with Google, Microsoft/Azure and Gitlab OpenID Providers (OP). 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: ```bash opkssh login ``` This opens a browser window where you can authenticate to your OpenID Provider. This will generate an SSH key in `~/.ssh/id_ecdsas` which contains your OpenID Connect identity. Then you can ssh under this identity to any ssh server which is configured to use opkssh to authenticate users using their OpenID Connect identities. ```bash ssh user@example.com ``` ## Getting Started To ssh with opkssh, Alice first needs to install opkssh using homebrew or manually downloading the binary. ### Homebrew Install (OSX) To install with homebrew run: ```bash brew tap openpubkey/opkssh brew install opkssh ``` ### Manual Install (Windows, Linux, OSX) To install manually, download the opkssh binary and run it: | | Download URL | |-----------|--------------| |🐧 Linux | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-linux-amd64](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-linux-amd64) | |🍎 OSX | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-osx-amd64](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-osx-amd64) | | ⊞ Win | [github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-amd64.exe](https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-amd64.exe) | To install on Windows run: ```powershell curl https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-windows-amd64.exe -o opkssh.exe ``` To install on OSX run: ```bash curl -L https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-osx-amd64 -o opkssh; chmod +x opkssh ``` To install on linux run: ```bash curl -L https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-linux-amd64 -o opkssh; chmod +x opkssh ``` ### SSHing with opkssh After downloading opkssh, on OSX or Linux run: ```cmd opkssh login ``` on Windows run: ```powershell .\opkssh.exe login ``` This opens a browser window to select which OpenID Provider you want to authenticate against. After successfully authenticating opkssh generates an SSH public key in `~/.ssh/id_ecdsas` which contains your PK Token. By default this ssh key expires after 24 hours and you must run `opkssh login` to generate a new ssh key. Since your PK Token has been saved as an SSH key you can SSH as normal: ```bash ssh root@example.com ``` This works because SSH sends the SSH public key opkssh wrote in `~/.ssh/id_ecdsas` to the server and sshd running on the server will send the public key to the opkssh command to verify. This also works for other protocols that build on ssh like [sftp](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol) or ssh tunnels. ```bash sftp root@example.com ``` ### Installing on a Server 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 ``` This downloads the opkssh binary, installs it as `/usr/local/bin/opkssh`, and then configures ssh to use opkssh as an additional authentication mechanism. To allow a user, `alice@gmail.com`, to ssh to your server as `root`, run: ```bash sudo opkssh add root alice@gmail.com google ``` To allow a group, `ssh-users`, to ssh to your server as `root`, run: ```bash sudo opkssh add root oidc:groups:ssh-users google ``` ## How it works We use two features of SSH to make this work. First we leverage the fact that SSH public keys can be SSH certificates and SSH Certificates support arbitrary extensions. This allows us to smuggle your PK Token, which includes your ID Token, into the SSH authentication protocol via an extension field of the SSH certificate. Second, we use the `AuthorizedKeysCommand` configuration option in `sshd_config` (see [sshd_config manpage](https://man.openbsd.org/sshd_config.5#AuthorizedKeysCommand)) so that the SSH server will send the SSH certificate to an installed program that knows how to verify PK Tokens. ## What is supported ### Client support | OS | Supported | Tested | Version Tested | Possible Future Support | | -------- | -------- | ------- | ---------------------- |----------- | | Linux | βœ… | βœ… | Ubuntu 24.04.1 LTS | - | | OSX | βœ… | βœ… | OSX 15.3.2 (Sequoia) | - | | Windows11 | βœ… | βœ… | Windows 11 | - | ### Server support | OS | Supported | Tested | Version Tested | Possible Future Support | | -------- | -------- | ------- | ---------------------- |----------- | | Linux | βœ… | βœ… | Ubuntu 24.04.1 LTS | - | | Linux | βœ… | βœ… | Centos 9 | - | | OSX | ❌ | ❌ | - | Likely | | Windows11 | ❌ | ❌ | - | Likely | ## Configuration All opkssh configuration files are space delimited and live on the server. We currently have no configuration files on the client. ### `/etc/opk/providers` `/etc/opk/providers` contains a list of allowed OPs (OpenID Providers), a.k.a. IDPs. This file functions as an access control list that enables admins to determine the OpenID Providers and Client IDs they wish to rely on. - Column 1: Issuer URI of the OP - Column 2: Client-ID, the audience claim in the ID Token - Column 3: Expiration policy, options are: - `24h` - user's ssh public key expires after 24 hours, - `48h` - user's ssh public key expires after 48 hours, - `1week` - user's ssh public key expires after 1 week, - `oidc` - user's ssh public key expires when the ID Token expires - `oidc-refreshed` - user's ssh public key expires when their refreshed ID Token expires. By default we use `24h` as it requires that the user authenticate to their OP once a day. Most OPs expire ID Tokens every one to two hours, so if `oidc` the user will have to sign multiple times a day. `oidc-refreshed` is supported but complex and not currently recommended unless you know what you are doing. The default values for `/etc/opk/providers` are: ```bash # 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 ``` `/etc/opk/providers` requires the following permissions (by default we create all configuration files with the correct permissions): ```bash sudo chown root:opksshuser /etc/opk/providers sudo chmod 640 /etc/opk/providers ``` ## `/etc/opk/auth_id` `/etc/opk/auth_id` is the global authorized identities file. This is a server wide file where policies can be configured to determine which identities can assume what linux user accounts. Linux user accounts are typically referred to in SSH as *principals* and we continue the use of this terminology. - Column 1: The principal, i.e., the account the user wants to assume - Column 2: Email address or subject ID of the user (choose one) - Email - the email of the identity - Subject ID - an unique ID for the user set by the OP. This is the `sub` claim in the ID Token. - Group - the name of the group that the user is part of. This uses the `groups` claim which is presumed to be an array. The group identifier uses a structured identifier. I.e. `oidc:groups:{groupId}`. Replace the `groupId` with the id of your group. - Column 3: Issuer URI ```bash # email/sub principal issuer alice alice@example.com https://accounts.google.com guest alice@example.com https://accounts.google.com root alice@example.com https://accounts.google.com dev bob@microsoft.com https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 # Group identifier dev oidc:groups:developer https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 ``` To add new rule run: `sudo opkssh add {USER} {EMAIL/SUB/GROUP} {ISSUER}` These `auth_id` files can be edited by hand or you can use the add command to add new policies. For convenience you can use the shorthand `google` or `azure` rather than specifying the entire issuer. This is especially useful in the case of azure where the issuer contains a long and hard to remember random string. For instance: `sudo opkssh add dev bob@microsoft.com azure` `/etc/opk/auth_id` requires the following permissions (by default we create all configuration files with the correct permissions): ```bash sudo chown root:opksshuser /etc/opk/auth_id sudo chmod 640 /etc/opk/auth_id ``` ### `~/.opk/auth_id` This is a local version of the auth_id file. It lives in the user's home directory (`/home/{USER}/.opk/auth_id`) and allows users to add or remove authorized identities without requiring root level permissions. It can only be used for user/principal whose home directory it lives in. That is, if it is in `/home/alice/.opk/auth_id` it can only specify who can assume the principal `alice` on the server. ```bash # email/sub principal issuer alice alice@example.com https://accounts.google.com # Group identifier dev oidc:groups:developer https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 ``` It requires the following permissions: ```bash chown {USER}:{USER} /home/{USER}/.opk/auth_id chmod 600 /home/{USER}/.opk/auth_id ``` ### AuthorizedKeysCommandUser We use a low privilege user for the SSH AuthorizedKeysCommandUser. Our install script creates this user and group automatically by running: ```bash sudo groupadd --system opksshuser sudo useradd -r -M -s /sbin/nologin -g opksshuser opksshuser ``` We then add the following lines to `/etc/ssh/sshd_config` ```bash AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t AuthorizedKeysCommandUser opksshuser ``` ## Custom OpenID Providers (Authentik, Authelia, Keycloak, Zitadel...) To log in using a custom OpenID Provider, run: ```bash opkssh login --provider={ISSUER},{CLIENT_ID} ``` or in the rare case that a client secret is required by the OpenID Provider: ```bash opkssh login --provider={ISSUER},{CLIENT_ID},{CLIENT_SECRET} ``` where ISSUER, CLIENT_ID and CLIENT_SECRET correspond to the issuer client ID and client secret of the custom OpenID Provider. For example if the issuer is `https://authentik.local/application/o/opkssh/` and the client ID was `ClientID123`: ```bash opkssh login --provider=https://authentik.local/application/o/opkssh/,ClientID123 ``` ### Server Configuration In the `/etc/opk/providers` file, add the OpenID Provider as you would any OpenID Provider. For example: ```bash https://authentik.local/application/o/opkssh/ ClientID123 24h ``` Then add identities to the policy to allow those identities SSH to the server: ```bash opkssh add root alice@example.com https://authentik.local/application/o/opkssh/ ``` ### Tested | OpenID Provider | Tested | Notes | |-----------|------------|--------------------------------------------------------------------| | Authentik | βœ… | Do not add a certificate in the encryption section of the provider | | Zitadel | βœ… | Check the UserInfo box on the Token Settings | Do not use Confidential/Secret mode **only** client ID is needed. ## More information We document how to manually install opkssh on a server [here](scripts/installing.md). opkssh-0.4.0/SECURITY.md000066400000000000000000000007061477307211400146240ustar00rootroot00000000000000# Security Policy ## Reporting a Vulnerability **Please do not file a public ticket** mentioning the vulnerability or issue. To privately report security issues or vulnerabilities send your report to security@bastionzero.com (not for support). A report should include: - a summary of the issue, - the steps needed to reproduce the issue, - the potential security impact and, if found, any proposed mitigation. We do not currently offer bug bounties.opkssh-0.4.0/commands/000077500000000000000000000000001477307211400146315ustar00rootroot00000000000000opkssh-0.4.0/commands/add.go000066400000000000000000000104141477307211400157100ustar00rootroot00000000000000// Copyright 2025 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 commands import ( "errors" "fmt" "os" "github.com/openpubkey/opkssh/policy" ) // AddCmd provides functionality to read and update the opkssh policy file type AddCmd struct { HomePolicyLoader *policy.HomePolicyLoader SystemPolicyLoader *policy.SystemPolicyLoader // Username is the username to lookup when the system policy file cannot be // read and we fallback to the user's policy file. // // See AddCmd.LoadPolicy for more details. Username string } // LoadPolicy reads the opkssh policy at the policy.SystemDefaultPolicyPath. If // there is a permission error when reading this file, then the user's local // policy file (defined as ~/.opk/auth_id where ~ maps to AddCmd.Username's // home directory) is read instead. // // If successful, returns the parsed policy and filepath used to read the // policy. Otherwise, a non-nil error is returned. func (a *AddCmd) LoadPolicy() (*policy.Policy, string, error) { // Try to read system policy first systemPolicy, _, err := a.SystemPolicyLoader.LoadSystemPolicy() if err != nil { if errors.Is(err, os.ErrPermission) { // If current process doesn't have permission, try reading the user // policy file. userPolicy, policyFilePath, err := a.HomePolicyLoader.LoadHomePolicy(a.Username, false) if err != nil { return nil, "", err } return userPolicy, policyFilePath, nil } else { // Non-permission error (e.g. system policy file missing or invalid // permission bits set). Return error return nil, "", err } } return systemPolicy, policy.SystemDefaultPolicyPath, nil } // GetPolicyPath returns the path to the policy file that the current command // will write to and a boolean to flag the path is for home policy. // True means home policy, false means system policy. func (a *AddCmd) GetPolicyPath(principal string, userEmail string, issuer string) (string, bool, error) { // Try to read system policy first _, _, err := a.SystemPolicyLoader.LoadSystemPolicy() if err != nil { if errors.Is(err, os.ErrPermission) { // If current process doesn't have permission, try reading the user // policy file. policyFilePath, err := a.HomePolicyLoader.UserPolicyPath(a.Username) if err != nil { return "", false, err } return policyFilePath, false, nil } else { // Non-permission error (e.g. system policy file missing or invalid // permission bits set). Return error return "", false, err } } return policy.SystemDefaultPolicyPath, true, nil } // Run adds a new allowed principal to the user whose email is equal to // userEmail. The policy file is read and modified. // // If successful, returns the policy filepath updated. Otherwise, returns a // non-nil error func (a *AddCmd) Run(principal string, userEmail string, issuer string) (string, error) { policyPath, useSystemPolicy, err := a.GetPolicyPath(principal, userEmail, issuer) if err != nil { return "", fmt.Errorf("failed to load policy: %w", err) } var policyLoader *policy.PolicyLoader if useSystemPolicy { policyLoader = a.SystemPolicyLoader.PolicyLoader } else { policyLoader = a.HomePolicyLoader.PolicyLoader } err = policyLoader.CreateIfDoesNotExist(policyPath) if err != nil { return "", fmt.Errorf("failed to create policy file: %w", err) } // Read current policy currentPolicy, policyFilePath, err := a.LoadPolicy() if err != nil { return "", fmt.Errorf("failed to load current policy: %w", err) } // Update policy currentPolicy.AddAllowedPrincipal(principal, userEmail, issuer) // Dump contents back to disk err = policyLoader.Dump(currentPolicy, policyFilePath) if err != nil { return "", fmt.Errorf("failed to write updated policy: %w", err) } return policyFilePath, nil } opkssh-0.4.0/commands/add_test.go000066400000000000000000000064341477307211400167560ustar00rootroot00000000000000// Copyright 2025 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 commands import ( "os/user" "testing" "github.com/openpubkey/opkssh/policy" "github.com/openpubkey/opkssh/policy/files" "github.com/spf13/afero" "github.com/stretchr/testify/require" ) // Duplicates code from multipolicyloader_test.go type MockUserLookup struct { // User is returned on any call to Lookup() if Error is nil User *user.User // Error is returned on any call to Lookup() if non-nil Error error } // Lookup implements policy.UserLookup func (m *MockUserLookup) Lookup(username string) (*user.User, error) { if m.Error == nil { return m.User, nil } else { return nil, m.Error } } var ValidUser *user.User = &user.User{HomeDir: "/home/foo", Username: "foo"} func MockAddCmd(mockFs afero.Fs) *AddCmd { mockUserLookup := &MockUserLookup{User: ValidUser} mockHomePolicyLoader := &policy.HomePolicyLoader{ PolicyLoader: &policy.PolicyLoader{ FileLoader: files.FileLoader{ Fs: mockFs, RequiredPerm: files.ModeHomePerms, }, UserLookup: mockUserLookup, }, } mockSystemPolicyLoader := &policy.SystemPolicyLoader{ PolicyLoader: &policy.PolicyLoader{ FileLoader: files.FileLoader{ Fs: mockFs, RequiredPerm: files.ModeSystemPerms, }, UserLookup: mockUserLookup, }, } return &AddCmd{ HomePolicyLoader: mockHomePolicyLoader, SystemPolicyLoader: mockSystemPolicyLoader, Username: ValidUser.Username, } } func TestAddErrors(t *testing.T) { principal := "foo" userEmail := "alice@example.com" issuer := "gitlab" // Test when the system policy file does not exist mockEmptyFs := afero.NewMemMapFs() addCmd := MockAddCmd(mockEmptyFs) policyPath, err := addCmd.Run(principal, userEmail, issuer) require.ErrorContains(t, err, "file does not exist") require.Empty(t, policyPath) // Create system policy file mockFs := afero.NewMemMapFs() _, err = mockFs.Create(policy.SystemDefaultPolicyPath) require.NoError(t, err) addCmd = MockAddCmd(mockFs) policyPath, err = addCmd.Run(principal, userEmail, issuer) require.ErrorContains(t, err, "file has insecure permissions: expected permissions (640), got (0)") require.Empty(t, policyPath) err = mockFs.Chmod(policy.SystemDefaultPolicyPath, 0640) require.NoError(t, err) addCmd = MockAddCmd(mockFs) policyPath, err = addCmd.Run(principal, userEmail, issuer) require.NoError(t, err) require.Equal(t, policy.SystemDefaultPolicyPath, policyPath) systemPolicyFile, err := mockFs.Open(policyPath) require.NoError(t, err) policyContent, err := afero.ReadAll(systemPolicyFile) require.NoError(t, err) expectedPolicyContent := principal + " " + userEmail + " " + issuer + "\n" require.Equal(t, expectedPolicyContent, string(policyContent)) } opkssh-0.4.0/commands/login.go000066400000000000000000000332111477307211400162700ustar00rootroot00000000000000// Copyright 2025 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 commands import ( "context" "crypto" "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" "io" "log" "os" "path/filepath" "strings" "time" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/client/choosers" "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/util" "github.com/openpubkey/opkssh/sshcert" "golang.org/x/crypto/ssh" ) type LoginCmd struct { autoRefresh bool logDir string disableBrowserOpenArg bool providerArg string providerFromLdFlags providers.OpenIdProvider pkt *pktoken.PKToken signer crypto.Signer alg jwa.SignatureAlgorithm client *client.OpkClient principals []string } func NewLogin(autoRefresh bool, logDir string, disableBrowserOpenArg bool, providerArg string, providerFromLdFlags providers.OpenIdProvider) *LoginCmd { return &LoginCmd{ autoRefresh: autoRefresh, logDir: logDir, disableBrowserOpenArg: disableBrowserOpenArg, providerArg: providerArg, providerFromLdFlags: providerFromLdFlags, } } func (l *LoginCmd) Run(ctx context.Context) error { // If a log directory was provided, write any logs to a file in that directory AND stdout if l.logDir != "" { logFilePath := filepath.Join(l.logDir, "opkssh.log") logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0660) if err != nil { log.Printf("Failed to open log for writing: %v \n", err) } defer logFile.Close() multiWriter := io.MultiWriter(os.Stdout, logFile) log.SetOutput(multiWriter) } else { log.SetOutput(os.Stdout) } openBrowser := !l.disableBrowserOpenArg // If the user has supplied commandline arguments for the provider, use those instead of the web chooser var provider providers.OpenIdProvider if l.providerArg != "" { parts := strings.Split(l.providerArg, ",") if len(parts) != 2 && len(parts) != 3 { return fmt.Errorf("invalid provider argument format. Expected format , or ,, got (%s)", l.providerArg) } issuerArg := parts[0] clientIDArg := parts[1] if !strings.HasPrefix(issuerArg, "https://") { return fmt.Errorf("invalid provider issuer value. Expected issuer to start with 'https://' got (%s) \n", issuerArg) } if clientIDArg == "" { return fmt.Errorf("invalid provider client-ID value got (%s) \n", clientIDArg) } if strings.HasPrefix(issuerArg, "https://accounts.google.com") { // The Google OP is strange in that it requires a client secret even if this is a public OIDC App. // Despite its name the Google OP client secret is a public value. if len(parts) != 3 { return fmt.Errorf("invalid provider argument format. Expected format for google: ,, got (%s)", l.providerArg) } clientSecretArg := parts[2] if clientSecretArg == "" { return fmt.Errorf("invalid provider client secret value got (%s) \n", clientSecretArg) } opts := providers.GetDefaultGoogleOpOptions() opts.Issuer = issuerArg opts.ClientID = clientIDArg opts.ClientSecret = clientSecretArg opts.GQSign = false opts.OpenBrowser = openBrowser provider = providers.NewGoogleOpWithOptions(opts) } else if strings.HasPrefix(issuerArg, "https://login.microsoftonline.com") { opts := providers.GetDefaultAzureOpOptions() opts.Issuer = issuerArg opts.ClientID = clientIDArg opts.GQSign = false opts.OpenBrowser = openBrowser provider = providers.NewAzureOpWithOptions(opts) } else if strings.HasPrefix(issuerArg, "https://gitlab.com") { opts := providers.GetDefaultGitlabOpOptions() opts.Issuer = issuerArg opts.ClientID = clientIDArg opts.GQSign = false opts.OpenBrowser = openBrowser provider = providers.NewGitlabOpWithOptions(opts) } else { // Generic provider - Need signing, no encryption opts := providers.GetDefaultGoogleOpOptions() opts.Issuer = issuerArg opts.ClientID = clientIDArg opts.ClientSecret = "" // No client secret for generic providers unless specified opts.GQSign = false opts.OpenBrowser = openBrowser if len(parts) == 3 { opts.ClientSecret = parts[2] } provider = providers.NewGoogleOpWithOptions(opts) } } else if l.providerFromLdFlags != nil { provider = l.providerFromLdFlags } else { googleOpOptions := providers.GetDefaultGoogleOpOptions() googleOpOptions.OpenBrowser = openBrowser googleOpOptions.GQSign = false googleOp := providers.NewGoogleOpWithOptions(googleOpOptions) azureOpOptions := providers.GetDefaultAzureOpOptions() azureOpOptions.OpenBrowser = openBrowser azureOpOptions.GQSign = false azureOp := providers.NewAzureOpWithOptions(azureOpOptions) gitlabOpOptions := providers.GetDefaultGitlabOpOptions() gitlabOpOptions.OpenBrowser = openBrowser gitlabOpOptions.GQSign = false gitlabOp := providers.NewGitlabOpWithOptions(gitlabOpOptions) var err error provider, err = choosers.NewWebChooser( []providers.BrowserOpenIdProvider{googleOp, azureOp, gitlabOp}, !l.disableBrowserOpenArg, ).ChooseOp(ctx) if err != nil { return fmt.Errorf("error selecting OpenID provider: %w", err) } } // Execute login command if l.autoRefresh { if providerRefreshable, ok := provider.(providers.RefreshableOpenIdProvider); ok { err := LoginWithRefresh(ctx, providerRefreshable) if err != nil { return fmt.Errorf("error logging in: %w", err) } } else { return fmt.Errorf("supplied OpenID Provider (%v) does not support auto-refresh and auto-refresh argument set to true", provider.Issuer()) } } else { err := Login(ctx, provider) if err != nil { return fmt.Errorf("error logging in: %w", err) } } return nil } func login(ctx context.Context, provider client.OpenIdProvider) (*LoginCmd, error) { var err error alg := jwa.ES256 signer, err := util.GenKeyPair(alg) if err != nil { return nil, fmt.Errorf("failed to generate keypair: %w", err) } opkClient, err := client.New(provider, client.WithSigner(signer, alg)) if err != nil { return nil, err } pkt, err := opkClient.Auth(ctx) if err != nil { return nil, err } // If principals is empty the server does not enforce any principal. The OPK // verifier should use policy to make this decision. principals := []string{} certBytes, seckeySshPem, err := createSSHCert(pkt, signer, principals) if err != nil { return nil, fmt.Errorf("failed to generate SSH cert: %w", err) } // Write ssh secret key and public key to filesystem if err := writeKeysToSSHDir(seckeySshPem, certBytes); err != nil { return nil, fmt.Errorf("failed to write SSH keys to filesystem: %w", err) } idStr, err := IdentityString(*pkt) if err != nil { return nil, fmt.Errorf("failed to parse ID Token: %w", err) } fmt.Printf("Keys generated for identity\n%s\n", idStr) return &LoginCmd{ pkt: pkt, signer: signer, client: opkClient, alg: alg, principals: principals, }, nil } // Login performs the OIDC login procedure and creates the SSH certs/keys in the // default SSH key location. func Login(ctx context.Context, provider client.OpenIdProvider) error { _, err := login(ctx, provider) return err } // LoginWithRefresh performs the OIDC login procedure, creates the SSH // certs/keys in the default SSH key location, and continues to run and refresh // the PKT (and create new SSH certs) indefinitely as its token expires. This // function only returns if it encounters an error or if the supplied context is // cancelled. func LoginWithRefresh(ctx context.Context, provider providers.RefreshableOpenIdProvider) error { if loginResult, err := login(ctx, provider); err != nil { return err } else { var claims struct { Expiration int64 `json:"exp"` } if err := json.Unmarshal(loginResult.pkt.Payload, &claims); err != nil { return err } for { // Sleep until a minute before expiration to give us time to refresh // the token and minimize any interruptions untilExpired := time.Until(time.Unix(claims.Expiration, 0)) - time.Minute log.Printf("Waiting for %v before attempting to refresh id_token...", untilExpired) select { case <-time.After(untilExpired): log.Print("Refreshing id_token...") case <-ctx.Done(): return ctx.Err() } refreshedPkt, err := loginResult.client.Refresh(ctx) if err != nil { return err } loginResult.pkt = refreshedPkt certBytes, seckeySshPem, err := createSSHCert(loginResult.pkt, loginResult.signer, loginResult.principals) if err != nil { return fmt.Errorf("failed to generate SSH cert: %w", err) } // Write ssh secret key and public key to filesystem if err := writeKeysToSSHDir(seckeySshPem, certBytes); err != nil { return fmt.Errorf("failed to write SSH keys to filesystem: %w", err) } comPkt, err := refreshedPkt.Compact() if err != nil { return err } _, payloadB64, _, err := jws.SplitCompactString(string(comPkt)) if err != nil { return fmt.Errorf("malformed ID token: %w", err) } payload, err := base64.RawURLEncoding.DecodeString(string(payloadB64)) if err != nil { return fmt.Errorf("refreshed ID token payload is not base64 encoded: %w", err) } if err = json.Unmarshal(payload, &claims); err != nil { return fmt.Errorf("malformed refreshed ID token payload: %w", err) } } } } func createSSHCert(pkt *pktoken.PKToken, signer crypto.Signer, principals []string) ([]byte, []byte, error) { cert, err := sshcert.New(pkt, principals) if err != nil { return nil, nil, err } sshSigner, err := ssh.NewSignerFromSigner(signer) if err != nil { return nil, nil, err } signerMas, err := ssh.NewSignerWithAlgorithms(sshSigner.(ssh.AlgorithmSigner), []string{ssh.KeyAlgoECDSA256}) if err != nil { return nil, nil, err } sshCert, err := cert.SignCert(signerMas) if err != nil { return nil, nil, err } certBytes := ssh.MarshalAuthorizedKey(sshCert) // Remove newline character that MarshalAuthorizedKey() adds certBytes = certBytes[:len(certBytes)-1] seckeySsh, err := ssh.MarshalPrivateKey(signer, "openpubkey cert") if err != nil { return nil, nil, err } seckeySshBytes := pem.EncodeToMemory(seckeySsh) return certBytes, seckeySshBytes, nil } func writeKeysToSSHDir(seckeySshPem []byte, certBytes []byte) error { homePath, err := os.UserHomeDir() if err != nil { return err } sshPath := filepath.Join(homePath, ".ssh") // Make ~/.ssh if folder does not exist err = os.MkdirAll(sshPath, os.ModePerm) if err != nil { return err } // For ssh to automatically find the key created by openpubkey when // connecting, we use one of the default ssh key paths. However, the file // might contain an existing key. We will overwrite the key if it was // generated by openpubkey which we check by looking at the associated // comment. If the comment is equal to "openpubkey", we overwrite the file // with a new key. for _, keyFilename := range []string{"id_ecdsa", "id_ed25519"} { seckeyPath := filepath.Join(sshPath, keyFilename) pubkeyPath := seckeyPath + ".pub" if !fileExists(seckeyPath) { // If ssh key file does not currently exist, we don't have to worry about overwriting it return writeKeys(seckeyPath, pubkeyPath, seckeySshPem, certBytes) } else if !fileExists(pubkeyPath) { continue } else { // If the ssh key file does exist, check if it was generated by openpubkey, if it was then it is safe to overwrite sshPubkey, err := os.ReadFile(pubkeyPath) if err != nil { log.Println("Failed to read:", pubkeyPath) continue } _, comment, _, _, err := ssh.ParseAuthorizedKey(sshPubkey) if err != nil { log.Println("Failed to parse:", pubkeyPath) continue } // If the key comment is "openpubkey" then we generated it if comment == "openpubkey" { return writeKeys(seckeyPath, pubkeyPath, seckeySshPem, certBytes) } } } return fmt.Errorf("no default ssh key file free for openpubkey") } func writeKeys(seckeyPath string, pubkeyPath string, seckeySshPem []byte, certBytes []byte) error { // Write ssh secret key to filesystem if err := os.WriteFile(seckeyPath, seckeySshPem, 0600); err != nil { return err } fmt.Printf("Writing opk ssh public key to %s and corresponding secret key to %s\n", pubkeyPath, seckeyPath) certBytes = append(certBytes, []byte(" openpubkey")...) // Write ssh public key (certificate) to filesystem return os.WriteFile(pubkeyPath, certBytes, 0644) } func fileExists(fPath string) bool { _, err := os.Open(fPath) return !errors.Is(err, os.ErrNotExist) } func IdentityString(pkt pktoken.PKToken) (string, error) { idt, err := oidc.NewJwt(pkt.OpToken) if err != nil { return "", err } claims := idt.GetClaims() if claims.Email == "" { return "Sub, issuer, audience: \n" + claims.Subject + " " + claims.Issuer + " " + claims.Audience, nil } else { return "Email, sub, issuer, audience: \n" + claims.Email + " " + claims.Subject + " " + claims.Issuer + " " + claims.Audience, nil } } opkssh-0.4.0/commands/login_test.go000066400000000000000000000044601477307211400173330ustar00rootroot00000000000000// Copyright 2025 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 commands import ( "context" "crypto" "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" ) func MockPKToken(t *testing.T) (*pktoken.PKToken, crypto.Signer) { alg := jwa.ES256 signer, err := util.GenKeyPair(alg) require.NoError(t, err) providerOpts := providers.DefaultMockProviderOpts() op, _, idtTemplate, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) mockEmail := "arthur.aardvark@example.com" idtTemplate.ExtraClaims = map[string]any{ "email": mockEmail, } client, err := client.New(op, client.WithSigner(signer, alg)) require.NoError(t, err) pkt, err := client.Auth(context.Background()) require.NoError(t, err) return pkt, signer } func TestCreateSSHCert(t *testing.T) { pkt, signer := MockPKToken(t) principals := []string{"guest", "dev"} sshCertBytes, signKeyBytes, err := createSSHCert(pkt, signer, principals) require.NoError(t, err) require.NotNil(t, sshCertBytes) require.NotNil(t, signKeyBytes) // Simple smoke test to verify we can parse the cert certPubkey, _, _, _, err := ssh.ParseAuthorizedKey([]byte("certType" + " " + string(sshCertBytes))) require.NoError(t, err) require.NotNil(t, certPubkey) } func TestIdentityString(t *testing.T) { pkt, _ := MockPKToken(t) idString, err := IdentityString(*pkt) require.NoError(t, err) expIdString := "Email, sub, issuer, audience: \narthur.aardvark@example.com me https://accounts.example.com test_client_id" require.Equal(t, expIdString, idString) } opkssh-0.4.0/commands/readhome.go000066400000000000000000000072131477307211400167470ustar00rootroot00000000000000// Copyright 2025 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 linux || darwin package commands import ( "errors" "fmt" "os" "os/user" "path/filepath" "regexp" "strconv" "syscall" "github.com/openpubkey/opkssh/policy/files" ) // ReadHome is used to read the home policy file for the user with // the specified username. This is used when opkssh is called by // AuthorizedKeysCommand as the opksshuser and needs to use sudoer // access to read the home policy file (`/home//opk/auth_id`). // This function is only available on Linux and Darwin because it relies on // syscall.Stat_t to determine the owner of the file. func ReadHome(username string) ([]byte, error) { if matched, _ := regexp.MatchString("^[a-z0-9_\\-.]+$", username); !matched { return nil, fmt.Errorf("%s is not a valid linux username", username) } userObj, err := user.Lookup(username) if err != nil { return nil, fmt.Errorf("failed to find user %s", username) } homePolicyPath := filepath.Join(userObj.HomeDir, ".opk", "auth_id") // Security critical: We reading this file as `sudo -u opksshuser` // and opksshuser has elevated permissions to read any file whose // path matches `/home/*/opk/auth_id`. We need to be cautious we do follow // a symlink as it could be to a file the user is not permitted to read. // This would not permit the user to read the file, but they might be able // to determine the existence of the file. We use O_NOFOLLOW to prevent // following symlinks. file, err := os.OpenFile(homePolicyPath, os.O_RDONLY|syscall.O_NOFOLLOW, 0) if err != nil { if errors.Is(err, syscall.ELOOP) { return nil, fmt.Errorf("home policy file %s is a symlink, symlink are unsafe in this context", homePolicyPath) } return nil, fmt.Errorf("failed to open %s, %v", homePolicyPath, err) } defer file.Close() if fileInfo, err := file.Stat(); err != nil { return nil, fmt.Errorf("failed to get info on file %s", homePolicyPath) } else if stat, ok := fileInfo.Sys().(*syscall.Stat_t); !ok { // This syscall.Stat_t is doesn't work on Windows return nil, fmt.Errorf("failed to stat file %s", homePolicyPath) } else { // We want to ensure that the file is owned by the correct user and has the correct permissions. requiredOwnerUid := userObj.Uid fileOwnerUID := strconv.FormatUint(uint64(stat.Uid), 10) fileOwner, err := user.LookupId(fileOwnerUID) if err != nil { return nil, fmt.Errorf("failed to find username for UID %s for file %s", fileOwnerUID, homePolicyPath) } if fileOwnerUID != userObj.Uid || fileOwner.Username != username { return nil, fmt.Errorf("unsafe file permissions on %s expected file owner %s (UID %s) got %s (UID %s)", homePolicyPath, username, requiredOwnerUid, fileOwner.Username, fileOwnerUID) } if fileInfo.Mode().Perm() != files.ModeHomePerms { return nil, fmt.Errorf("unsafe file permissions for %s got %o expected %o", homePolicyPath, fileInfo.Mode().Perm(), files.ModeHomePerms) } fileBytes, err := os.ReadFile(homePolicyPath) if err != nil { return nil, fmt.Errorf("failed to read %s, %v", homePolicyPath, err) } return fileBytes, nil } } opkssh-0.4.0/commands/readhome_windows.go000066400000000000000000000015241477307211400205200ustar00rootroot00000000000000// Copyright 2025 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 ( "errors" ) // ReadHome is not currently supported on Windows func ReadHome(username string) ([]byte, error) { return nil, errors.New("readhome not supported on windows") } opkssh-0.4.0/commands/verify.go000066400000000000000000000105001477307211400164600ustar00rootroot00000000000000// Copyright 2025 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 commands import ( "context" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/verifier" "github.com/openpubkey/opkssh/policy" "github.com/openpubkey/opkssh/sshcert" "golang.org/x/crypto/ssh" ) // PolicyEnforcerFunc returns nil if the supplied PK token is permitted to login as // username. Otherwise, an error is returned indicating the reason for rejection type PolicyEnforcerFunc func(username string, pkt *pktoken.PKToken) error // VerifyCmd provides functionality to verify OPK tokens contained in SSH // certificates and authorize requests to SSH as a specific username using a // configurable authorization system. It is designed to be used in conjunction // with sshd's AuthorizedKeysCommand feature. type VerifyCmd struct { // PktVerifier is responsible for verifying the PK token // contained in the SSH certificate PktVerifier verifier.Verifier // CheckPolicy determines whether the verified PK token is permitted to SSH as a // specific user CheckPolicy PolicyEnforcerFunc } // This function is called by the SSH server as the AuthorizedKeysCommand: // // The following lines are added to /etc/ssh/sshd_config: // // AuthorizedKeysCommand /usr/local/bin/opkssh ver %u %k %t // AuthorizedKeysCommandUser opksshuser // // The parameters specified in the config map the parameters sent to the function below. // We prepend "Arg" to specify which ones are arguments sent by sshd. They are: // // %u The username (requested principal) - userArg // %k The base64-encoded public key for authentication - certB64Arg - the public key is also a certificate // %t The public key type - typArg - in this case a certificate being used as a public key // // AuthorizedKeysCommand verifies the OPK PK token contained in the base64-encoded SSH pubkey; // the pubkey is expected to be an SSH certificate. pubkeyType is used to // determine how to parse the pubkey as one of the SSH certificate types. // // This function: // 1. Verifying the PK token with the OP (OpenID Provider) // 2. Enforcing policy by checking if the identity is allowed to assume // the username (principal) requested. // // If all steps of verification succeed, then the expected authorized_keys file // format string is returned (i.e. the expected line to produce on standard // output when using sshd's AuthorizedKeysCommand feature). Otherwise, a non-nil // error is returned. func (v *VerifyCmd) AuthorizedKeysCommand(ctx context.Context, userArg string, typArg string, certB64Arg string) (string, error) { // Parse the b64 pubkey and expect it to be an ssh certificate cert, err := sshcert.NewFromAuthorizedKey(typArg, certB64Arg) if err != nil { return "", err } if pkt, err := cert.VerifySshPktCert(ctx, v.PktVerifier); err != nil { // Verify the PKT contained in the cert return "", err } else if err := v.CheckPolicy(userArg, pkt); err != nil { // Check if username is authorized return "", err } else { // Success! // sshd expects the public key in the cert, not the cert itself. This // public key is key of the CA that signs the cert, in our setting there // is no CA. pubkeyBytes := ssh.MarshalAuthorizedKey(cert.SshCert.SignatureKey) return "cert-authority " + string(pubkeyBytes), nil } } // OpkPolicyEnforcerAuthFunc returns an opkssh policy.Enforcer that can be // used in the opkssh verify command. func OpkPolicyEnforcerFunc(username string) PolicyEnforcerFunc { policyEnforcer := &policy.Enforcer{ PolicyLoader: &policy.MultiPolicyLoader{ HomePolicyLoader: policy.NewHomePolicyLoader(), SystemPolicyLoader: policy.NewSystemPolicyLoader(), Username: username, LoadWithScript: true, // This is needed to load policy from the user's home directory }, } return policyEnforcer.CheckPolicy } opkssh-0.4.0/commands/verify_test.go000066400000000000000000000053111477307211400175230ustar00rootroot00000000000000// Copyright 2025 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 commands import ( "context" "strings" "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/util" "github.com/openpubkey/openpubkey/verifier" "github.com/openpubkey/opkssh/sshcert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" ) func AllowAllPolicyEnforcer(userDesired string, pkt *pktoken.PKToken) error { return nil } func TestAuthorizedKeysCommand(t *testing.T) { alg := jwa.ES256 signer, err := util.GenKeyPair(alg) require.NoError(t, err) providerOpts := providers.DefaultMockProviderOpts() op, _, idtTemplate, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) mockEmail := "arthur.aardvark@example.com" idtTemplate.ExtraClaims = map[string]any{ "email": mockEmail, } client, err := client.New(op, client.WithSigner(signer, alg)) require.NoError(t, err) pkt, err := client.Auth(context.Background()) require.NoError(t, err) principals := []string{"guest", "dev"} cert, err := sshcert.New(pkt, principals) require.NoError(t, err) sshSigner, err := ssh.NewSignerFromSigner(signer) require.NoError(t, err) signerMas, err := ssh.NewSignerWithAlgorithms(sshSigner.(ssh.AlgorithmSigner), []string{ssh.KeyAlgoECDSA256}) require.NoError(t, err) sshCert, err := cert.SignCert(signerMas) require.NoError(t, err) certTypeAndCertB64 := ssh.MarshalAuthorizedKey(sshCert) typeArg := strings.Split(string(certTypeAndCertB64), " ")[0] certB64Arg := strings.Split(string(certTypeAndCertB64), " ")[1] verPkt, err := verifier.New( op, verifier.WithExpirationPolicy(verifier.ExpirationPolicies.NEVER_EXPIRE), ) require.NoError(t, err) userArg := "user" ver := VerifyCmd{ PktVerifier: *verPkt, CheckPolicy: AllowAllPolicyEnforcer, } pubkeyList, err := ver.AuthorizedKeysCommand(context.Background(), userArg, typeArg, certB64Arg) require.NoError(t, err) expectedPubkeyList := "cert-authority ecdsa-sha2-nistp256" require.Contains(t, pubkeyList, expectedPubkeyList) } opkssh-0.4.0/docs/000077500000000000000000000000001477307211400137605ustar00rootroot00000000000000opkssh-0.4.0/docs/config.md000066400000000000000000000077261477307211400155630ustar00rootroot00000000000000# opkssh configuration files Herein we document the various configuration files used by opkssh. All our configuration files are space delimited like ssh authorized key files. We have the follow syntax rules: - `#` for comments Our goal is to have an distinct meaning for each column. This way if we want to extend the rules we can add additional columns. ## Allowed OpenID Providers: `/etc/opk/providers` 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. The client ID must match the aud (audience) claim in the PK Token. ### Columns - Column 1: Issuer - Column 2: Client-ID a.k.a. what to match on the aud claim in the ID Token - Column 3: Expiration policy, options are: `24h`, `48h`, `1week`, `oidc`, `oidc-refreshed` ### Examples The file lives at `/etc/opk/providers`. The default values are: ```bash # 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 ``` ## Authorized identities files: `/etc/opk/auth_id` and `/home/{USER}/.opk/auth_id` 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. We support matching on email, sub (subscriber) or group. ### System authorized identity file `/etc/opk/auth_id` This is a server wide policy file. ```bash # email/sub principal issuer alice alice@example.com https://accounts.google.com guest alice@example.com https://accounts.google.com root alice@example.com https://accounts.google.com dev bob@microsoft.com https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 # Group identifier dev oidc:groups:developer https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 ``` These `auth_id` files can be edited by hand or you can use the add command to add new policies. The add command has the following syntax. `sudo opkssh add {USER} {EMAIL|SUB|GROUP} {ISSUER}` For convenience you can use the shorthand `google`, `azure`, `gitlab` rather than specifying the entire issuer. This is especially useful in the case of azure where the issuer contains a long and hard to remember random string. The following command will allow `alice@example.com` to ssh in as `root`. Groups must be prefixed with `oidc:group`. So to allow anyone with the group `admin` to ssh in as root you would run the command: ```bash sudo opkssh add root oidc:group:admin azure ``` Note that currently Google does not put their groups in the ID Token, so groups based auth does not work if you OpenID Provider is Google. The system authorized identity file requires the following permissions: ```bash sudo chown root:opksshuser /etc/opk/auth_id 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` 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. ```bash # email/sub principal issuer alice alice@example.com https://accounts.google.com # Group identifier alice oidc:groups:developer https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 ``` Home authorized identity file requires the following permissions: ```bash chown {USER}:{USER} /home/{USER}/.opk/auth_id chmod 600 /home/{USER}/.opk/auth_id ``` ## See Also Our documentation on the changes our install script makes to a server: [installing.md](../scripts/installing.md) opkssh-0.4.0/go.mod000066400000000000000000000100201477307211400141270ustar00rootroot00000000000000module github.com/openpubkey/opkssh go 1.23.7 require ( github.com/docker/go-connections v0.5.0 github.com/jeremija/gosubmit v0.2.7 github.com/lestrrat-go/jwx/v2 v2.0.21 github.com/melbahja/goph v1.4.0 github.com/openpubkey/openpubkey v0.8.0 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 github.com/testcontainers/testcontainers-go v0.35.0 github.com/zitadel/oidc/v3 v3.23.2 golang.org/x/crypto v0.35.0 ) require ( dario.cat/mergo v1.0.0 // indirect filippo.io/bigmod v0.0.3 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/awnumar/memguard v0.22.3 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/containerd/containerd v1.7.27 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v27.1.1+incompatible // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-chi/chi/v5 v5.0.12 // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/kr/fs v0.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.5.0 // indirect github.com/moby/sys/user v0.3.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/muhlemmer/httpforwarded v0.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/sftp v1.13.7 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/rs/cors v1.11.0 // indirect github.com/shirou/gopsutil/v3 v3.23.12 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect github.com/zitadel/logging v0.6.0 // indirect github.com/zitadel/schema v1.3.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/metric v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect golang.org/x/net v0.36.0 // indirect ) require ( github.com/awnumar/memcall v0.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.5 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/muhlemmer/gu v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/spf13/afero v1.12.0 golang.org/x/exp v0.0.0-20231006140011-7918f672742d golang.org/x/oauth2 v0.25.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) opkssh-0.4.0/go.sum000066400000000000000000000662361477307211400142000ustar00rootroot00000000000000dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/bigmod v0.0.3 h1:qmdCFHmEMS+PRwzrW6eUrgA4Q3T8D6bRcjsypDMtWHM= filippo.io/bigmod v0.0.3/go.mod h1:WxGvOYE0OUaBC2N112Dflb3CjOnMBuNRA2UWZc2UbPE= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/awnumar/memcall v0.1.2 h1:7gOfDTL+BJ6nnbtAp9+HQzUFjtP1hEseRQq8eP055QY= github.com/awnumar/memcall v0.1.2/go.mod h1:S911igBPR9CThzd/hYQQmTc9SWNu3ZHIlCGaWsWsoJo= github.com/awnumar/memguard v0.22.3 h1:b4sgUXtbUjhrGELPbuC62wU+BsPQy+8lkWed9Z+pj0Y= github.com/awnumar/memguard v0.22.3/go.mod h1:mmGunnffnLHlxE5rRgQc3j+uwPZ27eYb61ccr8Clz2Y= github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/containerd/containerd v1.7.27 h1:yFyEyojddO3MIGVER2xJLWoCIn+Up4GaHFquP7hsFII= github.com/containerd/containerd v1.7.27/go.mod h1:xZmPnl75Vc+BLGt4MIfu6bp+fy03gdHAn9bz+FreFR0= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jeremija/gosubmit v0.2.7 h1:At0OhGCFGPXyjPYAsCchoBUhE099pcBXmsb4iZqROIc= github.com/jeremija/gosubmit v0.2.7/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc v1.0.5 h1:bsTfiH8xaKOJPrg1R+E3iE/AWZr/x0Phj9PBTG/OLUk= github.com/lestrrat-go/httprc v1.0.5/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= github.com/lestrrat-go/jwx/v2 v2.0.21 h1:jAPKupy4uHgrHFEdjVjNkUgoBKtVDgrQPB/h55FHrR0= github.com/lestrrat-go/jwx/v2 v2.0.21/go.mod h1:09mLW8zto6bWL9GbwnqAli+ArLf+5M33QLQPDggkUWM= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/melbahja/goph v1.4.0 h1:z0PgDbBFe66lRYl3v5dGb9aFgPy0kotuQ37QOwSQFqs= github.com/melbahja/goph v1.4.0/go.mod h1:uG+VfK2Dlhk+O32zFrRlc3kYKTlV6+BtvPWd/kK7U68= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/openpubkey/openpubkey v0.8.0 h1:nT3FHHmdqy/JK57plJ69bCCLm0WOKjRDp0WJQvtV4ss= github.com/openpubkey/openpubkey v0.8.0/go.mod h1:Y/dYOw3jbBJcuWQ0j7J8FSvZbtWiu56CcAK6QIsMJoo= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.5/go.mod h1:wHDZ0IZX6JcBYRK1TH9bcVq8G7TLpVHYIGJRFnmPfxg= github.com/pkg/sftp v1.13.7 h1:uv+I3nNJvlKZIQGSr8JVQLNHFU9YhhNpvC14Y6KgmSM= github.com/pkg/sftp v1.13.7/go.mod h1:KMKI0t3T6hfA+lTR/ssZdunHo+uwq7ghoN09/FSu3DY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zitadel/logging v0.6.0 h1:t5Nnt//r+m2ZhhoTmoPX+c96pbMarqJvW1Vq6xFTank= github.com/zitadel/logging v0.6.0/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= github.com/zitadel/oidc/v3 v3.23.2 h1:vRUM6SKudr6WR/lqxue4cvCbgR+IdEJGVBklucKKXgk= github.com/zitadel/oidc/v3 v3.23.2/go.mod h1:9snlhm3W/GNURqxtchjL1AAuClWRZ2NTkn9sLs1WYfM= github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo= go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok= go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A= google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA= google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8= google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s= google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= opkssh-0.4.0/internal/000077500000000000000000000000001477307211400146445ustar00rootroot00000000000000opkssh-0.4.0/internal/projectpath/000077500000000000000000000000001477307211400171675ustar00rootroot00000000000000opkssh-0.4.0/internal/projectpath/projectpath.go000066400000000000000000000017411477307211400220440ustar00rootroot00000000000000// Copyright 2025 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 projectpath is used internally by the integration tests to get the // root folder of the opkssh project package projectpath import ( "path/filepath" "runtime" ) // Source: https://stackoverflow.com/a/58294680 var ( _, b, _, _ = runtime.Caller(0) // Root is the root folder of the opkssh project Root = filepath.Join(filepath.Dir(b), "../..") ) opkssh-0.4.0/main.go000066400000000000000000000323341477307211400143100ustar00rootroot00000000000000// Copyright 2025 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 ( "context" "errors" "fmt" "log" "os" "os/exec" "os/signal" "regexp" "strings" "syscall" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/opkssh/commands" "github.com/openpubkey/opkssh/policy" "github.com/openpubkey/opkssh/policy/files" "github.com/spf13/cobra" ) var ( // These can be overridden at build time using ldflags. For example: // go build -v -o /usr/local/bin/opkssh -ldflags "-X main.issuer=http://oidc.local:${ISSUER_PORT}/ -X main.clientID=web -X main.clientSecret=secret" Version = "unversioned" issuer = "" clientID = "" clientSecret = "" redirectURIs = "" logFilePathServer = "/var/log/opkssh.log" // Remember if you change this, change it in the install script as well ) func main() { os.Exit(run()) } func run() int { rootCmd := &cobra.Command{ SilenceUsage: true, Use: "opkssh", Short: "SSH with OpenPubkey", Version: Version, Long: `SSH with OpenPubkey This program allows users to: - Login and create SSH key pairs using their OpenID Connect identity - Add policies to auth_id policy files - Verify OpenPubkey SSH certificates for use with sshd's AuthorizedKeysCommand`, Example: ` opkssh login opkssh add root alice@example.com https://accounts.google.com`, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } rootCmd.CompletionOptions.DisableDefaultCmd = true addCmd := &cobra.Command{ SilenceUsage: true, Use: "add ", Short: "Appends new rule to the policy file", Long: `Add appends a new policy entry in the auth_id policy file granting SSH access to the specified email or subscriber ID (sub) or group. It first attempts to write to the system-wide file (/etc/opk/auth_id). If it lacks permissions to update this file it falls back to writing to the user-specific file (~/.opk/auth_id). Arguments: PRINCIPAL The target user account (requested principal). EMAIL|SUB|GROUP Email address, subscriber ID or group authorized to assume this principal. If using an OIDC group, the argument needs to be in the format of oidc:groups:. ISSUER OpenID Connect provider (issuer) URL associated with the email/sub/group. `, Args: cobra.ExactArgs(3), Example: ` opkssh add root alice@example.com https://accounts.google.com opkssh add alice 103030642802723203118 https://accounts.google.com opkssh add developer oidc:groups:developer https://accounts.google.com`, RunE: func(cmd *cobra.Command, args []string) error { inputPrincipal := args[0] inputEmail := args[1] inputIssuer := args[2] // Convenience aliases to save user time (who is going to remember the hideous Azure issuer string) switch inputIssuer { case "google": inputIssuer = "https://accounts.google.com" case "azure", "microsoft": inputIssuer = "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0" case "gitlab": inputIssuer = "https://gitlab.com" } add := commands.AddCmd{ HomePolicyLoader: policy.NewHomePolicyLoader(), SystemPolicyLoader: policy.NewSystemPolicyLoader(), Username: inputPrincipal, } policyFilePath, err := add.Run(inputPrincipal, inputEmail, inputIssuer) if err != nil { fmt.Fprintf(os.Stderr, "Failed to add to policy: %v\n", err) return err } fmt.Fprintf(os.Stdout, "Successfully added new policy to %s\n", policyFilePath) return nil }, } rootCmd.AddCommand(addCmd) var autoRefresh bool var logDir string var providerArg string var disableBrowserOpenArg bool loginCmd := &cobra.Command{ SilenceUsage: true, Use: "login", Short: "Authenticate with an OpenID Provider to generate an SSH key for opkssh", Long: `Login creates opkssh SSH keys Login generates a key pair, then opens a browser to authenticate the user with the OpenID Provider. Upon successful authentication, opkssh creates an SSH public key (~/.ssh/id_ecdsa) containing the user's PK token. By default, this SSH key expires after 24 hours, after which the user must run "opkssh login" again to generate a new key. Users can then SSH into servers configured to use opkssh as the AuthorizedKeysCommand. The server verifies the PK token and grants access if the token is valid and the user is authorized per the auth_id policy. `, Example: ` opkssh login opkssh login --provider=,`, RunE: func(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigs cancel() }() // If LDFlags issuer is set, build providerFromLdFlags var providerFromLdFlags providers.OpenIdProvider if issuer != "" { opts := providers.GetDefaultGoogleOpOptions() opts.Issuer = issuer opts.ClientID = clientID opts.ClientSecret = clientSecret opts.RedirectURIs = strings.Split(redirectURIs, ",") providerFromLdFlags = providers.NewGoogleOpWithOptions(opts) } login := commands.NewLogin(autoRefresh, logDir, disableBrowserOpenArg, providerArg, providerFromLdFlags) if err := login.Run(ctx); err != nil { log.Println("Error executing login command:", err) return err } return nil }, } // Define flags for login. loginCmd.Flags().BoolVar(&autoRefresh, "auto-refresh", false, "Automatically refresh PK token after login") loginCmd.Flags().StringVar(&logDir, "log-dir", "", "Directory to write output logs") loginCmd.Flags().BoolVar(&disableBrowserOpenArg, "disable-browser-open", false, "Set this flag to disable opening the browser. Useful for choosing the browser you want to use.") loginCmd.Flags().StringVar(&providerArg, "provider", "", "OpenID Provider specification in the format: , or ,,") rootCmd.AddCommand(loginCmd) readhomeCmd := &cobra.Command{ SilenceUsage: true, Use: "readhome ", Short: "Read the principal's home policy file", Long: `Read the principal's policy file (/home//.opk/auth_id). You should not call this command directly. It is called by the opkssh verify command as part of the AuthorizedKeysCommand process to read the user's policy (principals) home file (~/.opk/auth_id) with sudoer permissions. This allows us to use an unprivileged user as the AuthorizedKeysCommand user. `, Args: cobra.ExactArgs(1), Example: ` opkssh readhome alice`, RunE: func(cmd *cobra.Command, args []string) error { userArg := os.Args[2] if fileBytes, err := commands.ReadHome(userArg); err != nil { fmt.Fprintf(os.Stderr, "Failed to read user's home policy file: %v\n", err) return err } else { fmt.Fprint(os.Stdout, string(fileBytes)) return nil } }, } rootCmd.AddCommand(readhomeCmd) verifyCmd := &cobra.Command{ SilenceUsage: true, Use: "verify ", Short: "Verify an SSH key (used by sshd AuthorizedKeysCommand)", Long: `Verify extracts a PK token from a base64-encoded SSH certificate and verifies it against policy. It expects an allowed provider file at /etc/opk/providers and a user policy file at either /etc/opk/auth_id or ~/.opk/auth_id. This command is intended to be called by sshd as an AuthorizedKeysCommand: https://man.openbsd.org/sshd_config#AuthorizedKeysCommand During installation, opkssh typically adds these lines to /etc/ssh/sshd_config: AuthorizedKeysCommand /usr/local/bin/opkssh verify %%u %%k %%t AuthorizedKeysCommandUser opksshuser Where the tokens in /etc/ssh/sshd_config are defined as: %%u Target username (requested principal) %%k Base64-encoded SSH public key (SSH certificate) provided for authentication %%t Public key type (SSH certificate format, e.g., ecdsa-sha2-nistp256-cert-v01@openssh.com) Verification checks performed: 1. Ensures the PK token is properly formed, signed, and issued by the specified OpenID Provider (OP). 2. Confirms the PK token's issue (iss) and client ID (audience) are listed in the allowed provider file (/etc/opk/providers) and the token is not expired. 3. Validates the identity (email or sub) in the PK token against user policies (/etc/opk/auth_id or ~/.opk/auth_id) to ensure it can assume the requested username (principal). If all checks pass, Verify authorizes the SSH connection. Arguments: PRINCIPAL Target username. CERT Base64-encoded SSH certificate. KEY_TYPE SSH certificate key type (e.g., ecdsa-sha2-nistp256-cert-v01@openssh.com)`, Args: cobra.ExactArgs(3), Example: ` opkssh verify root ecdsa-sha2-nistp256-cert-v01@openssh.com`, RunE: func(cmd *cobra.Command, args []string) error { 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 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) } else { defer logFile.Close() log.SetOutput(logFile) } // Logs if using an unsupported OpenSSH version checkOpenSSHVersion() // The "AuthorizedKeysCommand" func is designed to be used by sshd and specified as an AuthorizedKeysCommand // ref: https://man.openbsd.org/sshd_config#AuthorizedKeysCommand log.Println(strings.Join(os.Args, " ")) userArg := args[0] certB64Arg := args[1] typArg := args[2] providerPolicyPath := "/etc/opk/providers" providerPolicy, err := policy.NewProviderFileLoader().LoadProviderPolicy(providerPolicyPath) if err != nil { log.Println("Failed to open /etc/opk/providers:", err) return err } printConfigProblems() log.Println("Providers loaded: ", providerPolicy.ToString()) pktVerifier, err := providerPolicy.CreateVerifier() if err != nil { log.Println("Failed to create pk token verifier (likely bad configuration):", err) return err } v := commands.VerifyCmd{ PktVerifier: *pktVerifier, CheckPolicy: commands.OpkPolicyEnforcerFunc(userArg), } if authKey, err := v.AuthorizedKeysCommand(ctx, userArg, typArg, certB64Arg); err != nil { log.Println("failed to verify:", err) return err } else { log.Println("successfully verified") // sshd is awaiting a specific line, which we print here. Printing anything else before or after will break our solution fmt.Println(authKey) return nil } }, } rootCmd.AddCommand(verifyCmd) err := rootCmd.Execute() if err != nil { return 1 } return 0 } func printConfigProblems() { problems := files.ConfigProblems().GetProblems() if len(problems) > 0 { log.Println("Warning: Encountered the following configuration problems:") for _, problem := range problems { log.Println(problem.String()) } } } // OpenSSH used to impose a 4096-octet limit on the string buffers available to // the percent_expand function. In October 2019 as part of the 8.1 release, // that limit was removed. If you exceeded this amount it would fail with // fatal: percent_expand: string too long // The following two functions check whether the OpenSSH version on the // system running the verifier is greater than or equal to 8.1; // if not then prints a warning func checkOpenSSHVersion() { // Redhat/centos does not recognize `sshd -V` but does recognize `ssh -V` // Ubuntu recognizes both cmd := exec.Command("ssh", "-V") output, err := cmd.CombinedOutput() if err != nil { log.Println("Warning: Error executing ssh -V:", err) return } if ok, _ := isOpenSSHVersion8Dot1OrGreater(string(output)); !ok { log.Println("Warning: OpenPubkey SSH requires OpenSSH v. 8.1 or greater") } } func isOpenSSHVersion8Dot1OrGreater(opensshVersion 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+)*).*`) if err != nil { fmt.Println("Error compiling regex:", err) return false, err } opensshVersion = strings.TrimPrefix( strings.Split(opensshVersion, ", ")[0], "OpenSSH_", ) matches := re.FindStringSubmatch(opensshVersion) if len(matches) <= 0 { fmt.Println("Invalid OpenSSH version") return false, errors.New("invalid OpenSSH version") } version := matches[1] if version >= "8.1" { return true, nil } return false, nil } opkssh-0.4.0/main_test.go000066400000000000000000000161261477307211400153500ustar00rootroot00000000000000// Copyright 2024 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 ( "errors" "io" "os" "strings" "testing" "github.com/stretchr/testify/require" ) func TestIsOpenSSHVersion8Dot1OrGreater(t *testing.T) { tests := []struct { name string input string wantIsGreater bool wantErr error }{ { name: "Exact 8.1", input: "OpenSSH_8.1", wantIsGreater: true, wantErr: nil, }, { name: "Above 8.1 (8.4)", input: "OpenSSH_8.4", wantIsGreater: true, wantErr: nil, }, { name: "Above 8.1 with patch (9.9p1)", input: "OpenSSH_9.9p1", wantIsGreater: true, wantErr: nil, }, { name: "Below 8.1 (7.9)", input: "OpenSSH_7.9", wantIsGreater: false, wantErr: nil, }, { name: "Multiple dotted version above 8.1 (8.1.2)", input: "OpenSSH_8.1.2", wantIsGreater: true, wantErr: nil, }, { name: "Multiple dotted version below 8.1 (7.10.3)", input: "OpenSSH_7.10.3", wantIsGreater: false, wantErr: nil, }, { name: "Malformed version string", input: "OpenSSH_, something not right", wantIsGreater: false, wantErr: errors.New("invalid OpenSSH version"), }, { name: "No OpenSSH prefix at all", input: "Completely invalid input", wantIsGreater: false, wantErr: errors.New("invalid OpenSSH version"), }, { name: "Includes trailing info (8.2, Raspbian-1)", input: "OpenSSH_8.2, Raspbian-1", wantIsGreater: true, wantErr: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotIsGreater, gotErr := isOpenSSHVersion8Dot1OrGreater(tt.input) if gotIsGreater != tt.wantIsGreater { t.Errorf( "isOpenSSHVersion8Dot1OrGreater(%q) got %v; want %v", tt.input, gotIsGreater, tt.wantIsGreater, ) } if (gotErr != nil) != (tt.wantErr != nil) { t.Errorf( "isOpenSSHVersion8Dot1OrGreater(%q) error = %v; want %v", tt.input, gotErr, tt.wantErr, ) } else if gotErr != nil && tt.wantErr != nil { if gotErr.Error() != tt.wantErr.Error() { t.Errorf("Unexpected error message. got %q; want %q", gotErr.Error(), tt.wantErr.Error()) } } }) } } func RunCliAndCaptureResult(t *testing.T, args []string) (string, int) { // Backup and defer restore of os.Args oldArgs := os.Args defer func() { os.Args = oldArgs }() os.Args = args // Capture output oldStdout := os.Stdout oldStderr := os.Stderr r, w, _ := os.Pipe() os.Stdout = w os.Stderr = w // Run the opkssh cli exitCode := run() // Restore stdout and stderr w.Close() os.Stdout = oldStdout os.Stderr = oldStderr // Read captured output var cmdOutput strings.Builder _, err := io.Copy(&cmdOutput, r) require.NoError(t, err) return cmdOutput.String(), exitCode } func TestRun(t *testing.T) { tests := []struct { name string args []string wantOutput string wantExit int }{ { name: "No arguments", args: []string{"opkssh"}, wantOutput: "SSH with OpenPubkey", wantExit: 0, }, { name: "Root Help flag", args: []string{"opkssh", "--help"}, wantOutput: "opkssh [command]", wantExit: 0, }, { name: "Add Help flag", args: []string{"opkssh", "add", "--help"}, wantOutput: "Add appends a new policy entry in the auth_id policy file", wantExit: 0, }, { name: "Login Help flag", args: []string{"opkssh", "login", "--help"}, wantOutput: "Login creates opkssh SSH keys", wantExit: 0, }, { name: "Verify Help flag", args: []string{"opkssh", "verify", "--help"}, wantOutput: "Verify extracts a PK token", wantExit: 0, }, { name: "Version flag", args: []string{"opkssh", "--version"}, wantOutput: "unversioned", wantExit: 0, }, { name: "Unrecognized command", args: []string{"opkssh", "unknown"}, wantOutput: "Error: unknown command \"unknown\"", wantExit: 1, }, { name: "Add command with missing arguments", args: []string{"opkssh", "add"}, wantOutput: "Error: accepts 3 arg(s), received 0", wantExit: 1, }, { name: "Login command with bad arguments", args: []string{"opkssh", "login", "-badarg"}, wantOutput: "Error: unknown shorthand flag:", wantExit: 1, }, { name: "Login command with missing providers arguments", args: []string{"opkssh", "login", "--provider"}, wantOutput: "Error: flag needs an argument: --provider", wantExit: 1, }, { name: "Login command with provider bad provider value", args: []string{"opkssh", "login", "--provider=badvalue"}, wantOutput: "Error: invalid provider argument format. Expected format , or ,, got (badvalue)", wantExit: 1, }, { name: "Login command with provider bad provider issuer value", args: []string{"opkssh", "login", "--provider=badissuer.com,client_id"}, wantOutput: "Error: invalid provider issuer value. Expected issuer to start with 'https://' got (badissuer.com)", wantExit: 1, }, { name: "Login command with provider bad provider good azure issuer but no client id value", args: []string{"opkssh", "login", "--provider=https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0,"}, wantOutput: "invalid provider client-ID value got ()", wantExit: 1, }, { name: "Login command with provider bad provider good google issuer but no client id value", args: []string{"opkssh", "login", "--provider=https://accounts.google.com,client_id"}, wantOutput: "invalid provider argument format. Expected format for google: ,, got (https://accounts.google.com,client_id)", wantExit: 1, }, { name: "Login command with provider bad provider good google issuer but no client secret value", args: []string{"opkssh", "login", "--provider=https://accounts.google.com,client_id,"}, wantOutput: "invalid provider client secret value got () ", wantExit: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmdOutput, exitCode := RunCliAndCaptureResult(t, tt.args) require.Contains(t, cmdOutput, tt.wantOutput, "Incorrect command output") require.Equal(t, tt.wantExit, exitCode, "Incorrect Exit code") }) } } opkssh-0.4.0/policy/000077500000000000000000000000001477307211400143275ustar00rootroot00000000000000opkssh-0.4.0/policy/enforcer.go000066400000000000000000000062751477307211400164730ustar00rootroot00000000000000// Copyright 2025 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 policy import ( "encoding/json" "fmt" "strings" "github.com/openpubkey/openpubkey/pktoken" "golang.org/x/exp/slices" ) // Enforcer evaluates opkssh policy to determine if the desired principal is // permitted type Enforcer struct { PolicyLoader Loader } // type for Identity Token checkedClaims type checkedClaims struct { Email string `json:"email"` Sub string `json:"sub"` Groups []string `json:"groups"` } // Validates that the server defined identity attribute matches the // respective claim from the identity token func validateClaim(claims *checkedClaims, user *User) bool { if strings.HasPrefix(user.IdentityAttribute, "oidc:groups") { oidcGroupSections := strings.Split(user.IdentityAttribute, ":") return slices.Contains(claims.Groups, oidcGroupSections[len(oidcGroupSections)-1]) } // email should be a case-insensitive check // sub should be a case-sensitive check return strings.EqualFold(claims.Email, user.IdentityAttribute) || string(claims.Sub) == user.IdentityAttribute } // CheckPolicy loads the opkssh policy and checks to see if there is a policy // permitting access to principalDesired for the user identified by the PKT's // email claim. Returns nil if access is granted. Otherwise, an error is // returned. // // It is security critical to verify the pkt first before calling this function. // This is because if this function is called first, a timing channel exists which // allows an attacker check what identities and principals are allowed by the policy. func (p *Enforcer) CheckPolicy(principalDesired string, pkt *pktoken.PKToken) error { policy, source, err := p.PolicyLoader.Load() if err != nil { return fmt.Errorf("error loading policy: %w", err) } sourceStr := source.Source() if sourceStr == "" { sourceStr = "" } var claims checkedClaims if err := json.Unmarshal(pkt.Payload, &claims); err != nil { return fmt.Errorf("error unmarshalling pk token payload: %w", err) } issuer, err := pkt.Issuer() if err != nil { return fmt.Errorf("error getting issuer from pk token: %w", err) } for _, user := range policy.Users { // check each entry to see if the user in the checkedClaims is included if validateClaim(&claims, &user) { if issuer != user.Issuer { continue } // if they are, then check if the desired principal is allowed if slices.Contains(user.Principals, principalDesired) { // access granted return nil } } } return fmt.Errorf("no policy to allow %s with (issuer=%s) to assume %s, check policy config at %s", claims.Email, issuer, principalDesired, sourceStr) } opkssh-0.4.0/policy/enforcer_test.go000066400000000000000000000217621477307211400175300ustar00rootroot00000000000000// Copyright 2025 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 policy_test import ( "context" "testing" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/providers/mocks" "github.com/openpubkey/opkssh/policy" "github.com/stretchr/testify/require" ) func NewMockOpenIdProvider() (providers.OpenIdProvider, error) { providerOpts := providers.DefaultMockProviderOpts() op, _, idTokenTemplate, err := providers.NewMockProvider(providerOpts) idTokenTemplate.ExtraClaims = map[string]any{"email": "arthur.aardvark@example.com"} return op, err } func NewMockOpenIdProviderGroups(groups []string) (providers.OpenIdProvider, error) { providerOpts := providers.DefaultMockProviderOpts() op, _, idTokenTemplate, err := providers.NewMockProvider(providerOpts) idTokenTemplate.ExtraClaims = map[string]any{"email": "arthur.aardvark@example.com", "groups": groups} return op, err } func NewMockOpenIdProvider2(gqSign bool, issuer string, clientID string, extraClaims map[string]any) (providers.OpenIdProvider, *mocks.MockProviderBackend, error) { providerOpts := providers.MockProviderOpts{ Issuer: issuer, ClientID: clientID, GQSign: gqSign, NumKeys: 2, CommitType: providers.CommitTypesEnum.NONCE_CLAIM, VerifierOpts: providers.ProviderVerifierOpts{ CommitType: providers.CommitTypesEnum.NONCE_CLAIM, SkipClientIDCheck: false, GQOnly: false, ClientID: clientID, }, } op, mockBackend, _, err := providers.NewMockProvider(providerOpts) if err != nil { return nil, nil, err } expSigningKey, expKeyID, expRecord := mockBackend.RandomSigningKey() idTokenTemplate := &mocks.IDTokenTemplate{ CommitFunc: mocks.AddNonceCommit, Issuer: op.Issuer(), Aud: clientID, KeyID: expKeyID, Alg: expRecord.Alg, ExtraClaims: extraClaims, SigningKey: expSigningKey, } mockBackend.SetIDTokenTemplate(idTokenTemplate) return op, mockBackend, nil } var policyTest = &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@bastionzero.com", Principals: []string{"test"}, Issuer: "https://accounts.example.com", }, { IdentityAttribute: "arthur.aardvark@example.com", Principals: []string{"test"}, Issuer: "https://accounts.example.com", }, { IdentityAttribute: "bob@example.com", Principals: []string{"test"}, Issuer: "https://accounts.example.com", }, }, } var policyTestNoEntry = &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@bastionzero.com", Principals: []string{"test"}, }, { IdentityAttribute: "bob@example.com", Principals: []string{"test"}, }, }, } var policyWithOidcGroup = &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "oidc:groups:a", Principals: []string{"test"}, Issuer: "https://accounts.example.com", }, }, } type MockPolicyLoader struct { // Policy is returned on any call to Load() if Error is nil Policy *policy.Policy // Error is returned on any call to Load() if non-nil Error error } var _ policy.Loader = &MockPolicyLoader{} // Load implements policy.Loader. func (m *MockPolicyLoader) Load() (*policy.Policy, policy.Source, error) { if m.Error == nil { return m.Policy, policy.EmptySource{}, nil } else { return nil, nil, m.Error } } func TestPolicyApproved(t *testing.T) { t.Parallel() op, err := NewMockOpenIdProvider() require.NoError(t, err) opkClient, err := client.New(op) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) require.NoError(t, err) policyEnforcer := &policy.Enforcer{ PolicyLoader: &MockPolicyLoader{Policy: policyTest}, } // Check that policy file is properly parsed and checked err = policyEnforcer.CheckPolicy("test", pkt) require.NoError(t, err) } func TestPolicyEmailDifferentCase(t *testing.T) { t.Parallel() op, err := NewMockOpenIdProvider() require.NoError(t, err) opkClient, err := client.New(op) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) require.NoError(t, err) var policyWithDiffCapitalizationThanEmail = &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "ArThuR.AArdVARK@Example.COM", Principals: []string{"test"}, Issuer: "https://accounts.example.com", }, }, } policyEnforcer := &policy.Enforcer{ PolicyLoader: &MockPolicyLoader{Policy: policyWithDiffCapitalizationThanEmail}, } err = policyEnforcer.CheckPolicy("test", pkt) require.NoError(t, err, "user should have access despite email capitalization differences") } func TestPolicyDeniedBadUser(t *testing.T) { t.Parallel() op, err := NewMockOpenIdProvider() require.NoError(t, err) opkClient, err := client.New(op) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) require.NoError(t, err) policyEnforcer := &policy.Enforcer{ PolicyLoader: &MockPolicyLoader{Policy: policyTest}, } err = policyEnforcer.CheckPolicy("baduser", pkt) require.Error(t, err, "user should not have access") } func TestPolicyDeniedNoUserEntry(t *testing.T) { t.Parallel() op, err := NewMockOpenIdProvider() require.NoError(t, err) opkClient, err := client.New(op) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) require.NoError(t, err) policyEnforcer := &policy.Enforcer{ PolicyLoader: &MockPolicyLoader{Policy: policyTestNoEntry}, } err = policyEnforcer.CheckPolicy("test", pkt) require.Error(t, err, "user should not have access") } func TestPolicyDeniedWrongIssuer(t *testing.T) { t.Parallel() op, err := NewMockOpenIdProvider() require.NoError(t, err) opkClient, err := client.New(op) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) require.NoError(t, err) var policyWithDiffCapitalizationThanEmail = &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "arthur.aardvark@example.com", Principals: []string{"test"}, Issuer: "https://differentIssuer.example.com", }, }, } policyEnforcer := &policy.Enforcer{ PolicyLoader: &MockPolicyLoader{Policy: policyWithDiffCapitalizationThanEmail}, } err = policyEnforcer.CheckPolicy("test", pkt) require.Error(t, err, "user should not have access due to wrong issuer") } func TestPolicyApprovedOidcGroups(t *testing.T) { t.Parallel() op, err := NewMockOpenIdProviderGroups([]string{"a", "b", "c"}) require.NoError(t, err) opkClient, err := client.New(op) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) require.NoError(t, err) policyEnforcer := &policy.Enforcer{ PolicyLoader: &MockPolicyLoader{Policy: policyWithOidcGroup}, } err = policyEnforcer.CheckPolicy("test", pkt) require.NoError(t, err) } func TestPolicyApprovedOidcGroupWithAtSign(t *testing.T) { t.Parallel() op, err := NewMockOpenIdProviderGroups([]string{"it.infra@my_domain.com"}) policyLine := &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "oidc:groups:it.infra@my_domain.com", Principals: []string{"test"}, Issuer: "https://accounts.example.com", }, }, } require.NoError(t, err) opkClient, err := client.New(op) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) require.NoError(t, err) policyEnforcer := &policy.Enforcer{ PolicyLoader: &MockPolicyLoader{Policy: policyLine}, } err = policyEnforcer.CheckPolicy("test", pkt) require.NoError(t, err) } func TestPolicyDeniedOidcGroups(t *testing.T) { t.Parallel() op, err := NewMockOpenIdProviderGroups([]string{"z"}) require.NoError(t, err) opkClient, err := client.New(op) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) require.NoError(t, err) policyEnforcer := &policy.Enforcer{ PolicyLoader: &MockPolicyLoader{Policy: policyWithOidcGroup}, } err = policyEnforcer.CheckPolicy("test", pkt) require.Error(t, err, "user should not as they don't have group 'c'") } func TestPolicyDeniedMissingOidcGroupsClaim(t *testing.T) { t.Parallel() op, err := NewMockOpenIdProvider() require.NoError(t, err) opkClient, err := client.New(op) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) require.NoError(t, err) policyEnforcer := &policy.Enforcer{ PolicyLoader: &MockPolicyLoader{Policy: policyWithOidcGroup}, } err = policyEnforcer.CheckPolicy("test", pkt) require.Error(t, err, "user should not as the token is missing the groups claim") } opkssh-0.4.0/policy/files/000077500000000000000000000000001477307211400154315ustar00rootroot00000000000000opkssh-0.4.0/policy/files/configlog.go000066400000000000000000000041411477307211400177270ustar00rootroot00000000000000// Copyright 2025 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 files import ( "fmt" "strings" "sync" ) type ConfigProblem struct { Filepath string OffendingLine string OffendingLineNumber int ErrorMessage string Source string } func (e ConfigProblem) String() string { return "encountered error: " + e.ErrorMessage + ", reading " + e.OffendingLine + " in " + e.Filepath + " at line " + fmt.Sprint(e.OffendingLineNumber) } type ConfigLog struct { log []ConfigProblem logMutex sync.Mutex } func (c *ConfigLog) RecordProblem(entry ConfigProblem) { c.logMutex.Lock() defer c.logMutex.Unlock() c.log = append(c.log, entry) } func (c *ConfigLog) GetProblems() []ConfigProblem { c.logMutex.Lock() defer c.logMutex.Unlock() logCopy := make([]ConfigProblem, len(c.log)) copy(logCopy, c.log) return logCopy } func (c *ConfigLog) NoProblems() bool { c.logMutex.Lock() defer c.logMutex.Unlock() return len(c.log) == 0 } func (c *ConfigLog) String() string { // No mutex needed since GetLogs handles the mutex logs := c.GetProblems() logsStrings := []string{} for _, log := range logs { logsStrings = append(logsStrings, log.String()) } return strings.Join(logsStrings, "\n") } func (c *ConfigLog) Clear() { c.logMutex.Lock() defer c.logMutex.Unlock() c.log = []ConfigProblem{} } var ( singleton *ConfigLog once sync.Once ) func ConfigProblems() *ConfigLog { once.Do(func() { singleton = &ConfigLog{ log: []ConfigProblem{}, logMutex: sync.Mutex{}, } }) return singleton } opkssh-0.4.0/policy/files/configlog_test.go000066400000000000000000000062371477307211400207760ustar00rootroot00000000000000// Copyright 2025 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 files import ( "testing" "github.com/stretchr/testify/assert" ) func TestLog(t *testing.T) { tests := []struct { name string clearWhenDone bool input *[]ConfigProblem output string }{ { name: "empty", clearWhenDone: true, input: nil, output: "", }, { name: "single entry", clearWhenDone: true, input: &[]ConfigProblem{ { Filepath: "/path/to/file", OffendingLine: "offending line", OffendingLineNumber: 5, ErrorMessage: "wrong number of arguments", Source: "test 1", }, }, output: "encountered error: wrong number of arguments, reading offending line in /path/to/file at line 5", }, { name: "multiple entries", clearWhenDone: false, input: &[]ConfigProblem{ { Filepath: "/path/to/fileA", OffendingLine: "offending line 1", OffendingLineNumber: 77, ErrorMessage: "wrong number of arguments", Source: "test 2", }, { Filepath: "/path/to/fileB", OffendingLine: "offending line 2", OffendingLineNumber: 2, ErrorMessage: "could not parse", Source: "test 3", }, }, output: "encountered error: wrong number of arguments, reading offending line 1 in /path/to/fileA at line 77\nencountered error: could not parse, reading offending line 2 in /path/to/fileB at line 2", }, { name: "make sure that the log persists", clearWhenDone: true, input: &[]ConfigProblem{ { Filepath: "/path/to/filec", OffendingLine: "offending line 2", OffendingLineNumber: 128, ErrorMessage: "wrong number of arguments", Source: "test 4", }, }, output: "encountered error: wrong number of arguments, reading offending line 1 in /path/to/fileA at line 77\nencountered error: could not parse, reading offending line 2 in /path/to/fileB at line 2\nencountered error: wrong number of arguments, reading offending line 2 in /path/to/filec at line 128", }, { name: "check clear", clearWhenDone: true, input: nil, output: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { configLog := ConfigProblems() if tt.input != nil { for _, entry := range *tt.input { configLog.RecordProblem(entry) } } assert.Equal(t, tt.output, configLog.String()) if tt.clearWhenDone { configLog.Clear() } }) } } opkssh-0.4.0/policy/files/fileloader.go000066400000000000000000000053631477307211400200750ustar00rootroot00000000000000// Copyright 2025 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 files import ( "fmt" "io/fs" "path/filepath" "github.com/spf13/afero" ) // UserPolicyLoader contains methods to read/write the opkssh policy file from/to an // arbitrary filesystem. All methods that read policy from the filesystem fail // and return an error immediately if the permission bits are invalid. type FileLoader struct { Fs afero.Fs RequiredPerm fs.FileMode } // CreateIfDoesNotExist creates a file at the given path if it does not exist. func (l FileLoader) CreateIfDoesNotExist(path string) error { exists, err := afero.Exists(l.Fs, path) if err != nil { return err } if !exists { dirPath := filepath.Dir(path) if err := l.Fs.MkdirAll(dirPath, 0750); err != nil { return fmt.Errorf("failed to create directory: %w", err) } file, err := l.Fs.Create(path) if err != nil { return fmt.Errorf("failed to create file: %w", err) } file.Close() if err := l.Fs.Chmod(path, l.RequiredPerm); err != nil { return fmt.Errorf("failed to set file permissions: %w", err) } } return nil } // LoadFileAtPath validates that the file at path exists, can be read // by the current process, and has the correct permission bits set. Parses the // contents and returns the bytes if file permissions are valid and // reading is successful; otherwise returns an error. func (l *FileLoader) LoadFileAtPath(path string) ([]byte, error) { // Check if file exists and we can access it if _, err := l.Fs.Stat(path); err != nil { return nil, fmt.Errorf("failed to describe the file at path: %w", err) } // Validate that file has correct permission bits set if err := NewPermsChecker(l.Fs).CheckPerm(path, l.RequiredPerm, "", ""); err != nil { return nil, fmt.Errorf("policy file has insecure permissions: %w", err) } // Read file contents afs := &afero.Afero{Fs: l.Fs} content, err := afs.ReadFile(path) if err != nil { return nil, err } return content, nil } // Dump writes the bytes in fileBytes to the filepath func (l *FileLoader) Dump(fileBytes []byte, path string) error { // Write to disk if err := afero.WriteFile(l.Fs, path, fileBytes, l.RequiredPerm); err != nil { return err } return nil } opkssh-0.4.0/policy/files/permschecker.go000066400000000000000000000064341477307211400204420ustar00rootroot00000000000000// Copyright 2025 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 files import ( "fmt" "io/fs" "log" "os/exec" "strings" "github.com/spf13/afero" ) // ModeSystemPerms is the expected permission bits that should be set for opkssh // system policy files (`/etc/opk/auth_id`, `/etc/opk/providers`). This mode means // that only the owner of the file can write/read to the file, but the group which // should be opksshuser can read the file. const ModeSystemPerms = fs.FileMode(0640) // ModeHomePerms is the expected permission bits that should be set for opkssh // user home policy files `~/.opk/auth_id`. const ModeHomePerms = fs.FileMode(0600) // PermsChecker contains methods to check the ownership, group // and file permissions of a file on a Unix-like system. type PermsChecker struct { Fs afero.Fs cmdRunner func(string, ...string) ([]byte, error) } func NewPermsChecker(fs afero.Fs) *PermsChecker { return &PermsChecker{Fs: fs, cmdRunner: execCmd} } // CheckPerm checks the file at the given path if it has the desired permissions. // If the requiredOwner or requiredGroup are not empty then the function will also // that the owner and group of the file match the requiredOwner and requiredGroup // specified and fail if they do not. func (u *PermsChecker) CheckPerm(path string, requirePerm fs.FileMode, requiredOwner string, requiredGroup string) error { fileInfo, err := u.Fs.Stat(path) if err != nil { return fmt.Errorf("failed to describe the file at path: %w", err) } mode := fileInfo.Mode() // if the requiredOwner or requiredGroup are specified then run stat and check if they match if requiredOwner != "" || requiredGroup != "" { log.Println("Running, command: ", "stat", "-c", "%U %G", path) statOutput, err := u.cmdRunner("stat", "-c", "%U %G", path) log.Println("Got output:", string(statOutput)) if err != nil { return fmt.Errorf("failed to run stat: %w", err) } statOutputSplit := strings.Split(strings.TrimSpace(string(statOutput)), " ") statOwner := statOutputSplit[0] statGroup := statOutputSplit[1] if len(statOutputSplit) != 2 { return fmt.Errorf("expected stat command to return 2 values got %d", len(statOutputSplit)) } if requiredOwner != "" { if requiredOwner != statOwner { return fmt.Errorf("expected owner (%s), got (%s)", requiredOwner, statOwner) } } if requiredGroup != "" { if requiredGroup != statGroup { return fmt.Errorf("expected group (%s), got (%s)", requiredGroup, statGroup) } } } if mode.Perm() != requirePerm { return fmt.Errorf("expected permissions (%o), got (%o)", requirePerm.Perm(), mode.Perm()) } return nil } func execCmd(name string, arg ...string) ([]byte, error) { cmd := exec.Command(name, arg...) return cmd.CombinedOutput() } opkssh-0.4.0/policy/files/permschecker_test.go000066400000000000000000000116321477307211400214750ustar00rootroot00000000000000// Copyright 2025 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 files import ( "fmt" "io/fs" "testing" "github.com/spf13/afero" "github.com/stretchr/testify/require" ) func TestPermissionsChecker(t *testing.T) { tests := []struct { name string filePath string filePathExpected string perms fs.FileMode permsExpected fs.FileMode owner string ownerExpected string group string groupExpected string cmdError error errorExpected string }{ { name: "simple happy path (all match)", filePath: "/test_file", filePathExpected: "/test_file", perms: 0777, permsExpected: 0777, owner: "testOwner", ownerExpected: "testOwner", group: "testGroup", groupExpected: "testGroup", cmdError: nil, errorExpected: "", }, { name: "simple happy path (owner not checked)", filePath: "/test_file", filePathExpected: "/test_file", perms: 0777, permsExpected: 0777, owner: "testOwner", ownerExpected: "", group: "testGroup", groupExpected: "testGroup", cmdError: nil, errorExpected: "", }, { name: "simple happy path (group not checked)", filePath: "/test_file", filePathExpected: "/test_file", perms: 0777, permsExpected: 0777, owner: "testOwner", ownerExpected: "testOwner", group: "testGroup", groupExpected: "", cmdError: nil, errorExpected: "", }, { name: "simple happy path (only perm checked)", filePath: "/test_file", filePathExpected: "/test_file", perms: 0777, permsExpected: 0777, owner: "testOwner", ownerExpected: "", group: "testGroup", groupExpected: "", cmdError: nil, errorExpected: "", }, { name: "error (owner doesn't match)", filePath: "/test_file", filePathExpected: "/test_file", perms: 0777, permsExpected: 0777, owner: "testOwner", ownerExpected: "testDiffOwner", group: "testGroup", groupExpected: "", cmdError: nil, errorExpected: "expected owner (testDiffOwner), got (testOwner)", }, { name: "error (owner doesn't match)", filePath: "/test_file", filePathExpected: "/test_file", perms: 0777, permsExpected: 0777, owner: "testOwner", ownerExpected: "", group: "testGroup", groupExpected: "testDiffGroup", cmdError: nil, errorExpected: "expected group (testDiffGroup), got (testGroup)", }, { name: "error (perms don't match)", filePath: "/test_file", filePathExpected: "/test_file", perms: 0640, permsExpected: 0650, owner: "testOwner", ownerExpected: "", group: "testGroup", groupExpected: "", cmdError: nil, errorExpected: "expected permissions (650), got (640)", }, { name: "error (stat command error)", filePath: "/test_file", filePathExpected: "/test_file", perms: 0640, permsExpected: 0650, owner: "testOwner", ownerExpected: "testDiffGroup", group: "testGroup", groupExpected: "testDiffGroup", cmdError: fmt.Errorf("stat command error"), errorExpected: "failed to run stat: stat command error", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { execCmdMock := func(name string, arg ...string) ([]byte, error) { if tt.cmdError != nil { return nil, tt.cmdError } return []byte(tt.owner + " " + tt.group), nil } mockFs := afero.NewMemMapFs() permChecker := PermsChecker{ Fs: mockFs, cmdRunner: execCmdMock, } err := afero.WriteFile(mockFs, tt.filePath, []byte("1234567890"), tt.perms) require.NoError(t, err) err = permChecker.CheckPerm(tt.filePathExpected, tt.permsExpected, tt.ownerExpected, tt.groupExpected) if tt.errorExpected != "" { require.Error(t, err) require.ErrorContains(t, err, tt.errorExpected) } else { require.NoError(t, err) } }) } } opkssh-0.4.0/policy/files/table.go000066400000000000000000000027671477307211400170630ustar00rootroot00000000000000// Copyright 2025 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 files import "strings" type Table struct { rows [][]string } func NewTable(content []byte) *Table { table := [][]string{} rows := strings.Split(string(content), "\n") for _, row := range rows { row := CleanRow(row) if row == "" { continue } columns := strings.Fields(row) table = append(table, columns) } return &Table{rows: table} } func CleanRow(row string) string { // Remove comments rowFixed := strings.Split(row, "#")[0] // Skip empty rows rowFixed = strings.TrimSpace(rowFixed) return rowFixed } func (t *Table) AddRow(row ...string) { t.rows = append(t.rows, row) } func (t Table) ToString() string { var sb strings.Builder for _, row := range t.rows { sb.WriteString(strings.Join(row, " ") + "\n") } return sb.String() } func (t Table) ToBytes() []byte { return []byte(t.ToString()) } func (t Table) GetRows() [][]string { return t.rows } opkssh-0.4.0/policy/files/table_test.go000066400000000000000000000037511477307211400201140ustar00rootroot00000000000000// Copyright 2025 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 files import ( "testing" "github.com/stretchr/testify/assert" ) func TestToTable(t *testing.T) { tests := []struct { name string input string output [][]string }{ { name: "empty", input: "", output: [][]string{}, }, { name: "multiple empty rows", input: "\n \n\n \n", output: [][]string{}, }, { name: "commented out row", input: "# this is a comment\n", output: [][]string{}, }, { name: "multiple rows with comment", input: "1 2 3\n 4 5#comment \n6 7 #comment\n 8", output: [][]string{{"1", "2", "3"}, {"4", "5"}, {"6", "7"}, {"8"}}, }, { name: "realistic input", input: `# 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`, output: [][]string{ {"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"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { inputBytes := []byte(tt.input) assert.Equal(t, tt.output, NewTable(inputBytes).GetRows()) }) } } opkssh-0.4.0/policy/multipolicyloader.go000066400000000000000000000075611477307211400204300ustar00rootroot00000000000000// Copyright 2025 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 policy import ( "errors" "fmt" "log" "os/exec" "strings" ) var _ Loader = &MultiPolicyLoader{} // FileSource implements policy.Source by returning a string that is expected to // be a filepath type FileSource string func (s FileSource) Source() string { return string(s) } // MultiPolicyLoader implements policy.Loader by reading both the system default // policy (root policy) and user policy (~/.opk/auth_id where ~ maps to // Username's home directory) type MultiPolicyLoader struct { HomePolicyLoader *HomePolicyLoader SystemPolicyLoader *SystemPolicyLoader LoadWithScript bool Username string } func (l *MultiPolicyLoader) Load() (*Policy, Source, error) { policy := new(Policy) // Try to load the root policy rootPolicy, _, rootPolicyErr := l.SystemPolicyLoader.LoadSystemPolicy() if rootPolicyErr != nil { log.Println("warning: failed to load system default policy:", rootPolicyErr) } // Try to load the user policy userPolicy, userPolicyFilePath, userPolicyErr := l.HomePolicyLoader.LoadHomePolicy(l.Username, true, ReadWithSudoScript) if userPolicyErr != nil { log.Println("warning: failed to load user policy:", userPolicyErr) } // Log warning if no error loading, but userPolicy is empty meaning that // there are no valid entries if userPolicyErr == nil && len(userPolicy.Users) == 0 { log.Printf("warning: user policy %s has no valid user entries; an entry is considered valid if it gives %s access.", userPolicyFilePath, l.Username) } // Failed to read both policies. Return multi-error if rootPolicy == nil && userPolicy == nil { return nil, EmptySource{}, errors.Join(rootPolicyErr, userPolicyErr) } // TODO-Yuval: Optimize by merging duplicate entries instead of blindly // appending readPaths := []string{} if rootPolicy != nil { policy.Users = append(policy.Users, rootPolicy.Users...) readPaths = append(readPaths, SystemDefaultPolicyPath) } if userPolicy != nil { policy.Users = append(policy.Users, userPolicy.Users...) readPaths = append(readPaths, userPolicyFilePath) } return policy, FileSource(strings.Join(readPaths, ", ")), nil } // ReadWithSudoScript specifies additional way of loading the policy in the // user's home directory (`~/.opk/auth_id`). This is needed when the // AuthorizedKeysCommand user does not have privileges to transverse the user's // home directory. Instead we call run a command which uses special // sudoers permissions to read the policy file. // // Doing this is more secure than simply giving opkssh sudoer access because // if there was an RCE in opkssh could be triggered an SSH request via // AuthorizedKeysCommand, the new opkssh process we use to perform the read // would not be compromised. Thus, the compromised opkssh process could not assume // full root privileges. func ReadWithSudoScript(h *HomePolicyLoader, username string) ([]byte, error) { // opkssh readhome ensures the file is not a symlink and has the permissions/ownership. cmd := exec.Command("sudo", "-n", "/usr/local/bin/opkssh", "readhome", username) homePolicyFileBytes, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("error reading %s home policy using command %v got output %v and err %v", username, cmd, string(homePolicyFileBytes), err) } return homePolicyFileBytes, nil } opkssh-0.4.0/policy/multipolicyloader_test.go000066400000000000000000000167001477307211400214620ustar00rootroot00000000000000// Copyright 2025 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 policy_test import ( "path" "strings" "testing" "github.com/openpubkey/opkssh/policy" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func policyToMapUsers(p *policy.Policy) map[string]policy.User { m := make(map[string]*policy.User) for e, user := range p.Users { if seenUserEntry, ok := m[user.IdentityAttribute]; ok { seenUserEntry.Principals = append(seenUserEntry.Principals, user.Principals...) } else { entry := p.Users[e] m[user.IdentityAttribute] = &entry } } mapWithValues := make(map[string]policy.User) for k, v := range m { // Safe because we never put nil in map above mapWithValues[k] = *v } return mapWithValues } func TestLoad(t *testing.T) { t.Parallel() tests := []struct { name string // rootPolicy is the policy read from the system default policy path. If // nil the file will be missing rootPolicy *policy.Policy // userPolicy is the policy read from the ValidUser's home directory. If // nil the file will be missing userPolicy *policy.Policy expectedUsers map[string]policy.User shouldError bool }{ { name: "both policies are missing", rootPolicy: nil, userPolicy: nil, shouldError: true, }, { name: "only root policy exists", rootPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, }, }, expectedUsers: map[string]policy.User{ "alice@example.com": {IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com"}, }, }, { name: "only user policy exists", userPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{ValidUser.Username, "bob"}, Issuer: "https://example.com", }, }, }, expectedUsers: map[string]policy.User{ "alice@example.com": {IdentityAttribute: "alice@example.com", Principals: []string{ValidUser.Username}, Issuer: "https://example.com"}, }, }, { name: "both user and root policy exist", rootPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, { IdentityAttribute: "charlie@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, }, }, userPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{ValidUser.Username}, Issuer: "https://example.com", }, { IdentityAttribute: "bob@example.com", Principals: []string{ValidUser.Username}, Issuer: "https://example.com", }, }, }, expectedUsers: map[string]policy.User{ "alice@example.com": {IdentityAttribute: "alice@example.com", Principals: []string{"test", ValidUser.Username}, Issuer: "https://example.com"}, "bob@example.com": {IdentityAttribute: "bob@example.com", Principals: []string{ValidUser.Username}, Issuer: "https://example.com"}, "charlie@example.com": {IdentityAttribute: "charlie@example.com", Principals: []string{"test"}, Issuer: "https://example.com"}, }, }, { name: "both user and root policy exist but no valid user policy entries", rootPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, { IdentityAttribute: "charlie@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, }, }, userPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, { IdentityAttribute: "bob@example.com", Principals: []string{"test", "test2"}, Issuer: "https://example.com", }, { IdentityAttribute: "charlie@example.com", Principals: []string{"test", "test2", "test3"}, Issuer: "https://example.com", }, }, }, expectedUsers: map[string]policy.User{ "alice@example.com": {IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com"}, "charlie@example.com": {IdentityAttribute: "charlie@example.com", Principals: []string{"test"}, Issuer: "https://example.com"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockFs := afero.NewMemMapFs() // Init SUT on each sub-test multiFileLoader := &policy.MultiPolicyLoader{ HomePolicyLoader: NewTestHomePolicyLoader(mockFs, &MockUserLookup{User: ValidUser}), SystemPolicyLoader: NewTestSystemPolicyLoader(mockFs, &MockUserLookup{User: ValidUser}), Username: ValidUser.Username, } t.Logf("Root policy: %#v", tt.rootPolicy) t.Logf("User policy: %#v", tt.userPolicy) // Create files at expected paths expectedPaths := []string{} if tt.rootPolicy != nil { policyFile, err := tt.rootPolicy.ToTable() require.NoError(t, err) err = afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, policyFile, 0640) require.NoError(t, err) expectedPaths = append(expectedPaths, policy.SystemDefaultPolicyPath) } if tt.userPolicy != nil { policyFile, err := tt.userPolicy.ToTable() require.NoError(t, err) expectedPath := path.Join(ValidUser.HomeDir, ".opk", "auth_id") err = afero.WriteFile(mockFs, expectedPath, policyFile, 0600) require.NoError(t, err) expectedPaths = append(expectedPaths, expectedPath) } policy, source, err := multiFileLoader.Load() if tt.shouldError { require.Error(t, err) require.Nil(t, policy, "should not return policy if error") require.Empty(t, source.Source(), "should not return source if error") } else { // Check error require.NoError(t, err) // Check paths paths := strings.Split(source.Source(), ",") var pathsCleaned []string for _, p := range paths { pathsCleaned = append(pathsCleaned, strings.TrimSpace(p)) } require.ElementsMatch(t, expectedPaths, pathsCleaned) // Check user entries gotUsers := policyToMapUsers(policy) for email, expectedEntry := range tt.expectedUsers { gotEntry, ok := gotUsers[email] if assert.True(t, ok, "policy should have entry for email %s", email) { assert.Equal(t, expectedEntry.IdentityAttribute, gotEntry.IdentityAttribute) assert.ElementsMatch(t, expectedEntry.Principals, gotEntry.Principals) } } } }) } } opkssh-0.4.0/policy/policy.go000066400000000000000000000122751477307211400161640ustar00rootroot00000000000000// Copyright 2025 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 policy import ( "fmt" "log" "strings" "github.com/openpubkey/opkssh/policy/files" ) // User is an opkssh policy user entry type User struct { // IdentityAttribute is a string that is either structured or unstructured. // Structured: :: // E.g. `oidc:groups:ssh-users` // Using the structured identifier allows the capability of constructing // complex user matchers. // // Unstructured: // This is older version that only works with OIDC Identity Tokens, with // the claim being `email` or `sub`. The expected value is to be the user's // email or the user's subscriber ID. The expected value used when comparing // against an id_token's email claim Subscriber ID is a unique identifier // for the user at the OpenID Provider IdentityAttribute string // Principals is a list of allowed principals Principals []string // Sub string Issuer string } // Policy represents an opkssh policy type Policy struct { // Users is a list of all user entries in the policy Users []User } // FromTable decodes whitespace delimited input into policy.Policy func FromTable(input []byte, path string) *Policy { table := files.NewTable(input) policy := &Policy{} for i, row := range table.GetRows() { // Error should not break everyone's ability to login, skip those rows if len(row) != 3 { configProblem := files.ConfigProblem{ Filepath: path, OffendingLine: strings.Join(row, " "), OffendingLineNumber: i, ErrorMessage: fmt.Sprintf("wrong number of arguments (expected=3, got=%d)", len(row)), Source: "user policy file", } files.ConfigProblems().RecordProblem(configProblem) continue } user := User{ Principals: []string{row[0]}, IdentityAttribute: row[1], Issuer: row[2], } policy.Users = append(policy.Users, user) } return policy } // AddAllowedPrincipal adds a new allowed principal to the user whose email is // equal to userEmail. If no user can be found with the email userEmail, then a // new user entry is added with an initial allowed principals list containing // principal. No changes are made if the principal is already allowed for this // user. func (p *Policy) AddAllowedPrincipal(principal string, userEmail string, issuer string) { userExists := false if len(p.Users) != 0 { // search to see if the current user already has an entry in the policy // file for i := range p.Users { user := &p.Users[i] if user.IdentityAttribute == userEmail && user.Issuer == issuer { principalExists := false for _, p := range user.Principals { // if the principal already exists for this user, then skip if p == principal { log.Printf("User with email %s already has access under the principal %s, skipping...\n", userEmail, principal) principalExists = true } } if !principalExists { user.Principals = append(user.Principals, principal) user.Issuer = issuer log.Printf("Successfully added user with email %s with principal %s to the policy file\n", userEmail, principal) } userExists = true } } } // if the policy is empty or if no user found with userEmail, then create a // new entry if len(p.Users) == 0 || !userExists { newUser := User{ IdentityAttribute: userEmail, Principals: []string{principal}, Issuer: issuer, } // add the new user to the list of users in the policy p.Users = append(p.Users, newUser) } } // ToTable encodes the policy into a whitespace delimited table func (p *Policy) ToTable() ([]byte, error) { table := files.Table{} for _, user := range p.Users { for _, principal := range user.Principals { table.AddRow(principal, user.IdentityAttribute, user.Issuer) } } return table.ToBytes(), nil } // Source declares the minimal interface to describe the source of a fetched // opkssh policy (i.e. where the policy is retrieved from) type Source interface { // Source returns a string describing the source of an opkssh policy. The // returned value is empty if there is no information about its source Source() string } var _ Source = &EmptySource{} // EmptySource implements policy.Source and returns an empty string as the // source type EmptySource struct{} func (EmptySource) Source() string { return "" } // Loader declares the minimal interface to retrieve an opkssh policy from an // arbitrary source type Loader interface { // Load fetches an opkssh policy and returns information describing its // source. If an error occurs, all return values are nil except the error // value Load() (*Policy, Source, error) } opkssh-0.4.0/policy/policy_test.go000066400000000000000000000071741477307211400172250ustar00rootroot00000000000000// Copyright 2025 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 policy_test import ( "testing" "github.com/openpubkey/opkssh/policy" "github.com/stretchr/testify/assert" ) func TestAddAllowedPrincipal(t *testing.T) { t.Parallel() defaultIssuer := "https://example.com" // Test adding an allowed principal to an opkssh policy tests := []struct { name string principal string userEmail string initialPolicy *policy.Policy expectedPolicy *policy.Policy }{ { name: "empty policy", principal: "test", userEmail: "alice@example.com", initialPolicy: &policy.Policy{}, expectedPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, }, }, }, { name: "non-empty policy. user not found", principal: "test", userEmail: "bob@example.com", initialPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test", "test2"}, Issuer: "https://example.com", }, }}, expectedPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "bob@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, { IdentityAttribute: "alice@example.com", Principals: []string{"test", "test2"}, Issuer: "https://example.com", }, }, }, }, { name: "user already exists. new principal", principal: "test3", userEmail: "alice@example.com", initialPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test", "test2"}, Issuer: "https://example.com", }, }}, expectedPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test", "test2", "test3"}, Issuer: "https://example.com", }, }, }, }, { name: "user already exists. principal not new.", principal: "test", userEmail: "alice@example.com", initialPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, }}, expectedPolicy: &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Logf("AddAllowedPrincipal(principal=%s, userEmail=%s)", tt.principal, tt.userEmail) t.Logf("Initial policy: %#v", tt.initialPolicy) tt.initialPolicy.AddAllowedPrincipal(tt.principal, tt.userEmail, defaultIssuer) assert.ElementsMatch(t, tt.expectedPolicy.Users, tt.initialPolicy.Users) }) } } opkssh-0.4.0/policy/policyloader.go000066400000000000000000000167561477307211400173630ustar00rootroot00000000000000// Copyright 2025 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 policy import ( "fmt" "os/user" "path" "path/filepath" "github.com/openpubkey/opkssh/policy/files" "github.com/spf13/afero" "golang.org/x/exp/slices" ) // SystemDefaultPolicyPath is the default filepath where opkssh policy is // defined var SystemDefaultPolicyPath = filepath.FromSlash("/etc/opk/auth_id") // UserLookup defines the minimal interface to lookup users on the current // system type UserLookup interface { Lookup(username string) (*user.User, error) } // OsUserLookup implements the UserLookup interface by invoking the os/user // library type OsUserLookup struct{} func NewOsUserLookup() UserLookup { return &OsUserLookup{} } func (OsUserLookup) Lookup(username string) (*user.User, error) { return user.Lookup(username) } // PolicyLoader contains methods to read/write the opkssh policy file from/to an // arbitrary filesystem. All methods that read policy from the filesystem fail // and return an error immediately if the permission bits are invalid. type PolicyLoader struct { FileLoader files.FileLoader UserLookup UserLookup } func (l PolicyLoader) CreateIfDoesNotExist(path string) error { return l.FileLoader.CreateIfDoesNotExist(path) } // LoadPolicyAtPath validates that the policy file at path exists, can be read // by the current process, and has the correct permission bits set. Parses the // contents and returns a policy.Policy if file permissions are valid and // reading is successful; otherwise returns an error. func (l *PolicyLoader) LoadPolicyAtPath(path string) (*Policy, error) { content, err := l.FileLoader.LoadFileAtPath(path) if err != nil { return nil, err } policy := FromTable(content, path) return policy, nil } // Dump encodes the policy into file and writes the contents to the filepath // path func (l *PolicyLoader) Dump(policy *Policy, path string) error { fileBytes, err := policy.ToTable() if err != nil { return err } // Write to disk if err := l.FileLoader.Dump(fileBytes, path); err != nil { return fmt.Errorf("failed to write to policy file %s: %w", path, err) } return nil } // NewSystemPolicyLoader returns an opkssh policy loader that uses the os library to // read/write system policy from/to the filesystem. func NewSystemPolicyLoader() *SystemPolicyLoader { return &SystemPolicyLoader{ PolicyLoader: &PolicyLoader{ FileLoader: files.FileLoader{ Fs: afero.NewOsFs(), RequiredPerm: files.ModeSystemPerms, }, UserLookup: NewOsUserLookup(), }, } } // SystemPolicyLoader contains methods to read/write the system wide opkssh policy file // from/to a filesystem. All methods that read policy from the filesystem fail // and return an error immediately if the permission bits are invalid. type SystemPolicyLoader struct { *PolicyLoader } // LoadSystemPolicy reads the opkssh policy at SystemDefaultPolicyPath. // An error is returned if the file cannot be read or if the permissions bits // are not correct. func (s *SystemPolicyLoader) LoadSystemPolicy() (*Policy, Source, error) { policy, err := s.LoadPolicyAtPath(SystemDefaultPolicyPath) if err != nil { return nil, EmptySource{}, fmt.Errorf("failed to read system default policy file %s: %w", SystemDefaultPolicyPath, err) } return policy, FileSource(SystemDefaultPolicyPath), nil } type OptionalLoader func(h *HomePolicyLoader, username string) ([]byte, error) // HomePolicyLoader contains methods to read/write the opkssh policy file stored in // `~/.opk/ssh` from/to a filesystem. All methods that read policy from the filesystem fail // and return an error immediately if the permission bits are invalid. type HomePolicyLoader struct { *PolicyLoader } // NewHomePolicyLoader returns an opkssh policy loader that uses the os library to // read/write policy from/to the user's home directory, e.g. `~/.opk/auth_id`, func NewHomePolicyLoader() *HomePolicyLoader { return &HomePolicyLoader{ PolicyLoader: &PolicyLoader{ FileLoader: files.FileLoader{ Fs: afero.NewOsFs(), RequiredPerm: files.ModeHomePerms, }, UserLookup: NewOsUserLookup(), }, } } // LoadHomePolicy reads the user's opkssh policy at ~/.opk/auth_id (where ~ // maps to username's home directory) and returns the filepath read. An error is // returned if the file cannot be read, if the permission bits are not correct, // or if there is no user with username or has no home directory. // // If skipInvalidEntries is true, then invalid user entries are skipped and not // included in the returned policy. A user policy's entry is considered valid if // it gives username access. The returned policy is stripped of invalid entries. // To specify an alternative Loader that will be used if we don't have sufficient // permissions to read the policy file in the user's home directory, pass the // alternative loader as the last argument. func (h *HomePolicyLoader) LoadHomePolicy(username string, skipInvalidEntries bool, optLoader ...OptionalLoader) (*Policy, string, error) { policyFilePath, err := h.UserPolicyPath(username) if err != nil { return nil, "", fmt.Errorf("error getting user policy path for user %s: %w", username, err) } policyBytes, userPolicyErr := h.FileLoader.LoadFileAtPath(policyFilePath) if userPolicyErr != nil { if len(optLoader) == 1 { // Try to read using the optional loader policyBytes, err = optLoader[0](h, username) if err != nil { return nil, "", fmt.Errorf("failed to read user policy file %s: %w", policyFilePath, err) } } else if len(optLoader) > 1 { return nil, "", fmt.Errorf("only one optional loaders allowed, got %d", len(optLoader)) } else { return nil, "", fmt.Errorf("failed to read user policy file %s: %w", policyFilePath, userPolicyErr) } } policy := FromTable(policyBytes, policyFilePath) if skipInvalidEntries { // Build valid user policy. Ignore user entries that give access to a // principal not equal to the username where the policy file was read // from. validUserPolicy := new(Policy) for _, user := range policy.Users { if slices.Contains(user.Principals, username) { // Build clean entry that only gives access to username validUserPolicy.Users = append(validUserPolicy.Users, User{ IdentityAttribute: user.IdentityAttribute, Principals: []string{username}, Issuer: user.Issuer, }) } } return validUserPolicy, policyFilePath, nil } else { // Just return what we read return policy, policyFilePath, nil } } // UserPolicyPath returns the path to the user's opkssh policy file at // ~/.opk/auth_id. func (h *HomePolicyLoader) UserPolicyPath(username string) (string, error) { user, err := h.UserLookup.Lookup(username) if err != nil { return "", fmt.Errorf("failed to lookup username %s: %w", username, err) } userHomeDirectory := user.HomeDir if userHomeDirectory == "" { return "", fmt.Errorf("user %s does not have a home directory", username) } policyFilePath := path.Join(userHomeDirectory, ".opk", "auth_id") return policyFilePath, nil } opkssh-0.4.0/policy/policyloader_test.go000066400000000000000000000273441477307211400204150ustar00rootroot00000000000000// Copyright 2025 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 policy_test import ( "errors" "os" "os/user" "path" "testing" "github.com/openpubkey/opkssh/policy" "github.com/openpubkey/opkssh/policy/files" "github.com/spf13/afero" "github.com/stretchr/testify/require" ) type MockUserLookup struct { // User is returned on any call to Lookup() if Error is nil User *user.User // Error is returned on any call to Lookup() if non-nil Error error } var _ policy.UserLookup = &MockUserLookup{} // Lookup implements policy.UserLookup func (m *MockUserLookup) Lookup(username string) (*user.User, error) { if m.Error == nil { return m.User, nil } else { return nil, m.Error } } // MockFsOpenError embeds an afero.MemMapFs (implements afero.Fs) but allows for // finer control on when an error should be returned on a specific filepath type MockFsOpenError struct { afero.MemMapFs fileToErrorMap map[string]error } func NewMockFsOpenError() *MockFsOpenError { return &MockFsOpenError{fileToErrorMap: make(map[string]error)} } func (m *MockFsOpenError) Open(name string) (afero.File, error) { err, ok := m.fileToErrorMap[name] if ok { return nil, err } return m.MemMapFs.Open(name) } // ErrorOn makes Open(fileName) return err func (m *MockFsOpenError) ErrorOn(fileName string, err error) { m.fileToErrorMap[fileName] = err } func NewTestHomePolicyLoader(fs afero.Fs, userLookup policy.UserLookup) *policy.HomePolicyLoader { return &policy.HomePolicyLoader{ PolicyLoader: &policy.PolicyLoader{ FileLoader: files.FileLoader{ Fs: fs, RequiredPerm: files.ModeHomePerms, }, UserLookup: userLookup, }, } } func NewTestSystemPolicyLoader(fs afero.Fs, userLookup policy.UserLookup) *policy.SystemPolicyLoader { return &policy.SystemPolicyLoader{ &policy.PolicyLoader{ FileLoader: files.FileLoader{ Fs: fs, RequiredPerm: files.ModeSystemPerms, }, UserLookup: userLookup, }, } } var ValidUser *user.User = &user.User{HomeDir: "/home/foo", Username: "foo"} func TestLoadUserPolicy_FailUserLookup(t *testing.T) { // Test that LoadUserPolicy returns an error when user lookup fails t.Parallel() fakeError := errors.New("fake error") mockUserLookup := &MockUserLookup{Error: fakeError} policyLoader := NewTestHomePolicyLoader(afero.NewMemMapFs(), mockUserLookup) policy, path, err := policyLoader.LoadHomePolicy("", false) require.ErrorIs(t, err, fakeError) require.Nil(t, policy, "should not return policy if error") require.Empty(t, path, "should not return path if error") } func TestLoadUserPolicy_NoUserHomeDir(t *testing.T) { // Test that LoadUserPolicy returns an error when the user does not have a // home directory t.Parallel() mockUserLookup := &MockUserLookup{User: &user.User{}} policyLoader := NewTestHomePolicyLoader(afero.NewMemMapFs(), mockUserLookup) policy, path, err := policyLoader.LoadHomePolicy("", false) require.Error(t, err, "should not read policy if user does not have a home directory") require.Nil(t, policy, "should not return policy if error") require.Empty(t, path, "should not return path if error") } func TestLoadUserPolicy_ErrorFile(t *testing.T) { // Test that LoadUserPolicy returns an error when the file is invalid t.Parallel() mockUserLookup := &MockUserLookup{User: ValidUser} policyLoader := NewTestHomePolicyLoader(afero.NewMemMapFs(), mockUserLookup) mockFs := policyLoader.FileLoader.Fs // Create policy file at user policy path with invalid data err := afero.WriteFile(mockFs, path.Join(ValidUser.HomeDir, ".opk", "auth_id"), []byte("{"), 0600) require.NoError(t, err) policy, path, err := policyLoader.LoadHomePolicy(ValidUser.Username, false) require.NoError(t, err) require.False(t, files.ConfigProblems().NoProblems()) files.ConfigProblems().Clear() require.NotNil(t, policy, "should return policy even if error") require.NotEmpty(t, path, "should return path even if error") } func TestLoadUserPolicy_Success(t *testing.T) { // Test that LoadUserPolicy returns the policy when there are no errors t.Parallel() mockUserLookup := &MockUserLookup{User: ValidUser} policyLoader := NewTestHomePolicyLoader(afero.NewMemMapFs(), mockUserLookup) mockFs := policyLoader.FileLoader.Fs // Create policy file at path with valid file testPolicy := &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, }, } testPolicyFile, err := testPolicy.ToTable() require.NoError(t, err) expectedPath := path.Join(ValidUser.HomeDir, ".opk", "auth_id") err = afero.WriteFile(mockFs, expectedPath, testPolicyFile, 0600) require.NoError(t, err) gotPolicy, gotPath, err := policyLoader.LoadHomePolicy(ValidUser.Username, false) require.NoError(t, err) require.Equal(t, testPolicy, gotPolicy) require.Equal(t, expectedPath, gotPath) } func TestLoadUserPolicy_Success_SkipInvalidEntries(t *testing.T) { // Test that LoadUserPolicy returns the policy when there are no errors and // correctly skips invalid entries t.Parallel() mockUserLookup := &MockUserLookup{User: ValidUser} policyLoader := NewTestHomePolicyLoader(afero.NewMemMapFs(), mockUserLookup) mockFs := policyLoader.FileLoader.Fs // Create policy file at path with valid file testPolicy := &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, { IdentityAttribute: "bob@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, { IdentityAttribute: "charlie@example.com", Principals: []string{ValidUser.Username}, Issuer: "https://example.com", }, { IdentityAttribute: "daniel@example.com", Principals: []string{ValidUser.Username, "test", "test2"}, Issuer: "https://example.com", }, }, } // Expect only user statements that contain ValidUser.Username expectedPolicy := &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "charlie@example.com", Principals: []string{ValidUser.Username}, Issuer: "https://example.com", }, { IdentityAttribute: "daniel@example.com", Principals: []string{ValidUser.Username}, Issuer: "https://example.com", }, }, } testPolicyFile, err := testPolicy.ToTable() require.NoError(t, err) expectedPath := path.Join(ValidUser.HomeDir, ".opk", "auth_id") err = afero.WriteFile(mockFs, expectedPath, testPolicyFile, 0600) require.NoError(t, err) gotPolicy, gotPath, err := policyLoader.LoadHomePolicy(ValidUser.Username, true) require.NoError(t, err) require.Equal(t, expectedPolicy, gotPolicy) require.Equal(t, expectedPath, gotPath) } func TestLoadPolicyAtPath_FileMissing(t *testing.T) { // Test that LoadPolicyAtPath returns an error when the file cannot be // found at the specified path t.Parallel() mockUserLookup := &MockUserLookup{User: ValidUser} policyLoader := NewTestHomePolicyLoader(afero.NewMemMapFs(), mockUserLookup) contents, err := policyLoader.LoadPolicyAtPath("/auth_id") require.ErrorIs(t, err, os.ErrNotExist) require.Nil(t, contents, "should not return contents if error") } func TestLoadPolicyAtPath_BadPermissions(t *testing.T) { // Test that LoadPolicyAtPath returns an error when the file has invalid // permission bits t.Parallel() mockUserLookup := &MockUserLookup{User: ValidUser} mockFs := NewMockFsOpenError() policyLoader := NewTestHomePolicyLoader( mockFs, mockUserLookup, ) // Create empty policy with bad permissions err := afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, []byte{}, 0777) require.NoError(t, err) contents, err := policyLoader.LoadPolicyAtPath(policy.SystemDefaultPolicyPath) require.Error(t, err, "should fail if permissions are bad") require.Nil(t, contents, "should not return contents if error") } func TestLoadPolicyAtPath_ReadError(t *testing.T) { // Test that LoadPolicyAtPath returns an error when we fail to read the file // (but it exists) t.Parallel() mockUserLookup := &MockUserLookup{User: ValidUser} mockFs := NewMockFsOpenError() policyLoader := NewTestSystemPolicyLoader( mockFs, mockUserLookup, ) // Create empty policy file with correct permissions err := afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, []byte{}, 0640) require.NoError(t, err) // Now make it so mock filesystem returns error when reading the file (must // do this after creating the SystemDefaultPolicyPath file above) fakeError := errors.New("fake error") mockFs.ErrorOn(policy.SystemDefaultPolicyPath, fakeError) contents, err := policyLoader.LoadPolicyAtPath(policy.SystemDefaultPolicyPath) require.ErrorIs(t, err, fakeError) require.Nil(t, contents, "should not return contents if error") } func TestLoadSystemDefaultPolicy_ErrorFile(t *testing.T) { // Test that LoadSystemDefaultPolicy returns an error when the file is // invalid t.Parallel() mockUserLookup := &MockUserLookup{User: ValidUser} policyLoader := NewTestSystemPolicyLoader(afero.NewMemMapFs(), mockUserLookup) mockFs := policyLoader.FileLoader.Fs // Create policy file at default path with invalid file err := afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, []byte("{"), 0640) require.NoError(t, err) policy, _, err := policyLoader.LoadSystemPolicy() require.NoError(t, err) require.False(t, files.ConfigProblems().NoProblems()) files.ConfigProblems().Clear() require.NotNil(t, policy, "should return policy even if problems encountered") } func TestLoadSystemDefaultPolicy_Success(t *testing.T) { // Test that LoadSystemDefaultPolicy returns the policy when there are no // errors t.Parallel() mockUserLookup := &MockUserLookup{User: ValidUser} policyLoader := NewTestSystemPolicyLoader(afero.NewMemMapFs(), mockUserLookup) mockFs := policyLoader.FileLoader.Fs // Create policy file at default path with valid file testPolicy := &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, }, } testPolicyFile, err := testPolicy.ToTable() require.NoError(t, err) err = afero.WriteFile(mockFs, policy.SystemDefaultPolicyPath, testPolicyFile, 0640) require.NoError(t, err) gotPolicy, _, err := policyLoader.LoadSystemPolicy() require.NoError(t, err) require.Equal(t, testPolicy, gotPolicy) } func TestDump_Success(t *testing.T) { // Test that Dump writes the policy to the mock filesystem when there are no // errors t.Parallel() mockUserLookup := &MockUserLookup{User: ValidUser} testPolicy := &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "alice@example.com", Principals: []string{"test"}, Issuer: "https://example.com", }, }, } expectedContents, err := testPolicy.ToTable() require.NoError(t, err) policyLoader := NewTestSystemPolicyLoader(afero.NewMemMapFs(), mockUserLookup) mockFs := policyLoader.FileLoader.Fs err = policyLoader.Dump(testPolicy, policy.SystemDefaultPolicyPath) require.NoError(t, err) gotContents, err := afero.ReadFile(mockFs, policy.SystemDefaultPolicyPath) require.NoError(t, err) require.Equal(t, expectedContents, gotContents) } opkssh-0.4.0/policy/providerloader.go000066400000000000000000000130451477307211400177020ustar00rootroot00000000000000// Copyright 2025 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 policy import ( "fmt" "strings" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/verifier" "github.com/openpubkey/opkssh/policy/files" "github.com/spf13/afero" ) type ProvidersRow struct { Issuer string ClientID string ExpirationPolicy string } func (p ProvidersRow) GetExpirationPolicy() (verifier.ExpirationPolicy, error) { switch p.ExpirationPolicy { case "24h": return verifier.ExpirationPolicies.MAX_AGE_24HOURS, nil case "48h": return verifier.ExpirationPolicies.MAX_AGE_48HOURS, nil case "1week": return verifier.ExpirationPolicies.MAX_AGE_1WEEK, nil case "oidc": return verifier.ExpirationPolicies.OIDC, nil case "oidc_refreshed": return verifier.ExpirationPolicies.OIDC_REFRESHED, nil case "never": return verifier.ExpirationPolicies.NEVER_EXPIRE, nil default: return verifier.ExpirationPolicy{}, fmt.Errorf("invalid expiration policy: %s", p.ExpirationPolicy) } } func (p ProvidersRow) ToString() string { return p.Issuer + " " + p.ClientID + " " + p.ExpirationPolicy } type ProviderPolicy struct { rows []ProvidersRow } func (p *ProviderPolicy) AddRow(row ProvidersRow) { p.rows = append(p.rows, row) } func (p *ProviderPolicy) CreateVerifier() (*verifier.Verifier, error) { pvs := []verifier.ProviderVerifier{} var expirationPolicy verifier.ExpirationPolicy var err error for _, row := range p.rows { var provider verifier.ProviderVerifier // TODO: We should handle this issuer matching in a more generic way // oidc.local and localhost: are a test issuers if row.Issuer == "https://accounts.google.com" || strings.HasPrefix(row.Issuer, "http://oidc.local") || strings.HasPrefix(row.Issuer, "http://localhost:") { opts := providers.GetDefaultGoogleOpOptions() opts.Issuer = row.Issuer opts.ClientID = row.ClientID provider = providers.NewGoogleOpWithOptions(opts) } else if strings.HasPrefix(row.Issuer, "https://login.microsoftonline.com") { opts := providers.GetDefaultAzureOpOptions() opts.Issuer = row.Issuer opts.ClientID = row.ClientID provider = providers.NewAzureOpWithOptions(opts) } else if row.Issuer == "https://gitlab.com" { opts := providers.GetDefaultGitlabOpOptions() opts.Issuer = row.Issuer opts.ClientID = row.ClientID provider = providers.NewGitlabOpWithOptions(opts) } else { opts := providers.GetDefaultGoogleOpOptions() opts.Issuer = row.Issuer opts.ClientID = row.ClientID provider = providers.NewGoogleOpWithOptions(opts) } expirationPolicy, err = row.GetExpirationPolicy() if err != nil { return nil, err } pv := verifier.ProviderVerifierExpires{ ProviderVerifier: provider, Expiration: expirationPolicy, } pvs = append(pvs, pv) } if len(pvs) == 0 { return nil, fmt.Errorf("no providers configured") } pktVerifier, err := verifier.NewFromMany( pvs, verifier.WithExpirationPolicy(expirationPolicy), ) if err != nil { return nil, err } return pktVerifier, nil } func (p ProviderPolicy) ToString() string { var sb strings.Builder for _, row := range p.rows { sb.WriteString(row.ToString() + "\n") } return sb.String() } type ProvidersFileLoader struct { files.FileLoader Path string } func NewProviderFileLoader() *ProvidersFileLoader { return &ProvidersFileLoader{ FileLoader: files.FileLoader{ Fs: afero.NewOsFs(), RequiredPerm: files.ModeSystemPerms, }, } } func (o *ProvidersFileLoader) LoadProviderPolicy(path string) (*ProviderPolicy, error) { content, err := o.FileLoader.LoadFileAtPath(path) if err != nil { return nil, err } policy := o.FromTable(content, path) return policy, nil } // FromTable decodes whitespace delimited input into policy.Policy func (o ProvidersFileLoader) ToTable(opPolicies ProviderPolicy) files.Table { table := files.Table{} for _, opPolicy := range opPolicies.rows { table.AddRow(opPolicy.Issuer, opPolicy.ClientID, opPolicy.ExpirationPolicy) } return table } // FromTable decodes whitespace delimited input into policy.Policy // Path is passed only for logging purposes func (o *ProvidersFileLoader) FromTable(input []byte, path string) *ProviderPolicy { table := files.NewTable(input) policy := &ProviderPolicy{ rows: []ProvidersRow{}, } for i, row := range table.GetRows() { // Error should not break everyone's ability to login, skip those rows if len(row) != 3 { configProblem := files.ConfigProblem{ Filepath: path, OffendingLine: strings.Join(row, " "), OffendingLineNumber: i, ErrorMessage: fmt.Sprintf("wrong number of arguments (expected=3, got=%d)", len(row)), Source: "providers policy file", } files.ConfigProblems().RecordProblem(configProblem) continue } policyRow := ProvidersRow{ Issuer: row[0], ClientID: row[1], ExpirationPolicy: row[2], //TODO: Validate this so that we can determine the line number that has the error } policy.AddRow(policyRow) } return policy } opkssh-0.4.0/policy/providerloader_test.go000066400000000000000000000134161477307211400207430ustar00rootroot00000000000000// Copyright 2025 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 policy // Note: These tests were originally generated by o3-mini and then heavily modified import ( "strings" "testing" "github.com/openpubkey/openpubkey/verifier" "github.com/stretchr/testify/require" ) // Test for ProvidersPolicyRow.GetExpirationPolicy. func TestProvidersPolicyRow_GetExpirationPolicy(t *testing.T) { tests := []struct { input string expected verifier.ExpirationPolicy expectErr bool }{ {"24h", verifier.ExpirationPolicies.MAX_AGE_24HOURS, false}, {"48h", verifier.ExpirationPolicies.MAX_AGE_48HOURS, false}, {"1week", verifier.ExpirationPolicies.MAX_AGE_1WEEK, false}, {"oidc", verifier.ExpirationPolicies.OIDC, false}, {"oidc_refreshed", verifier.ExpirationPolicies.OIDC_REFRESHED, false}, {"never", verifier.ExpirationPolicies.NEVER_EXPIRE, false}, {"invalid", verifier.ExpirationPolicy{}, true}, } for _, tc := range tests { row := ProvidersRow{ExpirationPolicy: tc.input} res, err := row.GetExpirationPolicy() if tc.expectErr { if err == nil { t.Errorf("expected error for input %s, got nil", tc.input) } } else { if err != nil { t.Errorf("unexpected error for input %s: %v", tc.input, err) } if res != tc.expected { t.Errorf("for input %s, expected %v, got %v", tc.input, tc.expected, res) } } } } // Test for ProviderPolicy.ToString. func TestProviderPolicy_ToString(t *testing.T) { policy := ProviderPolicy{} policy.AddRow(ProvidersRow{Issuer: "issuer1", ClientID: "client1", ExpirationPolicy: "24h"}) policy.AddRow(ProvidersRow{Issuer: "issuer2", ClientID: "client2", ExpirationPolicy: "48h"}) expected := "issuer1 client1 24h\nissuer2 client2 48h\n" require.Equal(t, expected, policy.ToString()) } // Test ProviderPolicy.CreateVerifier with a valid Google issuer. func TestProviderPolicy_CreateVerifier_Google(t *testing.T) { policy := &ProviderPolicy{} policy.AddRow(ProvidersRow{ Issuer: "https://accounts.google.com", ClientID: "test-google", ExpirationPolicy: "24h", }) ver, err := policy.CreateVerifier() require.NoError(t, err) require.NotNil(t, ver) } // Test ProviderPolicy.CreateVerifier with a valid Azure issuer. func TestProviderPolicy_CreateVerifier_Azure(t *testing.T) { policy := &ProviderPolicy{} policy.AddRow(ProvidersRow{ Issuer: "https://login.microsoftonline.com/tenant", ClientID: "test-azure", ExpirationPolicy: "48h", }) ver, err := policy.CreateVerifier() require.NoError(t, err) require.NotNil(t, ver) } func TestProviderPolicy_CreateVerifier_Gitlab(t *testing.T) { policy := &ProviderPolicy{} policy.AddRow(ProvidersRow{ Issuer: "https://gitlab.com", ClientID: "test-gitlab", ExpirationPolicy: "24h", }) ver, err := policy.CreateVerifier() require.NoError(t, err) require.NotNil(t, ver) } // Test ProviderPolicy.CreateVerifier with an invalid expiration policy. func TestProviderPolicy_CreateVerifier_InvalidExpiration(t *testing.T) { policy := &ProviderPolicy{} policy.AddRow(ProvidersRow{ Issuer: "https://accounts.google.com", ClientID: "test-google", ExpirationPolicy: "invalid", }) ver, err := policy.CreateVerifier() require.ErrorContains(t, err, "invalid expiration policy") require.Nil(t, ver) } // Test ProviderPolicy.CreateVerifier when no providers are configured. func TestProviderPolicy_CreateVerifier_NoProviders(t *testing.T) { policy := &ProviderPolicy{} ver, err := policy.CreateVerifier() require.ErrorContains(t, err, "no providers configured") require.Nil(t, ver) } // Test ProvidersFileLoader.FromTable with valid and invalid rows. func TestProvidersFileLoader_FromTable(t *testing.T) { // Input with two valid rows and one invalid row. input := []byte("https://accounts.google.com test-google 24h\n" + "invalid-line\n" + "https://login.microsoftonline.com/tenant test-azure 48h\n") loader := ProvidersFileLoader{} policy := loader.FromTable(input, "dummy-path") require.Equal(t, 2, len(policy.rows)) // Check the first row. row1 := policy.rows[0] if row1.Issuer != "https://accounts.google.com" || row1.ClientID != "test-google" || row1.ExpirationPolicy != "24h" { t.Error("first row does not match expected values") } // Check the second row. row2 := policy.rows[1] if !strings.HasPrefix(row2.Issuer, "https://login.microsoftonline.com") || row2.ClientID != "test-azure" || row2.ExpirationPolicy != "48h" { t.Error("second row does not match expected values") } } // Test ProvidersFileLoader.ToTable. func TestProvidersFileLoader_ToTable(t *testing.T) { policy := ProviderPolicy{} policy.AddRow(ProvidersRow{ Issuer: "issuer1", ClientID: "client1", ExpirationPolicy: "24h", }) policy.AddRow(ProvidersRow{ Issuer: "issuer2", ClientID: "client2", ExpirationPolicy: "48h", }) loader := ProvidersFileLoader{} table := loader.ToTable(policy) rows := table.GetRows() require.Equal(t, 2, len(rows)) if rows[0][0] != "issuer1" || rows[0][1] != "client1" || rows[0][2] != "24h" { t.Error("first row in table does not match expected values") } if rows[1][0] != "issuer2" || rows[1][1] != "client2" || rows[1][2] != "48h" { t.Error("second row in table does not match expected values") } } opkssh-0.4.0/scripts/000077500000000000000000000000001477307211400145175ustar00rootroot00000000000000opkssh-0.4.0/scripts/install-linux.sh000066400000000000000000000247641477307211400176730ustar00rootroot00000000000000#!/bin/bash set -e # Exit if any command fails # Determine Linux type if [ -f /etc/redhat-release ]; then OS_TYPE="redhat" elif [ -f /etc/debian_version ]; then OS_TYPE="debian" else echo "Unsupported OS type." exit 1 fi echo "Detected OS is $OS_TYPE" # Define variables INSTALL_DIR="/usr/local/bin" BINARY_NAME="opkssh" GITHUB_REPO="openpubkey/opkssh" # Define the default OpenID Providers PROVIDER_GOOGLE="https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h" PROVIDER_MICROSOFT="https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h" PROVIDER_GITLAB="https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h" # AuthorizedKeysCommand user AUTH_CMD_USER="opksshuser" AUTH_CMD_GROUP="opksshuser" SUDOERS_PATH="/etc/sudoers.d/opkssh" if [ "$EUID" -ne 0 ]; then echo "Error: This script must be run as root." echo "sudo $0" exit 1 fi HOME_POLICY=true RESTART_SSH=true LOCAL_INSTALL_FILE="" INSTALL_VERSION="latest" for arg in "$@"; do if [[ "$arg" == "--no-home-policy" ]]; then HOME_POLICY=false elif [ "$arg" == "--no-sshd-restart" ]; then RESTART_SSH=false elif [[ "$arg" == --install-from=* ]]; then LOCAL_INSTALL_FILE="${arg#*=}" elif [[ "$arg" == --install-version=* ]]; then INSTALL_VERSION="${arg#*=}" fi done # Display help message if [[ "$1" == "--help" ]]; then echo "Usage: $0 [OPTIONS]" echo "" echo "Options:" echo " --no-home-policy Disables configuration that allows opkssh see policy files in user's home directory (/home//auth_id). Greatly simplifies install, try this if you are having install failures." echo " --no-sshd-restart Do not restart SSH after installation" echo " --install-from=FILEPATH Install using a local file" echo " --install-version=VER Install a specific version from GitHub" echo " --help Display this help message" exit 0 fi # Ensure wget is installed if ! command -v wget &> /dev/null; then echo "Error: wget is not installed. Please install it first." if [ "$OS_TYPE" == "debian" ]; then echo "sudo apt install wget" elif [ "$OS_TYPE" == "redhat" ]; then echo "sudo yum install wget" else echo "Unsupported OS type." fi exit 1 fi # Checks if the group and user used by the AuthorizedKeysCommand exists if not creates it if ! getent group "$AUTH_CMD_GROUP" >/dev/null; then groupadd --system "$AUTH_CMD_GROUP" echo "Created group: $AUTH_CMD_GROUP" fi # If the AuthorizedKeysCommand user does not exist, create it and add it to the group if ! getent passwd "$AUTH_CMD_USER" >/dev/null; then useradd -r -M -s /sbin/nologin -g "$AUTH_CMD_GROUP" "$AUTH_CMD_USER" echo "Created user: $AUTH_CMD_USER with group: $AUTH_CMD_GROUP" else # If the AuthorizedKeysCommand user exist, ensure it is added to the group usermod -aG "$AUTH_CMD_GROUP" "$AUTH_CMD_USER" echo "Added $AUTH_CMD_USER to group: $AUTH_CMD_GROUP" fi # Check if we should install from a local file if [ -n "$LOCAL_INSTALL_FILE" ]; then echo "--install-from option supplied, installing from local file: $LOCAL_INSTALL_FILE" BINARY_PATH=$LOCAL_INSTALL_FILE if [ ! -f "$BINARY_PATH" ]; then echo "Error: Specified binary path does not exist." exit 1 fi echo "Using binary from specified path: $BINARY_PATH" else if [ "$INSTALL_VERSION" == "latest" ]; then BINARY_URL="https://github.com/$GITHUB_REPO/releases/latest/download/opkssh-linux-amd64" else BINARY_URL="https://github.com/$GITHUB_REPO/releases/download/$INSTALL_VERSION/opkssh-linux-amd64" fi # Download the binary echo "Downloading version $INSTALL_VERSION of $BINARY_NAME from $BINARY_URL..." wget -q --show-progress -O "$BINARY_NAME" "$BINARY_URL" BINARY_PATH="$BINARY_NAME" fi # Move to installation directory mv "$BINARY_PATH" "$INSTALL_DIR/$BINARY_NAME" # Make the binary executable, correct permissions/ownership chmod +x "$INSTALL_DIR/$BINARY_NAME" chown root:${AUTH_CMD_GROUP} "$INSTALL_DIR/$BINARY_NAME" chmod 755 "$INSTALL_DIR/$BINARY_NAME" # Checks if SELinux is enabled and if so, ensures the context is set correctly if command -v getenforce >/dev/null 2>&1; then if [ "$(getenforce)" != "Disabled" ]; then echo "SELinux detected. Configuring SELinux for opkssh" echo " Restoring context for $INSTALL_DIR/$BINARY_NAME..." restorecon "$INSTALL_DIR/$BINARY_NAME" # Create temporary files for the compiled module and package TE_TMP="/tmp/opkssh.te" MOD_TMP="/tmp/opkssh.mod" # SELinux requires that modules have the same file name as the module name PP_TMP="/tmp/opkssh.pp" if [ "$HOME_POLICY" = true ]; then echo " Using SELinux module that permits home policy" # Pipe the TE directives into checkmodule via /dev/stdin cat << 'EOF' > "$TE_TMP" module opkssh 1.0; require { type sshd_t; type var_log_t; type ssh_exec_t; type http_port_t; type sudo_exec_t; class file { append execute execute_no_trans open read map }; class tcp_socket name_connect; } # We need to allow the AuthorizedKeysCommand opkssh process launched by sshd to: # 1. Make TCP connections to ports labeled http_port_t. This is so opkssh can download the public keys of the OpenID providers. allow sshd_t http_port_t:tcp_socket name_connect; # 2. Needed to allow opkssh to call `ssh -V` to determine if the version is supported by opkssh allow sshd_t ssh_exec_t:file { execute execute_no_trans open read map }; # 3. Needed to allow opkssh to call `sudo opkssh readhome` to read the policy file in the user's home directory allow sshd_t sudo_exec_t:file { execute execute_no_trans open read map }; # 4. Needed to allow opkssh to write to its log file allow sshd_t var_log_t:file { open append }; EOF else echo " Using SELinux module does not permits home policy (--no-home-policy option supplied)" # Redefine the tmp file names since SELinux modules must have the same name as the file TE_TMP="/tmp/opkssh-no-home.te" MOD_TMP="/tmp/opkssh-no-home.mod" # SELinux requires that modules have the same file name as the module name PP_TMP="/tmp/opkssh-no-home.pp" # Pipe the TE directives into checkmodule via /dev/stdin cat << 'EOF' > "$TE_TMP" module opkssh-no-home 1.0; require { type sshd_t; type var_log_t; type ssh_exec_t; type http_port_t; class file { append execute execute_no_trans open read map }; class tcp_socket name_connect; } # We need to allow the AuthorizedKeysCommand opkssh process launched by sshd to: # 1. Make TCP connections to ports labeled http_port_t. This is so opkssh can download the public keys of the OpenID providers. allow sshd_t http_port_t:tcp_socket name_connect; # 2. Needed to allow opkssh to call `ssh -V` to determine if the version is supported by opkssh allow sshd_t ssh_exec_t:file { execute execute_no_trans open read map }; # 3. Needed to allow opkssh to write to its log file allow sshd_t var_log_t:file { open append }; EOF fi echo " Compiling SELinux module..." checkmodule -M -m -o "$MOD_TMP" "$TE_TMP" echo " Packaging module..." semodule_package -o "$PP_TMP" -m "$MOD_TMP" echo " Installing module..." semodule -i "$PP_TMP" rm -f "$TE_TMP" "$MOD_TMP" "$PP_TMP" echo "SELinux module installed successfully!" fi fi echo "Installed $BINARY_NAME to $INSTALL_DIR/$BINARY_NAME" # Verify installation if command -v $INSTALL_DIR/$BINARY_NAME &> /dev/null; then # Setup configuration echo "Configuring opkssh:" mkdir -p /etc/opk touch /etc/opk/auth_id chown root:${AUTH_CMD_GROUP} /etc/opk/auth_id chmod 640 /etc/opk/auth_id touch /etc/opk/providers chown root:${AUTH_CMD_GROUP} /etc/opk/providers chmod 640 /etc/opk/providers if [ -s /etc/opk/providers ]; then echo " The providers policy file (/etc/opk/providers) is not empty. Keeping existing values" else echo "$PROVIDER_GOOGLE" >> /etc/opk/providers echo "$PROVIDER_MICROSOFT" >> /etc/opk/providers echo "$PROVIDER_GITLAB" >> /etc/opk/providers fi sed -i '/^AuthorizedKeysCommand /s/^/#/' /etc/ssh/sshd_config sed -i '/^AuthorizedKeysCommandUser /s/^/#/' /etc/ssh/sshd_config echo "AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t" >> /etc/ssh/sshd_config echo "AuthorizedKeysCommandUser ${AUTH_CMD_USER}" >> /etc/ssh/sshd_config if [ "$RESTART_SSH" = true ]; then if [ "$OS_TYPE" == "debian" ]; then systemctl restart ssh elif [ "$OS_TYPE" == "redhat" ]; then systemctl restart sshd else echo " Unsupported OS type." exit 1 fi else echo " --no-sshd-restart option supplied, skipping SSH restart." fi if [ "$HOME_POLICY" = true ]; then if [ ! -f "$SUDOERS_PATH" ]; then echo " Creating sudoers file at $SUDOERS_PATH..." touch "$SUDOERS_PATH" chmod 440 "$SUDOERS_PATH" fi SUDOERS_RULE_READ_HOME="$AUTH_CMD_USER ALL=(ALL) NOPASSWD: /usr/local/bin/opkssh readhome *" if ! grep -qxF "$SUDOERS_RULE_READ_HOME" "$SUDOERS_PATH"; then echo " Adding sudoers rule for $AUTH_CMD_USER..." echo "# This allows opkssh to call opkssh readhome to read the user's policy file in /home//auth_id" >> "$SUDOERS_PATH" echo "$SUDOERS_RULE_READ_HOME" >> "$SUDOERS_PATH" fi else echo " Skipping sudoers configuration as it is only needed for home policy (--no-home-policy option supplied)" fi touch /var/log/opkssh.log chown root:${AUTH_CMD_GROUP} /var/log/opkssh.log chmod 660 /var/log/opkssh.log VERSION_INSTALLED=$($INSTALL_DIR/$BINARY_NAME --version) INSTALLED_ON=$(date) # Log the installation details to /var/log/opkssh.log to help with debugging echo "Successfully installed opkssh (INSTALLED_ON: $INSTALLED_ON, VERSION_INSTALLED: $VERSION_INSTALLED, INSTALL_VERSION: $INSTALL_VERSION, LOCAL_INSTALL_FILE: $LOCAL_INSTALL_FILE, HOME_POLICY: $HOME_POLICY, RESTART_SSH: $RESTART_SSH)" >> /var/log/opkssh.log echo "Installation successful! Run '$BINARY_NAME' to use it." else echo "Installation failed." exit 1 fi opkssh-0.4.0/scripts/installing.md000066400000000000000000000077141477307211400172160ustar00rootroot00000000000000 # Installing opkssh This document provides a detailed description of how our [install-linux.sh](install-linux.sh) script works and the security protections used. If you just want to install opkssh you should run: ```bash wget -qO- "https://raw.githubusercontent.com/openpubkey/opkssh/main/scripts/install-linux.sh" | sudo bash ``` ## Script commands Running `./install-linux.sh --help` will show you all available flags. `--no-home-policy` disables configuration steps which allows opkssh see policy files in user's home directory (/home/{username}/auth_id). Try this if you are having install failures. `--nosshd-restart` turns off the sshd restart. This is useful in some docker setups where restarting sshd can break docker. `--install-from=FILEPATH` allows you to install the opkssh binary from a local file. This is useful if you want to install a locally built opkssh binary. `--install-version=VER` downloads and installs a particular release of opkssh. By default we download and install the latest release of opkssh. ## What the script is doing **1: Build opkssh.** Run the following from the root directory, replace GOARCH and GOOS to match with server you wish to install OPKSSH. This will generate the opkssh binary. ```bash go build ``` **2: Copy opkssh to server.** Copy the opkssh binary you just built in the previous step to the SSH server you want to configure ```bash scp opkssh ${USER}@${HOSTNAME}:~ ``` **3: Install opkssh on server.** SSH to the server Create the following file directory structure on the server and move the executable there: ```bash sudo mkdir /etc/opk sudo sudo mv ~/opkssh /usr/local/bin/opkssh sudo chown root /usr/local/bin/opkssh sudo chmod 755 /usr/local/bin/opkssh ``` **3: Setup policy.** The file `/etc/opk/providers` configures what the allowed OpenID Connect providers are. The default values for `/etc/opk/providers` are: ```bash # 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 ``` `/etc/opk/providers` requires the following permissions (by default we create all configuration files with the correct permissions): ```bash sudo chown root:opksshuser /etc/opk/providers sudo chmod 640 /etc/opk/providers ``` The file `/etc/opk/auth_id` controls which users and user identities can access the server using opkssh. If you do not have root access, you can create a new auth_id file in at ~/auth_id and use that instead. ```bash sudo touch /etc/opk/auth_id sudo chown root:opksshuser /etc/opk/auth_id sudo chmod 640 /etc/opk/auth_id sudo opkssh add {USER} {EMAIL} {ISSUER} ``` **4: Configure sshd to use opkssh.** Add the following lines to the sshd configuration file `/etc/ssh/sshd_config`. ```bash AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t AuthorizedKeysCommandUser opksshuser ``` Then create the required AuthorizedKeysCommandUser and group ```bash sudo groupadd --system opksshuser sudo useradd -r -M -s /sbin/nologin -g opksshuser opksshuser ``` **5: Configure sudoer and SELINUX.** Configures a sudoer command so that the opkssh AuthorizedKeysCommand process can call out to the shell to run `opkssh readhome {USER}` and thereby read the policy file for the user in `/home/{USER}/.opk/auth_id`. ```bash "opksshuser ALL=(ALL) NOPASSWD: /usr/local/bin/opkssh readhome *" ``` This config lives in `/etc/sudoers.d/opkssh` and must have the permissions `440` with root being the owner. If SELinux is configured we need to install an SELinux module to allow opkssh to read the policy in the user's home directory. See our install script [install-linux.sh](install-linux.sh) for details. **6: Restart sshd.** On Ubuntu and Debian Linux: ```bash systemctl restart ssh ``` On Redhat and centos Linux: ```bash sudo systemctl restart sshd ``` opkssh-0.4.0/sshcert/000077500000000000000000000000001477307211400145035ustar00rootroot00000000000000opkssh-0.4.0/sshcert/sshcert.go000066400000000000000000000106301477307211400165050ustar00rootroot00000000000000// Copyright 2024 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 sshcert import ( "context" "crypto/rand" "encoding/json" "fmt" "time" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/verifier" "golang.org/x/crypto/ssh" ) type SshCertSmuggler struct { SshCert *ssh.Certificate } func New(pkt *pktoken.PKToken, principals []string) (*SshCertSmuggler, error) { // TODO: assumes email exists in ID Token, // this will break for OPs like Azure that do not have email as a claim var claims struct { Email string `json:"email"` } if err := json.Unmarshal(pkt.Payload, &claims); err != nil { return nil, err } pubkeySsh, err := sshPubkeyFromPKT(pkt) if err != nil { return nil, err } pktCom, err := pkt.Compact() if err != nil { return nil, err } sshSmuggler := SshCertSmuggler{ SshCert: &ssh.Certificate{ Key: pubkeySsh, CertType: ssh.UserCert, KeyId: claims.Email, ValidPrincipals: principals, ValidBefore: ssh.CertTimeInfinity, Permissions: ssh.Permissions{ Extensions: map[string]string{ "permit-X11-forwarding": "", "permit-agent-forwarding": "", "permit-port-forwarding": "", "permit-pty": "", "permit-user-rc": "", "openpubkey-pkt": string(pktCom), }, }, }, } return &sshSmuggler, nil } func NewFromAuthorizedKey(certType string, certB64 string) (*SshCertSmuggler, error) { if certPubkey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certType + " " + certB64)); err != nil { return nil, err } else { sshCert, ok := certPubkey.(*ssh.Certificate) if !ok { return nil, fmt.Errorf("parsed SSH authorized_key is not an SSH certificate") } opkcert := &SshCertSmuggler{ SshCert: sshCert, } return opkcert, nil } } func (s *SshCertSmuggler) SignCert(signerMas ssh.MultiAlgorithmSigner) (*ssh.Certificate, error) { if err := s.SshCert.SignCert(rand.Reader, signerMas); err != nil { return nil, err } return s.SshCert, nil } func (s *SshCertSmuggler) VerifyCaSig(caPubkey ssh.PublicKey) error { certCopy := *(s.SshCert) certCopy.Signature = nil certBytes := certCopy.Marshal() certBytes = certBytes[:len(certBytes)-4] // Drops signature length bytes (see crypto.ssh.certs.go) return caPubkey.Verify(certBytes, s.SshCert.Signature) } func (s *SshCertSmuggler) GetPKToken() (*pktoken.PKToken, error) { pktCom, ok := s.SshCert.Extensions["openpubkey-pkt"] if !ok { return nil, fmt.Errorf("cert is missing required openpubkey-pkt extension") } pkt, err := pktoken.NewFromCompact([]byte(pktCom)) if err != nil { return nil, fmt.Errorf("openpubkey-pkt extension in cert failed deserialization: %w", err) } return pkt, nil } func (s *SshCertSmuggler) VerifySshPktCert(ctx context.Context, pktVerifier verifier.Verifier) (*pktoken.PKToken, error) { pkt, err := s.GetPKToken() if err != nil { return nil, fmt.Errorf("openpubkey-pkt extension in cert failed deserialization: %w", err) } ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() err = pktVerifier.VerifyPKToken(ctxWithTimeout, pkt) if err != nil { return nil, err } cic, err := pkt.GetCicValues() if err != nil { return nil, err } upk := cic.PublicKey() cryptoCertKey := (s.SshCert.Key.(ssh.CryptoPublicKey)).CryptoPublicKey() jwkCertKey, err := jwk.FromRaw(cryptoCertKey) if err != nil { return nil, err } if jwk.Equal(jwkCertKey, upk) { return pkt, nil } else { return nil, fmt.Errorf("public key 'upk' in PK Token does not match public key in certificate") } } func sshPubkeyFromPKT(pkt *pktoken.PKToken) (ssh.PublicKey, error) { cic, err := pkt.GetCicValues() if err != nil { return nil, err } upk := cic.PublicKey() var rawkey any if err := upk.Raw(&rawkey); err != nil { return nil, err } return ssh.NewPublicKey(rawkey) } opkssh-0.4.0/sshcert/sshcert_test.go000066400000000000000000000144141477307211400175500ustar00rootroot00000000000000// Copyright 2025 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 sshcert import ( "context" "crypto/rand" "crypto/rsa" "fmt" "strings" "testing" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/providers" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" ) var ( caSecretKey = testkey( `-----BEGIN OPENSSH TEST KEY: DO NOT REPORT----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn NhAAAAAwEAAQAAAQEAzIUpmKvLqHofAXVc/HU4eA9niB3l9mWztelMaa7lB5PSPco+Yw48 bQgg8l3ehBfe2/aLSQgz2nrE+6E23jgtOav57BK3Zs3QIYqpZL8qvSR5xWSvq5wQc1Df+Q rxdAK40vK4tutYzlIvYiaZu0B3TBxOCIwgcsfX6KJYRjwfhWgBg/Im2+eMsklAA9D3w4rD kkdArQBnLSC7g2zc/Hi/qGSSDE/g6Y77A0X3Sez+VM5vDzbcer9YhCQVoWVL5s6hFObyqu JQqTf4JqhSYNhHujNhsLzG2RsTgQkEjwZbEYGKXkKZnc0w7cTpfq0zKkuvuGyMEnyL/6zv LjR5d68cywAAA8D9LxhQ/S8YUAAAAAdzc2gtcnNhAAABAQDMhSmYq8uoeh8BdVz8dTh4D2 eIHeX2ZbO16UxpruUHk9I9yj5jDjxtCCDyXd6EF97b9otJCDPaesT7oTbeOC05q/nsErdm zdAhiqlkvyq9JHnFZK+rnBBzUN/5CvF0ArjS8ri261jOUi9iJpm7QHdMHE4IjCByx9fool hGPB+FaAGD8ibb54yySUAD0PfDisOSR0CtAGctILuDbNz8eL+oZJIMT+DpjvsDRfdJ7P5U zm8PNtx6v1iEJBWhZUvmzqEU5vKq4lCpN/gmqFJg2Ee6M2GwvMbZGxOBCQSPBlsRgYpeQp mdzTDtxOl+rTMqS6+4bIwSfIv/rO8uNHl3rxzLAAAAAwEAAQAAAQBKOOlnprE6a1dlSBp+ 5Guh5rVECNW0HiSiGBDLKdWkclkSY5tQh5IWX6TVUIu4lJEkcs0JrBhlabijOVaYPvrquy bwLbqxbG/kPFZNYbM5AUvP/0JhnTm7H9aoovgNig9ZPw0aFT8dYWYg0LFp63NgA8WuBGyi OzR4ELLIinlGCFqsR8W8C2E3dgogXqJQvaGg4Q+E9xjpxeiySl9eKQCtnul4kJ8tz7adIl ntdTTpi2K1OkIWGt+jjuOFAe33Vq77ub3TxolIPfh+1COx1YJ4dlTSTZTScRIdX5W3bQZn 681Vi0hqpmtMPkJ7F++38HDJzbd5yaQTcv7m7pXBh7aBAAAAgGyx/CNr3vt+WJKukHu8DZ naQ/B3lz4GNaJwed0sMpEKuaLXYoaefJKXVPq6hSimC9ScctzOKCizjQf20Goa96Jju4kt Zerw6y9vgufGL9prXVyjuCyHs4sxwKyOew7QuQzpu3ArVGMCgTfZE9tn0Ga6FfcjgKxvuJ k+KkoqblEzAAAAgQDnmzWHBeU0oXyMyPt4SeMozqcCkDY6pM+FZspf0zAYfLcrK4Tni74K enV8+ZyjNPpfNAWZ6roNZQ4HUz5tLs2OMI4OxG+ptWDHbm3nppYqfg0Qcy7jl1NBBh9XNM AwX2CwpoGpqcKWkcnH3/ZmN/8QIoTjl6uv6U0hLwBbVvFyyQAAAIEA4g+hppjyRW+G2WSW nCfwQSQ15QL43hQVbPXwZiokEcmaueRjC0s6i/5tjKgnV8eQa9A0BdoxUa67DKCVvthUs/ mFplwGXA0qGsvlqL9TYCm2wA4VLFzXW9bxvPLqI+0WuB79qmZn4V64PSj6XYYPOGdWHw5k uw2Z5widzugx6PMAAAAHdGVzdF9jYQECAwQ= -----END OPENSSH TEST KEY: DO NOT REPORT-----`) caPubkey, _, _, _, _ = ssh.ParseAuthorizedKey([]byte("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDMhSmYq8uoeh8BdVz8dTh4D2eIHeX2ZbO16UxpruUHk9I9yj5jDjxtCCDyXd6EF97b9otJCDPaesT7oTbeOC05q/nsErdmzdAhiqlkvyq9JHnFZK+rnBBzUN/5CvF0ArjS8ri261jOUi9iJpm7QHdMHE4IjCByx9foolhGPB+FaAGD8ibb54yySUAD0PfDisOSR0CtAGctILuDbNz8eL+oZJIMT+DpjvsDRfdJ7P5Uzm8PNtx6v1iEJBWhZUvmzqEU5vKq4lCpN/gmqFJg2Ee6M2GwvMbZGxOBCQSPBlsRgYpeQpmdzTDtxOl+rTMqS6+4bIwSfIv/rO8uNHl3rxzL test_ca")) testMsg = []byte("1234") badTestMsg = []byte("123X") ) func testkey(key string) []byte { return []byte(strings.ReplaceAll(key, "TEST KEY: DO NOT REPORT", "PRIVATE KEY")) } func newSshSignerFromPem(pemBytes []byte) (ssh.MultiAlgorithmSigner, error) { rawKey, err := ssh.ParseRawPrivateKey(pemBytes) if err != nil { return nil, err } sshSigner, err := ssh.NewSignerFromKey(rawKey) if err != nil { return nil, err } return ssh.NewSignerWithAlgorithms(sshSigner.(ssh.AlgorithmSigner), []string{ssh.KeyAlgoRSASHA256}) } func TestCASignerCreation(t *testing.T) { t.Parallel() caSigner, err := newSshSignerFromPem(caSecretKey) require.NoError(t, err) sshSig, err := caSigner.Sign(rand.Reader, testMsg) require.NoError(t, err) err = caPubkey.Verify(badTestMsg, sshSig) require.Error(t, err, "expected for signature to fail as the wrong message is used") } func TestInvalidSshPublicKey(t *testing.T) { // Test that the SSH cert smuggler cannot be constructed and returns an // error when given an SSH public key that isn't an SSH certificate t.Parallel() // Create SSH key that isn't a cert key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) pubKey, err := ssh.NewPublicKey(&key.PublicKey) require.NoError(t, err) // Marshal the key in expected authorized_keys format and parse the values // needed to construct an SSH cert smuggler splitMarshalPubkey := strings.Split(string(ssh.MarshalAuthorizedKey(pubKey)), " ") require.Len(t, splitMarshalPubkey, 2) _, err = NewFromAuthorizedKey(splitMarshalPubkey[0], splitMarshalPubkey[1]) require.Error(t, err) } func TestSshCertCreation(t *testing.T) { t.Parallel() providerOpts := providers.DefaultMockProviderOpts() op, _, idtTemplate, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) mockEmail := "arthur.aardvark@example.com" idtTemplate.ExtraClaims = map[string]any{"email": mockEmail} client, err := client.New(op) require.NoError(t, err) pkt, err := client.Auth(context.Background()) require.NoError(t, err) principals := []string{"guest", "dev"} cert, err := New(pkt, principals) require.NoError(t, err) caSigner, err := newSshSignerFromPem(caSecretKey) require.NoError(t, err) sshCert, err := cert.SignCert(caSigner) require.NoError(t, err) err = cert.VerifyCaSig(caPubkey) require.NoError(t, err) checker := ssh.CertChecker{} err = checker.CheckCert("guest", sshCert) require.NoError(t, err) require.Equal(t, sshCert.KeyId, mockEmail, "expected KeyId to be (%s) but was (%s)", mockEmail, sshCert.KeyId) pktCom, ok := sshCert.Extensions["openpubkey-pkt"] require.True(t, ok, "expected to find openpubkey-pkt extension in sshCert") pktExt, err := pktoken.NewFromCompact([]byte(pktCom)) require.NoError(t, err) cic, err := pktExt.GetCicValues() require.NoError(t, err) upk := cic.PublicKey() cryptoCertKey := (sshCert.Key.(ssh.CryptoPublicKey)).CryptoPublicKey() jwkCertKey, err := jwk.FromRaw(cryptoCertKey) require.NoError(t, err) if !jwk.Equal(upk, jwkCertKey) { t.Error(fmt.Errorf("expected upk to be equal to the value in sshCert.Key")) } } opkssh-0.4.0/test/000077500000000000000000000000001477307211400140075ustar00rootroot00000000000000opkssh-0.4.0/test/integration/000077500000000000000000000000001477307211400163325ustar00rootroot00000000000000opkssh-0.4.0/test/integration/add_test.go000066400000000000000000000237671477307211400204670ustar00rootroot00000000000000// Copyright 2025 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 integration package integration import ( "fmt" "io" "path" "strings" "testing" "github.com/openpubkey/opkssh/policy" "github.com/openpubkey/opkssh/policy/files" "github.com/openpubkey/opkssh/test/integration/ssh_server" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" tcexec "github.com/testcontainers/testcontainers-go/exec" ) const SudoerUser string = "test" const UnprivUser string = "test2" const RootUser string = "root" const UserGroup string = "opksshuser" func executeCommandAsUser(t *testing.T, container testcontainers.Container, cmd []string, user string) (int, string) { // Execute command execOpts := []tcexec.ProcessOption{tcexec.Multiplexed(), tcexec.WithUser(user)} code, reader, err := container.Exec(TestCtx, cmd, execOpts...) require.NoError(t, err) // Read stdout/stderr from command execution b, err := io.ReadAll(reader) require.NoError(t, err) t.Logf("Command `%s` being run as user %s returned exit code %d and the following stdout/stderr:\n%s", strings.Join(cmd, " "), user, code, string(b)) return code, string(b) } func FileExists(t *testing.T, container testcontainers.Container, filePath string) bool { code, _ := executeCommandAsUser(t, container, []string{"test", "-f", filePath}, RootUser) if code != 0 { return false } return true } func CreateAuthIdFile(t *testing.T, container testcontainers.Container, filePath string, cmdUser string, userPolicyFile bool) { // Create the auth_id file if it doesn't exist code, _ := executeCommandAsUser(t, container, []string{"mkdir", "-p", path.Dir(filePath)}, RootUser) require.Equal(t, 0, code, "failed to create user auth_id directory") code, _ = executeCommandAsUser(t, container, []string{"touch", filePath}, RootUser) require.Equal(t, 0, code, "failed to create user auth_id file") if userPolicyFile { // Set the permissions for the user policy file to 600 code, _ = executeCommandAsUser(t, container, []string{"chmod", "600", filePath}, RootUser) require.Equal(t, 0, code, "failed to set permissions for user policy file") userAndGroup := fmt.Sprintf("%s:%s", cmdUser, cmdUser) code, _ = executeCommandAsUser(t, container, []string{"chown", userAndGroup, filePath}, RootUser) require.Equal(t, 0, code, "failed to set ownership for user policy file") } else { // Set the permissions for the system policy file to 640 code, _ = executeCommandAsUser(t, container, []string{"chmod", "640", filePath}, RootUser) require.Equal(t, 0, code, "failed to set permissions for system policy file") code, _ = executeCommandAsUser(t, container, []string{"chown", "root:opksshuser", filePath}, RootUser) require.Equal(t, 0, code, "failed to set ownership for system policy file") } } func TestAdd(t *testing.T) { // Test adding an allowed principal to an opkssh policy issuer := fmt.Sprintf("http://oidc.local:%s/", issuerPort) tests := []struct { name string binaryPath string useSudo bool cmdUser string desiredPrincipal string preexistingHomeAuthIdFile bool shouldCmdFail bool }{ { name: "sudoer user can update root policy", binaryPath: "/usr/local/bin/opkssh", useSudo: true, cmdUser: SudoerUser, desiredPrincipal: SudoerUser, preexistingHomeAuthIdFile: true, shouldCmdFail: false, }, { name: "sudoer user can update root policy with principal != self", binaryPath: "/usr/local/bin/opkssh", useSudo: true, cmdUser: SudoerUser, desiredPrincipal: UnprivUser, preexistingHomeAuthIdFile: true, shouldCmdFail: false, }, { name: "unprivileged user creates an auth_id file (no preexisting ~/.opk/auth_id file)", binaryPath: "/usr/local/bin/opkssh", useSudo: false, cmdUser: UnprivUser, desiredPrincipal: UnprivUser, preexistingHomeAuthIdFile: false, shouldCmdFail: false, }, { name: "unprivileged user can update their user policy", binaryPath: "/usr/local/bin/opkssh", useSudo: false, cmdUser: UnprivUser, desiredPrincipal: UnprivUser, preexistingHomeAuthIdFile: true, shouldCmdFail: false, }, { name: "unprivileged user cannot add principal != self", binaryPath: "/usr/local/bin/opkssh", useSudo: false, cmdUser: UnprivUser, desiredPrincipal: SudoerUser, preexistingHomeAuthIdFile: true, shouldCmdFail: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create test container (fresh container for each sub-test) container, err := ssh_server.RunOpkSshContainer( TestCtx, // This test is only using add, so we don't need to set these // arguments "", "", "", false, // Skip init policy as this test is testing "add" directly ) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, container.Terminate(TestCtx), "failed to terminate add_test container") }) // Determine expected values based on sub-test options var expectedPolicyFilepath, expectedUser, expectedGroup, expectedPerms string var isUserPolicyFile bool if tt.useSudo { expectedPolicyFilepath = policy.SystemDefaultPolicyPath expectedUser = RootUser expectedGroup = UserGroup expectedPerms = "640" isUserPolicyFile = false } else { expectedPolicyFilepath = path.Join("/home/", tt.cmdUser, ".opk", "auth_id") expectedUser = tt.cmdUser expectedGroup = tt.cmdUser expectedPerms = "600" isUserPolicyFile = true } // Install automatically creates the system auth_id file, so can assume it exists if isUserPolicyFile { policyFileExists := FileExists(t, container.Container, expectedPolicyFilepath) require.False(t, policyFileExists, "home policy file should not exist yet in a fresh test container") // If test needs a preexisting auth_id file, create it if tt.preexistingHomeAuthIdFile { CreateAuthIdFile(t, container.Container, expectedPolicyFilepath, tt.cmdUser, isUserPolicyFile) policyFileExists := FileExists(t, container.Container, expectedPolicyFilepath) require.True(t, policyFileExists, "policy file should have been created in test container (test is broken)") } } // Build add command based on sub-test options addCmd := fmt.Sprintf("add %s foo@example.com %s", tt.desiredPrincipal, issuer) cmd := []string{tt.binaryPath, addCmd} if tt.useSudo { cmd = append([]string{"sudo"}, cmd...) } // Execute add command code, _ := executeCommandAsUser(t, container.Container, []string{"/bin/bash", "-c", strings.Join(cmd, " ")}, tt.cmdUser) if tt.shouldCmdFail { assert.Equal(t, 1, code, "add command should fail") if tt.preexistingHomeAuthIdFile && isUserPolicyFile { code, policyContents := executeCommandAsUser(t, container.Container, []string{"cat", expectedPolicyFilepath}, RootUser) require.Equal(t, 0, code, "failed to read policy file") assert.Empty(t, policyContents, "policy file should not be updated") } else { code, _ = executeCommandAsUser(t, container.Container, []string{"test", "-f", expectedPolicyFilepath}, RootUser) assert.NotEqual(t, 0, code, "policy file should not exist") } } else { require.Equal(t, 0, code, "failed to run add command") // Assert that the correct policy file is updated code, policyContents := executeCommandAsUser(t, container.Container, []string{"cat", expectedPolicyFilepath}, RootUser) require.Equal(t, 0, code, "failed to read policy file") gotPolicy := policy.FromTable([]byte(policyContents), "test-path") require.True(t, files.ConfigProblems().NoProblems()) expectedPolicy := &policy.Policy{ Users: []policy.User{ { IdentityAttribute: "foo@example.com", Principals: []string{tt.desiredPrincipal}, Issuer: issuer, }, }, } require.Equal(t, expectedPolicy, gotPolicy) // Assert that owner and permissions are still correct code, statOutput := executeCommandAsUser(t, container.Container, []string{"stat", "-c", "%U %G %a", expectedPolicyFilepath}, RootUser) require.Equal(t, 0, code, "failed to run stat command") statOutputSplit := strings.Split(strings.TrimSpace(statOutput), " ") require.Len(t, statOutputSplit, 3, "expected stat command to return 3 values") require.Equal(t, expectedUser, statOutputSplit[0]) // Assert user require.Equal(t, expectedGroup, statOutputSplit[1]) // Assert group require.Equal(t, expectedPerms, statOutputSplit[2]) // Assert permissions } // No matter what, if command fails or succeeds, the root policy // file should *never* be updated if the command was run without // sudo/as unprivileged user if !tt.useSudo { code, policyContents := executeCommandAsUser(t, container.Container, []string{"cat", policy.SystemDefaultPolicyPath}, RootUser) require.Equal(t, 0, code, "failed to read policy file") require.Empty(t, policyContents, "system policy file should not be updated if command was run without sudo") } }) } } opkssh-0.4.0/test/integration/fakeop.go000066400000000000000000000227311477307211400201330ustar00rootroot00000000000000// Copyright 2025 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 integration package integration import ( "bytes" "fmt" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "testing" "time" "log/slog" "github.com/jeremija/gosubmit" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/providers" "github.com/stretchr/testify/require" "github.com/zitadel/oidc/v3/example/server/exampleop" "github.com/zitadel/oidc/v3/example/server/storage" "github.com/zitadel/oidc/v3/pkg/op" ) // FakeOpServer is an OIDC provider example server that runs on a system-chosen // port on the local loopback interface for use in e2e tests. type FakeOpServer struct { op.Storage *httptest.Server } type deferredHandler struct { http.Handler } // NewFakeOpServer starts and returns a new FakeOpServer. The caller should call // Close() when finished to shut it down. func NewFakeOpServer() (*FakeOpServer, error) { exampleStorage := storage.NewStorage(storage.NewUserStore("http://localhost")) // Start new HTTP server var dh deferredHandler opServer := httptest.NewServer(&dh) issuerUrl := opServer.URL serverLogger := slog.New( slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ AddSource: true, Level: slog.LevelDebug, }), ) // Create the OIDC server handler and set this as the test HTTP server's // handler opRouter := exampleop.SetupServer(issuerUrl, exampleStorage, serverLogger, false) dh.Handler = opRouter return &FakeOpServer{ Storage: exampleStorage, Server: opServer, }, nil } // OpkProvider returns an OPK provider configured to interact with this fake // OIDC server and the login URL that is served by this OPK provider on // localhost when requesting tokens from the OP. // // The OPK provider uses this server's registered web client application's auth // details (clientID + clientSecret). scopes are the scopes to request when // performing OIDC login flow; if empty, then default scopes are used. func (s *FakeOpServer) OpkProvider() (client.OpenIdProvider, *url.URL, error) { // Find available port to run local auth callback server on when requesting // tokens from the OP. // // Technically, there is a small chance that this port is no longer free // before Login() is called. But we need to register the accepted redirect // URIs with the exampleop server before performing login. redirectURIPort, err := GetAvailablePort() if err != nil { return nil, nil, err } // Login callback path to redirect to after successful OIDC login callbackPath := "/login-callback" redirectURI := fmt.Sprintf("http://localhost:%d%s", redirectURIPort, callbackPath) // Register some OIDC client applications with this example OIDC server nativeClient := storage.NativeClient("native", redirectURI) clientSecret := "secret" webClient := storage.WebClient("web", clientSecret, redirectURI) storage.RegisterClients( nativeClient, webClient, ) // The *provider.GoogleProvider provider hosts the Op login redirect URL at // /login loginURL, err := url.Parse(fmt.Sprintf("http://localhost:%d/login", redirectURIPort)) if err != nil { return nil, nil, fmt.Errorf("failed to create login URL: %w", err) } // Disable auto-open URL feature as this provider should be used in // automated tests and not require user interaction via the browser provider := providers.NewGoogleOpWithOptions( &providers.GoogleOptions{ Issuer: s.URL, ClientID: webClient.GetID(), ClientSecret: clientSecret, RedirectURIs: []string{fmt.Sprintf("http://localhost:%d/login-callback", redirectURIPort)}, Scopes: []string{"openid", "profile", "email", "offline_access"}, OpenBrowser: false, }, ) return provider, loginURL, nil } // DoOidcInteractiveLogin runs the OIDC login procedure e2e from auth callback // server <--> OP login, assuming the OP server is running the zitadel exampleop // server. // // transport allows for customizing the Transport of the HTTP client used to // interact with the auth callback server and OP server. If nil, // http.DefaultTransport is used. // // loginURL is the auth callback server's login page that redirects to the OP // login page. username and password are the auth details to use to login when // presented with the login form. // // This function expects that the auth callback server serving loginURL is // already running. // // Only call this function in a go test function that has access to *testing.T. // If any error occurs when executing the login procedure, the test stops // execution and is marked as a failed test. func DoOidcInteractiveLogin(t *testing.T, transport http.RoundTripper, loginURL string, username string, password string) { // Source: https://github.com/zitadel/oidc/blob/9d12d1d900f30a2eed3a8e60b5e33988758409bf/pkg/client/integration_test.go#L191 jar, err := cookiejar.New(nil) require.NoError(t, err, "create cookie jar") httpClient := &http.Client{ Timeout: time.Second * 5, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, Transport: transport, Jar: jar, } t.Log("------- get OIDC provider login page ------") // Find the OIDC provider's login page by performing GET on the auth // callback server's login URL (AuthURLHandler) loginPageUrl, err := url.Parse(loginURL) require.NoError(t, err) loginPageUrl = getRedirect(t, "get redirect login url (#1)", httpClient, loginPageUrl) t.Logf("loginPageUrl (redirect #1): %v", loginPageUrl) loginPageUrl = getRedirect(t, "get redirect login url (#2)", httpClient, loginPageUrl) t.Logf("loginPageUrl (redirect #2): %v", loginPageUrl) // Get login form hosted by the fake OIDC provider server t.Log("------- get login form ------") form := getForm(t, "get login form", httpClient, loginPageUrl) t.Logf("login form (unfilled): %s", string(form)) // Perform login with supplied username and password t.Log("------- post to login form, get redirect to OP ------") postLoginRedirectURL := fillForm(t, "fill login form", httpClient, form, loginPageUrl, gosubmit.Set("username", username), gosubmit.Set("password", password), ) t.Logf("Get redirect from %s", postLoginRedirectURL) t.Log("------- redirect from OP back to auth callback server ------") codeBearingURL := getRedirect(t, "get redirect with code", httpClient, postLoginRedirectURL) t.Logf("Redirect with code %s", codeBearingURL) t.Log("------- complete OIDC flow (follow redirect URI) ------") resp, err := httpClient.Get(codeBearingURL.String()) require.NoError(t, err, "GET "+codeBearingURL.String()) defer resp.Body.Close() defer func() { if t.Failed() { body, _ := io.ReadAll(resp.Body) t.Logf("codeBearingURL: GET %s: body: %s", codeBearingURL, string(body)) } }() require.Equal(t, 200, resp.StatusCode) t.Log("Successfully completed OIDC login!") } func getRedirect(t *testing.T, desc string, httpClient *http.Client, uri *url.URL) *url.URL { // Source: https://github.com/zitadel/oidc/blob/9d12d1d900f30a2eed3a8e60b5e33988758409bf/pkg/client/integration_test.go#L442 req := &http.Request{ Method: "GET", URL: uri, Header: make(http.Header), } resp, err := httpClient.Do(req) require.NoError(t, err, "GET "+uri.String()) defer resp.Body.Close() defer func() { if t.Failed() { body, _ := io.ReadAll(resp.Body) t.Logf("%s: GET %s: body: %s", desc, uri, string(body)) } }() redirect, err := resp.Location() require.NoErrorf(t, err, "%s: get redirect %s", desc, uri) require.NotEmptyf(t, redirect, "%s: get redirect %s", desc, uri) return redirect } func getForm(t *testing.T, desc string, httpClient *http.Client, uri *url.URL) []byte { // Source: https: //github.com/zitadel/oidc/blob/9d12d1d900f30a2eed3a8e60b5e33988758409bf/pkg/client/integration_test.go#L466 req := &http.Request{ Method: "GET", URL: uri, Header: make(http.Header), } resp, err := httpClient.Do(req) require.NoErrorf(t, err, "%s: GET %s", desc, uri) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err, "%s: read GET %s", desc, uri) return body } func fillForm(t *testing.T, desc string, httpClient *http.Client, body []byte, uri *url.URL, opts ...gosubmit.Option) *url.URL { // Source: https://github.com/zitadel/oidc/blob/9d12d1d900f30a2eed3a8e60b5e33988758409bf/pkg/client/integration_test.go#L481 req := gosubmit.ParseWithURL(io.NopCloser(bytes.NewReader(body)), uri.String()).FirstForm().Testing(t).NewTestRequest( append([]gosubmit.Option{gosubmit.AutoFill()}, opts...)..., ) if req.URL.Scheme == "" { req.URL = uri t.Log("request lost it's proto..., adding back... request now", req.URL) } req.RequestURI = "" // bug in gosubmit? resp, err := httpClient.Do(req) require.NoErrorf(t, err, "%s: POST %s", desc, uri) defer resp.Body.Close() defer func() { if t.Failed() { body, _ := io.ReadAll(resp.Body) t.Logf("%s: GET %s: body: %s", desc, uri, string(body)) } }() redirect, err := resp.Location() require.NoErrorf(t, err, "%s: redirect for POST %s", desc, uri) return redirect } opkssh-0.4.0/test/integration/integration.go000066400000000000000000000123031477307211400212030ustar00rootroot00000000000000// Copyright 2025 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 integration // Package integration contains integration tests. // // These tests test opkssh e2e using external dependencies. package integration import ( "context" "errors" "fmt" "net" "net/http" "os" "path/filepath" "strings" "time" "github.com/testcontainers/testcontainers-go" "golang.org/x/crypto/ssh" ) const ( // LoginCallbackServerTimeout is the amount of time to wait for the opkssh // login callback server to startup LoginCallbackServerTimeout = 5 * time.Second ) // TestLogConsumer consumes log messages outputted by Docker containers spawned // by testcontainers-go. type TestLogConsumer struct { Msgs []string } // NewTestLogConsumer returns a new TestLogConsumer. func NewTestLogConsumer() *TestLogConsumer { return &TestLogConsumer{ Msgs: []string{}, } } // Accept appends the log message to an internal buffer. func (g *TestLogConsumer) Accept(l testcontainers.Log) { g.Msgs = append(g.Msgs, string(l.Content)) } // Dump returns all collected log messages from stdout or stderr func (g *TestLogConsumer) Dump() string { return strings.Join(g.Msgs, "") } // WaitForServer waits for an HTTP server running at url to start within the // supplied timeout. func WaitForServer(ctx context.Context, url string, timeout time.Duration) error { ch := make(chan error) // Create context that cancels after specified timeout ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() go func() { for { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { ch <- err return } _, err = http.DefaultClient.Do(req) if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { return } if err == nil { ch <- nil return } time.Sleep(10 * time.Millisecond) } }() // Wait for response or timeout select { case err := <-ch: return err case <-ctx.Done(): return ctx.Err() } } // GetOPKSshKey tries to find a valid OPK SSH key at one of the expected // locations. If found, the parsed public SSH key and path to its secret key is // returned. Otherwise, an error is returned if no valid OPK SSH key could be // found. func GetOPKSshKey() (ssh.PublicKey, string, error) { // Get user's SSH path homePath, err := os.UserHomeDir() if err != nil { return nil, "", fmt.Errorf("failed to get user's home directory: %w", err) } sshPath := filepath.Join(homePath, ".ssh") // Find a valid OPK SSH key at one of the expected locations expectedSSHSecKeyFilePaths := []string{"id_ecdsa", "id_dsa"} var pubKey ssh.PublicKey var secKeyFilePath string for _, secKeyFilePath = range expectedSSHSecKeyFilePaths { secKeyFilePath = filepath.Join(sshPath, secKeyFilePath) // Read public key. Expected public key has suffix ".pub" pubKeyFilePath := secKeyFilePath + ".pub" sshPubKey, err := os.ReadFile(pubKeyFilePath) if err != nil { continue } // Parse the public key and check that it is an openpubkey SSH cert parsedPubKey, comment, _, _, err := ssh.ParseAuthorizedKey(sshPubKey) if err != nil { continue } // Check if it's an OPK ssh key if comment == "openpubkey" { pubKey = parsedPubKey break } } // Check to see if we find at least one OPK SSH key if pubKey == nil { return nil, "", fmt.Errorf("failed to find valid OPK public SSH key") } // Check private SSH key file exists if _, err := os.Stat(secKeyFilePath); err == nil { return pubKey, secKeyFilePath, nil } else if errors.Is(err, os.ErrNotExist) { return nil, "", fmt.Errorf("failed to find corresponding OPK private SSH key at path %s: %w", secKeyFilePath, err) } else { return nil, "", err } } // GetAvailablePort finds and returns an available TCP port to bind to on // localhost. There is no guarantee the port remains available after this // function returns. func GetAvailablePort() (int, error) { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return 0, fmt.Errorf("failed to find available port: %w", err) } defer l.Close() return l.Addr().(*net.TCPAddr).Port, nil } // TryFunc runs f every second until f returns nil or the context is cancelled. // Returns the last error returned by f; otherwise, if f never had a chance to // run (due the context being cancelled before running f at least once), then // the context's error is returned instead. func TryFunc(ctx context.Context, f func() error) error { ticker := time.NewTicker(time.Second) defer ticker.Stop() var err error for { select { case <-ctx.Done(): if err == nil { return ctx.Err() } return err case <-ticker.C: // Save error err = f() if err == nil { return nil } } } } opkssh-0.4.0/test/integration/login_test.go000066400000000000000000000061011477307211400210260ustar00rootroot00000000000000// Copyright 2025 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 integration package integration import ( "context" "crypto/rand" "fmt" "os" "testing" "time" "github.com/openpubkey/opkssh/commands" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" ) func TestLogin(t *testing.T) { // Check that user can login and that valid openpubkey keys are written to // the correct places on disk // Setup fake OIDC server on localhost t.Log("------- setup OIDC server on localhost ------") opServer, err := NewFakeOpServer() require.NoError(t, err, "failed to create fake OIDC server") defer opServer.Close() t.Logf("OP server running at %s", opServer.URL) // Call login t.Log("------- call login cmd ------") errCh := make(chan error) opkProvider, loginURL, err := opServer.OpkProvider() require.NoError(t, err, "failed to create OPK provider") go func() { err := commands.Login(TestCtx, opkProvider) errCh <- err }() // Wait for auth callback server on localhost to come up. It should come up // when login command is called timeoutErr := WaitForServer(TestCtx, fmt.Sprintf("%s://%s", loginURL.Scheme, loginURL.Host), LoginCallbackServerTimeout) require.NoError(t, timeoutErr, "login callback server took too long to startup") // Do OIDC login DoOidcInteractiveLogin(t, nil, loginURL.String(), "test-user@localhost", "verysecure") // Wait for interactive login to complete and assert no error occurred timeoutCtx, cancel := context.WithTimeout(TestCtx, 3*time.Second) defer cancel() select { case loginErr := <-errCh: require.NoError(t, loginErr, "failed login") case <-timeoutCtx.Done(): t.Fatal(timeoutCtx.Err()) } // Expect to find OPK SSH key is written to disk pubKey, secKeyFilePath, err := GetOPKSshKey() require.NoError(t, err) require.Equal(t, ssh.CertAlgoECDSA256v01, pubKey.Type(), "expected SSH public key to be an ecdsa-sha2-nistp256 certificate") // Parse the private key and check that it is the private key for the public // key above by signing and verifying a message secKeyBytes, err := os.ReadFile(secKeyFilePath) require.NoErrorf(t, err, "failed to read SSH secret key at expected path %s", secKeyFilePath) secKey, err := ssh.ParsePrivateKey(secKeyBytes) require.NoError(t, err, "failed to parse SSH private key") msg := []byte("test") sig, err := secKey.Sign(rand.Reader, msg) require.NoError(t, err, "failed to sign message using parsed SSH private key") require.NoError(t, pubKey.Verify(msg, sig), "failed to verify message using parsed OPK SSH public key") } opkssh-0.4.0/test/integration/opkssh_test.go000066400000000000000000000024121477307211400212260ustar00rootroot00000000000000// Copyright 2025 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 integration package integration import ( "context" "os" "os/signal" "testing" ) // TestCtx is marked done when the `go test` binary receives an interrupt signal // or after all tests in the integration package have finished running var TestCtx context.Context func TestMain(m *testing.M) { os.Exit(func() int { // Do init stuff before all integration tests. defers in this func are // called after all the integration tests are complete. // Setup global integration CTX that accepts signal interrupt ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() TestCtx = ctx return m.Run() }()) } opkssh-0.4.0/test/integration/provider/000077500000000000000000000000001477307211400201645ustar00rootroot00000000000000opkssh-0.4.0/test/integration/provider/exampleop.Dockerfile000066400000000000000000000007601477307211400241520ustar00rootroot00000000000000FROM golang:1.20.12-bookworm ENV AUTH_CALLBACK_PATH "" ENV REDIRECT_PORT "" ENV PORT "" # Expose OIDC server so we can access it in the tests EXPOSE $PORT WORKDIR /app RUN git clone --branch test https://github.com/bastionzero/oidc.git WORKDIR /app/oidc/ RUN go mod download RUN go build -o /server -v ./example/server/dynamic # Start example OIDC server on container startup CMD ["sh", "-c", "AUTH_CALLBACK_PATH=${AUTH_CALLBACK_PATH} REDIRECT_PORT=${REDIRECT_PORT} PORT=${PORT} /server"]opkssh-0.4.0/test/integration/provider/provider.go000066400000000000000000000043551477307211400223540ustar00rootroot00000000000000// Copyright 2025 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 integration package provider import ( "context" "fmt" "net/http" "path/filepath" "time" "github.com/openpubkey/opkssh/internal/projectpath" "github.com/docker/go-connections/nat" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) type ExampleOpContainer struct { testcontainers.Container Host string Port int } func RunExampleOpContainer(ctx context.Context, networkName string, env map[string]string, port string) (*ExampleOpContainer, error) { issuerServerPort := fmt.Sprintf("%s/tcp", port) req := testcontainers.ContainerRequest{ FromDockerfile: testcontainers.FromDockerfile{ Context: projectpath.Root, Dockerfile: filepath.Join("test", "integration", "provider", "exampleop.Dockerfile"), PrintBuildLog: true, KeepImage: true, }, Env: env, ExposedPorts: []string{issuerServerPort}, Networks: []string{ networkName, }, ImagePlatform: "linux/amd64", WaitingFor: wait.ForHTTP("/.well-known/openid-configuration"). WithPort(nat.Port(issuerServerPort)). WithStartupTimeout(10 * time.Second). WithMethod(http.MethodGet), } container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return nil, err } mappedPort, err := container.MappedPort(ctx, nat.Port(issuerServerPort)) if err != nil { return nil, err } hostIP, err := container.Host(ctx) if err != nil { return nil, err } return &ExampleOpContainer{ Container: container, Host: hostIP, Port: mappedPort.Int(), }, nil } opkssh-0.4.0/test/integration/ssh_server/000077500000000000000000000000001477307211400205155ustar00rootroot00000000000000opkssh-0.4.0/test/integration/ssh_server/centos_opkssh.Dockerfile000066400000000000000000000042461477307211400253760ustar00rootroot00000000000000# Stage 1: Build the Go binary FROM golang:1.23 as builder # Set destination for COPY WORKDIR /app # Download Go modules COPY go.mod go.sum ./ RUN go mod download # Copy our repo COPY . ./ # Copy the source code and build the binary ARG ISSUER_PORT="9998" RUN go build -v -o opksshbuild # Stage 2: Create a minimal CentOS-based image FROM quay.io/centos/centos:stream9 # Install dependencies required for runtime (e.g., SSH server) RUN dnf update -y && \ dnf install -y sudo openssh-server openssh-clients telnet wget jq && \ dnf clean all # Source: # https://medium.com/@ratnesh4209211786/simplified-ssh-server-setup-within-a-docker-container-77eedd87a320 # # Create an SSH user named "test". Make it a sudoer RUN useradd -rm -d /home/test -s /bin/bash -g root -G wheel -u 1000 test # Set password to "test" RUN echo "test:test" | chpasswd # Make it so "test" user does not need to present password when using sudo # Source: https://askubuntu.com/a/878705 RUN echo "test ALL=(ALL:ALL) NOPASSWD: ALL" | tee /etc/sudoers.d/test # Create unprivileged user named "test2" RUN useradd -rm -d /home/test2 -s /bin/bash -u 1001 test2 # Set password to "test" RUN echo "test2:test" | chpasswd # Allow SSH access RUN mkdir /var/run/sshd # Expose SSH server so we can ssh in from the tests EXPOSE 22 WORKDIR /app # Copy binary and install script from builder COPY --from=builder /app/opksshbuild ./opksshbuild COPY --from=builder /app/scripts/install-linux.sh install-linux.sh # Run install script to install/configure opkssh RUN chmod +x install-linux.sh RUN bash ./install-linux.sh --install-from=opksshbuild --no-sshd-restart RUN opkssh --version RUN ls -l /usr/local/bin RUN printenv PATH ARG ISSUER_PORT="9998" RUN echo "http://oidc.local:${ISSUER_PORT}/ web oidc_refreshed" >> /etc/opk/providers # Add integration test user as allowed email in policy (this directly tests # policy "add" command) ARG BOOTSTRAP_POLICY RUN if [ -n "$BOOTSTRAP_POLICY" ] ; then opkssh add "test" "test-user@zitadel.ch" "http://oidc.local:${ISSUER_PORT}/"; else echo "Will not init policy" ; fi # Generate SSH host keys RUN ssh-keygen -A # Start the SSH server on container startup CMD ["/usr/sbin/sshd", "-D"]opkssh-0.4.0/test/integration/ssh_server/debian_opkssh.Dockerfile000066400000000000000000000033551477307211400253250ustar00rootroot00000000000000FROM golang:1.23 # Update/Upgrade RUN apt-get update -y && apt-get upgrade -y # Install dependencies, such as the SSH server RUN apt-get install -y sudo openssh-server telnet jq # Source: # https://medium.com/@ratnesh4209211786/simplified-ssh-server-setup-within-a-docker-container-77eedd87a320 # # Create an SSH user named "test". Make it a sudoer RUN useradd -rm -d /home/test -s /bin/bash -g root -G sudo -u 1000 test # Set password to "test" RUN echo "test:test" | chpasswd # Make it so "test" user does not need to present password when using sudo # Source: https://askubuntu.com/a/878705 RUN echo "test ALL=(ALL:ALL) NOPASSWD: ALL" | tee /etc/sudoers.d/test # Create unprivileged user named "test2" RUN useradd -rm -d /home/test2 -s /bin/bash -u 1001 test2 # Set password to "test" RUN echo "test2:test" | chpasswd # Allow SSH access RUN mkdir /var/run/sshd # Expose SSH server so we can ssh in from the tests EXPOSE 22 # Set destination for COPY WORKDIR /app # Download Go modules COPY go.mod go.sum ./ RUN go mod download # Copy our repo COPY . ./ # Build "opkssh" binary and write to the opk directory ARG ISSUER_PORT="9998" RUN go build -v -o opksshbuild RUN chmod +x ./scripts/install-linux.sh RUN bash ./scripts/install-linux.sh --install-from=opksshbuild --no-sshd-restart # RUN chmod 700 /usr/local/bin/opkssh RUN echo "http://oidc.local:${ISSUER_PORT}/ web oidc_refreshed" >> /etc/opk/providers # Add integration test user as allowed email in policy (this directly tests # policy "add" command) ARG BOOTSTRAP_POLICY RUN if [ -n "$BOOTSTRAP_POLICY" ] ; then opkssh add "test" "test-user@zitadel.ch" "http://oidc.local:${ISSUER_PORT}/"; else echo "Will not init policy" ; fi # Start SSH server on container startup CMD ["/usr/sbin/sshd", "-D"]opkssh-0.4.0/test/integration/ssh_server/ssh_server.go000066400000000000000000000076511477307211400232400ustar00rootroot00000000000000// Copyright 2025 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 integration package ssh_server import ( "context" "fmt" "os" "path/filepath" "strings" "time" "github.com/openpubkey/opkssh/internal/projectpath" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) type SshServerContainer struct { testcontainers.Container Host string Port int User string Password string } func RunOpkSshContainer(ctx context.Context, issuerHostIp string, issuerPort string, networkName string, bootstrapPolicy bool) (*SshServerContainer, error) { osType := os.Getenv("OS_TYPE") var dockerFile string var err error if osType == "ubuntu" { dockerFile = filepath.Join("test", "integration", "ssh_server", "debian_opkssh.Dockerfile") } else if osType == "centos" { dockerFile = filepath.Join("test", "integration", "ssh_server", "centos_opkssh.Dockerfile") } else { return nil, fmt.Errorf("unsupported OS type: %s", osType) } req := testcontainers.ContainerRequest{ FromDockerfile: testcontainers.FromDockerfile{ Context: projectpath.Root, Dockerfile: dockerFile, PrintBuildLog: true, KeepImage: true, BuildArgs: make(map[string]*string), }, ExposedPorts: []string{"22/tcp"}, ImagePlatform: "linux/amd64", // Wait for SSH server to be running by attempting to connect // // https://stackoverflow.com/a/54364978 WaitingFor: wait.ForExec(strings.Split("echo -e '\\x1dclose\\x0d' | telnet localhost 22", " ")). WithStartupTimeout(time.Second * 10). WithExitCodeMatcher(func(exitCode int) bool { return exitCode == 0 }), } if issuerHostIp != "" { req.ExtraHosts = []string{fmt.Sprintf("oidc.local:%v", issuerHostIp)} } if issuerPort != "" { req.BuildArgs["ISSUER_PORT"] = &issuerPort } if networkName != "" { req.Networks = []string{networkName} } if bootstrapPolicy { trueStr := "true" req.BuildArgs["BOOTSTRAP_POLICY"] = &trueStr } container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return nil, err } mappedPort, err := container.MappedPort(ctx, "22") if err != nil { return nil, err } hostIP, err := container.Host(ctx) if err != nil { return nil, err } return &SshServerContainer{ Container: container, Host: hostIP, Port: mappedPort.Int(), User: "test", Password: "test", }, nil } func RunUbuntuContainer(ctx context.Context) (*SshServerContainer, error) { req := testcontainers.ContainerRequest{ FromDockerfile: testcontainers.FromDockerfile{ Context: projectpath.Root, Dockerfile: filepath.Join("test", "integration", "ssh_server", "ubuntu.Dockerfile"), KeepImage: true, }, ExposedPorts: []string{"22/tcp"}, ImagePlatform: "linux/amd64", WaitingFor: wait.ForExposedPort(), } container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { return nil, err } mappedPort, err := container.MappedPort(ctx, "22") if err != nil { return nil, err } hostIP, err := container.Host(ctx) if err != nil { return nil, err } return &SshServerContainer{ Container: container, Host: hostIP, Port: mappedPort.Int(), User: "test", Password: "test", }, nil } opkssh-0.4.0/test/integration/ssh_server/ubuntu.Dockerfile000066400000000000000000000012111477307211400240230ustar00rootroot00000000000000FROM ubuntu:latest # Update/Upgrade RUN apt-get update -y && apt-get upgrade -y # Install dependencies, such as the SSH server RUN apt-get install -y sudo openssh-server # Source: # https://medium.com/@ratnesh4209211786/simplified-ssh-server-setup-within-a-docker-container-77eedd87a320 # # Create an SSH user named "test". Make it a sudoer RUN useradd -rm -d /home/test -s /bin/bash -g root -G sudo -u 1000 test # Set password to "test" RUN echo 'test:test' | chpasswd # Allow SSH access RUN mkdir /var/run/sshd # Expose SSH server so we can ssh in from the tests EXPOSE 22 # Start SSH server on container startup CMD ["/usr/sbin/sshd", "-D"]opkssh-0.4.0/test/integration/ssh_test.go000066400000000000000000000616401477307211400205240ustar00rootroot00000000000000// Copyright 2025 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 integration package integration import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "os" "os/exec" "strconv" "strings" "testing" "time" "github.com/openpubkey/openpubkey/discover" simpleoidc "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/pktoken/clientinstance" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/opkssh/commands" testprovider "github.com/openpubkey/opkssh/test/integration/provider" "github.com/openpubkey/opkssh/test/integration/ssh_server" "github.com/melbahja/goph" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/testcontainers/testcontainers-go" "golang.org/x/crypto/ssh" ) const ( // callbackPath is the login callback path that the OIDC provider redirects // to after successful OIDC login callbackPath = "/login-callback" // issuerPort is the port the example OIDC provider runs its server on issuerPort = "9998" // networkName is the name of the Docker network that the test containers // are connected to networkName = "opkssh-integration-test-net" ) // oidcHttpClientTransport wraps an existing http.RoundTripper and sets the // `Host` header of all HTTP requests to one of the registered issuer hostnames // (oidc.local) of the dynamic zitadel example server. The zitadel server, when // run in dynamic mode, uses the `Host` header to figure out the issuer--if we // don't set it, then it will be 127.0.0.1 which is not the issuer that the OPK // verifier expects type oidcHttpClientTransport struct { underlyingTransport http.RoundTripper // port is the port that the zitadel example issuer server is running on // internally within the docker container (the exposed port) port string } func (t *oidcHttpClientTransport) RoundTrip(req *http.Request) (*http.Response, error) { req.Host = fmt.Sprintf("oidc.local:%s", t.port) return t.underlyingTransport.RoundTrip(req) } // pulseRefreshProvider wraps an existing provider.RefreshableOP, but modifies // Refresh() to block until a pulse is received. Once Pulse() is called, // Refresh() unblocks and the function call is forwarded to the underlying // provider.RefreshableOP type pulseRefreshProvider struct { RefreshableOP providers.RefreshableOpenIdProvider pulseCh chan struct{} } // newPulseRefreshProvider creates a new pulseRefreshProvider func newPulseRefreshProvider(provider providers.RefreshableOpenIdProvider) *pulseRefreshProvider { return &pulseRefreshProvider{ RefreshableOP: provider, pulseCh: make(chan struct{}, 1), } } func (p *pulseRefreshProvider) RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error) { return p.RefreshableOP.RequestTokens(ctx, cic) } func (p *pulseRefreshProvider) VerifyRefreshedIDToken(ctx context.Context, origIdt []byte, reIdt []byte) error { return p.RefreshableOP.VerifyRefreshedIDToken(ctx, origIdt, reIdt) } func (p *pulseRefreshProvider) PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error) { return p.RefreshableOP.PublicKeyByToken(ctx, token) } func (p *pulseRefreshProvider) VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error { return p.RefreshableOP.VerifyIDToken(ctx, idt, cic) } // Pulse unblocks Refresh() func (p *pulseRefreshProvider) Pulse() { p.pulseCh <- struct{}{} } func (p *pulseRefreshProvider) Issuer() string { return p.RefreshableOP.Issuer() } func (p *pulseRefreshProvider) PublicKeyByKeyId(ctx context.Context, keyId string) (*discover.PublicKeyRecord, error) { return p.RefreshableOP.PublicKeyByKeyId(ctx, keyId) } // Refresh calls the underlying provider.RefreshableOP() function only after a // pulse has been received. This function stops waiting for a pulse if TestCtx // has been cancelled. func (p *pulseRefreshProvider) RefreshTokens(ctx context.Context, refreshToken []byte) (*simpleoidc.Tokens, error) { select { case <-p.pulseCh: return p.RefreshableOP.RefreshTokens(ctx, refreshToken) case <-TestCtx.Done(): return nil, TestCtx.Err() } } // createOpkSshSigner creates an ssh.Signer, for use in a go ssh client, by // combining the OPK SSH public key (certificate) and the corresponding SSH // private key // // This function returns both an ssh.Signer and the pubKey casted as an // *ssh.Certificate func createOpkSshSigner(t *testing.T, pubKey ssh.PublicKey, secKeyFilePath string) (ssh.Signer, *ssh.Certificate) { // Source: https://carlosbecker.com/posts/golang-ssh-client-certificates/ // Parse the user's private key pvtKeyBts, err := os.ReadFile(secKeyFilePath) require.NoError(t, err) signer, err := ssh.ParsePrivateKey(pvtKeyBts) require.NoError(t, err) // Create a signer using both the certificate and the private key sshCert, ok := pubKey.(*ssh.Certificate) require.True(t, ok, "SSH public key should be of type *ssh.Certificate") certSigner, err := ssh.NewCertSigner(sshCert, signer) require.NoError(t, err) return certSigner, sshCert } // createZitadelOPKSshProvider creates an OPK SSH provider, the same one used by // opkssh, except the issuer has been configured to be the fake OIDC server // running in a Docker container // // This function returns both an OPK SSH provider and an HTTP transport that has // been modified from the http.DefaultTransport to send requests to 127.0.0.1 // instead of oidc.local func createZitadelOPKSshProvider(oidcContainerMappedPort int, authCallbackServerRedirectPort int) (zitadelOp providers.BrowserOpenIdProvider, httpTransport http.RoundTripper) { // Create custom HTTP client that sends HTTP requests to the correct port // and valid IP of the container running the OIDC server instead of // "oidc.local" (which is an unknown name on the host machine); "oidc.local" // is still preserved in the HTTP request because we add that back in the // Host header customDialTransport := &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { dialer := &net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, } // Perform "fake" DNS lookup and overwrite the outgoing // address--instead of sending the request to "oidc.local" (which is // not mapped in /etc/hosts on the host machine and therefore should // fail on lookup), send it to localhost and the forwarded port of // the OIDC container if addr == fmt.Sprintf("oidc.local:%s", issuerPort) { addr = fmt.Sprintf("127.0.0.1:%v", oidcContainerMappedPort) } return dialer.DialContext(ctx, network, addr) }, } httpTransport = &oidcHttpClientTransport{underlyingTransport: customDialTransport, port: issuerPort} httpClient := http.Client{Transport: httpTransport} zitadelOp = providers.NewGoogleOpWithOptions(&providers.GoogleOptions{ Issuer: fmt.Sprintf("http://oidc.local:%s/", issuerPort), ClientID: "web", ClientSecret: "secret", RedirectURIs: []string{fmt.Sprintf("http://localhost:%d/login-callback", authCallbackServerRedirectPort)}, // TODO: check this correct Scopes: []string{"openid", "profile", "email", "offline_access"}, OpenBrowser: false, HttpClient: &httpClient, }) return } // spawnTestContainers spawns a container running an example OIDC issuer and a // linux container configured with sshd and opkssh as the // AuthorizedKeysCommand. // // 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) { // 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{ NetworkRequest: testcontainers.NetworkRequest{ Name: networkName, CheckDuplicate: true, }, }) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, newNetwork.Remove(TestCtx), "failed to terminate Docker network used for e2e ssh tests") }) // Start OIDC server authCallbackRedirectPort, err = GetAvailablePort() require.NoError(t, err) oidcContainer, err = testprovider.RunExampleOpContainer( TestCtx, networkName, map[string]string{ "AUTH_CALLBACK_PATH": callbackPath, "REDIRECT_PORT": strconv.Itoa(authCallbackRedirectPort), "PORT": issuerPort, }, issuerPort, ) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, oidcContainer.Terminate(TestCtx), "failed to terminate OIDC container") }) // Track OIDC server logs and dump if test fails tlc := NewTestLogConsumer() oidcContainer.FollowOutput(tlc) err = oidcContainer.StartLogProducer(TestCtx) require.NoError(t, err) t.Cleanup(func() { if t.Failed() { logs := tlc.Dump() t.Logf("oidcContainer logs: \n%v", string(logs)) } }) // Start linux container with opkssh installed and configured to verify // incoming PK tokens against the OIDC issuer created above issuerIp, err := oidcContainer.ContainerIP(TestCtx) require.NoError(t, err) serverContainer, err = ssh_server.RunOpkSshContainer( TestCtx, issuerIp, issuerPort, networkName, true, ) require.NoError(t, err) t.Cleanup(func() { require.NoError(t, serverContainer.Terminate(TestCtx), "failed to terminate SSH container") }) // Use backdoor (non-OPK) SSH client to dump opkssh logs if test fails auth := goph.Password(serverContainer.Password) nonOpkSshClient, err := goph.NewConn(&goph.Config{ User: serverContainer.User, Addr: serverContainer.Host, Port: uint(serverContainer.Port), Auth: auth, Timeout: goph.DefaultTimeout, Callback: ssh.InsecureIgnoreHostKey(), }) require.NoError(t, err) t.Cleanup(func() { if t.Failed() { // Get opkssh error logs _, err := nonOpkSshClient.Run("sudo chmod 777 /var/log/opkssh.log") if assert.NoError(t, err) { errorLog, err := nonOpkSshClient.Run("cat /var/log/opkssh.log") if assert.NoError(t, err) { t.Logf("/var/log/opkssh.log: \n%v", string(errorLog)) } } } require.NoError(t, nonOpkSshClient.Close(), "failed to close backdoor (non-OPK) SSH client") }) return } func TestEndToEndSSH(t *testing.T) { // Test opkssh e2e by performing an SSH connection to a linux container. // // Tests login, policy, and verify against an example OIDC server and // container configured with opkssh in the "AuthorizedKeysCommand" var err error // Spawn test containers to run these tests oidcContainer, authCallbackRedirectPort, serverContainer := spawnTestContainers(t) // Create OPK SSH provider that is configured against the spawned OIDC // container's issuer server zitadelOp, customTransport := createZitadelOPKSshProvider(oidcContainer.Port, authCallbackRedirectPort) // Call login errCh := make(chan error) t.Log("------- call login cmd ------") go func() { err := commands.Login(TestCtx, zitadelOp) errCh <- err }() // Wait for login-callback server on localhost to come up. It should come up // when login command is called timeoutErr := WaitForServer(TestCtx, fmt.Sprintf("http://localhost:%d", authCallbackRedirectPort), LoginCallbackServerTimeout) require.NoError(t, timeoutErr, "login callback server took too long to startup") // Do OIDC login. Use custom transport that adds the expected Host // header--if not specified, then the zitadel server will say it is an // unexpected issuer DoOidcInteractiveLogin(t, customTransport, fmt.Sprintf("http://localhost:%d/login", authCallbackRedirectPort), "test-user@oidc.local", "verysecure") // Wait for interactive login to complete and assert no error occurred timeoutCtx, cancel := context.WithTimeout(TestCtx, 3*time.Second) defer cancel() select { case loginErr := <-errCh: require.NoError(t, loginErr, "failed login") case <-timeoutCtx.Done(): t.Fatal(timeoutCtx.Err()) } // Expect to find OPK SSH key is written to disk pubKey, secKeyFilePath, err := GetOPKSshKey() require.NoError(t, err, "expected to find OPK ssh key written to disk") // Create OPK SSH signer using the found OPK SSH key on disk certSigner, _ := createOpkSshSigner(t, pubKey, secKeyFilePath) // Start new ssh connection using the OPK ssh cert key authKey := goph.Auth{ssh.PublicKeys(certSigner)} opkSshClient, err := goph.NewConn(&goph.Config{ User: serverContainer.User, Addr: serverContainer.Host, Port: uint(serverContainer.Port), Auth: authKey, Timeout: goph.DefaultTimeout, Callback: ssh.InsecureIgnoreHostKey(), }) require.NoError(t, err) defer opkSshClient.Close() // Run simple command to test the connection out, err := opkSshClient.Run("whoami") require.NoError(t, err) require.Equal(t, serverContainer.User, strings.TrimSpace(string(out))) t.Log("Testing SFTP") // Ensure the test file does not exist remoteTestFilePath := "/home/test/testfile.txt" localTestFilePath := "testfile.txt" _, err = opkSshClient.Run("test -f " + remoteTestFilePath) require.Error(t, err, "expected test file to not exist") testContent := "IF YOU CAN READ THIS SFTP WORKS!" _, err = exec.Command("bash", "-c", fmt.Sprintf("echo %q > %s", testContent, localTestFilePath)).CombinedOutput() require.NoError(t, err, "failed to create test file") // Execute the SFTP command to copy the test file to the server sftpCommand := fmt.Sprintf("echo 'put %s %s' | sftp -o StrictHostKeyChecking=no -P %d %s@%s", localTestFilePath, remoteTestFilePath, uint(serverContainer.Port), serverContainer.User, serverContainer.Host) out, err = exec.Command("bash", "-c", sftpCommand).CombinedOutput() require.NoError(t, err, "failed to execute SFTP command") out, err = opkSshClient.Run("cat " + remoteTestFilePath) require.NoError(t, err) require.Equal(t, testContent, strings.TrimSpace(string(out)), "SFTP file content mismatch") } func TestEndToEndSSHAsUnprivilegedUser(t *testing.T) { // Test usecase of unprivileged user using opkssh e2e by performing an SSH // connection to a linux container. // // This user has policy access via their user policy--not the root policy var err error // Spawn test containers to run these tests oidcContainer, authCallbackRedirectPort, serverContainer := spawnTestContainers(t) // Create OPK SSH provider that is configured against the spawned OIDC // container's issuer server zitadelOp, customTransport := createZitadelOPKSshProvider(oidcContainer.Port, authCallbackRedirectPort) // Give integration test user access to test2 via user policy issuer := fmt.Sprintf("http://oidc.local:%s/", issuerPort) cmdString := fmt.Sprintf("opkssh add \"test2\" \"test-user@zitadel.ch\" \"%s\"", issuer) code, _ := executeCommandAsUser(t, serverContainer.Container, []string{"/bin/bash", "-c", cmdString}, "test2") require.Equal(t, 0, code, "failed to update user policy") // Call login errCh := make(chan error) t.Log("------- call login cmd ------") go func() { err := commands.Login(TestCtx, zitadelOp) errCh <- err }() // Wait for login-callback server on localhost to come up. It should come up // when login command is called timeoutErr := WaitForServer(TestCtx, fmt.Sprintf("http://localhost:%d", authCallbackRedirectPort), LoginCallbackServerTimeout) require.NoError(t, timeoutErr, "login callback server took too long to startup") // Do OIDC login. Use custom transport that adds the expected Host // header--if not specified, then the zitadel server will say it is an // unexpected issuer DoOidcInteractiveLogin(t, customTransport, fmt.Sprintf("http://localhost:%d/login", authCallbackRedirectPort), "test-user@oidc.local", "verysecure") // Wait for interactive login to complete and assert no error occurred timeoutCtx, cancel := context.WithTimeout(TestCtx, 3*time.Second) defer cancel() select { case loginErr := <-errCh: require.NoError(t, loginErr, "failed login") case <-timeoutCtx.Done(): t.Fatal(timeoutCtx.Err()) } // Expect to find OPK SSH key is written to disk pubKey, secKeyFilePath, err := GetOPKSshKey() require.NoError(t, err, "expected to find OPK ssh key written to disk") // Create OPK SSH signer using the found OPK SSH key on disk certSigner, _ := createOpkSshSigner(t, pubKey, secKeyFilePath) // Start new ssh connection using the OPK ssh cert key authKey := goph.Auth{ssh.PublicKeys(certSigner)} opkSshClient, err := goph.NewConn(&goph.Config{ User: "test2", // test2 is not a sudoer Addr: serverContainer.Host, Port: uint(serverContainer.Port), Auth: authKey, Timeout: goph.DefaultTimeout, Callback: ssh.InsecureIgnoreHostKey(), }) require.NoError(t, err) defer opkSshClient.Close() // Run simple command to test the connection out, err := opkSshClient.Run("whoami") require.NoError(t, err) require.Equal(t, "test2", strings.TrimSpace(string(out))) } func updateIdTokenLifetime(t *testing.T, oidcContainerMappedPort int, duration string) { controlServerClient := &http.Client{} controlWebClientBaseURL := fmt.Sprintf("http://127.0.0.1:%d/control/client/web/", oidcContainerMappedPort) + "%s" req, err := http.NewRequestWithContext(TestCtx, http.MethodPatch, fmt.Sprintf(controlWebClientBaseURL, "idTokenLifetime"), bytes.NewBufferString(duration)) require.NoError(t, err) resp, err := controlServerClient.Do(req) require.NoError(t, err, "PATCH idTokenLifetime") defer resp.Body.Close() defer func() { if t.Failed() { body, _ := io.ReadAll(resp.Body) t.Logf("PATCH idTokenLifetime: body: %s", string(body)) } }() require.Equal(t, 200, resp.StatusCode) } func TestEndToEndSSHWithRefresh(t *testing.T) { // Test refresh flow of opkssh e2e by first attempting to SSH with an // expired id_token (and expect a failure). Then, let the background refresh // process get a new unexpired id_token, and then attempt a successful SSH // connection. // // The second SSH connection should succeed as the verifier on the container // should see an unexpired, valid refreshed_id_token in the PKT header. var err error // Spawn test containers to run these tests oidcContainer, authCallbackRedirectPort, serverContainer := spawnTestContainers(t) // Create OPK SSH provider that is configured against the spawned OIDC // container's issuer server zitadelOp, customTransport := createZitadelOPKSshProvider(oidcContainer.Port, authCallbackRedirectPort) // Control when this provider is permitted to call refresh logic with the // OP. We need fine control over refresh since this test modifies the OIDC // issuer server's id_token expiration time on the fly. We don't want // refresh to run until the expiration time has successfully been changed pulseZitadelOp := newPulseRefreshProvider(zitadelOp) // Create error channel to hold any errors that can occur during login or // the background refresh process errCh := make(chan error, 1) // If the test fails, check to see if there is an error on this channel as // it may give information on why the overall test has failed t.Cleanup(func() { // Drain errCh. Check to see if there is an important error select { case err := <-errCh: if errors.Is(err, context.Canceled) { return } require.NoError(t, err, "LoginWithRefresh process returned an unexpected error") default: // LoginWithRefresh returned no errors } }) // Call login with refresh enabled. Must spawn on goroutine because refresh // runs forever until context is cancelled or an error occurs. refreshCtx, cancelRefresh := context.WithCancel(TestCtx) defer cancelRefresh() t.Log("------- call login cmd ------") go func() { err := commands.LoginWithRefresh(refreshCtx, pulseZitadelOp) errCh <- err }() // Wait for login-callback server on localhost to come up. It should come up // when login command is called timeoutErr := WaitForServer(TestCtx, fmt.Sprintf("http://localhost:%d", authCallbackRedirectPort), LoginCallbackServerTimeout) require.NoError(t, timeoutErr, "login callback server took too long to startup") // Update idTokenLifetime to 10s. Can't make this too small otherwise code // exchange fails completely (i.e we need enough time to complete the whole // interactive OIDC login flow) updateIdTokenLifetime(t, oidcContainer.Port, "10s") // Do OIDC login. Use custom transport that adds the expected Host // header--if not specified, then the zitadel server will say it is an // unexpected issuer DoOidcInteractiveLogin(t, customTransport, fmt.Sprintf("http://localhost:%d/login", authCallbackRedirectPort), "test-user@oidc.local", "verysecure") // findOPKSshKeyTimeout is how long to wait for an OPK SSH key to be written // to disk const findOPKSshKeyTimeout = 5 * time.Second // Expect to find OPK SSH key is written to disk. // // Notice: Unlike the non-refresh SSH test, we must run this assertion many // times until we see what we want (or timeout); we can't immediately run // this check like before because there isn't a mechanism to know when login // process has finished and refresh background process has begun var pubKey ssh.PublicKey var secKeyFilePath string findKeyCtx, findKeyCancel := context.WithTimeout(TestCtx, findOPKSshKeyTimeout) defer findKeyCancel() t.Logf("Waiting for login process to write an OPK ssh key to disk...") err = TryFunc(findKeyCtx, func() error { pubKey, secKeyFilePath, err = GetOPKSshKey() return err }) require.NoError(t, err, "expected to find OPK ssh key written to disk") // Create OPK SSH signer using the found OPK SSH key on disk certSigner, sshCert := createOpkSshSigner(t, pubKey, secKeyFilePath) // Wait for id_token to expire (should not take longer than 10 seconds) pktCom, ok := sshCert.Extensions["openpubkey-pkt"] require.True(t, ok, "expected to find openpubkey-pkt extension") pkt, err := pktoken.NewFromCompact([]byte(pktCom)) require.NoError(t, err) var claims struct { Expiration int64 `json:"exp"` } err = json.Unmarshal(pkt.Payload, &claims) require.NoError(t, err) expTime := time.Unix(claims.Expiration, 0) untilExpired := time.Until(expTime) t.Logf("Waiting for id token to expire before making first OPK SSH connection: %v...", untilExpired) select { case <-time.After(untilExpired): t.Log("sshing...") case <-TestCtx.Done(): t.Fatal(TestCtx.Err()) } // Start new ssh connection using the OPK ssh cert key authKey := goph.Auth{ssh.PublicKeys(certSigner)} opkSshClient, err := goph.NewConn(&goph.Config{ User: serverContainer.User, Addr: serverContainer.Host, Port: uint(serverContainer.Port), Auth: authKey, Timeout: goph.DefaultTimeout, Callback: ssh.InsecureIgnoreHostKey(), }) require.Error(t, err, "OPK SSH connection should not be successful since id_token should be expired") // Reset idTokenLifetime to 1 hour, so that the refreshed id_token doesn't // expire before this test completes updateIdTokenLifetime(t, oidcContainer.Port, "1h") // Delete expired SSH key, so we can find a new OPK SSH key after running // refresh; otherwise, we might read the stale key before refresh finishes // // TODO-Yuval: Ideally, we should change Login() to take in custom SSH // directory path so we're not touching the host machine's SSH keys err = os.Remove(secKeyFilePath) require.NoError(t, err, "failed to remove OPK SSH private key") err = os.Remove(secKeyFilePath + ".pub") require.NoError(t, err, "failed to remove OPK SSH public key") // Let refresh go through pulseZitadelOp.Pulse() // Expect to find OPK SSH key is written to disk findRefreshedKeyCtx, findRefreshedKeyCancel := context.WithTimeout(TestCtx, findOPKSshKeyTimeout) defer findRefreshedKeyCancel() t.Logf("Waiting for refresh process to write an OPK ssh key to disk...") err = TryFunc(findRefreshedKeyCtx, func() error { pubKey, secKeyFilePath, err = GetOPKSshKey() return err }) require.NoError(t, err, "expected to find OPK ssh key written to disk after refresh") // Create OPK SSH signer using the refreshed OPK SSH key on disk certSigner, _ = createOpkSshSigner(t, pubKey, secKeyFilePath) // Start new ssh connection using the refreshed OPK ssh cert key authKey = goph.Auth{ssh.PublicKeys(certSigner)} opkSshClient, err = goph.NewConn(&goph.Config{ User: serverContainer.User, Addr: serverContainer.Host, Port: uint(serverContainer.Port), Auth: authKey, Timeout: goph.DefaultTimeout, Callback: ssh.InsecureIgnoreHostKey(), }) require.NoError(t, err, "expected to be able to SSH after refreshing id_token") defer opkSshClient.Close() // Run simple command to test the connection out, err := opkSshClient.Run("whoami") require.NoError(t, err) require.Equal(t, serverContainer.User, strings.TrimSpace(string(out))) }