├── .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 | [](https://github.com/smlx/piv-agent/actions/workflows/release.yaml)
4 | [](https://github.com/smlx/piv-agent/actions/workflows/coverage.yaml)
5 | [](https://goreportcard.com/report/github.com/smlx/piv-agent)
6 | [](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 |
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 |
--------------------------------------------------------------------------------