├── .gitattributes ├── .github ├── dependabot.yml ├── release-drafter-config.yml └── workflows │ ├── build.yml │ ├── ci.yml │ ├── gha.yml │ ├── gha_opkssh.Dockerfile │ ├── go.yml │ ├── release-drafter.yml │ ├── release.yml │ ├── staging.yml │ └── weekly.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── CODE-OF-CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── commands ├── add.go ├── add_test.go ├── config │ ├── client_config.go │ ├── client_config_test.go │ ├── default-client-config.yml │ ├── providerconfig.go │ ├── providerconfig_test.go │ └── server_config.go ├── login.go ├── login_test.go ├── readhome.go ├── readhome_windows.go ├── verify.go └── verify_test.go ├── docs ├── config.md ├── gitlab-selfhosted.md ├── paramiko.md ├── policyplugins.md └── putty.md ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── hack ├── build.sh ├── integration-tests.sh └── unit-tests.sh ├── internal └── projectpath │ └── projectpath.go ├── main.go ├── main_test.go ├── policy ├── enforcer.go ├── enforcer_test.go ├── files │ ├── configlog.go │ ├── configlog_test.go │ ├── fileloader.go │ ├── permschecker.go │ ├── permschecker_test.go │ ├── table.go │ └── table_test.go ├── multipolicyloader.go ├── multipolicyloader_test.go ├── plugins │ ├── pluginconfig.go │ ├── plugins.go │ ├── plugins_test.go │ ├── tokens.go │ └── tokens_test.go ├── policy.go ├── policy_test.go ├── policyloader.go ├── policyloader_test.go ├── providerloader.go └── providerloader_test.go ├── scripts ├── install-linux.sh └── installing.md ├── sshcert ├── sshcert.go └── sshcert_test.go └── test └── integration ├── add_test.go ├── fakeop.go ├── integration.go ├── login_test.go ├── opkssh_test.go ├── policy-plugins ├── plugin-cmd.sh └── plugin-simple.yml ├── provider ├── exampleop.Dockerfile └── provider.go ├── ssh_server ├── arch_opkssh.Dockerfile ├── centos_opkssh.Dockerfile ├── debian_opkssh.Dockerfile ├── opensuse_opkssh.Dockerfile ├── ssh_server.go └── ubuntu.Dockerfile └── ssh_test.go /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | commit-message: 8 | prefix: 'fix(deps): ' 9 | groups: 10 | all: 11 | patterns: 12 | - '*' 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | commit-message: 18 | prefix: 'fix(deps): ' 19 | groups: 20 | all: 21 | patterns: 22 | - '*' 23 | -------------------------------------------------------------------------------- /.github/release-drafter-config.yml: -------------------------------------------------------------------------------- 1 | name-template: "v$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | categories: 4 | - title: "🚀 Features" 5 | labels: 6 | - "feat" 7 | - "feature" 8 | - "enhancement" 9 | - title: "🐛 Bug Fixes" 10 | labels: 11 | - "fix" 12 | - "bugfix" 13 | - "bug" 14 | - title: "🧰 Maintenance" 15 | labels: 16 | - "chore" 17 | 18 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 19 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. 20 | version-resolver: 21 | major: 22 | labels: 23 | - "major" 24 | minor: 25 | labels: 26 | - "minor" 27 | patch: 28 | labels: 29 | - "patch" 30 | default: minor 31 | template: | 32 | ## Changes 33 | 34 | $CHANGES 35 | 36 | autolabeler: 37 | - label: "chore" 38 | files: 39 | - "*.md" 40 | branch: 41 | - '/docs{0,1}\/.+/' 42 | - '/tests{0,1}\/.+/' 43 | title: 44 | - "/docs/i" 45 | - "/test/i" 46 | - label: "bug" 47 | branch: 48 | - '/fix\/.+/' 49 | - '/revert\/.+/' 50 | title: 51 | - "/fix/i" 52 | - "/revert/i" 53 | - label: "feature" 54 | branch: 55 | - '/feature\/.+/' 56 | - '/feat\/.+/' 57 | title: 58 | - "/feat/i" 59 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | goreleaser-build: 13 | name: Build opkssh with GoReleaser 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 18 | with: 19 | fetch-depth: 0 20 | persist-credentials: false 21 | - name: Set up Go 22 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 23 | with: 24 | go-version-file: 'go.mod' 25 | cache: false 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6 28 | with: 29 | distribution: goreleaser 30 | version: "~> v2" 31 | args: release --clean --snapshot 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Runs CI for pull requests and pushes to main 4 | on: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | # schedule: 10 | # - cron: 0 14 * * MON-FRI # Every weekday at 14:00 UTC 11 | 12 | permissions: {} 13 | 14 | jobs: 15 | # Check that binary can be built 16 | build: 17 | name: Build 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 5 20 | strategy: 21 | matrix: 22 | go-version: [1.23.x] 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 26 | with: 27 | persist-credentials: false 28 | - name: Install Go 29 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 30 | with: 31 | go-version: ${{ matrix.go-version }} 32 | - name: Install dependencies 33 | run: go mod download 34 | - name: Build 35 | run: go build -v -o /dev/null 36 | nix-build: 37 | name: Nix Build 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 42 | with: 43 | persist-credentials: false 44 | - name: Check Nix flake inputs 45 | uses: DeterminateSystems/flake-checker-action@0af51e37404acfb298f7e2eec77470be27eb57c5 # v10 46 | - name: Install Nix 47 | uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v17 48 | - name: Build 49 | run: nix build . 50 | # Run integration tests 51 | test: 52 | needs: build 53 | name: 'Integration Tests' 54 | runs-on: ${{ matrix.runs_on }} 55 | timeout-minutes: 8 56 | strategy: 57 | matrix: 58 | runs_on: [ubuntu-latest, ubuntu-24.04-arm] 59 | os: [ubuntu, centos, arch, opensuse] 60 | exclude: 61 | - runs_on: ubuntu-24.04-arm 62 | os: arch 63 | env: 64 | OS_TYPE: ${{ matrix.os }} 65 | steps: 66 | - name: Checkout 67 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 68 | with: 69 | persist-credentials: false 70 | - name: Install Go 71 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 72 | with: 73 | go-version-file: 'go.mod' 74 | - name: Install Docker 75 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 76 | - name: Install dependencies 77 | run: go mod download 78 | - name: Run integration tests 79 | run: go test -tags=integration ./test/integration -timeout=15m -count=1 -parallel=2 -v 80 | -------------------------------------------------------------------------------- /.github/workflows/gha.yml: -------------------------------------------------------------------------------- 1 | name: Test GitHub Provider 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | permissions: 15 | id-token: write 16 | contents: read 17 | timeout-minutes: 5 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 21 | with: 22 | persist-credentials: false 23 | - name: Install Go 24 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 25 | with: 26 | go-version-file: 'go.mod' 27 | - name: Install dependencies 28 | run: go mod download 29 | - name: Build 30 | run: go build -v -o /dev/null 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 33 | - name: Build and export to Docker 34 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 35 | with: 36 | build-args: | 37 | AUTHORIZED_REPOSITORY=${{ github.repository }} 38 | AUTHORIZED_REF=${{ github.ref }} 39 | load: true 40 | tags: sshserver:latest 41 | file: .github/workflows/gha_opkssh.Dockerfile 42 | cache-from: type=gha 43 | cache-to: type=gha,mode=max 44 | 45 | - name: Run SSH Container 46 | run: docker run -d -p 2222:22 sshserver:latest 47 | 48 | - name: Login 49 | run: go run main.go login github --print-id-token 50 | 51 | - name: SSH into Container with opkssh 52 | run: | 53 | ssh -o StrictHostKeyChecking=no -p 2222 test@localhost ls -la 54 | 55 | - name: Debug - dump opkssh config 56 | run: | 57 | sshpass -p test ssh -o StrictHostKeyChecking=no -p 2222 test@localhost ls -la /etc/opk || true 58 | sshpass -p test ssh -o StrictHostKeyChecking=no -p 2222 test@localhost sudo cat /etc/opk/providers || true 59 | sshpass -p test ssh -o StrictHostKeyChecking=no -p 2222 test@localhost sudo cat /etc/opk/auth_id || true 60 | if: always() 61 | 62 | - name: Debug - dump logs 63 | run: | 64 | sshpass -p test ssh -o StrictHostKeyChecking=no -p 2222 test@localhost sudo cat /var/log/opkssh.log 65 | if: always() 66 | -------------------------------------------------------------------------------- /.github/workflows/gha_opkssh.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 2 | 3 | # Update/Upgrade 4 | RUN apt-get update -y && apt-get upgrade -y 5 | 6 | # Install dependencies, such as the SSH server 7 | RUN apt-get install -y sudo openssh-server telnet jq 8 | 9 | # Source: 10 | # https://medium.com/@ratnesh4209211786/simplified-ssh-server-setup-within-a-docker-container-77eedd87a320 11 | # 12 | # Create an SSH user named "test". Make it a sudoer 13 | RUN useradd -rm -d /home/test -s /bin/bash -g root -G sudo -u 1000 test 14 | # Set password to "test" 15 | RUN echo "test:test" | chpasswd 16 | 17 | # Make it so "test" user does not need to present password when using sudo 18 | # Source: https://askubuntu.com/a/878705 19 | RUN echo "test ALL=(ALL:ALL) NOPASSWD: ALL" | tee /etc/sudoers.d/test 20 | 21 | # Allow SSH access 22 | RUN mkdir /var/run/sshd 23 | 24 | # Expose SSH server so we can ssh in from the tests 25 | EXPOSE 22 26 | 27 | # Set destination for COPY 28 | WORKDIR /app 29 | 30 | # Download Go modules 31 | COPY go.mod go.sum ./ 32 | RUN go mod download 33 | 34 | # Copy our repo 35 | COPY . ./ 36 | 37 | # Build "opkssh" binary and write to the opk directory 38 | RUN go build -v -o opksshbuild 39 | RUN chmod +x ./scripts/install-linux.sh 40 | RUN bash ./scripts/install-linux.sh --install-from=opksshbuild --no-sshd-restart 41 | 42 | # Authorize GitHub provider for SSH logins 43 | RUN echo "https://token.actions.githubusercontent.com github oidc" >> /etc/opk/providers 44 | 45 | # Add integration test user as allowed email in policy (this directly tests 46 | # policy "add" command) 47 | ARG AUTHORIZED_REPOSITORY 48 | ARG AUTHORIZED_REF 49 | RUN opkssh add "test" "repo:${AUTHORIZED_REPOSITORY}:ref:${AUTHORIZED_REF}" "https://token.actions.githubusercontent.com" 50 | 51 | # Start SSH server on container startup 52 | CMD ["/usr/sbin/sshd", "-D"] 53 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go Checks 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "**.go" 7 | - "go.mod" 8 | - "go.sum" 9 | 10 | permissions: {} 11 | 12 | jobs: 13 | golangci-linter: 14 | name: Run golangci linter 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 18 | with: 19 | persist-credentials: false 20 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 21 | with: 22 | go-version-file: 'go.mod' 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v6 25 | with: 26 | version: v1.64.7 27 | 28 | gotest: 29 | name: Run Tests 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout code 33 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 34 | with: 35 | persist-credentials: false 36 | 37 | - name: Set up Go 38 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 39 | with: 40 | go-version-file: 'go.mod' 41 | 42 | - name: Download dependencies 43 | run: go mod download 44 | 45 | - name: Test 46 | run: go test ./... 47 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, reopened, synchronize] 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | update_release_draft: 14 | permissions: 15 | contents: write 16 | pull-requests: write 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # v6 20 | with: 21 | config-name: release-drafter-config.yml 22 | publish: false 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: {} 8 | 9 | jobs: 10 | goreleaser-release: 11 | name: Build and release opkssh with GoReleaser 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | # id-token: write # Enable for cosign: https://github.com/sigstore/cosign 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 19 | with: 20 | fetch-depth: 0 21 | persist-credentials: false 22 | - name: Set up Go 23 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 24 | with: 25 | go-version-file: 'go.mod' 26 | cache: false 27 | - name: Run GoReleaser 28 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6 29 | with: 30 | distribution: goreleaser 31 | version: "~> v2" 32 | args: release --clean 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/staging.yml: -------------------------------------------------------------------------------- 1 | name: Go Checks 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | 7 | permissions: {} 8 | 9 | jobs: 10 | 11 | codecov: 12 | name: Push to main test 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | pages: write 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 20 | with: 21 | persist-credentials: false 22 | 23 | - name: Set up Go 24 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 25 | with: 26 | go-version-file: 'go.mod' 27 | 28 | - name: Test 29 | run: go test ./... 30 | 31 | - name: Update coverage report 32 | uses: ncruces/go-coverage-report@494b2847891f4dd3b10f6704ca533367dbb7493d # v0 33 | with: 34 | report: true 35 | chart: true 36 | amend: true 37 | if: github.event_name == 'push' 38 | continue-on-error: true 39 | -------------------------------------------------------------------------------- /.github/workflows/weekly.yml: -------------------------------------------------------------------------------- 1 | name: Update flake.lock 2 | 3 | on: 4 | workflow_dispatch: # allows manual triggering 5 | schedule: 6 | - cron: '0 0 * * 0' # runs weekly on Sunday at 00:00 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | lockfile: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 19 | with: 20 | persist-credentials: false 21 | - name: Install Nix 22 | uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v17 23 | - name: Update flake.lock 24 | uses: DeterminateSystems/update-flake-lock@428c2b58a4b7414dabd372acb6a03dba1084d3ab # v25 25 | with: 26 | pr-title: "Update flake.lock" 27 | pr-labels: | 28 | dependencies 29 | automated 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | configs/ 24 | .vscode/ 25 | 26 | # For policy files that exist while testing 27 | auth_id 28 | opk-ssh 29 | opk-ssh-login 30 | *DS_Store 31 | 32 | # Build folder 33 | dist/ 34 | 35 | # Created by integration tests 36 | test/integration/testfile.txt 37 | 38 | # Go build cache 39 | .cache 40 | .mod-cache 41 | 42 | # Direnv files, because these should be opt-in by the developer. 43 | .direnv 44 | .envrc 45 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | # golangci-lint defaults 4 | # ref: https://golangci-lint.run/usage/linters/#enabled-by-default 5 | - errcheck 6 | - gosimple 7 | - govet 8 | - ineffassign 9 | - staticcheck 10 | - unused 11 | # additional 12 | - misspell 13 | - gofmt 14 | fast: true 15 | run: 16 | timeout: 5m -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # The documentation of Goreleaser is available here at https://goreleaser.com 2 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 3 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 4 | 5 | version: 2 6 | 7 | before: 8 | hooks: 9 | - go mod tidy 10 | 11 | builds: 12 | - env: 13 | - CGO_ENABLED=0 14 | ldflags: 15 | - -s -w -X main.Version={{.Version}} 16 | goos: 17 | - linux 18 | - windows 19 | - darwin 20 | goarch: 21 | - amd64 22 | - arm64 23 | # Ignore some builds until they are not tested 24 | ignore: 25 | - goos: windows 26 | goarch: arm64 27 | 28 | # Make binaries available with the same naming format as before 29 | archives: 30 | - formats: binary 31 | name_template: >- 32 | {{ .ProjectName }}- 33 | {{- if eq .Os "darwin" }}osx 34 | {{- else }}{{ .Os }}{{ end }}- 35 | {{- .Arch }} 36 | 37 | # Create distribution packages 38 | nfpms: 39 | - package_name: opkssh 40 | description: |- 41 | Enable ssh to be used with OpenID Connect allowing SSH access management 42 | via identities like alice@example.com instead of long-lived SSH keys. 43 | vendor: OpenPubKey 44 | maintainer: Ethan Heilman 45 | license: Apache 2.0 46 | homepage: https://github.com/openpubkey/opkssh 47 | formats: 48 | - apk # Alpine 49 | - deb # Debian 50 | - rpm # RHEL based 51 | - archlinux # Archlinux 52 | # TODO: Add debian overrides and match with https://salsa.debian.org/go-team/packages/opkssh 53 | 54 | # Create checksums file 55 | checksum: 56 | name_template: 'checksums.txt' 57 | 58 | # Define how the changelog is generated 59 | changelog: 60 | sort: asc 61 | filters: 62 | exclude: 63 | - "^docs:" 64 | - "^test:" 65 | groups: 66 | # TOFIX: update regex to match `release-drafter-config.yml` 67 | - title: 🚀 Features 68 | regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' 69 | order: 0 70 | - title: "🐛 Bug Fixes" 71 | regexp: '^.*?bug(\([[:word:]]+\))??!?:.+$' 72 | order: 1 73 | - title: 🧰 Maintenance 74 | order: 999 75 | 76 | # Define how to make GitHub releases 77 | release: 78 | draft: true 79 | make_latest: true 80 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | # OpenPubkey Code of Conduct 2 | 3 | Please see our [OpenPubkey Community Code of Conduct](https://github.com/openpubkey/community/blob/main/CODE-OF-CONDUCT.md). -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | **Please do not file a public ticket** mentioning the vulnerability or issue. 6 | 7 | To privately report security issues or vulnerabilities send your report to security@bastionzero.com (not for support). A report should include: 8 | 9 | - a summary of the issue, 10 | - the steps needed to reproduce the issue, 11 | - the potential security impact and, if found, any proposed mitigation. 12 | 13 | We do not currently offer bug bounties. -------------------------------------------------------------------------------- /commands/add.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package commands 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | "os" 23 | 24 | "github.com/openpubkey/opkssh/policy" 25 | ) 26 | 27 | // AddCmd provides functionality to read and update the opkssh policy file 28 | type AddCmd struct { 29 | HomePolicyLoader *policy.HomePolicyLoader 30 | SystemPolicyLoader *policy.SystemPolicyLoader 31 | 32 | // Username is the username to lookup when the system policy file cannot be 33 | // read and we fallback to the user's policy file. 34 | // 35 | // See AddCmd.LoadPolicy for more details. 36 | Username string 37 | } 38 | 39 | // LoadPolicy reads the opkssh policy at the policy.SystemDefaultPolicyPath. If 40 | // there is a permission error when reading this file, then the user's local 41 | // policy file (defined as ~/.opk/auth_id where ~ maps to AddCmd.Username's 42 | // home directory) is read instead. 43 | // 44 | // If successful, returns the parsed policy and filepath used to read the 45 | // policy. Otherwise, a non-nil error is returned. 46 | func (a *AddCmd) LoadPolicy() (*policy.Policy, string, error) { 47 | // Try to read system policy first 48 | systemPolicy, _, err := a.SystemPolicyLoader.LoadSystemPolicy() 49 | if err != nil { 50 | if errors.Is(err, os.ErrPermission) { 51 | // If current process doesn't have permission, try reading the user 52 | // policy file. 53 | userPolicy, policyFilePath, err := a.HomePolicyLoader.LoadHomePolicy(a.Username, false) 54 | if err != nil { 55 | return nil, "", err 56 | } 57 | return userPolicy, policyFilePath, nil 58 | } else { 59 | // Non-permission error (e.g. system policy file missing or invalid 60 | // permission bits set). Return error 61 | return nil, "", err 62 | } 63 | } 64 | 65 | return systemPolicy, policy.SystemDefaultPolicyPath, nil 66 | } 67 | 68 | // GetPolicyPath returns the path to the policy file that the current command 69 | // will write to and a boolean to flag the path is for home policy. 70 | // True means home policy, false means system policy. 71 | func (a *AddCmd) GetPolicyPath(principal string, userEmail string, issuer string) (string, bool, error) { 72 | // Try to read system policy first 73 | _, _, err := a.SystemPolicyLoader.LoadSystemPolicy() 74 | if err != nil { 75 | if errors.Is(err, os.ErrPermission) { 76 | // If current process doesn't have permission, try reading the user 77 | // policy file. 78 | policyFilePath, err := a.HomePolicyLoader.UserPolicyPath(a.Username) 79 | if err != nil { 80 | return "", false, err 81 | } 82 | return policyFilePath, false, nil 83 | } else { 84 | // Non-permission error (e.g. system policy file missing or invalid 85 | // permission bits set). Return error 86 | return "", false, err 87 | } 88 | } 89 | return policy.SystemDefaultPolicyPath, true, nil 90 | } 91 | 92 | // Run adds a new allowed principal to the user whose email is equal to 93 | // userEmail. The policy file is read and modified. 94 | // 95 | // If successful, returns the policy filepath updated. Otherwise, returns a 96 | // non-nil error 97 | func (a *AddCmd) Run(principal string, userEmail string, issuer string) (string, error) { 98 | policyPath, useSystemPolicy, err := a.GetPolicyPath(principal, userEmail, issuer) 99 | if err != nil { 100 | return "", fmt.Errorf("failed to load policy: %w", err) 101 | } 102 | 103 | var policyLoader *policy.PolicyLoader 104 | if useSystemPolicy { 105 | policyLoader = a.SystemPolicyLoader.PolicyLoader 106 | } else { 107 | policyLoader = a.HomePolicyLoader.PolicyLoader 108 | } 109 | 110 | err = policyLoader.CreateIfDoesNotExist(policyPath) 111 | if err != nil { 112 | return "", fmt.Errorf("failed to create policy file: %w", err) 113 | } 114 | 115 | // Read current policy 116 | currentPolicy, policyFilePath, err := a.LoadPolicy() 117 | if err != nil { 118 | return "", fmt.Errorf("failed to load current policy: %w", err) 119 | } 120 | 121 | // Update policy 122 | currentPolicy.AddAllowedPrincipal(principal, userEmail, issuer) 123 | 124 | // Dump contents back to disk 125 | err = policyLoader.Dump(currentPolicy, policyFilePath) 126 | if err != nil { 127 | return "", fmt.Errorf("failed to write updated policy: %w", err) 128 | } 129 | 130 | return policyFilePath, nil 131 | } 132 | -------------------------------------------------------------------------------- /commands/add_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package commands 18 | 19 | import ( 20 | "os/user" 21 | "testing" 22 | 23 | "github.com/openpubkey/opkssh/policy" 24 | "github.com/openpubkey/opkssh/policy/files" 25 | "github.com/spf13/afero" 26 | "github.com/stretchr/testify/require" 27 | ) 28 | 29 | // Duplicates code from multipolicyloader_test.go 30 | type MockUserLookup struct { 31 | // User is returned on any call to Lookup() if Error is nil 32 | User *user.User 33 | // Error is returned on any call to Lookup() if non-nil 34 | Error error 35 | } 36 | 37 | // Lookup implements policy.UserLookup 38 | func (m *MockUserLookup) Lookup(username string) (*user.User, error) { 39 | if m.Error == nil { 40 | return m.User, nil 41 | } else { 42 | return nil, m.Error 43 | } 44 | } 45 | 46 | var ValidUser *user.User = &user.User{HomeDir: "/home/foo", Username: "foo"} 47 | 48 | func MockAddCmd(mockFs afero.Fs) *AddCmd { 49 | mockUserLookup := &MockUserLookup{User: ValidUser} 50 | 51 | mockHomePolicyLoader := &policy.HomePolicyLoader{ 52 | PolicyLoader: &policy.PolicyLoader{ 53 | FileLoader: files.FileLoader{ 54 | Fs: mockFs, 55 | RequiredPerm: files.ModeHomePerms, 56 | }, 57 | UserLookup: mockUserLookup, 58 | }, 59 | } 60 | 61 | mockSystemPolicyLoader := &policy.SystemPolicyLoader{ 62 | PolicyLoader: &policy.PolicyLoader{ 63 | FileLoader: files.FileLoader{ 64 | Fs: mockFs, 65 | RequiredPerm: files.ModeSystemPerms, 66 | }, 67 | UserLookup: mockUserLookup, 68 | }, 69 | } 70 | 71 | return &AddCmd{ 72 | HomePolicyLoader: mockHomePolicyLoader, 73 | SystemPolicyLoader: mockSystemPolicyLoader, 74 | Username: ValidUser.Username, 75 | } 76 | 77 | } 78 | 79 | func TestAddErrors(t *testing.T) { 80 | principal := "foo" 81 | userEmail := "alice@example.com" 82 | issuer := "gitlab" 83 | 84 | // Test when the system policy file does not exist 85 | mockEmptyFs := afero.NewMemMapFs() 86 | addCmd := MockAddCmd(mockEmptyFs) 87 | policyPath, err := addCmd.Run(principal, userEmail, issuer) 88 | require.ErrorContains(t, err, "file does not exist") 89 | require.Empty(t, policyPath) 90 | 91 | // Create system policy file 92 | mockFs := afero.NewMemMapFs() 93 | _, err = mockFs.Create(policy.SystemDefaultPolicyPath) 94 | 95 | require.NoError(t, err) 96 | addCmd = MockAddCmd(mockFs) 97 | 98 | policyPath, err = addCmd.Run(principal, userEmail, issuer) 99 | require.ErrorContains(t, err, "file has insecure permissions: expected one of the following permissions [640], got (0)") 100 | require.Empty(t, policyPath) 101 | 102 | err = mockFs.Chmod(policy.SystemDefaultPolicyPath, 0640) 103 | require.NoError(t, err) 104 | 105 | addCmd = MockAddCmd(mockFs) 106 | policyPath, err = addCmd.Run(principal, userEmail, issuer) 107 | require.NoError(t, err) 108 | require.Equal(t, policy.SystemDefaultPolicyPath, policyPath) 109 | 110 | systemPolicyFile, err := mockFs.Open(policyPath) 111 | require.NoError(t, err) 112 | policyContent, err := afero.ReadAll(systemPolicyFile) 113 | require.NoError(t, err) 114 | expectedPolicyContent := principal + " " + userEmail + " " + issuer + "\n" 115 | require.Equal(t, expectedPolicyContent, string(policyContent)) 116 | } 117 | -------------------------------------------------------------------------------- /commands/config/client_config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package config 18 | 19 | import ( 20 | _ "embed" 21 | 22 | "gopkg.in/yaml.v3" 23 | ) 24 | 25 | //go:embed default-client-config.yml 26 | var DefaultClientConfig []byte 27 | 28 | type ClientConfig struct { 29 | DefaultProvider string `yaml:"default_provider"` 30 | Providers []ProviderConfig `yaml:"providers"` 31 | } 32 | 33 | func NewClientConfig(c []byte) (*ClientConfig, error) { 34 | var clientConfig ClientConfig 35 | if err := yaml.Unmarshal(c, &clientConfig); err != nil { 36 | return nil, err 37 | } 38 | 39 | return &clientConfig, nil 40 | } 41 | 42 | func (c *ClientConfig) GetProvidersMap() (map[string]ProviderConfig, error) { 43 | return CreateProvidersMap(c.Providers) 44 | } 45 | 46 | // GetByIssuer looks up an OpenID Provider by its issuer URL. If there are 47 | // multiple providers with the same issuer, it returns the first one found. 48 | func (c *ClientConfig) GetByIssuer(issuer string) (*ProviderConfig, bool) { 49 | for _, provider := range c.Providers { 50 | if provider.Issuer == issuer { 51 | return &provider, true 52 | } 53 | } 54 | return nil, false 55 | } 56 | -------------------------------------------------------------------------------- /commands/config/client_config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package config 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/stretchr/testify/require" 23 | ) 24 | 25 | func TestParseConfig(t *testing.T) { 26 | clientConfigDefault, err := NewClientConfig(DefaultClientConfig) 27 | require.NoError(t, err) 28 | require.NotNil(t, clientConfigDefault) 29 | require.Equal(t, clientConfigDefault.DefaultProvider, "webchooser") 30 | require.Equal(t, 4, len(clientConfigDefault.Providers)) 31 | 32 | providerMap, err := clientConfigDefault.GetProvidersMap() 33 | require.NoError(t, err) 34 | // This is 5 rather than 4 because one of the providers has 2 aliases 35 | require.Equal(t, 5, len(providerMap)) 36 | 37 | for _, provider := range clientConfigDefault.Providers { 38 | require.NotEmpty(t, provider.Issuer, "Provider issuer should not be empty") 39 | require.False(t, provider.SendAccessToken, "SendAccessToken should be false by default") 40 | } 41 | 42 | provider, found := clientConfigDefault.GetByIssuer("https://accounts.google.com") 43 | require.NotEmpty(t, provider, "Provider should found since it exists in the config") 44 | require.True(t, found) 45 | 46 | provider, found = clientConfigDefault.GetByIssuer("https://not-a-real-provider.example.com") 47 | require.Nil(t, provider, "Provider should not found since it does not exist in the config") 48 | require.False(t, found) 49 | 50 | // Test failure 51 | clientConfigDefault, err = NewClientConfig([]byte("invalid yaml")) 52 | require.ErrorContains(t, err, "yaml: unmarshal errors") 53 | require.Nil(t, clientConfigDefault) 54 | } 55 | 56 | func TestParseConfigWithSendAccessToken(t *testing.T) { 57 | c := `--- 58 | default_provider: webchooser 59 | 60 | providers: 61 | - alias: google 62 | issuer: https://accounts.google.com 63 | client_id: 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 64 | client_secret: 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 65 | scopes: openid email profile 66 | access_type: offline 67 | send_access_token: true 68 | prompt: consent 69 | redirect_uris: 70 | - http://localhost:3000/login-callback 71 | - http://localhost:10001/login-callback 72 | - http://localhost:11110/login-callback` 73 | 74 | clientConfig, err := NewClientConfig([]byte(c)) 75 | require.NoError(t, err) 76 | require.NotNil(t, clientConfig) 77 | require.Equal(t, clientConfig.Providers[0].SendAccessToken, true) 78 | } 79 | -------------------------------------------------------------------------------- /commands/config/default-client-config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | default_provider: webchooser 3 | 4 | providers: 5 | - alias: google 6 | issuer: https://accounts.google.com 7 | client_id: 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 8 | client_secret: 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 9 | scopes: openid email profile 10 | access_type: offline 11 | prompt: consent 12 | redirect_uris: 13 | - http://localhost:3000/login-callback 14 | - http://localhost:10001/login-callback 15 | - http://localhost:11110/login-callback 16 | 17 | - alias: azure microsoft 18 | issuer: https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 19 | client_id: 096ce0a3-5e72-4da8-9c86-12924b294a01 20 | scopes: openid profile email offline_access 21 | access_type: offline 22 | prompt: consent 23 | redirect_uris: 24 | - http://localhost:3000/login-callback 25 | - http://localhost:10001/login-callback 26 | - http://localhost:11110/login-callback 27 | 28 | - alias: gitlab 29 | issuer: https://gitlab.com 30 | client_id: 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 31 | scopes: openid email 32 | access_type: offline 33 | prompt: consent 34 | redirect_uris: 35 | - http://localhost:3000/login-callback 36 | - http://localhost:10001/login-callback 37 | - http://localhost:11110/login-callback 38 | 39 | - alias: hello 40 | issuer: https://issuer.hello.coop 41 | client_id: app_xejobTKEsDNSRd5vofKB2iay_2rN 42 | scopes: openid email 43 | access_type: offline 44 | prompt: consent 45 | redirect_uris: 46 | - http://localhost:3000/login-callback 47 | - http://localhost:10001/login-callback 48 | - http://localhost:11110/login-callback 49 | -------------------------------------------------------------------------------- /commands/config/providerconfig_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package config 18 | 19 | import ( 20 | "os" 21 | "testing" 22 | 23 | "github.com/stretchr/testify/require" 24 | ) 25 | 26 | func TestProvidersConfigFromStrings(t *testing.T) { 27 | providersString := "google,https://accounts.google.com,206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com,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;" + 28 | "microsoft,https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0,096ce0a3-5e72-4da8-9c86-12924b294a01;" + 29 | "gitlab,https://gitlab.com,8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923;" + 30 | "hello,https://issuer.hello.coop,app_xejobTKEsDNSRd5vofKB2iay_2rN" 31 | 32 | providerConfigs, err := ProvidersConfigListFromStrings(providersString) 33 | require.NoError(t, err) 34 | require.NotNil(t, providerConfigs) 35 | require.Equal(t, len(providerConfigs), 4) 36 | 37 | providersStringRepeatsAlias := "google,https://accounts.google.com,1234,4566;" + 38 | "fakeOP1,https://fake1.example.com,abcde,,openid email;" + 39 | "fakeOP2,https://fake2.example.com,abcde,,openid email;" + 40 | "fakeOP1,https://fake3.example.com,xyz" 41 | 42 | providerConfigs, err = ProvidersConfigListFromStrings(providersStringRepeatsAlias) 43 | require.NoError(t, err) 44 | providerMap, err := CreateProvidersMap(providerConfigs) 45 | require.ErrorContains(t, err, "duplicate provider alias found: fakeOP1") 46 | require.Nil(t, providerMap) 47 | 48 | providersStringNoClientID := "fakeOP1,https://fake1.example.com,,," 49 | providerConfigs, err = ProvidersConfigListFromStrings(providersStringNoClientID) 50 | require.ErrorContains(t, err, "invalid provider client-ID value got ()") 51 | require.Nil(t, providerConfigs) 52 | 53 | providersStringInvalidFormat := "fakeOP1,https://fake1.example.com" 54 | providerConfigs, err = ProvidersConfigListFromStrings(providersStringInvalidFormat) 55 | require.ErrorContains(t, err, "invalid provider config string") 56 | require.Nil(t, providerConfigs) 57 | } 58 | 59 | func TestProvidersConfigFromEnv(t *testing.T) { 60 | 61 | tests := []struct { 62 | name string 63 | envVar string 64 | envValue string 65 | expectedLen int 66 | wantOutput string 67 | wantError string 68 | }{ 69 | { 70 | name: "Set OPKSSH_PROVIDERS to good value", 71 | envVar: "OPKSSH_PROVIDERS", 72 | envValue: "google,https://accounts.google.com,1234,4566;" + 73 | "fakeOP1,https://fake1.example.com,abcde,,openid email", 74 | expectedLen: 2, 75 | wantError: "", 76 | }, 77 | { 78 | name: "Set OPKSSH_PROVIDERS to emptye", 79 | envVar: "OPKSSH_PROVIDERS", 80 | envValue: "", 81 | wantError: "", 82 | }, 83 | } 84 | 85 | for _, tt := range tests { 86 | t.Run(tt.name, func(t *testing.T) { 87 | _ = os.Setenv(tt.envVar, tt.envValue) 88 | defer func(key string) { 89 | _ = os.Unsetenv(key) 90 | }(tt.envVar) 91 | 92 | providerConfigs, err := GetProvidersConfigFromEnv() 93 | if tt.wantError != "" { 94 | require.ErrorContains(t, err, tt.wantError) 95 | require.Nil(t, providerConfigs) 96 | } else { 97 | require.NoError(t, err) 98 | if tt.expectedLen > 0 { 99 | require.Equal(t, tt.expectedLen, len(providerConfigs)) 100 | } else { 101 | // If no providers, this this should be nil 102 | require.Nil(t, providerConfigs) 103 | } 104 | } 105 | }) 106 | } 107 | } 108 | 109 | func TestProviderConfigFromString(t *testing.T) { 110 | providerAlias := "op1" 111 | providerIssuer := "https://example.com/tokens-1/" 112 | providerScopes := "openid profile email" 113 | providerArg := providerIssuer + ",client-id1234," + "," + "" + "," + providerScopes 114 | providerStr := providerAlias + "," + providerArg 115 | 116 | tests := []struct { 117 | name string 118 | configString string 119 | hasAlias bool 120 | expectedIssuer string 121 | wantError1 bool 122 | errorString1 string 123 | wantError2 bool 124 | errorString2 string 125 | }{ 126 | { 127 | name: "Good path with test providerStr", 128 | configString: providerStr, 129 | hasAlias: true, 130 | expectedIssuer: providerIssuer, 131 | }, 132 | { 133 | name: "Good path with test authentik OP", 134 | configString: "authentik,https://authentik.io/application/o/opkssh/,client_id,,openid profile email", 135 | hasAlias: true, 136 | expectedIssuer: "https://authentik.io/application/o/opkssh/", 137 | }, 138 | { 139 | name: "Good path with test Google OP", 140 | configString: "https://accounts.google.com,206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com,NOT-aREAL_3a_GOOGLE-CLIENTSECRET", 141 | hasAlias: false, 142 | expectedIssuer: "https://accounts.google.com", 143 | }, 144 | { 145 | name: "Good path with test microsoft OP", 146 | configString: "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0,096ce0a3-5e72-4da8-9c86-12924b294a01", 147 | hasAlias: false, 148 | expectedIssuer: "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0", 149 | }, 150 | { 151 | name: "Good path with test microsoft OP", 152 | configString: "https://gitlab.com,8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923", 153 | hasAlias: false, 154 | expectedIssuer: "https://gitlab.com", 155 | }, 156 | { 157 | name: "Good path with test hello OP", 158 | configString: "https://issuer.hello.coop,client-id,,openid email", 159 | hasAlias: false, 160 | expectedIssuer: "https://issuer.hello.coop", 161 | }, 162 | { 163 | name: "Alias set but no alias expected", 164 | configString: "exampleOp,https://token.example.com/,client_id,,openid profile email,", 165 | hasAlias: false, 166 | expectedIssuer: "https://token.example.com/", 167 | wantError2: true, 168 | errorString2: "invalid provider issuer value. Expected issuer to start with 'https://'", 169 | }, 170 | { 171 | name: "No alias set but alias expected", 172 | configString: "https://token.example.com/,client_id,,openid profile email,", 173 | hasAlias: true, 174 | expectedIssuer: "https://token.example.com/", 175 | wantError1: true, 176 | errorString1: "invalid provider client-ID value got ()", 177 | }, 178 | } 179 | 180 | for _, tt := range tests { 181 | t.Run(tt.name, func(t *testing.T) { 182 | providerConfig, err := NewProviderConfigFromString(tt.configString, tt.hasAlias) 183 | if tt.wantError1 { 184 | require.Error(t, err, "Expected error but got none") 185 | if tt.errorString1 != "" { 186 | require.ErrorContains(t, err, tt.errorString1, "Got a wrong error message") 187 | } 188 | 189 | } else { 190 | require.NoError(t, err) 191 | provider, err := providerConfig.ToProvider(false) 192 | if tt.wantError2 { 193 | require.Error(t, err, "Expected error but got none") 194 | if tt.errorString2 != "" { 195 | require.ErrorContains(t, err, tt.errorString2, "Got a wrong error message") 196 | } 197 | } else { 198 | require.NoError(t, err) 199 | require.Equal(t, tt.expectedIssuer, provider.Issuer()) 200 | } 201 | } 202 | }) 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /commands/config/server_config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package config 18 | 19 | import ( 20 | "os" 21 | 22 | "gopkg.in/yaml.v3" 23 | ) 24 | 25 | type ServerConfig struct { 26 | EnvVars map[string]string `yaml:"env_vars"` 27 | } 28 | 29 | func NewServerConfig(c []byte) (*ServerConfig, error) { 30 | var serverConfig ServerConfig 31 | if err := yaml.Unmarshal(c, &serverConfig); err != nil { 32 | return nil, err 33 | } 34 | 35 | return &serverConfig, nil 36 | } 37 | 38 | func (c *ServerConfig) SetEnvVars() error { 39 | for k, v := range c.EnvVars { 40 | if err := os.Setenv(k, v); err != nil { 41 | return err 42 | } 43 | } 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /commands/readhome.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | //go:build linux || darwin 18 | 19 | package commands 20 | 21 | import ( 22 | "errors" 23 | "fmt" 24 | "os" 25 | "os/user" 26 | "path/filepath" 27 | "regexp" 28 | "strconv" 29 | "syscall" 30 | 31 | "github.com/openpubkey/opkssh/policy/files" 32 | ) 33 | 34 | // ReadHome is used to read the home policy file for the user with 35 | // the specified username. This is used when opkssh is called by 36 | // AuthorizedKeysCommand as the opksshuser and needs to use sudoer 37 | // access to read the home policy file (`/home//opk/auth_id`). 38 | // This function is only available on Linux and Darwin because it relies on 39 | // syscall.Stat_t to determine the owner of the file. 40 | func ReadHome(username string) ([]byte, error) { 41 | if matched, _ := regexp.MatchString("^[a-z0-9_\\-.]+$", username); !matched { 42 | return nil, fmt.Errorf("%s is not a valid linux username", username) 43 | } 44 | 45 | userObj, err := user.Lookup(username) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to find user %s", username) 48 | } 49 | homePolicyPath := filepath.Join(userObj.HomeDir, ".opk", "auth_id") 50 | 51 | // Security critical: We reading this file as `sudo -u opksshuser` 52 | // and opksshuser has elevated permissions to read any file whose 53 | // path matches `/home/*/opk/auth_id`. We need to be cautious we do follow 54 | // a symlink as it could be to a file the user is not permitted to read. 55 | // This would not permit the user to read the file, but they might be able 56 | // to determine the existence of the file. We use O_NOFOLLOW to prevent 57 | // following symlinks. 58 | file, err := os.OpenFile(homePolicyPath, os.O_RDONLY|syscall.O_NOFOLLOW, 0) 59 | if err != nil { 60 | if errors.Is(err, syscall.ELOOP) { 61 | return nil, fmt.Errorf("home policy file %s is a symlink, symlink are unsafe in this context", homePolicyPath) 62 | } 63 | return nil, fmt.Errorf("failed to open %s, %v", homePolicyPath, err) 64 | } 65 | defer file.Close() 66 | 67 | if fileInfo, err := file.Stat(); err != nil { 68 | return nil, fmt.Errorf("failed to get info on file %s", homePolicyPath) 69 | } else if stat, ok := fileInfo.Sys().(*syscall.Stat_t); !ok { // This syscall.Stat_t is doesn't work on Windows 70 | return nil, fmt.Errorf("failed to stat file %s", homePolicyPath) 71 | } else { 72 | // We want to ensure that the file is owned by the correct user and has the correct permissions. 73 | requiredOwnerUid := userObj.Uid 74 | fileOwnerUID := strconv.FormatUint(uint64(stat.Uid), 10) 75 | fileOwner, err := user.LookupId(fileOwnerUID) 76 | if err != nil { 77 | return nil, fmt.Errorf("failed to find username for UID %s for file %s", fileOwnerUID, homePolicyPath) 78 | } 79 | if fileOwnerUID != userObj.Uid || fileOwner.Username != username { 80 | return nil, fmt.Errorf("unsafe file permissions on %s expected file owner %s (UID %s) got %s (UID %s)", 81 | homePolicyPath, username, requiredOwnerUid, fileOwner.Username, fileOwnerUID) 82 | } 83 | if fileInfo.Mode().Perm() != files.ModeHomePerms { 84 | return nil, fmt.Errorf("unsafe file permissions for %s got %o expected %o", homePolicyPath, fileInfo.Mode().Perm(), files.ModeHomePerms) 85 | } 86 | fileBytes, err := os.ReadFile(homePolicyPath) 87 | if err != nil { 88 | return nil, fmt.Errorf("failed to read %s, %v", homePolicyPath, err) 89 | } 90 | return fileBytes, nil 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /commands/readhome_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | //go:build windows 18 | 19 | package commands 20 | 21 | import ( 22 | "errors" 23 | ) 24 | 25 | // ReadHome is not currently supported on Windows 26 | func ReadHome(username string) ([]byte, error) { 27 | return nil, errors.New("readhome not supported on windows") 28 | } 29 | -------------------------------------------------------------------------------- /commands/verify.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package commands 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "io/fs" 23 | "net/http" 24 | 25 | "github.com/openpubkey/openpubkey/pktoken" 26 | "github.com/openpubkey/openpubkey/verifier" 27 | "github.com/openpubkey/opkssh/commands/config" 28 | "github.com/openpubkey/opkssh/policy" 29 | "github.com/openpubkey/opkssh/policy/files" 30 | "github.com/openpubkey/opkssh/sshcert" 31 | "github.com/spf13/afero" 32 | "golang.org/x/crypto/ssh" 33 | ) 34 | 35 | // PolicyEnforcerFunc returns nil if the supplied PK token is permitted to login as 36 | // username. Otherwise, an error is returned indicating the reason for rejection 37 | type PolicyEnforcerFunc func(username string, pkt *pktoken.PKToken, userInfo string, sshCert string, keyType string) error 38 | 39 | // VerifyCmd provides functionality to verify OPK tokens contained in SSH 40 | // certificates and authorize requests to SSH as a specific username using a 41 | // configurable authorization system. It is designed to be used in conjunction 42 | // with sshd's AuthorizedKeysCommand feature. 43 | type VerifyCmd struct { 44 | Fs afero.Fs 45 | // PktVerifier is responsible for verifying the PK token 46 | // contained in the SSH certificate 47 | PktVerifier verifier.Verifier 48 | // CheckPolicy determines whether the verified PK token is permitted to SSH as a 49 | // specific user 50 | CheckPolicy PolicyEnforcerFunc 51 | // ConfigPathArg is the path to the server config file 52 | ConfigPathArg string 53 | // filePermChecker is used to check the file permissions of the config file 54 | filePermChecker files.PermsChecker 55 | // HTTPClient can be mocked using a roundtripper in tests 56 | HttpClient *http.Client 57 | } 58 | 59 | func NewVerifyCmd(pktVerifier verifier.Verifier, checkPolicy PolicyEnforcerFunc, configPathArg string) *VerifyCmd { 60 | fs := afero.NewOsFs() 61 | return &VerifyCmd{ 62 | Fs: fs, 63 | PktVerifier: pktVerifier, 64 | CheckPolicy: checkPolicy, 65 | ConfigPathArg: configPathArg, 66 | filePermChecker: files.PermsChecker{ 67 | Fs: fs, 68 | CmdRunner: files.ExecCmd, 69 | }, 70 | } 71 | } 72 | 73 | // This function is called by the SSH server as the AuthorizedKeysCommand: 74 | // 75 | // The following lines are added to /etc/ssh/sshd_config: 76 | // 77 | // AuthorizedKeysCommand /usr/local/bin/opkssh ver %u %k %t 78 | // AuthorizedKeysCommandUser opksshuser 79 | // 80 | // The parameters specified in the config map the parameters sent to the function below. 81 | // We prepend "Arg" to specify which ones are arguments sent by sshd. They are: 82 | // 83 | // %u The username (requested principal) - userArg 84 | // %k The base64-encoded public key for authentication - certB64Arg - the public key is also a certificate 85 | // %t The public key type - typArg - in this case a certificate being used as a public key 86 | // 87 | // AuthorizedKeysCommand verifies the OPK PK token contained in the base64-encoded SSH pubkey; 88 | // the pubkey is expected to be an SSH certificate. pubkeyType is used to 89 | // determine how to parse the pubkey as one of the SSH certificate types. 90 | // 91 | // This function: 92 | // 1. Verifying the PK token with the OP (OpenID Provider) 93 | // 2. Enforcing policy by checking if the identity is allowed to assume 94 | // the username (principal) requested. 95 | // 96 | // If all steps of verification succeed, then the expected authorized_keys file 97 | // format string is returned (i.e. the expected line to produce on standard 98 | // output when using sshd's AuthorizedKeysCommand feature). Otherwise, a non-nil 99 | // error is returned. 100 | func (v *VerifyCmd) AuthorizedKeysCommand(ctx context.Context, userArg string, typArg string, certB64Arg string) (string, error) { 101 | // Parse the b64 pubkey and expect it to be an ssh certificate 102 | cert, err := sshcert.NewFromAuthorizedKey(typArg, certB64Arg) 103 | if err != nil { 104 | return "", err 105 | } 106 | 107 | if pkt, err := cert.VerifySshPktCert(ctx, v.PktVerifier); err != nil { // Verify the PKT contained in the cert 108 | return "", err 109 | } else { 110 | userInfo := "" 111 | if accessToken := cert.GetAccessToken(); accessToken != "" { 112 | if userInfoRet, err := v.UserInfoLookup(ctx, pkt, accessToken); err == nil { 113 | // userInfo is optional so we should not fail if we can't access it 114 | userInfo = userInfoRet 115 | } 116 | } 117 | 118 | if err := v.CheckPolicy(userArg, pkt, userInfo, certB64Arg, typArg); err != nil { 119 | return "", err 120 | } else { // Success! 121 | // sshd expects the public key in the cert, not the cert itself. This 122 | // public key is key of the CA that signs the cert, in our setting there 123 | // is no CA. 124 | pubkeyBytes := ssh.MarshalAuthorizedKey(cert.SshCert.SignatureKey) 125 | return "cert-authority " + string(pubkeyBytes), nil 126 | } 127 | } 128 | } 129 | 130 | // SetEnvVarInConfig sets the environment variables specified in the server config file 131 | func (v *VerifyCmd) SetEnvVarInConfig() error { 132 | var configBytes []byte 133 | 134 | // Load the file from the filesystem 135 | afs := &afero.Afero{Fs: v.Fs} 136 | configBytes, err := afs.ReadFile(v.ConfigPathArg) 137 | if err != nil { 138 | return fmt.Errorf("failed to read config file: %w", err) 139 | } 140 | 141 | err = v.filePermChecker.CheckPerm(v.ConfigPathArg, []fs.FileMode{0640}, "root", "opksshuser") 142 | if err != nil { 143 | return err 144 | } 145 | 146 | serverConfig, err := config.NewServerConfig(configBytes) 147 | if err != nil { 148 | return fmt.Errorf("failed to parse config file: %w", err) 149 | } 150 | return serverConfig.SetEnvVars() 151 | } 152 | 153 | func (v *VerifyCmd) UserInfoLookup(ctx context.Context, pkt *pktoken.PKToken, accessToken string) (string, error) { 154 | ui, err := verifier.NewUserInfoRequester(pkt, accessToken) 155 | if err != nil { 156 | return "", err 157 | } 158 | ui.HttpClient = v.HttpClient 159 | return ui.Request(ctx) 160 | } 161 | 162 | // OpkPolicyEnforcerAuthFunc returns an opkssh policy.Enforcer that can be 163 | // used in the opkssh verify command. 164 | func OpkPolicyEnforcerFunc(username string) PolicyEnforcerFunc { 165 | policyEnforcer := &policy.Enforcer{ 166 | PolicyLoader: policy.NewMultiPolicyLoader(username, policy.ReadWithSudoScript), 167 | } 168 | return policyEnforcer.CheckPolicy 169 | } 170 | -------------------------------------------------------------------------------- /commands/verify_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package commands 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "io/fs" 23 | "os" 24 | "path/filepath" 25 | "strings" 26 | "testing" 27 | 28 | "github.com/lestrrat-go/jwx/v2/jwa" 29 | "github.com/openpubkey/openpubkey/client" 30 | "github.com/openpubkey/openpubkey/pktoken" 31 | "github.com/openpubkey/openpubkey/providers" 32 | "github.com/openpubkey/openpubkey/providers/mocks" 33 | "github.com/openpubkey/openpubkey/util" 34 | "github.com/openpubkey/openpubkey/verifier" 35 | "github.com/openpubkey/opkssh/policy/files" 36 | "github.com/openpubkey/opkssh/sshcert" 37 | "github.com/spf13/afero" 38 | "github.com/stretchr/testify/require" 39 | "golang.org/x/crypto/ssh" 40 | ) 41 | 42 | const userInfoResponse = `{ 43 | "sub": "me", 44 | "email": "alice@example.com", 45 | "name": "Alice Example", 46 | "groups": ["group1", "group2"] 47 | }` 48 | 49 | func AllowAllPolicyEnforcer(userDesired string, pkt *pktoken.PKToken, userInfo string, certB64 string, typArg string) error { 50 | return nil 51 | } 52 | 53 | func AllowIfExpectedUserInfo(userDesired string, pkt *pktoken.PKToken, userInfo string, certB64 string, typArg string) error { 54 | if userInfo == "" { 55 | return fmt.Errorf("userInfo is required") 56 | } else if len(userInfo) != 93 { 57 | // Smoke test that something is returned 58 | return fmt.Errorf("userInfo is not valid, %d", len(userInfo)) 59 | } 60 | return nil 61 | } 62 | 63 | func TestAuthorizedKeysCommand(t *testing.T) { 64 | t.Parallel() 65 | expectedAccessToken := "fake-auth-token" 66 | 67 | alg := jwa.ES256 68 | signer, err := util.GenKeyPair(alg) 69 | require.NoError(t, err) 70 | 71 | providerOpts := providers.DefaultMockProviderOpts() 72 | providerOpts.Issuer = "https://accounts.google.com" 73 | op, _, idtTemplate, err := providers.NewMockProvider(providerOpts) 74 | require.NoError(t, err) 75 | 76 | mockEmail := "arthur.aardvark@example.com" 77 | idtTemplate.ExtraClaims = map[string]any{ 78 | "email": mockEmail, 79 | } 80 | 81 | tests := []struct { 82 | name string 83 | accessToken string 84 | errorString string 85 | policyFunc func(userDesired string, pkt *pktoken.PKToken, userInfo string, certB64 string, typArg string) error 86 | }{ 87 | { 88 | name: "Happy Path", 89 | policyFunc: AllowAllPolicyEnforcer, 90 | }, 91 | { 92 | name: "Happy Path (with auth token)", 93 | accessToken: expectedAccessToken, 94 | policyFunc: AllowIfExpectedUserInfo, 95 | }, 96 | { 97 | name: "Wrong auth token", 98 | accessToken: "Bad-auth-token", 99 | policyFunc: AllowIfExpectedUserInfo, 100 | errorString: "userInfo is required", 101 | }, 102 | } 103 | for _, tt := range tests { 104 | t.Run(tt.name, func(t *testing.T) { 105 | client, err := client.New(op, client.WithSigner(signer, alg)) 106 | require.NoError(t, err) 107 | 108 | pkt, err := client.Auth(context.Background()) 109 | require.NoError(t, err) 110 | 111 | var accessToken []byte 112 | if tt.accessToken != "" { 113 | accessToken = []byte(tt.accessToken) 114 | } else { 115 | accessToken = nil 116 | } 117 | 118 | principals := []string{"guest", "dev"} 119 | cert, err := sshcert.New(pkt, accessToken, principals) 120 | require.NoError(t, err) 121 | 122 | sshSigner, err := ssh.NewSignerFromSigner(signer) 123 | require.NoError(t, err) 124 | 125 | signerMas, err := ssh.NewSignerWithAlgorithms(sshSigner.(ssh.AlgorithmSigner), 126 | []string{ssh.KeyAlgoECDSA256}) 127 | require.NoError(t, err) 128 | 129 | sshCert, err := cert.SignCert(signerMas) 130 | require.NoError(t, err) 131 | 132 | certTypeAndCertB64 := ssh.MarshalAuthorizedKey(sshCert) 133 | typeArg := strings.Split(string(certTypeAndCertB64), " ")[0] 134 | certB64Arg := strings.Split(string(certTypeAndCertB64), " ")[1] 135 | 136 | verPkt, err := verifier.New( 137 | op, 138 | verifier.WithExpirationPolicy(verifier.ExpirationPolicies.NEVER_EXPIRE), 139 | ) 140 | require.NoError(t, err) 141 | 142 | userArg := "user" 143 | ver := VerifyCmd{ 144 | PktVerifier: *verPkt, 145 | CheckPolicy: tt.policyFunc, 146 | HttpClient: mocks.NewMockGoogleUserInfoHTTPClient(userInfoResponse, expectedAccessToken), 147 | } 148 | 149 | pubkeyList, err := ver.AuthorizedKeysCommand(context.Background(), userArg, typeArg, certB64Arg) 150 | 151 | if tt.errorString != "" { 152 | require.ErrorContains(t, err, tt.errorString) 153 | require.Empty(t, pubkeyList) 154 | } else { 155 | require.NoError(t, err) 156 | 157 | expectedPubkeyList := "cert-authority ecdsa-sha2-nistp256" 158 | require.Contains(t, pubkeyList, expectedPubkeyList) 159 | } 160 | }) 161 | 162 | } 163 | } 164 | 165 | func TestEnvFromConfig(t *testing.T) { 166 | // Do not run this test in parallel with other tests as it modifies environment variables 167 | 168 | configContent := `--- 169 | env_vars: 170 | OPKSSH_TEST_EXAMPLE_VAR1: ABC 171 | OPKSSH_TEST_EXAMPLE_VAR2: DEF 172 | ` 173 | 174 | tests := []struct { 175 | name string 176 | configFile map[string]string 177 | permission fs.FileMode 178 | Content string 179 | owner string 180 | group string 181 | errorString string 182 | }{ 183 | { 184 | name: "Happy Path", 185 | configFile: map[string]string{"server_config.yml": configContent}, 186 | permission: 0640, 187 | owner: "root", 188 | group: "opksshuser", 189 | errorString: "", 190 | }, 191 | { 192 | name: "Wrong Permissions", 193 | configFile: map[string]string{"server_config.yml": configContent}, 194 | permission: 0677, 195 | owner: "root", 196 | group: "opksshuser", 197 | errorString: "expected one of the following permissions [640], got (677)", 198 | }, 199 | { 200 | name: "Wrong ownership", 201 | configFile: map[string]string{"server_config.yml": configContent}, 202 | permission: 0640, 203 | owner: "opksshuser", 204 | group: "opksshuser", 205 | errorString: "expected owner (root), got (opksshuser)", 206 | }, 207 | { 208 | name: "Missing config", 209 | configFile: map[string]string{"wrong-filename.yml": configContent}, 210 | permission: 0640, 211 | owner: "root", 212 | group: "opksshuser", 213 | errorString: "file does not exist", 214 | }, 215 | { 216 | name: "Corrupted file", 217 | configFile: map[string]string{"server_config.yml": `;;;corrupted`}, 218 | permission: 0640, 219 | owner: "root", 220 | group: "opksshuser", 221 | errorString: "failed to parse config file", 222 | }, 223 | } 224 | for _, tt := range tests { 225 | t.Run(tt.name, func(t *testing.T) { 226 | // Unset the environment variables after the test is done to avoid side effects 227 | defer func() { 228 | for _, v := range os.Environ() { 229 | if strings.HasPrefix(v, "OPKSSH_TEST_EXAMPLE_VAR") { 230 | parts := strings.SplitN(v, "=", 2) 231 | os.Unsetenv(parts[0]) 232 | } 233 | } 234 | }() 235 | 236 | mockFs := afero.NewMemMapFs() 237 | tempDir, _ := afero.TempDir(mockFs, "opk", "config") 238 | for name, content := range tt.configFile { 239 | err := afero.WriteFile(mockFs, filepath.Join(tempDir, name), []byte(content), tt.permission) 240 | require.NoError(t, err) 241 | } 242 | 243 | ver := VerifyCmd{ 244 | Fs: mockFs, 245 | ConfigPathArg: filepath.Join(tempDir, "server_config.yml"), 246 | filePermChecker: files.PermsChecker{ 247 | Fs: mockFs, 248 | CmdRunner: func(name string, arg ...string) ([]byte, error) { 249 | return []byte(tt.owner + " " + tt.group), nil 250 | }, 251 | }, 252 | } 253 | err := ver.SetEnvVarInConfig() 254 | 255 | if tt.errorString != "" { 256 | require.ErrorContains(t, err, tt.errorString) 257 | } else { 258 | require.NoError(t, err) 259 | require.Equal(t, "ABC", os.Getenv("OPKSSH_TEST_EXAMPLE_VAR1")) 260 | require.Equal(t, "DEF", os.Getenv("OPKSSH_TEST_EXAMPLE_VAR2")) 261 | } 262 | }) 263 | } 264 | 265 | } 266 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # opkssh configuration files 2 | 3 | Herein we document the various configuration files used by opkssh. 4 | The documentation for the `/etc/opk/policy.d/` policy plugin system is found [here](policyplugins.md). 5 | 6 | All our configuration files are space delimited like ssh authorized key files. 7 | We have the follow syntax rules: 8 | 9 | - `#` for comments 10 | 11 | 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. 12 | 13 | ## Client config `~/.opk/config.yml` 14 | 15 | The config file for the client is saved in `~/.opk/config.yml`. 16 | It configures which OpenID Providers the user can log in with. 17 | This file is not required to exist to use opkssh and it is not created by default. 18 | To create it, simple run `~/opkssh login --create-config`. 19 | 20 | The default client config can be found in [../commands/config/default-client-config.yml](../commands/config/default-client-config.yml). 21 | 22 | The client config can be used to configure the following values: 23 | 24 | - **default_provider** By default this is set to the webchooser, which opens a webpage and allows the user to select the OpenID Provider they want by clicking. However if you wish to always connect to one particular OpenID Provider you can set this to the alias of that OpenID Provider and it will skip the web chooser and automatically just open a browser window to that provider. 25 | 26 | - **providers** This allows you to configure all the OpenID Providers you wish to use. See example below. 27 | - **send_access_token** Is a boolean value scoped to a particular provider. It determines if opkssh should put the user's access token into the SSH public key (SSH Certificate). This is useful for allowing the opkssh verifier to read claims not available in the ID Token that can only be read from the OpenID Provider's [userinfo endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo). The opkssh verifier on the SSH server will use the access token to make a call to the OpenID Provider's userinfo endpoint. Configuration option false by default as SSH will send SSH Public Keys to any host you are attempting to SSH into. Before setting this to true carefully consider the security implications of including the access token in the SSH Public key. 28 | 29 | ```yaml 30 | --- 31 | 32 | default_provider: webchooser 33 | 34 | providers: 35 | - alias: google 36 | issuer: https://accounts.google.com 37 | client_id: 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 38 | client_secret: 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 39 | scopes: openid email profile 40 | access_type: offline 41 | prompt: consent 42 | redirect_uris: 43 | - http://localhost:3000/login-callback 44 | - http://localhost:10001/login-callback 45 | - http://localhost:11110/login-callback 46 | send_access_token: false 47 | 48 | - alias: azure microsoft 49 | issuer: https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 50 | client_id: 096ce0a3-5e72-4da8-9c86-12924b294a01 51 | scopes: openid profile email offline_access 52 | access_type: offline 53 | prompt: consent 54 | redirect_uris: 55 | - http://localhost:3000/login-callback 56 | - http://localhost:10001/login-callback 57 | - http://localhost:11110/login-callback 58 | 59 | 60 | ``` 61 | 62 | ## Server config `/etc/opk/config.yml` 63 | 64 | This is the config file for opkssh when used on the SSH server. 65 | The only current config field it supports is setting additional environment variables when `opkssh verify` is called. 66 | For instance if you want to specify the URI of a proxy server you can pass the environment variable HTTPS_PROXY: 67 | 68 | ```yml 69 | --- 70 | env_vars: 71 | HTTPS_PROXY: http://yourproxy:3128 72 | ``` 73 | 74 | It requires the following permissions be set: 75 | 76 | ```bash 77 | sudo chown root:opksshuser /etc/opk/config.yml 78 | sudo chmod 640 /etc/opk/config.yml 79 | ``` 80 | 81 | ## Allowed OpenID Providers: `/etc/opk/providers` 82 | 83 | This file functions as an access control list that enables admins to determine the OpenID Providers and Client IDs they wish to use. 84 | This file contains a list of allowed OPKSSH OPs (OpenID Providers) and the associated client ID. 85 | The client ID must match the aud (audience) claim in the PK Token. 86 | 87 | ### Columns 88 | 89 | - Column 1: Issuer 90 | - Column 2: Client-ID a.k.a. what to match on the aud claim in the ID Token 91 | - Column 3: Expiration policy, options are: `24h`, `48h`, `1week`, `oidc`, `oidc-refreshed` 92 | 93 | ### Examples 94 | 95 | The file lives at `/etc/opk/providers`. The default values are: 96 | 97 | ```bash 98 | # Issuer Client-ID expiration-policy 99 | https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h 100 | https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h 101 | https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h 102 | ``` 103 | 104 | ## Authorized identities files: `/etc/opk/auth_id` and `/home/{USER}/.opk/auth_id` 105 | 106 | These files contain the policies to determine which identities can assume what linux user accounts. 107 | Linux user accounts are typically referred to in SSH as *principals* and we use this terminology. 108 | 109 | We support matching on email, sub (subscriber) or group. 110 | 111 | ### System authorized identity file `/etc/opk/auth_id` 112 | 113 | This is a server wide policy file. 114 | 115 | ```bash 116 | # email/sub principal issuer 117 | alice alice@example.com https://accounts.google.com 118 | guest alice@example.com https://accounts.google.com 119 | root alice@example.com https://accounts.google.com 120 | dev bob@microsoft.com https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 121 | 122 | # Group identifier 123 | dev oidc:groups:developer https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 124 | ``` 125 | 126 | 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. 127 | 128 | `sudo opkssh add {USER} {EMAIL|SUB|GROUP} {ISSUER}` 129 | 130 | For convenience you can use the shorthand `google`, `azure`, `gitlab` rather than specifying the entire issuer. 131 | This is especially useful in the case of azure where the issuer contains a long and hard to remember random string. 132 | 133 | The following command will allow `alice@example.com` to ssh in as `root`. 134 | 135 | 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: 136 | 137 | ```bash 138 | sudo opkssh add root oidc:group:admin azure 139 | ``` 140 | 141 | 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. 142 | 143 | The system authorized identity file requires the following permissions: 144 | 145 | ```bash 146 | sudo chown root:opksshuser /etc/opk/auth_id 147 | sudo chmod 640 /etc/opk/auth_id 148 | ``` 149 | 150 | **Note:** The permissions for the system authorized identity file are different than the home authorized identity file. 151 | 152 | ### Home authorized identity file `/home/{USER}/.opk/auth_id` 153 | 154 | This is user/principal specific permissions. 155 | That is, if it is in `/home/alice/.opk/auth_id` it can only specify who can assume the principal `alice` on the server. 156 | 157 | ```bash 158 | # email/sub principal issuer 159 | alice alice@example.com https://accounts.google.com 160 | 161 | # Group identifier 162 | alice oidc:groups:developer https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 163 | ``` 164 | 165 | Home authorized identity file requires the following permissions: 166 | 167 | ```bash 168 | chown {USER}:{USER} /home/{USER}/.opk/auth_id 169 | chmod 600 /home/{USER}/.opk/auth_id 170 | ``` 171 | 172 | ## See Also 173 | 174 | Our documentation on the changes our install script makes to a server: [installing.md](../scripts/installing.md) 175 | -------------------------------------------------------------------------------- /docs/gitlab-selfhosted.md: -------------------------------------------------------------------------------- 1 | # Configure Self hosted Gitlab instance 2 | 3 | ### Create an OAuth Application in Gitlab 4 | 5 | Create an OAuth application in your Gitlab instance that allows opkssh access. 6 | 7 | 1. Go to the Gitlab Admin page 8 | 2. Go to Applications, add a new application 9 | 3. Give it a descriptive name (Users will see this name when they authorize opkssh) 10 | 4. For the redirect URI's enter: 11 | ``` 12 | http://localhost:3000/login-callback 13 | http://localhost:10001/login-callback 14 | http://localhost:11110/login-callback 15 | ``` 16 | 5. Deselect Trusted and Confidential. 17 | 6. Select the scopes: `openid`, `profile` and `email` 18 | 19 | Create the application and note the Application ID. 20 | 21 | ### Configure the client 22 | 23 | Add the configuration in the [config file](../README.md#client-config-file) 24 | 25 | ``` 26 | providers: 27 | - alias: my-gitlab 28 | issuer: https://my-gitlab-url.com 29 | client_id: 30 | scopes: openid email 31 | access_type: offline 32 | prompt: consent 33 | redirect_uris: 34 | - http://localhost:3000/login-callback 35 | - http://localhost:10001/login-callback 36 | - http://localhost:11110/login-callback 37 | ``` 38 | 39 | You can then log in using your Gitlab instance via 40 | 41 | ``` 42 | opkssh login my-gitlab 43 | ``` 44 | 45 | ### Configure the server 46 | 47 | Add the Gitlab URL and Application ID to the [providers file](../README.md#etcopkproviders) on the server: 48 | 49 | ``` 50 | https://my-gitlab-url.com 24h 51 | ``` 52 | 53 | Then add identities to the policy to allow those identities to SSH to the server: 54 | 55 | ``` 56 | opkssh add root alice@example.com https://my-gitlab-url.com 57 | ``` 58 | -------------------------------------------------------------------------------- /docs/paramiko.md: -------------------------------------------------------------------------------- 1 | # paramiko 2 | 3 | If you use the `paramiko` libary for Python, then you'll have to manually load the public key like this: 4 | 5 | ```python 6 | import paramiko 7 | 8 | private_key = paramiko.ECDSAKey(filename='/home/username/.ssh/opkssh_server_group1') 9 | private_key.load_certificate('/home/username/.ssh/opkssh_server_group1.pub') 10 | 11 | sshcon = paramiko.SSHClient() 12 | sshcon.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 13 | sshcon.connect('192.168.10.10', username='ubuntu', pkey=private_key) 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/policyplugins.md: -------------------------------------------------------------------------------- 1 | # Policy plugins 2 | 3 | Inspired by the power of [the OpenSSH AuthorizedKeysCommand](https://man.openbsd.org/sshd_config.5#AuthorizedKeysCommand), opkssh provides policy plugins. 4 | These policy plugins provide a simple way to bring your own policy which extends the default opkssh policy. 5 | 6 | To use your own policy create a policy plugin config file in `/etc/opk/policy.d`. This config files specifies what command you want to calls out to evaluate policy. If the command returns anything else other than "allowed" and exit code 0, this is viewed as a policy rejection. 7 | 8 | The policy plugin does not bypass the providers check. This means that a policy plugin can count on the ID Token having been validated as validly signed by one of the OPs in the `/etc/opk/providers`. We do this to allow people to write policies without having to rebuild all the code in opkssh verify. 9 | 10 | For example by creating the file in `/etc/opk/policy.d/example-plugin.yml`: 11 | 12 | ```yml 13 | name: Example plugin config 14 | command: /etc/opk/plugin-cmd.sh 15 | ``` 16 | 17 | and then when someone runs `ssh dev alice@example.com` the opkssh will call `/tmp/plugin-cmd.sh` to determine if policy should allow `alice@gmail.com` to assume ssh access as the linux principal `dev`. [Environment variables](https://en.wikipedia.org/wiki/Environment_variable) are set to communicate the details of the ssh login attempt to the command such as: 18 | 19 | ```bash 20 | OPKSSH_PLUGIN_U=dev 21 | OPKSSH_PLUGIN_EMAIL=alice@gmail.com 22 | OPKSSH_PLUGIN_EMAIL_VERIFIED=true 23 | ``` 24 | 25 | The command `/etc/opk/plugin-cmd.sh` would allow `alice@example.com` to log as any user: 26 | 27 | ```bash 28 | #!/usr/bin/env sh 29 | 30 | if [ "${OPKSSH_PLUGIN_EMAIL}" = "alice@example.com" ] && [ "${OPKSSH_PLUGIN_EMAIL_VERIFIED}" = "true" ]; then 31 | echo "allow" 32 | exit 0 33 | else 34 | echo "deny" 35 | exit 1 36 | fi 37 | ``` 38 | 39 | **Important:** If you have multiple policy plugins only one of them needs to return "allow" for the action to be allowed. 40 | If you added five policy plugins and four of them say "deny" and one of them says "allow" the allow wins out. Additionally policy plugins do not disable standard policy. 41 | To completely turn off standard policy and only use policy plugins ensure all auth_id files are empty. 42 | 43 | The pseudocode policy is: 44 | 45 | 1. pluginAllowsAccess = false 46 | 2. FOR each policy-plugin config in `/etc/opk/policy.d/*.yml` 47 | 1. IF config.command() == "allow": 48 | 1. pluginAllowsAccess = true 49 | 3. IF pluginAllowsAccess == true: 50 | 1. return "allow" 51 | 4. ELSE IF standardPolicy() == "allow" 52 | 1. return "allow" 53 | 5. ELSE: 54 | 1. return "deny" 55 | 56 | ## Permission requirements 57 | 58 | The policy plugin config file must have the permission `640` with ownership set to `root:opksshuser`. 59 | 60 | ```bash 61 | chmod 640 /etc/opk/policy.d/example-plugin.yml 62 | chmod root:opksshuser /etc/opk/policy.d/example-plugin.yml 63 | ``` 64 | 65 | The policy plugin command file must have the permission `755` or `555` with ownership set to `root:opksshuser`. 66 | 67 | ```bash 68 | chmod 755 /etc/opk/plugin-cmd.sh 69 | chmod root:opksshuser /etc/opk/plugin-cmd.sh 70 | ``` 71 | 72 | These rules are required so that these policy files are only write by root. 73 | 74 | ## Environment Variables Set 75 | 76 | We support set the following information about the login attempt to the policy plugin command 77 | 78 | ### OpenSSH 79 | 80 | We provide the following values specified by [OpenSSHd AuthorizedKeysCommand TOKENS pattern](https://man.openbsd.org/sshd_config#TOKENS). 81 | 82 | - OPKSSH_PLUGIN_U Target username (requested principal). This is `%u` token in SSH. 83 | - OPKSSH_PLUGIN_K Base64-encoded SSH public key (SSH certificate) provided for authentication. This is useful if someone really wants to see everything opkssh sees. This is the `%k` token in SSH. 84 | - OPKSSH_PLUGIN_T Public key type (SSH certificate format, e.g., [ecdsa-sha2-nistp256-cert-v01@openssh.com](mailto:ecdsa-sha2-nistp256-cert-v01@openssh.com)). This is the `%t` token in SSH. 85 | 86 | ### From ID Token claims 87 | 88 | - OPKSSH_PLUGIN_ISS Issuer (iss) claim 89 | - OPKSSH_PLUGIN_SUB Sub claim of the identity 90 | - OPKSSH_PLUGIN_EMAIL Email claim of the identity 91 | - OPKSSH_PLUGIN_EMAIL_VERIFIED Optional claim that signals if the email address has been verified 92 | - OPKSSH_PLUGIN_AUD Audience/client_id (aud) claim 93 | - OPKSSH_PLUGIN_EXP Expiration (exp) claim 94 | - OPKSSH_PLUGIN_NBF Not Before (nbf) claim 95 | - OPKSSH_PLUGIN_IAT IssuedAt 96 | - OPKSSH_PLUGIN_JTI JTI JWT ID 97 | 98 | #### Misc 99 | 100 | - OPKSSH_PLUGIN_PAYLOAD Based64-encoded ID Token payload (JSON) 101 | - OPKSSH_PLUGIN_UPK Base64-encoded JWK of the user's public key in the PK Token 102 | - OPKSSH_PLUGIN_IDT Compact-encoded ID Token 103 | - OPKSSH_PLUGIN_PKT Compact-encoded PK Token 104 | - OPKSSH_PLUGIN_CONFIG Base64 encoded bytes of the plugin config used in this call. Useful for debugging. 105 | - OPKSSH_PLUGIN_GROUPS Groups claim (if present) of the identity. 106 | - OPKSSH_PLUGIN_USERINFO the results of userinfo endpoint. This is set to the empty string if no access token was provided in the SSH certificate. See send_access_token in [config.md](config.md) to see how to set an access token. 107 | 108 | ### Handling missing or empty claims 109 | 110 | Note that if an claim is not present we set to the empty string, "". For instance for an ID Token payload below, we set `OPKSSH_PLUGIN_AUD` and `OPKSSH_PLUGIN_EMAIL` to the empty string ("") since there is no `aud` claim and no email claim: 111 | 112 | ```json 113 | { 114 | "iss":"https://example.com", 115 | "sub":"123", 116 | "aud":"", 117 | "exp":34, 118 | "iat":12, 119 | "email":"alice@example.com", 120 | } 121 | ``` 122 | 123 | We do this to avoid situations where a policy plugin includes a claim to check if it present but does not require it. If we threw an error if it was not found then this would cause hard to debug policy failures if an ID Token is missing that claim. 124 | 125 | If a policy plugin wishes to discriminate between claims which are missing or merely set to the empty string, they could use the `OPKSSH_PLUGIN_IDT` and parse the ID Token themselves. 126 | 127 | ## Example policy configs 128 | 129 | ### Match username to email address 130 | 131 | This policy plugin allows ssh access as the principal (linux user) if the principal is the same as the username part of the email address in the ID Token, i.e. when email of the user fits the pattern `principal@example.com`. For instance this would allow `ssh alice@hostname` if Alice's email address is `alice@example.com`. 132 | 133 | To prevent issues where someone might get the email `root@example.com` it has a list of default linux principles always denies such as `root`, `admin`, `email`, `backup`... 134 | 135 | The last part of the email address must match the value supplied at the commandline, for instance in the policy plugin config below, this would be `example.com`. If you wanted to use this for say `gmail.com` change this value from `example.com` to `gmail.com` in the config: 136 | 137 | ```yml 138 | name: Match linux username to email username 139 | command: /etc/opk/match-email.sh example.com 140 | ``` 141 | 142 | ```bash 143 | #!/usr/bin/env sh 144 | 145 | principal="${OPKSSH_PLUGIN_U}" 146 | email="${OPKSSH_PLUGIN_EMAIL}" 147 | email_verified="${OPKSSH_PLUGIN_EMAIL_VERIFIED}" 148 | req_domain="$1" 149 | 150 | DENY_LIST="root admin email backup" 151 | 152 | for deny_principal in $DENY_LIST; do 153 | if [ "$principal" = "$deny_principal" ]; then 154 | echo "deny" 155 | exit 1 156 | fi 157 | done 158 | 159 | expectedEmail="${principal}@${req_domain}" 160 | if [ "$expectedEmail" = "$email" ] && [ "$email_verified" = "true" ]; then 161 | echo "allow" 162 | exit 0 163 | else 164 | echo "deny" 165 | exit 1 166 | fi 167 | ``` 168 | -------------------------------------------------------------------------------- /docs/putty.md: -------------------------------------------------------------------------------- 1 | # Using PuTTY with OPKSSH 2 | 3 | OPKSSH supports making SSH connections with [PuTTY](https://www.chiark.greenend.org.uk/~sgtatham/putty/). 4 | As OPKSSH requires SSH certificate support and [PuTTY only added SSH certificate support in version 0.78 in the year 2022](https://www.chiark.greenend.org.uk/~sgtatham/putty/changes.html), ensure your version of PuTTY is at least 0.78 or greater. 5 | 6 | ## Should you use PuTTY? 7 | 8 | Windows 10 and after natively support SSH and provide a much better user experience than PuTTY. 9 | We recommend against using PuTTY if you are using a recent version of Windows and just using the built-in window SSH command. 10 | 11 | To use native SSH windows with opkssh simply open a terminal or command.com and type: 12 | 13 | ```powershell 14 | .\opkssh.exe login 15 | ssh user@hostname 16 | ``` 17 | 18 | We provide this guide for those circumstances in which someone absolutely has to use PuTTY. 19 | 20 | ## Importing an SSH certificate into PuTTY 21 | 22 | PuTTY has its own incompatible SSH certificate and SSH private key format and can not understand regular SSH certificates and SSH private keys. 23 | Thankfully PuTTY provides a tool PuTTYgen which can convert regular SSH certificates and private keys into this special format. 24 | In the following steps we provide a walkthrough on how to import the regular SSH certificate and SSH private key into the PuTTY format. 25 | 26 | **Note:** Some parts of the PuTTY interface will refer to an SSH public key rather than an SSH certificate. Do not be confused, SSH certificates are a type SSH public keys. 27 | 28 | **Important: make sure you are using the latest version of Putty, earlier versions of Putty don't support this.** 29 | 30 | ### Step 1: Generate your OPKSSH ssh key 31 | 32 | Generate your OPKSSH ssh key by running `opkssh.exe login`. 33 | The output of this command will tell you the location opkssh wrote the key on your machine. Make note of this, we will need it in the next step. Typically these files are written to: 34 | 35 | - `C:\Users\{USERNAME}\.ssh\id_ecdsa.pub` for the SSH certificate 36 | - `C:\Users\{USERNAME}\.ssh\id_ecdsa` for the SSH private key 37 | 38 | ![Shows terminal output of running opkssh and location of ssh public key and ssh private key](https://github.com/user-attachments/assets/c1101d5e-8e6a-4a7e-82c8-d139b911efb6) 39 | 40 | ### Step 2: Use PuTTYgen to import the certificate and private key 41 | 42 | Open PuTTYgen. PuTTyGen comes automatically with your PuTTY, so if you have PuTTY installed you have PuTTYgen installed. 43 | 44 | In PuTTYgen click "Conversions --> Import Key" in the taskbar and then select the SSH private key `opkssh login` generated in step 1. 45 | By default this should be `C:\Users\{USERNAME}\.ssh\id_ecdsa`. 46 | You should know see the PuTTYgen has imported your private key because PuTTYgen will look something like: 47 | 48 | ![PuTTYgen after importing a private key](https://github.com/user-attachments/assets/bef3d39d-d602-41d6-b5fc-e456690df038) 49 | 50 | Now to import the certificate click "Key --> Add Certificate to key" in the taskbar. This will now add a "Certificate Info" button to PuTTYgen. 51 | 52 | ![PuTTYGgen after adding the certificate](https://github.com/user-attachments/assets/afbdac54-8c68-4a82-98c2-688f5999b1ae) 53 | 54 | Then you need to save both the private key and certificate in the PuTTY custom key format. Click the "Save public key" button and then click the "Save private key" button. 55 | 56 | ![save public key and save private key buttons](https://github.com/user-attachments/assets/45b06cd0-9ffd-42f4-97bb-388ddb92ce20) 57 | 58 | I saved my certificate as `opk-putty-cert` and my private key as `opk-priv-key`. Looking in the file explorer you should see them: 59 | 60 | ![Image](https://github.com/user-attachments/assets/c7810b61-0c75-4fd8-b1a1-91df97ac3b0f) 61 | 62 | ### Step 3: Connect with PuTTY 63 | 64 | After importing the SSH certificate and SSH private key and saving them in the PuTTY format, you can SSH with PuTTY. To do this open PuTTY and go to `Connection --> SSH --> Auth` in the left panel. 65 | 66 | ![Image](https://github.com/user-attachments/assets/f0b191cf-7b36-414e-ac46-19359c5542ac) 67 | 68 | Add the certificate (public key) and private key you imported and saved. 69 | 70 | ![Image](https://github.com/user-attachments/assets/be1169b1-2afb-45bb-b5fa-b7cedecb77b0) 71 | 72 | Now you can return to Session and click open to SSH using the OPKSSH generated certificate and private key. 73 | 74 | ![Image](https://github.com/user-attachments/assets/4b66ce4f-95f5-464c-8bfc-1fa8be32535e) 75 | 76 | You can save this connection profile so you don't have to edit these settings each time. 77 | 78 | By default opkssh keys expire every 24 hours and so each day you need to generate a new one and then reimport it into PuTTY. 79 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1746422338, 6 | "narHash": "sha256-NTtKOTLQv6dPfRe00OGSywg37A1FYqldS6xiNmqBUYc=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "5b35d248e9206c1f3baf8de6a7683fee126364aa", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-24.11", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: Apache-2.0 2 | 3 | { 4 | description = "Open Pubkey for SSH"; 5 | 6 | inputs = { 7 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-24.11"; 8 | }; 9 | 10 | outputs = { self, nixpkgs }: 11 | let 12 | supported-systems = [ 13 | "x86_64-linux" 14 | "aarch64-linux" 15 | "x86_64-darwin" 16 | "aarch64-darwin" 17 | ]; 18 | 19 | # Helper to provide system-specific attributes 20 | forSupportedSystems = f: nixpkgs.lib.genAttrs supported-systems (system: f { 21 | pkgs = import nixpkgs { inherit system; }; 22 | }); 23 | in 24 | { 25 | packages = forSupportedSystems ({ pkgs }: rec { 26 | opkssh = pkgs.buildGoModule { 27 | name = "opkssh"; 28 | src = self; 29 | vendorHash = "sha256-dADoFT9fFPOKWel0UoJI3GcES5kc6RGGoZXuiotZHtQ="; 30 | goSum = ./go.sum; 31 | meta.mainProgram = "opkssh"; 32 | }; 33 | default = opkssh; 34 | }); 35 | 36 | overlays.default = final: prev: { 37 | opkssh = self.packages.${final.stdenv.system}.opkssh; 38 | }; 39 | 40 | nixosModules = { 41 | server = { config, pkgs, lib, ... }: let cfg = config.programs.opkssh; in { 42 | options.programs.opkssh = { 43 | enable = lib.options.mkEnableOption "opkssh"; 44 | package = lib.options.mkOption { 45 | default = pkgs.opkssh; 46 | type = lib.types.package; 47 | }; 48 | command.enable = lib.options.mkEnableOption "opkssh command"; 49 | config = { 50 | # TODO: Replace these options with submodules. 51 | authorization_rules' = lib.options.mkOption { 52 | default = ""; 53 | type = lib.types.lines; 54 | }; 55 | providers' = lib.options.mkOption { 56 | default = '' 57 | https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h 58 | https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h 59 | https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h 60 | ''; 61 | type = lib.types.lines; 62 | }; 63 | }; 64 | }; 65 | 66 | config = lib.modules.mkIf cfg.enable { 67 | # This config follows the install-linux.sh procedure. 68 | users = { 69 | groups.opkssh = {}; 70 | users.opkssh = { 71 | isSystemUser = true; 72 | group = config.users.groups.opkssh.name; 73 | }; 74 | }; 75 | 76 | environment = { 77 | systemPackages = lib.lists.optional cfg.command.enable cfg.package; 78 | etc = let inherit (config.users) users groups; in { 79 | "opk/auth_id" = { 80 | user = users.opkssh.name; 81 | group = groups.opkssh.name; 82 | mode = "0640"; 83 | text = cfg.config.authorization_rules'; 84 | }; 85 | "opk/providers" = { 86 | user = users.opkssh.name; 87 | group = groups.opkssh.name; 88 | mode = "0640"; 89 | text = cfg.config.providers'; 90 | }; 91 | }; 92 | }; 93 | 94 | security.wrappers.opkssh = let inherit (config.users) users groups; in { 95 | owner = users.root.name; 96 | group = groups.root.name; 97 | source = lib.meta.getExe cfg.package; 98 | }; 99 | 100 | services.openssh = { 101 | # Command path has to be hardcoded unfortunately. 102 | authorizedKeysCommand = "/run/wrappers/bin/opkssh verify %u %k %t"; 103 | authorizedKeysCommandUser = config.users.users.opkssh.name; 104 | }; 105 | 106 | systemd.tmpfiles.rules = let inherit (config.users) users groups; in [ 107 | "f /var/log/opkssh.log 660 ${users.root.name} ${groups.opkssh.name} -" 108 | ]; 109 | }; 110 | }; 111 | }; 112 | }; 113 | } 114 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/openpubkey/opkssh 2 | 3 | go 1.23.7 4 | 5 | require ( 6 | github.com/docker/go-connections v0.5.0 7 | github.com/jeremija/gosubmit v0.2.8 8 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 9 | github.com/lestrrat-go/jwx/v2 v2.1.6 10 | github.com/melbahja/goph v1.4.0 11 | github.com/openpubkey/openpubkey v0.16.0 12 | github.com/spf13/cobra v1.9.1 13 | github.com/stretchr/testify v1.10.0 14 | github.com/testcontainers/testcontainers-go v0.37.0 15 | github.com/zitadel/oidc/v3 v3.38.1 16 | golang.org/x/crypto v0.38.0 17 | ) 18 | 19 | require ( 20 | dario.cat/mergo v1.0.1 // indirect 21 | filippo.io/bigmod v0.0.3 // indirect 22 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 23 | github.com/Microsoft/go-winio v0.6.2 // indirect 24 | github.com/awnumar/memguard v0.22.3 // indirect 25 | github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect 26 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 27 | github.com/containerd/log v0.1.0 // indirect 28 | github.com/containerd/platforms v0.2.1 // indirect 29 | github.com/cpuguy83/dockercfg v0.3.2 // indirect 30 | github.com/distribution/reference v0.6.0 // indirect 31 | github.com/docker/docker v28.0.1+incompatible // indirect 32 | github.com/docker/go-units v0.5.0 // indirect 33 | github.com/ebitengine/purego v0.8.2 // indirect 34 | github.com/felixge/httpsnoop v1.0.4 // indirect 35 | github.com/go-chi/chi/v5 v5.2.1 // indirect 36 | github.com/go-jose/go-jose/v4 v4.0.5 // indirect 37 | github.com/go-logr/logr v1.4.2 // indirect 38 | github.com/go-logr/stdr v1.2.2 // indirect 39 | github.com/go-ole/go-ole v1.2.6 // indirect 40 | github.com/gogo/protobuf v1.3.2 // indirect 41 | github.com/google/uuid v1.6.0 // indirect 42 | github.com/gorilla/securecookie v1.1.2 // indirect 43 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 44 | github.com/klauspost/compress v1.17.4 // indirect 45 | github.com/kr/fs v0.1.0 // indirect 46 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 47 | github.com/magiconair/properties v1.8.10 // indirect 48 | github.com/moby/docker-image-spec v1.3.1 // indirect 49 | github.com/moby/patternmatcher v0.6.0 // indirect 50 | github.com/moby/sys/sequential v0.5.0 // indirect 51 | github.com/moby/sys/user v0.3.0 // indirect 52 | github.com/moby/sys/userns v0.1.0 // indirect 53 | github.com/moby/term v0.5.0 // indirect 54 | github.com/morikuni/aec v1.0.0 // indirect 55 | github.com/muhlemmer/httpforwarded v0.1.0 // indirect 56 | github.com/opencontainers/go-digest v1.0.0 // indirect 57 | github.com/opencontainers/image-spec v1.1.1 // indirect 58 | github.com/pkg/errors v0.9.1 // indirect 59 | github.com/pkg/sftp v1.13.7 // indirect 60 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 61 | github.com/rs/cors v1.11.1 // indirect 62 | github.com/shirou/gopsutil/v4 v4.25.1 // indirect 63 | github.com/sirupsen/logrus v1.9.3 // indirect 64 | github.com/spf13/pflag v1.0.6 // indirect 65 | github.com/tklauser/go-sysconf v0.3.12 // indirect 66 | github.com/tklauser/numcpus v0.6.1 // indirect 67 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 68 | github.com/zitadel/logging v0.6.2 // indirect 69 | github.com/zitadel/schema v1.3.1 // indirect 70 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 71 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect 72 | go.opentelemetry.io/otel v1.35.0 // indirect 73 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect 74 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 75 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 76 | go.opentelemetry.io/proto/otlp v1.0.0 // indirect 77 | golang.org/x/net v0.38.0 // indirect 78 | ) 79 | 80 | require ( 81 | github.com/awnumar/memcall v0.1.2 // indirect 82 | github.com/davecgh/go-spew v1.1.1 // indirect 83 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 84 | github.com/goccy/go-json v0.10.3 // indirect 85 | github.com/lestrrat-go/blackmagic v1.0.3 // indirect 86 | github.com/lestrrat-go/httpcc v1.0.1 // indirect 87 | github.com/lestrrat-go/httprc v1.0.6 // indirect 88 | github.com/lestrrat-go/iter v1.0.2 // indirect 89 | github.com/lestrrat-go/option v1.0.1 // indirect 90 | github.com/muhlemmer/gu v0.3.1 // indirect 91 | github.com/pmezard/go-difflib v1.0.0 // indirect 92 | github.com/segmentio/asm v1.2.0 // indirect 93 | github.com/spf13/afero v1.14.0 94 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d 95 | golang.org/x/oauth2 v0.29.0 // indirect 96 | golang.org/x/sys v0.33.0 // indirect 97 | golang.org/x/text v0.25.0 // indirect 98 | gopkg.in/yaml.v3 v3.0.1 99 | ) 100 | -------------------------------------------------------------------------------- /hack/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 4 | 5 | set -eou pipefail 6 | 7 | pushd $SCRIPT_DIR/../ 8 | 9 | GO_VERSION=${GO_VERSION:-"1.24.2"} 10 | 11 | mkdir -p .cache 12 | mkdir -p .mod-cache 13 | 14 | docker run --rm \ 15 | -v "$PWD":/data/ \ 16 | -w /data \ 17 | --user=$(id -g):$(id -g) \ 18 | -v ${PWD}/.cache:/.cache \ 19 | -v ${PWD}/.mod-cache:/go/pkg/mod \ 20 | golang:${GO_VERSION}-alpine \ 21 | go build -v -o opkssh 22 | 23 | chmod u+x opkssh 24 | 25 | popd 26 | -------------------------------------------------------------------------------- /hack/integration-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eou pipefail 4 | 5 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 6 | 7 | pushd $SCRIPT_DIR/../ 8 | 9 | OS_TYPE=ubuntu go test -tags=integration ./test/integration -timeout=15m -count=1 -parallel=2 -v 10 | 11 | popd 12 | -------------------------------------------------------------------------------- /hack/unit-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eou pipefail 4 | 5 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 6 | 7 | pushd $SCRIPT_DIR/../ 8 | 9 | go test -v ./... 10 | 11 | popd 12 | -------------------------------------------------------------------------------- /internal/projectpath/projectpath.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | // Package projectpath is used internally by the integration tests to get the 18 | // root folder of the opkssh project 19 | package projectpath 20 | 21 | import ( 22 | "path/filepath" 23 | "runtime" 24 | ) 25 | 26 | // Source: https://stackoverflow.com/a/58294680 27 | var ( 28 | _, b, _, _ = runtime.Caller(0) 29 | 30 | // Root is the root folder of the opkssh project 31 | Root = filepath.Join(filepath.Dir(b), "../..") 32 | ) 33 | -------------------------------------------------------------------------------- /policy/enforcer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package policy 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "log" 23 | "strings" 24 | 25 | "github.com/openpubkey/openpubkey/pktoken" 26 | "github.com/openpubkey/opkssh/policy/plugins" 27 | "golang.org/x/exp/slices" 28 | ) 29 | 30 | // Enforcer evaluates opkssh policy to determine if the desired principal is 31 | // permitted 32 | type Enforcer struct { 33 | PolicyLoader Loader 34 | } 35 | 36 | // type for Identity Token checkedClaims 37 | type checkedClaims struct { 38 | Email string `json:"email"` 39 | Sub string `json:"sub"` 40 | Groups []string `json:"groups"` 41 | } 42 | 43 | // Validates that the server defined identity attribute matches the 44 | // respective claim from the identity token 45 | func validateClaim(claims *checkedClaims, user *User) bool { 46 | if strings.HasPrefix(user.IdentityAttribute, "oidc:groups") { 47 | oidcGroupSections := strings.Split(user.IdentityAttribute, ":") 48 | 49 | return slices.Contains(claims.Groups, oidcGroupSections[len(oidcGroupSections)-1]) 50 | } 51 | 52 | // email should be a case-insensitive check 53 | // sub should be a case-sensitive check 54 | return strings.EqualFold(claims.Email, user.IdentityAttribute) || string(claims.Sub) == user.IdentityAttribute 55 | } 56 | 57 | // CheckPolicy loads opkssh policy and checks to see if there is a policy 58 | // permitting access to principalDesired for the user identified by the PKT's 59 | // email claim. Returns nil if access is granted. Otherwise, an error is 60 | // returned. 61 | // 62 | // It is security critical to verify the pkt first before calling this function. 63 | // This is because if this function is called first, a timing channel exists which 64 | // allows an attacker check what identities and principals are allowed by the policy.F 65 | func (p *Enforcer) CheckPolicy(principalDesired string, pkt *pktoken.PKToken, userInfoJson string, sshCert string, keyType string) error { 66 | pluginPolicy := plugins.NewPolicyPluginEnforcer() 67 | 68 | results, err := pluginPolicy.CheckPolicies("/etc/opk/policy.d", pkt, userInfoJson, principalDesired, sshCert, keyType) 69 | if err != nil { 70 | log.Printf("Error checking policy plugins: %v \n", err) 71 | // Despite the error, we don't fail here because we still want to check 72 | // the standard policy below. Policy plugins can only expand the set of 73 | // allow set, not shrink it. 74 | } else { 75 | for _, result := range results { 76 | commandRunStr := strings.Join(result.CommandRun, " ") 77 | log.Printf("Policy plugin result, path: (%s), allowed: (%t), error: (%v), command_run: (%s), policyOutput: (%s)\n", result.Path, result.Allowed, result.Error, commandRunStr, result.PolicyOutput) 78 | } 79 | if results.Allowed() { 80 | log.Printf("Access granted by policy plugin\n") 81 | return nil 82 | } 83 | } 84 | 85 | policy, source, err := p.PolicyLoader.Load() 86 | if err != nil { 87 | return fmt.Errorf("error loading policy: %w", err) 88 | } 89 | 90 | var claims checkedClaims 91 | 92 | if err := json.Unmarshal(pkt.Payload, &claims); err != nil { 93 | return fmt.Errorf("error unmarshalling pk token payload: %w", err) 94 | } 95 | issuer, err := pkt.Issuer() 96 | if err != nil { 97 | return fmt.Errorf("error getting issuer from pk token: %w", err) 98 | } 99 | 100 | var userInfoClaims *checkedClaims 101 | if userInfoJson != "" { 102 | userInfoClaims = new(checkedClaims) 103 | if err := json.Unmarshal([]byte(userInfoJson), userInfoClaims); err != nil { 104 | return fmt.Errorf("error unmarshalling claims from userinfo endpoint: %w", err) 105 | } 106 | } 107 | 108 | for _, user := range policy.Users { 109 | // The underlying library checks idT.sub == userInfo.sub when we call the userinfo endpoint. 110 | // We want to be extra sure so we also check it here as well. 111 | if userInfoClaims != nil && claims.Sub != userInfoClaims.Sub { 112 | return fmt.Errorf("userInfo sub claim (%s) does not match user policy sub claim (%s)", userInfoClaims.Sub, claims.Sub) 113 | } 114 | 115 | if issuer != user.Issuer { 116 | continue 117 | } 118 | 119 | // if they are, then check if the desired principal is allowed 120 | if !slices.Contains(user.Principals, principalDesired) { 121 | continue 122 | } 123 | 124 | // check each entry to see if the user in the checkedClaims is included 125 | if validateClaim(&claims, &user) { 126 | // access granted 127 | return nil 128 | } 129 | 130 | // check each entry to see if the user matches the userInfoClaims 131 | if userInfoClaims != nil && validateClaim(userInfoClaims, &user) { 132 | // access granted 133 | return nil 134 | } 135 | 136 | } 137 | 138 | return fmt.Errorf("no policy to allow %s with (issuer=%s) to assume %s, check policy config at %s", claims.Email, issuer, principalDesired, source.Source()) 139 | } 140 | -------------------------------------------------------------------------------- /policy/files/configlog.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package files 18 | 19 | import ( 20 | "fmt" 21 | "strings" 22 | "sync" 23 | ) 24 | 25 | type ConfigProblem struct { 26 | Filepath string 27 | OffendingLine string 28 | OffendingLineNumber int 29 | ErrorMessage string 30 | Source string 31 | } 32 | 33 | func (e ConfigProblem) String() string { 34 | return "encountered error: " + e.ErrorMessage + ", reading " + e.OffendingLine + " in " + e.Filepath + " at line " + fmt.Sprint(e.OffendingLineNumber) 35 | } 36 | 37 | type ConfigLog struct { 38 | log []ConfigProblem 39 | logMutex sync.Mutex 40 | } 41 | 42 | func (c *ConfigLog) RecordProblem(entry ConfigProblem) { 43 | c.logMutex.Lock() 44 | defer c.logMutex.Unlock() 45 | c.log = append(c.log, entry) 46 | } 47 | 48 | func (c *ConfigLog) GetProblems() []ConfigProblem { 49 | c.logMutex.Lock() 50 | defer c.logMutex.Unlock() 51 | logCopy := make([]ConfigProblem, len(c.log)) 52 | copy(logCopy, c.log) 53 | return logCopy 54 | } 55 | 56 | func (c *ConfigLog) NoProblems() bool { 57 | c.logMutex.Lock() 58 | defer c.logMutex.Unlock() 59 | return len(c.log) == 0 60 | } 61 | 62 | func (c *ConfigLog) String() string { 63 | // No mutex needed since GetLogs handles the mutex 64 | logs := c.GetProblems() 65 | logsStrings := []string{} 66 | for _, log := range logs { 67 | logsStrings = append(logsStrings, log.String()) 68 | } 69 | return strings.Join(logsStrings, "\n") 70 | } 71 | 72 | func (c *ConfigLog) Clear() { 73 | c.logMutex.Lock() 74 | defer c.logMutex.Unlock() 75 | c.log = []ConfigProblem{} 76 | } 77 | 78 | var ( 79 | singleton *ConfigLog 80 | once sync.Once 81 | ) 82 | 83 | func ConfigProblems() *ConfigLog { 84 | once.Do(func() { 85 | singleton = &ConfigLog{ 86 | log: []ConfigProblem{}, 87 | logMutex: sync.Mutex{}, 88 | } 89 | }) 90 | return singleton 91 | } 92 | -------------------------------------------------------------------------------- /policy/files/configlog_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package files 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | func TestLog(t *testing.T) { 26 | 27 | tests := []struct { 28 | name string 29 | clearWhenDone bool 30 | input *[]ConfigProblem 31 | output string 32 | }{ 33 | { 34 | name: "empty", 35 | clearWhenDone: true, 36 | input: nil, 37 | output: "", 38 | }, 39 | { 40 | name: "single entry", 41 | clearWhenDone: true, 42 | input: &[]ConfigProblem{ 43 | { 44 | Filepath: "/path/to/file", 45 | OffendingLine: "offending line", 46 | OffendingLineNumber: 5, 47 | ErrorMessage: "wrong number of arguments", 48 | Source: "test 1", 49 | }, 50 | }, 51 | output: "encountered error: wrong number of arguments, reading offending line in /path/to/file at line 5", 52 | }, 53 | { 54 | name: "multiple entries", 55 | clearWhenDone: false, 56 | input: &[]ConfigProblem{ 57 | { 58 | Filepath: "/path/to/fileA", 59 | OffendingLine: "offending line 1", 60 | OffendingLineNumber: 77, 61 | ErrorMessage: "wrong number of arguments", 62 | Source: "test 2", 63 | }, 64 | { 65 | Filepath: "/path/to/fileB", 66 | OffendingLine: "offending line 2", 67 | OffendingLineNumber: 2, 68 | ErrorMessage: "could not parse", 69 | Source: "test 3", 70 | }, 71 | }, 72 | 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", 73 | }, 74 | { 75 | name: "make sure that the log persists", 76 | clearWhenDone: true, 77 | input: &[]ConfigProblem{ 78 | { 79 | Filepath: "/path/to/filec", 80 | OffendingLine: "offending line 2", 81 | OffendingLineNumber: 128, 82 | ErrorMessage: "wrong number of arguments", 83 | Source: "test 4", 84 | }, 85 | }, 86 | 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", 87 | }, 88 | { 89 | name: "check clear", 90 | clearWhenDone: true, 91 | input: nil, 92 | output: "", 93 | }, 94 | } 95 | for _, tt := range tests { 96 | t.Run(tt.name, func(t *testing.T) { 97 | configLog := ConfigProblems() 98 | 99 | if tt.input != nil { 100 | for _, entry := range *tt.input { 101 | configLog.RecordProblem(entry) 102 | } 103 | } 104 | assert.Equal(t, tt.output, configLog.String()) 105 | if tt.clearWhenDone { 106 | configLog.Clear() 107 | } 108 | }) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /policy/files/fileloader.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package files 18 | 19 | import ( 20 | "fmt" 21 | "io/fs" 22 | "path/filepath" 23 | 24 | "github.com/spf13/afero" 25 | ) 26 | 27 | // UserPolicyLoader contains methods to read/write the opkssh policy file from/to an 28 | // arbitrary filesystem. All methods that read policy from the filesystem fail 29 | // and return an error immediately if the permission bits are invalid. 30 | type FileLoader struct { 31 | Fs afero.Fs 32 | RequiredPerm fs.FileMode 33 | } 34 | 35 | // CreateIfDoesNotExist creates a file at the given path if it does not exist. 36 | func (l FileLoader) CreateIfDoesNotExist(path string) error { 37 | exists, err := afero.Exists(l.Fs, path) 38 | if err != nil { 39 | return err 40 | } 41 | if !exists { 42 | dirPath := filepath.Dir(path) 43 | if err := l.Fs.MkdirAll(dirPath, 0750); err != nil { 44 | return fmt.Errorf("failed to create directory: %w", err) 45 | } 46 | file, err := l.Fs.Create(path) 47 | if err != nil { 48 | return fmt.Errorf("failed to create file: %w", err) 49 | } 50 | file.Close() 51 | if err := l.Fs.Chmod(path, l.RequiredPerm); err != nil { 52 | return fmt.Errorf("failed to set file permissions: %w", err) 53 | } 54 | } 55 | return nil 56 | } 57 | 58 | // LoadFileAtPath validates that the file at path exists, can be read 59 | // by the current process, and has the correct permission bits set. Parses the 60 | // contents and returns the bytes if file permissions are valid and 61 | // reading is successful; otherwise returns an error. 62 | func (l *FileLoader) LoadFileAtPath(path string) ([]byte, error) { 63 | // Check if file exists and we can access it 64 | if _, err := l.Fs.Stat(path); err != nil { 65 | return nil, fmt.Errorf("failed to describe the file at path: %w", err) 66 | } 67 | 68 | // Validate that file has correct permission bits set 69 | if err := NewPermsChecker(l.Fs).CheckPerm(path, []fs.FileMode{l.RequiredPerm}, "", ""); err != nil { 70 | return nil, fmt.Errorf("policy file has insecure permissions: %w", err) 71 | } 72 | 73 | // Read file contents 74 | afs := &afero.Afero{Fs: l.Fs} 75 | content, err := afs.ReadFile(path) 76 | if err != nil { 77 | return nil, err 78 | } 79 | return content, nil 80 | } 81 | 82 | // Dump writes the bytes in fileBytes to the filepath 83 | func (l *FileLoader) Dump(fileBytes []byte, path string) error { 84 | // Write to disk 85 | if err := afero.WriteFile(l.Fs, path, fileBytes, l.RequiredPerm); err != nil { 86 | return err 87 | } 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /policy/files/permschecker.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package files 18 | 19 | import ( 20 | "fmt" 21 | "io/fs" 22 | "os/exec" 23 | "strings" 24 | 25 | "github.com/spf13/afero" 26 | ) 27 | 28 | // ModeSystemPerms is the expected permission bits that should be set for opkssh 29 | // system policy files (`/etc/opk/auth_id`, `/etc/opk/providers`). This mode means 30 | // that only the owner of the file can write/read to the file, but the group which 31 | // should be opksshuser can read the file. 32 | const ModeSystemPerms = fs.FileMode(0640) 33 | 34 | // ModeHomePerms is the expected permission bits that should be set for opkssh 35 | // user home policy files `~/.opk/auth_id`. 36 | const ModeHomePerms = fs.FileMode(0600) 37 | 38 | // PermsChecker contains methods to check the ownership, group 39 | // and file permissions of a file on a Unix-like system. 40 | type PermsChecker struct { 41 | Fs afero.Fs 42 | CmdRunner func(string, ...string) ([]byte, error) 43 | } 44 | 45 | func NewPermsChecker(fs afero.Fs) *PermsChecker { 46 | return &PermsChecker{Fs: fs, CmdRunner: ExecCmd} 47 | } 48 | 49 | // CheckPerm checks the file at the given path if it has the desired permissions. 50 | // The argument requirePerm is a list to enable the caller to specify multiple 51 | // permissions only one of which needs to match the permissions on the file. 52 | // If the requiredOwner or requiredGroup are not empty then the function will also 53 | // that the owner and group of the file match the requiredOwner and requiredGroup 54 | // specified and fail if they do not. 55 | func (u *PermsChecker) CheckPerm(path string, requirePerm []fs.FileMode, requiredOwner string, requiredGroup string) error { 56 | fileInfo, err := u.Fs.Stat(path) 57 | if err != nil { 58 | return fmt.Errorf("failed to describe the file at path: %w", err) 59 | } 60 | mode := fileInfo.Mode() 61 | 62 | // if the requiredOwner or requiredGroup are specified then run stat and check if they match 63 | if requiredOwner != "" || requiredGroup != "" { 64 | statOutput, err := u.CmdRunner("stat", "-c", "%U %G", path) 65 | if err != nil { 66 | return fmt.Errorf("failed to run stat: %w", err) 67 | } 68 | 69 | statOutputSplit := strings.Split(strings.TrimSpace(string(statOutput)), " ") 70 | statOwner := statOutputSplit[0] 71 | statGroup := statOutputSplit[1] 72 | if len(statOutputSplit) != 2 { 73 | return fmt.Errorf("expected stat command to return 2 values got %d", len(statOutputSplit)) 74 | } 75 | 76 | if requiredOwner != "" { 77 | if requiredOwner != statOwner { 78 | return fmt.Errorf("expected owner (%s), got (%s)", requiredOwner, statOwner) 79 | } 80 | } 81 | if requiredGroup != "" { 82 | if requiredGroup != statGroup { 83 | return fmt.Errorf("expected group (%s), got (%s)", requiredGroup, statGroup) 84 | } 85 | } 86 | } 87 | 88 | permMatch := false 89 | requiredPermString := []string{} 90 | for _, p := range requirePerm { 91 | requiredPermString = append(requiredPermString, fmt.Sprintf("%o", p.Perm())) 92 | if mode.Perm() == p { 93 | permMatch = true 94 | } 95 | } 96 | if !permMatch { 97 | return fmt.Errorf("expected one of the following permissions [%s], got (%o)", strings.Join(requiredPermString, ", "), mode.Perm()) 98 | } 99 | 100 | return nil 101 | } 102 | 103 | func ExecCmd(name string, arg ...string) ([]byte, error) { 104 | cmd := exec.Command(name, arg...) 105 | return cmd.CombinedOutput() 106 | } 107 | -------------------------------------------------------------------------------- /policy/files/permschecker_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package files 18 | 19 | import ( 20 | "fmt" 21 | "io/fs" 22 | "testing" 23 | 24 | "github.com/spf13/afero" 25 | "github.com/stretchr/testify/require" 26 | ) 27 | 28 | func TestPermissionsChecker(t *testing.T) { 29 | tests := []struct { 30 | name string 31 | filePath string 32 | filePathExpected string 33 | perms fs.FileMode 34 | permsExpected fs.FileMode 35 | owner string 36 | ownerExpected string 37 | group string 38 | groupExpected string 39 | cmdError error 40 | errorExpected string 41 | }{ 42 | { 43 | name: "simple happy path (all match)", 44 | filePath: "/test_file", 45 | filePathExpected: "/test_file", 46 | perms: 0777, 47 | permsExpected: 0777, 48 | owner: "testOwner", 49 | ownerExpected: "testOwner", 50 | group: "testGroup", 51 | groupExpected: "testGroup", 52 | cmdError: nil, 53 | errorExpected: "", 54 | }, 55 | { 56 | name: "simple happy path (owner not checked)", 57 | filePath: "/test_file", 58 | filePathExpected: "/test_file", 59 | perms: 0777, 60 | permsExpected: 0777, 61 | owner: "testOwner", 62 | ownerExpected: "", 63 | group: "testGroup", 64 | groupExpected: "testGroup", 65 | cmdError: nil, 66 | errorExpected: "", 67 | }, 68 | { 69 | name: "simple happy path (group not checked)", 70 | filePath: "/test_file", 71 | filePathExpected: "/test_file", 72 | perms: 0777, 73 | permsExpected: 0777, 74 | owner: "testOwner", 75 | ownerExpected: "testOwner", 76 | group: "testGroup", 77 | groupExpected: "", 78 | cmdError: nil, 79 | errorExpected: "", 80 | }, 81 | { 82 | name: "simple happy path (only perm checked)", 83 | filePath: "/test_file", 84 | filePathExpected: "/test_file", 85 | perms: 0777, 86 | permsExpected: 0777, 87 | owner: "testOwner", 88 | ownerExpected: "", 89 | group: "testGroup", 90 | groupExpected: "", 91 | cmdError: nil, 92 | errorExpected: "", 93 | }, 94 | { 95 | name: "error (owner doesn't match)", 96 | filePath: "/test_file", 97 | filePathExpected: "/test_file", 98 | perms: 0777, 99 | permsExpected: 0777, 100 | owner: "testOwner", 101 | ownerExpected: "testDiffOwner", 102 | group: "testGroup", 103 | groupExpected: "", 104 | cmdError: nil, 105 | errorExpected: "expected owner (testDiffOwner), got (testOwner)", 106 | }, 107 | { 108 | name: "error (owner doesn't match)", 109 | filePath: "/test_file", 110 | filePathExpected: "/test_file", 111 | perms: 0777, 112 | permsExpected: 0777, 113 | owner: "testOwner", 114 | ownerExpected: "", 115 | group: "testGroup", 116 | groupExpected: "testDiffGroup", 117 | cmdError: nil, 118 | errorExpected: "expected group (testDiffGroup), got (testGroup)", 119 | }, 120 | { 121 | name: "error (perms don't match)", 122 | filePath: "/test_file", 123 | filePathExpected: "/test_file", 124 | perms: 0640, 125 | permsExpected: 0650, 126 | owner: "testOwner", 127 | ownerExpected: "", 128 | group: "testGroup", 129 | groupExpected: "", 130 | cmdError: nil, 131 | errorExpected: "expected one of the following permissions [650], got (640)", 132 | }, 133 | { 134 | name: "error (stat command error)", 135 | filePath: "/test_file", 136 | filePathExpected: "/test_file", 137 | perms: 0640, 138 | permsExpected: 0650, 139 | owner: "testOwner", 140 | ownerExpected: "testDiffGroup", 141 | group: "testGroup", 142 | groupExpected: "testDiffGroup", 143 | cmdError: fmt.Errorf("stat command error"), 144 | errorExpected: "failed to run stat: stat command error", 145 | }, 146 | } 147 | 148 | for _, tt := range tests { 149 | t.Run(tt.name, func(t *testing.T) { 150 | 151 | mockFs := afero.NewMemMapFs() 152 | permChecker := PermsChecker{ 153 | Fs: mockFs, 154 | CmdRunner: func(name string, arg ...string) ([]byte, error) { 155 | if tt.cmdError != nil { 156 | return nil, tt.cmdError 157 | } 158 | return []byte(tt.owner + " " + tt.group), nil 159 | }, 160 | } 161 | 162 | err := afero.WriteFile(mockFs, tt.filePath, []byte("1234567890"), tt.perms) 163 | require.NoError(t, err) 164 | 165 | err = permChecker.CheckPerm(tt.filePathExpected, []fs.FileMode{tt.permsExpected}, tt.ownerExpected, tt.groupExpected) 166 | if tt.errorExpected != "" { 167 | require.Error(t, err) 168 | require.ErrorContains(t, err, tt.errorExpected) 169 | } else { 170 | require.NoError(t, err) 171 | } 172 | }) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /policy/files/table.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package files 18 | 19 | import ( 20 | "log" 21 | "strings" 22 | 23 | "github.com/kballard/go-shellquote" 24 | ) 25 | 26 | type Table struct { 27 | rows [][]string 28 | } 29 | 30 | func NewTable(content []byte) *Table { 31 | table := [][]string{} 32 | rows := strings.Split(string(content), "\n") 33 | for _, row := range rows { 34 | row := CleanRow(row) 35 | if row == "" { 36 | continue 37 | } 38 | columns, err := shellquote.Split(row) 39 | 40 | if err != nil { 41 | log.Printf("Unable to parse: %s. (%s), skipping...\n", row, err) 42 | continue 43 | } 44 | table = append(table, columns) 45 | } 46 | return &Table{rows: table} 47 | } 48 | 49 | func CleanRow(row string) string { 50 | // Remove comments 51 | rowFixed := strings.Split(row, "#")[0] 52 | // Skip empty rows 53 | rowFixed = strings.TrimSpace(rowFixed) 54 | return rowFixed 55 | } 56 | 57 | func (t *Table) AddRow(row ...string) { 58 | t.rows = append(t.rows, row) 59 | } 60 | 61 | func (t Table) ToString() string { 62 | var sb strings.Builder 63 | for _, row := range t.rows { 64 | sb.WriteString(shellquote.Join(row...) + "\n") 65 | } 66 | return sb.String() 67 | } 68 | 69 | func (t Table) ToBytes() []byte { 70 | return []byte(t.ToString()) 71 | } 72 | 73 | func (t Table) GetRows() [][]string { 74 | return t.rows 75 | } 76 | -------------------------------------------------------------------------------- /policy/files/table_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package files 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | func TestToTable(t *testing.T) { 26 | 27 | tests := []struct { 28 | name string 29 | input string 30 | output [][]string 31 | reverse string 32 | }{ 33 | { 34 | name: "empty", 35 | input: "", 36 | output: [][]string{}, 37 | reverse: "", 38 | }, 39 | { 40 | name: "multiple empty rows", 41 | input: "\n \n\n \n", 42 | output: [][]string{}, 43 | reverse: "", 44 | }, 45 | { 46 | name: "commented out row", 47 | input: "# this is a comment\n", 48 | output: [][]string{}, 49 | reverse: "", 50 | }, 51 | { 52 | name: "field with spaces", 53 | input: "1 \"2 3\" 3\n", 54 | output: [][]string{{"1", "2 3", "3"}}, 55 | reverse: "1 '2 3' 3\n", 56 | }, 57 | { 58 | name: "field with spaces", 59 | input: "1 'oidc:claims[\"https://example.com/my-custom-groups\"].contains(\"group with space and :\")' 3\n", 60 | output: [][]string{{"1", "oidc:claims[\"https://example.com/my-custom-groups\"].contains(\"group with space and :\")", "3"}}, 61 | reverse: "1 'oidc:claims[\"https://example.com/my-custom-groups\"].contains(\"group with space and :\")' 3\n", 62 | }, 63 | { 64 | name: "multiple rows with comment", 65 | input: "1 2 3\n 4 5#comment \n6 7 #comment\n 8", 66 | output: [][]string{{"1", "2", "3"}, {"4", "5"}, {"6", "7"}, {"8"}}, 67 | reverse: "1 2 3\n4 5\n6 7\n8\n", 68 | }, 69 | { 70 | name: "realistic input", 71 | input: "# Issuer Client-ID expiration-policy\n" + 72 | "https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h\n" + 73 | "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h", 74 | output: [][]string{ 75 | {"https://accounts.google.com", "206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com", "24h"}, 76 | {"https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0", "096ce0a3-5e72-4da8-9c86-12924b294a01", "24h"}, 77 | }, 78 | reverse: "https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h\n" + 79 | "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h\n", 80 | }, 81 | } 82 | for _, tt := range tests { 83 | t.Run(tt.name, func(t *testing.T) { 84 | inputBytes := []byte(tt.input) 85 | output := NewTable(inputBytes) 86 | 87 | reverse := output.ToString() 88 | 89 | assert.Equal(t, tt.output, output.GetRows()) 90 | assert.Equal(t, tt.reverse, reverse) 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /policy/multipolicyloader.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package policy 18 | 19 | import ( 20 | "errors" 21 | "fmt" 22 | "log" 23 | "os" 24 | "os/exec" 25 | "strings" 26 | ) 27 | 28 | var _ Loader = &MultiPolicyLoader{ 29 | LoaderScript: ReadWithSudoScript, 30 | } 31 | 32 | // FileSource implements policy.Source by returning a string that is expected to 33 | // be a filepath 34 | type FileSource string 35 | 36 | func (s FileSource) Source() string { 37 | return string(s) 38 | } 39 | 40 | func NewMultiPolicyLoader(username string, loader OptionalLoader) *MultiPolicyLoader { 41 | return &MultiPolicyLoader{ 42 | HomePolicyLoader: NewHomePolicyLoader(), 43 | SystemPolicyLoader: NewSystemPolicyLoader(), 44 | LoaderScript: loader, 45 | Username: username, 46 | } 47 | } 48 | 49 | // MultiPolicyLoader implements policy.Loader by reading both the system default 50 | // policy (root policy) and user policy (~/.opk/auth_id where ~ maps to 51 | // Username's home directory) 52 | type MultiPolicyLoader struct { 53 | HomePolicyLoader *HomePolicyLoader 54 | SystemPolicyLoader *SystemPolicyLoader 55 | LoaderScript OptionalLoader 56 | Username string 57 | } 58 | 59 | func (l *MultiPolicyLoader) Load() (*Policy, Source, error) { 60 | policy := new(Policy) 61 | 62 | // Try to load the root policy 63 | rootPolicy, _, rootPolicyErr := l.SystemPolicyLoader.LoadSystemPolicy() 64 | if rootPolicyErr != nil { 65 | log.Println("warning: failed to load system default policy:", rootPolicyErr) 66 | } 67 | 68 | // Try to load the user policy 69 | userPolicy, userPolicyFilePath, userPolicyErr := l.HomePolicyLoader.LoadHomePolicy(l.Username, true, l.LoaderScript) 70 | if userPolicyErr != nil { 71 | log.Println("warning: failed to load user policy:", userPolicyErr) 72 | } 73 | // Log warning if no error loading, but userPolicy is empty meaning that 74 | // there are no valid entries 75 | if userPolicyErr == nil && len(userPolicy.Users) == 0 { 76 | log.Printf("warning: user policy %s has no valid user entries; an entry is considered valid if it gives %s access.", userPolicyFilePath, l.Username) 77 | } 78 | 79 | // Failed to read both policies. Return multi-error 80 | if rootPolicy == nil && userPolicy == nil { 81 | return nil, EmptySource{}, errors.Join(rootPolicyErr, userPolicyErr) 82 | } 83 | 84 | // TODO-Yuval: Optimize by merging duplicate entries instead of blindly 85 | // appending 86 | readPaths := []string{} 87 | if rootPolicy != nil { 88 | policy.Users = append(policy.Users, rootPolicy.Users...) 89 | readPaths = append(readPaths, SystemDefaultPolicyPath) 90 | } 91 | if userPolicy != nil { 92 | policy.Users = append(policy.Users, userPolicy.Users...) 93 | readPaths = append(readPaths, userPolicyFilePath) 94 | } 95 | 96 | return policy, FileSource(strings.Join(readPaths, ", ")), nil 97 | } 98 | 99 | // ReadWithSudoScript specifies additional way of loading the policy in the 100 | // user's home directory (`~/.opk/auth_id`). This is needed when the 101 | // AuthorizedKeysCommand user does not have privileges to transverse the user's 102 | // home directory. Instead we call run a command which uses special 103 | // sudoers permissions to read the policy file. 104 | // 105 | // Doing this is more secure than simply giving opkssh sudoer access because 106 | // if there was an RCE in opkssh could be triggered an SSH request via 107 | // AuthorizedKeysCommand, the new opkssh process we use to perform the read 108 | // would not be compromised. Thus, the compromised opkssh process could not assume 109 | // full root privileges. 110 | func ReadWithSudoScript(h *HomePolicyLoader, username string) ([]byte, error) { 111 | // opkssh readhome ensures the file is not a symlink and has the permissions/ownership. 112 | // The default path is /usr/local/bin/opkssh 113 | opkBin, err := os.Executable() 114 | if err != nil { 115 | return nil, fmt.Errorf("error getting opkssh executable path: %w", err) 116 | } 117 | cmd := exec.Command("sudo", "-n", opkBin, "readhome", username) 118 | 119 | homePolicyFileBytes, err := cmd.CombinedOutput() 120 | if err != nil { 121 | return nil, fmt.Errorf("error reading %s home policy using command %v got output %v and err %v", username, cmd, string(homePolicyFileBytes), err) 122 | } 123 | return homePolicyFileBytes, nil 124 | } 125 | -------------------------------------------------------------------------------- /policy/plugins/pluginconfig.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package plugins 18 | 19 | // PluginConfig represents the structure of a policy command configuration. 20 | type PluginConfig struct { 21 | Name string `yaml:"name"` 22 | Command string `yaml:"command"` 23 | } 24 | -------------------------------------------------------------------------------- /policy/plugins/tokens.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package plugins 18 | 19 | import ( 20 | "encoding/base64" 21 | "encoding/json" 22 | "fmt" 23 | "strings" 24 | 25 | "github.com/openpubkey/openpubkey/pktoken" 26 | ) 27 | 28 | func PopulatePluginEnvVars(pkt *pktoken.PKToken, userInfoJson string, principal string, sshCert string, keyType string) (map[string]string, error) { 29 | pktCom, err := pkt.Compact() 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | cicClaims, err := pkt.GetCicValues() 35 | if err != nil { 36 | return nil, err 37 | } 38 | upkJwk := cicClaims.PublicKey() 39 | upkJson, err := json.Marshal(upkJwk) 40 | if err != nil { 41 | return nil, err 42 | } 43 | upkB64 := base64.StdEncoding.EncodeToString(upkJson) 44 | 45 | type Claims struct { 46 | Issuer string `json:"iss"` 47 | Sub string `json:"sub"` 48 | Email string `json:"email"` 49 | EmailVerified *bool `json:"email_verified"` 50 | Aud Audience `json:"aud"` 51 | Exp *int64 `json:"exp"` 52 | Nbf *int64 `json:"nbf"` 53 | Iat *int64 `json:"iat"` 54 | Jti string `json:"jti"` 55 | Groups *[]string `json:"groups"` 56 | } 57 | var claims Claims 58 | if err := json.Unmarshal(pkt.Payload, &claims); err != nil { 59 | return nil, fmt.Errorf("error unmarshalling pk token payload: %w", err) 60 | } 61 | 62 | groupsStr := "" 63 | if claims.Groups != nil { 64 | groupsStr = fmt.Sprintf(`["%s"]`, strings.Join(*claims.Groups, `","`)) 65 | } 66 | 67 | emailVerifiedStr := "" 68 | if claims.EmailVerified != nil { 69 | emailVerifiedStr = fmt.Sprintf("%t", *claims.EmailVerified) 70 | } 71 | 72 | expStr := "" 73 | if claims.Exp != nil { 74 | expStr = fmt.Sprintf("%d", *claims.Exp) 75 | } 76 | 77 | nbfStr := "" 78 | if claims.Nbf != nil { 79 | nbfStr = fmt.Sprintf("%d", *claims.Nbf) 80 | } 81 | 82 | iatStr := "" 83 | if claims.Iat != nil { 84 | iatStr = fmt.Sprintf("%d", *claims.Iat) 85 | } 86 | 87 | tokens := map[string]string{ 88 | "OPKSSH_PLUGIN_U": principal, 89 | "OPKSSH_PLUGIN_K": sshCert, 90 | "OPKSSH_PLUGIN_T": keyType, 91 | 92 | "OPKSSH_PLUGIN_ISS": claims.Issuer, 93 | "OPKSSH_PLUGIN_SUB": claims.Sub, 94 | "OPKSSH_PLUGIN_EMAIL": claims.Email, 95 | "OPKSSH_PLUGIN_EMAIL_VERIFIED": emailVerifiedStr, 96 | "OPKSSH_PLUGIN_AUD": string(claims.Aud), 97 | "OPKSSH_PLUGIN_EXP": expStr, 98 | "OPKSSH_PLUGIN_NBF": nbfStr, 99 | "OPKSSH_PLUGIN_IAT": iatStr, 100 | "OPKSSH_PLUGIN_JTI": claims.Jti, 101 | "OPKSSH_PLUGIN_GROUPS": groupsStr, 102 | 103 | "OPKSSH_PLUGIN_PAYLOAD": string(b64(string(pkt.Payload))), // base64-encoded ID Token payload 104 | "OPKSSH_PLUGIN_UPK": string(upkB64), // base64-encoded JWK of the user's public key 105 | "OPKSSH_PLUGIN_PKT": string(pktCom), // compact-encoded PK Token 106 | "OPKSSH_PLUGIN_IDT": string(pkt.OpToken), // base64-encoded ID Token 107 | "OPKSSH_PLUGIN_USERINFO": userInfoJson, // what the userinfo endpoint returned if an access token was supplied (by default this the empty string) 108 | } 109 | 110 | return tokens, nil 111 | } 112 | 113 | type Audience string 114 | 115 | func (a *Audience) UnmarshalJSON(data []byte) error { 116 | var multi []string 117 | if err := json.Unmarshal(data, &multi); err == nil { 118 | *a = Audience(`["` + strings.Join(multi, `","`) + `"]`) 119 | return nil 120 | } 121 | 122 | var single string 123 | if err := json.Unmarshal(data, &single); err == nil { 124 | *a = Audience(single) 125 | return nil 126 | } else { 127 | return err 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /policy/plugins/tokens_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package plugins 18 | 19 | import ( 20 | "context" 21 | "strings" 22 | "testing" 23 | 24 | "github.com/openpubkey/openpubkey/client" 25 | "github.com/openpubkey/openpubkey/pktoken" 26 | "github.com/openpubkey/openpubkey/providers" 27 | "github.com/stretchr/testify/require" 28 | ) 29 | 30 | func CreateMockPKToken(t *testing.T, claims map[string]any) *pktoken.PKToken { 31 | providerOpts := providers.DefaultMockProviderOpts() 32 | op, _, idtTemplate, err := providers.NewMockProvider(providerOpts) 33 | require.NoError(t, err) 34 | 35 | idtTemplate.ExtraClaims = claims 36 | 37 | client, err := client.New(op) 38 | require.NoError(t, err) 39 | 40 | pkt, err := client.Auth(context.Background()) 41 | require.NoError(t, err) 42 | return pkt 43 | } 44 | 45 | func TestNewTokens(t *testing.T) { 46 | userInfoJson := `{"email":"alice@gmail.com","email_verified":true,"family_name":"Example","given_name":"Alice","name":"Alice Example","picture":"https://example.com/me.jpg","sub":"1234"}` 47 | 48 | tests := []struct { 49 | name string 50 | pkt *pktoken.PKToken 51 | userInfoJson string 52 | principal string 53 | sshCert string 54 | keyType string 55 | expectTokens map[string]string 56 | expectErrorString string 57 | }{ 58 | { 59 | name: "Happy path (all tokens)", 60 | pkt: CreateMockPKToken(t, map[string]any{ 61 | "email": "alice@gmail.com", 62 | "email_verified": true, 63 | "sub": "1234", 64 | "iss": "https://accounts.example.com", 65 | "aud": "test_client_id", 66 | "exp": 99999999999, 67 | "nbf": 12345678900, 68 | "iat": 99999999900, 69 | "jti": "abcdefg", 70 | "groups": []string{"admin", "user"}, 71 | }), 72 | userInfoJson: userInfoJson, 73 | principal: "root", 74 | sshCert: b64("SSH certificate"), 75 | keyType: "ssh-rsa", 76 | expectTokens: map[string]string{ 77 | "OPKSSH_PLUGIN_AUD": "test_client_id", 78 | "OPKSSH_PLUGIN_EMAIL": "alice@gmail.com", 79 | "OPKSSH_PLUGIN_EMAIL_VERIFIED": "true", 80 | "OPKSSH_PLUGIN_EXP": "-", 81 | "OPKSSH_PLUGIN_GROUPS": `["admin","user"]`, 82 | "OPKSSH_PLUGIN_IAT": "99999999900", 83 | "OPKSSH_PLUGIN_IDT": "-", 84 | "OPKSSH_PLUGIN_ISS": "https://accounts.example.com", 85 | "OPKSSH_PLUGIN_JTI": "abcdefg", 86 | "OPKSSH_PLUGIN_K": b64("SSH certificate"), 87 | "OPKSSH_PLUGIN_NBF": "12345678900", 88 | "OPKSSH_PLUGIN_PAYLOAD": "-", 89 | "OPKSSH_PLUGIN_PKT": "-", 90 | "OPKSSH_PLUGIN_SUB": "1234", 91 | "OPKSSH_PLUGIN_T": "ssh-rsa", 92 | "OPKSSH_PLUGIN_U": "root", 93 | "OPKSSH_PLUGIN_UPK": "-", 94 | "OPKSSH_PLUGIN_USERINFO": userInfoJson, 95 | }, 96 | }, 97 | { 98 | name: "Happy path (minimal tokens)", 99 | pkt: CreateMockPKToken(t, map[string]any{ 100 | "iat": 99999999900, 101 | }), 102 | principal: "root", 103 | sshCert: b64("SSH certificate"), 104 | keyType: "ssh-rsa", 105 | expectTokens: map[string]string{ 106 | "OPKSSH_PLUGIN_AUD": "test_client_id", 107 | "OPKSSH_PLUGIN_EMAIL": "", 108 | "OPKSSH_PLUGIN_EMAIL_VERIFIED": "", 109 | "OPKSSH_PLUGIN_EXP": "-", 110 | "OPKSSH_PLUGIN_GROUPS": "", 111 | "OPKSSH_PLUGIN_IAT": "99999999900", 112 | "OPKSSH_PLUGIN_IDT": "-", 113 | "OPKSSH_PLUGIN_ISS": "https://accounts.example.com", 114 | "OPKSSH_PLUGIN_JTI": "", 115 | "OPKSSH_PLUGIN_K": b64("SSH certificate"), 116 | "OPKSSH_PLUGIN_NBF": "", 117 | "OPKSSH_PLUGIN_PAYLOAD": "-", 118 | "OPKSSH_PLUGIN_PKT": "-", 119 | "OPKSSH_PLUGIN_SUB": "me", 120 | "OPKSSH_PLUGIN_T": "ssh-rsa", 121 | "OPKSSH_PLUGIN_U": "root", 122 | "OPKSSH_PLUGIN_UPK": "-", 123 | "OPKSSH_PLUGIN_USERINFO": "", 124 | }, 125 | }, 126 | { 127 | name: "Happy path (string list audience)", 128 | pkt: CreateMockPKToken(t, map[string]any{ 129 | "iat": 99999999900, 130 | "aud": []string{"test_client_id", "other_client_id"}, 131 | }), 132 | principal: "root", 133 | sshCert: b64("SSH certificate"), 134 | keyType: "ssh-rsa", 135 | expectTokens: map[string]string{ 136 | "OPKSSH_PLUGIN_AUD": `["test_client_id","other_client_id"]`, 137 | "OPKSSH_PLUGIN_EMAIL": "", 138 | "OPKSSH_PLUGIN_EMAIL_VERIFIED": "", 139 | "OPKSSH_PLUGIN_EXP": "-", 140 | "OPKSSH_PLUGIN_GROUPS": "", 141 | "OPKSSH_PLUGIN_IAT": "99999999900", 142 | "OPKSSH_PLUGIN_IDT": "-", 143 | "OPKSSH_PLUGIN_ISS": "https://accounts.example.com", 144 | "OPKSSH_PLUGIN_JTI": "", 145 | "OPKSSH_PLUGIN_K": b64("SSH certificate"), 146 | "OPKSSH_PLUGIN_NBF": "", 147 | "OPKSSH_PLUGIN_PAYLOAD": "-", 148 | "OPKSSH_PLUGIN_PKT": "-", 149 | "OPKSSH_PLUGIN_SUB": "me", 150 | "OPKSSH_PLUGIN_T": "ssh-rsa", 151 | "OPKSSH_PLUGIN_U": "root", 152 | "OPKSSH_PLUGIN_UPK": "-", 153 | "OPKSSH_PLUGIN_USERINFO": "", 154 | }, 155 | }, 156 | { 157 | name: "Wrong type for email_verified claim in ID token", 158 | pkt: CreateMockPKToken(t, map[string]any{ 159 | "email_verified": 1234, 160 | }), 161 | principal: "root", 162 | sshCert: b64("SSH certificate"), 163 | keyType: "ssh-rsa", 164 | expectErrorString: "error unmarshalling pk token payload", 165 | }, 166 | } 167 | 168 | for _, tt := range tests { 169 | t.Run(tt.name, func(t *testing.T) { 170 | tokens, err := PopulatePluginEnvVars(tt.pkt, tt.userInfoJson, tt.principal, tt.sshCert, tt.keyType) 171 | if tt.expectErrorString != "" { 172 | require.Error(t, err) 173 | require.ErrorContains(t, err, tt.expectErrorString) 174 | } else { 175 | require.NoError(t, err) 176 | require.NotNil(t, tokens) 177 | 178 | // Simple smoke test that these values where set. They are random so we check equality. 179 | require.Equal(t, len(strings.Split(tokens["OPKSSH_PLUGIN_PKT"], ":")), 5) 180 | tokens["OPKSSH_PLUGIN_PKT"] = "-" 181 | 182 | require.Equal(t, len(strings.Split(tokens["OPKSSH_PLUGIN_IDT"], ".")), 3) 183 | tokens["OPKSSH_PLUGIN_IDT"] = "-" 184 | 185 | require.Greater(t, len(tokens["OPKSSH_PLUGIN_UPK"]), 10) 186 | tokens["OPKSSH_PLUGIN_UPK"] = "-" 187 | 188 | require.Greater(t, len(tokens["OPKSSH_PLUGIN_PAYLOAD"]), 10) 189 | tokens["OPKSSH_PLUGIN_PAYLOAD"] = "-" 190 | 191 | require.Greater(t, len(tokens["OPKSSH_PLUGIN_EXP"]), 8) 192 | tokens["OPKSSH_PLUGIN_EXP"] = "-" 193 | 194 | require.Equal(t, tt.expectTokens, tokens) 195 | } 196 | }) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /policy/policy.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package policy 18 | 19 | import ( 20 | "fmt" 21 | "log" 22 | "strings" 23 | 24 | "github.com/openpubkey/opkssh/policy/files" 25 | ) 26 | 27 | // User is an opkssh policy user entry 28 | type User struct { 29 | // IdentityAttribute is a string that is either structured or unstructured. 30 | // Structured: :: 31 | // E.g. `oidc:groups:ssh-users` 32 | // Using the structured identifier allows the capability of constructing 33 | // complex user matchers. 34 | // 35 | // Unstructured: 36 | // This is older version that only works with OIDC Identity Tokens, with 37 | // the claim being `email` or `sub`. The expected value is to be the user's 38 | // email or the user's subscriber ID. The expected value used when comparing 39 | // against an id_token's email claim Subscriber ID is a unique identifier 40 | // for the user at the OpenID Provider 41 | IdentityAttribute string 42 | // Principals is a list of allowed principals 43 | Principals []string 44 | // Sub string 45 | Issuer string 46 | } 47 | 48 | // Policy represents an opkssh policy 49 | type Policy struct { 50 | // Users is a list of all user entries in the policy 51 | Users []User 52 | } 53 | 54 | // FromTable decodes whitespace delimited input into policy.Policy 55 | func FromTable(input []byte, path string) *Policy { 56 | table := files.NewTable(input) 57 | policy := &Policy{} 58 | for i, row := range table.GetRows() { 59 | // Error should not break everyone's ability to login, skip those rows 60 | if len(row) != 3 { 61 | configProblem := files.ConfigProblem{ 62 | Filepath: path, 63 | OffendingLine: strings.Join(row, " "), 64 | OffendingLineNumber: i, 65 | ErrorMessage: fmt.Sprintf("wrong number of arguments (expected=3, got=%d)", len(row)), 66 | Source: "user policy file", 67 | } 68 | files.ConfigProblems().RecordProblem(configProblem) 69 | continue 70 | } 71 | user := User{ 72 | Principals: []string{row[0]}, 73 | IdentityAttribute: row[1], 74 | Issuer: row[2], 75 | } 76 | policy.Users = append(policy.Users, user) 77 | } 78 | return policy 79 | } 80 | 81 | // AddAllowedPrincipal adds a new allowed principal to the user whose email is 82 | // equal to userEmail. If no user can be found with the email userEmail, then a 83 | // new user entry is added with an initial allowed principals list containing 84 | // principal. No changes are made if the principal is already allowed for this 85 | // user. 86 | func (p *Policy) AddAllowedPrincipal(principal string, userEmail string, issuer string) { 87 | userExists := false 88 | if len(p.Users) != 0 { 89 | // search to see if the current user already has an entry in the policy 90 | // file 91 | for i := range p.Users { 92 | user := &p.Users[i] 93 | if user.IdentityAttribute == userEmail && user.Issuer == issuer { 94 | principalExists := false 95 | for _, p := range user.Principals { 96 | // if the principal already exists for this user, then skip 97 | if p == principal { 98 | log.Printf("User with email %s already has access under the principal %s, skipping...\n", userEmail, principal) 99 | principalExists = true 100 | } 101 | } 102 | 103 | if !principalExists { 104 | user.Principals = append(user.Principals, principal) 105 | user.Issuer = issuer 106 | log.Printf("Successfully added user with email %s with principal %s to the policy file\n", userEmail, principal) 107 | } 108 | userExists = true 109 | } 110 | } 111 | } 112 | 113 | // if the policy is empty or if no user found with userEmail, then create a 114 | // new entry 115 | if len(p.Users) == 0 || !userExists { 116 | newUser := User{ 117 | IdentityAttribute: userEmail, 118 | Principals: []string{principal}, 119 | Issuer: issuer, 120 | } 121 | // add the new user to the list of users in the policy 122 | p.Users = append(p.Users, newUser) 123 | } 124 | } 125 | 126 | // ToTable encodes the policy into a whitespace delimited table 127 | func (p *Policy) ToTable() ([]byte, error) { 128 | table := files.Table{} 129 | for _, user := range p.Users { 130 | for _, principal := range user.Principals { 131 | table.AddRow(principal, user.IdentityAttribute, user.Issuer) 132 | } 133 | } 134 | return table.ToBytes(), nil 135 | } 136 | 137 | // Source declares the minimal interface to describe the source of a fetched 138 | // opkssh policy (i.e. where the policy is retrieved from) 139 | type Source interface { 140 | // Source returns a string describing the source of an opkssh policy. The 141 | // returned value is empty if there is no information about its source 142 | Source() string 143 | } 144 | 145 | var _ Source = &EmptySource{} 146 | 147 | // EmptySource implements policy.Source and returns an empty string as the 148 | // source 149 | type EmptySource struct{} 150 | 151 | func (EmptySource) Source() string { return "" } 152 | 153 | // Loader declares the minimal interface to retrieve an opkssh policy from an 154 | // arbitrary source 155 | type Loader interface { 156 | // Load fetches an opkssh policy and returns information describing its 157 | // source. If an error occurs, all return values are nil except the error 158 | // value 159 | Load() (*Policy, Source, error) 160 | } 161 | -------------------------------------------------------------------------------- /policy/policy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package policy_test 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/openpubkey/opkssh/policy" 23 | "github.com/stretchr/testify/assert" 24 | ) 25 | 26 | func TestAddAllowedPrincipal(t *testing.T) { 27 | t.Parallel() 28 | 29 | defaultIssuer := "https://example.com" 30 | 31 | // Test adding an allowed principal to an opkssh policy 32 | tests := []struct { 33 | name string 34 | principal string 35 | userEmail string 36 | initialPolicy *policy.Policy 37 | expectedPolicy *policy.Policy 38 | }{ 39 | { 40 | name: "empty policy", 41 | principal: "test", 42 | userEmail: "alice@example.com", 43 | initialPolicy: &policy.Policy{}, 44 | expectedPolicy: &policy.Policy{ 45 | Users: []policy.User{ 46 | { 47 | IdentityAttribute: "alice@example.com", 48 | Principals: []string{"test"}, 49 | Issuer: "https://example.com", 50 | }, 51 | }, 52 | }, 53 | }, 54 | { 55 | name: "non-empty policy. user not found", 56 | principal: "test", 57 | userEmail: "bob@example.com", 58 | initialPolicy: &policy.Policy{ 59 | Users: []policy.User{ 60 | { 61 | IdentityAttribute: "alice@example.com", 62 | Principals: []string{"test", "test2"}, 63 | Issuer: "https://example.com", 64 | }, 65 | }}, 66 | expectedPolicy: &policy.Policy{ 67 | Users: []policy.User{ 68 | { 69 | IdentityAttribute: "bob@example.com", 70 | Principals: []string{"test"}, 71 | Issuer: "https://example.com", 72 | }, 73 | { 74 | IdentityAttribute: "alice@example.com", 75 | Principals: []string{"test", "test2"}, 76 | Issuer: "https://example.com", 77 | }, 78 | }, 79 | }, 80 | }, 81 | { 82 | name: "user already exists. new principal", 83 | principal: "test3", 84 | userEmail: "alice@example.com", 85 | initialPolicy: &policy.Policy{ 86 | Users: []policy.User{ 87 | { 88 | IdentityAttribute: "alice@example.com", 89 | Principals: []string{"test", "test2"}, 90 | Issuer: "https://example.com", 91 | }, 92 | }}, 93 | expectedPolicy: &policy.Policy{ 94 | Users: []policy.User{ 95 | { 96 | IdentityAttribute: "alice@example.com", 97 | Principals: []string{"test", "test2", "test3"}, 98 | Issuer: "https://example.com", 99 | }, 100 | }, 101 | }, 102 | }, 103 | { 104 | name: "user already exists. principal not new.", 105 | principal: "test", 106 | userEmail: "alice@example.com", 107 | initialPolicy: &policy.Policy{ 108 | Users: []policy.User{ 109 | { 110 | IdentityAttribute: "alice@example.com", 111 | Principals: []string{"test"}, 112 | Issuer: "https://example.com", 113 | }, 114 | }}, 115 | expectedPolicy: &policy.Policy{ 116 | Users: []policy.User{ 117 | { 118 | IdentityAttribute: "alice@example.com", 119 | Principals: []string{"test"}, 120 | Issuer: "https://example.com", 121 | }, 122 | }, 123 | }, 124 | }, 125 | } 126 | for _, tt := range tests { 127 | t.Run(tt.name, func(t *testing.T) { 128 | t.Logf("AddAllowedPrincipal(principal=%s, userEmail=%s)", tt.principal, tt.userEmail) 129 | t.Logf("Initial policy: %#v", tt.initialPolicy) 130 | tt.initialPolicy.AddAllowedPrincipal(tt.principal, tt.userEmail, defaultIssuer) 131 | assert.ElementsMatch(t, tt.expectedPolicy.Users, tt.initialPolicy.Users) 132 | }) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /policy/policyloader.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package policy 18 | 19 | import ( 20 | "fmt" 21 | "os/user" 22 | "path" 23 | "path/filepath" 24 | 25 | "github.com/openpubkey/opkssh/policy/files" 26 | "github.com/spf13/afero" 27 | "golang.org/x/exp/slices" 28 | ) 29 | 30 | // SystemDefaultPolicyPath is the default filepath where opkssh policy is 31 | // defined 32 | var SystemDefaultPolicyPath = filepath.FromSlash("/etc/opk/auth_id") 33 | 34 | // UserLookup defines the minimal interface to lookup users on the current 35 | // system 36 | type UserLookup interface { 37 | Lookup(username string) (*user.User, error) 38 | } 39 | 40 | // OsUserLookup implements the UserLookup interface by invoking the os/user 41 | // library 42 | type OsUserLookup struct{} 43 | 44 | func NewOsUserLookup() UserLookup { 45 | return &OsUserLookup{} 46 | } 47 | func (OsUserLookup) Lookup(username string) (*user.User, error) { return user.Lookup(username) } 48 | 49 | // PolicyLoader contains methods to read/write the opkssh policy file from/to an 50 | // arbitrary filesystem. All methods that read policy from the filesystem fail 51 | // and return an error immediately if the permission bits are invalid. 52 | type PolicyLoader struct { 53 | FileLoader files.FileLoader 54 | UserLookup UserLookup 55 | } 56 | 57 | func (l PolicyLoader) CreateIfDoesNotExist(path string) error { 58 | return l.FileLoader.CreateIfDoesNotExist(path) 59 | } 60 | 61 | // LoadPolicyAtPath validates that the policy file at path exists, can be read 62 | // by the current process, and has the correct permission bits set. Parses the 63 | // contents and returns a policy.Policy if file permissions are valid and 64 | // reading is successful; otherwise returns an error. 65 | func (l *PolicyLoader) LoadPolicyAtPath(path string) (*Policy, error) { 66 | content, err := l.FileLoader.LoadFileAtPath(path) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | policy := FromTable(content, path) 72 | return policy, nil 73 | } 74 | 75 | // Dump encodes the policy into file and writes the contents to the filepath 76 | // path 77 | func (l *PolicyLoader) Dump(policy *Policy, path string) error { 78 | fileBytes, err := policy.ToTable() 79 | if err != nil { 80 | return err 81 | } 82 | 83 | // Write to disk 84 | if err := l.FileLoader.Dump(fileBytes, path); err != nil { 85 | return fmt.Errorf("failed to write to policy file %s: %w", path, err) 86 | } 87 | 88 | return nil 89 | } 90 | 91 | // NewSystemPolicyLoader returns an opkssh policy loader that uses the os library to 92 | // read/write system policy from/to the filesystem. 93 | func NewSystemPolicyLoader() *SystemPolicyLoader { 94 | return &SystemPolicyLoader{ 95 | PolicyLoader: &PolicyLoader{ 96 | FileLoader: files.FileLoader{ 97 | Fs: afero.NewOsFs(), 98 | RequiredPerm: files.ModeSystemPerms, 99 | }, 100 | UserLookup: NewOsUserLookup(), 101 | }, 102 | } 103 | } 104 | 105 | // SystemPolicyLoader contains methods to read/write the system wide opkssh policy file 106 | // from/to a filesystem. All methods that read policy from the filesystem fail 107 | // and return an error immediately if the permission bits are invalid. 108 | type SystemPolicyLoader struct { 109 | *PolicyLoader 110 | } 111 | 112 | // LoadSystemPolicy reads the opkssh policy at SystemDefaultPolicyPath. 113 | // An error is returned if the file cannot be read or if the permissions bits 114 | // are not correct. 115 | func (s *SystemPolicyLoader) LoadSystemPolicy() (*Policy, Source, error) { 116 | policy, err := s.LoadPolicyAtPath(SystemDefaultPolicyPath) 117 | if err != nil { 118 | return nil, EmptySource{}, fmt.Errorf("failed to read system default policy file %s: %w", SystemDefaultPolicyPath, err) 119 | } 120 | return policy, FileSource(SystemDefaultPolicyPath), nil 121 | } 122 | 123 | type OptionalLoader func(h *HomePolicyLoader, username string) ([]byte, error) 124 | 125 | // HomePolicyLoader contains methods to read/write the opkssh policy file stored in 126 | // `~/.opk/ssh` from/to a filesystem. All methods that read policy from the filesystem fail 127 | // and return an error immediately if the permission bits are invalid. 128 | type HomePolicyLoader struct { 129 | *PolicyLoader 130 | } 131 | 132 | // NewHomePolicyLoader returns an opkssh policy loader that uses the os library to 133 | // read/write policy from/to the user's home directory, e.g. `~/.opk/auth_id`, 134 | func NewHomePolicyLoader() *HomePolicyLoader { 135 | return &HomePolicyLoader{ 136 | PolicyLoader: &PolicyLoader{ 137 | FileLoader: files.FileLoader{ 138 | Fs: afero.NewOsFs(), 139 | RequiredPerm: files.ModeHomePerms, 140 | }, 141 | UserLookup: NewOsUserLookup(), 142 | }, 143 | } 144 | } 145 | 146 | // LoadHomePolicy reads the user's opkssh policy at ~/.opk/auth_id (where ~ 147 | // maps to username's home directory) and returns the filepath read. An error is 148 | // returned if the file cannot be read, if the permission bits are not correct, 149 | // or if there is no user with username or has no home directory. 150 | // 151 | // If skipInvalidEntries is true, then invalid user entries are skipped and not 152 | // included in the returned policy. A user policy's entry is considered valid if 153 | // it gives username access. The returned policy is stripped of invalid entries. 154 | // To specify an alternative Loader that will be used if we don't have sufficient 155 | // permissions to read the policy file in the user's home directory, pass the 156 | // alternative loader as the last argument. 157 | func (h *HomePolicyLoader) LoadHomePolicy(username string, skipInvalidEntries bool, optLoader ...OptionalLoader) (*Policy, string, error) { 158 | policyFilePath, err := h.UserPolicyPath(username) 159 | if err != nil { 160 | return nil, "", fmt.Errorf("error getting user policy path for user %s: %w", username, err) 161 | } 162 | 163 | policyBytes, userPolicyErr := h.FileLoader.LoadFileAtPath(policyFilePath) 164 | if userPolicyErr != nil { 165 | if len(optLoader) == 1 { 166 | // Try to read using the optional loader 167 | policyBytes, err = optLoader[0](h, username) 168 | if err != nil { 169 | return nil, "", fmt.Errorf("failed to read user policy file %s: %w", policyFilePath, err) 170 | } 171 | } else if len(optLoader) > 1 { 172 | return nil, "", fmt.Errorf("only one optional loaders allowed, got %d", len(optLoader)) 173 | } else { 174 | return nil, "", fmt.Errorf("failed to read user policy file %s: %w", policyFilePath, userPolicyErr) 175 | } 176 | } 177 | policy := FromTable(policyBytes, policyFilePath) 178 | 179 | if skipInvalidEntries { 180 | // Build valid user policy. Ignore user entries that give access to a 181 | // principal not equal to the username where the policy file was read 182 | // from. 183 | validUserPolicy := new(Policy) 184 | for _, user := range policy.Users { 185 | if slices.Contains(user.Principals, username) { 186 | // Build clean entry that only gives access to username 187 | validUserPolicy.Users = append(validUserPolicy.Users, User{ 188 | IdentityAttribute: user.IdentityAttribute, 189 | Principals: []string{username}, 190 | Issuer: user.Issuer, 191 | }) 192 | } 193 | } 194 | return validUserPolicy, policyFilePath, nil 195 | } else { 196 | // Just return what we read 197 | return policy, policyFilePath, nil 198 | } 199 | } 200 | 201 | // UserPolicyPath returns the path to the user's opkssh policy file at 202 | // ~/.opk/auth_id. 203 | func (h *HomePolicyLoader) UserPolicyPath(username string) (string, error) { 204 | user, err := h.UserLookup.Lookup(username) 205 | if err != nil { 206 | return "", fmt.Errorf("failed to lookup username %s: %w", username, err) 207 | } 208 | userHomeDirectory := user.HomeDir 209 | if userHomeDirectory == "" { 210 | return "", fmt.Errorf("user %s does not have a home directory", username) 211 | } 212 | 213 | policyFilePath := path.Join(userHomeDirectory, ".opk", "auth_id") 214 | return policyFilePath, nil 215 | } 216 | -------------------------------------------------------------------------------- /policy/providerloader.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package policy 18 | 19 | import ( 20 | "fmt" 21 | "strings" 22 | 23 | "github.com/openpubkey/openpubkey/providers" 24 | "github.com/openpubkey/openpubkey/verifier" 25 | "github.com/openpubkey/opkssh/policy/files" 26 | "github.com/spf13/afero" 27 | ) 28 | 29 | type ProvidersRow struct { 30 | Issuer string 31 | ClientID string 32 | ExpirationPolicy string 33 | } 34 | 35 | func (p ProvidersRow) GetExpirationPolicy() (verifier.ExpirationPolicy, error) { 36 | switch p.ExpirationPolicy { 37 | case "24h": 38 | return verifier.ExpirationPolicies.MAX_AGE_24HOURS, nil 39 | case "48h": 40 | return verifier.ExpirationPolicies.MAX_AGE_48HOURS, nil 41 | case "1week": 42 | return verifier.ExpirationPolicies.MAX_AGE_1WEEK, nil 43 | case "oidc": 44 | return verifier.ExpirationPolicies.OIDC, nil 45 | case "oidc_refreshed": 46 | return verifier.ExpirationPolicies.OIDC_REFRESHED, nil 47 | case "never": 48 | return verifier.ExpirationPolicies.NEVER_EXPIRE, nil 49 | default: 50 | return verifier.ExpirationPolicy{}, fmt.Errorf("invalid expiration policy: %s", p.ExpirationPolicy) 51 | } 52 | } 53 | 54 | func (p ProvidersRow) ToString() string { 55 | return p.Issuer + " " + p.ClientID + " " + p.ExpirationPolicy 56 | } 57 | 58 | type ProviderPolicy struct { 59 | rows []ProvidersRow 60 | } 61 | 62 | func (p *ProviderPolicy) AddRow(row ProvidersRow) { 63 | p.rows = append(p.rows, row) 64 | } 65 | 66 | func (p *ProviderPolicy) CreateVerifier() (*verifier.Verifier, error) { 67 | pvs := []verifier.ProviderVerifier{} 68 | var expirationPolicy verifier.ExpirationPolicy 69 | var err error 70 | for _, row := range p.rows { 71 | var provider verifier.ProviderVerifier 72 | // TODO: We should handle this issuer matching in a more generic way 73 | // oidc.local and localhost: are a test issuers 74 | if row.Issuer == "https://accounts.google.com" || 75 | strings.HasPrefix(row.Issuer, "http://oidc.local") || 76 | strings.HasPrefix(row.Issuer, "http://localhost:") { 77 | 78 | opts := providers.GetDefaultGoogleOpOptions() 79 | opts.Issuer = row.Issuer 80 | opts.ClientID = row.ClientID 81 | provider = providers.NewGoogleOpWithOptions(opts) 82 | } else if strings.HasPrefix(row.Issuer, "https://login.microsoftonline.com") { 83 | opts := providers.GetDefaultAzureOpOptions() 84 | opts.Issuer = row.Issuer 85 | opts.ClientID = row.ClientID 86 | provider = providers.NewAzureOpWithOptions(opts) 87 | } else if row.Issuer == "https://gitlab.com" { 88 | opts := providers.GetDefaultGitlabOpOptions() 89 | opts.Issuer = row.Issuer 90 | opts.ClientID = row.ClientID 91 | provider = providers.NewGitlabOpWithOptions(opts) 92 | } else if row.Issuer == "https://token.actions.githubusercontent.com" { 93 | provider = providers.NewGithubOp(row.Issuer, "") 94 | } else { 95 | opts := providers.GetDefaultGoogleOpOptions() 96 | opts.Issuer = row.Issuer 97 | opts.ClientID = row.ClientID 98 | provider = providers.NewGoogleOpWithOptions(opts) 99 | } 100 | 101 | expirationPolicy, err = row.GetExpirationPolicy() 102 | if err != nil { 103 | return nil, err 104 | } 105 | pv := verifier.ProviderVerifierExpires{ 106 | ProviderVerifier: provider, 107 | Expiration: expirationPolicy, 108 | } 109 | pvs = append(pvs, pv) 110 | } 111 | 112 | if len(pvs) == 0 { 113 | return nil, fmt.Errorf("no providers configured") 114 | } 115 | pktVerifier, err := verifier.NewFromMany( 116 | pvs, 117 | verifier.WithExpirationPolicy(expirationPolicy), 118 | ) 119 | if err != nil { 120 | return nil, err 121 | } 122 | return pktVerifier, nil 123 | } 124 | 125 | func (p ProviderPolicy) ToString() string { 126 | var sb strings.Builder 127 | for _, row := range p.rows { 128 | sb.WriteString(row.ToString() + "\n") 129 | } 130 | return sb.String() 131 | } 132 | 133 | type ProvidersFileLoader struct { 134 | files.FileLoader 135 | Path string 136 | } 137 | 138 | func NewProviderFileLoader() *ProvidersFileLoader { 139 | return &ProvidersFileLoader{ 140 | FileLoader: files.FileLoader{ 141 | Fs: afero.NewOsFs(), 142 | RequiredPerm: files.ModeSystemPerms, 143 | }, 144 | } 145 | } 146 | 147 | func (o *ProvidersFileLoader) LoadProviderPolicy(path string) (*ProviderPolicy, error) { 148 | content, err := o.FileLoader.LoadFileAtPath(path) 149 | if err != nil { 150 | return nil, err 151 | } 152 | policy := o.FromTable(content, path) 153 | return policy, nil 154 | } 155 | 156 | // FromTable decodes whitespace delimited input into policy.Policy 157 | func (o ProvidersFileLoader) ToTable(opPolicies ProviderPolicy) files.Table { 158 | table := files.Table{} 159 | for _, opPolicy := range opPolicies.rows { 160 | table.AddRow(opPolicy.Issuer, opPolicy.ClientID, opPolicy.ExpirationPolicy) 161 | } 162 | return table 163 | } 164 | 165 | // FromTable decodes whitespace delimited input into policy.Policy 166 | // Path is passed only for logging purposes 167 | func (o *ProvidersFileLoader) FromTable(input []byte, path string) *ProviderPolicy { 168 | table := files.NewTable(input) 169 | policy := &ProviderPolicy{ 170 | rows: []ProvidersRow{}, 171 | } 172 | for i, row := range table.GetRows() { 173 | // Error should not break everyone's ability to login, skip those rows 174 | if len(row) != 3 { 175 | configProblem := files.ConfigProblem{ 176 | Filepath: path, 177 | OffendingLine: strings.Join(row, " "), 178 | OffendingLineNumber: i, 179 | ErrorMessage: fmt.Sprintf("wrong number of arguments (expected=3, got=%d)", len(row)), 180 | Source: "providers policy file", 181 | } 182 | files.ConfigProblems().RecordProblem(configProblem) 183 | continue 184 | } 185 | policyRow := ProvidersRow{ 186 | Issuer: row[0], 187 | ClientID: row[1], 188 | ExpirationPolicy: row[2], // TODO: Validate this so that we can determine the line number that has the error 189 | } 190 | policy.AddRow(policyRow) 191 | } 192 | return policy 193 | } 194 | -------------------------------------------------------------------------------- /policy/providerloader_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package policy 18 | 19 | // Note: These tests were originally generated by o3-mini and then heavily modified 20 | 21 | import ( 22 | "strings" 23 | "testing" 24 | 25 | "github.com/openpubkey/openpubkey/verifier" 26 | "github.com/stretchr/testify/require" 27 | ) 28 | 29 | // Test for ProvidersPolicyRow.GetExpirationPolicy. 30 | func TestProvidersPolicyRow_GetExpirationPolicy(t *testing.T) { 31 | tests := []struct { 32 | input string 33 | expected verifier.ExpirationPolicy 34 | expectErr bool 35 | }{ 36 | {"24h", verifier.ExpirationPolicies.MAX_AGE_24HOURS, false}, 37 | {"48h", verifier.ExpirationPolicies.MAX_AGE_48HOURS, false}, 38 | {"1week", verifier.ExpirationPolicies.MAX_AGE_1WEEK, false}, 39 | {"oidc", verifier.ExpirationPolicies.OIDC, false}, 40 | {"oidc_refreshed", verifier.ExpirationPolicies.OIDC_REFRESHED, false}, 41 | {"never", verifier.ExpirationPolicies.NEVER_EXPIRE, false}, 42 | {"invalid", verifier.ExpirationPolicy{}, true}, 43 | } 44 | 45 | for _, tc := range tests { 46 | row := ProvidersRow{ExpirationPolicy: tc.input} 47 | res, err := row.GetExpirationPolicy() 48 | if tc.expectErr { 49 | if err == nil { 50 | t.Errorf("expected error for input %s, got nil", tc.input) 51 | } 52 | } else { 53 | if err != nil { 54 | t.Errorf("unexpected error for input %s: %v", tc.input, err) 55 | } 56 | if res != tc.expected { 57 | t.Errorf("for input %s, expected %v, got %v", tc.input, tc.expected, res) 58 | } 59 | } 60 | } 61 | } 62 | 63 | // Test for ProviderPolicy.ToString. 64 | func TestProviderPolicy_ToString(t *testing.T) { 65 | policy := ProviderPolicy{} 66 | policy.AddRow(ProvidersRow{Issuer: "issuer1", ClientID: "client1", ExpirationPolicy: "24h"}) 67 | policy.AddRow(ProvidersRow{Issuer: "issuer2", ClientID: "client2", ExpirationPolicy: "48h"}) 68 | expected := "issuer1 client1 24h\nissuer2 client2 48h\n" 69 | require.Equal(t, expected, policy.ToString()) 70 | } 71 | 72 | // Test ProviderPolicy.CreateVerifier with a valid Google issuer. 73 | func TestProviderPolicy_CreateVerifier_Google(t *testing.T) { 74 | policy := &ProviderPolicy{} 75 | policy.AddRow(ProvidersRow{ 76 | Issuer: "https://accounts.google.com", 77 | ClientID: "test-google", 78 | ExpirationPolicy: "24h", 79 | }) 80 | ver, err := policy.CreateVerifier() 81 | require.NoError(t, err) 82 | require.NotNil(t, ver) 83 | } 84 | 85 | // Test ProviderPolicy.CreateVerifier with a valid Azure issuer. 86 | func TestProviderPolicy_CreateVerifier_Azure(t *testing.T) { 87 | policy := &ProviderPolicy{} 88 | policy.AddRow(ProvidersRow{ 89 | Issuer: "https://login.microsoftonline.com/tenant", 90 | ClientID: "test-azure", 91 | ExpirationPolicy: "48h", 92 | }) 93 | ver, err := policy.CreateVerifier() 94 | require.NoError(t, err) 95 | require.NotNil(t, ver) 96 | } 97 | 98 | func TestProviderPolicy_CreateVerifier_Gitlab(t *testing.T) { 99 | policy := &ProviderPolicy{} 100 | policy.AddRow(ProvidersRow{ 101 | Issuer: "https://gitlab.com", 102 | ClientID: "test-gitlab", 103 | ExpirationPolicy: "24h", 104 | }) 105 | ver, err := policy.CreateVerifier() 106 | require.NoError(t, err) 107 | require.NotNil(t, ver) 108 | } 109 | 110 | // Test ProviderPolicy.CreateVerifier with an invalid expiration policy. 111 | func TestProviderPolicy_CreateVerifier_InvalidExpiration(t *testing.T) { 112 | policy := &ProviderPolicy{} 113 | policy.AddRow(ProvidersRow{ 114 | Issuer: "https://accounts.google.com", 115 | ClientID: "test-google", 116 | ExpirationPolicy: "invalid", 117 | }) 118 | ver, err := policy.CreateVerifier() 119 | require.ErrorContains(t, err, "invalid expiration policy") 120 | require.Nil(t, ver) 121 | } 122 | 123 | // Test ProviderPolicy.CreateVerifier when no providers are configured. 124 | func TestProviderPolicy_CreateVerifier_NoProviders(t *testing.T) { 125 | policy := &ProviderPolicy{} 126 | ver, err := policy.CreateVerifier() 127 | require.ErrorContains(t, err, "no providers configured") 128 | require.Nil(t, ver) 129 | } 130 | 131 | // Test ProvidersFileLoader.FromTable with valid and invalid rows. 132 | func TestProvidersFileLoader_FromTable(t *testing.T) { 133 | // Input with two valid rows and one invalid row. 134 | input := []byte("https://accounts.google.com test-google 24h\n" + 135 | "invalid-line\n" + 136 | "https://login.microsoftonline.com/tenant test-azure 48h\n") 137 | loader := ProvidersFileLoader{} 138 | policy := loader.FromTable(input, "dummy-path") 139 | require.Equal(t, 2, len(policy.rows)) 140 | // Check the first row. 141 | row1 := policy.rows[0] 142 | if row1.Issuer != "https://accounts.google.com" || row1.ClientID != "test-google" || row1.ExpirationPolicy != "24h" { 143 | t.Error("first row does not match expected values") 144 | } 145 | // Check the second row. 146 | row2 := policy.rows[1] 147 | if !strings.HasPrefix(row2.Issuer, "https://login.microsoftonline.com") || 148 | row2.ClientID != "test-azure" || 149 | row2.ExpirationPolicy != "48h" { 150 | t.Error("second row does not match expected values") 151 | } 152 | } 153 | 154 | // Test ProvidersFileLoader.ToTable. 155 | func TestProvidersFileLoader_ToTable(t *testing.T) { 156 | policy := ProviderPolicy{} 157 | policy.AddRow(ProvidersRow{ 158 | Issuer: "issuer1", 159 | ClientID: "client1", 160 | ExpirationPolicy: "24h", 161 | }) 162 | policy.AddRow(ProvidersRow{ 163 | Issuer: "issuer2", 164 | ClientID: "client2", 165 | ExpirationPolicy: "48h", 166 | }) 167 | loader := ProvidersFileLoader{} 168 | table := loader.ToTable(policy) 169 | rows := table.GetRows() 170 | require.Equal(t, 2, len(rows)) 171 | if rows[0][0] != "issuer1" || rows[0][1] != "client1" || rows[0][2] != "24h" { 172 | t.Error("first row in table does not match expected values") 173 | } 174 | if rows[1][0] != "issuer2" || rows[1][1] != "client2" || rows[1][2] != "48h" { 175 | t.Error("second row in table does not match expected values") 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /scripts/installing.md: -------------------------------------------------------------------------------- 1 | 2 | # Installing opkssh 3 | 4 | This document provides a detailed description of how our [install-linux.sh](install-linux.sh) script works and the security protections used. 5 | 6 | If you just want to install opkssh you should run: 7 | 8 | ```bash 9 | wget -qO- "https://raw.githubusercontent.com/openpubkey/opkssh/main/scripts/install-linux.sh" | sudo bash 10 | ``` 11 | 12 | ## Script commands 13 | 14 | Running `./install-linux.sh --help` will show you all available flags. 15 | 16 | `--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. 17 | 18 | `--nosshd-restart` turns off the sshd restart. This is useful in some docker setups where restarting sshd can break docker. 19 | 20 | `--install-from=FILEPATH` allows you to install the opkssh binary from a local file. 21 | This is useful if you want to install a locally built opkssh binary. 22 | 23 | `--install-version=VER` downloads and installs a particular release of opkssh. By default we download and install the latest release of opkssh. 24 | 25 | ## What the script is doing 26 | 27 | **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. 28 | 29 | ```bash 30 | go build 31 | ``` 32 | 33 | **2: Copy opkssh to server.** Copy the opkssh binary you just built in the previous step to the SSH server you want to configure 34 | 35 | ```bash 36 | scp opkssh ${USER}@${HOSTNAME}:~ 37 | ``` 38 | 39 | **3: Install opkssh on server.** SSH to the server 40 | 41 | Create the following file directory structure on the server and move the executable there: 42 | 43 | ```bash 44 | sudo mkdir /etc/opk 45 | sudo sudo mv ~/opkssh /usr/local/bin/opkssh 46 | sudo chown root /usr/local/bin/opkssh 47 | sudo chmod 755 /usr/local/bin/opkssh 48 | ``` 49 | 50 | **3: Setup policy.** 51 | 52 | The file `/etc/opk/providers` configures what the allowed OpenID Connect providers are. 53 | 54 | The default values for `/etc/opk/providers` are: 55 | 56 | ```bash 57 | # Issuer Client-ID expiration-policy 58 | https://accounts.google.com 206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com 24h 59 | https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h 60 | https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h 61 | ``` 62 | 63 | `/etc/opk/providers` requires the following permissions (by default we create all configuration files with the correct permissions): 64 | 65 | ```bash 66 | sudo chown root:opksshuser /etc/opk/providers 67 | sudo chmod 640 /etc/opk/providers 68 | ``` 69 | 70 | The file `/etc/opk/auth_id` controls which users and user identities can access the server using opkssh. 71 | If you do not have root access, you can create a new auth_id file in at ~/auth_id and use that instead. 72 | 73 | ```bash 74 | sudo touch /etc/opk/auth_id 75 | sudo chown root:opksshuser /etc/opk/auth_id 76 | sudo chmod 640 /etc/opk/auth_id 77 | sudo opkssh add {USER} {EMAIL} {ISSUER} 78 | ``` 79 | 80 | **4: Configure sshd to use opkssh.** Check which configuration file is active. 81 | 82 | In most cases the active configuration file will be `/etc/ssh/sshd_config`. 83 | Add the following lines to the configuration file 84 | 85 | ```bash 86 | AuthorizedKeysCommand /usr/local/bin/opkssh verify %u %k %t 87 | AuthorizedKeysCommandUser opksshuser 88 | ``` 89 | 90 | If `/etc/ssh/sshd_config` contains the entry `Include /etc/ssh/sshd_config.d/*.conf`, 91 | add a new configuration file with a lower starting number than other configuration files in ` /etc/ssh/sshd_config.d/`. 92 | 93 | For example, if the file `/etc/ssh/sshd_config.d/20-systemd-userdb.conf` exists, 94 | create `/etc/ssh/sshd_config.d/19-opk-ssh.conf` with the lines above. 95 | 96 | Verify the setting is active with 97 | 98 | ```bash 99 | sudo sshd -T | grep authorizedkeyscommand 100 | ``` 101 | 102 | You should see 103 | 104 | ```bash 105 | authorizedkeyscommand /usr/local/bin/opkssh verify %u %k %t 106 | authorizedkeyscommanduser opksshuser 107 | ``` 108 | 109 | Then create the required AuthorizedKeysCommandUser and group 110 | 111 | ```bash 112 | sudo groupadd --system opksshuser 113 | sudo useradd -r -M -s /sbin/nologin -g opksshuser opksshuser 114 | ``` 115 | 116 | **6: 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`. 117 | 118 | ```bash 119 | "opksshuser ALL=(ALL) NOPASSWD: /usr/local/bin/opkssh readhome *" 120 | ``` 121 | 122 | This config lives in `/etc/sudoers.d/opkssh` and must have the permissions `440` with root being the owner. 123 | 124 | 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. 125 | 126 | **7: Restart sshd.** 127 | 128 | On Ubuntu and Debian Linux: 129 | 130 | ```bash 131 | systemctl restart ssh 132 | ``` 133 | 134 | On Redhat, centos Linux and Arch Linux: 135 | 136 | ```bash 137 | sudo systemctl restart sshd 138 | ``` 139 | -------------------------------------------------------------------------------- /sshcert/sshcert.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | package sshcert 18 | 19 | import ( 20 | "context" 21 | "crypto/rand" 22 | "encoding/json" 23 | "fmt" 24 | "time" 25 | 26 | "github.com/lestrrat-go/jwx/v2/jwk" 27 | "github.com/openpubkey/openpubkey/pktoken" 28 | "github.com/openpubkey/openpubkey/verifier" 29 | "golang.org/x/crypto/ssh" 30 | ) 31 | 32 | type SshCertSmuggler struct { 33 | SshCert *ssh.Certificate 34 | } 35 | 36 | func New(pkt *pktoken.PKToken, accessToken []byte, principals []string) (*SshCertSmuggler, error) { 37 | 38 | // TODO: assumes email exists in ID Token, 39 | // this will break for OPs like Azure that do not have email as a claim 40 | var claims struct { 41 | Email string `json:"email"` 42 | } 43 | if err := json.Unmarshal(pkt.Payload, &claims); err != nil { 44 | return nil, err 45 | } 46 | 47 | pubkeySsh, err := sshPubkeyFromPKT(pkt) 48 | if err != nil { 49 | return nil, err 50 | } 51 | pktCom, err := pkt.Compact() 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | extensions := map[string]string{ 57 | "permit-X11-forwarding": "", 58 | "permit-agent-forwarding": "", 59 | "permit-port-forwarding": "", 60 | "permit-pty": "", 61 | "permit-user-rc": "", 62 | "openpubkey-pkt": string(pktCom), 63 | } 64 | 65 | if accessToken != nil { 66 | extensions["openpubkey-act"] = string(accessToken) 67 | } 68 | 69 | sshSmuggler := SshCertSmuggler{ 70 | SshCert: &ssh.Certificate{ 71 | Key: pubkeySsh, 72 | CertType: ssh.UserCert, 73 | KeyId: claims.Email, 74 | ValidPrincipals: principals, 75 | ValidBefore: ssh.CertTimeInfinity, 76 | Permissions: ssh.Permissions{ 77 | Extensions: extensions, 78 | }, 79 | }, 80 | } 81 | return &sshSmuggler, nil 82 | } 83 | 84 | func NewFromAuthorizedKey(certType string, certB64 string) (*SshCertSmuggler, error) { 85 | if certPubkey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certType + " " + certB64)); err != nil { 86 | return nil, err 87 | } else { 88 | sshCert, ok := certPubkey.(*ssh.Certificate) 89 | if !ok { 90 | return nil, fmt.Errorf("parsed SSH authorized_key is not an SSH certificate") 91 | } 92 | opkcert := &SshCertSmuggler{ 93 | SshCert: sshCert, 94 | } 95 | return opkcert, nil 96 | } 97 | } 98 | 99 | func (s *SshCertSmuggler) SignCert(signerMas ssh.MultiAlgorithmSigner) (*ssh.Certificate, error) { 100 | if err := s.SshCert.SignCert(rand.Reader, signerMas); err != nil { 101 | return nil, err 102 | } 103 | return s.SshCert, nil 104 | } 105 | 106 | func (s *SshCertSmuggler) VerifyCaSig(caPubkey ssh.PublicKey) error { 107 | certCopy := *(s.SshCert) 108 | certCopy.Signature = nil 109 | certBytes := certCopy.Marshal() 110 | certBytes = certBytes[:len(certBytes)-4] // Drops signature length bytes (see crypto.ssh.certs.go) 111 | return caPubkey.Verify(certBytes, s.SshCert.Signature) 112 | } 113 | 114 | func (s *SshCertSmuggler) GetPKToken() (*pktoken.PKToken, error) { 115 | pktCom, ok := s.SshCert.Extensions["openpubkey-pkt"] 116 | if !ok { 117 | return nil, fmt.Errorf("cert is missing required openpubkey-pkt extension") 118 | } 119 | pkt, err := pktoken.NewFromCompact([]byte(pktCom)) 120 | if err != nil { 121 | return nil, fmt.Errorf("openpubkey-pkt extension in cert failed deserialization: %w", err) 122 | } 123 | return pkt, nil 124 | } 125 | 126 | func (s *SshCertSmuggler) GetAccessToken() string { 127 | // Generally we don't expect this to be set, but if it is, we return it 128 | if accessToken, ok := s.SshCert.Extensions["openpubkey-act"]; ok { 129 | return accessToken 130 | } 131 | return "" 132 | } 133 | 134 | func (s *SshCertSmuggler) VerifySshPktCert(ctx context.Context, pktVerifier verifier.Verifier) (*pktoken.PKToken, error) { 135 | pkt, err := s.GetPKToken() 136 | if err != nil { 137 | return nil, fmt.Errorf("openpubkey-pkt extension in cert failed deserialization: %w", err) 138 | } 139 | 140 | ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second) 141 | defer cancel() 142 | err = pktVerifier.VerifyPKToken(ctxWithTimeout, pkt) 143 | if err != nil { 144 | return nil, err 145 | } 146 | 147 | cic, err := pkt.GetCicValues() 148 | if err != nil { 149 | return nil, err 150 | } 151 | upk := cic.PublicKey() 152 | 153 | cryptoCertKey := (s.SshCert.Key.(ssh.CryptoPublicKey)).CryptoPublicKey() 154 | jwkCertKey, err := jwk.FromRaw(cryptoCertKey) 155 | if err != nil { 156 | return nil, err 157 | } 158 | 159 | if jwk.Equal(jwkCertKey, upk) { 160 | return pkt, nil 161 | } else { 162 | return nil, fmt.Errorf("public key 'upk' in PK Token does not match public key in certificate") 163 | } 164 | } 165 | 166 | func sshPubkeyFromPKT(pkt *pktoken.PKToken) (ssh.PublicKey, error) { 167 | cic, err := pkt.GetCicValues() 168 | if err != nil { 169 | return nil, err 170 | } 171 | upk := cic.PublicKey() 172 | 173 | var rawkey any 174 | if err := upk.Raw(&rawkey); err != nil { 175 | return nil, err 176 | } 177 | return ssh.NewPublicKey(rawkey) 178 | } 179 | -------------------------------------------------------------------------------- /test/integration/integration.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | //go:build integration 18 | 19 | // Package integration contains integration tests. 20 | // 21 | // These tests test opkssh e2e using external dependencies. 22 | package integration 23 | 24 | import ( 25 | "context" 26 | "errors" 27 | "fmt" 28 | "net" 29 | "net/http" 30 | "os" 31 | "path/filepath" 32 | "strings" 33 | "time" 34 | 35 | "github.com/testcontainers/testcontainers-go" 36 | "golang.org/x/crypto/ssh" 37 | ) 38 | 39 | const ( 40 | // LoginCallbackServerTimeout is the amount of time to wait for the opkssh 41 | // login callback server to startup 42 | LoginCallbackServerTimeout = 5 * time.Second 43 | ) 44 | 45 | // TestLogConsumer consumes log messages outputted by Docker containers spawned 46 | // by testcontainers-go. 47 | type TestLogConsumer struct { 48 | Msgs []string 49 | } 50 | 51 | // NewTestLogConsumer returns a new TestLogConsumer. 52 | func NewTestLogConsumer() *TestLogConsumer { 53 | return &TestLogConsumer{ 54 | Msgs: []string{}, 55 | } 56 | } 57 | 58 | // Accept appends the log message to an internal buffer. 59 | func (g *TestLogConsumer) Accept(l testcontainers.Log) { 60 | g.Msgs = append(g.Msgs, string(l.Content)) 61 | } 62 | 63 | // Dump returns all collected log messages from stdout or stderr 64 | func (g *TestLogConsumer) Dump() string { 65 | return strings.Join(g.Msgs, "") 66 | } 67 | 68 | // WaitForServer waits for an HTTP server running at url to start within the 69 | // supplied timeout. 70 | func WaitForServer(ctx context.Context, url string, timeout time.Duration) error { 71 | ch := make(chan error) 72 | // Create context that cancels after specified timeout 73 | ctx, cancel := context.WithTimeout(ctx, timeout) 74 | defer cancel() 75 | go func() { 76 | for { 77 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 78 | if err != nil { 79 | ch <- err 80 | return 81 | } 82 | 83 | _, err = http.DefaultClient.Do(req) 84 | if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { 85 | return 86 | } 87 | if err == nil { 88 | ch <- nil 89 | return 90 | } 91 | time.Sleep(10 * time.Millisecond) 92 | } 93 | }() 94 | 95 | // Wait for response or timeout 96 | select { 97 | case err := <-ch: 98 | return err 99 | case <-ctx.Done(): 100 | return ctx.Err() 101 | } 102 | } 103 | 104 | // GetOPKSshKey tries to find a valid OPK SSH key at one of the expected 105 | // locations. If found, the parsed public SSH key and path to its secret key is 106 | // returned. Otherwise, an error is returned if no valid OPK SSH key could be 107 | // found. 108 | func GetOPKSshKey(seckeyPath string) (ssh.PublicKey, string, error) { 109 | var expectedSSHSecKeyFilePaths []string 110 | var sshPath string 111 | 112 | if seckeyPath != "" { 113 | sshPath = filepath.Dir(seckeyPath) 114 | expectedSSHSecKeyFilePaths = []string{filepath.Base(seckeyPath)} 115 | 116 | } else { 117 | // Get user's SSH path 118 | homePath, err := os.UserHomeDir() 119 | if err != nil { 120 | return nil, "", fmt.Errorf("failed to get user's home directory: %w", err) 121 | } 122 | sshPath = filepath.Join(homePath, ".ssh") 123 | 124 | // Find a valid OPK SSH key at one of the expected locations 125 | expectedSSHSecKeyFilePaths = []string{"id_ecdsa", "id_dsa"} 126 | } 127 | 128 | var pubKey ssh.PublicKey 129 | var secKeyFilePath string 130 | for _, secKeyFilePath = range expectedSSHSecKeyFilePaths { 131 | secKeyFilePath = filepath.Join(sshPath, secKeyFilePath) 132 | 133 | // Read public key. Expected public key has suffix ".pub" 134 | pubKeyFilePath := secKeyFilePath + ".pub" 135 | sshPubKey, err := os.ReadFile(pubKeyFilePath) 136 | if err != nil { 137 | continue 138 | } 139 | 140 | // Parse the public key and check that it is an openpubkey SSH cert 141 | parsedPubKey, comment, _, _, err := ssh.ParseAuthorizedKey(sshPubKey) 142 | if err != nil { 143 | continue 144 | } 145 | 146 | // Check if it's an OPK ssh key 147 | if comment == "openpubkey" { 148 | pubKey = parsedPubKey 149 | break 150 | } 151 | } 152 | 153 | // Check to see if we find at least one OPK SSH key 154 | if pubKey == nil { 155 | return nil, "", fmt.Errorf("failed to find valid OPK public SSH key") 156 | } 157 | 158 | // Check private SSH key file exists 159 | if _, err := os.Stat(secKeyFilePath); err == nil { 160 | return pubKey, secKeyFilePath, nil 161 | } else if errors.Is(err, os.ErrNotExist) { 162 | return nil, "", fmt.Errorf("failed to find corresponding OPK private SSH key at path %s: %w", secKeyFilePath, err) 163 | } else { 164 | return nil, "", err 165 | } 166 | } 167 | 168 | // GetAvailablePort finds and returns an available TCP port to bind to on 169 | // localhost. There is no guarantee the port remains available after this 170 | // function returns. 171 | func GetAvailablePort() (int, error) { 172 | l, err := net.Listen("tcp", "127.0.0.1:0") 173 | if err != nil { 174 | return 0, fmt.Errorf("failed to find available port: %w", err) 175 | } 176 | defer l.Close() 177 | return l.Addr().(*net.TCPAddr).Port, nil 178 | } 179 | 180 | // TryFunc runs f every second until f returns nil or the context is cancelled. 181 | // Returns the last error returned by f; otherwise, if f never had a chance to 182 | // run (due the context being cancelled before running f at least once), then 183 | // the context's error is returned instead. 184 | func TryFunc(ctx context.Context, f func() error) error { 185 | ticker := time.NewTicker(time.Second) 186 | defer ticker.Stop() 187 | 188 | var err error 189 | for { 190 | select { 191 | case <-ctx.Done(): 192 | if err == nil { 193 | return ctx.Err() 194 | } 195 | return err 196 | case <-ticker.C: 197 | // Save error 198 | err = f() 199 | if err == nil { 200 | return nil 201 | } 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /test/integration/login_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | //go:build integration 18 | 19 | package integration 20 | 21 | import ( 22 | "context" 23 | "crypto/rand" 24 | "fmt" 25 | "os" 26 | "path/filepath" 27 | "testing" 28 | "time" 29 | 30 | "github.com/openpubkey/opkssh/commands" 31 | "github.com/spf13/afero" 32 | 33 | "github.com/stretchr/testify/require" 34 | "golang.org/x/crypto/ssh" 35 | ) 36 | 37 | func TestLogin(t *testing.T) { 38 | // Check that user can login and that valid openpubkey keys are written to 39 | // the correct places on disk 40 | 41 | // Setup fake OIDC server on localhost 42 | t.Log("------- setup OIDC server on localhost ------") 43 | opServer, err := NewFakeOpServer() 44 | require.NoError(t, err, "failed to create fake OIDC server") 45 | defer opServer.Close() 46 | t.Logf("OP server running at %s", opServer.URL) 47 | 48 | // Call login 49 | t.Log("------- call login cmd ------") 50 | errCh := make(chan error) 51 | opkProvider, loginURL, err := opServer.OpkProvider() 52 | require.NoError(t, err, "failed to create OPK provider") 53 | go func() { 54 | loginCmd := commands.LoginCmd{Fs: afero.NewOsFs()} 55 | err := loginCmd.Login(TestCtx, opkProvider, false, "") 56 | errCh <- err 57 | }() 58 | 59 | // Wait for auth callback server on localhost to come up. It should come up 60 | // when login command is called 61 | timeoutErr := WaitForServer(TestCtx, fmt.Sprintf("%s://%s", loginURL.Scheme, loginURL.Host), LoginCallbackServerTimeout) 62 | require.NoError(t, timeoutErr, "login callback server took too long to startup") 63 | 64 | // Do OIDC login 65 | DoOidcInteractiveLogin(t, nil, loginURL.String(), "test-user@localhost", "verysecure") 66 | 67 | // Wait for interactive login to complete and assert no error occurred 68 | timeoutCtx, cancel := context.WithTimeout(TestCtx, 3*time.Second) 69 | defer cancel() 70 | select { 71 | case loginErr := <-errCh: 72 | require.NoError(t, loginErr, "failed login") 73 | case <-timeoutCtx.Done(): 74 | t.Fatal(timeoutCtx.Err()) 75 | } 76 | 77 | // Expect to find OPK SSH key is written to disk 78 | pubKey, secKeyFilePath, err := GetOPKSshKey("") 79 | require.NoError(t, err) 80 | require.Equal(t, ssh.CertAlgoECDSA256v01, pubKey.Type(), "expected SSH public key to be an ecdsa-sha2-nistp256 certificate") 81 | 82 | // Parse the private key and check that it is the private key for the public 83 | // key above by signing and verifying a message 84 | secKeyBytes, err := os.ReadFile(secKeyFilePath) 85 | require.NoErrorf(t, err, "failed to read SSH secret key at expected path %s", secKeyFilePath) 86 | secKey, err := ssh.ParsePrivateKey(secKeyBytes) 87 | require.NoError(t, err, "failed to parse SSH private key") 88 | msg := []byte("test") 89 | sig, err := secKey.Sign(rand.Reader, msg) 90 | require.NoError(t, err, "failed to sign message using parsed SSH private key") 91 | require.NoError(t, pubKey.Verify(msg, sig), "failed to verify message using parsed OPK SSH public key") 92 | } 93 | 94 | func TestLoginCustomKeyPath(t *testing.T) { 95 | // Check that user can login and that valid openpubkey keys are written to 96 | // the correct places on disk 97 | 98 | // Setup fake OIDC server on localhost 99 | t.Log("------- setup OIDC server on localhost ------") 100 | opServer, err := NewFakeOpServer() 101 | require.NoError(t, err, "failed to create fake OIDC server") 102 | defer opServer.Close() 103 | t.Logf("OP server running at %s", opServer.URL) 104 | 105 | // Call login 106 | t.Log("------- call login cmd ------") 107 | errCh := make(chan error) 108 | opkProvider, loginURL, err := opServer.OpkProvider() 109 | require.NoError(t, err, "failed to create OPK provider") 110 | 111 | homePath, err := os.UserHomeDir() 112 | 113 | require.NoError(t, err) 114 | 115 | sshPath := filepath.Join(homePath, ".ssh") 116 | 117 | // Make ~/.ssh if folder does not exist 118 | err = os.MkdirAll(sshPath, os.ModePerm) 119 | require.NoError(t, err) 120 | 121 | seckeyPath := filepath.Join(sshPath, "opkssh-key") 122 | 123 | go func() { 124 | loginCmd := commands.LoginCmd{Fs: afero.NewOsFs()} 125 | err := loginCmd.Login(TestCtx, opkProvider, false, seckeyPath) 126 | errCh <- err 127 | }() 128 | 129 | // Wait for auth callback server on localhost to come up. It should come up 130 | // when login command is called 131 | timeoutErr := WaitForServer(TestCtx, fmt.Sprintf("%s://%s", loginURL.Scheme, loginURL.Host), LoginCallbackServerTimeout) 132 | require.NoError(t, timeoutErr, "login callback server took too long to startup") 133 | 134 | // Do OIDC login 135 | DoOidcInteractiveLogin(t, nil, loginURL.String(), "test-user@localhost", "verysecure") 136 | 137 | // Wait for interactive login to complete and assert no error occurred 138 | timeoutCtx, cancel := context.WithTimeout(TestCtx, 3*time.Second) 139 | defer cancel() 140 | select { 141 | case loginErr := <-errCh: 142 | require.NoError(t, loginErr, "failed login") 143 | case <-timeoutCtx.Done(): 144 | t.Fatal(timeoutCtx.Err()) 145 | } 146 | 147 | // Expect to find OPK SSH key is written to disk 148 | pubKey, secKeyFilePath, err := GetOPKSshKey(seckeyPath) 149 | require.NoError(t, err) 150 | require.Equal(t, ssh.CertAlgoECDSA256v01, pubKey.Type(), "expected SSH public key to be an ecdsa-sha2-nistp256 certificate") 151 | 152 | // Parse the private key and check that it is the private key for the public 153 | // key above by signing and verifying a message 154 | secKeyBytes, err := os.ReadFile(secKeyFilePath) 155 | require.NoErrorf(t, err, "failed to read SSH secret key at expected path %s", secKeyFilePath) 156 | secKey, err := ssh.ParsePrivateKey(secKeyBytes) 157 | require.NoError(t, err, "failed to parse SSH private key") 158 | msg := []byte("test") 159 | sig, err := secKey.Sign(rand.Reader, msg) 160 | require.NoError(t, err, "failed to sign message using parsed SSH private key") 161 | require.NoError(t, pubKey.Verify(msg, sig), "failed to verify message using parsed OPK SSH public key") 162 | } 163 | -------------------------------------------------------------------------------- /test/integration/opkssh_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | //go:build integration 18 | 19 | package integration 20 | 21 | import ( 22 | "context" 23 | "os" 24 | "os/signal" 25 | "testing" 26 | ) 27 | 28 | // TestCtx is marked done when the `go test` binary receives an interrupt signal 29 | // or after all tests in the integration package have finished running 30 | var TestCtx context.Context 31 | 32 | func TestMain(m *testing.M) { 33 | os.Exit(func() int { 34 | // Do init stuff before all integration tests. defers in this func are 35 | // called after all the integration tests are complete. 36 | 37 | // Setup global integration CTX that accepts signal interrupt 38 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 39 | defer cancel() 40 | TestCtx = ctx 41 | 42 | return m.Run() 43 | }()) 44 | } 45 | -------------------------------------------------------------------------------- /test/integration/policy-plugins/plugin-cmd.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if [ "${OPKSSH_PLUGIN_U}" = "root" ] && [ "${OPKSSH_PLUGIN_EMAIL}" = "test-user2@zitadel.ch" ]; then 4 | echo "allow" 5 | else 6 | echo "deny" 7 | fi -------------------------------------------------------------------------------- /test/integration/policy-plugins/plugin-simple.yml: -------------------------------------------------------------------------------- 1 | name: integration test policy plugin 2 | command: /tmp/plugin-cmd.sh -------------------------------------------------------------------------------- /test/integration/provider/exampleop.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 2 | 3 | ENV AUTH_CALLBACK_PATH "" 4 | ENV REDIRECT_PORT "" 5 | ENV PORT "" 6 | 7 | # Expose OIDC server so we can access it in the tests 8 | EXPOSE $PORT 9 | 10 | WORKDIR /app 11 | 12 | RUN git clone --branch test https://github.com/openpubkey/oidc.git 13 | 14 | WORKDIR /app/oidc/ 15 | 16 | RUN go mod download 17 | 18 | RUN go build -o /server -v ./example/server/dynamic 19 | 20 | # Start example OIDC server on container startup 21 | CMD ["sh", "-c", "AUTH_CALLBACK_PATH=${AUTH_CALLBACK_PATH} REDIRECT_PORT=${REDIRECT_PORT} PORT=${PORT} /server"] 22 | -------------------------------------------------------------------------------- /test/integration/provider/provider.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | //go:build integration 18 | 19 | package provider 20 | 21 | import ( 22 | "context" 23 | "fmt" 24 | "net/http" 25 | "path/filepath" 26 | "runtime" 27 | "time" 28 | 29 | "github.com/openpubkey/opkssh/internal/projectpath" 30 | 31 | "github.com/docker/go-connections/nat" 32 | "github.com/testcontainers/testcontainers-go" 33 | "github.com/testcontainers/testcontainers-go/wait" 34 | ) 35 | 36 | type ExampleOpContainer struct { 37 | testcontainers.Container 38 | Host string 39 | Port int 40 | } 41 | 42 | func RunExampleOpContainer(ctx context.Context, networkName string, env map[string]string, port string) (*ExampleOpContainer, error) { 43 | issuerServerPort := fmt.Sprintf("%s/tcp", port) 44 | req := testcontainers.ContainerRequest{ 45 | FromDockerfile: testcontainers.FromDockerfile{ 46 | Context: projectpath.Root, 47 | Dockerfile: filepath.Join("test", "integration", "provider", "exampleop.Dockerfile"), 48 | PrintBuildLog: true, 49 | KeepImage: true, 50 | }, 51 | Env: env, 52 | ExposedPorts: []string{issuerServerPort}, 53 | Networks: []string{ 54 | networkName, 55 | }, 56 | ImagePlatform: "linux/" + runtime.GOARCH, 57 | WaitingFor: wait.ForHTTP("/.well-known/openid-configuration"). 58 | WithPort(nat.Port(issuerServerPort)). 59 | WithStartupTimeout(10 * time.Second). 60 | WithMethod(http.MethodGet), 61 | } 62 | container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ 63 | ContainerRequest: req, 64 | Started: true, 65 | }) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | mappedPort, err := container.MappedPort(ctx, nat.Port(issuerServerPort)) 71 | if err != nil { 72 | return nil, err 73 | } 74 | hostIP, err := container.Host(ctx) 75 | if err != nil { 76 | return nil, err 77 | } 78 | return &ExampleOpContainer{ 79 | Container: container, 80 | Host: hostIP, 81 | Port: mappedPort.Int(), 82 | }, nil 83 | } 84 | -------------------------------------------------------------------------------- /test/integration/ssh_server/arch_opkssh.Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the Go binary 2 | FROM golang:1.23 as builder 3 | 4 | # Set destination for COPY 5 | WORKDIR /app 6 | 7 | # Download Go modules 8 | COPY go.mod go.sum ./ 9 | RUN go mod download 10 | 11 | # Copy our repo 12 | COPY . ./ 13 | 14 | # Copy the source code and build the binary 15 | ARG ISSUER_PORT="9998" 16 | RUN go build -v -o opksshbuild 17 | 18 | # Stage 2: Create a minimal ArchLinux-based image 19 | FROM quay.io/archlinux/archlinux 20 | # Install dependencies required for runtime (e.g., SSH server) 21 | RUN pacman -Syu --noconfirm && \ 22 | pacman -Sy openssh inetutils wget jq sudo --noconfirm && \ 23 | pacman -Scc --noconfirm 24 | 25 | 26 | # Source: 27 | # https://medium.com/@ratnesh4209211786/simplified-ssh-server-setup-within-a-docker-container-77eedd87a320 28 | # 29 | # Create an SSH user named "test". Make it a sudoer 30 | 31 | # Create the sudoers.d directory if it doesn't exist 32 | RUN mkdir -p /etc/sudoers.d 33 | 34 | # Create an SSH user named "test" and make it a sudoer 35 | RUN useradd -m -d /home/test -s /bin/bash -g root -G wheel -u 1000 test 36 | 37 | # Set password for "test" user to "test" 38 | RUN echo "test:test" | chpasswd 39 | 40 | # Make it so "test" user does not need to present password when using sudo 41 | RUN echo "test ALL=(ALL:ALL) NOPASSWD: ALL" | tee /etc/sudoers.d/test 42 | 43 | # Create unprivileged user named "test2" 44 | RUN useradd -rm -d /home/test2 -s /bin/bash -u 1001 test2 45 | # Set password to "test" 46 | RUN echo "test2:test" | chpasswd 47 | 48 | # Allow SSH access 49 | RUN mkdir /var/run/sshd 50 | 51 | # Expose SSH server so we can ssh in from the tests 52 | EXPOSE 22 53 | 54 | WORKDIR /app 55 | 56 | # Copy binary and install script from builder 57 | COPY --from=builder /app/opksshbuild ./opksshbuild 58 | COPY --from=builder /app/scripts/install-linux.sh install-linux.sh 59 | 60 | # Run install script to install/configure opkssh 61 | RUN chmod +x install-linux.sh 62 | RUN bash ./install-linux.sh --install-from=opksshbuild --no-sshd-restart 63 | 64 | RUN opkssh --version 65 | RUN ls -l /usr/local/bin 66 | RUN printenv PATH 67 | 68 | ARG ISSUER_PORT="9998" 69 | RUN echo "http://oidc.local:${ISSUER_PORT}/ web oidc_refreshed" >> /etc/opk/providers 70 | 71 | # Add integration test user as allowed email in policy (this directly tests 72 | # policy "add" command) 73 | ARG BOOTSTRAP_POLICY 74 | 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 75 | 76 | # Generate SSH host keys 77 | RUN ssh-keygen -A 78 | 79 | # Start the SSH server on container startup 80 | CMD ["/usr/sbin/sshd", "-D"] -------------------------------------------------------------------------------- /test/integration/ssh_server/centos_opkssh.Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the Go binary 2 | FROM golang:1.23 as builder 3 | 4 | # Set destination for COPY 5 | WORKDIR /app 6 | 7 | # Download Go modules 8 | COPY go.mod go.sum ./ 9 | RUN go mod download 10 | 11 | # Copy our repo 12 | COPY . ./ 13 | 14 | # Copy the source code and build the binary 15 | ARG ISSUER_PORT="9998" 16 | RUN go build -v -o opksshbuild 17 | 18 | # Stage 2: Create a minimal CentOS-based image 19 | FROM quay.io/centos/centos:stream9 20 | # Install dependencies required for runtime (e.g., SSH server) 21 | RUN dnf update -y && \ 22 | dnf install -y sudo openssh-server openssh-clients telnet wget jq && \ 23 | dnf clean all 24 | 25 | 26 | # Source: 27 | # https://medium.com/@ratnesh4209211786/simplified-ssh-server-setup-within-a-docker-container-77eedd87a320 28 | # 29 | # Create an SSH user named "test". Make it a sudoer 30 | RUN useradd -rm -d /home/test -s /bin/bash -g root -G wheel -u 1000 test 31 | # Set password to "test" 32 | RUN echo "test:test" | chpasswd 33 | 34 | # Make it so "test" user does not need to present password when using sudo 35 | # Source: https://askubuntu.com/a/878705 36 | RUN echo "test ALL=(ALL:ALL) NOPASSWD: ALL" | tee /etc/sudoers.d/test 37 | 38 | # Create unprivileged user named "test2" 39 | RUN useradd -rm -d /home/test2 -s /bin/bash -u 1001 test2 40 | # Set password to "test" 41 | RUN echo "test2:test" | chpasswd 42 | 43 | # Allow SSH access 44 | RUN mkdir /var/run/sshd 45 | 46 | # Expose SSH server so we can ssh in from the tests 47 | EXPOSE 22 48 | 49 | WORKDIR /app 50 | 51 | # Copy binary and install script from builder 52 | COPY --from=builder /app/opksshbuild ./opksshbuild 53 | COPY --from=builder /app/scripts/install-linux.sh install-linux.sh 54 | 55 | # Run install script to install/configure opkssh 56 | RUN chmod +x install-linux.sh 57 | RUN bash ./install-linux.sh --install-from=opksshbuild --no-sshd-restart 58 | 59 | RUN opkssh --version 60 | RUN ls -l /usr/local/bin 61 | RUN printenv PATH 62 | 63 | ARG ISSUER_PORT="9998" 64 | RUN echo "http://oidc.local:${ISSUER_PORT}/ web oidc_refreshed" >> /etc/opk/providers 65 | 66 | # Add integration test user as allowed email in policy (this directly tests 67 | # policy "add" command) 68 | ARG BOOTSTRAP_POLICY 69 | 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 70 | 71 | # Generate SSH host keys 72 | RUN ssh-keygen -A 73 | 74 | # Start the SSH server on container startup 75 | CMD ["/usr/sbin/sshd", "-D"] -------------------------------------------------------------------------------- /test/integration/ssh_server/debian_opkssh.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 2 | 3 | # Update/Upgrade 4 | RUN apt-get update -y && apt-get upgrade -y 5 | 6 | # Install dependencies, such as the SSH server 7 | RUN apt-get install -y sudo openssh-server telnet jq 8 | 9 | # Source: 10 | # https://medium.com/@ratnesh4209211786/simplified-ssh-server-setup-within-a-docker-container-77eedd87a320 11 | # 12 | # Create an SSH user named "test". Make it a sudoer 13 | RUN useradd -rm -d /home/test -s /bin/bash -g root -G sudo -u 1000 test 14 | # Set password to "test" 15 | RUN echo "test:test" | chpasswd 16 | 17 | # Make it so "test" user does not need to present password when using sudo 18 | # Source: https://askubuntu.com/a/878705 19 | RUN echo "test ALL=(ALL:ALL) NOPASSWD: ALL" | tee /etc/sudoers.d/test 20 | 21 | # Create unprivileged user named "test2" 22 | RUN useradd -rm -d /home/test2 -s /bin/bash -u 1001 test2 23 | # Set password to "test" 24 | RUN echo "test2:test" | chpasswd 25 | 26 | # Allow SSH access 27 | RUN mkdir /var/run/sshd 28 | 29 | # Expose SSH server so we can ssh in from the tests 30 | EXPOSE 22 31 | 32 | # Set destination for COPY 33 | WORKDIR /app 34 | 35 | # Download Go modules 36 | COPY go.mod go.sum ./ 37 | RUN go mod download 38 | 39 | # Copy our repo 40 | COPY . ./ 41 | 42 | # Build "opkssh" binary and write to the opk directory 43 | ARG ISSUER_PORT="9998" 44 | RUN go build -v -o opksshbuild 45 | RUN chmod +x ./scripts/install-linux.sh 46 | RUN bash ./scripts/install-linux.sh --install-from=opksshbuild --no-sshd-restart 47 | # RUN chmod 700 /usr/local/bin/opkssh 48 | 49 | RUN echo "http://oidc.local:${ISSUER_PORT}/ web oidc_refreshed" >> /etc/opk/providers 50 | 51 | # Add integration test user as allowed email in policy (this directly tests 52 | # policy "add" command) 53 | ARG BOOTSTRAP_POLICY 54 | 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 55 | 56 | # Start SSH server on container startup 57 | CMD ["/usr/sbin/sshd", "-D"] 58 | -------------------------------------------------------------------------------- /test/integration/ssh_server/opensuse_opkssh.Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the Go binary 2 | FROM golang:1.23 as builder 3 | 4 | # Set destination for COPY 5 | WORKDIR /app 6 | 7 | # Download Go modules 8 | COPY go.mod go.sum ./ 9 | RUN go mod download 10 | 11 | # Copy our repo 12 | COPY . ./ 13 | 14 | # Copy the source code and build the binary 15 | ARG ISSUER_PORT="9998" 16 | RUN go build -v -o opksshbuild 17 | 18 | # Stage 2: Create a minimal openSUSE-Tumbleweed-based image 19 | FROM opensuse/tumbleweed:latest 20 | # Install dependencies required for runtime (e.g., SSH server) 21 | RUN zypper refresh && \ 22 | zypper --non-interactive install sudo openssh-server openssh-clients telnet wget jq && \ 23 | zypper clean --all && \ 24 | rm /var/log/zypp/history && \ 25 | rm /var/log/zypper.log 26 | 27 | # Source: 28 | # https://medium.com/@ratnesh4209211786/simplified-ssh-server-setup-within-a-docker-container-77eedd87a320 29 | # 30 | # Create an SSH user named "test". Make it a sudoer 31 | RUN useradd -rm -d /home/test -s /bin/bash -g root -u 480 test 32 | # Set password to "test" 33 | RUN echo "test:test" | chpasswd 34 | 35 | # Make it so "test" user does not need to present password when using sudo 36 | # Source: https://askubuntu.com/a/878705 37 | RUN echo "test ALL=(ALL:ALL) NOPASSWD: ALL" | tee /etc/sudoers.d/test 38 | 39 | # Create unprivileged user named "test2" 40 | RUN useradd -rm -d /home/test2 -s /bin/bash -u 481 test2 41 | # Set password to "test" 42 | RUN echo "test2:test" | chpasswd 43 | 44 | # Allow SSH access 45 | RUN mkdir /var/run/sshd 46 | 47 | # Expose SSH server so we can ssh in from the tests 48 | EXPOSE 22 49 | 50 | WORKDIR /app 51 | 52 | # Copy binary and install script from builder 53 | COPY --from=builder /app/opksshbuild ./opksshbuild 54 | COPY --from=builder /app/scripts/install-linux.sh install-linux.sh 55 | 56 | # Run install script to install/configure opkssh 57 | RUN chmod +x install-linux.sh 58 | RUN bash ./install-linux.sh --install-from=opksshbuild --no-sshd-restart 59 | 60 | RUN opkssh --version 61 | RUN ls -l /usr/local/bin 62 | RUN printenv PATH 63 | 64 | ARG ISSUER_PORT="9998" 65 | RUN echo "http://oidc.local:${ISSUER_PORT}/ web oidc_refreshed" >> /etc/opk/providers 66 | 67 | # Add integration test user as allowed email in policy (this directly tests 68 | # policy "add" command) 69 | ARG BOOTSTRAP_POLICY 70 | 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 71 | 72 | # Generate SSH host keys 73 | RUN ssh-keygen -A 74 | 75 | # Start the SSH server on container startup 76 | CMD ["/usr/sbin/sshd", "-D"] 77 | -------------------------------------------------------------------------------- /test/integration/ssh_server/ssh_server.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 OpenPubkey 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | // 15 | // SPDX-License-Identifier: Apache-2.0 16 | 17 | //go:build integration 18 | 19 | package ssh_server 20 | 21 | import ( 22 | "context" 23 | "fmt" 24 | "os" 25 | "path/filepath" 26 | "runtime" 27 | "strings" 28 | "time" 29 | 30 | "github.com/openpubkey/opkssh/internal/projectpath" 31 | 32 | "github.com/testcontainers/testcontainers-go" 33 | "github.com/testcontainers/testcontainers-go/wait" 34 | ) 35 | 36 | type SshServerContainer struct { 37 | testcontainers.Container 38 | Host string 39 | Port int 40 | User string 41 | Password string 42 | } 43 | 44 | func RunOpkSshContainer(ctx context.Context, issuerHostIp string, issuerPort string, networkName string, bootstrapPolicy bool) (*SshServerContainer, error) { 45 | osType := os.Getenv("OS_TYPE") 46 | var dockerFile string 47 | var err error 48 | 49 | if osType == "ubuntu" { 50 | dockerFile = filepath.Join("test", "integration", "ssh_server", "debian_opkssh.Dockerfile") 51 | } else if osType == "centos" { 52 | dockerFile = filepath.Join("test", "integration", "ssh_server", "centos_opkssh.Dockerfile") 53 | } else if osType == "arch" { 54 | dockerFile = filepath.Join("test", "integration", "ssh_server", "arch_opkssh.Dockerfile") 55 | } else if osType == "opensuse" { 56 | dockerFile = filepath.Join("test", "integration", "ssh_server", "opensuse_opkssh.Dockerfile") 57 | } else { 58 | return nil, fmt.Errorf("unsupported OS type: %s", osType) 59 | } 60 | 61 | req := testcontainers.ContainerRequest{ 62 | FromDockerfile: testcontainers.FromDockerfile{ 63 | Context: projectpath.Root, 64 | Dockerfile: dockerFile, 65 | PrintBuildLog: true, 66 | KeepImage: true, 67 | BuildArgs: make(map[string]*string), 68 | }, 69 | ExposedPorts: []string{"22/tcp"}, 70 | ImagePlatform: "linux/" + runtime.GOARCH, 71 | // Wait for SSH server to be running by attempting to connect 72 | // 73 | // https://stackoverflow.com/a/54364978 74 | WaitingFor: wait.ForExec(strings.Split("echo -e '\\x1dclose\\x0d' | telnet localhost 22", " ")). 75 | WithStartupTimeout(time.Second * 10). 76 | WithExitCodeMatcher(func(exitCode int) bool { 77 | return exitCode == 0 78 | }), 79 | } 80 | if issuerHostIp != "" { 81 | req.ExtraHosts = []string{fmt.Sprintf("oidc.local:%v", issuerHostIp)} 82 | } 83 | if issuerPort != "" { 84 | req.BuildArgs["ISSUER_PORT"] = &issuerPort 85 | } 86 | if networkName != "" { 87 | req.Networks = []string{networkName} 88 | } 89 | if bootstrapPolicy { 90 | trueStr := "true" 91 | req.BuildArgs["BOOTSTRAP_POLICY"] = &trueStr 92 | } 93 | container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ 94 | ContainerRequest: req, 95 | Started: true, 96 | }) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | mappedPort, err := container.MappedPort(ctx, "22") 102 | if err != nil { 103 | return nil, err 104 | } 105 | hostIP, err := container.Host(ctx) 106 | if err != nil { 107 | return nil, err 108 | } 109 | return &SshServerContainer{ 110 | Container: container, 111 | Host: hostIP, 112 | Port: mappedPort.Int(), 113 | User: "test", 114 | Password: "test", 115 | }, nil 116 | } 117 | 118 | func RunUbuntuContainer(ctx context.Context) (*SshServerContainer, error) { 119 | req := testcontainers.ContainerRequest{ 120 | FromDockerfile: testcontainers.FromDockerfile{ 121 | Context: projectpath.Root, 122 | Dockerfile: filepath.Join("test", "integration", "ssh_server", "ubuntu.Dockerfile"), 123 | KeepImage: true, 124 | }, 125 | ExposedPorts: []string{"22/tcp"}, 126 | ImagePlatform: "linux/" + runtime.GOARCH, 127 | WaitingFor: wait.ForExposedPort(), 128 | } 129 | container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ 130 | ContainerRequest: req, 131 | Started: true, 132 | }) 133 | if err != nil { 134 | return nil, err 135 | } 136 | 137 | mappedPort, err := container.MappedPort(ctx, "22") 138 | if err != nil { 139 | return nil, err 140 | } 141 | hostIP, err := container.Host(ctx) 142 | if err != nil { 143 | return nil, err 144 | } 145 | return &SshServerContainer{ 146 | Container: container, 147 | Host: hostIP, 148 | Port: mappedPort.Int(), 149 | User: "test", 150 | Password: "test", 151 | }, nil 152 | } 153 | -------------------------------------------------------------------------------- /test/integration/ssh_server/ubuntu.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | 3 | # Update/Upgrade 4 | RUN apt-get update -y && apt-get upgrade -y 5 | 6 | # Install dependencies, such as the SSH server 7 | RUN apt-get install -y sudo openssh-server 8 | 9 | # Source: 10 | # https://medium.com/@ratnesh4209211786/simplified-ssh-server-setup-within-a-docker-container-77eedd87a320 11 | # 12 | # Create an SSH user named "test". Make it a sudoer 13 | RUN useradd -rm -d /home/test -s /bin/bash -g root -G sudo -u 1000 test 14 | # Set password to "test" 15 | RUN echo 'test:test' | chpasswd 16 | 17 | # Allow SSH access 18 | RUN mkdir /var/run/sshd 19 | 20 | # Expose SSH server so we can ssh in from the tests 21 | EXPOSE 22 22 | 23 | # Start SSH server on container startup 24 | CMD ["/usr/sbin/sshd", "-D"] --------------------------------------------------------------------------------