├── .github ├── commitlint.config.mjs ├── dependabot.yaml ├── dependency-review-config.yaml ├── pull_request_template.md └── workflows │ ├── build.yaml │ ├── coverage.yaml │ ├── dependabot-automerge.yaml │ ├── dependency-review.yaml │ ├── lint.yaml │ ├── release.yaml │ ├── test.yaml │ └── user-documentation.yaml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.macos-latest.yaml ├── .goreleaser.ubuntu-latest.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── piv-agent │ ├── list.go │ ├── main.go │ ├── serve.go │ ├── setup.go │ ├── setupslots.go │ └── version.go ├── deploy ├── launchd │ └── com.github.smlx.piv-agent.plist └── systemd │ ├── piv-agent.service │ └── piv-agent.socket ├── docs ├── .gitignore ├── Makefile ├── assets │ └── scss │ │ └── _variables_project.scss ├── content │ └── en │ │ ├── _index.html │ │ └── docs │ │ ├── _index.md │ │ ├── faq.md │ │ ├── gpg-walkthrough.md │ │ ├── install.md │ │ ├── setup.md │ │ └── use.md ├── go.mod ├── go.sum ├── hugo.toml └── layouts │ ├── 404.html │ └── _default │ └── _markup │ └── render-heading.html ├── go.mod ├── go.sum └── internal ├── assuan ├── README.md ├── assuan.go ├── assuan_test.go ├── encode.go ├── event_enumer.go ├── fsm.go ├── readkey.go ├── run.go ├── sign.go ├── state_enumer.go └── testdata │ ├── C54A8868468BC138.asc │ ├── private-subkeys │ ├── foo-sub-ecdsa@example.com.sub-ecdsa.gpg │ ├── foo-sub@example.com.sub-rsa.gpg │ └── foo@example.com.primary-rsa.gpg │ └── private │ ├── foo@example.com.gpg │ └── test-assuan2@example.com.gpg ├── keyservice ├── gpg │ ├── ecdhkey.go │ ├── havekey.go │ ├── helper_test.go │ ├── keyfile.go │ ├── keygrip.go │ ├── keygrip_test.go │ ├── keyservice.go │ ├── keyservice_test.go │ ├── openpgpecdsa.go │ ├── rsakey.go │ └── testdata │ │ ├── key1.asc │ │ ├── key2.asc │ │ ├── key3.asc │ │ ├── key4.asc │ │ └── private │ │ ├── bar-protected@example.com.gpg │ │ └── bar@example.com.gpg └── piv │ ├── ecdhkey.go │ ├── keyservice.go │ └── list.go ├── mock ├── mock_assuan.go ├── mock_keyservice.go └── mock_pivservice.go ├── notify └── touch.go ├── pinentry └── pinentry.go ├── securitykey ├── decryptingkey.go ├── openpgpecdsa.go ├── securitykey.go ├── setup.go ├── setupslots.go ├── signingkey.go ├── slotspec.go └── string.go ├── server ├── common.go ├── gpg.go └── ssh.go ├── sockets ├── get_darwin.go └── get_linux.go └── ssh └── agent.go /.github/commitlint.config.mjs: -------------------------------------------------------------------------------- 1 | /* Taken from: https://github.com/wagoid/commitlint-github-action/blob/7f0a61df502599e1f1f50880aaa7ec1e2c0592f2/commitlint.config.mjs */ 2 | /* eslint-disable import/no-extraneous-dependencies */ 3 | import { maxLineLength } from '@commitlint/ensure' 4 | 5 | const bodyMaxLineLength = 100 6 | 7 | const validateBodyMaxLengthIgnoringDeps = (parsedCommit) => { 8 | const { type, scope, body } = parsedCommit 9 | const isDepsCommit = 10 | type === 'chore' && (scope === 'deps' || scope === 'deps-dev') 11 | 12 | return [ 13 | isDepsCommit || !body || maxLineLength(body, bodyMaxLineLength), 14 | `body's lines must not be longer than ${bodyMaxLineLength}`, 15 | ] 16 | } 17 | 18 | export default { 19 | extends: ['@commitlint/config-conventional'], 20 | plugins: ['commitlint-plugin-function-rules'], 21 | rules: { 22 | 'body-max-line-length': [0], 23 | 'function-rules/body-max-line-length': [ 24 | 2, 25 | 'always', 26 | validateBodyMaxLengthIgnoringDeps, 27 | ], 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | commit-message: 5 | prefix: chore 6 | include: scope 7 | directory: / 8 | schedule: 9 | interval: monthly 10 | groups: 11 | github-actions: 12 | patterns: 13 | - "*" 14 | update-types: 15 | - "minor" 16 | - "patch" 17 | - package-ecosystem: docker 18 | commit-message: 19 | prefix: chore 20 | include: scope 21 | directory: / 22 | schedule: 23 | interval: monthly 24 | groups: 25 | docker: 26 | patterns: 27 | - "*" 28 | update-types: 29 | - "minor" 30 | - "patch" 31 | - package-ecosystem: gomod 32 | commit-message: 33 | prefix: chore 34 | include: scope 35 | directory: / 36 | schedule: 37 | interval: monthly 38 | groups: 39 | gomod: 40 | patterns: 41 | - "*" 42 | update-types: 43 | - "minor" 44 | - "patch" 45 | -------------------------------------------------------------------------------- /.github/dependency-review-config.yaml: -------------------------------------------------------------------------------- 1 | # https://github.com/cncf/foundation/blob/main/allowed-third-party-license-policy.md 2 | allow-licenses: 3 | - 'Apache-2.0' 4 | - 'BSD-2-Clause' 5 | - 'BSD-2-Clause-FreeBSD' 6 | - 'BSD-3-Clause' 7 | - 'ISC' 8 | - 'MIT' 9 | - 'PostgreSQL' 10 | - 'Python-2.0' 11 | - 'X11' 12 | - 'Zlib' 13 | 14 | allow-dependencies-licenses: 15 | # this action is GPL-3 but it is only used in CI 16 | # https://github.com/actions/dependency-review-action/issues/530#issuecomment-1638291806 17 | - pkg:githubactions/vladopajic/go-test-coverage@bcd064e5ceef1ccec5441519eb054263b6a44787 18 | # this package is MPL-2.0 and has a CNCF exception 19 | # https://github.com/cncf/foundation/blob/9b8c9173c2101c1b4aedad3caf2c0128715133f6/license-exceptions/cncf-exceptions-2022-04-12.json#L43C17-L43C47 20 | - pkg:golang/github.com/go-sql-driver/mysql 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | permissions: {} 7 | jobs: 8 | build-snapshot: 9 | permissions: 10 | contents: read 11 | strategy: 12 | matrix: 13 | os: 14 | - ubuntu-latest 15 | - macos-latest 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | with: 20 | ref: ${{ github.event.pull_request.head.sha }} 21 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 22 | with: 23 | go-version: stable 24 | - name: Install Dependencies (ubuntu) 25 | if: matrix.os == 'ubuntu-latest' 26 | run: sudo apt-get update && sudo apt-get -u install libpcsclite-dev 27 | - uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 28 | id: goreleaser 29 | with: 30 | version: latest 31 | args: build --clean --verbose --snapshot --config .goreleaser.${{ matrix.os }}.yaml 32 | check-tag: 33 | permissions: 34 | contents: read 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 38 | with: 39 | fetch-depth: 0 40 | - id: ccv 41 | uses: smlx/ccv@7318e2f25a52dcd550e75384b84983973251a1f8 # v0.10.0 42 | with: 43 | write-tag: false 44 | - run: | 45 | echo "new-tag=$NEW_TAG" 46 | echo "new-tag-version=$NEW_TAG_VERSION" 47 | env: 48 | NEW_TAG: ${{steps.ccv.outputs.new-tag}} 49 | NEW_TAG_VERSION: ${{steps.ccv.outputs.new-tag-version}} 50 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yaml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: {} 7 | jobs: 8 | coverage: 9 | permissions: 10 | contents: write 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 14 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 15 | with: 16 | go-version: stable 17 | - name: Install Dependencies 18 | run: sudo apt-get update && sudo apt-get -u install libpcsclite-dev 19 | - name: Calculate coverage 20 | run: | 21 | go test -v -covermode=atomic -coverprofile=cover.out.raw -coverpkg=./... ./... 22 | # remove generated code from coverage calculation 23 | grep -Ev 'internal/mock|_enumer.go' cover.out.raw > cover.out 24 | - name: Generage coverage badge 25 | uses: vladopajic/go-test-coverage@7003e902e787e60375e1ce3ae5b85b19313dbff2 # v2.14.3 26 | with: 27 | profile: cover.out 28 | local-prefix: github.com/${{ github.repository }} 29 | git-token: ${{ secrets.GITHUB_TOKEN }} 30 | # orphan branch for storing badges 31 | git-branch: badges 32 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-automerge.yaml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/automating-dependabot-with-github-actions#enable-auto-merge-on-a-pull-request 2 | name: dependabot auto-merge 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | permissions: {} 8 | jobs: 9 | dependabot-automerge: 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | runs-on: ubuntu-latest 14 | if: github.actor == 'dependabot[bot]' 15 | steps: 16 | - name: Fetch dependabot metadata 17 | id: metadata 18 | uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b # v2.4.0 19 | with: 20 | github-token: "${{ secrets.GITHUB_TOKEN }}" 21 | - name: Enable auto-merge for Dependabot PRs # these still need approval before merge 22 | run: gh pr merge --auto --merge "$PR_URL" 23 | env: 24 | PR_URL: ${{github.event.pull_request.html_url}} 25 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 26 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yaml: -------------------------------------------------------------------------------- 1 | name: dependency review 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | permissions: {} 7 | jobs: 8 | dependency-review: 9 | permissions: 10 | contents: read 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 14 | - uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 15 | with: 16 | config-file: .github/dependency-review-config.yaml 17 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | permissions: {} 7 | jobs: 8 | lint-go: 9 | permissions: 10 | contents: read 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 14 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 15 | with: 16 | go-version: stable 17 | - name: Install Dependencies 18 | run: sudo apt-get update && sudo apt-get -u install libpcsclite-dev 19 | - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 20 | with: 21 | args: --timeout=180s --enable gocritic 22 | lint-commits: 23 | permissions: 24 | contents: read 25 | pull-requests: read 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | with: 30 | fetch-depth: 0 31 | - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1 32 | with: 33 | configFile: .github/commitlint.config.mjs 34 | lint-actions: 35 | permissions: 36 | contents: read 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 40 | - uses: docker://rhysd/actionlint:1.7.0@sha256:601d6faeefa07683a4a79f756f430a1850b34d575d734b1d1324692202bf312e # v1.7.0 41 | with: 42 | args: -color 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: {} 7 | jobs: 8 | release-tag: 9 | permissions: 10 | # create tag 11 | contents: write 12 | runs-on: ubuntu-latest 13 | outputs: 14 | new-tag: ${{ steps.ccv.outputs.new-tag }} 15 | steps: 16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | with: 18 | fetch-depth: 0 19 | - name: Bump tag if necessary 20 | id: ccv 21 | uses: smlx/ccv@7318e2f25a52dcd550e75384b84983973251a1f8 # v0.10.0 22 | release-build: 23 | permissions: 24 | # create release 25 | contents: write 26 | # required by attest-build-provenance 27 | id-token: write 28 | attestations: write 29 | needs: release-tag 30 | if: needs.release-tag.outputs.new-tag == 'true' 31 | strategy: 32 | # avoid concurrent goreleaser runs 33 | max-parallel: 1 34 | matrix: 35 | os: 36 | - ubuntu-latest 37 | - macos-latest 38 | runs-on: ${{ matrix.os }} 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | with: 42 | fetch-depth: 0 43 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 44 | with: 45 | go-version: stable 46 | - name: Install Dependencies 47 | if: matrix.os == 'ubuntu-latest' 48 | run: sudo apt-get update && sudo apt-get -u install libpcsclite-dev 49 | - uses: advanced-security/sbom-generator-action@6fe43abf522b2e7a19bc769aec1e6c848614b517 # v0.0.2 50 | id: sbom 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | - name: Move sbom to avoid dirty git 54 | run: mv "$GITHUB_SBOM_PATH" ./sbom.spdx.json 55 | env: 56 | GITHUB_SBOM_PATH: ${{ steps.sbom.outputs.fileName }} 57 | - uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 58 | id: goreleaser 59 | with: 60 | version: latest 61 | args: release --clean --config .goreleaser.${{ matrix.os }}.yaml 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | GITHUB_SBOM_PATH: ./sbom.spdx.json 65 | # attest archives 66 | - uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 67 | with: 68 | subject-path: "dist/*.tar.gz" 69 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | permissions: {} 7 | jobs: 8 | test-go: 9 | permissions: 10 | contents: read 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 14 | with: 15 | ref: ${{ github.event.pull_request.head.sha }} 16 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 17 | with: 18 | go-version: stable 19 | - name: Install Dependencies 20 | run: sudo apt-get update && sudo apt-get -u install libpcsclite-dev 21 | - run: go test -v ./... 22 | -------------------------------------------------------------------------------- /.github/workflows/user-documentation.yaml: -------------------------------------------------------------------------------- 1 | name: User Documentation 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main # Set a branch to deploy 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | steps: 13 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 14 | with: 15 | fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod 16 | - name: Setup Hugo 17 | uses: peaceiris/actions-hugo@75d2e84710de30f6ff7268e08f310b60ef14033f # v3.0.0 18 | with: 19 | hugo-version: '0.123.8' 20 | extended: true 21 | - name: Setup Node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | - run: cd docs && npm install postcss-cli autoprefixer && hugo --minify 26 | - name: Deploy 27 | uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 28 | if: ${{ github.ref == 'refs/heads/main' }} 29 | with: 30 | github_token: ${{ secrets.GITHUB_TOKEN }} 31 | publish_dir: ./docs/public 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /cover.out 3 | /cover.out.raw 4 | /sbom.spdx.json 5 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | exclusions: 4 | presets: 5 | - std-error-handling 6 | -------------------------------------------------------------------------------- /.goreleaser.macos-latest.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | archives: 3 | - files: 4 | - deploy/launchd 5 | - LICENSE 6 | - README.md 7 | 8 | builds: 9 | - id: piv-agent 10 | binary: piv-agent 11 | main: ./cmd/piv-agent 12 | ldflags: 13 | - > 14 | -s -w 15 | -X "main.commit={{.Commit}}" 16 | -X "main.date={{.Date}}" 17 | -X "main.projectName={{.ProjectName}}" 18 | -X "main.version=v{{.Version}}" 19 | env: 20 | - CGO_ENABLED=1 21 | goos: 22 | - darwin 23 | goarch: 24 | - amd64 25 | 26 | changelog: 27 | use: github-native 28 | 29 | checksum: 30 | name_template: "{{ .ProjectName }}_{{ .Version }}_darwin_checksums.txt" 31 | 32 | release: 33 | extra_files: 34 | - glob: "{{ .Env.GITHUB_SBOM_PATH }}" 35 | name_template: "{{ .ProjectName }}.v{{ .Version }}.sbom.darwin.spdx.json" 36 | -------------------------------------------------------------------------------- /.goreleaser.ubuntu-latest.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | archives: 3 | - files: 4 | - deploy/systemd 5 | - LICENSE 6 | - README.md 7 | 8 | builds: 9 | - id: piv-agent 10 | binary: piv-agent 11 | main: ./cmd/piv-agent 12 | ldflags: 13 | - > 14 | -s -w 15 | -X "main.commit={{.Commit}}" 16 | -X "main.date={{.Date}}" 17 | -X "main.projectName={{.ProjectName}}" 18 | -X "main.version=v{{.Version}}" 19 | env: 20 | - CGO_ENABLED=1 21 | goos: 22 | - linux 23 | goarch: 24 | - amd64 25 | 26 | changelog: 27 | use: github-native 28 | 29 | checksum: 30 | name_template: "{{ .ProjectName }}_{{ .Version }}_linux_checksums.txt" 31 | 32 | release: 33 | extra_files: 34 | - glob: "{{ .Env.GITHUB_SBOM_PATH }}" 35 | name_template: "{{ .ProjectName }}.v{{ .Version }}.sbom.linux.spdx.json" 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This Dockerfile is not currently published as an image, it only exists to 2 | # test the build in a clean local development environment. 3 | FROM golang:1-buster 4 | RUN apt-get update \ 5 | && apt-get install -y libpcsclite-dev \ 6 | && apt-get clean \ 7 | && curl -sfL https://install.goreleaser.com/github.com/goreleaser/goreleaser.sh | sh 8 | WORKDIR /src 9 | COPY . . 10 | RUN goreleaser build --snapshot --rm-dist --config .goreleaser.ubuntu-latest.yaml 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: mod-tidy generate 3 | CGO_ENABLED=1 go test -v ./... 4 | 5 | .PHONY: generate 6 | generate: mod-tidy 7 | go generate ./... 8 | 9 | .PHONY: mod-tidy 10 | mod-tidy: 11 | go mod tidy 12 | 13 | .PHONY: build 14 | build: test 15 | CGO_ENABLED=1 go build ./cmd/piv-agent 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PIV Agent 2 | 3 | [![Release](https://github.com/smlx/piv-agent/actions/workflows/release.yaml/badge.svg)](https://github.com/smlx/piv-agent/actions/workflows/release.yaml) 4 | [![coverage](https://raw.githubusercontent.com/smlx/piv-agent/badges/.badges/main/coverage.svg)](https://github.com/smlx/piv-agent/actions/workflows/coverage.yaml) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/smlx/piv-agent)](https://goreportcard.com/report/github.com/smlx/piv-agent) 6 | [![User Documentation](https://github.com/smlx/piv-agent/actions/workflows/user-documentation.yaml/badge.svg)](https://smlx.github.io/piv-agent/) 7 | 8 | ## About 9 | 10 | * `piv-agent` is an SSH and GPG agent providing simple integration of [PIV](https://csrc.nist.gov/projects/piv/piv-standards-and-supporting-documentation) hardware (e.g. a [Yubikey](https://developers.yubico.com/yubico-piv-tool/YubiKey_PIV_introduction.html)) with `ssh`, and `gpg` workflows such as [`git`](https://git-scm.com/) signing, [`pass`](https://www.passwordstore.org/) encryption, or [keybase](https://keybase.io/) chat. 11 | * `piv-agent` originated as a reimplementation of [yubikey-agent](https://github.com/FiloSottile/yubikey-agent) because I needed some extra features, and also to gain a better understanding of the PIV applet on security key hardware. 12 | * `piv-agent` makes heavy use of the Go standard library and supplementary `crypto` packages, as well as [`piv-go`](https://github.com/go-piv/piv-go/) and [`pcsclite`](https://pcsclite.apdu.fr/). Thanks for the great software! 13 | 14 | --- 15 | **DISCLAIMER** 16 | 17 | I make no assertion about the security or otherwise of this software and I am not a cryptographer. 18 | If you are, please take a look at the code and send PRs or issues. :green_heart: 19 | 20 | --- 21 | 22 | ### Features 23 | 24 | * implements (a subset of) both `ssh-agent` and `gpg-agent` functionality 25 | * support for multiple hardware security keys 26 | * support for multiple slots in those keys 27 | * support for multiple touch policies 28 | * all cryptographic keys are generated on the hardware security key, rather than on your laptop 29 | * secret keys never touch your hard drive 30 | * uses systemd (Linux) or launchd (macOS) socket activation 31 | * as a result, automatically drop the transaction on the security key and cached passphrases after some period of disuse 32 | * provides "fall-back" to traditional SSH and OpenPGP keyfiles 33 | 34 | ### Design philosophy 35 | 36 | This agent should require no interaction and in general do the right thing when security keys are plugged/unplugged, laptop is power cycled, etc. 37 | 38 | It is highly opinionated: 39 | 40 | * Only supports 256-bit ECC keys on hardware tokens 41 | * Only supports ed25519 SSH keys on disk (`~/.ssh/id_ed25519`) 42 | * Requires socket activation 43 | 44 | It makes some concession to practicality with OpenPGP: 45 | 46 | * Supports RSA signing and decryption for OpenPGP keyfiles. 47 | RSA OpenPGP keys are widespread and Debian in particular [only documents RSA keys](https://wiki.debian.org/Keysigning). 48 | 49 | It tries to strike a balance between security and usability: 50 | 51 | * Takes a persistent transaction on the hardware token, effectively caching the PIN. 52 | * Caches passphrases for on-disk keys (i.e. `~/.ssh/id_ed25519`) in memory, so these only need to be provided once after the agent starts. 53 | * After a period of inactivity (32 minutes by default) it exits, dropping both of these. 54 | Socket activation restarts it automatically as required. 55 | 56 | ### Hardware support 57 | 58 | Tested with: 59 | 60 | * [YubiKey 5C](https://www.yubico.com/au/product/yubikey-5c/), firmware 5.2.4 61 | 62 | Will be tested with (once PIV support [is available](https://github.com/solokeys/solo2/discussions/88)): 63 | 64 | * [Solo V2](https://www.kickstarter.com/projects/conorpatrick/solo-v2-safety-net-against-phishing/) 65 | 66 | Any device implementing the SCard API (PC/SC), and supported by [`piv-go`](https://github.com/go-piv/piv-go/) / [`pcsclite`](https://pcsclite.apdu.fr/) may work. 67 | If you have tested another device with `piv-agent` successfully, please send a PR adding it to this list. 68 | 69 | ### Platform support 70 | 71 | Currently tested on Linux with `systemd` and macOS with `launchd`. 72 | 73 | ### Protocol / Encryption Algorithm support 74 | 75 | | Supported | Not Supported | Support Blocked (Curve25519) | 76 | | --- | --- | --- | 77 | | ✅ | ❌ | ⏳ | 78 | 79 | Curve25519 algorithms are blocked on hardware support. 80 | Currently I'm only aware of Solo V2 which intends to implement this non-standard curve. 81 | Support is not yet available (see the link above). 82 | 83 | #### ssh-agent 84 | 85 | | | Security Key | Keyfile | 86 | | --- | --- | --- | 87 | | ecdsa-sha2-nistp256 | ✅ | ❌ | 88 | | ssh-ed25519 | ⏳ | ✅ | 89 | 90 | 91 | #### gpg-agent 92 | 93 | | | Security Key | Keyfile | 94 | | --- | --- | --- | 95 | | ECDSA Sign (NIST Curve P-256) | ✅ | ✅ | 96 | | EDDSA Sign (Curve25519) | ⏳ | ⏳ | 97 | | ECDH Decrypt | ✅ | ✅ | 98 | | RSA Sign | ❌ | ✅ | 99 | | RSA Decrypt | ❌ | ✅ | 100 | 101 | ## Install and Use 102 | 103 | Please see the [documentation](https://smlx.github.io/piv-agent/). 104 | 105 | ## Develop 106 | 107 | ### Prerequisites 108 | 109 | Install build dependencies: 110 | 111 | ``` 112 | # debian/ubuntu 113 | sudo apt install libpcsclite-dev 114 | ``` 115 | 116 | ### Build and test 117 | 118 | ``` 119 | make 120 | ``` 121 | 122 | ### Build and test manually 123 | 124 | This D-Bus variable is required for `pinentry` to use a graphical prompt: 125 | 126 | ``` 127 | go build ./cmd/piv-agent && systemd-socket-activate -l /tmp/piv-agent.sock -E DBUS_SESSION_BUS_ADDRESS ./piv-agent serve --debug 128 | ``` 129 | 130 | Then in another terminal: 131 | 132 | ``` 133 | export SSH_AUTH_SOCK=/tmp/piv-agent.sock 134 | ssh ... 135 | ``` 136 | 137 | ### Build and test the documentation 138 | 139 | ``` 140 | cd docs && make serve 141 | ``` 142 | -------------------------------------------------------------------------------- /cmd/piv-agent/list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/smlx/piv-agent/internal/keyservice/piv" 8 | "github.com/smlx/piv-agent/internal/pinentry" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | // ListCmd represents the list command. 13 | type ListCmd struct { 14 | KeyFormats []string `kong:"default='ssh',enum='ssh,gpg',help='Key formats to list (ssh, gpg)'"` 15 | PGPName string `kong:"default='piv-agent',help='Name set on synthesized PGP identities'"` 16 | PGPEmail string `kong:"default='noreply@example.com',help='Email set on synthesized PGP identities'"` 17 | } 18 | 19 | // Run the list command. 20 | func (cmd *ListCmd) Run(l *zap.Logger) error { 21 | p := piv.New(l, pinentry.New("pinentry")) 22 | securityKeys, err := p.SecurityKeys() 23 | if err != nil { 24 | return fmt.Errorf("couldn't get security keys: %w", err) 25 | } 26 | fmt.Println("Security keys (cards):") 27 | for _, k := range securityKeys { 28 | fmt.Println(k.Card()) 29 | } 30 | keyformats := map[string]bool{} 31 | for _, f := range cmd.KeyFormats { 32 | keyformats[f] = true 33 | } 34 | if keyformats["ssh"] { 35 | fmt.Println("\nSSH keys:") 36 | for _, k := range securityKeys { 37 | for _, s := range k.StringsSSH() { 38 | fmt.Println(strings.TrimSpace(s)) 39 | } 40 | } 41 | } 42 | if keyformats["gpg"] { 43 | for _, k := range securityKeys { 44 | ss, err := k.StringsGPG(cmd.PGPName, cmd.PGPEmail) 45 | if err != nil { 46 | return fmt.Errorf("couldn't get GPG keys as strings: %v", err) 47 | } 48 | for _, s := range ss { 49 | fmt.Println(s) 50 | } 51 | } 52 | } 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /cmd/piv-agent/main.go: -------------------------------------------------------------------------------- 1 | // Package main implements the piv-agent CLI. 2 | package main 3 | 4 | import ( 5 | "github.com/alecthomas/kong" 6 | "go.uber.org/zap" 7 | ) 8 | 9 | var ( 10 | date string 11 | goVersion string 12 | shortCommit string 13 | version string 14 | ) 15 | 16 | // CLI represents the command-line interface. 17 | type CLI struct { 18 | Debug bool `kong:"help='Enable debug logging'"` 19 | Serve ServeCmd `kong:"cmd,default=1,help='(default) Listen for ssh-agent and gpg-agent requests'"` 20 | Setup SetupCmd `kong:"cmd,help='Reset the PIV applet to factory settings before configuring it for use with piv-agent'"` 21 | SetupSlots SetupSlotsCmd `kong:"cmd,help='Set up a single slot on the PIV applet for use with piv-agent. This is for advanced users, most people should use the setup command.'"` 22 | List ListCmd `kong:"cmd,help='List cryptographic keys available on the PIV applet of each hardware security key'"` 23 | Version VersionCmd `kong:"cmd,help='Print version information'"` 24 | } 25 | 26 | func main() { 27 | // parse CLI config 28 | cli := CLI{} 29 | kctx := kong.Parse(&cli, 30 | kong.UsageOnError(), 31 | ) 32 | // init logger 33 | var log *zap.Logger 34 | var err error 35 | if cli.Debug { 36 | log, err = zap.NewDevelopment(zap.AddStacktrace(zap.ErrorLevel)) 37 | } else { 38 | log, err = zap.NewProduction() 39 | } 40 | if err != nil { 41 | panic(err) 42 | } 43 | defer log.Sync() //nolint:errcheck 44 | // execute CLI 45 | kctx.FatalIfErrorf(kctx.Run(log)) 46 | } 47 | -------------------------------------------------------------------------------- /cmd/piv-agent/serve.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/smlx/piv-agent/internal/keyservice/piv" 11 | "github.com/smlx/piv-agent/internal/notify" 12 | "github.com/smlx/piv-agent/internal/pinentry" 13 | "github.com/smlx/piv-agent/internal/server" 14 | "github.com/smlx/piv-agent/internal/sockets" 15 | "github.com/smlx/piv-agent/internal/ssh" 16 | "go.uber.org/zap" 17 | "golang.org/x/sync/errgroup" 18 | ) 19 | 20 | type agentTypeFlag map[string]uint 21 | 22 | // ServeCmd represents the listen command. 23 | type ServeCmd struct { 24 | LoadKeyfile bool `kong:"default=true,help='Load the key file from ~/.ssh/id_ed25519'"` 25 | ExitTimeout time.Duration `kong:"default=12h,help='Exit after this period to drop transaction and key file passphrase cache, even if service is in use'"` 26 | IdleTimeout time.Duration `kong:"default=32m,help='Exit after this period of disuse'"` 27 | TouchNotifyDelay time.Duration `kong:"default=6s,help='Display a notification after this period when waiting for a touch'"` 28 | PinentryBinaryName string `kong:"default='pinentry',help='Pinentry binary which will be used, must be in $PATH'"` 29 | AgentTypes agentTypeFlag `kong:"default='ssh=0;gpg=1',help='Agent types to handle'"` 30 | } 31 | 32 | // validAgents is the list of agents supported by piv-agent. 33 | var validAgents = []string{"ssh", "gpg"} 34 | 35 | // AfterApply validates the given agent types. 36 | func (flagAgents *agentTypeFlag) AfterApply() error { 37 | for flagAgent := range map[string]uint(*flagAgents) { 38 | valid := false 39 | for _, validAgent := range validAgents { 40 | if flagAgent == validAgent { 41 | valid = true 42 | } 43 | } 44 | if !valid { 45 | return fmt.Errorf("invalid agent-type: %v", flagAgent) 46 | } 47 | } 48 | return nil 49 | } 50 | 51 | // Run the listen command to start listening for ssh-agent requests. 52 | func (cmd *ServeCmd) Run(log *zap.Logger) error { 53 | log.Info("startup", zap.String("version", version), 54 | zap.String("build date", date)) 55 | pe := pinentry.New(cmd.PinentryBinaryName) 56 | p := piv.New(log, pe) 57 | defer p.CloseAll() 58 | // use FDs passed via socket activation 59 | ls, err := sockets.Get(validAgents) 60 | if err != nil { 61 | return fmt.Errorf("cannot retrieve listeners: %w", err) 62 | } 63 | // validate given agent types 64 | if len(ls) != len(cmd.AgentTypes) { 65 | return fmt.Errorf("wrong number of agent sockets: wanted %v, received %v", 66 | len(cmd.AgentTypes), len(ls)) 67 | } 68 | // prepare dependencies 69 | ctx, cancel := context.WithCancel(context.Background()) 70 | defer cancel() 71 | idle := time.NewTicker(cmd.IdleTimeout) 72 | n := notify.New(log, cmd.TouchNotifyDelay) 73 | g := errgroup.Group{} 74 | // start SSH agent if given in agent-type flag 75 | if _, ok := cmd.AgentTypes["ssh"]; ok { 76 | log.Debug("starting SSH server") 77 | g.Go(func() error { 78 | s := server.NewSSH(log) 79 | a := ssh.NewAgent(p, pe, log, cmd.LoadKeyfile, n, cancel) 80 | err := s.Serve(ctx, a, ls[cmd.AgentTypes["ssh"]], idle, cmd.IdleTimeout) 81 | if err != nil { 82 | log.Debug("exiting SSH server", zap.Error(err)) 83 | } else { 84 | log.Debug("exiting SSH server successfully") 85 | } 86 | cancel() 87 | return err 88 | }) 89 | } 90 | // start GPG agent if given in agent-type flag 91 | home, err := os.UserHomeDir() 92 | if err != nil { 93 | log.Warn("couldn't determine $HOME", zap.Error(err)) 94 | } 95 | fallbackKeys := filepath.Join(home, ".gnupg", "piv-agent.secring") 96 | if _, ok := cmd.AgentTypes["gpg"]; ok { 97 | log.Debug("starting GPG server") 98 | g.Go(func() error { 99 | s := server.NewGPG(p, pe, log, fallbackKeys, n) 100 | err := s.Serve(ctx, ls[cmd.AgentTypes["gpg"]], idle, cmd.IdleTimeout) 101 | if err != nil { 102 | log.Debug("exiting GPG server", zap.Error(err)) 103 | } else { 104 | log.Debug("exiting GPG server successfully") 105 | } 106 | cancel() 107 | return err 108 | }) 109 | } 110 | exit := time.NewTicker(cmd.ExitTimeout) 111 | loop: 112 | for { 113 | select { 114 | case <-ctx.Done(): 115 | log.Debug("exit done") 116 | break loop 117 | case <-idle.C: 118 | log.Debug("idle timeout") 119 | cancel() 120 | break loop 121 | case <-exit.C: 122 | log.Debug("exit timeout") 123 | cancel() 124 | break loop 125 | } 126 | } 127 | return g.Wait() 128 | } 129 | -------------------------------------------------------------------------------- /cmd/piv-agent/setup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strconv" 9 | 10 | "github.com/smlx/piv-agent/internal/pinentry" 11 | "github.com/smlx/piv-agent/internal/securitykey" 12 | "golang.org/x/term" 13 | ) 14 | 15 | // SetupCmd represents the setup command. 16 | type SetupCmd struct { 17 | Card string `kong:"help='Specify a smart card device'"` 18 | ResetSecurityKey bool `kong:"help='Overwrite any existing keys'"` 19 | PIN uint64 `kong:"help='Set the PIN/PUK of the device (6-8 digits). Will be prompted interactively if not provided.'"` 20 | SigningKeys []string `kong:"default='cached,always,never',enum='cached,always,never',help='Generate signing keys with various touch policies (possible values: cached,always,never)'"` 21 | DecryptingKeys []string `kong:"default='cached,always,never',enum='cached,always,never',help='Generate decrypting keys with various touch policies (possible values: cached,always,never)'"` 22 | } 23 | 24 | // interactiveNewPIN prompts twice for a new PIN. 25 | func interactiveNewPIN() (uint64, error) { 26 | fmt.Print("Enter a new PIN/PUK (6-8 digits): ") 27 | rawPIN, err := term.ReadPassword(int(os.Stdin.Fd())) 28 | fmt.Println() 29 | if err != nil { 30 | return 0, fmt.Errorf("couldn't read PIN/PUK: %w", err) 31 | } 32 | pin, err := strconv.ParseUint(string(rawPIN), 10, 64) 33 | if err != nil { 34 | return 0, fmt.Errorf("invalid characters: %w", err) 35 | } 36 | fmt.Print("Repeat PIN/PUK: ") 37 | repeat, err := term.ReadPassword(int(os.Stdin.Fd())) 38 | fmt.Println() 39 | if err != nil { 40 | return 0, fmt.Errorf("couldn't read PIN/PUK: %w", err) 41 | } 42 | if !bytes.Equal(repeat, rawPIN) { 43 | return 0, fmt.Errorf("PIN/PUK entries not equal") 44 | } 45 | return pin, nil 46 | } 47 | 48 | // Run the setup command to configure a security key. 49 | func (cmd *SetupCmd) Run() error { 50 | // if PIN has not been specified, ask interactively 51 | var err error 52 | if cmd.PIN == 0 { 53 | cmd.PIN, err = interactiveNewPIN() 54 | if err != nil { 55 | return err 56 | } 57 | } 58 | if cmd.PIN < 100000 || cmd.PIN > 99999999 { 59 | return fmt.Errorf("invalid PIN, must be 6-8 digits") 60 | } 61 | k, err := securitykey.New(cmd.Card, pinentry.New("pinentry")) 62 | if err != nil { 63 | return fmt.Errorf("couldn't get security key: %v", err) 64 | } 65 | err = k.Setup(strconv.FormatUint(cmd.PIN, 10), version, 66 | cmd.ResetSecurityKey, cmd.SigningKeys, cmd.DecryptingKeys) 67 | if errors.Is(err, securitykey.ErrKeySetUp) { 68 | return fmt.Errorf("--reset-security-key not specified: %w", err) 69 | } 70 | return err 71 | } 72 | -------------------------------------------------------------------------------- /cmd/piv-agent/setupslots.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/smlx/piv-agent/internal/pinentry" 10 | "github.com/smlx/piv-agent/internal/securitykey" 11 | "golang.org/x/term" 12 | ) 13 | 14 | // SetupSlotsCmd represents the setup command. 15 | type SetupSlotsCmd struct { 16 | Card string `kong:"help='Specify a smart card device'"` 17 | ResetSlots bool `kong:"help='Overwrite existing keys in the targeted slots'"` 18 | PIN uint64 `kong:"help='The PIN/PUK of the device (6-8 digits). Will be prompted interactively if not provided.'"` 19 | SigningKeys []string `kong:"enum='cached,always,never',help='Set up slots for signing keys with various touch policies (possible values cached,always,never)'"` 20 | DecryptingKeys []string `kong:"enum='cached,always,never',help='Set up slots for a decrypting keys with various touch polcies (possible values cached,always,never)'"` 21 | } 22 | 23 | // interactivePIN prompts once for an existing PIN. 24 | func interactivePIN() (uint64, error) { 25 | fmt.Print("Enter the PIN/PUK (6-8 digits): ") 26 | rawPIN, err := term.ReadPassword(int(os.Stdin.Fd())) 27 | fmt.Println() 28 | if err != nil { 29 | return 0, fmt.Errorf("couldn't read PIN/PUK: %w", err) 30 | } 31 | pin, err := strconv.ParseUint(string(rawPIN), 10, 64) 32 | if err != nil { 33 | return 0, fmt.Errorf("invalid characters: %w", err) 34 | } 35 | return pin, nil 36 | } 37 | 38 | // Run the setup-slot command to configure a slot on a security key. 39 | func (cmd *SetupSlotsCmd) Run() error { 40 | // validate keys specified 41 | if len(cmd.SigningKeys) == 0 && len(cmd.DecryptingKeys) == 0 { 42 | return fmt.Errorf("at least one key slot must be specified via --signing-keys=... or --decrypting-keys=... ") 43 | } 44 | // if PIN has not been specified, ask interactively 45 | var err error 46 | if cmd.PIN == 0 { 47 | cmd.PIN, err = interactivePIN() 48 | if err != nil { 49 | return err 50 | } 51 | } 52 | if cmd.PIN < 100000 || cmd.PIN > 99999999 { 53 | return fmt.Errorf("invalid PIN, must be 6-8 digits") 54 | } 55 | k, err := securitykey.New(cmd.Card, pinentry.New("pinentry")) 56 | if err != nil { 57 | return fmt.Errorf("couldn't get security key: %v", err) 58 | } 59 | err = k.SetupSlots(strconv.FormatUint(cmd.PIN, 10), version, cmd.ResetSlots, 60 | cmd.SigningKeys, cmd.DecryptingKeys) 61 | if errors.Is(err, securitykey.ErrKeySetUp) { 62 | return fmt.Errorf("--reset-slots not specified: %w", err) 63 | } 64 | return err 65 | } 66 | -------------------------------------------------------------------------------- /cmd/piv-agent/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | // VersionCmd represents the version command. 6 | type VersionCmd struct{} 7 | 8 | // Run the version command to print version information. 9 | func (cmd *VersionCmd) Run() error { 10 | fmt.Printf("piv-agent %v (%v) compiled with %v on %v\n", version, 11 | shortCommit, goVersion, date) 12 | return nil 13 | } 14 | -------------------------------------------------------------------------------- /deploy/launchd/com.github.smlx.piv-agent.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | com.github.smlx.piv-agent 7 | StandardErrorPath 8 | /tmp/piv-agent.err 9 | StandardOutPath 10 | /tmp/piv-agent.out 11 | ProgramArguments 12 | 13 | /usr/local/bin/piv-agent 14 | serve 15 | 16 | Sockets 17 | 18 | ssh 19 | 20 | SecureSocketWithKey 21 | SSH_AUTH_SOCK 22 | 23 | gpg 24 | 25 | SockPathName 26 | /Users/ExampleUserName/.gnupg/S.gpg-agent 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /deploy/systemd/piv-agent.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=piv-agent service 3 | 4 | [Service] 5 | ExecStart=piv-agent serve --agent-types=ssh=0;gpg=1 6 | -------------------------------------------------------------------------------- /deploy/systemd/piv-agent.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=piv-agent socket activation 3 | 4 | [Socket] 5 | ListenStream=%t/piv-agent/ssh.socket 6 | ListenStream=%t/gnupg/S.gpg-agent 7 | 8 | [Install] 9 | WantedBy=sockets.target 10 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /.hugo_build.lock 2 | /node_modules 3 | /package-lock.json 4 | /package.json 5 | /public 6 | /resources 7 | /themes 8 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: serve 2 | serve: 3 | cd .. && \ 4 | docker run --rm -it -v $$(pwd):/src -v $$HOME/.cache/hugo_cache:/home/node/.cache/hugo_cache -u $$(id -u) -p 1313:1313 \ 5 | --entrypoint sh hugomods/hugo:latest -c \ 6 | "cd docs && hugo server --baseURL=/ --bind 0.0.0.0" 7 | 8 | .PHONY: minify 9 | minify: 10 | cd .. && \ 11 | docker run --rm -it -v $$(pwd):/src -v $$HOME/.cache/hugo_cache:/home/node/.cache/hugo_cache -u $$(id -u)\ 12 | --entrypoint sh hugomods/hugo:latest -c \ 13 | "cd docs && npm install postcss-cli autoprefixer && hugo --minify" 14 | -------------------------------------------------------------------------------- /docs/assets/scss/_variables_project.scss: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Add styles or override variables from the theme here. 4 | 5 | */ 6 | 7 | -------------------------------------------------------------------------------- /docs/content/en/_index.html: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "PIV Agent" 3 | linkTitle = "PIV Agent" 4 | 5 | +++ 6 | 7 | {{< blocks/cover title="PIV Agent" image_anchor="top" height="full" color="orange" >}} 8 |
9 | }}"> 10 | Documentation 11 | 12 | 13 | Github / Download 14 | 15 |

16 | An SSH and GPG agent providing simple integration of PIV hardware (e.g. a Yubikey) with existing SSH and GPG workflows. 17 |

18 |
19 | {{< /blocks/cover >}} 20 | -------------------------------------------------------------------------------- /docs/content/en/docs/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "PIV Agent Documentation" 3 | --- 4 | 5 | PIV Agent must be installed and set up to work with your hardware security device before use. 6 | -------------------------------------------------------------------------------- /docs/content/en/docs/faq.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "FAQ" 3 | weight: 40 4 | --- 5 | 6 | ## How do I switch between gpg-agent and piv-agent 7 | 8 | ### Linux (systemd) 9 | 10 | Stop both `gpg-agent` and `piv-agent`: 11 | 12 | {{% alert title="Note" %}} 13 | The `pkill` is required because `gpg` may be configured to automatically start `gpg-agent` in a manner which is not managed by `systemd`. 14 | {{% /alert %}} 15 | 16 | ``` 17 | systemctl --user stop gpg-agent.socket gpg-agent.service piv-agent.socket piv-agent.service; pkill gpg-agent 18 | ``` 19 | 20 | Start `piv-agent` sockets: 21 | 22 | ``` 23 | systemctl --user start piv-agent.socket 24 | ``` 25 | 26 | Or start `gpg-agent` socket: 27 | 28 | ``` 29 | systemctl --user start gpg-agent.socket 30 | ``` 31 | 32 | ### macOS (launchd) 33 | 34 | Stop `piv-agent`: 35 | 36 | ``` 37 | launchctl disable gui/$UID/com.github.smlx.piv-agent 38 | ``` 39 | 40 | Start `piv-agent` sockets: 41 | 42 | ``` 43 | launchctl enable gui/$UID/com.github.smlx.piv-agent 44 | ``` 45 | -------------------------------------------------------------------------------- /docs/content/en/docs/install.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Install" 3 | weight: 10 4 | --- 5 | 6 | ## Prerequisites 7 | 8 | ### Consider redundancy 9 | 10 | If you lose access to your hardware security device (for example if it is lost, stolen, or broken) **there is no way to recover the keys stored on it**. 11 | For that reason it is highly recommended that you use fallback SSH or GPG keyfiles and/or multiple hardware security devices. 12 | 13 | ### Install pcsclite 14 | 15 | `piv-agent` has transitive dependencies through [`piv-go`](https://github.com/go-piv/piv-go#installation), on [`pcsclite`](https://pcsclite.apdu.fr/). 16 | 17 | ``` 18 | # debian / ubuntu 19 | sudo apt install libpcsclite1 20 | # TODO: other platforms 21 | ... 22 | ``` 23 | 24 | ## Install piv-agent 25 | 26 | Download the latest [release](https://github.com/smlx/piv-agent/releases), and extract it to a temporary location. 27 | 28 | ### Linux 29 | 30 | Copy the `piv-agent` binary into your `$PATH`, and the `systemd` unit files to the correct location: 31 | 32 | ``` 33 | sudo cp piv-agent /usr/local/bin/ 34 | cp deploy/systemd/piv-agent.{socket,service} ~/.config/systemd/user/ 35 | systemctl --user daemon-reload 36 | ``` 37 | 38 | ### macOS 39 | 40 | `piv-agent` requires [Homebrew](https://brew.sh) in order to install dependencies. 41 | So install that first. 42 | 43 | Copy the `piv-agent` binary into your `$PATH`, and the `launchd` `.plist` files to the correct location: 44 | 45 | ``` 46 | sudo cp piv-agent /usr/local/bin/ 47 | cp deploy/launchd/com.github.smlx.piv-agent.plist ~/Library/LaunchAgents/ 48 | ``` 49 | 50 | From what I can tell `.plist` files only support absolute file paths, even for user agents. 51 | So edit `~/Library/LaunchAgents/com.github.smlx.piv-agent.plist` and update the path to `$HOME/.gnupg/S.gpg-agent`. 52 | 53 | If you plan to use `gpg`, install it via `brew install gnupg`. 54 | If not, you still need a `pinentry`, so `brew install pinentry`. 55 | 56 | If `~/.gnupg` doesn't already exist, create it. 57 | 58 | ``` 59 | mkdir ~/.gnupg 60 | chmod 700 ~/.gnupg 61 | ``` 62 | 63 | Then enable the service: 64 | 65 | ``` 66 | launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.github.smlx.piv-agent.plist 67 | launchctl enable gui/$UID/com.github.smlx.piv-agent 68 | ``` 69 | 70 | A socket should appear in `~/.gnupg/S.gpg-agent`. 71 | 72 | Disable `ssh-agent` to avoid `SSH_AUTH_SOCK` environment variable conflict. 73 | 74 | ``` 75 | launchctl disable gui/$UID/com.openssh.ssh-agent 76 | ``` 77 | 78 | Set `launchd` user path to include `/usr/local/bin/` for `pinentry`. 79 | 80 | ``` 81 | sudo launchctl config user path $PATH 82 | ``` 83 | 84 | Reboot and log back in. 85 | 86 | ### Socket activation 87 | 88 | `piv-agent` relies on [socket activation](https://0pointer.de/blog/projects/socket-activated-containers.html), and is currently tested with `systemd` on Linux, and `launchd` on macOS. 89 | It doesn't listen to any sockets directly, and instead requires the init system to pass file descriptors to the `piv-agent` process after it is running. 90 | This requirement makes it possible to exit the process when not in use. 91 | 92 | `ssh-agent` and `gpg-agent` functionality are enabled by default in the `systemd` and `launchd` configuration files. 93 | 94 | On Linux, the index of the sockets listed in `piv-agent.socket` are indicated by the arguments to `--agent-types`. 95 | -------------------------------------------------------------------------------- /docs/content/en/docs/setup.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Setup" 3 | weight: 20 4 | description: Set up piv-agent to work with your hardware. 5 | --- 6 | 7 | ## Hardware 8 | 9 | ### Default setup 10 | 11 | {{% alert title="Warning" color="warning" %}} 12 | This procedure resets the state of the PIV applet to factory defaults and wipes any existing keys from _all_ PIV slots. 13 | {{% /alert %}} 14 | 15 | This procedure is only required once per hardware security device. 16 | Performing it a second time will reset the keys on the PIV applet of the device. 17 | It will not make any changes to applets providing other functionality the device may have, such as WebAuthn. 18 | 19 | By default, `piv-agent` uses six slots on your hardware security device to set up three signing keys, and three decrypting key. 20 | Each of the signing and decrypting keys have different [touch policies](https://docs.yubico.com/yesdk/users-manual/application-piv/pin-touch-policies.html): never required, cached (for 15 seconds), and always. 21 | 22 | The three signing keys are used for both SSH and GPG signing. 23 | The decrypting keys are used for GPG decryption. 24 | Having a range of touch policies available facilitates practical use of the hardware security device. 25 | 26 | The default slot usage by `piv-agent` is detailed in the table below, with reference to the [Yubikey certificate slot usage description](https://developers.yubico.com/PIV/Introduction/Certificate_slots.html). 27 | It is highly recommended to use these setup defaults as this has had the most usability testing. 28 | 29 | | Slot ID | Nominal purpose | `piv-agent` usage | Touch policy | 30 | | --- | --- | --- | --- | 31 | | `0x9a` | PIV Authentication | Signing | Cached | 32 | | `0x9c` | Digital Signature | Signing | Always | 33 | | `0x9e` | Card Authentication | Signing | Never | 34 | | `0x9d` | Key Management | Decrypting | Cached | 35 | | `0x82` | Key Management (retired) | Decrypting | Always | 36 | | `0x83` | Key Management (retired) | Decrypting | Never | 37 | 38 | #### Example setup workflow 39 | 40 | ```bash 41 | # find the name of the hardware security devices (cards) 42 | piv-agent list 43 | 44 | # generate new keys (PIN will be requested via interactive prompt) 45 | piv-agent setup --card='Yubico YubiKey FIDO+CCID 01 00' 46 | 47 | # view newly generated keys (SSH only by default) 48 | piv-agent list 49 | ``` 50 | 51 | ### Single slot setup 52 | 53 | {{% alert title="Warning" color="warning" %}} 54 | `piv-agent` has been designed to work best with the default setup. 55 | Only set up single slots if you know what you are doing. 56 | 57 | This action can be destructive. 58 | If you reset a slot which already contains a key, that key will be lost. 59 | {{% /alert %}} 60 | 61 | It is possible to set up a single PIV slot on your hardware device without resetting the PIV applet entirely. 62 | This means that you can target a single slot to set up a key if the slot has not been set up yet, or reset a key if the slot already contains one. 63 | Other PIV slots will not be affected, and will retain their existing keys. 64 | 65 | For example this command will reset just the decrypting key with touch policy `never` on your Yubikey: 66 | 67 | ```bash 68 | piv-agent setup-slots --card="Yubico YubiKey FIDO+CCID 01 00" --pin=123456 --decrypting-keys=never --reset-slots 69 | ``` 70 | 71 | See the interactive help for more usage details: 72 | 73 | ```bash 74 | piv-agent setup-slots --help 75 | ``` 76 | 77 | ## SSH 78 | 79 | ### List keys 80 | 81 | List your hardware SSH keys: 82 | 83 | ```bash 84 | piv-agent list 85 | ``` 86 | 87 | Add the public SSH key with the touch policy you want from the list, to any SSH service. 88 | 89 | ### Set `SSH_AUTH_SOCK` 90 | 91 | Export the `SSH_AUTH_SOCK` variable in your shell. 92 | 93 | ```bash 94 | export SSH_AUTH_SOCK=$XDG_RUNTIME_DIR/piv-agent/ssh.socket 95 | ``` 96 | 97 | ### List keys using ssh-add 98 | 99 | Confirm that `ssh-add` can talk to `piv-agent` by listing the keys available. 100 | 101 | ```bash 102 | ssh-add -L 103 | ``` 104 | 105 | You should see the Yubikey ssh keys listed. 106 | 107 | ### Prefer keys on the hardware security device 108 | 109 | If you don't already have one, it's a good idea to generate an `ed25519` keyfile and add that to all SSH services too for redundancy. 110 | `piv-agent` will automatically load and use `~/.ssh/id_ed25519` as a fallback. 111 | 112 | By default, `ssh` will offer [keyfiles it finds on disk](https://manpages.debian.org/testing/openssh-client/ssh_config.5.en.html#IdentityFile) _before_ those from the agent. 113 | This is a problem because `piv-agent` is designed to offer keys from the hardware token first, and only fall back to local keyfiles if token keys are refused. 114 | 115 | To get `ssh` to offer hardware keys first instead, copy the output of the hardware keys you want to offer from the `ssh-add -L` command to a local file: 116 | 117 | ```bash 118 | # list keys 119 | ssh-add -L 120 | # add output to local file 121 | ssh-add -L | grep cached > ~/.ssh/id_yk_cached.pub 122 | ``` 123 | 124 | And add a line referencing the file to your `ssh_config`. 125 | 126 | ``` 127 | IdentityFile ~/.ssh/id_yk_cached.pub 128 | ``` 129 | 130 | ## GPG 131 | 132 | ### Export fallback cryptographic keys 133 | 134 | Private GPG keys to be used by `piv-agent` must be exported to the directory `~/.gnupg/piv-agent.secring/`. 135 | 136 | {{% alert title="Note" %}} 137 | This step requires `gpg-agent` to be running, not `piv-agent`. 138 | See the [FAQ](../../docs/faq) for how to switch between the two services. 139 | {{% /alert %}} 140 | 141 | {{% alert title="Note" %}} 142 | If your private key is encrypted using a password (it should be!), the encryption is retained during export. 143 | The key is still stored encrypted in the exported keyfile - it's just converted into a standard OpenPGP format that `piv-agent` can read. 144 | {{% /alert %}} 145 | 146 | ```bash 147 | # example 148 | # set umask for user-only permissions 149 | umask 77 150 | mkdir -p ~/.gnupg/piv-agent.secring 151 | gpg --export-secret-key 0xB346A434C7652C02 > ~/.gnupg/piv-agent.secring/art@example.com.gpg 152 | ``` 153 | 154 | ### Disable gpg-agent 155 | 156 | It is not possible to set a custom path for the `gpg-agent` socket in a similar manner to `ssh-agent`. 157 | Instead `gpg-agent` always uses a hard-coded path for its socket. 158 | In order for `piv-agent` to work with `gpg`, it sets up a socket in this same default location. 159 | To avoid conflict over this path, `gpg-agent` should be disabled. 160 | 161 | This is how you can disable `gpg-agent` on Debian/Ubuntu: 162 | 163 | * Add `no-autostart` to `~/.gnupg/gpg.conf`. 164 | * `systemctl --user disable --now gpg-agent.socket gpg-agent.service; pkill gpg-agent` 165 | 166 | Other platforms may have slightly different instructions - PRs welcome. 167 | 168 | ### Import public cryptographic keys from the security hardware 169 | 170 | Before any private GPG keys on the hardware dvice can be used, `gpg` requires their public keys to be imported. 171 | This structure of a GPG public key contains a [User ID packet](https://datatracker.ietf.org/doc/html/rfc4880#section-5.11), which must be signed by the associated _private key_. 172 | 173 | The `piv-agent list` command can synthesize a public key for the private key stored on the security hardware device. 174 | Listing a GPG key via `piv-agent list --key-formats=gpg` will require a touch to perform signing on the keys associated with those slots (due to the User ID packet). 175 | You should provide a name and email which will be embedded in the synthesized public key (see `piv-agent --help list`). 176 | 177 | ```bash 178 | # example 179 | piv-agent list --key-formats=ssh,gpg --pgp-name='Art Vandelay' --pgp-email='art@example.com' 180 | ``` 181 | 182 | Paste the public key(s) you would like to use into a `key.asc` file, and run `gpg --import key.asc`. 183 | 184 | ## GPG Advanced 185 | 186 | If you have followed the setup instructions to this point you should have a functional `gpg-agent` backed by a PIV hardware device. 187 | The following instructions allow deeper integration of the hardware with existing GPG keys and workflows. 188 | 189 | ### Add cryptographic key stored in hardware as a GPG signing subkey 190 | 191 | {{% alert title="Note" %}} 192 | There is a [bug](https://dev.gnupg.org/T5555) in current versions of GnuPG which doesn't allow ECDSA keys to be added as signing subkeys. 193 | This is unfortunate since signing is much more useful than decryption. 194 | 195 | Until this is fixed upstream, [here is a Docker image](https://github.com/smlx/gnupg-piv-agent) containing a patched version of `gpg` which will add ECDSA keys as signing subkeys. 196 | {{% /alert %}} 197 | 198 | Adding a `piv-agent` OpenPGP key as a signing subkey of an existing OpenPGP key is a convenient way to integrate a hardware security device with your existing `gpg` workflow. 199 | This allows you to do things like sign `git` commits using your Yubikey, while keeping the same OpenPGP key ID. 200 | Adding a subkey requires cross-signing between the master key and sub key, so you need to export the master secret key of your existing OpenPGP key as described above to make it available to `piv-agent`. 201 | 202 | `gpg` will choose the _newest_ available subkey to perform an action. So it will automatically prefer a newly added `piv-agent` subkey over any existing keyfile subkeys, but fall back to keyfiles if e.g. the Yubikey is not plugged in. 203 | 204 | See the [GPG Walkthrough](../../docs/gpg-walkthrough) for an example of this procedure. 205 | -------------------------------------------------------------------------------- /docs/content/en/docs/use.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Use" 3 | weight: 30 4 | description: Use piv-agent with ssh and gpg. 5 | --- 6 | 7 | ## Start `piv-agent.socket` 8 | 9 | Start the agent sockets, and test: 10 | 11 | ``` 12 | systemctl --user enable --now piv-agent.socket 13 | ssh-add -l 14 | gpg -K 15 | ``` 16 | 17 | This should be enough to allow you to use `piv-agent`. 18 | 19 | ## Common operations 20 | 21 | ### List keys 22 | 23 | ``` 24 | piv-agent list 25 | ``` 26 | 27 | If this command returns an empty list, it may be because the running agent is holding a transaction to the hardware security device. 28 | The solution is to stop the agent and run the list command again. 29 | 30 | ``` 31 | systemctl --user stop piv-agent 32 | # should work now.. 33 | piv-agent list 34 | ``` 35 | 36 | ## Advanced 37 | 38 | This section describes some ways to enhance the usability of `piv-agent`. 39 | 40 | ### PIN / Passphrase caching 41 | 42 | If your pinentry supports caching credentials, `piv-agent` will offer to cache the PIN of the hardware security device. 43 | It will not cache the passphrase of any fallback keys. 44 | 45 | This is a usability/security tradeoff that ensures that at least the encrypted private key file and its passphrase aren't stored together on disk. 46 | It also has the advantage of ensuring that you don't forget your keyfile passphrase, as you'll need to enter it periodically. 47 | 48 | However you might also forget your device PIN, so maybe don't cache that either if you're concerned about that possibility. 49 | -------------------------------------------------------------------------------- /docs/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/google/docsy-example 2 | 3 | go 1.12 4 | 5 | require github.com/google/docsy v0.9.1 // indirect 6 | -------------------------------------------------------------------------------- /docs/go.sum: -------------------------------------------------------------------------------- 1 | github.com/FortAwesome/Font-Awesome v0.0.0-20240108205627-a1232e345536/go.mod h1:IUgezN/MFpCDIlFezw3L8j83oeiIuYoj28Miwr/KUYo= 2 | github.com/google/docsy v0.9.1 h1:+jqges1YCd+yHeuZ1BUvD8V8mEGVtPxULg5j/vaJ984= 3 | github.com/google/docsy v0.9.1/go.mod h1:saOqKEUOn07Bc0orM/JdIF3VkOanHta9LU5Y53bwN2U= 4 | github.com/twbs/bootstrap v5.2.3+incompatible h1:lOmsJx587qfF7/gE7Vv4FxEofegyJlEACeVV+Mt7cgc= 5 | github.com/twbs/bootstrap v5.2.3+incompatible/go.mod h1:fZTSrkpSf0/HkL0IIJzvVspTt1r9zuf7XlZau8kpcY0= 6 | -------------------------------------------------------------------------------- /docs/hugo.toml: -------------------------------------------------------------------------------- 1 | baseURL = "/piv-agent/" 2 | title = "PIV Agent" 3 | 4 | # Language settings 5 | contentDir = "content/en" 6 | defaultContentLanguage = "en" 7 | defaultContentLanguageInSubdir = false 8 | # Useful when translating. 9 | enableMissingTranslationPlaceholders = true 10 | 11 | enableRobotsTXT = true 12 | 13 | # Will give values to .Lastmod etc. 14 | enableGitInfo = true 15 | 16 | # Comment out to enable taxonomies in Docsy 17 | disableKinds = ["taxonomy"] 18 | 19 | # Highlighting config 20 | pygmentsCodeFences = true 21 | pygmentsUseClasses = false 22 | # Use the new Chroma Go highlighter in Hugo. 23 | pygmentsUseClassic = false 24 | #pygmentsOptions = "linenos=table" 25 | # See https://help.farbox.com/pygments.html 26 | pygmentsStyle = "tango" 27 | 28 | # Configure how URLs look like per section. 29 | [permalinks] 30 | blog = "/:section/:year/:month/:day/:slug/" 31 | 32 | # Image processing configuration. 33 | [imaging] 34 | resampleFilter = "CatmullRom" 35 | quality = 75 36 | anchor = "smart" 37 | 38 | # Language configuration 39 | 40 | [languages] 41 | [languages.en] 42 | languageName ="English" 43 | # Weight used for sorting. 44 | weight = 1 45 | [languages.en.params] 46 | title = "PIV Agent" 47 | description = "PIV Agent replaces ssh-agent and gpg-agent, and allows you to use your PIV security token with SSH or GPG" 48 | 49 | 50 | [markup] 51 | [markup.goldmark] 52 | [markup.goldmark.parser.attribute] 53 | block = true 54 | [markup.goldmark.renderer] 55 | unsafe = true 56 | [markup.highlight] 57 | # See a complete list of available styles at https://xyproto.github.io/splash/docs/all.html 58 | style = "tango" 59 | # Uncomment if you want your chosen highlight style used for code blocks without a specified language 60 | # guessSyntax = "true" 61 | 62 | # Everything below this are Site Params 63 | 64 | # Comment out if you don't want the "print entire section" link enabled. 65 | [outputs] 66 | section = ["HTML", "print"] 67 | 68 | [params] 69 | # First one is picked as the Twitter card image if not set on page. 70 | # images = ["images/project-illustration.png"] 71 | 72 | # Menu title if your navbar has a versions selector to access old versions of your site. 73 | # This menu appears only if you have at least one [params.versions] set. 74 | version_menu = "Releases" 75 | 76 | # Flag used in the "version-banner" partial to decide whether to display a 77 | # banner on every page indicating that this is an archived version of the docs. 78 | # Set this flag to "true" if you want to display the banner. 79 | archived_version = false 80 | 81 | # The version number for the version of the docs represented in this doc set. 82 | # Used in the "version-banner" partial to display a version number for the 83 | # current doc set. 84 | version = "0.0" 85 | 86 | # A link to latest version of the docs. Used in the "version-banner" partial to 87 | # point people to the main doc site. 88 | url_latest_version = "https://example.com" 89 | 90 | # Repository configuration (URLs for in-page links to opening issues and suggesting changes) 91 | github_repo = "https://github.com/smlx/piv-agent" 92 | # An optional link to a related project repo. For example, the sibling repository where your product code lives. 93 | # github_project_repo = "https://github.com/google/docsy" 94 | 95 | # Specify a value here if your content directory is not in your repo's root directory 96 | # github_subdir = "" 97 | 98 | # Uncomment this if your GitHub repo does not have "main" as the default branch, 99 | # or specify a new value if you want to reference another branch in your GitHub links 100 | github_branch= "main" 101 | 102 | # Google Custom Search Engine ID. Remove or comment out to disable search. 103 | # gcs_engine_id = "d72aa9b2712488cc3" 104 | 105 | # Enable Lunr.js offline search 106 | offlineSearch = true 107 | 108 | # Enable syntax highlighting and copy buttons on code blocks with Prism 109 | prism_syntax_highlighting = false 110 | 111 | [params.copyright] 112 | authors = "PIV Agent Contributors | [CC BY SA 4.0](https://creativecommons.org/licenses/by-sa/4.0)" 113 | from_year = 2020 114 | 115 | # User interface configuration 116 | [params.ui] 117 | # Set to true to disable breadcrumb navigation. 118 | breadcrumb_disable = false 119 | # Set to false if you don't want to display a logo (/assets/icons/logo.svg) in the top navbar 120 | navbar_logo = true 121 | # Set to true if you don't want the top navbar to be translucent when over a `block/cover`, like on the homepage. 122 | navbar_translucent_over_cover_disable = false 123 | # Enable to show the side bar menu in its compact state. 124 | sidebar_menu_compact = false 125 | # Set to true to hide the sidebar search box (the top nav search box will still be displayed if search is enabled) 126 | sidebar_search_disable = false 127 | 128 | # Adds a H2 section titled "Feedback" to the bottom of each doc. The responses are sent to Google Analytics as events. 129 | # This feature depends on [services.googleAnalytics] and will be disabled if "services.googleAnalytics.id" is not set. 130 | # If you want this feature, but occasionally need to remove the "Feedback" section from a single page, 131 | # add "hide_feedback: true" to the page's front matter. 132 | [params.ui.feedback] 133 | enable = false 134 | # The responses that the user sees after clicking "yes" (the page was helpful) or "no" (the page was not helpful). 135 | yes = 'Glad to hear it! Please tell us how we can improve.' 136 | no = 'Sorry to hear that. Please tell us how we can improve.' 137 | 138 | # Adds a reading time to the top of each doc. 139 | # If you want this feature, but occasionally need to remove the Reading time from a single page, 140 | # add "hide_readingtime: true" to the page's front matter 141 | [params.ui.readingtime] 142 | enable = false 143 | 144 | [params.links] 145 | # End user relevant links. These will show up on left side of footer and in the community page if you have one. 146 | # Developer relevant links. These will show up on right side of footer and in the community page if you have one. 147 | [[params.links.developer]] 148 | name = "GitHub" 149 | url = "https://github.com/smlx/piv-agent" 150 | icon = "fab fa-github" 151 | desc = "Development takes place here!" 152 | 153 | # hugo module configuration 154 | 155 | [module] 156 | # Uncomment the next line to build and serve using local docsy clone declared in the named Hugo workspace: 157 | # workspace = "docsy.work" 158 | [module.hugoVersion] 159 | extended = true 160 | min = "0.110.0" 161 | [[module.imports]] 162 | path = "github.com/google/docsy" 163 | disable = false 164 | -------------------------------------------------------------------------------- /docs/layouts/404.html: -------------------------------------------------------------------------------- 1 | {{ define "main" -}} 2 |
3 |

Not found

4 |

Oops! This page doesn't exist. Try going back to the home page.

5 |

You can learn how to make a 404 page like this in Custom 404 Pages.

6 |
7 | {{- end }} 8 | -------------------------------------------------------------------------------- /docs/layouts/_default/_markup/render-heading.html: -------------------------------------------------------------------------------- 1 | {{ template "_default/_markup/td-render-heading.html" . }} 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/smlx/piv-agent 2 | 3 | go 1.23.2 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | filippo.io/nistec v0.0.3 9 | github.com/ProtonMail/go-crypto v0.0.0-20230316153859-cb82d937a5d9 10 | github.com/alecthomas/kong v1.11.0 11 | github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf 12 | github.com/davecgh/go-spew v1.1.1 13 | github.com/gen2brain/beeep v0.0.0-20200526185328-e9c15c258e28 14 | github.com/go-piv/piv-go/v2 v2.3.0 15 | github.com/smlx/fsm v0.2.1 16 | github.com/twpayne/go-pinentry-minimal v0.0.0-20220113210447-2a5dc4396c2a 17 | github.com/x13a/go-launch v0.0.0-20210715084817-fd409384939b 18 | go.uber.org/mock v0.5.2 19 | go.uber.org/zap v1.27.0 20 | golang.org/x/crypto v0.38.0 21 | golang.org/x/sync v0.14.0 22 | golang.org/x/term v0.32.0 23 | ) 24 | 25 | require ( 26 | github.com/cloudflare/circl v1.3.7 // indirect 27 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect 28 | github.com/godbus/dbus/v5 v5.0.3 // indirect 29 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect 30 | github.com/gopherjs/gopherwasm v1.1.0 // indirect 31 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect 32 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect 33 | go.uber.org/multierr v1.10.0 // indirect 34 | golang.org/x/sys v0.33.0 // indirect 35 | ) 36 | 37 | replace github.com/ProtonMail/go-crypto => github.com/smlx/go-crypto v0.0.0-20230324130354-fc893cd601c2 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/nistec v0.0.3 h1:h336Je2jRDZdBCLy2fLDUd9E2unG32JLwcJi0JQE9Cw= 2 | filippo.io/nistec v0.0.3/go.mod h1:84fxC9mi+MhC2AERXI4LSa8cmSVOzrFikg6hZ4IfCyw= 3 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 4 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 5 | github.com/alecthomas/kong v1.11.0 h1:y++1gI7jf8O7G7l4LZo5ASFhrhJvzc+WgF/arranEmM= 6 | github.com/alecthomas/kong v1.11.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= 7 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 8 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 9 | github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 10 | github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= 11 | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= 12 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 13 | github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= 14 | github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 15 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/gen2brain/beeep v0.0.0-20200526185328-e9c15c258e28 h1:M2Zt3G2w6Q57GZndOYk42p7RvMeO8izO8yKTfIxGqxA= 18 | github.com/gen2brain/beeep v0.0.0-20200526185328-e9c15c258e28/go.mod h1:ElSskYZe3oM8kThaHGJ+kiN2yyUMVXMZ7WxF9QqLDS8= 19 | github.com/go-piv/piv-go/v2 v2.3.0 h1:kKkrYlgLQTMPA6BiSL25A7/x4CEh2YCG7rtb/aTkx+g= 20 | github.com/go-piv/piv-go/v2 v2.3.0/go.mod h1:ShZi74nnrWNQEdWzRUd/3cSig3uNOcEZp+EWl0oewnI= 21 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= 22 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= 23 | github.com/godbus/dbus/v5 v5.0.3 h1:ZqHaoEF7TBzh4jzPmqVhE/5A1z9of6orkAe5uHoAeME= 24 | github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 25 | github.com/gopherjs/gopherjs v0.0.0-20180825215210-0210a2f0f73c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 26 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 27 | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 28 | github.com/gopherjs/gopherwasm v1.1.0 h1:fA2uLoctU5+T3OhOn2vYP0DVT6pxc7xhTlBB1paATqQ= 29 | github.com/gopherjs/gopherwasm v1.1.0/go.mod h1:SkZ8z7CWBz5VXbhJel8TxCmAcsQqzgWGR/8nMhyhZSI= 30 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 31 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 32 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= 33 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= 34 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 35 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 36 | github.com/smlx/fsm v0.2.1 h1:JKGqQa7Fmmn9UEK1JDh0BTEoeRfsvKdxlrkY3sBonc8= 37 | github.com/smlx/fsm v0.2.1/go.mod h1:LiXoNZ+m3neHxSVsc8KN7ed0mbiY6K/1MKj+HcZzhkQ= 38 | github.com/smlx/go-crypto v0.0.0-20230324130354-fc893cd601c2 h1:n4enF1jCKh/Rokt4i8gTL0alf6k0vf4BxQSYtokPKPU= 39 | github.com/smlx/go-crypto v0.0.0-20230324130354-fc893cd601c2/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE= 40 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 41 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 42 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= 43 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= 44 | github.com/twpayne/go-pinentry-minimal v0.0.0-20220113210447-2a5dc4396c2a h1:a1bRrtgkiv0tytmDVXU5Dqse/WOTws7JvsY2WxPMZ6M= 45 | github.com/twpayne/go-pinentry-minimal v0.0.0-20220113210447-2a5dc4396c2a/go.mod h1:ARJJXqNuaxVS84jX6ST52hQh0TtuQZWABhTe95a6BI4= 46 | github.com/x13a/go-launch v0.0.0-20210715084817-fd409384939b h1:rpNT9cyxH8nsCM8htO1SLhrehyt74GFczE9s/O6WkfE= 47 | github.com/x13a/go-launch v0.0.0-20210715084817-fd409384939b/go.mod h1:kfVYr1hMcmOVxZt+2kFzCXf/YRX9Cz+F1QkijZQMaMM= 48 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 49 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 50 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 51 | go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= 52 | go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= 53 | go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= 54 | go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 55 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 56 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 57 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 58 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 59 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 60 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 61 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 62 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 63 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 64 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 65 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 66 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 67 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 68 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 69 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 70 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 71 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 72 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 73 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 74 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 75 | golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 76 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 77 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 78 | golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 81 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 83 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 84 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 85 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 86 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 87 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 88 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 89 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 90 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 91 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 92 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 93 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 94 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 95 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 96 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 97 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 98 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 99 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 100 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 101 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 102 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 103 | -------------------------------------------------------------------------------- /internal/assuan/README.md: -------------------------------------------------------------------------------- 1 | Generate sample ECC key like so 2 | 3 | ``` 4 | gpg --full-gen-key --expert 5 | # select ECC sign only 6 | # use e.g. Name: foo bar, Email: foo@example.com 7 | ``` 8 | 9 | Generate signing traces like so: 10 | 11 | ``` 12 | echo foo | strace -xs 1024 /usr/bin/gpg --verbose --status-fd=2 -bsau C54A8868468BC138 2> gpg-agent.sign.strace 13 | # grep the agent socket 14 | grep '(5' 15 | # reads 16 | grep '^read' 17 | # writes 18 | grep '^write' 19 | ``` 20 | 21 | Export key for use in CI: 22 | ``` 23 | gpg --export -ao /tmp/C54A8868468BC138.asc foo@example.com 24 | ``` 25 | -------------------------------------------------------------------------------- /internal/assuan/assuan.go: -------------------------------------------------------------------------------- 1 | // Package assuan implements an libgcrypt Assuan protocol server. 2 | package assuan 3 | 4 | //go:generate mockgen -source=assuan.go -destination=../mock/mock_assuan.go -package=mock 5 | 6 | import ( 7 | "bufio" 8 | "bytes" 9 | "crypto" 10 | "encoding/hex" 11 | "fmt" 12 | "io" 13 | "strconv" 14 | "strings" 15 | 16 | "github.com/ProtonMail/go-crypto/openpgp" 17 | "github.com/smlx/fsm" 18 | "github.com/smlx/piv-agent/internal/notify" 19 | "go.uber.org/zap" 20 | ) 21 | 22 | // version indicates the version of gpg-agent to emulate. 23 | // The gpg CLI client will emit a warning if this is lower than the version of 24 | // the gpg client itself. 25 | const version = "2.4.7" 26 | 27 | // The KeyService interface provides functions used by the Assuan FSM. 28 | type KeyService interface { 29 | Name() string 30 | HaveKey([][]byte) (bool, []byte, error) 31 | Keygrips() ([][]byte, error) 32 | GetSigner([]byte) (crypto.Signer, error) 33 | GetDecrypter([]byte) (crypto.Decrypter, error) 34 | } 35 | 36 | // New initialises a new gpg-agent server assuan FSM. 37 | // It returns a *fsm.Machine configured in the ready state. 38 | func New(rw io.ReadWriter, log *zap.Logger, n *notify.Notify, 39 | ks ...KeyService) *Assuan { 40 | var signature []byte 41 | var keygrips, hash [][]byte 42 | assuan := Assuan{ 43 | notify: n, 44 | reader: bufio.NewReader(rw), 45 | Machine: fsm.Machine{ 46 | State: fsm.State(ready), 47 | Transitions: assuanTransitions, 48 | }, 49 | } 50 | assuan.OnEntry = map[fsm.State][]fsm.TransitionFunc{ 51 | fsm.State(connected): { 52 | func(e fsm.Event, _ fsm.State) error { 53 | var err error 54 | switch Event(e) { 55 | case connect: 56 | // acknowledge connection using the format expected by the client 57 | _, err = io.WriteString(rw, 58 | "OK Pleased to meet you, process 123456789\n") 59 | case reset: 60 | assuan.reset() 61 | _, err = io.WriteString(rw, "OK\n") 62 | case option: 63 | // ignore option values - piv-agent doesn't use them 64 | _, err = io.WriteString(rw, "OK\n") 65 | case getinfo: 66 | if bytes.Equal(assuan.data[0], []byte("version")) { 67 | // masquerade as a compatible gpg-agent 68 | _, err = fmt.Fprintf(rw, "D %s\nOK\n", version) 69 | } else { 70 | err = fmt.Errorf("unknown getinfo command: %q", assuan.data[0]) 71 | } 72 | case havekey: 73 | err = assuan.havekey(rw, ks) 74 | case keyinfo: 75 | err = doKeyinfo(rw, assuan.data, ks) 76 | case scd: 77 | // ignore scdaemon requests 78 | _, err = io.WriteString(rw, "ERR 100696144 No such device \n") 79 | case readkey: 80 | // READKEY argument is a keygrip, optionally prefixed by "--". 81 | if bytes.Equal(assuan.data[0], []byte("--")) { 82 | assuan.data = assuan.data[1:] 83 | } 84 | // return information about the given key 85 | keygrips, err = hexDecode(assuan.data...) 86 | if err != nil { 87 | return fmt.Errorf("couldn't decode keygrips: %v", err) 88 | } 89 | var signer crypto.Signer 90 | for _, k := range ks { 91 | signer, err = k.GetSigner(keygrips[0]) 92 | if err == nil { 93 | break 94 | } 95 | } 96 | if signer == nil { 97 | _, _ = io.WriteString(rw, "ERR 1 couldn't match keygrip\n") 98 | return fmt.Errorf("couldn't match keygrip: %v", err) 99 | } 100 | var data string 101 | data, err = readKeyData(signer.Public()) 102 | if err != nil { 103 | _, _ = io.WriteString(rw, "ERR 1 couldn't get key data\n") 104 | return fmt.Errorf("couldn't get key data: %v", err) 105 | } 106 | _, err = io.WriteString(rw, data) 107 | case setkeydesc: 108 | // ignore this event since we don't currently use the client's 109 | // description in the prompt 110 | _, err = io.WriteString(rw, "OK\n") 111 | case passwd: 112 | // ignore this event since we assume that if the key is decrypted the 113 | // user has permissions 114 | _, err = io.WriteString(rw, "OK\n") 115 | default: 116 | return fmt.Errorf("unknown event: %v", e) 117 | } 118 | return err 119 | }, 120 | }, 121 | fsm.State(signingKeyIsSet): { 122 | func(e fsm.Event, _ fsm.State) error { 123 | var err error 124 | switch Event(e) { 125 | case sigkey: 126 | // SIGKEY has a single argument: a keygrip indicating the key which 127 | // will be used for subsequent signing operations 128 | keygrips, err = hexDecode(assuan.data...) 129 | if err != nil { 130 | return fmt.Errorf("couldn't decode keygrips: %v", err) 131 | } 132 | for _, k := range ks { 133 | assuan.signer, err = k.GetSigner(keygrips[0]) 134 | if err == nil { 135 | break 136 | } 137 | } 138 | if err != nil { 139 | _, _ = io.WriteString(rw, "ERR 1 couldn't get key for keygrip\n") 140 | return fmt.Errorf("couldn't get key for keygrip: %v", err) 141 | } 142 | _, err = io.WriteString(rw, "OK\n") 143 | case setkeydesc: 144 | // ignore this event since we don't currently use the client's 145 | // description in the prompt 146 | _, err = io.WriteString(rw, "OK\n") 147 | default: 148 | return fmt.Errorf("unknown event: %v", Event(e)) 149 | } 150 | return err 151 | }, 152 | }, 153 | fsm.State(hashIsSet): { 154 | func(e fsm.Event, _ fsm.State) error { 155 | var err error 156 | switch Event(e) { 157 | case sethash: 158 | // record the algorithm and hash 159 | var n uint64 160 | n, err = strconv.ParseUint(string(assuan.data[0]), 10, 8) 161 | if err != nil { 162 | return fmt.Errorf("couldn't parse uint %s: %v", assuan.data[0], err) 163 | } 164 | var ok bool 165 | if assuan.hashAlgo, ok = openpgp.HashIdToHash(uint8(n)); !ok { 166 | return fmt.Errorf("invalid hash algorithm value: %x", n) 167 | } 168 | hash, err = hexDecode(assuan.data[1:]...) 169 | if err != nil { 170 | return fmt.Errorf("couldn't decode hash: %v", err) 171 | } 172 | assuan.hash = hash[0] 173 | _, err = io.WriteString(rw, "OK\n") 174 | case pksign: 175 | signature, err = assuan.sign() 176 | if err != nil { 177 | return fmt.Errorf("couldn't sign: %v", err) 178 | } 179 | _, err = rw.Write(signature) 180 | if err != nil { 181 | return fmt.Errorf("couldn't write signature: %v", err) 182 | } 183 | _, err = io.WriteString(rw, "\n") 184 | if err != nil { 185 | return fmt.Errorf("couldn't write newline: %v", err) 186 | } 187 | _, err = io.WriteString(rw, "OK\n") 188 | case keyinfo: 189 | err = doKeyinfo(rw, assuan.data, ks) 190 | default: 191 | return fmt.Errorf("unknown event: %v", Event(e)) 192 | } 193 | return err 194 | }, 195 | }, 196 | fsm.State(decryptingKeyIsSet): { 197 | func(e fsm.Event, _ fsm.State) error { 198 | var err error 199 | switch Event(e) { 200 | case setkey: 201 | // SETKEY has a single argument: a keygrip indicating the key which 202 | // will be used for subsequent decrypting operations 203 | keygrips, err = hexDecode(assuan.data...) 204 | if err != nil { 205 | return fmt.Errorf("couldn't decode keygrips: %v", err) 206 | } 207 | for _, k := range ks { 208 | assuan.decrypter, err = k.GetDecrypter(keygrips[0]) 209 | if err == nil { 210 | break 211 | } 212 | } 213 | if err != nil { 214 | _, _ = io.WriteString(rw, "ERR 1 couldn't get key for keygrip\n") 215 | log.Warn("couldn't get key for keygrip", zap.Error(err)) 216 | return nil // this is not a fatal error 217 | } 218 | _, err = io.WriteString(rw, "OK\n") 219 | case setkeydesc: 220 | // ignore this event since we don't currently use the client's 221 | // description in the prompt 222 | _, err = io.WriteString(rw, "OK\n") 223 | default: 224 | return fmt.Errorf("unknown event: %v", Event(e)) 225 | } 226 | return err 227 | }, 228 | }, 229 | fsm.State(waitingForCiphertext): { 230 | func(e fsm.Event, _ fsm.State) error { 231 | var err error 232 | switch Event(e) { 233 | case pkdecrypt: 234 | // once we receive PKDECRYPT we enter a "reversed" state where the 235 | // agent drives the client by sending commands. 236 | // ask for ciphertext 237 | _, err = io.WriteString(rw, 238 | "S INQUIRE_MAXLEN 4096\nINQUIRE CIPHERTEXT\n") 239 | if err != nil { 240 | return err 241 | } 242 | var chunk []byte 243 | var chunks [][]byte 244 | scanner := bufio.NewScanner(assuan.reader) 245 | for scanner.Scan() { 246 | chunk = scanner.Bytes() 247 | if bytes.Equal([]byte("END"), chunk) { 248 | break // end of ciphertext 249 | } 250 | chunks = append(chunks, chunk) 251 | } 252 | if len(chunks) < 1 { 253 | return fmt.Errorf("invalid ciphertext format") 254 | } 255 | var plaintext, ciphertext []byte 256 | ciphertext = bytes.Join(chunks, []byte("\n")) 257 | // start notify timer 258 | cancel := assuan.notify.Touch() 259 | defer cancel() 260 | plaintext, err = assuan.decrypter.Decrypt(nil, ciphertext, nil) 261 | if err != nil { 262 | return fmt.Errorf("couldn't decrypt: %v", err) 263 | } 264 | _, err = rw.Write(plaintext) 265 | case setkeydesc: 266 | // ignore this event since we don't currently use the client's 267 | // description in the prompt 268 | _, err = io.WriteString(rw, "OK\n") 269 | case havekey: 270 | // gpg skips the RESET command occasionally so we have to emulate it. 271 | assuan.reset() 272 | // now jump straight to havekey 273 | if err = assuan.havekey(rw, ks); err != nil { 274 | return err 275 | } 276 | _, err = io.WriteString(rw, "OK\n") 277 | default: 278 | return fmt.Errorf("unknown event: %v", Event(e)) 279 | } 280 | return err 281 | }, 282 | }, 283 | } 284 | return &assuan 285 | } 286 | 287 | func (assuan *Assuan) reset() { 288 | assuan.signer = nil 289 | assuan.decrypter = nil 290 | assuan.hashAlgo = 0 291 | assuan.hash = []byte{} 292 | } 293 | 294 | func (assuan *Assuan) havekey(rw io.ReadWriter, ks []KeyService) error { 295 | var err error 296 | var keyFound bool 297 | var keygrips [][]byte 298 | // HAVEKEY arguments are either: 299 | // * a list of keygrips; or 300 | // * --list=1000 301 | // if _any_ key is available, we return OK, otherwise No_Secret_Key. 302 | // handle --list 303 | if bytes.HasPrefix(assuan.data[0], []byte("--list")) { 304 | var grips []byte 305 | grips, err = allKeygrips(ks) 306 | if err != nil { 307 | _, _ = io.WriteString(rw, "ERR 1 couldn't list keygrips\n") 308 | return err 309 | } 310 | // apply libgcrypt encoding 311 | _, err = io.WriteString(rw, fmt.Sprintf("D %s\nOK\n", 312 | PercentEncodeSExp(grips))) 313 | return err 314 | } 315 | // handle list of keygrips 316 | keygrips, err = hexDecode(assuan.data...) 317 | if err != nil { 318 | return fmt.Errorf("couldn't decode keygrips: %v", err) 319 | } 320 | keyFound, _, err = haveKey(ks, keygrips) 321 | if err != nil { 322 | _, _ = io.WriteString(rw, "ERR 1 couldn't check for keygrip\n") 323 | return err 324 | } 325 | if keyFound { 326 | _, err = io.WriteString(rw, "OK\n") 327 | } else { 328 | _, err = io.WriteString(rw, "No_Secret_Key\n") 329 | } 330 | return err 331 | } 332 | 333 | // doKeyinfo checks for key availability by keygrip, writing the result to rw. 334 | func doKeyinfo(rw io.ReadWriter, data [][]byte, ks []KeyService) error { 335 | // KEYINFO arguments are a list of keygrips 336 | // if _any_ key is available, we return info, otherwise 337 | // No_Secret_Key. 338 | keygrips, err := hexDecode(data...) 339 | if err != nil { 340 | return fmt.Errorf("couldn't decode keygrips: %v", err) 341 | } 342 | keyFound, keygrip, err := haveKey(ks, keygrips) 343 | if err != nil { 344 | _, _ = io.WriteString(rw, "ERR 1 couldn't match keygrip\n") 345 | return fmt.Errorf("couldn't match keygrip: %v", err) 346 | } 347 | if keyFound { 348 | _, err = io.WriteString(rw, 349 | fmt.Sprintf("S KEYINFO %s D - - - - - - -\nOK\n", 350 | strings.ToUpper(hex.EncodeToString(keygrip)))) 351 | return err 352 | } 353 | _, err = io.WriteString(rw, "No_Secret_Key\n") 354 | return err 355 | } 356 | 357 | // haveKey returns true if any of the keygrips refer to keys known locally, and 358 | // false otherwise. 359 | // It takes keygrips in raw byte format, so keygrip in hex-encoded form must 360 | // first be decoded before being passed to this function. It returns the 361 | // keygrip found. 362 | func haveKey(ks []KeyService, keygrips [][]byte) (bool, []byte, error) { 363 | var keyFound bool 364 | var keygrip []byte 365 | var err error 366 | for _, k := range ks { 367 | keyFound, keygrip, err = k.HaveKey(keygrips) 368 | if err != nil { 369 | return false, nil, fmt.Errorf("couldn't check %s keygrips: %v", k.Name(), err) 370 | } 371 | if keyFound { 372 | return true, keygrip, nil 373 | } 374 | } 375 | return false, nil, nil 376 | } 377 | 378 | // allKeygrips returns all keygrips available for any of the given keyservices, 379 | // concatenated into a single byte slice. 380 | func allKeygrips(ks []KeyService) ([]byte, error) { 381 | var grips []byte 382 | for _, k := range ks { 383 | kgs, err := k.Keygrips() 384 | if err != nil { 385 | return nil, fmt.Errorf("couldn't get keygrips for %s: %v", k.Name(), err) 386 | } 387 | for _, kg := range kgs { 388 | grips = append(grips, kg...) 389 | } 390 | } 391 | return grips, nil 392 | } 393 | 394 | // hexDecode take a list of hex-encoded bytestring values and converts them to 395 | // their raw byte representation. 396 | func hexDecode(data ...[]byte) ([][]byte, error) { 397 | var decoded [][]byte 398 | for _, d := range data { 399 | dst := make([]byte, hex.DecodedLen(len(d))) 400 | _, err := hex.Decode(dst, d) 401 | if err != nil { 402 | return nil, err 403 | } 404 | decoded = append(decoded, dst) 405 | } 406 | return decoded, nil 407 | } 408 | -------------------------------------------------------------------------------- /internal/assuan/encode.go: -------------------------------------------------------------------------------- 1 | package assuan 2 | 3 | import "bytes" 4 | 5 | // Work around bug(?) in gnupg where some byte sequences are 6 | // percent-encoded in the sexp. Yes, really. NFI what to do if the 7 | // percent-encoded byte sequences themselves are part of the 8 | // ciphertext. Yikes. 9 | // 10 | // These two functions represent over a week of late nights stepping through 11 | // debug builds of libcrypt in gdb :-( 12 | 13 | // PercentDecodeSExp replaces the percent-encoded byte sequences with their raw 14 | // byte values. 15 | func PercentDecodeSExp(data []byte) []byte { 16 | data = bytes.ReplaceAll(data, []byte{0x25, 0x32, 0x35}, []byte{0x25}) // % 17 | data = bytes.ReplaceAll(data, []byte{0x25, 0x30, 0x41}, []byte{0x0a}) // \n 18 | data = bytes.ReplaceAll(data, []byte{0x25, 0x30, 0x44}, []byte{0x0d}) // \r 19 | return data 20 | } 21 | 22 | // PercentEncodeSExp replaces the raw byte values with their percent-encoded 23 | // byte sequences. 24 | func PercentEncodeSExp(data []byte) []byte { 25 | data = bytes.ReplaceAll(data, []byte{0x25}, []byte{0x25, 0x32, 0x35}) 26 | data = bytes.ReplaceAll(data, []byte{0x0a}, []byte{0x25, 0x30, 0x41}) 27 | data = bytes.ReplaceAll(data, []byte{0x0d}, []byte{0x25, 0x30, 0x44}) 28 | return data 29 | } 30 | -------------------------------------------------------------------------------- /internal/assuan/event_enumer.go: -------------------------------------------------------------------------------- 1 | // Code generated by "enumer -type=Event -text -transform upper"; DO NOT EDIT. 2 | 3 | package assuan 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | const _EventName = "INVALIDEVENTCONNECTRESETOPTIONGETINFOHAVEKEYKEYINFOSIGKEYSETKEYDESCSETHASHPKSIGNSETKEYPKDECRYPTSCDREADKEYPASSWD" 11 | 12 | var _EventIndex = [...]uint8{0, 12, 19, 24, 30, 37, 44, 51, 57, 67, 74, 80, 86, 95, 98, 105, 111} 13 | 14 | const _EventLowerName = "invalideventconnectresetoptiongetinfohavekeykeyinfosigkeysetkeydescsethashpksignsetkeypkdecryptscdreadkeypasswd" 15 | 16 | func (i Event) String() string { 17 | if i < 0 || i >= Event(len(_EventIndex)-1) { 18 | return fmt.Sprintf("Event(%d)", i) 19 | } 20 | return _EventName[_EventIndex[i]:_EventIndex[i+1]] 21 | } 22 | 23 | // An "invalid array index" compiler error signifies that the constant values have changed. 24 | // Re-run the stringer command to generate them again. 25 | func _EventNoOp() { 26 | var x [1]struct{} 27 | _ = x[invalidEvent-(0)] 28 | _ = x[connect-(1)] 29 | _ = x[reset-(2)] 30 | _ = x[option-(3)] 31 | _ = x[getinfo-(4)] 32 | _ = x[havekey-(5)] 33 | _ = x[keyinfo-(6)] 34 | _ = x[sigkey-(7)] 35 | _ = x[setkeydesc-(8)] 36 | _ = x[sethash-(9)] 37 | _ = x[pksign-(10)] 38 | _ = x[setkey-(11)] 39 | _ = x[pkdecrypt-(12)] 40 | _ = x[scd-(13)] 41 | _ = x[readkey-(14)] 42 | _ = x[passwd-(15)] 43 | } 44 | 45 | var _EventValues = []Event{invalidEvent, connect, reset, option, getinfo, havekey, keyinfo, sigkey, setkeydesc, sethash, pksign, setkey, pkdecrypt, scd, readkey, passwd} 46 | 47 | var _EventNameToValueMap = map[string]Event{ 48 | _EventName[0:12]: invalidEvent, 49 | _EventLowerName[0:12]: invalidEvent, 50 | _EventName[12:19]: connect, 51 | _EventLowerName[12:19]: connect, 52 | _EventName[19:24]: reset, 53 | _EventLowerName[19:24]: reset, 54 | _EventName[24:30]: option, 55 | _EventLowerName[24:30]: option, 56 | _EventName[30:37]: getinfo, 57 | _EventLowerName[30:37]: getinfo, 58 | _EventName[37:44]: havekey, 59 | _EventLowerName[37:44]: havekey, 60 | _EventName[44:51]: keyinfo, 61 | _EventLowerName[44:51]: keyinfo, 62 | _EventName[51:57]: sigkey, 63 | _EventLowerName[51:57]: sigkey, 64 | _EventName[57:67]: setkeydesc, 65 | _EventLowerName[57:67]: setkeydesc, 66 | _EventName[67:74]: sethash, 67 | _EventLowerName[67:74]: sethash, 68 | _EventName[74:80]: pksign, 69 | _EventLowerName[74:80]: pksign, 70 | _EventName[80:86]: setkey, 71 | _EventLowerName[80:86]: setkey, 72 | _EventName[86:95]: pkdecrypt, 73 | _EventLowerName[86:95]: pkdecrypt, 74 | _EventName[95:98]: scd, 75 | _EventLowerName[95:98]: scd, 76 | _EventName[98:105]: readkey, 77 | _EventLowerName[98:105]: readkey, 78 | _EventName[105:111]: passwd, 79 | _EventLowerName[105:111]: passwd, 80 | } 81 | 82 | var _EventNames = []string{ 83 | _EventName[0:12], 84 | _EventName[12:19], 85 | _EventName[19:24], 86 | _EventName[24:30], 87 | _EventName[30:37], 88 | _EventName[37:44], 89 | _EventName[44:51], 90 | _EventName[51:57], 91 | _EventName[57:67], 92 | _EventName[67:74], 93 | _EventName[74:80], 94 | _EventName[80:86], 95 | _EventName[86:95], 96 | _EventName[95:98], 97 | _EventName[98:105], 98 | _EventName[105:111], 99 | } 100 | 101 | // EventString retrieves an enum value from the enum constants string name. 102 | // Throws an error if the param is not part of the enum. 103 | func EventString(s string) (Event, error) { 104 | if val, ok := _EventNameToValueMap[s]; ok { 105 | return val, nil 106 | } 107 | 108 | if val, ok := _EventNameToValueMap[strings.ToLower(s)]; ok { 109 | return val, nil 110 | } 111 | return 0, fmt.Errorf("%s does not belong to Event values", s) 112 | } 113 | 114 | // EventValues returns all values of the enum 115 | func EventValues() []Event { 116 | return _EventValues 117 | } 118 | 119 | // EventStrings returns a slice of all String values of the enum 120 | func EventStrings() []string { 121 | strs := make([]string, len(_EventNames)) 122 | copy(strs, _EventNames) 123 | return strs 124 | } 125 | 126 | // IsAEvent returns "true" if the value is listed in the enum definition. "false" otherwise 127 | func (i Event) IsAEvent() bool { 128 | for _, v := range _EventValues { 129 | if i == v { 130 | return true 131 | } 132 | } 133 | return false 134 | } 135 | 136 | // MarshalText implements the encoding.TextMarshaler interface for Event 137 | func (i Event) MarshalText() ([]byte, error) { 138 | return []byte(i.String()), nil 139 | } 140 | 141 | // UnmarshalText implements the encoding.TextUnmarshaler interface for Event 142 | func (i *Event) UnmarshalText(text []byte) error { 143 | var err error 144 | *i, err = EventString(string(text)) 145 | return err 146 | } 147 | -------------------------------------------------------------------------------- /internal/assuan/fsm.go: -------------------------------------------------------------------------------- 1 | package assuan 2 | 3 | import ( 4 | "bufio" 5 | "crypto" 6 | "sync" 7 | 8 | "github.com/smlx/fsm" 9 | "github.com/smlx/piv-agent/internal/notify" 10 | ) 11 | 12 | //go:generate enumer -type=Event -text -transform upper 13 | 14 | // Event represents an Assuan event. 15 | type Event fsm.Event 16 | 17 | // enumeration of all possible events in the assuan FSM 18 | const ( 19 | invalidEvent Event = iota 20 | connect 21 | reset 22 | option 23 | getinfo 24 | havekey 25 | keyinfo 26 | sigkey 27 | setkeydesc 28 | sethash 29 | pksign 30 | setkey 31 | pkdecrypt 32 | scd 33 | readkey 34 | passwd 35 | ) 36 | 37 | //go:generate enumer -type=State -text -transform upper 38 | 39 | // State represents an Assuan state. 40 | type State fsm.State 41 | 42 | // Enumeration of all possible states in the assuan FSM. 43 | // connected is the initial state when the client connects. 44 | // signingKeyIsSet indicates that the client has selected a key. 45 | // hashIsSet indicates that the client has selected a hash (and key). 46 | const ( 47 | invalidState State = iota 48 | ready 49 | connected 50 | signingKeyIsSet 51 | hashIsSet 52 | decryptingKeyIsSet 53 | waitingForCiphertext 54 | ) 55 | 56 | // Assuan is the Assuan protocol FSM. 57 | type Assuan struct { 58 | fsm.Machine 59 | mu sync.Mutex 60 | notify *notify.Notify 61 | // buffered IO for linewise reading 62 | reader *bufio.Reader 63 | // data is passed during Occur() 64 | data [][]byte 65 | // remaining fields store Assuan internal state 66 | signer crypto.Signer 67 | decrypter crypto.Decrypter 68 | hashAlgo crypto.Hash 69 | hash []byte 70 | } 71 | 72 | // Occur handles an event occurrence. 73 | func (a *Assuan) Occur(e Event, data ...[]byte) error { 74 | a.mu.Lock() 75 | defer a.mu.Unlock() 76 | a.data = data 77 | return a.Machine.Occur(fsm.Event(e)) 78 | } 79 | 80 | var assuanTransitions = []fsm.Transition{ 81 | { 82 | Src: fsm.State(ready), 83 | Event: fsm.Event(connect), 84 | Dst: fsm.State(connected), 85 | }, { 86 | Src: fsm.State(connected), 87 | Event: fsm.Event(reset), 88 | Dst: fsm.State(connected), 89 | }, { 90 | Src: fsm.State(connected), 91 | Event: fsm.Event(option), 92 | Dst: fsm.State(connected), 93 | }, { 94 | Src: fsm.State(connected), 95 | Event: fsm.Event(getinfo), 96 | Dst: fsm.State(connected), 97 | }, { 98 | Src: fsm.State(connected), 99 | Event: fsm.Event(havekey), 100 | Dst: fsm.State(connected), 101 | }, { 102 | Src: fsm.State(connected), 103 | Event: fsm.Event(keyinfo), 104 | Dst: fsm.State(connected), 105 | }, { 106 | Src: fsm.State(connected), 107 | Event: fsm.Event(scd), 108 | Dst: fsm.State(connected), 109 | }, { 110 | Src: fsm.State(connected), 111 | Event: fsm.Event(readkey), 112 | Dst: fsm.State(connected), 113 | }, { 114 | Src: fsm.State(connected), 115 | Event: fsm.Event(setkeydesc), 116 | Dst: fsm.State(connected), 117 | }, { 118 | Src: fsm.State(connected), 119 | Event: fsm.Event(passwd), 120 | Dst: fsm.State(connected), 121 | }, 122 | // signing transitions 123 | { 124 | Src: fsm.State(connected), 125 | Event: fsm.Event(sigkey), 126 | Dst: fsm.State(signingKeyIsSet), 127 | }, { 128 | Src: fsm.State(signingKeyIsSet), 129 | Event: fsm.Event(setkeydesc), 130 | Dst: fsm.State(signingKeyIsSet), 131 | }, { 132 | Src: fsm.State(signingKeyIsSet), 133 | Event: fsm.Event(sethash), 134 | Dst: fsm.State(hashIsSet), 135 | }, { 136 | Src: fsm.State(hashIsSet), 137 | Event: fsm.Event(pksign), 138 | Dst: fsm.State(hashIsSet), 139 | }, { 140 | Src: fsm.State(hashIsSet), 141 | Event: fsm.Event(keyinfo), 142 | Dst: fsm.State(hashIsSet), 143 | }, { 144 | Src: fsm.State(hashIsSet), 145 | Event: fsm.Event(reset), 146 | Dst: fsm.State(connected), 147 | }, 148 | // decrypting transitions 149 | { 150 | Src: fsm.State(connected), 151 | Event: fsm.Event(setkey), 152 | Dst: fsm.State(decryptingKeyIsSet), 153 | }, { 154 | Src: fsm.State(decryptingKeyIsSet), 155 | Event: fsm.Event(setkeydesc), 156 | Dst: fsm.State(decryptingKeyIsSet), 157 | }, { 158 | Src: fsm.State(decryptingKeyIsSet), 159 | Event: fsm.Event(pkdecrypt), 160 | Dst: fsm.State(waitingForCiphertext), 161 | }, { 162 | Src: fsm.State(decryptingKeyIsSet), 163 | Event: fsm.Event(reset), 164 | Dst: fsm.State(connected), 165 | }, { 166 | Src: fsm.State(waitingForCiphertext), 167 | Event: fsm.Event(havekey), 168 | Dst: fsm.State(connected), 169 | }, 170 | } 171 | -------------------------------------------------------------------------------- /internal/assuan/readkey.go: -------------------------------------------------------------------------------- 1 | package assuan 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rsa" 8 | "fmt" 9 | "math/big" 10 | ) 11 | 12 | // readKeyData returns information about the given key in a libgcrypt-specific 13 | // format 14 | func readKeyData(pub crypto.PublicKey) (string, error) { 15 | switch k := pub.(type) { 16 | case *rsa.PublicKey: 17 | n := k.N.Bytes() 18 | nLen := len(n) // need the actual byte length before munging 19 | n = PercentEncodeSExp(n) // ugh 20 | ei := new(big.Int) 21 | ei.SetInt64(int64(k.E)) 22 | e := ei.Bytes() 23 | // prefix the key with a null byte for compatibility 24 | return fmt.Sprintf("D (10:public-key(3:rsa(1:n%d:\x00%s)(1:e%d:%s)))\nOK\n", 25 | nLen+1, n, len(e), e), nil 26 | case *ecdsa.PublicKey: 27 | switch k.Curve { 28 | case elliptic.P256(): 29 | ecdhPubKey, err := k.ECDH() 30 | if err != nil { 31 | return "", fmt.Errorf("couldn't convert pub key to ecdh.PublicKey: %v", err) 32 | } 33 | q := ecdhPubKey.Bytes() 34 | qLen := len(q) 35 | q = PercentEncodeSExp(q) 36 | return fmt.Sprintf( 37 | "D (10:public-key(3:ecc(5:curve10:NIST P-256)(1:q%d:%s)))\nOK\n", 38 | qLen, q), nil 39 | default: 40 | return "", fmt.Errorf("unsupported curve: %T", k.Curve) 41 | } 42 | default: 43 | return "", nil 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/assuan/run.go: -------------------------------------------------------------------------------- 1 | package assuan 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | ) 9 | 10 | // Run the event machine loop 11 | func (a *Assuan) Run(ctx context.Context) error { 12 | // register connection 13 | if err := a.Occur(connect); err != nil { 14 | return fmt.Errorf("error handling connect: %w", err) 15 | } 16 | var e Event 17 | for { 18 | // check for cancellation 19 | if err := ctx.Err(); err != nil { 20 | return err 21 | } 22 | // get the next command. returns at latest after conn deadline expiry. 23 | line, err := a.reader.ReadBytes(byte('\n')) 24 | if err != nil { 25 | if err == io.EOF { 26 | return nil // connection closed 27 | } 28 | return fmt.Errorf("socket read error: %w", err) 29 | } 30 | // parse the event 31 | msg := bytes.Split(bytes.TrimRight(line, "\n"), []byte(" ")) 32 | if err := e.UnmarshalText(msg[0]); err != nil { 33 | return fmt.Errorf(`couldn't unmarshal line %q: %w`, line, err) 34 | } 35 | // send the event and additional arguments to the state machine 36 | if err := a.Occur(e, msg[1:]...); err != nil { 37 | return fmt.Errorf("couldn't handle event %v in state %v: %w", 38 | e, State(a.State), err) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/assuan/sign.go: -------------------------------------------------------------------------------- 1 | package assuan 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "fmt" 7 | "math/big" 8 | 9 | "golang.org/x/crypto/cryptobyte" 10 | "golang.org/x/crypto/cryptobyte/asn1" 11 | ) 12 | 13 | // sign performs signing of the specified "hash" data, using the specified 14 | // "hashAlgo" hash algorithm. It then encodes the response into an s-expression 15 | // and returns it as a byte slice. 16 | func (a *Assuan) sign() ([]byte, error) { 17 | switch a.signer.Public().(type) { 18 | case *rsa.PublicKey: 19 | return a.signRSA() 20 | default: 21 | // default also handles mock signers in the test suite 22 | return a.signECDSA() 23 | } 24 | } 25 | 26 | // signRSA returns a signature for the given hash. 27 | func (a *Assuan) signRSA() ([]byte, error) { 28 | signature, err := a.signer.Sign(rand.Reader, a.hash, a.hashAlgo) 29 | if err != nil { 30 | return nil, fmt.Errorf("couldn't sign: %v", err) 31 | } 32 | var buf []byte 33 | return fmt.Appendf(buf, `D (7:sig-val(3:rsa(1:s%d:%s)))`, len(signature), 34 | PercentEncodeSExp(signature)), nil 35 | } 36 | 37 | // signECDSA returns a signature for the given hash. 38 | // 39 | // This function's complexity is due to the fact that while Sign() returns the 40 | // r and s components of the signature ASN1-encoded, gpg expects them to be 41 | // separately s-exp encoded. So we have to decode the ASN1 signature, extract 42 | // the params, and re-encode them into the s-exp. Ugh. 43 | func (a *Assuan) signECDSA() ([]byte, error) { 44 | cancel := a.notify.Touch() 45 | defer cancel() 46 | signature, err := a.signer.Sign(rand.Reader, a.hash, a.hashAlgo) 47 | if err != nil { 48 | return nil, fmt.Errorf("couldn't sign: %v", err) 49 | } 50 | var sig cryptobyte.String = signature 51 | var b []byte 52 | if !sig.ReadASN1Bytes(&b, asn1.SEQUENCE) { 53 | return nil, fmt.Errorf("couldn't read asn1.SEQUENCE") 54 | } 55 | var rawInts cryptobyte.String = b 56 | var r, s big.Int 57 | if !rawInts.ReadASN1Integer(&r) { 58 | return nil, fmt.Errorf("couldn't read r as asn1.Integer") 59 | } 60 | if !rawInts.ReadASN1Integer(&s) { 61 | return nil, fmt.Errorf("couldn't read s as asn1.Integer") 62 | } 63 | // encode the params (r, s) into s-exp 64 | var buf []byte 65 | return fmt.Appendf(buf, `D (7:sig-val(5:ecdsa(1:r32#%X#)(1:s32#%X#)))`, 66 | r.Bytes(), s.Bytes()), nil 67 | } 68 | -------------------------------------------------------------------------------- /internal/assuan/state_enumer.go: -------------------------------------------------------------------------------- 1 | // Code generated by "enumer -type=State -text -transform upper"; DO NOT EDIT. 2 | 3 | package assuan 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | const _StateName = "INVALIDSTATEREADYCONNECTEDSIGNINGKEYISSETHASHISSETDECRYPTINGKEYISSETWAITINGFORCIPHERTEXT" 11 | 12 | var _StateIndex = [...]uint8{0, 12, 17, 26, 41, 50, 68, 88} 13 | 14 | const _StateLowerName = "invalidstatereadyconnectedsigningkeyissethashissetdecryptingkeyissetwaitingforciphertext" 15 | 16 | func (i State) String() string { 17 | if i < 0 || i >= State(len(_StateIndex)-1) { 18 | return fmt.Sprintf("State(%d)", i) 19 | } 20 | return _StateName[_StateIndex[i]:_StateIndex[i+1]] 21 | } 22 | 23 | // An "invalid array index" compiler error signifies that the constant values have changed. 24 | // Re-run the stringer command to generate them again. 25 | func _StateNoOp() { 26 | var x [1]struct{} 27 | _ = x[invalidState-(0)] 28 | _ = x[ready-(1)] 29 | _ = x[connected-(2)] 30 | _ = x[signingKeyIsSet-(3)] 31 | _ = x[hashIsSet-(4)] 32 | _ = x[decryptingKeyIsSet-(5)] 33 | _ = x[waitingForCiphertext-(6)] 34 | } 35 | 36 | var _StateValues = []State{invalidState, ready, connected, signingKeyIsSet, hashIsSet, decryptingKeyIsSet, waitingForCiphertext} 37 | 38 | var _StateNameToValueMap = map[string]State{ 39 | _StateName[0:12]: invalidState, 40 | _StateLowerName[0:12]: invalidState, 41 | _StateName[12:17]: ready, 42 | _StateLowerName[12:17]: ready, 43 | _StateName[17:26]: connected, 44 | _StateLowerName[17:26]: connected, 45 | _StateName[26:41]: signingKeyIsSet, 46 | _StateLowerName[26:41]: signingKeyIsSet, 47 | _StateName[41:50]: hashIsSet, 48 | _StateLowerName[41:50]: hashIsSet, 49 | _StateName[50:68]: decryptingKeyIsSet, 50 | _StateLowerName[50:68]: decryptingKeyIsSet, 51 | _StateName[68:88]: waitingForCiphertext, 52 | _StateLowerName[68:88]: waitingForCiphertext, 53 | } 54 | 55 | var _StateNames = []string{ 56 | _StateName[0:12], 57 | _StateName[12:17], 58 | _StateName[17:26], 59 | _StateName[26:41], 60 | _StateName[41:50], 61 | _StateName[50:68], 62 | _StateName[68:88], 63 | } 64 | 65 | // StateString retrieves an enum value from the enum constants string name. 66 | // Throws an error if the param is not part of the enum. 67 | func StateString(s string) (State, error) { 68 | if val, ok := _StateNameToValueMap[s]; ok { 69 | return val, nil 70 | } 71 | 72 | if val, ok := _StateNameToValueMap[strings.ToLower(s)]; ok { 73 | return val, nil 74 | } 75 | return 0, fmt.Errorf("%s does not belong to State values", s) 76 | } 77 | 78 | // StateValues returns all values of the enum 79 | func StateValues() []State { 80 | return _StateValues 81 | } 82 | 83 | // StateStrings returns a slice of all String values of the enum 84 | func StateStrings() []string { 85 | strs := make([]string, len(_StateNames)) 86 | copy(strs, _StateNames) 87 | return strs 88 | } 89 | 90 | // IsAState returns "true" if the value is listed in the enum definition. "false" otherwise 91 | func (i State) IsAState() bool { 92 | for _, v := range _StateValues { 93 | if i == v { 94 | return true 95 | } 96 | } 97 | return false 98 | } 99 | 100 | // MarshalText implements the encoding.TextMarshaler interface for State 101 | func (i State) MarshalText() ([]byte, error) { 102 | return []byte(i.String()), nil 103 | } 104 | 105 | // UnmarshalText implements the encoding.TextUnmarshaler interface for State 106 | func (i *State) UnmarshalText(text []byte) error { 107 | var err error 108 | *i, err = StateString(string(text)) 109 | return err 110 | } 111 | -------------------------------------------------------------------------------- /internal/assuan/testdata/C54A8868468BC138.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mFIEYFWKLhMIKoZIzj0DAQcCAwTnFaLLFRC51An4fzUoS5S0CTFHmNjg/n9RkKCe 4 | cESxqmTVXkZ/QFdaTwTCxAnT5+Y8qgbmdV90vuFaoS6KhkBbtBlmb28gYmFyIDxm 5 | b29AZXhhbXBsZS5jb20+iJAEExMKADgWIQRW59m5zOYUP4iz3nLFSohoRovBOAUC 6 | YFWKLgIbAwULCQgHAwUVCgkICwUWAgMBAAIeAQIXgAAKCRDFSohoRovBOPVOAP9U 7 | L1BP6AJsLX7ONptB8TZHkvzNImphdKJNSynXOTcMSwEAipKBlL264weTearDjfE9 8 | kneXv5/mag9YcEJg4JJFN00= 9 | =bbyI 10 | -----END PGP PUBLIC KEY BLOCK----- 11 | -------------------------------------------------------------------------------- /internal/assuan/testdata/private-subkeys/foo-sub-ecdsa@example.com.sub-ecdsa.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smlx/piv-agent/2e30c1887faa241b144d0690cbfed262e3c52969/internal/assuan/testdata/private-subkeys/foo-sub-ecdsa@example.com.sub-ecdsa.gpg -------------------------------------------------------------------------------- /internal/assuan/testdata/private-subkeys/foo-sub@example.com.sub-rsa.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smlx/piv-agent/2e30c1887faa241b144d0690cbfed262e3c52969/internal/assuan/testdata/private-subkeys/foo-sub@example.com.sub-rsa.gpg -------------------------------------------------------------------------------- /internal/assuan/testdata/private-subkeys/foo@example.com.primary-rsa.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smlx/piv-agent/2e30c1887faa241b144d0690cbfed262e3c52969/internal/assuan/testdata/private-subkeys/foo@example.com.primary-rsa.gpg -------------------------------------------------------------------------------- /internal/assuan/testdata/private/foo@example.com.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smlx/piv-agent/2e30c1887faa241b144d0690cbfed262e3c52969/internal/assuan/testdata/private/foo@example.com.gpg -------------------------------------------------------------------------------- /internal/assuan/testdata/private/test-assuan2@example.com.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smlx/piv-agent/2e30c1887faa241b144d0690cbfed262e3c52969/internal/assuan/testdata/private/test-assuan2@example.com.gpg -------------------------------------------------------------------------------- /internal/keyservice/gpg/ecdhkey.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "fmt" 7 | "io" 8 | "regexp" 9 | 10 | "filippo.io/nistec" 11 | "github.com/smlx/piv-agent/internal/assuan" 12 | ) 13 | 14 | var ciphertextECDH = regexp.MustCompile( 15 | `^D \(7:enc-val\(4:ecdh\(1:s\d+:.+\)\(1:e(\d+):(.+)\)\)\)$`) 16 | 17 | // ECDHKey implements ECDH using an underlying ECDSA key. 18 | type ECDHKey struct { 19 | ecdsa *ecdsa.PrivateKey 20 | } 21 | 22 | // Decrypt performs ECDH as per gpg-agent. 23 | func (k *ECDHKey) Decrypt(_ io.Reader, sexp []byte, 24 | _ crypto.DecrypterOpts) ([]byte, error) { 25 | // parse out the ephemeral public key 26 | matches := ciphertextECDH.FindAllSubmatch(sexp, -1) 27 | ciphertext := matches[0][2] 28 | // undo the buggy encoding sent by gpg 29 | ciphertext = assuan.PercentDecodeSExp(ciphertext) 30 | // perform scalar multiplication 31 | sharedPoint := nistec.NewP256Point() 32 | _, err := sharedPoint.SetBytes(ciphertext) 33 | if err != nil { 34 | return nil, fmt.Errorf("couldn't set point bytes: %v", err) 35 | } 36 | _, err = sharedPoint.ScalarMult(sharedPoint, k.ecdsa.D.Bytes()) 37 | if err != nil { 38 | return nil, fmt.Errorf("couldn't perform scalar mult: %v", err) 39 | } 40 | // marshal, encode, and return the result 41 | shared := sharedPoint.Bytes() 42 | sharedLen := len(shared) 43 | shared = assuan.PercentEncodeSExp(shared) 44 | return []byte(fmt.Sprintf("D (5:value%d:%s)\nOK\n", sharedLen, shared)), nil 45 | } 46 | 47 | // Public implements the other required method of the crypto.Decrypter and 48 | // crypto.Signer interfaces. 49 | func (k *ECDHKey) Public() crypto.PublicKey { 50 | return k.ecdsa.Public() 51 | } 52 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/havekey.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | import ( 4 | "bytes" 5 | "crypto/ecdsa" 6 | "crypto/rsa" 7 | "fmt" 8 | 9 | openpgpecdsa "github.com/ProtonMail/go-crypto/openpgp/ecdsa" 10 | ) 11 | 12 | // Keygrips returns a slice of keygrip byteslices; one for each cryptographic 13 | // key available on the keyservice. 14 | func (g *KeyService) Keygrips() ([][]byte, error) { 15 | var grips [][]byte 16 | var kg []byte 17 | var err error 18 | for _, keyfile := range g.privKeys { 19 | for _, privKey := range keyfile.keys { 20 | switch openpgpPubKey := privKey.PublicKey.PublicKey.(type) { 21 | case *rsa.PublicKey: 22 | kg, err = keygripRSA(openpgpPubKey) 23 | if err != nil { 24 | return nil, fmt.Errorf("couldn't get keygrip: %w", err) 25 | } 26 | case *openpgpecdsa.PublicKey: 27 | pubKey, err := ecdsaPublicKey(openpgpPubKey) 28 | if err != nil { 29 | return nil, fmt.Errorf("couldn't convert ecdsa public key: %v", err) 30 | } 31 | kg, err = KeygripECDSA(pubKey) 32 | if err != nil { 33 | return nil, fmt.Errorf("couldn't get keygrip: %w", err) 34 | } 35 | default: 36 | // unknown public key type 37 | continue 38 | } 39 | grips = append(grips, kg) 40 | } 41 | } 42 | return grips, nil 43 | } 44 | 45 | // HaveKey takes a list of keygrips, and returns a boolean indicating if any of 46 | // the given keygrips were found, the found keygrip, and an error, if any. 47 | func (g *KeyService) HaveKey(keygrips [][]byte) (bool, []byte, error) { 48 | for _, keyfile := range g.privKeys { 49 | for _, privKey := range keyfile.keys { 50 | switch pubKey := privKey.PublicKey.PublicKey.(type) { 51 | case *rsa.PublicKey: 52 | for _, kg := range keygrips { 53 | rsaKG, err := keygripRSA(pubKey) 54 | if err != nil { 55 | return false, nil, err 56 | } 57 | if bytes.Equal(kg, rsaKG) { 58 | return true, kg, nil 59 | } 60 | } 61 | case *ecdsa.PublicKey: 62 | for _, kg := range keygrips { 63 | ecdsaKG, err := KeygripECDSA(pubKey) 64 | if err != nil { 65 | return false, nil, err 66 | } 67 | if bytes.Equal(kg, ecdsaKG) { 68 | return true, kg, nil 69 | } 70 | } 71 | default: 72 | // unknown public key type 73 | continue 74 | } 75 | } 76 | } 77 | return false, nil, nil 78 | } 79 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/helper_test.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | // export functions for test suite 4 | var ECDSAPublicKey = ecdsaPublicKey 5 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/keyfile.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path" 8 | 9 | "github.com/ProtonMail/go-crypto/openpgp/errors" 10 | "github.com/ProtonMail/go-crypto/openpgp/packet" 11 | ) 12 | 13 | // keyfilePrivateKeys reads the given path and returns any private keys found. 14 | func keyfilePrivateKeys(p string) ([]privateKeyfile, error) { 15 | f, err := os.Open(p) 16 | if err != nil { 17 | return nil, fmt.Errorf("couldn't open path %s: %v", p, err) 18 | } 19 | fileInfo, err := f.Stat() 20 | if err != nil { 21 | return nil, fmt.Errorf("couldn't stat path %s: %v", p, err) 22 | 23 | } 24 | switch { 25 | case fileInfo.Mode().IsRegular(): 26 | pk, err := keysFromFile(f) 27 | return []privateKeyfile{*pk}, err 28 | case fileInfo.IsDir(): 29 | // enumerate files in directory 30 | dirents, err := f.ReadDir(0) 31 | if err != nil { 32 | return nil, fmt.Errorf("couldn't read directory") 33 | } 34 | // get any private keys from each file 35 | var privKeys []privateKeyfile 36 | for _, dirent := range dirents { 37 | direntInfo, err := dirent.Info() 38 | if err != nil { 39 | return nil, fmt.Errorf("couldn't stat directory entry") 40 | } 41 | // ignore subdirectories 42 | if direntInfo.Mode().IsRegular() { 43 | subPath := path.Join(p, dirent.Name()) 44 | ff, err := os.Open(subPath) 45 | if err != nil { 46 | return nil, fmt.Errorf("couldn't open path %s: %v", subPath, err) 47 | } 48 | subPrivKeys, err := keysFromFile(ff) 49 | if err != nil { 50 | return nil, 51 | fmt.Errorf("couldn't get keys from file %s: %v", subPath, err) 52 | } 53 | privKeys = append(privKeys, *subPrivKeys) 54 | } 55 | } 56 | return privKeys, nil 57 | default: 58 | return nil, fmt.Errorf("invalid file type for path to keyfiles") 59 | } 60 | } 61 | 62 | // keysFromFile read a file and return any private keys found 63 | func keysFromFile(f *os.File) (*privateKeyfile, error) { 64 | var err error 65 | var pkt packet.Packet 66 | var uid *packet.UserId 67 | var privKeys []*packet.PrivateKey 68 | reader := packet.NewReader(f) 69 | for pkt, err = reader.Next(); err != io.EOF; pkt, err = reader.Next() { 70 | if _, ok := err.(errors.UnsupportedError); ok { 71 | continue // gpg writes some non-standard cruft 72 | } 73 | if err != nil { 74 | return nil, fmt.Errorf("couldn't get next packet: %v", err) 75 | } 76 | switch k := pkt.(type) { 77 | case *packet.PrivateKey: 78 | privKeys = append(privKeys, k) 79 | case *packet.UserId: 80 | uid = k 81 | default: 82 | continue 83 | } 84 | } 85 | if uid == nil { 86 | uid = packet.NewUserId("n/a", "n/a", "n/a") 87 | } 88 | return &privateKeyfile{ 89 | uid: uid, 90 | keys: privKeys, 91 | }, nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/keygrip.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | import ( 4 | "bytes" 5 | "crypto/ecdsa" 6 | "crypto/rsa" 7 | "crypto/sha1" 8 | "fmt" 9 | "math/big" 10 | ) 11 | 12 | type part struct { 13 | name string 14 | value []byte 15 | } 16 | 17 | // KeygripECDSA calculates a keygrip for an ECDSA public key. This is a SHA1 hash of 18 | // public key parameters. It is pretty much undocumented outside of the 19 | // libgcrypt codebase. 20 | // 21 | // The idea behind the keygrip is to use only the cryptographic properties of 22 | // the public key to produce an identifier. Each parameter (part) of the public 23 | // key is byte-encoded, the parts are s-exp encoded in a particular order, and 24 | // then the s-exp is sha1-hashed to produced the keygrip, which is generally 25 | // displayed hex-encoded. 26 | func KeygripECDSA(pubKey *ecdsa.PublicKey) ([]byte, error) { 27 | if pubKey == nil { 28 | return nil, fmt.Errorf("nil key") 29 | } 30 | // extract the p, a, b, g, n, an q parameters 31 | var p, a, b, g, gx, gy, n, q, x, y *big.Int 32 | 33 | p = pubKey.Params().P 34 | 35 | a = big.NewInt(-3) 36 | a.Mod(a, p) 37 | 38 | // we need to allocate and set rather than just assign here and throughout 39 | // the function otherwise we end up mutating the curve variable directly! 40 | b = big.NewInt(0) 41 | b.Set(pubKey.Params().B) 42 | b.Mod(b, p) 43 | 44 | g = big.NewInt(4) 45 | g.Lsh(g, 512) 46 | gx = big.NewInt(0) 47 | gx.Set(pubKey.Params().Gx) 48 | gx.Lsh(gx, 256) 49 | g.Or(g, gx) 50 | gy = big.NewInt(0) 51 | gy.Set(pubKey.Params().Gy) 52 | g.Or(g, gy) 53 | 54 | n = pubKey.Params().N 55 | 56 | q = big.NewInt(4) 57 | q.Lsh(q, 512) 58 | x = big.NewInt(0) 59 | x.Set(pubKey.X) 60 | x.Lsh(x, 256) 61 | q.Or(q, x) 62 | y = big.NewInt(0) 63 | y.Set(pubKey.Y) 64 | q.Or(q, y) 65 | 66 | parts := []part{ 67 | {name: "p", value: p.Bytes()[:32]}, 68 | {name: "a", value: a.Bytes()[:32]}, 69 | {name: "b", value: b.Bytes()[:32]}, 70 | {name: "g", value: g.Bytes()[:65]}, 71 | {name: "n", value: n.Bytes()[:32]}, 72 | {name: "q", value: q.Bytes()[:65]}, 73 | } 74 | // hash them all 75 | return compute(parts) 76 | } 77 | 78 | func compute(parts []part) ([]byte, error) { 79 | h := new(bytes.Buffer) 80 | for i := 0; i < len(parts); i++ { 81 | _, err := fmt.Fprintf(h, "(%d:%s%d:%s)", len(parts[i].name), parts[i].name, len(parts[i].value), parts[i].value) 82 | if err != nil { 83 | return nil, err 84 | } 85 | } 86 | s := sha1.Sum(h.Bytes()) 87 | return s[:], nil 88 | } 89 | 90 | // keygripRSA calculates a keygrip for an RSA public key. 91 | func keygripRSA(pubKey *rsa.PublicKey) ([]byte, error) { 92 | if pubKey == nil { 93 | return nil, fmt.Errorf("nil key") 94 | } 95 | keygrip := sha1.New() 96 | keygrip.Write([]byte{0}) 97 | keygrip.Write(pubKey.N.Bytes()) 98 | return keygrip.Sum(nil), nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/keygrip_test.go: -------------------------------------------------------------------------------- 1 | package gpg_test 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "encoding/hex" 7 | "math/big" 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/ProtonMail/go-crypto/openpgp" 13 | "github.com/ProtonMail/go-crypto/openpgp/armor" 14 | openpgpecdsa "github.com/ProtonMail/go-crypto/openpgp/ecdsa" 15 | "github.com/ProtonMail/go-crypto/openpgp/packet" 16 | "github.com/smlx/piv-agent/internal/keyservice/gpg" 17 | ) 18 | 19 | func TestTrezorCompat(t *testing.T) { 20 | var testCases = map[string]struct { 21 | input *big.Int 22 | expect string 23 | }{ 24 | "keygrip 1": {input: big.NewInt(1), expect: "95852E917FE2C39152BA998192B5791DB15CDCF0"}, 25 | } 26 | for name, tc := range testCases { 27 | t.Run(name, func(tt *testing.T) { 28 | 29 | // construct private key 30 | priv := ecdsa.PrivateKey{} 31 | curve := elliptic.P256() 32 | priv.Curve = curve 33 | priv.D = tc.input 34 | priv.X, priv.Y = curve.ScalarBaseMult(tc.input.Bytes()) 35 | 36 | keygrip, err := gpg.KeygripECDSA(&priv.PublicKey) 37 | if err != nil { 38 | tt.Fatal(err) 39 | } 40 | kgString := strings.ToUpper(hex.EncodeToString(keygrip)) 41 | if kgString != tc.expect { 42 | tt.Fatalf("expected %s, got %s", tc.expect, kgString) 43 | } 44 | }) 45 | } 46 | } 47 | 48 | func TestKeyGrip(t *testing.T) { 49 | var testCases = map[string]struct { 50 | input string 51 | expect string 52 | }{ 53 | "keygrip 1": {input: "testdata/key1.asc", expect: "27B6858AA86F7B3DE9ADF89D5C91EA06558659DE"}, 54 | "keygrip 2": {input: "testdata/key2.asc", expect: "D88F095C9279EE30E5F64AE82C0033A4CAE9D336"}, 55 | "keygrip 3": {input: "testdata/key3.asc", expect: "137770C017D7693C1DAD922EB3E83AEFCC9743BA"}, 56 | "keygrip 4": {input: "testdata/key4.asc", expect: "E21BD507D4B1C5E82858F69BC1C12D4E51EED503"}, 57 | } 58 | for name, tc := range testCases { 59 | t.Run(name, func(tt *testing.T) { 60 | 61 | // parse ascii armored public key 62 | in, err := os.Open(tc.input) 63 | if err != nil { 64 | tt.Fatal(err) 65 | } 66 | defer in.Close() 67 | 68 | block, err := armor.Decode(in) 69 | if err != nil { 70 | tt.Fatal(err) 71 | } 72 | 73 | if block.Type != openpgp.PublicKeyType { 74 | tt.Fatal(err) 75 | } 76 | 77 | reader := packet.NewReader(block.Body) 78 | pkt, err := reader.Next() 79 | if err != nil { 80 | tt.Fatal(err) 81 | } 82 | key, ok := pkt.(*packet.PublicKey) 83 | if !ok { 84 | tt.Fatal("not an openpgp public key") 85 | } 86 | eccKey, ok := key.PublicKey.(*openpgpecdsa.PublicKey) 87 | if !ok { 88 | tt.Fatal("not an ecdsa public key") 89 | } 90 | pubKey, err := gpg.ECDSAPublicKey(eccKey) 91 | if err != nil { 92 | tt.Fatal(err) 93 | } 94 | if pubKey.Curve != elliptic.P256() { 95 | tt.Fatal("wrong curve") 96 | } 97 | 98 | keygrip, err := gpg.KeygripECDSA(pubKey) 99 | if err != nil { 100 | tt.Fatal(err) 101 | } 102 | kgString := strings.ToUpper(hex.EncodeToString(keygrip)) 103 | if kgString != tc.expect { 104 | tt.Fatalf("expected %s, got %s", tc.expect, kgString) 105 | } 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/keyservice.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | //go:generate mockgen -source=keyservice.go -destination=../../mock/mock_keyservice.go -package=mock 4 | 5 | import ( 6 | "bytes" 7 | "crypto" 8 | "crypto/ecdsa" 9 | "crypto/rsa" 10 | "fmt" 11 | 12 | openpgpecdsa "github.com/ProtonMail/go-crypto/openpgp/ecdsa" 13 | "github.com/ProtonMail/go-crypto/openpgp/packet" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | // retries is the passphrase attempt limit when decrypting GPG keyfiles 18 | const retries = 3 19 | 20 | // PINEntryService provides an interface to talk to a pinentry program. 21 | type PINEntryService interface { 22 | GetPassphrase(string, string, int) ([]byte, error) 23 | } 24 | 25 | type privateKeyfile struct { 26 | uid *packet.UserId 27 | keys []*packet.PrivateKey 28 | } 29 | 30 | // KeyService implements an interface for getting cryptographic keys from 31 | // keyfiles on disk. 32 | type KeyService struct { 33 | // cache passphrases used for keyfile decryption 34 | passphrases [][]byte 35 | privKeys []privateKeyfile 36 | log *zap.Logger 37 | pinentry PINEntryService 38 | } 39 | 40 | // New returns a keyservice initialised with keys found at path. 41 | // Path can be a file or directory. 42 | func New(l *zap.Logger, pe PINEntryService, path string) *KeyService { 43 | p, err := keyfilePrivateKeys(path) 44 | if err != nil { 45 | l.Info("couldn't load keyfiles", zap.String("path", path), zap.Error(err)) 46 | } 47 | return &KeyService{ 48 | privKeys: p, 49 | log: l, 50 | pinentry: pe, 51 | } 52 | } 53 | 54 | // Name returns the name of the keyservice. 55 | func (*KeyService) Name() string { 56 | return "GPG Keyfile" 57 | } 58 | 59 | // doDecrypt prompts for a passphrase via pinentry and uses the passphrase to 60 | // decrypt the given private key 61 | func (g *KeyService) doDecrypt(k *packet.PrivateKey, uid string) error { 62 | var pass []byte 63 | var err error 64 | for i := range retries { 65 | pass, err = g.pinentry.GetPassphrase( 66 | fmt.Sprintf("UserID: %s\rFingerprint: %X %X %X %X", uid, 67 | k.Fingerprint[:5], k.Fingerprint[5:10], k.Fingerprint[10:15], 68 | k.Fingerprint[15:]), 69 | uid, retries-i) 70 | if err != nil { 71 | return fmt.Errorf("couldn't get passphrase for key %s: %v", 72 | k.KeyIdString(), err) 73 | } 74 | if err = k.Decrypt(pass); err == nil { 75 | g.passphrases = append(g.passphrases, pass) 76 | return nil 77 | } 78 | } 79 | return fmt.Errorf("couldn't decrypt key %s: %v", k.KeyIdString(), err) 80 | } 81 | 82 | // decryptPrivateKey decrypts the given private key. 83 | // Returns nil if successful, or an error if the key could not be decrypted. 84 | func (g *KeyService) decryptPrivateKey(k *packet.PrivateKey, uid string) error { 85 | var err error 86 | if k.Encrypted { 87 | // try existing passphrases 88 | for _, pass := range g.passphrases { 89 | if err = k.Decrypt(pass); err == nil { 90 | g.log.Debug("decrypted using cached passphrase", 91 | zap.String("fingerprint", k.KeyIdString())) 92 | break 93 | } 94 | } 95 | } 96 | if k.Encrypted { 97 | if err := g.doDecrypt(k, uid); err != nil { 98 | return err 99 | } 100 | g.log.Debug("decrypted using passphrase", 101 | zap.String("fingerprint", k.KeyIdString())) 102 | } 103 | return nil 104 | } 105 | 106 | // getRSAKey returns a matching private RSA key if the keygrip matches. If a key 107 | // is returned err will be nil. If no key is found, both values may be nil. 108 | func (g *KeyService) getRSAKey(keygrip []byte) (*rsa.PrivateKey, error) { 109 | for _, pk := range g.privKeys { 110 | for _, k := range pk.keys { 111 | pubKey, ok := k.PublicKey.PublicKey.(*rsa.PublicKey) 112 | if !ok { 113 | continue 114 | } 115 | pubKeygrip, err := keygripRSA(pubKey) 116 | if err != nil { 117 | return nil, fmt.Errorf("couldn't get RSA keygrip: %v", err) 118 | } 119 | if !bytes.Equal(keygrip, pubKeygrip) { 120 | continue 121 | } 122 | err = g.decryptPrivateKey(k, 123 | fmt.Sprintf("%s (%s) <%s>", 124 | pk.uid.Name, pk.uid.Comment, pk.uid.Email)) 125 | if err != nil { 126 | return nil, err 127 | } 128 | privKey, ok := k.PrivateKey.(*rsa.PrivateKey) 129 | if !ok { 130 | return nil, fmt.Errorf("not an RSA key %s: %v", 131 | k.KeyIdString(), err) 132 | } 133 | return privKey, nil 134 | } 135 | } 136 | return nil, nil 137 | } 138 | 139 | // getECDSAKey returns a matching private ECDSA key if the keygrip matches. If 140 | // a key is returned err will be nil. If no key is found, both values will be 141 | // nil. 142 | func (g *KeyService) getECDSAKey(keygrip []byte) (*ecdsa.PrivateKey, error) { 143 | for _, pk := range g.privKeys { 144 | for _, k := range pk.keys { 145 | openpgpPubKey, ok := k.PublicKey.PublicKey.(*openpgpecdsa.PublicKey) 146 | if !ok { 147 | continue 148 | } 149 | pubKey, err := ecdsaPublicKey(openpgpPubKey) 150 | if err != nil { 151 | return nil, 152 | fmt.Errorf("couldn't convert openpgp to stdlib ecdsa public key: %v", err) 153 | } 154 | pubKeygrip, err := KeygripECDSA(pubKey) 155 | if err != nil { 156 | return nil, fmt.Errorf("couldn't get ECDSA keygrip: %v", err) 157 | } 158 | if !bytes.Equal(keygrip, pubKeygrip) { 159 | continue 160 | } 161 | err = g.decryptPrivateKey(k, 162 | fmt.Sprintf("%s (%s) <%s>", 163 | pk.uid.Name, pk.uid.Comment, pk.uid.Email)) 164 | if err != nil { 165 | return nil, err 166 | } 167 | openpgpPrivKey, ok := k.PrivateKey.(*openpgpecdsa.PrivateKey) 168 | if !ok { 169 | return nil, fmt.Errorf("not an ECDSA key %s: %v", 170 | k.KeyIdString(), err) 171 | } 172 | privKey, err := ecdsaPrivateKey(openpgpPrivKey) 173 | if err != nil { 174 | return nil, 175 | fmt.Errorf("couldn't convert openpgp to stdlib ecdsa private key: %v", err) 176 | } 177 | return privKey, nil 178 | } 179 | } 180 | return nil, nil 181 | } 182 | 183 | // GetSigner returns a crypto.Signer associated with the given keygrip. 184 | func (g *KeyService) GetSigner(keygrip []byte) (crypto.Signer, error) { 185 | rsaPrivKey, err := g.getRSAKey(keygrip) 186 | if err != nil { 187 | return nil, fmt.Errorf("couldn't getRSAKey: %v", err) 188 | } 189 | if rsaPrivKey != nil { 190 | return &RSAKey{rsa: rsaPrivKey}, nil 191 | } 192 | ecdsaPrivKey, err := g.getECDSAKey(keygrip) 193 | if err != nil { 194 | return nil, fmt.Errorf("couldn't getECDSAKey: %v", err) 195 | } 196 | if ecdsaPrivKey != nil { 197 | return ecdsaPrivKey, nil 198 | } 199 | return nil, fmt.Errorf("couldn't get signer for keygrip %X", keygrip) 200 | } 201 | 202 | // GetDecrypter returns a crypto.Decrypter associated with the given keygrip. 203 | func (g *KeyService) GetDecrypter(keygrip []byte) (crypto.Decrypter, error) { 204 | rsaPrivKey, err := g.getRSAKey(keygrip) 205 | if err != nil { 206 | return nil, fmt.Errorf("couldn't getRSAKey: %v", err) 207 | } 208 | if rsaPrivKey != nil { 209 | return &RSAKey{rsa: rsaPrivKey}, nil 210 | } 211 | ecdsaPrivKey, err := g.getECDSAKey(keygrip) 212 | if err != nil { 213 | return nil, fmt.Errorf("couldn't getECDSAKey: %v", err) 214 | } 215 | if ecdsaPrivKey != nil { 216 | return &ECDHKey{ecdsa: ecdsaPrivKey}, nil 217 | } 218 | return nil, fmt.Errorf("couldn't get decrypter for keygrip %X", keygrip) 219 | } 220 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/keyservice_test.go: -------------------------------------------------------------------------------- 1 | package gpg_test 2 | 3 | import ( 4 | "encoding/hex" 5 | "testing" 6 | 7 | "github.com/smlx/piv-agent/internal/keyservice/gpg" 8 | "github.com/smlx/piv-agent/internal/mock" 9 | "go.uber.org/mock/gomock" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | func hexMustDecode(s string) []byte { 14 | raw, err := hex.DecodeString(s) 15 | if err != nil { 16 | panic(err) 17 | } 18 | return raw 19 | } 20 | 21 | func TestGetSigner(t *testing.T) { 22 | var testCases = map[string]struct { 23 | path string 24 | keygrip []byte 25 | protected bool 26 | }{ 27 | "unprotected key": { 28 | path: "testdata/private/bar@example.com.gpg", 29 | keygrip: hexMustDecode("9128BB9362750577445FAAE9E737684EBB74FD6C"), 30 | }, 31 | "protected key": { 32 | path: "testdata/private/bar-protected@example.com.gpg", 33 | keygrip: hexMustDecode("75B7C5A35213E71BA282F64317DDB90EC5C3FEE0"), 34 | protected: true, 35 | }, 36 | } 37 | log, err := zap.NewDevelopment() 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | for name, tc := range testCases { 42 | t.Run(name, func(tt *testing.T) { 43 | ctrl := gomock.NewController(tt) 44 | defer ctrl.Finish() 45 | var mockPES = mock.NewMockPINEntryService(ctrl) 46 | if tc.protected { 47 | mockPES.EXPECT().GetPassphrase(gomock.Any(), gomock.Any(), 3). 48 | Return([]byte("trustno1"), nil) 49 | } 50 | ks := gpg.New(log, mockPES, tc.path) 51 | if _, err := ks.GetSigner(tc.keygrip); err != nil { 52 | tt.Fatal(err) 53 | } 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/openpgpecdsa.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "fmt" 7 | 8 | openpgpecdsa "github.com/ProtonMail/go-crypto/openpgp/ecdsa" 9 | ) 10 | 11 | // nameToCurve takes a given curve name and returns the associated 12 | // elliptic.Curve. 13 | func nameToCurve(name string) (elliptic.Curve, error) { 14 | switch name { 15 | case elliptic.P224().Params().Name: 16 | return elliptic.P224(), nil 17 | case elliptic.P256().Params().Name: 18 | return elliptic.P256(), nil 19 | case elliptic.P384().Params().Name: 20 | return elliptic.P384(), nil 21 | case elliptic.P521().Params().Name: 22 | return elliptic.P521(), nil 23 | default: 24 | return nil, fmt.Errorf("unknown curve name: %s", name) 25 | } 26 | } 27 | 28 | // ecdsaPublicKey converts the given ECDSA Key in go-crypto/openpgp 29 | // representation, to standard library crypto/ecdsa representation. 30 | func ecdsaPublicKey(k *openpgpecdsa.PublicKey) (*ecdsa.PublicKey, error) { 31 | curve, err := nameToCurve(k.GetCurve().GetCurveName()) 32 | if err != nil { 33 | return nil, err 34 | } 35 | return &ecdsa.PublicKey{ 36 | Curve: curve, 37 | X: k.X, 38 | Y: k.Y, 39 | }, nil 40 | } 41 | 42 | // ecdsaPrivateKey converts the given ECDSA Key in go-crypto/openpgp 43 | // representation, to standard library crypto/ecdsa representation. 44 | func ecdsaPrivateKey(k *openpgpecdsa.PrivateKey) (*ecdsa.PrivateKey, error) { 45 | curve, err := nameToCurve(k.GetCurve().GetCurveName()) 46 | if err != nil { 47 | return nil, err 48 | } 49 | return &ecdsa.PrivateKey{ 50 | D: k.D, 51 | PublicKey: ecdsa.PublicKey{ 52 | Curve: curve, 53 | X: k.X, 54 | Y: k.Y, 55 | }, 56 | }, nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/rsakey.go: -------------------------------------------------------------------------------- 1 | package gpg 2 | 3 | import ( 4 | "crypto" 5 | "crypto/rsa" 6 | "fmt" 7 | "io" 8 | "math/big" 9 | "regexp" 10 | 11 | "github.com/smlx/piv-agent/internal/assuan" 12 | ) 13 | 14 | var ciphertextRSA = regexp.MustCompile( 15 | `^D \(7:enc-val\(3:rsa\(1:a(\d+):(.+)\)\)\)$`) 16 | 17 | // RSAKey represents a GPG key loaded from a keyfile. 18 | // It implements the crypto.Decrypter and crypto.Signer interfaces. 19 | type RSAKey struct { 20 | rsa *rsa.PrivateKey 21 | } 22 | 23 | // Decrypt performs RSA decryption as per gpg-agent. 24 | // The ciphertext is expected to be in gpg sexp-encoded format, and is returned 25 | // in the same format as expected by the gpg assuan protocol. 26 | // 27 | // Terrible things about this function (not exhaustive): 28 | // * rolling my own crypto 29 | // * possibly makes well-known RSA implementation mistakes(?) 30 | // * RSA in 2021 31 | // 32 | // I'd love to not have to do this, but hey, it's for gnupg compatibility. 33 | // Get in touch if you know how to improve this function. 34 | func (k *RSAKey) Decrypt(_ io.Reader, sexp []byte, 35 | _ crypto.DecrypterOpts) ([]byte, error) { 36 | // parse out ciphertext 37 | matches := ciphertextRSA.FindAllSubmatch(sexp, -1) 38 | ciphertext := matches[0][2] 39 | // undo the buggy encoding sent by gpg 40 | ciphertext = assuan.PercentDecodeSExp(ciphertext) 41 | // unmarshal ciphertext 42 | c := new(big.Int) 43 | c.SetBytes(ciphertext) 44 | // TODO: libgcrypt does this, not sure if required? 45 | c.Rem(c, k.rsa.N) 46 | // perform arithmetic manually 47 | c.Exp(c, k.rsa.D, k.rsa.N) 48 | // marshal plaintext 49 | plaintext := c.Bytes() 50 | // gnupg uses the pre-buggy-encoding length in the sexp 51 | plaintextLen := len(plaintext) 52 | // apply the buggy encoding as expected by gpg 53 | plaintext = assuan.PercentEncodeSExp(plaintext) 54 | return []byte(fmt.Sprintf("D (5:value%d:%s)\x00\nOK\n", 55 | plaintextLen, plaintext)), nil 56 | } 57 | 58 | // Public implements the other required method of the crypto.Decrypter and 59 | // crypto.Signer interfaces. 60 | func (k *RSAKey) Public() crypto.PublicKey { 61 | return k.rsa.Public() 62 | } 63 | 64 | // Sign performs RSA signing as per gpg-agent. 65 | func (k *RSAKey) Sign(r io.Reader, digest []byte, 66 | o crypto.SignerOpts) ([]byte, error) { 67 | return rsa.SignPKCS1v15(r, k.rsa, o.HashFunc(), digest) 68 | } 69 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/testdata/key1.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mFIEYEJSCxMIKoZIzj0DAQcCAwT2Rsv+uSRnwuF6aOIo2c4Zrhf1DwyADriUg8AB 4 | M3uz/1abLPUfIdeB17if8HiJH5+lGO+LJ47XYD/HZZ/f0YIJtBlmb28gYmFyIDxm 5 | b29AZXhhbXBsZS5jb20+iJAEExMIADgWIQRlB23WjoYl+SvYri5RmyeVWHnVdQUC 6 | YEJSCwIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRBRmyeVWHnVdXaEAP9C 7 | 1L2702mpgaKuSuKkdOGsUvMmbZZM7bvPDJ+NBEFxYgD/X1KKY/QWvDFwmaNnSQ9Z 8 | Foi0GQrOSZN1XPIAmSk1sGo= 9 | =dUGs 10 | -----END PGP PUBLIC KEY BLOCK----- 11 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/testdata/key2.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mFIEYExQ+RMIKoZIzj0DAQcCAwQzbPeV4xXEJ4tJPV4nTJJ5i/bnx0+sJB0SQ7u6 4 | u1OTlVTlslmWpTMBbFKeKrja83Ux5wAptPuRD+MycptCvzyCtBhmb29iYXIgPGZv 5 | b0BleGFtcGxlLmNvbT6IjwQTEwgAOBYhBCbop0iMuOsGBU9ACwGvAVVnFnhIBQJg 6 | TFD5AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEAGvAVVnFnhI+9sBAIxI 7 | tV7dZj9vwdz7+//Z9Hmmj2PTe90L6PEW+blb1/hYAPdz9ky2ZZNELlPvX0JTeXLm 8 | I54aUYeXKqp8OtmaKjp4 9 | =3bVU 10 | -----END PGP PUBLIC KEY BLOCK----- 11 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/testdata/key3.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mFIEYE4n9BMIKoZIzj0DAQcCAwQlDsHnFsJLNsmsylj7gQ/4IF69NYpSkDfNEK5r 4 | E4ZGVTruzof4LdwIPc7mtZAcL3t7RI6P8cPImKAuNPRZsun8tA9mb29AZXhhbXBs 5 | ZS5jb22IkwQTEwgAPBYhBCb2o/G7DdTfZ/2eoUnX0Bxe4w7XBQJgTif0AhsDBQkD 6 | wmcABQsJCAcCBhUKCQgLAgIWAAIeAQIXgAAKCRBJ19AcXuMO1+atAP4gYhtpmnOI 7 | SHft7BUv86akwwNMPs3BP43F+EB/GwMr+wD4rQyJKResTft896fgMlCh+ec9md1B 8 | wlCNrHmvtzCSGw== 9 | =Z6ur 10 | -----END PGP PUBLIC KEY BLOCK----- 11 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/testdata/key4.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mFIEYE4rphMIKoZIzj0DAQcCAwR7mN/rZtYYmFNg6qUEqyrsLRqbWdy898I3TagI 4 | GoHAD1rG5rrVZRVwUbL34mRhJJ5Srw9aduk5vvbgm3H5qUvItA9mb29AZXhhbXBs 5 | ZS5jb22IlAQTEwgAPBYhBJFEHMsnSaUHGlAdBdHkgkQmO+AQBQJgTiumAhsDBQkD 6 | wmcABQsJCAcCBhUKCQgLAgIWAAIeAQIXgAAKCRDR5IJEJjvgECZjAQDlG0D7cNY9 7 | R64UaC9eQIZ4Ke8gEvkKA8Z4cABF5VLiBQEAkpMUjG8iGXMxjoZYukN7t9rHgVwL 8 | GHUCOrR5hK2Gd7s= 9 | =mVv+ 10 | -----END PGP PUBLIC KEY BLOCK----- 11 | -------------------------------------------------------------------------------- /internal/keyservice/gpg/testdata/private/bar-protected@example.com.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smlx/piv-agent/2e30c1887faa241b144d0690cbfed262e3c52969/internal/keyservice/gpg/testdata/private/bar-protected@example.com.gpg -------------------------------------------------------------------------------- /internal/keyservice/gpg/testdata/private/bar@example.com.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smlx/piv-agent/2e30c1887faa241b144d0690cbfed262e3c52969/internal/keyservice/gpg/testdata/private/bar@example.com.gpg -------------------------------------------------------------------------------- /internal/keyservice/piv/ecdhkey.go: -------------------------------------------------------------------------------- 1 | package piv 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdh" 6 | "fmt" 7 | "io" 8 | "regexp" 9 | "sync" 10 | 11 | pivgo "github.com/go-piv/piv-go/v2/piv" 12 | "github.com/smlx/piv-agent/internal/assuan" 13 | ) 14 | 15 | var ciphertextECDH = regexp.MustCompile( 16 | `^D \(7:enc-val\(4:ecdh\(1:s\d+:.+\)\(1:e(\d+):(.+)\)\)\)$`) 17 | 18 | // ECDHKey implements ECDH using an underlying ECDSA key. 19 | type ECDHKey struct { 20 | mu *sync.Mutex 21 | *pivgo.ECDSAPrivateKey 22 | } 23 | 24 | // Decrypt performs ECDH as per gpg-agent, and implements the crypto.Decrypter 25 | // interface. 26 | func (k *ECDHKey) Decrypt(_ io.Reader, sexp []byte, 27 | _ crypto.DecrypterOpts) ([]byte, error) { 28 | k.mu.Lock() 29 | defer k.mu.Unlock() 30 | // parse out the ephemeral public key 31 | matches := ciphertextECDH.FindAllSubmatch(sexp, -1) 32 | ciphertext := matches[0][2] 33 | // undo the buggy encoding sent by gpg 34 | ciphertext = assuan.PercentDecodeSExp(ciphertext) 35 | // unmarshal the ephemeral key 36 | ephPub, err := ecdh.P256().NewPublicKey(ciphertext) 37 | if err != nil { 38 | return nil, fmt.Errorf("couldn't unmarshal ephemeral key: %v", err) 39 | } 40 | // perform scalar multiplication, encode, and return the result 41 | shared, err := k.ECDH(ephPub) 42 | if err != nil { 43 | return nil, fmt.Errorf("couldn't generate shared secret: %v", err) 44 | } 45 | sharedLen := len(shared) 46 | shared = assuan.PercentEncodeSExp(shared) 47 | return []byte(fmt.Sprintf("D (5:value%d:%s)\nOK\n", sharedLen, shared)), nil 48 | } 49 | 50 | // Sign wraps the underlying private key Sign operation in a mutex. 51 | func (k *ECDHKey) Sign(rand io.Reader, digest []byte, 52 | opts crypto.SignerOpts) ([]byte, error) { 53 | k.mu.Lock() 54 | defer k.mu.Unlock() 55 | return k.ECDSAPrivateKey.Sign(rand, digest, opts) 56 | } 57 | -------------------------------------------------------------------------------- /internal/keyservice/piv/keyservice.go: -------------------------------------------------------------------------------- 1 | // Package piv implements the PIV keyservice. 2 | package piv 3 | 4 | import ( 5 | "bytes" 6 | "crypto" 7 | "crypto/ecdsa" 8 | "fmt" 9 | "sync" 10 | 11 | pivgo "github.com/go-piv/piv-go/v2/piv" 12 | "github.com/smlx/piv-agent/internal/keyservice/gpg" 13 | "github.com/smlx/piv-agent/internal/pinentry" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | // KeyService represents a collection of tokens and slots accessed by the 18 | // Personal Identity Verifaction card interface. 19 | type KeyService struct { 20 | mu sync.Mutex 21 | log *zap.Logger 22 | pinentry *pinentry.PINEntry 23 | securityKeys []SecurityKey 24 | } 25 | 26 | // New constructs a PIV and returns it. 27 | func New(l *zap.Logger, pe *pinentry.PINEntry) *KeyService { 28 | return &KeyService{ 29 | log: l, 30 | pinentry: pe, 31 | } 32 | } 33 | 34 | // Name returns the name of the keyservice. 35 | func (*KeyService) Name() string { 36 | return "PIV" 37 | } 38 | 39 | // Keygrips returns a single slice of concatenated keygrip byteslices - one for 40 | // each cryptographic key available on the keyservice. 41 | func (p *KeyService) Keygrips() ([][]byte, error) { 42 | p.mu.Lock() 43 | defer p.mu.Unlock() 44 | var grips [][]byte 45 | securityKeys, err := p.getSecurityKeys() 46 | if err != nil { 47 | return nil, fmt.Errorf("couldn't get security keys: %w", err) 48 | } 49 | for _, sk := range securityKeys { 50 | for _, cryptoKey := range sk.CryptoKeys() { 51 | ecdsaPubKey, ok := cryptoKey.Public.(*ecdsa.PublicKey) 52 | if !ok { 53 | // TODO: handle other key types 54 | continue 55 | } 56 | kg, err := gpg.KeygripECDSA(ecdsaPubKey) 57 | if err != nil { 58 | return nil, fmt.Errorf("couldn't get keygrip: %w", err) 59 | } 60 | grips = append(grips, kg) 61 | } 62 | } 63 | return grips, nil 64 | } 65 | 66 | // HaveKey takes a list of keygrips, and returns a boolean indicating if any of 67 | // the given keygrips were found, the found keygrip, and an error, if any. 68 | func (p *KeyService) HaveKey(keygrips [][]byte) (bool, []byte, error) { 69 | p.mu.Lock() 70 | defer p.mu.Unlock() 71 | securityKeys, err := p.getSecurityKeys() 72 | if err != nil { 73 | return false, nil, fmt.Errorf("couldn't get security keys: %w", err) 74 | } 75 | for _, sk := range securityKeys { 76 | for _, cryptoKey := range sk.CryptoKeys() { 77 | ecdsaPubKey, ok := cryptoKey.Public.(*ecdsa.PublicKey) 78 | if !ok { 79 | // TODO: handle other key types 80 | continue 81 | } 82 | thisKeygrip, err := gpg.KeygripECDSA(ecdsaPubKey) 83 | if err != nil { 84 | return false, nil, fmt.Errorf("couldn't get keygrip: %w", err) 85 | } 86 | for _, kg := range keygrips { 87 | if bytes.Equal(thisKeygrip, kg) { 88 | return true, thisKeygrip, nil 89 | } 90 | } 91 | } 92 | } 93 | return false, nil, nil 94 | } 95 | 96 | func (p *KeyService) getPrivateKey(keygrip []byte) (crypto.PrivateKey, error) { 97 | securityKeys, err := p.getSecurityKeys() 98 | if err != nil { 99 | return nil, fmt.Errorf("couldn't get security keys: %w", err) 100 | } 101 | for _, sk := range securityKeys { 102 | for _, cryptoKey := range sk.CryptoKeys() { 103 | ecdsaPubKey, ok := cryptoKey.Public.(*ecdsa.PublicKey) 104 | if !ok { 105 | // TODO: handle other key types 106 | continue 107 | } 108 | thisKeygrip, err := gpg.KeygripECDSA(ecdsaPubKey) 109 | if err != nil { 110 | return nil, fmt.Errorf("couldn't get keygrip: %w", err) 111 | } 112 | if bytes.Equal(thisKeygrip, keygrip) { 113 | privKey, err := sk.PrivateKey(&cryptoKey) 114 | if err != nil { 115 | return nil, fmt.Errorf("couldn't get private key from slot") 116 | } 117 | pivGoPrivKey, ok := privKey.(*pivgo.ECDSAPrivateKey) 118 | if !ok { 119 | return nil, fmt.Errorf("unexpected private key type: %T", privKey) 120 | } 121 | return &ECDHKey{mu: &p.mu, ECDSAPrivateKey: pivGoPrivKey}, nil 122 | } 123 | } 124 | } 125 | return nil, fmt.Errorf("couldn't match keygrip") 126 | } 127 | 128 | // GetSigner returns a crypto.Signer associated with the given keygrip. 129 | func (p *KeyService) GetSigner(keygrip []byte) (crypto.Signer, error) { 130 | p.mu.Lock() 131 | defer p.mu.Unlock() 132 | privKey, err := p.getPrivateKey(keygrip) 133 | if err != nil { 134 | return nil, fmt.Errorf("couldn't get private key: %v", err) 135 | } 136 | signingPrivKey, ok := privKey.(crypto.Signer) 137 | if !ok { 138 | return nil, fmt.Errorf("private key is not a signer") 139 | } 140 | return signingPrivKey, nil 141 | } 142 | 143 | // GetDecrypter returns a crypto.Decrypter associated with the given keygrip. 144 | func (p *KeyService) GetDecrypter(keygrip []byte) (crypto.Decrypter, error) { 145 | p.mu.Lock() 146 | defer p.mu.Unlock() 147 | privKey, err := p.getPrivateKey(keygrip) 148 | if err != nil { 149 | return nil, fmt.Errorf("couldn't get private key: %v", err) 150 | } 151 | decryptingPrivKey, ok := privKey.(crypto.Decrypter) 152 | if !ok { 153 | return nil, fmt.Errorf("private key is not a decrypter") 154 | } 155 | return decryptingPrivKey, nil 156 | } 157 | 158 | // CloseAll closes all security keys without checking for errors. 159 | // This should be called to clean up connections to `pcscd`. 160 | func (p *KeyService) CloseAll() { 161 | p.mu.Lock() 162 | defer p.mu.Unlock() 163 | p.log.Debug("closing security keys", zap.Int("count", len(p.securityKeys))) 164 | for _, k := range p.securityKeys { 165 | if err := k.Close(); err != nil { 166 | p.log.Debug("couldn't close key", zap.Error(err)) 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /internal/keyservice/piv/list.go: -------------------------------------------------------------------------------- 1 | package piv 2 | 3 | //go:generate mockgen -source=list.go -destination=../../mock/mock_pivservice.go -package=mock 4 | 5 | import ( 6 | "crypto" 7 | "crypto/x509" 8 | "fmt" 9 | 10 | pivgo "github.com/go-piv/piv-go/v2/piv" 11 | "github.com/smlx/piv-agent/internal/pinentry" 12 | "github.com/smlx/piv-agent/internal/securitykey" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | // SecurityKey is a simple interface for security keys allowing abstraction 17 | // over the securitykey implementation, and allowing generation of mocks for 18 | // testing. 19 | type SecurityKey interface { 20 | AttestationCertificate() (*x509.Certificate, error) 21 | Card() string 22 | Close() error 23 | Comment(*securitykey.SlotSpec) string 24 | PrivateKey(*securitykey.CryptoKey) (crypto.PrivateKey, error) 25 | SigningKeys() []securitykey.SigningKey 26 | CryptoKeys() []securitykey.CryptoKey 27 | StringsGPG(string, string) ([]string, error) 28 | StringsSSH() []string 29 | } 30 | 31 | func (p *KeyService) reloadSecurityKeys() error { 32 | // try to clean up and reset state 33 | for _, k := range p.securityKeys { 34 | _ = k.Close() 35 | } 36 | p.securityKeys = nil 37 | // open cards and load keys from scratch 38 | cards, err := pivgo.Cards() 39 | if err != nil { 40 | return fmt.Errorf("couldn't get cards: %v", err) 41 | } 42 | for _, card := range cards { 43 | sk, err := securitykey.New(card, pinentry.New("pinentry")) 44 | if err != nil { 45 | p.log.Warn("couldn't get SecurityKey", zap.String("card", card), 46 | zap.Error(err)) 47 | continue 48 | } 49 | p.securityKeys = append(p.securityKeys, sk) 50 | } 51 | if len(p.securityKeys) == 0 { 52 | p.log.Warn("no valid security keys found") 53 | } 54 | return nil 55 | } 56 | 57 | func (p *KeyService) getSecurityKeys() ([]SecurityKey, error) { 58 | var err error 59 | // check if any securityKeys are cached, and if not then cache them 60 | if len(p.securityKeys) == 0 { 61 | if err = p.reloadSecurityKeys(); err != nil { 62 | return nil, fmt.Errorf("couldn't reload security keys: %v", err) 63 | } 64 | } 65 | // check they are healthy, and reload if not 66 | for _, k := range p.securityKeys { 67 | if _, err = k.AttestationCertificate(); err != nil { 68 | p.log.Debug("PIV KeyService: couldn't get AttestationCertificate()", zap.Error(err)) 69 | if err = p.reloadSecurityKeys(); err != nil { 70 | return nil, fmt.Errorf("couldn't reload security keys: %v", err) 71 | } 72 | break 73 | } 74 | } 75 | return p.securityKeys, nil 76 | } 77 | 78 | // SecurityKeys returns a slice containing all available security keys. 79 | func (p *KeyService) SecurityKeys() ([]SecurityKey, error) { 80 | p.mu.Lock() 81 | defer p.mu.Unlock() 82 | return p.getSecurityKeys() 83 | } 84 | -------------------------------------------------------------------------------- /internal/mock/mock_assuan.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: assuan.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -source=assuan.go -destination=../mock/mock_assuan.go -package=mock 7 | // 8 | 9 | // Package mock is a generated GoMock package. 10 | package mock 11 | 12 | import ( 13 | crypto "crypto" 14 | reflect "reflect" 15 | 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockKeyService is a mock of KeyService interface. 20 | type MockKeyService struct { 21 | ctrl *gomock.Controller 22 | recorder *MockKeyServiceMockRecorder 23 | } 24 | 25 | // MockKeyServiceMockRecorder is the mock recorder for MockKeyService. 26 | type MockKeyServiceMockRecorder struct { 27 | mock *MockKeyService 28 | } 29 | 30 | // NewMockKeyService creates a new mock instance. 31 | func NewMockKeyService(ctrl *gomock.Controller) *MockKeyService { 32 | mock := &MockKeyService{ctrl: ctrl} 33 | mock.recorder = &MockKeyServiceMockRecorder{mock} 34 | return mock 35 | } 36 | 37 | // EXPECT returns an object that allows the caller to indicate expected use. 38 | func (m *MockKeyService) EXPECT() *MockKeyServiceMockRecorder { 39 | return m.recorder 40 | } 41 | 42 | // GetDecrypter mocks base method. 43 | func (m *MockKeyService) GetDecrypter(arg0 []byte) (crypto.Decrypter, error) { 44 | m.ctrl.T.Helper() 45 | ret := m.ctrl.Call(m, "GetDecrypter", arg0) 46 | ret0, _ := ret[0].(crypto.Decrypter) 47 | ret1, _ := ret[1].(error) 48 | return ret0, ret1 49 | } 50 | 51 | // GetDecrypter indicates an expected call of GetDecrypter. 52 | func (mr *MockKeyServiceMockRecorder) GetDecrypter(arg0 any) *gomock.Call { 53 | mr.mock.ctrl.T.Helper() 54 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDecrypter", reflect.TypeOf((*MockKeyService)(nil).GetDecrypter), arg0) 55 | } 56 | 57 | // GetSigner mocks base method. 58 | func (m *MockKeyService) GetSigner(arg0 []byte) (crypto.Signer, error) { 59 | m.ctrl.T.Helper() 60 | ret := m.ctrl.Call(m, "GetSigner", arg0) 61 | ret0, _ := ret[0].(crypto.Signer) 62 | ret1, _ := ret[1].(error) 63 | return ret0, ret1 64 | } 65 | 66 | // GetSigner indicates an expected call of GetSigner. 67 | func (mr *MockKeyServiceMockRecorder) GetSigner(arg0 any) *gomock.Call { 68 | mr.mock.ctrl.T.Helper() 69 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSigner", reflect.TypeOf((*MockKeyService)(nil).GetSigner), arg0) 70 | } 71 | 72 | // HaveKey mocks base method. 73 | func (m *MockKeyService) HaveKey(arg0 [][]byte) (bool, []byte, error) { 74 | m.ctrl.T.Helper() 75 | ret := m.ctrl.Call(m, "HaveKey", arg0) 76 | ret0, _ := ret[0].(bool) 77 | ret1, _ := ret[1].([]byte) 78 | ret2, _ := ret[2].(error) 79 | return ret0, ret1, ret2 80 | } 81 | 82 | // HaveKey indicates an expected call of HaveKey. 83 | func (mr *MockKeyServiceMockRecorder) HaveKey(arg0 any) *gomock.Call { 84 | mr.mock.ctrl.T.Helper() 85 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HaveKey", reflect.TypeOf((*MockKeyService)(nil).HaveKey), arg0) 86 | } 87 | 88 | // Keygrips mocks base method. 89 | func (m *MockKeyService) Keygrips() ([][]byte, error) { 90 | m.ctrl.T.Helper() 91 | ret := m.ctrl.Call(m, "Keygrips") 92 | ret0, _ := ret[0].([][]byte) 93 | ret1, _ := ret[1].(error) 94 | return ret0, ret1 95 | } 96 | 97 | // Keygrips indicates an expected call of Keygrips. 98 | func (mr *MockKeyServiceMockRecorder) Keygrips() *gomock.Call { 99 | mr.mock.ctrl.T.Helper() 100 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Keygrips", reflect.TypeOf((*MockKeyService)(nil).Keygrips)) 101 | } 102 | 103 | // Name mocks base method. 104 | func (m *MockKeyService) Name() string { 105 | m.ctrl.T.Helper() 106 | ret := m.ctrl.Call(m, "Name") 107 | ret0, _ := ret[0].(string) 108 | return ret0 109 | } 110 | 111 | // Name indicates an expected call of Name. 112 | func (mr *MockKeyServiceMockRecorder) Name() *gomock.Call { 113 | mr.mock.ctrl.T.Helper() 114 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockKeyService)(nil).Name)) 115 | } 116 | -------------------------------------------------------------------------------- /internal/mock/mock_keyservice.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: keyservice.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -source=keyservice.go -destination=../../mock/mock_keyservice.go -package=mock 7 | // 8 | 9 | // Package mock is a generated GoMock package. 10 | package mock 11 | 12 | import ( 13 | reflect "reflect" 14 | 15 | gomock "go.uber.org/mock/gomock" 16 | ) 17 | 18 | // MockPINEntryService is a mock of PINEntryService interface. 19 | type MockPINEntryService struct { 20 | ctrl *gomock.Controller 21 | recorder *MockPINEntryServiceMockRecorder 22 | } 23 | 24 | // MockPINEntryServiceMockRecorder is the mock recorder for MockPINEntryService. 25 | type MockPINEntryServiceMockRecorder struct { 26 | mock *MockPINEntryService 27 | } 28 | 29 | // NewMockPINEntryService creates a new mock instance. 30 | func NewMockPINEntryService(ctrl *gomock.Controller) *MockPINEntryService { 31 | mock := &MockPINEntryService{ctrl: ctrl} 32 | mock.recorder = &MockPINEntryServiceMockRecorder{mock} 33 | return mock 34 | } 35 | 36 | // EXPECT returns an object that allows the caller to indicate expected use. 37 | func (m *MockPINEntryService) EXPECT() *MockPINEntryServiceMockRecorder { 38 | return m.recorder 39 | } 40 | 41 | // GetPassphrase mocks base method. 42 | func (m *MockPINEntryService) GetPassphrase(arg0, arg1 string, arg2 int) ([]byte, error) { 43 | m.ctrl.T.Helper() 44 | ret := m.ctrl.Call(m, "GetPassphrase", arg0, arg1, arg2) 45 | ret0, _ := ret[0].([]byte) 46 | ret1, _ := ret[1].(error) 47 | return ret0, ret1 48 | } 49 | 50 | // GetPassphrase indicates an expected call of GetPassphrase. 51 | func (mr *MockPINEntryServiceMockRecorder) GetPassphrase(arg0, arg1, arg2 any) *gomock.Call { 52 | mr.mock.ctrl.T.Helper() 53 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPassphrase", reflect.TypeOf((*MockPINEntryService)(nil).GetPassphrase), arg0, arg1, arg2) 54 | } 55 | -------------------------------------------------------------------------------- /internal/mock/mock_pivservice.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: list.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -source=list.go -destination=../../mock/mock_pivservice.go -package=mock 7 | // 8 | 9 | // Package mock is a generated GoMock package. 10 | package mock 11 | 12 | import ( 13 | crypto "crypto" 14 | x509 "crypto/x509" 15 | reflect "reflect" 16 | 17 | securitykey "github.com/smlx/piv-agent/internal/securitykey" 18 | gomock "go.uber.org/mock/gomock" 19 | ) 20 | 21 | // MockSecurityKey is a mock of SecurityKey interface. 22 | type MockSecurityKey struct { 23 | ctrl *gomock.Controller 24 | recorder *MockSecurityKeyMockRecorder 25 | } 26 | 27 | // MockSecurityKeyMockRecorder is the mock recorder for MockSecurityKey. 28 | type MockSecurityKeyMockRecorder struct { 29 | mock *MockSecurityKey 30 | } 31 | 32 | // NewMockSecurityKey creates a new mock instance. 33 | func NewMockSecurityKey(ctrl *gomock.Controller) *MockSecurityKey { 34 | mock := &MockSecurityKey{ctrl: ctrl} 35 | mock.recorder = &MockSecurityKeyMockRecorder{mock} 36 | return mock 37 | } 38 | 39 | // EXPECT returns an object that allows the caller to indicate expected use. 40 | func (m *MockSecurityKey) EXPECT() *MockSecurityKeyMockRecorder { 41 | return m.recorder 42 | } 43 | 44 | // AttestationCertificate mocks base method. 45 | func (m *MockSecurityKey) AttestationCertificate() (*x509.Certificate, error) { 46 | m.ctrl.T.Helper() 47 | ret := m.ctrl.Call(m, "AttestationCertificate") 48 | ret0, _ := ret[0].(*x509.Certificate) 49 | ret1, _ := ret[1].(error) 50 | return ret0, ret1 51 | } 52 | 53 | // AttestationCertificate indicates an expected call of AttestationCertificate. 54 | func (mr *MockSecurityKeyMockRecorder) AttestationCertificate() *gomock.Call { 55 | mr.mock.ctrl.T.Helper() 56 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AttestationCertificate", reflect.TypeOf((*MockSecurityKey)(nil).AttestationCertificate)) 57 | } 58 | 59 | // Card mocks base method. 60 | func (m *MockSecurityKey) Card() string { 61 | m.ctrl.T.Helper() 62 | ret := m.ctrl.Call(m, "Card") 63 | ret0, _ := ret[0].(string) 64 | return ret0 65 | } 66 | 67 | // Card indicates an expected call of Card. 68 | func (mr *MockSecurityKeyMockRecorder) Card() *gomock.Call { 69 | mr.mock.ctrl.T.Helper() 70 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Card", reflect.TypeOf((*MockSecurityKey)(nil).Card)) 71 | } 72 | 73 | // Close mocks base method. 74 | func (m *MockSecurityKey) Close() error { 75 | m.ctrl.T.Helper() 76 | ret := m.ctrl.Call(m, "Close") 77 | ret0, _ := ret[0].(error) 78 | return ret0 79 | } 80 | 81 | // Close indicates an expected call of Close. 82 | func (mr *MockSecurityKeyMockRecorder) Close() *gomock.Call { 83 | mr.mock.ctrl.T.Helper() 84 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockSecurityKey)(nil).Close)) 85 | } 86 | 87 | // Comment mocks base method. 88 | func (m *MockSecurityKey) Comment(arg0 *securitykey.SlotSpec) string { 89 | m.ctrl.T.Helper() 90 | ret := m.ctrl.Call(m, "Comment", arg0) 91 | ret0, _ := ret[0].(string) 92 | return ret0 93 | } 94 | 95 | // Comment indicates an expected call of Comment. 96 | func (mr *MockSecurityKeyMockRecorder) Comment(arg0 any) *gomock.Call { 97 | mr.mock.ctrl.T.Helper() 98 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Comment", reflect.TypeOf((*MockSecurityKey)(nil).Comment), arg0) 99 | } 100 | 101 | // CryptoKeys mocks base method. 102 | func (m *MockSecurityKey) CryptoKeys() []securitykey.CryptoKey { 103 | m.ctrl.T.Helper() 104 | ret := m.ctrl.Call(m, "CryptoKeys") 105 | ret0, _ := ret[0].([]securitykey.CryptoKey) 106 | return ret0 107 | } 108 | 109 | // CryptoKeys indicates an expected call of CryptoKeys. 110 | func (mr *MockSecurityKeyMockRecorder) CryptoKeys() *gomock.Call { 111 | mr.mock.ctrl.T.Helper() 112 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CryptoKeys", reflect.TypeOf((*MockSecurityKey)(nil).CryptoKeys)) 113 | } 114 | 115 | // PrivateKey mocks base method. 116 | func (m *MockSecurityKey) PrivateKey(arg0 *securitykey.CryptoKey) (crypto.PrivateKey, error) { 117 | m.ctrl.T.Helper() 118 | ret := m.ctrl.Call(m, "PrivateKey", arg0) 119 | ret0, _ := ret[0].(crypto.PrivateKey) 120 | ret1, _ := ret[1].(error) 121 | return ret0, ret1 122 | } 123 | 124 | // PrivateKey indicates an expected call of PrivateKey. 125 | func (mr *MockSecurityKeyMockRecorder) PrivateKey(arg0 any) *gomock.Call { 126 | mr.mock.ctrl.T.Helper() 127 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrivateKey", reflect.TypeOf((*MockSecurityKey)(nil).PrivateKey), arg0) 128 | } 129 | 130 | // SigningKeys mocks base method. 131 | func (m *MockSecurityKey) SigningKeys() []securitykey.SigningKey { 132 | m.ctrl.T.Helper() 133 | ret := m.ctrl.Call(m, "SigningKeys") 134 | ret0, _ := ret[0].([]securitykey.SigningKey) 135 | return ret0 136 | } 137 | 138 | // SigningKeys indicates an expected call of SigningKeys. 139 | func (mr *MockSecurityKeyMockRecorder) SigningKeys() *gomock.Call { 140 | mr.mock.ctrl.T.Helper() 141 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SigningKeys", reflect.TypeOf((*MockSecurityKey)(nil).SigningKeys)) 142 | } 143 | 144 | // StringsGPG mocks base method. 145 | func (m *MockSecurityKey) StringsGPG(arg0, arg1 string) ([]string, error) { 146 | m.ctrl.T.Helper() 147 | ret := m.ctrl.Call(m, "StringsGPG", arg0, arg1) 148 | ret0, _ := ret[0].([]string) 149 | ret1, _ := ret[1].(error) 150 | return ret0, ret1 151 | } 152 | 153 | // StringsGPG indicates an expected call of StringsGPG. 154 | func (mr *MockSecurityKeyMockRecorder) StringsGPG(arg0, arg1 any) *gomock.Call { 155 | mr.mock.ctrl.T.Helper() 156 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StringsGPG", reflect.TypeOf((*MockSecurityKey)(nil).StringsGPG), arg0, arg1) 157 | } 158 | 159 | // StringsSSH mocks base method. 160 | func (m *MockSecurityKey) StringsSSH() []string { 161 | m.ctrl.T.Helper() 162 | ret := m.ctrl.Call(m, "StringsSSH") 163 | ret0, _ := ret[0].([]string) 164 | return ret0 165 | } 166 | 167 | // StringsSSH indicates an expected call of StringsSSH. 168 | func (mr *MockSecurityKeyMockRecorder) StringsSSH() *gomock.Call { 169 | mr.mock.ctrl.T.Helper() 170 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StringsSSH", reflect.TypeOf((*MockSecurityKey)(nil).StringsSSH)) 171 | } 172 | -------------------------------------------------------------------------------- /internal/notify/touch.go: -------------------------------------------------------------------------------- 1 | // Package notify implements a touch notification system. 2 | package notify 3 | 4 | import ( 5 | "context" 6 | "time" 7 | 8 | "github.com/gen2brain/beeep" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | // Notify contains touch notification configuration. 13 | type Notify struct { 14 | log *zap.Logger 15 | touchNotifyDelay time.Duration 16 | } 17 | 18 | // New initialises a new Notify struct. 19 | func New(log *zap.Logger, touchNotifyDelay time.Duration) *Notify { 20 | return &Notify{ 21 | log: log, 22 | touchNotifyDelay: touchNotifyDelay, 23 | } 24 | } 25 | 26 | // Touch starts a goroutine, and waits for a short period. If the returned 27 | // CancelFunc has not been called it sends a notification to remind the user to 28 | // physically touch the Security Key. 29 | func (n *Notify) Touch() context.CancelFunc { 30 | ctx, cancel := context.WithCancel(context.Background()) 31 | timer := time.NewTimer(n.touchNotifyDelay) 32 | go func() { 33 | select { 34 | case <-ctx.Done(): 35 | timer.Stop() 36 | case <-timer.C: 37 | err := beeep.Alert("Security Key Agent", "Waiting for touch...", "") 38 | if err != nil { 39 | n.log.Warn("couldn't send touch notification", zap.Error(err)) 40 | } 41 | } 42 | }() 43 | return cancel 44 | } 45 | -------------------------------------------------------------------------------- /internal/pinentry/pinentry.go: -------------------------------------------------------------------------------- 1 | // Package pinentry implements a PIN/passphrase entry dialog. 2 | package pinentry 3 | 4 | import ( 5 | "fmt" 6 | 7 | gpm "github.com/twpayne/go-pinentry-minimal/pinentry" 8 | ) 9 | 10 | // A SecurityKey is a physical hardware token that requires a PIN. 11 | type SecurityKey interface { 12 | Card() string 13 | Retries() (int, error) 14 | Serial() uint32 15 | } 16 | 17 | // PINEntry implements useful pinentry service methods. 18 | type PINEntry struct { 19 | binaryName string 20 | } 21 | 22 | // New initialises a new PINEntry. 23 | func New(binaryName string) *PINEntry { 24 | return &PINEntry{ 25 | binaryName: binaryName, 26 | } 27 | } 28 | 29 | // GetPin uses pinentry to get the pin of the given token. 30 | func (pe *PINEntry) GetPin(k SecurityKey) func() (string, error) { 31 | return func() (string, error) { 32 | r, err := k.Retries() 33 | if err != nil { 34 | return "", fmt.Errorf("couldn't get retries for security key: %w", err) 35 | } 36 | c, err := gpm.NewClient( 37 | gpm.WithBinaryName(pe.binaryName), 38 | gpm.WithTitle("piv-agent PIN Prompt"), 39 | gpm.WithPrompt("Please enter PIN:"), 40 | gpm.WithDesc( 41 | fmt.Sprintf("%s #%d\r(%d attempts remaining)", 42 | k.Card(), k.Serial(), r)), 43 | // optional PIN cache with yubikey-agent compatibility 44 | gpm.WithOption("allow-external-password-cache"), 45 | gpm.WithKeyInfo(fmt.Sprintf("--yubikey-id-%d", k.Serial())), 46 | ) 47 | if err != nil { 48 | return "", fmt.Errorf("couldn't get pinentry client: %w", err) 49 | } 50 | defer c.Close() 51 | pin, _, err := c.GetPIN() 52 | return pin, err 53 | } 54 | } 55 | 56 | // GetPassphrase uses pinentry to get the passphrase of the given key file. 57 | func (pe *PINEntry) GetPassphrase(desc, keyID string, tries int) ([]byte, error) { 58 | c, err := gpm.NewClient( 59 | gpm.WithBinaryName(pe.binaryName), 60 | gpm.WithTitle("piv-agent Passphrase Prompt"), 61 | gpm.WithPrompt("Please enter passphrase"), 62 | gpm.WithDesc(fmt.Sprintf("%s\r(%d attempts remaining)", desc, tries)), 63 | 64 | // optional PIN cache with yubikey-agent compatibility 65 | gpm.WithOption("allow-external-password-cache"), 66 | gpm.WithKeyInfo(keyID), 67 | ) 68 | if err != nil { 69 | return nil, fmt.Errorf("couldn't get pinentry client: %w", err) 70 | } 71 | defer c.Close() 72 | pin, _, err := c.GetPIN() 73 | return []byte(pin), err 74 | } 75 | -------------------------------------------------------------------------------- /internal/securitykey/decryptingkey.go: -------------------------------------------------------------------------------- 1 | package securitykey 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/ProtonMail/go-crypto/openpgp/packet" 9 | pivgo "github.com/go-piv/piv-go/v2/piv" 10 | ) 11 | 12 | // DecryptingKey is a cryptographic decrypting key on a hardware security 13 | // device. 14 | type DecryptingKey struct { 15 | CryptoKey 16 | PubPGP *packet.PublicKey 17 | } 18 | 19 | // decryptingKeys returns the decrypting keys available on the given yubikey. 20 | func decryptingKeys(yk *pivgo.YubiKey) ([]DecryptingKey, error) { 21 | var decryptingKeys []DecryptingKey 22 | for _, s := range defaultDecryptSlots { 23 | cert, err := yk.Certificate(s.Slot) 24 | if err != nil { 25 | if errors.Is(err, pivgo.ErrNotFound) { 26 | continue 27 | } 28 | return nil, fmt.Errorf("couldn't get certificate for slot %x: %v", 29 | s.Slot.Key, err) 30 | } 31 | pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey) 32 | if !ok { 33 | return nil, fmt.Errorf("invalid public key type: %T", cert.PublicKey) 34 | } 35 | decryptingKeys = append(decryptingKeys, DecryptingKey{ 36 | CryptoKey: CryptoKey{ 37 | Public: pubKey, 38 | SlotSpec: s, 39 | }, 40 | PubPGP: packet.NewECDSAPublicKey(cert.NotBefore, 41 | openpgpECDSAPublicKey(pubKey)), 42 | }) 43 | } 44 | return decryptingKeys, nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/securitykey/openpgpecdsa.go: -------------------------------------------------------------------------------- 1 | package securitykey 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | 6 | openpgpecdsa "github.com/ProtonMail/go-crypto/openpgp/ecdsa" 7 | ) 8 | 9 | // openpgpECDSAPublicKey converts the given ECDSA Key in crypto/ecdsa 10 | // representation, to go-crypto/openpgp representation. 11 | func openpgpECDSAPublicKey(k *ecdsa.PublicKey) *openpgpecdsa.PublicKey { 12 | openpgpPubKey := openpgpecdsa.NewPublicKeyFromCurve(k.Curve) 13 | openpgpPubKey.X = k.X 14 | openpgpPubKey.Y = k.Y 15 | return openpgpPubKey 16 | } 17 | -------------------------------------------------------------------------------- /internal/securitykey/securitykey.go: -------------------------------------------------------------------------------- 1 | // Package securitykey provides an interface to a physical security key such as 2 | // a Yubikey. 3 | package securitykey 4 | 5 | import ( 6 | "crypto" 7 | "crypto/x509" 8 | "fmt" 9 | 10 | pivgo "github.com/go-piv/piv-go/v2/piv" 11 | "github.com/smlx/piv-agent/internal/pinentry" 12 | ) 13 | 14 | // A SecurityKey is a physical hardware token which implements PIV, such as a 15 | // Yubikey. It provides a convenient abstraction around the low-level 16 | // pivgo.YubiKey object. 17 | type SecurityKey struct { 18 | card string 19 | serial uint32 20 | yubikey *pivgo.YubiKey 21 | signingKeys []SigningKey 22 | decryptingKeys []DecryptingKey 23 | cryptoKeys []CryptoKey 24 | pinentry *pinentry.PINEntry 25 | } 26 | 27 | // CryptoKey represents a cryptographic key on a hardware security device. 28 | type CryptoKey struct { 29 | SlotSpec SlotSpec 30 | Public crypto.PublicKey 31 | } 32 | 33 | // New returns a security key identified by card string. 34 | func New(card string, pe *pinentry.PINEntry) (*SecurityKey, error) { 35 | yk, err := pivgo.Open(card) 36 | if err != nil { 37 | return nil, fmt.Errorf(`couldn't open card "%s": %v`, card, err) 38 | } 39 | serial, err := yk.Serial() 40 | if err != nil { 41 | return nil, fmt.Errorf(`couldn't get serial for card "%s": %v`, 42 | card, err) 43 | } 44 | 45 | sks, err := signingKeys(yk) 46 | if err != nil { 47 | return nil, fmt.Errorf(`couldn't get signing keys for card "%s": %v`, 48 | card, err) 49 | } 50 | var cryptoKeys []CryptoKey 51 | for _, k := range sks { 52 | cryptoKeys = append(cryptoKeys, k.CryptoKey) 53 | } 54 | 55 | dks, err := decryptingKeys(yk) 56 | if err != nil { 57 | return nil, fmt.Errorf(`couldn't get decrypting keys for card "%s": %v`, 58 | card, err) 59 | } 60 | for _, k := range dks { 61 | cryptoKeys = append(cryptoKeys, k.CryptoKey) 62 | } 63 | return &SecurityKey{ 64 | card: card, 65 | serial: serial, 66 | yubikey: yk, 67 | signingKeys: sks, 68 | decryptingKeys: dks, 69 | cryptoKeys: cryptoKeys, 70 | pinentry: pe, 71 | }, nil 72 | } 73 | 74 | // Retries returns the number of attempts remaining to enter the correct PIN. 75 | func (k *SecurityKey) Retries() (int, error) { 76 | return k.yubikey.Retries() 77 | } 78 | 79 | // Serial returns the serial number of the SecurityKey. 80 | func (k *SecurityKey) Serial() uint32 { 81 | return k.serial 82 | } 83 | 84 | // SigningKeys returns the slice of cryptographic signing keys held by the 85 | // SecurityKey. 86 | func (k *SecurityKey) SigningKeys() []SigningKey { 87 | return k.signingKeys 88 | } 89 | 90 | // DecryptingKeys returns the slice of cryptographic decrypting keys held by 91 | // the SecurityKey. 92 | func (k *SecurityKey) DecryptingKeys() []DecryptingKey { 93 | return k.decryptingKeys 94 | } 95 | 96 | // CryptoKeys returns the slice of cryptographic signing and decrypting keys 97 | // held by the SecurityKey. 98 | func (k *SecurityKey) CryptoKeys() []CryptoKey { 99 | return k.cryptoKeys 100 | } 101 | 102 | // PrivateKey returns the private key of the given public signing key. 103 | func (k *SecurityKey) PrivateKey(c *CryptoKey) (crypto.PrivateKey, error) { 104 | return k.yubikey.PrivateKey(c.SlotSpec.Slot, c.Public, 105 | pivgo.KeyAuth{PINPrompt: k.pinentry.GetPin(k)}) 106 | } 107 | 108 | // Close closes the underlying yubikey. 109 | func (k *SecurityKey) Close() error { 110 | return k.yubikey.Close() 111 | } 112 | 113 | // AttestationCertificate returns the attestation certificate of the underlying 114 | // yubikey. 115 | func (k *SecurityKey) AttestationCertificate() (*x509.Certificate, error) { 116 | return k.yubikey.AttestationCertificate() 117 | } 118 | 119 | // Card returns the card identifier. 120 | func (k *SecurityKey) Card() string { 121 | return k.card 122 | } 123 | -------------------------------------------------------------------------------- /internal/securitykey/setup.go: -------------------------------------------------------------------------------- 1 | package securitykey 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "crypto/rand" 7 | "crypto/x509" 8 | "crypto/x509/pkix" 9 | "errors" 10 | "fmt" 11 | "math/big" 12 | "time" 13 | 14 | pivgo "github.com/go-piv/piv-go/v2/piv" 15 | ) 16 | 17 | // ErrKeySetUp is returned from Setup when the security key is already set up 18 | // and reset is false. 19 | var ErrKeySetUp = errors.New("security key already set up") 20 | 21 | // checkSlotSetUp checks if the provided slot is set up, returning true if the 22 | // slot is set up and false otherwise. 23 | func (k *SecurityKey) checkSlotSetUp(s SlotSpec) (bool, error) { 24 | _, err := k.yubikey.Certificate(s.Slot) 25 | if err == nil { 26 | return true, nil 27 | } else if errors.Is(err, pivgo.ErrNotFound) { 28 | return false, nil 29 | } 30 | return false, fmt.Errorf("couldn't check slot certificate: %v", err) 31 | } 32 | 33 | // checkSlotsSetUp checks if the provided slots are set up returning true if 34 | // any of the slots are set up, and false otherwise. 35 | func (k *SecurityKey) checkSlotsSetUp(signingKeys []string, 36 | decryptingKeys []string) (bool, error) { 37 | for _, p := range signingKeys { 38 | setUp, err := k.checkSlotSetUp(defaultSignSlots[p]) 39 | if err != nil { 40 | return false, err 41 | } 42 | if setUp { 43 | return true, nil 44 | } 45 | } 46 | for _, p := range decryptingKeys { 47 | setUp, err := k.checkSlotSetUp(defaultDecryptSlots[p]) 48 | if err != nil { 49 | return false, err 50 | } 51 | if setUp { 52 | return true, nil 53 | } 54 | } 55 | return false, nil 56 | } 57 | 58 | // Setup configures the SecurityKey to work with piv-agent. 59 | func (k *SecurityKey) Setup(pin, version string, reset bool, 60 | signingKeys []string, decryptingKeys []string) error { 61 | var err error 62 | if !reset { 63 | setUp, err := k.checkSlotsSetUp(signingKeys, decryptingKeys) 64 | if err != nil { 65 | return fmt.Errorf("couldn't check slots: %v", err) 66 | } 67 | if setUp { 68 | return ErrKeySetUp 69 | } 70 | } 71 | // reset security key 72 | if err = k.yubikey.Reset(); err != nil { 73 | return fmt.Errorf("couldn't reset security key: %v", err) 74 | } 75 | // generate management key and store on the security key 76 | var mgmtKey = make([]byte, 24) 77 | if _, err := rand.Read(mgmtKey); err != nil { 78 | return fmt.Errorf("couldn't get random bytes: %v", err) 79 | } 80 | err = k.yubikey.SetManagementKey(pivgo.DefaultManagementKey, mgmtKey) 81 | if err != nil { 82 | return fmt.Errorf("couldn't set management key: %v", err) 83 | } 84 | err = k.yubikey.SetMetadata(mgmtKey, &pivgo.Metadata{ManagementKey: &mgmtKey}) 85 | if err != nil { 86 | return fmt.Errorf("couldn't store management key: %v", err) 87 | } 88 | // set pin/puk 89 | if err = k.yubikey.SetPIN(pivgo.DefaultPIN, pin); err != nil { 90 | return fmt.Errorf("couldn't set PIN: %v", err) 91 | } 92 | if err = k.yubikey.SetPUK(pivgo.DefaultPUK, pin); err != nil { 93 | return fmt.Errorf("couldn't set PUK: %v", err) 94 | } 95 | // setup signing keys 96 | for _, p := range signingKeys { 97 | err := k.configureSlot(mgmtKey, defaultSignSlots[p], version) 98 | if err != nil { 99 | return fmt.Errorf("couldn't configure slot %v: %v", 100 | defaultSignSlots[p], err) 101 | } 102 | } 103 | // setup decrypt keys 104 | for _, p := range decryptingKeys { 105 | err := k.configureSlot(mgmtKey, defaultDecryptSlots[p], version) 106 | if err != nil { 107 | return fmt.Errorf("couldn't configure slot %v: %v", 108 | defaultDecryptSlots[p], err) 109 | } 110 | } 111 | return nil 112 | } 113 | 114 | func (k *SecurityKey) configureSlot(mgmtKey []byte, spec SlotSpec, 115 | version string) error { 116 | pub, err := k.yubikey.GenerateKey(mgmtKey, spec.Slot, pivgo.Key{ 117 | Algorithm: pivgo.AlgorithmEC256, 118 | PINPolicy: pivgo.PINPolicyOnce, 119 | TouchPolicy: spec.TouchPolicy, 120 | }) 121 | if err != nil { 122 | return fmt.Errorf("couldn't generate key for spec %v: %v", spec, err) 123 | } 124 | priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 125 | if err != nil { 126 | return fmt.Errorf("couldn't generate parent key: %w", err) 127 | } 128 | parent := &x509.Certificate{ 129 | Subject: pkix.Name{ 130 | Organization: []string{"piv-agent"}, 131 | OrganizationalUnit: []string{version}, 132 | }, 133 | PublicKey: priv.Public(), 134 | } 135 | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) 136 | if err != nil { 137 | return fmt.Errorf("couldn't generate serial: %w", err) 138 | } 139 | template := &x509.Certificate{ 140 | Subject: pkix.Name{ 141 | CommonName: "piv-agent key", 142 | }, 143 | NotAfter: time.Now().AddDate(64, 0, 0), 144 | NotBefore: time.Now(), 145 | SerialNumber: serial, 146 | KeyUsage: x509.KeyUsageKeyAgreement | x509.KeyUsageDigitalSignature, 147 | } 148 | certBytes, err := x509.CreateCertificate(rand.Reader, template, parent, pub, 149 | priv) 150 | if err != nil { 151 | return fmt.Errorf("couldn't create certificate: %w", err) 152 | } 153 | cert, err := x509.ParseCertificate(certBytes) 154 | if err != nil { 155 | return fmt.Errorf("couldn't parse certificate: %w", err) 156 | } 157 | if err = k.yubikey.SetCertificate(mgmtKey, spec.Slot, cert); err != nil { 158 | return fmt.Errorf("couldn't set certificate: %w", err) 159 | } 160 | return nil 161 | } 162 | -------------------------------------------------------------------------------- /internal/securitykey/setupslots.go: -------------------------------------------------------------------------------- 1 | package securitykey 2 | 3 | import "fmt" 4 | 5 | // SetupSlots configures slots on the security key without resetting it 6 | // completely. 7 | func (k *SecurityKey) SetupSlots(pin, version string, reset bool, 8 | signingKeys []string, decryptingKeys []string) error { 9 | var err error 10 | if !reset { 11 | setUp, err := k.checkSlotsSetUp(signingKeys, decryptingKeys) 12 | if err != nil { 13 | return fmt.Errorf("couldn't check slots: %v", err) 14 | } 15 | if setUp { 16 | return ErrKeySetUp 17 | } 18 | } 19 | // get the management key 20 | metadata, err := k.yubikey.Metadata(pin) 21 | if err != nil { 22 | return fmt.Errorf("coudnt' get metadata: %v", err) 23 | } 24 | // setup signing keys 25 | for _, p := range signingKeys { 26 | err := k.configureSlot(*metadata.ManagementKey, defaultSignSlots[p], 27 | version) 28 | if err != nil { 29 | return fmt.Errorf("couldn't configure slot %v: %v", defaultSignSlots[p], 30 | err) 31 | } 32 | } 33 | // setup decrypt key 34 | for _, p := range decryptingKeys { 35 | err := k.configureSlot(*metadata.ManagementKey, defaultDecryptSlots[p], 36 | version) 37 | if err != nil { 38 | return fmt.Errorf("couldn't configure slot %v: %v", 39 | defaultDecryptSlots[p], err) 40 | } 41 | } 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/securitykey/signingkey.go: -------------------------------------------------------------------------------- 1 | package securitykey 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/ProtonMail/go-crypto/openpgp/packet" 9 | pivgo "github.com/go-piv/piv-go/v2/piv" 10 | "golang.org/x/crypto/ssh" 11 | ) 12 | 13 | // SigningKey is a public signing key on a security key / hardware token. 14 | type SigningKey struct { 15 | CryptoKey 16 | PubSSH ssh.PublicKey 17 | PubPGP *packet.PublicKey 18 | } 19 | 20 | // signingKeys returns the signing keys available on the given yubikey. 21 | func signingKeys(yk *pivgo.YubiKey) ([]SigningKey, error) { 22 | var signingKeys []SigningKey 23 | for _, s := range defaultSignSlots { 24 | cert, err := yk.Certificate(s.Slot) 25 | if err != nil { 26 | if errors.Is(err, pivgo.ErrNotFound) { 27 | continue 28 | } 29 | return nil, fmt.Errorf("couldn't get certificate for slot %x: %v", 30 | s.Slot.Key, err) 31 | } 32 | pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey) 33 | if !ok { 34 | return nil, fmt.Errorf("invalid public key type: %T", cert.PublicKey) 35 | } 36 | pubSSH, err := ssh.NewPublicKey(cert.PublicKey) 37 | if err != nil { 38 | return nil, fmt.Errorf("couldn't convert public key: %v", err) 39 | } 40 | signingKeys = append(signingKeys, SigningKey{ 41 | CryptoKey: CryptoKey{ 42 | Public: pubKey, 43 | SlotSpec: s, 44 | }, 45 | PubSSH: pubSSH, 46 | PubPGP: packet.NewECDSAPublicKey(cert.NotBefore, 47 | openpgpECDSAPublicKey(pubKey)), 48 | }) 49 | } 50 | return signingKeys, nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/securitykey/slotspec.go: -------------------------------------------------------------------------------- 1 | package securitykey 2 | 3 | import pivgo "github.com/go-piv/piv-go/v2/piv" 4 | 5 | // SlotSpec represents a combination of slot and touch policy on the token. 6 | type SlotSpec struct { 7 | Slot pivgo.Slot 8 | TouchPolicy pivgo.TouchPolicy 9 | } 10 | 11 | // defaultSignSlots represents the default slot specifications for signing 12 | // operations. 13 | // See https://developers.yubico.com/PIV/Introduction/Certificate_slots.html 14 | var defaultSignSlots = map[string]SlotSpec{ 15 | // Slot 9a: PIV Authentication 16 | // This certificate and its associated private key is used to authenticate 17 | // the card and the cardholder. This slot is used for things like system 18 | // login. The end user PIN is required to perform any private key operations. 19 | // Once the PIN has been provided successfully, multiple private key 20 | // operations may be performed without additional cardholder consent. 21 | "cached": {pivgo.SlotAuthentication, pivgo.TouchPolicyCached}, 22 | // Slot 9c: Digital Signature 23 | // This certificate and its associated private key is used for digital 24 | // signatures for the purpose of document signing, or signing files and 25 | // executables. The end user PIN is required to perform any private key 26 | // operations. The PIN must be submitted every time immediately before a sign 27 | // operation, to ensure cardholder participation for every digital signature 28 | // generated. 29 | "always": {pivgo.SlotSignature, pivgo.TouchPolicyAlways}, 30 | // Slot 9e: Card Authentication 31 | // This certificate and its associated private key is used to support 32 | // additional physical access applications, such as providing physical access 33 | // to buildings via PIV-enabled door locks. The end user PIN is NOT required 34 | // to perform private key operations for this slot. 35 | "never": {pivgo.SlotCardAuthentication, pivgo.TouchPolicyNever}, 36 | } 37 | 38 | var alwaysDecryptSlot, _ = pivgo.RetiredKeyManagementSlot(0x82) 39 | var neverDecryptSlot, _ = pivgo.RetiredKeyManagementSlot(0x83) 40 | 41 | // defaultDecryptSlots represents the slot specifications for decrypting 42 | // operations. By using additional "retired" slots we can enable multiple touch 43 | // policies for decrypt. 44 | var defaultDecryptSlots = map[string]SlotSpec{ 45 | // Slot 9d: Key Management 46 | // This certificate and its associated private key is used for encryption for 47 | // the purpose of confidentiality. This slot is used for things like 48 | // encrypting e-mails or files. The end user PIN is required to perform any 49 | // private key operations. Once the PIN has been provided successfully, 50 | // multiple private key operations may be performed without additional 51 | // cardholder consent. 52 | "cached": {pivgo.SlotKeyManagement, pivgo.TouchPolicyCached}, 53 | // "Retired" key management slot with an "always" touch policy. 54 | "always": {alwaysDecryptSlot, pivgo.TouchPolicyAlways}, 55 | // "Retired" key management slot with a "never" touch policy. 56 | "never": {neverDecryptSlot, pivgo.TouchPolicyNever}, 57 | } 58 | -------------------------------------------------------------------------------- /internal/securitykey/string.go: -------------------------------------------------------------------------------- 1 | package securitykey 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "crypto/ecdsa" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | "github.com/ProtonMail/go-crypto/openpgp" 12 | "github.com/ProtonMail/go-crypto/openpgp/armor" 13 | openpgpecdsa "github.com/ProtonMail/go-crypto/openpgp/ecdsa" 14 | "github.com/ProtonMail/go-crypto/openpgp/errors" 15 | "github.com/ProtonMail/go-crypto/openpgp/packet" 16 | pivgo "github.com/go-piv/piv-go/v2/piv" 17 | "golang.org/x/crypto/ssh" 18 | ) 19 | 20 | var touchStringMap = map[pivgo.TouchPolicy]string{ 21 | pivgo.TouchPolicyNever: "never", 22 | pivgo.TouchPolicyAlways: "always", 23 | pivgo.TouchPolicyCached: "cached", 24 | } 25 | 26 | // Entity wraps a synthesized openpgp.Entity and associates it with a 27 | // SigningKey. 28 | type Entity struct { 29 | openpgp.Entity 30 | CryptoKey 31 | } 32 | 33 | // Comment returns a comment suitable for e.g. the SSH public key format 34 | func (k *SecurityKey) Comment(ss *SlotSpec) string { 35 | return fmt.Sprintf("%v #%v, touch policy: %s", k.card, k.serial, 36 | touchStringMap[ss.TouchPolicy]) 37 | } 38 | 39 | // StringsSSH returns an array of commonly formatted SSH keys as strings. 40 | func (k *SecurityKey) StringsSSH() []string { 41 | var ss []string 42 | for _, s := range k.SigningKeys() { 43 | ss = append(ss, fmt.Sprintf("%s %s\n", 44 | strings.TrimSuffix(string(ssh.MarshalAuthorizedKey(s.PubSSH)), "\n"), 45 | k.Comment(&s.SlotSpec))) 46 | } 47 | return ss 48 | } 49 | 50 | func (k *SecurityKey) synthesizeEntity(ck *CryptoKey, now time.Time, 51 | name, email, comment string) (*openpgp.Entity, error) { 52 | cryptoPrivKey, err := k.PrivateKey(ck) 53 | if err != nil { 54 | return nil, fmt.Errorf("couldn't get private key: %v", err) 55 | } 56 | signer, ok := cryptoPrivKey.(crypto.Signer) 57 | if !ok { 58 | return nil, fmt.Errorf("private key is invalid type") 59 | } 60 | uid := packet.NewUserId(name, comment, email) 61 | if uid == nil { 62 | return nil, errors.InvalidArgumentError("invalid characters in user ID") 63 | } 64 | ecdsaPubKey, ok := ck.Public.(*ecdsa.PublicKey) 65 | if !ok { 66 | // TODO: handle ed25519 keys 67 | return nil, fmt.Errorf("not an ECDSA key") 68 | } 69 | pub := packet.NewECDSAPublicKey(now, 70 | openpgpecdsa.NewPublicKeyFromCurve(ecdsaPubKey.Curve)) 71 | priv := packet.NewSignerPrivateKey(now, signer) 72 | selfSignature := packet.Signature{ 73 | CreationTime: now, 74 | SigType: packet.SigTypePositiveCert, 75 | // TODO: determine the key type 76 | PubKeyAlgo: packet.PubKeyAlgoECDSA, 77 | Hash: crypto.SHA256, 78 | IssuerKeyId: &pub.KeyId, 79 | FlagsValid: true, 80 | FlagSign: true, 81 | FlagCertify: true, 82 | } 83 | err = selfSignature.SignUserId(uid.Id, pub, priv, nil) 84 | if err != nil { 85 | return nil, fmt.Errorf("couldn't sign user ID: %v", err) 86 | } 87 | return &openpgp.Entity{ 88 | PrimaryKey: pub, 89 | PrivateKey: priv, 90 | Identities: map[string]*openpgp.Identity{ 91 | uid.Id: { 92 | Name: uid.Name, 93 | UserId: uid, 94 | SelfSignature: &selfSignature, 95 | }, 96 | }, 97 | }, nil 98 | } 99 | 100 | // synthesizeEntities returns an array of signing and decrypting Entities for 101 | // k's cryptographic keys. 102 | // Because OpenPGP entities must be self-signed, this function needs a physical 103 | // touch on the yubikey for slots with touch policies that require it. 104 | func (k *SecurityKey) synthesizeEntities(name, email string) ([]Entity, 105 | []Entity, error) { 106 | now := time.Now() 107 | var signing, decrypting []Entity 108 | for _, sk := range k.SigningKeys() { 109 | e, err := k.synthesizeEntity(&sk.CryptoKey, now, name, email, 110 | fmt.Sprintf("piv-agent signing key; touch-policy %s", 111 | touchStringMap[sk.SlotSpec.TouchPolicy])) 112 | if err != nil { 113 | return nil, nil, fmt.Errorf("couldn't synthesize entity: %v", err) 114 | } 115 | signing = append(signing, Entity{Entity: *e, CryptoKey: sk.CryptoKey}) 116 | } 117 | for _, dk := range k.DecryptingKeys() { 118 | e, err := k.synthesizeEntity(&dk.CryptoKey, now, name, email, 119 | fmt.Sprintf("piv-agent decrypting key; touch-policy %s", 120 | touchStringMap[dk.SlotSpec.TouchPolicy])) 121 | if err != nil { 122 | return nil, nil, fmt.Errorf("couldn't synthesize entity: %v", err) 123 | } 124 | decrypting = append(decrypting, Entity{Entity: *e, CryptoKey: dk.CryptoKey}) 125 | } 126 | return signing, decrypting, nil 127 | } 128 | 129 | func (k *SecurityKey) armorEntity(e *openpgp.Entity, 130 | t pivgo.TouchPolicy) (string, error) { 131 | buf := bytes.Buffer{} 132 | w, err := armor.Encode(&buf, openpgp.PublicKeyType, 133 | map[string]string{ 134 | "Comment": fmt.Sprintf("%v #%v, touch policy: %s", 135 | k.card, k.serial, touchStringMap[t]), 136 | }) 137 | if err != nil { 138 | return "", fmt.Errorf("couldn't get PGP public key armorer: %w", err) 139 | } 140 | err = e.Serialize(w) 141 | if err != nil { 142 | return "", fmt.Errorf("couldn't serialize PGP public key: %w", err) 143 | } 144 | err = w.Close() 145 | if err != nil { 146 | return "", fmt.Errorf("couldn't close pgp writer: %w", err) 147 | } 148 | return buf.String(), nil 149 | } 150 | 151 | // StringsGPG returns an array of commonly formatted GPG keys as strings. 152 | func (k *SecurityKey) StringsGPG(name, email string) ([]string, error) { 153 | var ss []string 154 | signing, decrypting, err := k.synthesizeEntities(name, email) 155 | if err != nil { 156 | return nil, fmt.Errorf("couldn't synthesize entities: %v", err) 157 | } 158 | ss = append(ss, "\nSigning GPG Keys:") 159 | for _, key := range signing { 160 | s, err := k.armorEntity(&key.Entity, key.SlotSpec.TouchPolicy) 161 | if err != nil { 162 | return nil, fmt.Errorf("couldn't armor entity: %v", err) 163 | } 164 | ss = append(ss, s) 165 | } 166 | ss = append(ss, "\nDecrypting GPG Keys:") 167 | for _, key := range decrypting { 168 | s, err := k.armorEntity(&key.Entity, key.SlotSpec.TouchPolicy) 169 | if err != nil { 170 | return nil, fmt.Errorf("couldn't armor entity: %v", err) 171 | } 172 | ss = append(ss, s) 173 | } 174 | return ss, nil 175 | } 176 | -------------------------------------------------------------------------------- /internal/server/common.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net" 5 | 6 | "go.uber.org/zap" 7 | ) 8 | 9 | // accept connections in a goroutine and return them on a channel 10 | func accept(log *zap.Logger, l net.Listener) <-chan net.Conn { 11 | conns := make(chan net.Conn) 12 | go func() { 13 | for { 14 | c, err := l.Accept() 15 | if err != nil { 16 | log.Error("accept error", zap.Error(err)) 17 | close(conns) 18 | return 19 | } 20 | conns <- c 21 | } 22 | }() 23 | return conns 24 | } 25 | -------------------------------------------------------------------------------- /internal/server/gpg.go: -------------------------------------------------------------------------------- 1 | // Package server implements a gpg-agent server. 2 | package server 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "net" 8 | "time" 9 | 10 | "github.com/smlx/piv-agent/internal/assuan" 11 | "github.com/smlx/piv-agent/internal/keyservice/gpg" 12 | "github.com/smlx/piv-agent/internal/keyservice/piv" 13 | "github.com/smlx/piv-agent/internal/notify" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | const connTimeout = 4 * time.Minute 18 | 19 | // GPG represents a gpg-agent server. 20 | type GPG struct { 21 | log *zap.Logger 22 | notify *notify.Notify 23 | pivKeyService *piv.KeyService 24 | gpgKeyService *gpg.KeyService // fallback keyfile keys 25 | } 26 | 27 | // NewGPG initialises a new gpg-agent server. 28 | func NewGPG(piv *piv.KeyService, pinentry gpg.PINEntryService, 29 | log *zap.Logger, path string, n *notify.Notify) *GPG { 30 | return &GPG{ 31 | log: log, 32 | notify: n, 33 | pivKeyService: piv, 34 | gpgKeyService: gpg.New(log, pinentry, path), 35 | } 36 | } 37 | 38 | // Serve starts serving signing requests, and returns when the request socket 39 | // is closed, the context is cancelled, or an error occurs. 40 | func (g *GPG) Serve(ctx context.Context, l net.Listener, exit *time.Ticker, 41 | timeout time.Duration) error { 42 | // start serving connections 43 | conns := accept(g.log, l) 44 | for { 45 | select { 46 | case conn, ok := <-conns: 47 | if !ok { 48 | return fmt.Errorf("listen socket closed") 49 | } 50 | g.log.Debug("accepted gpg-agent connection") 51 | // reset the exit timer 52 | exit.Reset(timeout) 53 | // if the client takes too long, give up 54 | if err := conn.SetDeadline(time.Now().Add(connTimeout)); err != nil { 55 | return fmt.Errorf("couldn't set deadline: %v", err) 56 | } 57 | // init protocol state machine 58 | a := assuan.New(conn, g.log, g.notify, g.pivKeyService, g.gpgKeyService) 59 | // this goroutine will exit by either: 60 | // * client severs connection (the usual case) 61 | // * conn deadline reached (client stopped responding) 62 | // err will be non-nil in this case. 63 | go func() { 64 | // run the protocol state machine to completion 65 | if err := a.Run(ctx); err != nil { 66 | g.log.Error("gpg-agent error", zap.Error(err)) 67 | } 68 | }() 69 | case <-ctx.Done(): 70 | return nil 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/server/ssh.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net" 9 | "time" 10 | 11 | "github.com/smlx/piv-agent/internal/ssh" 12 | "go.uber.org/zap" 13 | "golang.org/x/crypto/ssh/agent" 14 | ) 15 | 16 | // SSH represents an ssh-agent server. 17 | type SSH struct { 18 | log *zap.Logger 19 | } 20 | 21 | // NewSSH initialises a new ssh-agent server. 22 | func NewSSH(l *zap.Logger) *SSH { 23 | return &SSH{ 24 | log: l, 25 | } 26 | } 27 | 28 | // Serve starts serving signing requests, and returns when the request socket 29 | // is closed, the context is cancelled, or an error occurs. 30 | func (s *SSH) Serve(ctx context.Context, a *ssh.Agent, l net.Listener, 31 | exit *time.Ticker, timeout time.Duration) error { 32 | // start serving connections 33 | conns := accept(s.log, l) 34 | for { 35 | select { 36 | case conn, ok := <-conns: 37 | if !ok { 38 | return fmt.Errorf("listen socket closed") 39 | } 40 | // reset the exit timer 41 | exit.Reset(timeout) 42 | s.log.Debug("start serving SSH connection") 43 | if err := agent.ServeAgent(a, conn); err != nil { 44 | if errors.Is(err, io.EOF) { 45 | s.log.Debug("finish serving SSH connection") 46 | continue 47 | } 48 | return fmt.Errorf("ssh Serve error: %w", err) 49 | } 50 | case <-ctx.Done(): 51 | return nil 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/sockets/get_darwin.go: -------------------------------------------------------------------------------- 1 | package sockets 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | 8 | "github.com/x13a/go-launch" 9 | ) 10 | 11 | // Get returns the sockets passed to the process from launchd socket 12 | // activation. 13 | func Get(names []string) ([]net.Listener, error) { 14 | var listeners []net.Listener 15 | // get the FDs 16 | for _, name := range names { 17 | nameFDs, err := launch.ActivateSocket(name) 18 | if err != nil { 19 | return nil, err 20 | } 21 | for _, fd := range nameFDs { 22 | f := os.NewFile(uintptr(fd), name) 23 | if f == nil { 24 | return nil, fmt.Errorf("couldn't create file from FD") 25 | } 26 | l, err := net.FileListener(f) 27 | if err != nil { 28 | return nil, err 29 | } 30 | listeners = append(listeners, l) 31 | } 32 | } 33 | return listeners, nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/sockets/get_linux.go: -------------------------------------------------------------------------------- 1 | package sockets 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/coreos/go-systemd/activation" 7 | ) 8 | 9 | // Get returns the sockets passed to the process from systemd socket 10 | // activation. 11 | func Get(_ []string) ([]net.Listener, error) { 12 | return activation.Listeners() 13 | } 14 | -------------------------------------------------------------------------------- /internal/ssh/agent.go: -------------------------------------------------------------------------------- 1 | // Package ssh implements an ssh-agent. 2 | package ssh 3 | 4 | import ( 5 | "bytes" 6 | "context" 7 | "crypto/rand" 8 | "errors" 9 | "fmt" 10 | "os" 11 | "path/filepath" 12 | "sync" 13 | 14 | "github.com/smlx/piv-agent/internal/keyservice/piv" 15 | "github.com/smlx/piv-agent/internal/notify" 16 | pinentry "github.com/smlx/piv-agent/internal/pinentry" 17 | "go.uber.org/zap" 18 | gossh "golang.org/x/crypto/ssh" 19 | "golang.org/x/crypto/ssh/agent" 20 | ) 21 | 22 | // retries is the passphrase attempt limit when decrypting SSH keyfiles 23 | const retries = 3 24 | 25 | // Agent implements the crypto/ssh Agent interface 26 | // https://pkg.go.dev/golang.org/x/crypto/ssh/agent#Agent 27 | type Agent struct { 28 | mu sync.Mutex 29 | piv *piv.KeyService 30 | log *zap.Logger 31 | notify *notify.Notify 32 | pinentry *pinentry.PINEntry 33 | loadKeyfile bool 34 | cancel context.CancelFunc 35 | } 36 | 37 | // ErrNotImplemented is returned from any unimplemented method. 38 | var ErrNotImplemented = errors.New("not implemented in piv-agent") 39 | 40 | // ErrUnknownKey is returned when a signature is requested for an unknown key. 41 | var ErrUnknownKey = errors.New("requested signature of unknown key") 42 | 43 | // passphrases caches passphrases for keyfiles 44 | var passphrases = map[string][]byte{} 45 | 46 | // NewAgent returns a new Agent. 47 | func NewAgent(p *piv.KeyService, pe *pinentry.PINEntry, log *zap.Logger, 48 | loadKeyfile bool, n *notify.Notify, cancel context.CancelFunc) *Agent { 49 | return &Agent{piv: p, pinentry: pe, log: log, notify: n, 50 | loadKeyfile: loadKeyfile, cancel: cancel} 51 | } 52 | 53 | // List returns the identities known to the agent. 54 | func (a *Agent) List() ([]*agent.Key, error) { 55 | a.mu.Lock() 56 | defer a.mu.Unlock() 57 | // get security key identities first 58 | ski, err := a.securityKeyIDs() 59 | if err != nil { 60 | return nil, fmt.Errorf("couldn't get token identities: %w", err) 61 | } 62 | // then key file identities 63 | if !a.loadKeyfile { 64 | return ski, err 65 | } 66 | kfi, err := a.keyFileIDs() 67 | if err != nil { 68 | return nil, fmt.Errorf("couldn't get keyfile identities: %w", err) 69 | } 70 | return append(ski, kfi...), nil 71 | } 72 | 73 | // returns the identities from hardware tokens 74 | func (a *Agent) securityKeyIDs() ([]*agent.Key, error) { 75 | var keys []*agent.Key 76 | securityKeys, err := a.piv.SecurityKeys() 77 | if err != nil { 78 | return nil, fmt.Errorf("couldn't get security keys: %v", err) 79 | } 80 | for _, k := range securityKeys { 81 | for _, s := range k.SigningKeys() { 82 | keys = append(keys, &agent.Key{ 83 | Format: s.PubSSH.Type(), 84 | Blob: s.PubSSH.Marshal(), 85 | Comment: k.Comment(&s.SlotSpec), 86 | }) 87 | } 88 | } 89 | return keys, nil 90 | } 91 | 92 | // returns the identities from keyfiles on disk 93 | func (a *Agent) keyFileIDs() ([]*agent.Key, error) { 94 | var keys []*agent.Key 95 | home, err := os.UserHomeDir() 96 | if err != nil { 97 | return nil, err 98 | } 99 | keyPath := filepath.Join(home, ".ssh/id_ed25519.pub") 100 | pubBytes, err := os.ReadFile(keyPath) 101 | if err != nil { 102 | a.log.Debug("couldn't load keyfile", zap.String("path", keyPath), 103 | zap.Error(err)) 104 | return keys, nil 105 | } 106 | pubKey, _, _, _, err := gossh.ParseAuthorizedKey(pubBytes) 107 | if err != nil { 108 | a.log.Debug("couldn't parse keyfile", zap.String("path", keyPath), 109 | zap.Error(err)) 110 | return keys, nil 111 | } 112 | keys = append(keys, &agent.Key{ 113 | Format: pubKey.Type(), 114 | Blob: pubKey.Marshal(), 115 | Comment: keyPath, 116 | }) 117 | return keys, nil 118 | } 119 | 120 | // Sign has the agent sign the data using a protocol 2 key as defined 121 | // in [PROTOCOL.agent] section 2.6.2. 122 | func (a *Agent) Sign(key gossh.PublicKey, data []byte) (*gossh.Signature, error) { 123 | a.mu.Lock() 124 | defer a.mu.Unlock() 125 | // try token keys first 126 | ts, err := a.tokenSigners() 127 | if err != nil { 128 | return nil, fmt.Errorf("couldn't get token signers: %w", err) 129 | } 130 | sig, err := a.signWithSigners(key, data, ts) 131 | if err != nil { 132 | if !errors.Is(err, ErrUnknownKey) || !a.loadKeyfile { 133 | return nil, err 134 | } 135 | } else { 136 | return sig, nil 137 | } 138 | // fall back to keyfile keys 139 | ks, err := a.keyfileSigners() 140 | if err != nil { 141 | return nil, fmt.Errorf("couldn't get keyfile signers: %w", err) 142 | } 143 | return a.signWithSigners(key, data, ks) 144 | } 145 | 146 | func (a *Agent) signWithSigners(key gossh.PublicKey, data []byte, 147 | signers []gossh.Signer) (*gossh.Signature, error) { 148 | for _, s := range signers { 149 | if !bytes.Equal(s.PublicKey().Marshal(), key.Marshal()) { 150 | continue 151 | } 152 | cancel := a.notify.Touch() 153 | defer cancel() 154 | // perform signature 155 | a.log.Debug("signing", 156 | zap.Binary("public key bytes", s.PublicKey().Marshal())) 157 | return s.Sign(rand.Reader, data) 158 | } 159 | return nil, fmt.Errorf("%w: %v", ErrUnknownKey, key) 160 | } 161 | 162 | // Add adds a private key to the agent. 163 | func (a *Agent) Add(key agent.AddedKey) error { 164 | return ErrNotImplemented 165 | } 166 | 167 | // Remove removes all identities with the given public key. 168 | func (a *Agent) Remove(key gossh.PublicKey) error { 169 | return ErrNotImplemented 170 | } 171 | 172 | // RemoveAll removes all identities. 173 | // This is implemented by causing piv-agent to exit. 174 | func (a *Agent) RemoveAll() error { 175 | a.cancel() 176 | return nil 177 | } 178 | 179 | // Lock locks the agent. Sign and Remove will fail, and List will empty an 180 | // empty list. 181 | func (a *Agent) Lock(passphrase []byte) error { 182 | return ErrNotImplemented 183 | } 184 | 185 | // Unlock undoes the effect of Lock 186 | func (a *Agent) Unlock(passphrase []byte) error { 187 | return ErrNotImplemented 188 | } 189 | 190 | // Signers returns signers for all the known keys. 191 | func (a *Agent) Signers() ([]gossh.Signer, error) { 192 | a.mu.Lock() 193 | defer a.mu.Unlock() 194 | ts, err := a.tokenSigners() 195 | if err != nil { 196 | return nil, fmt.Errorf("couldn't get token signers: %w", err) 197 | } 198 | if !a.loadKeyfile { 199 | return ts, nil 200 | } 201 | ks, err := a.keyfileSigners() 202 | if err != nil { 203 | return nil, fmt.Errorf("couldn't get keyfile signers: %w", err) 204 | } 205 | return append(ts, ks...), nil 206 | } 207 | 208 | // get signers for all keys stored in hardware tokens 209 | func (a *Agent) tokenSigners() ([]gossh.Signer, error) { 210 | var signers []gossh.Signer 211 | securityKeys, err := a.piv.SecurityKeys() 212 | if err != nil { 213 | return nil, fmt.Errorf("couldn't get security keys: %v", err) 214 | } 215 | for _, k := range securityKeys { 216 | for _, s := range k.SigningKeys() { 217 | privKey, err := k.PrivateKey(&s.CryptoKey) 218 | if err != nil { 219 | return nil, fmt.Errorf("couldn't get private key for slot %x: %v", 220 | s.SlotSpec.Slot.Key, err) 221 | } 222 | s, err := gossh.NewSignerFromKey(privKey) 223 | if err != nil { 224 | return nil, fmt.Errorf("couldn't get signer for key: %v", err) 225 | } 226 | a.log.Debug("loaded signing key from security key", 227 | zap.Binary("public key bytes", s.PublicKey().Marshal())) 228 | signers = append(signers, s) 229 | } 230 | } 231 | return signers, nil 232 | } 233 | 234 | // doDecrypt prompts for a passphrase via pinentry and uses the passphrase to 235 | // decrypt the given private key 236 | func (a *Agent) doDecrypt(keyPath string, 237 | pub gossh.PublicKey, priv []byte) (gossh.Signer, error) { 238 | var passphrase []byte 239 | var signer gossh.Signer 240 | var err error 241 | for i := 0; i < retries; i++ { 242 | passphrase = passphrases[string(pub.Marshal())] 243 | if passphrase == nil { 244 | fingerprint := gossh.FingerprintSHA256(pub) 245 | passphrase, err = a.pinentry.GetPassphrase( 246 | fmt.Sprintf("%s %s %s", keyPath, fingerprint[:25], fingerprint[25:]), 247 | fingerprint, retries-i) 248 | if err != nil { 249 | return nil, err 250 | } 251 | } 252 | signer, err = gossh.ParsePrivateKeyWithPassphrase(priv, passphrase) 253 | if err == nil { 254 | a.log.Debug("loaded key from disk", 255 | zap.Binary("public key bytes", signer.PublicKey().Marshal())) 256 | passphrases[string(signer.PublicKey().Marshal())] = passphrase 257 | return signer, nil 258 | } 259 | } 260 | return nil, fmt.Errorf("couldn't decrypt and parse private key %v", err) 261 | } 262 | 263 | // get signers for all keys stored in files on disk 264 | func (a *Agent) keyfileSigners() ([]gossh.Signer, error) { 265 | var signers []gossh.Signer 266 | home, err := os.UserHomeDir() 267 | if err != nil { 268 | return nil, err 269 | } 270 | keyPath := filepath.Join(home, ".ssh/id_ed25519") 271 | priv, err := os.ReadFile(keyPath) 272 | if err != nil { 273 | a.log.Debug("couldn't load keyfile", zap.String("path", keyPath), 274 | zap.Error(err)) 275 | return signers, nil 276 | } 277 | signer, err := gossh.ParsePrivateKey(priv) 278 | if err != nil { 279 | pmErr, ok := err.(*gossh.PassphraseMissingError) 280 | if !ok { 281 | return nil, err 282 | } 283 | signer, err = a.doDecrypt(keyPath, pmErr.PublicKey, priv) 284 | if err != nil { 285 | return nil, err 286 | } 287 | } 288 | signers = append(signers, signer) 289 | return signers, nil 290 | } 291 | --------------------------------------------------------------------------------