├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github ├── dependabot.yml └── workflows │ └── main.yaml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── helpers.go ├── pgp.go ├── piv.go ├── root.go └── version.go ├── go.mod ├── go.sum ├── main.go └── pkg ├── pgp ├── attestation.go ├── attestation_test.go ├── verify.go └── verify_test.go ├── piv ├── attestation.go ├── attestation_test.go ├── verify.go └── verify_test.go └── pubkeys ├── compare.go ├── compare_pre_go115.go └── compare_test.go /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. 4 | #------------------------------------------------------------------------------------------------------------- 5 | 6 | FROM golang:1.15beta1 7 | 8 | # This Dockerfile adds a non-root user with sudo access. Use the "remoteUser" 9 | # property in devcontainer.json to use it. On Linux, the container user's GID/UIDs 10 | # will be updated to match your local UID/GID (when using the dockerFile property). 11 | # See https://aka.ms/vscode-remote/containers/non-root-user for details. 12 | ARG USERNAME=vscode 13 | ARG USER_UID=1000 14 | ARG USER_GID=$USER_UID 15 | 16 | # Configure apt, install packages and tools 17 | RUN apt-get update \ 18 | && export DEBIAN_FRONTEND=noninteractive \ 19 | && apt-get -y install --no-install-recommends apt-utils dialog 2>&1 \ 20 | # 21 | # Verify git, process tools, lsb-release (common in install instructions for CLIs) installed 22 | && apt-get -y install git openssh-client less iproute2 procps lsb-release \ 23 | # 24 | # Build Go tools w/module support 25 | && mkdir -p /tmp/gotools \ 26 | && cd /tmp/gotools \ 27 | && GOPATH=/tmp/gotools GO111MODULE=on go get -v golang.org/x/tools/gopls@latest 2>&1 \ 28 | && GOPATH=/tmp/gotools GO111MODULE=on go get -v \ 29 | honnef.co/go/tools/...@latest \ 30 | golang.org/x/tools/cmd/gorename@latest \ 31 | golang.org/x/tools/cmd/goimports@latest \ 32 | golang.org/x/tools/cmd/guru@latest \ 33 | golang.org/x/lint/golint@latest \ 34 | github.com/mdempsky/gocode@latest \ 35 | github.com/cweill/gotests/...@latest \ 36 | github.com/haya14busa/goplay/cmd/goplay@latest \ 37 | github.com/sqs/goreturns@latest \ 38 | github.com/josharian/impl@latest \ 39 | github.com/davidrjenni/reftools/cmd/fillstruct@latest \ 40 | github.com/uudashr/gopkgs/v2/cmd/gopkgs@latest \ 41 | github.com/ramya-rao-a/go-outline@latest \ 42 | github.com/acroca/go-symbols@latest \ 43 | github.com/godoctor/godoctor@latest \ 44 | github.com/rogpeppe/godef@latest \ 45 | github.com/zmb3/gogetdoc@latest \ 46 | github.com/fatih/gomodifytags@latest \ 47 | github.com/mgechev/revive@latest \ 48 | github.com/go-delve/delve/cmd/dlv@latest 2>&1 \ 49 | # 50 | # Build Go tools w/o module support 51 | && GOPATH=/tmp/gotools go get -v github.com/alecthomas/gometalinter 2>&1 \ 52 | # 53 | # Build gocode-gomod 54 | && GOPATH=/tmp/gotools go get -x -d github.com/stamblerre/gocode 2>&1 \ 55 | && GOPATH=/tmp/gotools go build -o gocode-gomod github.com/stamblerre/gocode \ 56 | # 57 | # Install Go tools 58 | && mv /tmp/gotools/bin/* /usr/local/bin/ \ 59 | && mv gocode-gomod /usr/local/bin/ \ 60 | # 61 | # Install golangci-lint 62 | && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin 2>&1 \ 63 | # 64 | # Create a non-root user to use if preferred - see https://aka.ms/vscode-remote/containers/non-root-user. 65 | && groupadd --gid $USER_GID $USERNAME \ 66 | && useradd -s /bin/bash --uid $USER_UID --gid $USER_GID -m $USERNAME \ 67 | # [Optional] Add sudo support 68 | && apt-get install -y sudo \ 69 | && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ 70 | && chmod 0440 /etc/sudoers.d/$USERNAME \ 71 | # 72 | # Clean up 73 | && apt-get autoremove -y \ 74 | && apt-get clean -y \ 75 | && rm -rf /var/lib/apt/lists/* /tmp/gotools 76 | 77 | # Update this to "on" or "off" as appropriate 78 | ENV GO111MODULE=auto 79 | 80 | 81 | # go-piv deps: 82 | RUN sudo apt-get update && apt-get -qy install libpcsclite-dev 83 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.122.1/containers/go 3 | { 4 | "name": "Go", 5 | "dockerFile": "Dockerfile", 6 | "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], 7 | 8 | // Set *default* container specific settings.json values on container create. 9 | "settings": { 10 | "terminal.integrated.shell.linux": "/bin/bash", 11 | "go.gopath": "/go" 12 | }, 13 | 14 | // Add the IDs of extensions you want installed when the container is created. 15 | "extensions": [ 16 | "golang.Go" 17 | ] 18 | 19 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 20 | // "forwardPorts": [], 21 | 22 | // Use 'postCreateCommand' to run commands after the container is created. 23 | // "postCreateCommand": "go version", 24 | 25 | // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root. 26 | // "remoteUser": "vscode" 27 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: main 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | strategy: 7 | matrix: 8 | os: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.os }} 10 | if: github.event_name == 'push' && !contains(toJson(github.event.commits), '[ci skip]') && !contains(toJson(github.event.commits), '[skip ci]') 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-go@v5 14 | with: 15 | go-version-file: go.mod 16 | - name: install golangci-lint 17 | run: | 18 | mkdir -p "$HOME/bin" 19 | curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "$HOME/bin" v1.54.2 20 | echo "$HOME/bin" >> $GITHUB_PATH 21 | shell: bash # force windows to use git-bash for access to curl 22 | 23 | - name: Install GoReleaser 24 | # only need to lint goreleaser on one platform: 25 | if: startsWith(runner.os, 'Linux') 26 | uses: goreleaser/goreleaser-action@v6 27 | with: 28 | install-only: true 29 | 30 | - run: make lint 31 | shell: bash 32 | - run: make test 33 | shell: bash 34 | 35 | release-test: 36 | needs: [test] 37 | # don't waste time running a goreleaser test build on main since we will run a full release: 38 | if: github.ref != 'refs/heads/main' 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v4 42 | - uses: actions/setup-go@v5 43 | with: 44 | go-version-file: go.mod 45 | - name: Install GoReleaser 46 | uses: goreleaser/goreleaser-action@v6 47 | with: 48 | install-only: true 49 | 50 | - run: make snapshot 51 | 52 | release: 53 | needs: [test] 54 | # only create a release on main builds: 55 | if: github.ref == 'refs/heads/main' 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v4 59 | - uses: actions/setup-go@v5 60 | with: 61 | go-version-file: go.mod 62 | - name: docker.io login 63 | run: docker login docker.io -u joemiller -p ${{ secrets.DOCKERIO_TOKEN }} 64 | 65 | - name: Unshallow 66 | run: | 67 | # fetch all tags and history so that goreleaser can generate a proper changelog 68 | # and autotag can calculate the next version tag: 69 | git fetch --tags --unshallow --prune 70 | 71 | if [ $(git rev-parse --abbrev-ref HEAD) != "main" ]; then 72 | # ensure a local 'main' branch exists for autotag to work correctly: 73 | git branch --track main origin/main 74 | fi 75 | 76 | - name: Install GoReleaser 77 | uses: goreleaser/goreleaser-action@v6 78 | with: 79 | install-only: true 80 | 81 | - name: run autotag to increment version 82 | run: | 83 | curl -sL https://git.io/autotag-install | sudo sh -s -- -b /usr/local/bin 84 | autotag 85 | 86 | - name: build and push release artifacts 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.BREW_GITHUB_TOKEN }} 89 | run: | 90 | make deps 91 | make release 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .Attic 2 | dist/ 3 | yk-attest-verify 4 | HACK.md 5 | localtestdata/ 6 | cover.out 7 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: yk-attest-verify 2 | 3 | builds: 4 | - binary: yk-attest-verify 5 | env: 6 | - CGO_ENABLED=0 7 | ldflags: 8 | # Default is `-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}`. 9 | -s -w -X github.com/joemiller/yk-attest-verify/cmd.version={{.Version}}+{{.ShortCommit}} 10 | goos: 11 | - linux 12 | - darwin 13 | # - windows 14 | - freebsd 15 | - openbsd 16 | # - dragonfly 17 | # - netbsd 18 | goarch: 19 | - 386 20 | - amd64 21 | - arm64 22 | goarm: 23 | - "" 24 | ignore: 25 | - goos: darwin 26 | goarch: 386 27 | - goos: windows 28 | goarch: 386 29 | 30 | archives: 31 | # binary-only releases - all platforms 32 | - id: binaries 33 | format: binary 34 | name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" 35 | # archive releases containing: binary, readme, and license. tarballs (macos, linux), zip (windows) 36 | - id: archives 37 | name_template: >- 38 | {{ .ProjectName }}_ 39 | {{- title .Os }}_ 40 | {{- if eq .Arch "amd64" }}amd64 41 | {{- else if eq .Arch "386" }}i386 42 | {{- else }}{{ .Arch }}{{ end }} 43 | format_overrides: 44 | - goos: windows 45 | format: zip 46 | 47 | checksum: 48 | name_template: "checksums.txt" 49 | 50 | snapshot: 51 | version_template: "{{ .Tag }}-next" 52 | 53 | changelog: 54 | sort: asc 55 | filters: 56 | exclude: 57 | - "^docs:" 58 | - "^test:" 59 | - "skip ci" 60 | - "ci skip" 61 | - Merge pull request 62 | - Merge branch 63 | 64 | brews: 65 | - ids: 66 | - archives 67 | repository: 68 | owner: joemiller 69 | name: homebrew-taps 70 | commit_author: 71 | name: joe miller 72 | email: yk-attest-verify@joemiller.me 73 | directory: Formula 74 | homepage: "https://github.com/joemiller/yk-attest-verify" 75 | description: "Validate and enforce policy on YubiKey PIV and OpenPGP attestation certificates" 76 | 77 | dockers: 78 | # primary docker image for amd64 arch 79 | - dockerfile: Dockerfile 80 | ids: 81 | - yk-attest-verify 82 | goos: linux 83 | goarch: amd64 84 | image_templates: 85 | - "joemiller/yk-attest-verify:{{ .Tag }}" # v1.0.0 86 | - "joemiller/yk-attest-verify:v{{ .Major }}" # v1 87 | - "joemiller/yk-attest-verify:v{{ .Major }}.{{ .Minor }}" # v1.0 88 | - "joemiller/yk-attest-verify:latest" 89 | # build a docker image for arm64 arch 90 | - dockerfile: Dockerfile 91 | ids: 92 | - yk-attest-verify 93 | goos: linux 94 | goarch: arm64 95 | goarm: "" 96 | image_templates: 97 | - "joemiller/yk-attest-verify:{{ .Tag }}-arm64" # v1.0.0 98 | - "joemiller/yk-attest-verify:v{{ .Major }}-arm64" # v1 99 | - "joemiller/yk-attest-verify:v{{ .Major }}.{{ .Minor }}-arm64" # v1.0 100 | - "joemiller/yk-attest-verify:latest-arm64" 101 | 102 | # ## generate RPM and DEB packages 103 | nfpms: 104 | - id: yk-attest-verify 105 | vendor: "Joe Miller" 106 | homepage: "https://github.com/joemiller/yk-attest-verify" 107 | maintainer: "yk-attest-verify@joemiller.me" 108 | description: "Validate and enforce policy on YubiKey PIV and OpenPGP attestation certificates" 109 | license: MIT 110 | formats: 111 | - deb 112 | - rpm 113 | overrides: 114 | rpm: 115 | file_name_template: >- 116 | {{ .ProjectName }}- 117 | {{- .Version }}- 118 | {{- if eq .Arch "amd64" }}x86_64 119 | {{- else if eq .Arch "386" }}i386 120 | {{- else if eq .Arch "arm" }}armhfp 121 | {{- else if eq .Arch "arm64" }}aarch64 122 | {{- else }}{{ .Arch }}{{ end }} 123 | deb: 124 | file_name_template: >- 125 | {{ .ProjectName }}_ 126 | {{- .Version }}_ 127 | {{- .Os }}_ 128 | {{- if eq .Arch "386" }}i386 129 | {{- else if eq .Arch "386" }}i386 130 | {{- else if eq .Arch "arm" }}armel 131 | {{- else }}{{ .Arch }}{{ end }} 132 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | 3 | COPY yk-attest-verify /yk-attest-verify 4 | 5 | ENTRYPOINT ["/yk-attest-verify"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | deps: 2 | @go get 3 | 4 | lint: 5 | @golangci-lint run -v --timeout=3m 6 | @if command -v goreleaser >/dev/null; then \ 7 | goreleaser check; \ 8 | else \ 9 | echo "goreleaser not installed, skipping goreleaser linting"; \ 10 | fi 11 | 12 | test: 13 | @go test -coverprofile=cover.out -v ./... 14 | 15 | cov: 16 | @go tool cover -html=cover.out 17 | 18 | build-linux: 19 | @GOOS=linux GOOARCH=amd64 go build . 20 | 21 | build: 22 | @go build . 23 | 24 | release: 25 | @goreleaser $(GORELEASER_ARGS) 26 | 27 | snapshot: GORELEASER_ARGS= --clean --snapshot 28 | snapshot: release 29 | 30 | todo: 31 | @grep \ 32 | --exclude-dir=vendor \ 33 | --exclude-dir=dist \ 34 | --exclude-dir=Attic \ 35 | --text \ 36 | --color \ 37 | -nRo -E 'TODO.*' . 38 | 39 | .PHONY: build build-linux test snapshot todo 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | yk-attest-verify 2 | ================ 3 | 4 | ![main](https://github.com/joemiller/yk-attest-verify/workflows/main/badge.svg) 5 | [![Go Doc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://pkg.go.dev/github.com/joemiller/yk-attest-verify?tab=subdirectories) 6 | 7 | Validate and enforce policy on YubiKey PIV and OpenPGP attestation certificates. 8 | 9 | One use case of this utility is to enforce SSH keys are generated and stored solely on a YubiKey. 10 | 11 | Install 12 | ------- 13 | 14 | * macOS homebrew (Linuxbrew might work too): `brew install joemiller/taps/yk-attest-verify` 15 | * Binaries for all platforms (macOS, Linux, *BSD) on [GitHub Releases](https://github.com/joemiller/yk-attest-verify/releases) 16 | * [Docker images](https://hub.docker.com/r/joemiller/yk-attest-verify) are also available. 17 | 18 | Generating attestation certs 19 | ---------------------------- 20 | 21 | The process for generating and verifying attestation certs is similar for both PIV and 22 | OpenPGP. You need to (1) generate an *attestation* certificate and (2) export the *signing* certificate 23 | from the YubiKey. Both files are required inputs to `yk-attest-verify`. 24 | 25 | The signing certificate is similar to an intermediate CA cert. It is unique to each YubiKey 26 | and is signed by YubiKey's root certificate. 27 | 28 | The attestation certificate and the signing certificate are input into `yk-attest-verify` 29 | and the signing chain back to the YubiKey root certificate is checked. 30 | 31 | Attestation certificates cover a single key slot on the card. If you want to attest multiple 32 | key slots you will generate an attestation for each of them. 33 | 34 | For SSH keys the 'authentication' key slot is typically used. This is slot `AUT` for OpenPGP 35 | and slot `9a` for PIV. 36 | 37 | ### PGP 38 | 39 | PGP attestation is available on YubiKey 5.2+ 40 | 41 | https://developers.yubico.com/PGP/Attestation.html 42 | 43 | After you've generated your PGP keys using a tool such as GPG or `ykman`: 44 | 45 | * generate an attestation certificate covering the key in the `AUT` (authenticate) slot: 46 | 47 | ykman openpgp attest AUT attestation.pem 48 | 49 | * export the signer certificate: 50 | 51 | ykman openpgp export-certificate ATT signer.pem 52 | 53 | ### PIV 54 | 55 | PIV attestation is available on YubiKey 4.3+ 56 | 57 | https://developers.yubico.com/PIV/Introduction/PIV_attestation.html 58 | 59 | After you've generated your PIV keys using a tool such as `yubico-piv-tool` or 60 | [yubikey-agent](https://github.com/FiloSottile/yubikey-agent): 61 | 62 | * generate an attestation cert covering the key in the 9a slot: 63 | 64 | yubico-piv-tool --action=attest --slot=9a >attestation.pem 65 | 66 | * export the signer certificate: 67 | 68 | yubico-piv-tool --action=read-certificate --slot=f9 >signer.pem 69 | 70 | Verification 71 | ------------ 72 | 73 | ### PGP 74 | 75 | * Help: 76 | 77 | yk-attest-verify pgp -h 78 | 79 | * Verify the signature chain of `attestation.pem`: 80 | 81 | yk-attest-verify pgp attestation.pem signer.pem 82 | 83 | * Verify the signature chain and compare the public keys from an SSH pub key file 84 | to the public key on the YubiKey: 85 | 86 | yk-attest-verify pgp attestation.pem signer.pem --ssh-pub-key="id_rsa.pub" 87 | 88 | * policy check: verify the attested key was generated on the YubiKey: 89 | 90 | yk-attest-verify pgp attestation.pem signer.pem --allowed-keysources="generated" 91 | 92 | * policy check: verify the attested key has an allowed Touch Policy set: 93 | 94 | yk-attest-verify pgp attestation.pem signer.pem --allowed-touch-policies="enabled,cached" 95 | 96 | Multiple `--allowed-*` flags can be used together to express a complete policy. 97 | 98 | ### PIV 99 | 100 | * Help: 101 | 102 | yk-attest-verify piv -h 103 | 104 | * Verify the signature chain of `attestation.pem`: 105 | 106 | yk-attest-verify piv attestation.pem signer.pem 107 | 108 | * Verify the signature chain and compare the public keys from an SSH pub key file 109 | to the public key on the YubiKey: 110 | 111 | yk-attest-verify piv attestation.pem signer.pem --ssh-pub-key="id_rsa.pub" 112 | 113 | * policy check: verify the attested key has an allowed Touch Policy set: 114 | 115 | yk-attest-verify piv attestation.pem signer.pem --allowed-touch-policies="always,cached" 116 | 117 | * policy check: verify the attested key has an allowed PIN Policy set: 118 | 119 | yk-attest-verify piv attestation.pem signer.pem --allowed-touch-policies="once,always" 120 | 121 | Multiple `--allowed-*` flags can be used together to express a complete policy. 122 | -------------------------------------------------------------------------------- /cmd/helpers.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/json" 6 | "encoding/pem" 7 | "fmt" 8 | "os" 9 | "strings" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var indentation = ` ` 15 | 16 | func loadX509CertFile(certFile string) (*x509.Certificate, error) { 17 | certPEMBlock, err := os.ReadFile(certFile) 18 | if err != nil { 19 | return nil, err 20 | } 21 | block, _ := pem.Decode(certPEMBlock) 22 | return x509.ParseCertificate(block.Bytes) 23 | } 24 | 25 | func indentor(s string) string { 26 | indentedLines := []string{} 27 | for _, line := range strings.Split(s, "\n") { 28 | // line = strings.TrimSpace(line) 29 | line = indentation + line 30 | indentedLines = append(indentedLines, line) 31 | } 32 | return strings.Join(indentedLines, "\n") 33 | } 34 | 35 | type result struct { 36 | Cmd *cobra.Command 37 | JSON bool 38 | Data struct { 39 | Attestation interface{} 40 | Error *resultError 41 | } 42 | } 43 | 44 | type resultError struct { 45 | Messages []string 46 | } 47 | 48 | func (e *resultError) Add(msg string) { 49 | e.Messages = append(e.Messages, msg) 50 | } 51 | 52 | func (e *resultError) MarshalJSON() ([]byte, error) { 53 | if e.Messages == nil { 54 | // Make sure JSON ends up as an empty array, not null. 55 | return json.Marshal(make([]string, 0)) 56 | } 57 | return json.Marshal(e.Messages) 58 | } 59 | 60 | // Wrap cobra.Command.Printf(), only running it if not in JSON mode. 61 | func (o *result) Printf(s string, a ...interface{}) { 62 | if !o.JSON { 63 | o.Cmd.Printf(s, a...) 64 | } 65 | } 66 | 67 | func (o *result) PrintE(s string) { 68 | if o.JSON { 69 | o.Data.Error.Add(s) 70 | } else { 71 | o.Cmd.Printf("✖ %s\n", s) 72 | } 73 | } 74 | 75 | func (o *result) PrintResultJSON(attestation interface{}) error { 76 | o.Data.Attestation = attestation 77 | data, err := json.MarshalIndent(o.Data, "", " ") 78 | if err != nil { 79 | return err 80 | } 81 | fmt.Fprintf(o.Cmd.OutOrStdout(), "%s\n", data) 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /cmd/pgp.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/joemiller/yk-attest-verify/pkg/pgp" 10 | "github.com/joemiller/yk-attest-verify/pkg/pubkeys" 11 | "github.com/spf13/cobra" 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | // pgpCmd represents the PGP attestion verification sub command 16 | var pgpCmd = &cobra.Command{ 17 | Use: "pgp ATTESTATION SIGNER", 18 | Short: "Verify the signature and contents of a YuibKey OpenPGP Attestation certificate.", 19 | Long: "Verify the signature and contents of a YuibKey OpenPGP Attestation certificate.", 20 | Example: indentor(` 21 | # verify the signature chain of an attestation certificate: 22 | yk-attest-verify pgp attestation.pem signer.pem 23 | 24 | # also verify the public key in an ssh public key file matches the public key in the attestation: 25 | yk-attest-verify pgp attestation.pem signer.pem --ssh-pub-key="id_rsa.pub" 26 | 27 | # policy: verify the attested key was generated on the YubiKey: 28 | yk-attest-verify pgp attestation.pem signer.pem --allowed-keysources="generated" 29 | 30 | # policy: verify the attested key has an allowed Touch Policy set: 31 | yk-attest-verify pgp attestation.pem signer.pem --allowed-touch-policies="enabled,cached" 32 | `), 33 | Args: cobra.ExactArgs(2), 34 | SilenceUsage: true, 35 | RunE: pgpVerify, 36 | } 37 | 38 | func init() { 39 | // policy flags (--allowed-*) 40 | pgpCmd.Flags().StringSlice( 41 | "allowed-slots", 42 | []string{}, 43 | "Comma-separated list of allowed key Slots. If not set all slots are accepted. (SIG,ENC,AUT)", 44 | ) 45 | 46 | pgpCmd.Flags().StringSlice( 47 | "allowed-keysources", 48 | []string{}, 49 | "Comma-separated list of allowed key sources. If not set any source is accepted. (generated,imported)", 50 | ) 51 | 52 | pgpCmd.Flags().StringSlice( 53 | "allowed-touch-policies", 54 | []string{}, 55 | "Comma-separated list of allowed touch policies. If not set all policies are accepted. (disabled,enabled,enabled-permanent,enabled-cached,enabled-permanent-cached)", 56 | ) 57 | 58 | pgpCmd.Flags().StringSlice( 59 | "allowed-cardholders", 60 | []string{}, 61 | "Comma-separated list of accepted card holder names. If not set all policies are accepted.", 62 | ) 63 | 64 | pgpCmd.Flags().Bool( 65 | "json", 66 | false, 67 | "Use JSON output format", 68 | ) 69 | 70 | pgpCmd.Flags().String( 71 | "ssh-pub-key", 72 | "", 73 | "Verify an ssh public key file contains the same public key as the attestation certificate", 74 | ) 75 | 76 | rootCmd.AddCommand(pgpCmd) 77 | } 78 | 79 | func pgpVerify(cmd *cobra.Command, args []string) error { 80 | // these are guaranteed to exist by the ExactArgs(2) in the pgpCmd struct 81 | attestCertFile := args[0] 82 | attestSignerFile := args[1] 83 | 84 | attestCert, err := loadX509CertFile(attestCertFile) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | attestSigner, err := loadX509CertFile(attestSignerFile) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | sshPubKeyFile, err := cmd.Flags().GetString("ssh-pub-key") 95 | if err != nil { 96 | return err 97 | } 98 | 99 | var sshPubKey ssh.PublicKey 100 | if sshPubKeyFile != "" { 101 | pubkeyraw, err := os.ReadFile(sshPubKeyFile) 102 | if err != nil { 103 | return fmt.Errorf("Error reading SSH pub key %s: %w", sshPubKeyFile, err) 104 | } 105 | sshPubKey, _, _, _, err = ssh.ParseAuthorizedKey(pubkeyraw) 106 | if err != nil { 107 | return fmt.Errorf("Error parsing SSH pub key %s: %w", sshPubKeyFile, err) 108 | } 109 | } 110 | 111 | verifyReq := pgp.VerificationRequest{ 112 | AttestCert: attestCert, 113 | AttestSignerCert: attestSigner, 114 | } 115 | if val, err := cmd.Flags().GetStringSlice("allowed-slots"); err == nil { 116 | for _, i := range val { 117 | i = strings.ToUpper(i) 118 | switch i { 119 | case "AUT", "SIG", "ENC": 120 | verifyReq.Policy.AllowedSlots = append(verifyReq.Policy.AllowedSlots, pgp.Slot(i)) 121 | case "DEC": 122 | verifyReq.Policy.AllowedSlots = append(verifyReq.Policy.AllowedSlots, pgp.Slot("ENC")) 123 | default: 124 | return fmt.Errorf("--allowed-slots unknown slot name '%v'", i) 125 | } 126 | } 127 | } 128 | 129 | if val, err := cmd.Flags().GetStringSlice("allowed-keysources"); err == nil { 130 | for _, i := range val { 131 | i = strings.ToLower(i) 132 | switch i { 133 | case "imported": 134 | verifyReq.Policy.AllowedKeySources = append(verifyReq.Policy.AllowedKeySources, pgp.KeysourceImported) 135 | case "generated": 136 | verifyReq.Policy.AllowedKeySources = append(verifyReq.Policy.AllowedKeySources, pgp.KeysourceGenerated) 137 | default: 138 | return fmt.Errorf("--allowed-keysource unknown keysource '%v'", i) 139 | } 140 | } 141 | } 142 | 143 | if val, err := cmd.Flags().GetStringSlice("allowed-touch-policies"); err == nil { 144 | for _, i := range val { 145 | i = strings.ToLower(i) 146 | switch i { 147 | case "disabled": 148 | verifyReq.Policy.AllowedTouchPolicies = append(verifyReq.Policy.AllowedTouchPolicies, pgp.TouchPolicyDisabled) 149 | case "enabled": 150 | verifyReq.Policy.AllowedTouchPolicies = append(verifyReq.Policy.AllowedTouchPolicies, pgp.TouchPolicyEnabled) 151 | case "enabled-permanent": 152 | verifyReq.Policy.AllowedTouchPolicies = append(verifyReq.Policy.AllowedTouchPolicies, pgp.TouchPolicyPermanent) 153 | case "enabled-cached": 154 | verifyReq.Policy.AllowedTouchPolicies = append(verifyReq.Policy.AllowedTouchPolicies, pgp.TouchPolicyCached) 155 | case "enabled-permanent-cached": 156 | verifyReq.Policy.AllowedTouchPolicies = append(verifyReq.Policy.AllowedTouchPolicies, pgp.TouchPolicyPermanentCached) 157 | default: 158 | return fmt.Errorf("--allowed-touch-policies unknown policy '%v'", i) 159 | } 160 | } 161 | } 162 | 163 | if val, err := cmd.Flags().GetStringSlice("allowed-cardholders"); err == nil { 164 | verifyReq.Policy.AllowedCardholders = append(verifyReq.Policy.AllowedCardholders, val...) 165 | } 166 | 167 | res := &result{Cmd: cmd} 168 | res.Data.Error = &resultError{} 169 | 170 | if val, err := cmd.Flags().GetBool("json"); err == nil { 171 | res.JSON = val 172 | } 173 | 174 | errors := false 175 | 176 | attestation, err := pgp.VerifyAttestation(verifyReq) 177 | if attestation != nil && !res.JSON { 178 | printPGPAttestation(cmd.OutOrStdout(), attestation) 179 | } 180 | 181 | res.Printf("\nAttestation Policy Checks:\n") 182 | if err == nil { 183 | res.Printf("✔ All policy checks OK\n") 184 | } else { 185 | errors = true 186 | verifyErrs, ok := err.(pgp.VerificationErrors) 187 | if !ok { 188 | return err 189 | } 190 | for _, e := range verifyErrs { 191 | res.PrintE(e.Error()) 192 | } 193 | } 194 | 195 | // if --ssh-pub-key=file was specified, compare it to the public key in the attestation 196 | if sshPubKey != nil { 197 | res.Printf("\nSSH public key file '%s':\n", sshPubKeyFile) 198 | 199 | if pubkeys.Compare(sshPubKey, attestCert.PublicKey) { 200 | res.Printf("✔ SSH public key file matches attestation public key\n") 201 | } else { 202 | errors = true 203 | sshFP := ssh.FingerprintSHA256(sshPubKey) 204 | certSSHpub, err := ssh.NewPublicKey(attestCert.PublicKey) 205 | certFP := ssh.FingerprintSHA256(certSSHpub) 206 | 207 | if err != nil { 208 | res.PrintE(fmt.Sprintf("Unable to parse attestation cert public key: %v", err)) 209 | } else { 210 | res.PrintE(fmt.Sprintf("SSH public key (%s) does not match attestation public key (%s)", sshFP, certFP)) 211 | } 212 | } 213 | } 214 | 215 | if res.JSON { 216 | if err = res.PrintResultJSON(attestation); err != nil { 217 | return err 218 | } 219 | } 220 | 221 | if errors { 222 | os.Exit(1) 223 | } 224 | 225 | return nil 226 | } 227 | 228 | func printPGPAttestation(w io.Writer, attestation *pgp.Attestation) { 229 | fmt.Fprintln(w, "YubiKey OPGP Attestation:") 230 | fmt.Fprintf(w, " - Generation Date: %s\n", attestation.GenerationDate) 231 | fmt.Fprintf(w, " - Cardholder : %s\n", attestation.Cardholder) 232 | fmt.Fprintf(w, " - Key slot : %s\n", attestation.Slot) 233 | fmt.Fprintf(w, " - Key source : %s\n", attestation.Keysource) 234 | fmt.Fprintf(w, " - Key fingerprint: %s\n", attestation.Fingerprint) 235 | fmt.Fprintf(w, " - YubiKey Version: v%d.%d.%d\n", attestation.Version.Major, attestation.Version.Minor, attestation.Version.Patch) 236 | fmt.Fprintf(w, " - Serial # : %d\n", attestation.Serial) 237 | fmt.Fprintf(w, " - Formfactor : %s\n", attestation.Formfactor) 238 | fmt.Fprintf(w, " - Touch Policy : %s\n", attestation.TouchPolicy) 239 | } 240 | -------------------------------------------------------------------------------- /cmd/piv.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/joemiller/yk-attest-verify/pkg/piv" 10 | "github.com/joemiller/yk-attest-verify/pkg/pubkeys" 11 | "github.com/spf13/cobra" 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | // pivCmd represents the PIV attestion verification sub command 16 | var pivCmd = &cobra.Command{ 17 | Use: "piv ATTESTATION SIGNER", 18 | Short: "Verify the signature and contents of a YuibKey PIV Attestation certificate.", 19 | Long: "Verify the signature and contents of a YuibKey PIV Attestation certificate.", 20 | Example: indentor(` 21 | # verify the signature chain of an attestation certificate: 22 | yk-attest-verify piv attestation.pem signer.pem 23 | 24 | # also verify the public key in an ssh public key file matches the public key in the attestation: 25 | yk-attest-verify piv attestation.pem signer.pem --ssh-pub-key="id_rsa.pub" 26 | 27 | # policy: verify the attested key has an allowed Touch Policy set: 28 | yk-attest-verify piv attestation.pem signer.pem --allowed-touch-policies="always,cached" 29 | 30 | # policy: verify the attested key has an allowed PIN Policy set: 31 | yk-attest-verify piv attestation.pem signer.pem --allowed-touch-policies="once,always" 32 | `), 33 | Args: cobra.ExactArgs(2), 34 | SilenceUsage: true, 35 | RunE: pivVerify, 36 | } 37 | 38 | func init() { 39 | // policy flags (--allowed-*) 40 | pivCmd.Flags().StringSlice( 41 | "allowed-slots", 42 | []string{}, 43 | "Comma-separated list of allowed key Slots. If not set all slots are accepted. (9a,9c,9d,9e)", 44 | ) 45 | 46 | pivCmd.Flags().StringSlice( 47 | "allowed-pin-policies", 48 | []string{}, 49 | "Comma-separated list of allowed PIN policies. If not set any source is accepted. (never,once,always)", 50 | ) 51 | 52 | pivCmd.Flags().StringSlice( 53 | "allowed-touch-policies", 54 | []string{}, 55 | "Comma-separated list of allowed touch policies. If not set all policies are accepted. (never,always,cached)", 56 | ) 57 | 58 | pivCmd.Flags().Bool( 59 | "json", 60 | false, 61 | "Use JSON output format", 62 | ) 63 | 64 | pivCmd.Flags().String( 65 | "ssh-pub-key", 66 | "", 67 | "Verify an ssh public key file contains the same public key as the attestation certificate", 68 | ) 69 | 70 | rootCmd.AddCommand(pivCmd) 71 | } 72 | 73 | func pivVerify(cmd *cobra.Command, args []string) error { 74 | // these are guaranteed to exist by the ExactArgs(2) in the pivCmd struct 75 | attestCertFile := args[0] 76 | attestSignerFile := args[1] 77 | 78 | attestCert, err := loadX509CertFile(attestCertFile) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | attestSigner, err := loadX509CertFile(attestSignerFile) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | sshPubKeyFile, err := cmd.Flags().GetString("ssh-pub-key") 89 | if err != nil { 90 | return err 91 | } 92 | 93 | var sshPubKey ssh.PublicKey 94 | if sshPubKeyFile != "" { 95 | pubkeyraw, err := os.ReadFile(sshPubKeyFile) 96 | if err != nil { 97 | return fmt.Errorf("Error reading SSH pub key %s: %w", sshPubKeyFile, err) 98 | } 99 | sshPubKey, _, _, _, err = ssh.ParseAuthorizedKey(pubkeyraw) 100 | if err != nil { 101 | return fmt.Errorf("Error parsing SSH pub key %s: %w", sshPubKeyFile, err) 102 | } 103 | } 104 | 105 | verifyReq := piv.VerificationRequest{ 106 | AttestCert: attestCert, 107 | AttestSignerCert: attestSigner, 108 | } 109 | if val, err := cmd.Flags().GetStringSlice("allowed-slots"); err == nil { 110 | for _, i := range val { 111 | i = strings.ToLower(i) 112 | switch i { 113 | case "9a", "9c", "9d", "9e": 114 | verifyReq.Policy.AllowedSlots = append(verifyReq.Policy.AllowedSlots, piv.Slot(i)) 115 | default: 116 | return fmt.Errorf("--allowed-slots unknown slot name '%v'", i) 117 | } 118 | } 119 | } 120 | 121 | if val, err := cmd.Flags().GetStringSlice("allowed-pin-policies"); err == nil { 122 | for _, i := range val { 123 | i = strings.ToLower(i) 124 | switch i { 125 | case "never": 126 | verifyReq.Policy.AllowedPINPolicies = append(verifyReq.Policy.AllowedPINPolicies, piv.PINPolicyNever) 127 | case "once": 128 | verifyReq.Policy.AllowedPINPolicies = append(verifyReq.Policy.AllowedPINPolicies, piv.PINPolicyOnce) 129 | case "always": 130 | verifyReq.Policy.AllowedPINPolicies = append(verifyReq.Policy.AllowedPINPolicies, piv.PINPolicyAlways) 131 | default: 132 | return fmt.Errorf("--allowed-pin-policies unknown policy '%v'", i) 133 | } 134 | } 135 | } 136 | 137 | if val, err := cmd.Flags().GetStringSlice("allowed-touch-policies"); err == nil { 138 | for _, i := range val { 139 | i = strings.ToLower(i) 140 | switch i { 141 | case "never": 142 | verifyReq.Policy.AllowedTouchPolicies = append(verifyReq.Policy.AllowedTouchPolicies, piv.TouchPolicyNever) 143 | case "always": 144 | verifyReq.Policy.AllowedTouchPolicies = append(verifyReq.Policy.AllowedTouchPolicies, piv.TouchPolicyAlways) 145 | case "cached": 146 | verifyReq.Policy.AllowedTouchPolicies = append(verifyReq.Policy.AllowedTouchPolicies, piv.TouchPolicyCached) 147 | default: 148 | return fmt.Errorf("--allowed-touch-policies unknown policy '%v'", i) 149 | } 150 | } 151 | } 152 | 153 | res := &result{Cmd: cmd} 154 | res.Data.Error = &resultError{} 155 | 156 | if val, err := cmd.Flags().GetBool("json"); err == nil { 157 | res.JSON = val 158 | } 159 | 160 | errors := false 161 | 162 | attestation, err := piv.VerifyAttestation(verifyReq) 163 | if attestation != nil && !res.JSON { 164 | printPIVAttestation(cmd.OutOrStdout(), attestation) 165 | } 166 | 167 | res.Printf("\nAttestation Policy Checks:\n") 168 | if err == nil { 169 | res.Printf("✔ All policy checks OK\n") 170 | } else { 171 | errors = true 172 | verifyErrs, ok := err.(piv.VerificationErrors) 173 | if !ok { 174 | return err 175 | } 176 | for _, e := range verifyErrs { 177 | res.PrintE(e.Error()) 178 | } 179 | } 180 | 181 | // if --ssh-pub-key=file was specified, compare it to the public key in the attestation 182 | if sshPubKey != nil { 183 | res.Printf("\nSSH public key file '%s':\n", sshPubKeyFile) 184 | 185 | if pubkeys.Compare(sshPubKey, attestCert.PublicKey) { 186 | res.Printf("✔ SSH public key file matches attestation public key\n") 187 | } else { 188 | errors = true 189 | sshFP := ssh.FingerprintSHA256(sshPubKey) 190 | certSSHpub, err := ssh.NewPublicKey(attestCert.PublicKey) 191 | certFP := ssh.FingerprintSHA256(certSSHpub) 192 | 193 | if err != nil { 194 | res.PrintE(fmt.Sprintf("Unable to parse attestation cert public key: %v", err)) 195 | } else { 196 | res.PrintE(fmt.Sprintf("SSH public key (%s) does not match attestation public key (%s)", sshFP, certFP)) 197 | } 198 | } 199 | } 200 | 201 | if res.JSON { 202 | if err = res.PrintResultJSON(attestation); err != nil { 203 | return err 204 | } 205 | } 206 | 207 | if errors { 208 | os.Exit(1) 209 | } 210 | 211 | return nil 212 | } 213 | 214 | func printPIVAttestation(w io.Writer, attestation *piv.Attestation) { 215 | fmt.Fprintln(w, "YubiKey PIV Attestation:") 216 | fmt.Fprintf(w, " - Key slot : %s\n", attestation.Slot) 217 | fmt.Fprintf(w, " - YubiKey Version: v%d.%d.%d\n", attestation.Version.Major, attestation.Version.Minor, attestation.Version.Patch) 218 | fmt.Fprintf(w, " - Serial # : %d\n", attestation.Serial) 219 | fmt.Fprintf(w, " - Formfactor : %s\n", attestation.Formfactor) 220 | fmt.Fprintf(w, " - PIN Policy : %s\n", attestation.PINPolicy) 221 | fmt.Fprintf(w, " - Touch Policy : %s\n", attestation.TouchPolicy) 222 | } 223 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var app = "yk-attest-verify" 10 | 11 | // rootCmd represents the base command when called without any subcommands 12 | var rootCmd = &cobra.Command{ 13 | Use: app, 14 | Short: "Validate and enforce policy on YubiKey PIV and OpenPGP attestation certificates.", 15 | } 16 | 17 | // Execute adds all child commands to the root command and sets flags appropriately. 18 | // This is called by main.main(). It only needs to happen once to the rootCmd. 19 | func Execute() { 20 | if err := rootCmd.Execute(); err != nil { 21 | os.Exit(1) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var version = "development" 10 | 11 | // versionCmd represents the version command 12 | var versionCmd = &cobra.Command{ 13 | Use: "version", 14 | Short: "Print version", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | fmt.Println(version) 17 | }, 18 | } 19 | 20 | func init() { 21 | rootCmd.AddCommand(versionCmd) 22 | } 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/joemiller/yk-attest-verify 2 | 3 | go 1.21.1 4 | 5 | require ( 6 | github.com/joemiller/certin v0.3.5 7 | github.com/spf13/cobra v1.8.1 8 | github.com/stretchr/testify v1.10.0 9 | golang.org/x/crypto v0.32.0 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | github.com/spf13/pflag v1.0.5 // indirect 17 | golang.org/x/sys v0.29.0 // indirect 18 | gopkg.in/yaml.v3 v3.0.1 // indirect 19 | ) 20 | 21 | // replace github.com/joemiller/certin v0.0.0-20200616162212-8e034fba0302 => ../certin 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 7 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 8 | github.com/joemiller/certin v0.3.5 h1:aghfTg884X8bBV5rofHYRvlJgu+78GpPnBPIgw7jHbk= 9 | github.com/joemiller/certin v0.3.5/go.mod h1:iycNCl6jEKmKQ35RVw23gQVJ8DUK24wGejs7WWSXuBQ= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 13 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 14 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 15 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 16 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 17 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 18 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 19 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 20 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 21 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 22 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 23 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 24 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 25 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 26 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 27 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 28 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 29 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 30 | golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= 31 | golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 32 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 34 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 36 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/joemiller/yk-attest-verify/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /pkg/pgp/attestation.go: -------------------------------------------------------------------------------- 1 | package pgp 2 | 3 | import ( 4 | "crypto/x509" 5 | "crypto/x509/pkix" 6 | "encoding/asn1" 7 | "encoding/binary" 8 | "encoding/hex" 9 | "encoding/json" 10 | "fmt" 11 | "time" 12 | ) 13 | 14 | // This package provides the ability to parse and verify Yubikey OpenPGP attestation certificates. 15 | // 16 | // It is based off of https://github.com/go-piv/piv-go piv.Verify() which is 17 | // only capable of verifying Yubikey PIV attestation certificates. 18 | 19 | var ( 20 | yubicoBaseOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 41482} 21 | 22 | // OIDs present in OpenPGP attestation certs - https://developers.yubico.com/PGP/Attestation.html 23 | yubikeyPGPCardHolderName = append(yubicoBaseOID, asn1.ObjectIdentifier{5, 1}...) 24 | yubikeyPGPKeySource = append(yubicoBaseOID, asn1.ObjectIdentifier{5, 2}...) 25 | yubikeyPGPVersionNumber = append(yubicoBaseOID, asn1.ObjectIdentifier{5, 3}...) 26 | yubikeyPGPKeyFingerprint = append(yubicoBaseOID, asn1.ObjectIdentifier{5, 4}...) 27 | yubikeyPGPKeyGenerationDate = append(yubicoBaseOID, asn1.ObjectIdentifier{5, 5}...) 28 | yubikeyPGPSignatureCounter = append(yubicoBaseOID, asn1.ObjectIdentifier{5, 6}...) 29 | yubikeyPGPSerialNumber = append(yubicoBaseOID, asn1.ObjectIdentifier{5, 7}...) 30 | yubikeyPGPUserInteractionFlag = append(yubicoBaseOID, asn1.ObjectIdentifier{5, 8}...) 31 | yubikeyPGPFormFactor = append(yubicoBaseOID, asn1.ObjectIdentifier{5, 9}...) 32 | ) 33 | 34 | // Keysourxe represents the source of the key (imported or generated) 35 | type Keysource int 36 | 37 | const ( 38 | KeysourceImported Keysource = iota 39 | KeysourceGenerated 40 | ) 41 | 42 | var keysourceNames = []string{ 43 | KeysourceImported: "Imported", 44 | KeysourceGenerated: "Generated", 45 | } 46 | 47 | func (k Keysource) String() string { 48 | return keysourceNames[k] 49 | } 50 | 51 | // MarshalJSON encodes value into String(). 52 | func (k Keysource) MarshalJSON() ([]byte, error) { 53 | return json.Marshal(k.String()) 54 | } 55 | 56 | // Slot represents the YubiKey card slot that is covered by the attestation. 57 | type Slot string 58 | 59 | const ( 60 | SlotSignature = Slot("SIG") 61 | SlotEncrypt = Slot("ENC") 62 | SlotAuthenticate = Slot("AUT") 63 | ) 64 | 65 | // Version encodes a major, minor, and patch version. 66 | type Version struct { 67 | Major int 68 | Minor int 69 | Patch int 70 | } 71 | 72 | // Formfactor enumerates the physical set of forms a key can take. USB-A vs. 73 | // USB-C and Keychain vs. Nano. 74 | type Formfactor int 75 | 76 | // Formfactors recognized by this package. 77 | const ( 78 | FormfactorUnspecified Formfactor = iota 79 | FormfactorUSBAKeychain 80 | FormfactorUSBANano 81 | FormfactorUSBCKeychain 82 | FormfactorUSBCNano 83 | FormfactorUSBCLightningKeychain 84 | ) 85 | 86 | var formfactorNames = []string{ 87 | FormfactorUnspecified: "Unspecified", 88 | FormfactorUSBAKeychain: "USB-A Keychain", 89 | FormfactorUSBANano: "USB-A Nano", 90 | FormfactorUSBCKeychain: "USB-C Keychain", 91 | FormfactorUSBCNano: "USB-C Nano", 92 | FormfactorUSBCLightningKeychain: "USB-C + Lightning Keychain", 93 | } 94 | 95 | func (f Formfactor) String() string { 96 | return formfactorNames[f] 97 | } 98 | 99 | // MarshalJSON encodes value into String(). 100 | func (f Formfactor) MarshalJSON() ([]byte, error) { 101 | return json.Marshal(f.String()) 102 | } 103 | 104 | // TouchPolicy represents proof-of-presence requirements when signing or 105 | // decrypting with asymmetric key in a given slot. 106 | type TouchPolicy int 107 | 108 | // Touch policies supported by this package. 109 | // $ ykman openpgp set-touch 110 | const ( 111 | TouchPolicyDisabled TouchPolicy = iota // No touch required 112 | TouchPolicyEnabled // Touch required 113 | TouchPolicyPermanent // Touch required, can't be disabled without a full reset 114 | TouchPolicyCached // Touch required, cached for 15s after use 115 | TouchPolicyPermanentCached // Touch required, cached for 15s after use, can't be disabled without a full reset 116 | ) 117 | 118 | var touchPolicyNames = []string{ 119 | TouchPolicyDisabled: "Disabled", 120 | TouchPolicyEnabled: "Enabled", 121 | TouchPolicyPermanent: "Enabled-Permanent", 122 | TouchPolicyCached: "Enabled-Cached", 123 | TouchPolicyPermanentCached: "Enabled-Permanent-Cached", 124 | } 125 | 126 | func (t TouchPolicy) String() string { 127 | return touchPolicyNames[t] 128 | } 129 | 130 | // MarshalJSON encodes value into String(). 131 | func (t TouchPolicy) MarshalJSON() ([]byte, error) { 132 | return json.Marshal(t.String()) 133 | } 134 | 135 | // Attestation contains additional information about a key attested to be on a 136 | // card. 137 | type Attestation struct { 138 | // Cardholder is the name of the cardholder 139 | Cardholder string 140 | 141 | // Keysource 142 | Keysource Keysource 143 | 144 | // Slot is the key slot 145 | Slot Slot 146 | 147 | // Version of the YubiKey's firmware. 148 | Version Version 149 | 150 | // Fingerprint 151 | Fingerprint string 152 | 153 | // GenerationDate 154 | GenerationDate time.Time 155 | 156 | // SignatureCounter (if applicable) 157 | SignatureCounter uint32 158 | 159 | // Serial is the YubiKey's serial number. 160 | Serial uint32 161 | 162 | // Formfactor indicates the physical type of the YubiKey. 163 | // 164 | // Formfactor may be empty Formfactor(0) for some YubiKeys. 165 | Formfactor Formfactor 166 | 167 | // TouchPolicy set on the slot. 168 | TouchPolicy TouchPolicy 169 | } 170 | 171 | // ParseAttestation parses a YubiKey OPGP attestation certificate and returns 172 | // an Attestation. 173 | func ParseAttestation(attestCert *x509.Certificate) (*Attestation, error) { 174 | var a Attestation 175 | for _, ext := range attestCert.Extensions { 176 | if err := a.addExt(ext); err != nil { 177 | return nil, fmt.Errorf("parsing extension: %v", err) 178 | } 179 | } 180 | slot, err := parseSlot(attestCert.Subject.CommonName) 181 | if err != nil { 182 | return nil, fmt.Errorf("parsing slot: %v", err) 183 | } 184 | a.Slot = slot 185 | 186 | return &a, nil 187 | } 188 | 189 | func (a *Attestation) addExt(e pkix.Extension) error { 190 | switch { 191 | case e.Id.Equal(yubikeyPGPCardHolderName): 192 | var name string 193 | if _, err := asn1.Unmarshal(e.Value, &name); err != nil { 194 | return fmt.Errorf("parsing cardholder name: %v", err) 195 | } 196 | a.Cardholder = string(name) 197 | 198 | case e.Id.Equal(yubikeyPGPKeySource): 199 | var source int 200 | if _, err := asn1.Unmarshal(e.Value, &source); err != nil { 201 | return fmt.Errorf("parsing key source: %v", err) 202 | } 203 | switch source { 204 | case 0x00: 205 | a.Keysource = KeysourceImported // not permitted, but yubikey may some day generate attestations for imported keys 206 | case 0x01: 207 | a.Keysource = KeysourceGenerated 208 | default: 209 | return fmt.Errorf("unknown keysource 0x%x", source) 210 | } 211 | 212 | case e.Id.Equal(yubikeyPGPVersionNumber): 213 | var version []byte 214 | if _, err := asn1.Unmarshal(e.Value, &version); err != nil { 215 | return fmt.Errorf("parsing version: %v", err) 216 | } 217 | if len(version) != 3 { 218 | return fmt.Errorf("expected at least 3 bytes for firmware version, got: %d", len(version)) 219 | } 220 | a.Version = Version{ 221 | Major: int(version[0]), 222 | Minor: int(version[1]), 223 | Patch: int(version[2]), 224 | } 225 | 226 | case e.Id.Equal(yubikeyPGPKeyFingerprint): 227 | var fp []byte 228 | if _, err := asn1.Unmarshal(e.Value, &fp); err != nil { 229 | return fmt.Errorf("parsing fingerprint: %v", err) 230 | } 231 | a.Fingerprint = hex.EncodeToString(fp) 232 | 233 | case e.Id.Equal(yubikeyPGPKeyGenerationDate): 234 | var ts []byte 235 | if _, err := asn1.Unmarshal(e.Value, &ts); err != nil { 236 | return fmt.Errorf("parsing generation date: %v", err) 237 | } 238 | a.GenerationDate = time.Unix(int64(binary.BigEndian.Uint32(ts)), 0) 239 | 240 | case e.Id.Equal(yubikeyPGPSignatureCounter): 241 | var counter int64 242 | if _, err := asn1.Unmarshal(e.Value, &counter); err != nil { 243 | return fmt.Errorf("parsing signature counter: %v", err) 244 | } 245 | a.SignatureCounter = uint32(counter) 246 | 247 | case e.Id.Equal(yubikeyPGPSerialNumber): 248 | var serial int64 249 | if _, err := asn1.Unmarshal(e.Value, &serial); err != nil { 250 | return fmt.Errorf("parsing serial number: %v", err) 251 | } 252 | if serial < 0 { 253 | return fmt.Errorf("serial number was negative: %d", serial) 254 | } 255 | a.Serial = uint32(serial) 256 | 257 | case e.Id.Equal(yubikeyPGPUserInteractionFlag): 258 | var flag []byte 259 | if _, err := asn1.Unmarshal(e.Value, &flag); err != nil { 260 | return fmt.Errorf("parsing touch policy: %v", err) 261 | } 262 | switch flag[0] { 263 | case 0x00: 264 | a.TouchPolicy = TouchPolicyDisabled 265 | case 0x01: 266 | a.TouchPolicy = TouchPolicyEnabled 267 | case 0x02: 268 | a.TouchPolicy = TouchPolicyPermanent 269 | case 0x03: 270 | a.TouchPolicy = TouchPolicyCached 271 | case 0x04: 272 | a.TouchPolicy = TouchPolicyPermanentCached 273 | default: 274 | return fmt.Errorf("unknown touch policy 0x%x", flag) 275 | } 276 | 277 | case e.Id.Equal(yubikeyPGPFormFactor): 278 | var formfactor []byte 279 | if _, err := asn1.Unmarshal(e.Value, &formfactor); err != nil { 280 | return fmt.Errorf("parsing form factor: %v", err) 281 | } 282 | switch formfactor[0] { 283 | case 0x00: 284 | a.Formfactor = FormfactorUnspecified 285 | case 0x01: 286 | a.Formfactor = FormfactorUSBAKeychain 287 | case 0x02: 288 | a.Formfactor = FormfactorUSBANano 289 | case 0x03: 290 | a.Formfactor = FormfactorUSBCKeychain 291 | case 0x04: 292 | a.Formfactor = FormfactorUSBCNano 293 | case 0x05: 294 | a.Formfactor = FormfactorUSBCLightningKeychain 295 | default: 296 | return fmt.Errorf("unrecognized formfactor: 0x%x", formfactor) 297 | } 298 | } 299 | 300 | return nil 301 | } 302 | 303 | // parseSlot parses the common-name from the attestation cert's subject. The format 304 | // is described in: https://developers.yubico.com/PGP/Attestation.html - 305 | // Subject will be the string "YubiKey OPGP Attestation " with the 306 | // attested slot appended ("SIG", "DEC", or "AUT") 307 | func parseSlot(subject string) (Slot, error) { 308 | if len(subject) < 3 { 309 | return Slot(""), fmt.Errorf("subject less than 3 chars, unable to determine slot") 310 | } 311 | slot := subject[len(subject)-3:] 312 | 313 | switch slot { 314 | case "SIG": 315 | return SlotSignature, nil 316 | case "ENC", "DEC": 317 | return SlotEncrypt, nil 318 | case "AUT": 319 | return SlotAuthenticate, nil 320 | } 321 | return Slot(""), fmt.Errorf("Unknown slot '%v'", slot) 322 | } 323 | -------------------------------------------------------------------------------- /pkg/pgp/attestation_test.go: -------------------------------------------------------------------------------- 1 | package pgp 2 | 3 | import ( 4 | "crypto/x509" 5 | "crypto/x509/pkix" 6 | "encoding/asn1" 7 | "encoding/binary" 8 | "fmt" 9 | "testing" 10 | "time" 11 | 12 | "github.com/joemiller/certin" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func TestParseAttestation(t *testing.T) { 18 | root := fakeYubiRoot(t) 19 | signer := fakeAttestSigner(t, root) 20 | 21 | cfg := opgpAttestation{ 22 | cardholder: "Alice Crypto", 23 | slot: SlotAuthenticate, 24 | keysource: KeysourceGenerated, 25 | version: []byte{5, 2, 4}, 26 | fingerprint: []byte{1, 2, 3}, 27 | timestamp: time.Now(), 28 | sigcounter: 2, 29 | serial: 1234, 30 | formfactor: FormfactorUSBCNano, 31 | touchpolicy: TouchPolicyPermanent, 32 | } 33 | attestationCert := makeOPGPAttestationCert(t, signer, cfg) 34 | 35 | parsedAttestation, err := ParseAttestation(attestationCert) 36 | assert.NoError(t, err) 37 | assert.Equal(t, "Alice Crypto", parsedAttestation.Cardholder) 38 | assert.Equal(t, SlotAuthenticate, parsedAttestation.Slot) 39 | assert.Equal(t, KeysourceGenerated, parsedAttestation.Keysource) 40 | assert.Equal(t, "010203", parsedAttestation.Fingerprint) 41 | assert.Equal(t, uint32(2), parsedAttestation.SignatureCounter) 42 | assert.Equal(t, uint32(1234), parsedAttestation.Serial) 43 | assert.Equal(t, TouchPolicyPermanent, parsedAttestation.TouchPolicy) 44 | assert.Equal(t, FormfactorUSBCNano, parsedAttestation.Formfactor) 45 | 46 | expectedVersion := Version{5, 2, 4} 47 | assert.Equal(t, expectedVersion, parsedAttestation.Version) 48 | } 49 | 50 | func fakeYubiRoot(t *testing.T) *certin.KeyAndCert { 51 | root, err := certin.NewCert(nil, certin.Request{CN: "Yubico OpenPGP Attestation CA"}) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | return root 56 | } 57 | 58 | func fakeAttestSigner(t *testing.T, root *certin.KeyAndCert) *certin.KeyAndCert { 59 | signer, err := certin.NewCert(root, certin.Request{CN: "Yubikey OPGP Attestation"}) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | return signer 64 | } 65 | 66 | type opgpAttestation struct { 67 | cardholder string 68 | slot Slot 69 | keysource Keysource 70 | version []byte 71 | fingerprint []byte 72 | timestamp time.Time 73 | sigcounter uint32 74 | serial uint32 75 | formfactor Formfactor 76 | touchpolicy TouchPolicy 77 | } 78 | 79 | // Create an attestation cert and sign it with the attestation signer key. 80 | // Sane defaults will be used for nil or zero values where possible so that callers 81 | // can supply values for only things they're interested in testing. 82 | func makeOPGPAttestationCert(t *testing.T, signer *certin.KeyAndCert, cfg opgpAttestation) *x509.Certificate { 83 | cardholder, err := asn1.Marshal(cfg.cardholder) 84 | require.Nil(t, err) 85 | 86 | if cfg.slot == "" { 87 | cfg.slot = SlotSignature // default slot is "SIG" if not specified 88 | } 89 | 90 | keysource, err := asn1.Marshal(cfg.keysource) 91 | require.Nil(t, err) 92 | 93 | if cfg.version == nil { 94 | cfg.version = []byte{5, 2, 1} // default version if not specified 95 | } 96 | version, err := asn1.Marshal(cfg.version) 97 | require.Nil(t, err) 98 | 99 | fingerprint, err := asn1.Marshal(cfg.fingerprint) 100 | require.Nil(t, err) 101 | 102 | if !cfg.timestamp.IsZero() { 103 | cfg.timestamp = time.Now() // default timestmap if not specified 104 | } 105 | timestampBytes := make([]byte, 4) 106 | binary.BigEndian.PutUint32(timestampBytes, uint32(cfg.timestamp.Unix())) 107 | timestamp, err := asn1.Marshal(timestampBytes) 108 | require.Nil(t, err) 109 | 110 | sigcounter, err := asn1.Marshal(int64(cfg.sigcounter)) 111 | require.Nil(t, err) 112 | 113 | serial, err := asn1.Marshal(int64(cfg.serial)) 114 | require.Nil(t, err) 115 | 116 | pinpolicy, err := asn1.Marshal([]byte{byte(cfg.touchpolicy)}) 117 | require.Nil(t, err) 118 | 119 | formfactor, err := asn1.Marshal([]byte{byte(cfg.formfactor)}) 120 | require.Nil(t, err) 121 | 122 | attestationCertTemplate := &x509.Certificate{ 123 | Subject: pkix.Name{ 124 | CommonName: fmt.Sprintf("YubiKey OPGP Attestation %s", cfg.slot), 125 | }, 126 | NotBefore: time.Now().Add(-30 * time.Second), 127 | NotAfter: time.Now().Add(24 * time.Hour), 128 | ExtraExtensions: []pkix.Extension{ 129 | {Id: yubikeyPGPCardHolderName, Critical: false, Value: cardholder}, 130 | {Id: yubikeyPGPKeySource, Critical: false, Value: keysource}, 131 | {Id: yubikeyPGPVersionNumber, Critical: false, Value: version}, 132 | {Id: yubikeyPGPKeyFingerprint, Critical: false, Value: fingerprint}, 133 | {Id: yubikeyPGPKeyGenerationDate, Critical: false, Value: timestamp}, 134 | {Id: yubikeyPGPSignatureCounter, Critical: false, Value: sigcounter}, 135 | {Id: yubikeyPGPSerialNumber, Critical: false, Value: serial}, 136 | {Id: yubikeyPGPUserInteractionFlag, Critical: false, Value: pinpolicy}, 137 | {Id: yubikeyPGPFormFactor, Critical: false, Value: formfactor}, 138 | }, 139 | } 140 | attestation, err := certin.NewCertFromX509Template(signer, "rsa-2048", attestationCertTemplate) 141 | if err != nil { 142 | t.Fatal(err) 143 | } 144 | return attestation.Certificate 145 | } 146 | -------------------------------------------------------------------------------- /pkg/pgp/verify.go: -------------------------------------------------------------------------------- 1 | package pgp 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/pem" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | // Policy represents a set of allowed contents of a YubiKey OPGP attestation certificate. 11 | type Policy struct { 12 | AllowedTouchPolicies []TouchPolicy 13 | AllowedKeySources []Keysource 14 | AllowedSlots []Slot 15 | AllowedCardholders []string 16 | } 17 | 18 | // VerificationRequest contains a Yubikey Attestation certificate signed by a 19 | // attestation signer key. 20 | // 21 | // Attestation (AttestCert) certs can be generated with the `ykman` utility and the 22 | // `ykman openpgp attest` command. The AttestSignerCert used to sign the attestation cert (signer) 23 | // can be exported from the Yubikey using the `ykman openpgp export-certificate ATT`: 24 | // 25 | // # create an attestation cert covering the key in the authentication (AUT) key slot 26 | // ykman openpgp attest AUT attest.pem 27 | // 28 | // # export the attestation (ATT) singer cert used to sign the cert above. 29 | // ykman openpgp attest AUT signer.pem 30 | // 31 | type VerificationRequest struct { 32 | AttestCert *x509.Certificate 33 | AttestSignerCert *x509.Certificate 34 | Policy Policy 35 | } 36 | 37 | // VerificationErrors holds errors representing policy violations from a verification request. 38 | type VerificationErrors []error 39 | 40 | // Error implements the error interface for VerificationErrors and returns a 41 | // summary of the error messages. To inspect the list of errors individually you 42 | // would cast the err to VerificationError and inspect the list. 43 | // errs := err.(VerificationErrors) 44 | func (ve VerificationErrors) Error() string { 45 | if len(ve) == 0 { 46 | return "" 47 | } 48 | 49 | s := []string{} 50 | for _, e := range ve { 51 | s = append(s, e.Error()) 52 | } 53 | return strings.Join(s, "\n") 54 | } 55 | 56 | // VerifyAttestation verifies the signature chain of an attestation cert and evaluates 57 | // the attributes in the attestation against a list of policies. If the cert chain 58 | // is valid and all policy rules apply nil is returned. Otherwise an error that 59 | // may be cast to .(VerificationErrors) will be returned. This accumulator contains 60 | // a slice of one or more errors representing policy violations. 61 | func VerifyAttestation(req VerificationRequest) (*Attestation, error) { 62 | var v verifier 63 | return v.verify(req) 64 | } 65 | 66 | type verifier struct { 67 | Root *x509.Certificate 68 | } 69 | 70 | func (v *verifier) verify(req VerificationRequest) (*Attestation, error) { 71 | var errs VerificationErrors 72 | 73 | root := v.Root 74 | if root == nil { 75 | ca, err := yubicoCA() 76 | if err != nil { 77 | errs = append(errs, fmt.Errorf("parsing YubiCo Root CA: %v", err)) 78 | return nil, errs 79 | } 80 | root = ca 81 | } 82 | 83 | // Verify signatures: 84 | // The Attestation Signer Cert from the yubikey must be signed by YubiCo's attestation root 85 | if err := verifySignature(root, req.AttestSignerCert); err != nil { 86 | errs = append(errs, fmt.Errorf("attestation signer certificate is not signed by the YubiCo OpenPGP Root CA: %v", err)) 87 | } 88 | // The Attestation Cert must be signed by the Attestation Signer Cert 89 | if err := verifySignature(req.AttestSignerCert, req.AttestCert); err != nil { 90 | errs = append(errs, fmt.Errorf("attestation certificate not signed by device's attestation signer key: %v", err)) 91 | } 92 | 93 | attestation, err := ParseAttestation(req.AttestCert) 94 | if err != nil { 95 | errs = append(errs, fmt.Errorf("Unable to parse attestation cert: %v", err)) 96 | return nil, errs 97 | } 98 | 99 | // Verify the attestation specifies an allowed TouchPolicy. 100 | if len(req.Policy.AllowedTouchPolicies) > 0 { 101 | found := false 102 | for _, pol := range req.Policy.AllowedTouchPolicies { 103 | if attestation.TouchPolicy == pol { 104 | found = true 105 | break 106 | } 107 | } 108 | if !found { 109 | errs = append(errs, fmt.Errorf("Touch Policy '%v' is not allowed", attestation.TouchPolicy)) 110 | } 111 | } 112 | 113 | if len(req.Policy.AllowedKeySources) > 0 { 114 | found := false 115 | for _, src := range req.Policy.AllowedKeySources { 116 | if attestation.Keysource == src { 117 | found = true 118 | break 119 | } 120 | } 121 | if !found { 122 | errs = append(errs, fmt.Errorf("Key Source '%v' is not allowed", attestation.Keysource)) 123 | } 124 | } 125 | 126 | if len(req.Policy.AllowedSlots) > 0 { 127 | found := false 128 | for _, slot := range req.Policy.AllowedSlots { 129 | if attestation.Slot == slot { 130 | found = true 131 | break 132 | } 133 | } 134 | if !found { 135 | errs = append(errs, fmt.Errorf("Slot '%v' not allowed by policy", attestation.Slot)) 136 | } 137 | } 138 | 139 | if len(req.Policy.AllowedCardholders) > 0 { 140 | found := false 141 | for _, cardholder := range req.Policy.AllowedCardholders { 142 | if attestation.Cardholder == cardholder { 143 | found = true 144 | break 145 | } 146 | } 147 | if !found { 148 | errs = append(errs, fmt.Errorf("Unexpected cardholder '%v'", attestation.Cardholder)) 149 | } 150 | } 151 | 152 | // check if errs is empty and if so return nil explicitly, otherwise 153 | // errs will always be != nil 154 | if len(errs) == 0 { 155 | return attestation, nil 156 | } 157 | return attestation, errs 158 | } 159 | 160 | func verifySignature(parent, c *x509.Certificate) error { 161 | return parent.CheckSignature(c.SignatureAlgorithm, c.RawTBSCertificate, c.Signature) 162 | } 163 | 164 | // yubicoPGPCAPEM is the PEM encoded attestation certificate used by Yubico for OpenPGP keys. 165 | // 166 | // https://developers.yubico.com/PGP/Attestation.html 167 | // https://github.com/Yubico/developers.yubico.com/blob/master/content/PGP/Attestation.adoc 168 | // https://developers.yubico.com/PGP/opgp-attestation-ca.pem 169 | const yubicoPGPCAPEM = `-----BEGIN CERTIFICATE----- 170 | MIIDOTCCAiGgAwIBAgIJAN0XtOvBoi4ZMA0GCSqGSIb3DQEBCwUAMCgxJjAkBgNV 171 | BAMMHVl1YmljbyBPcGVuUEdQIEF0dGVzdGF0aW9uIENBMB4XDTE5MDgwMTAwMDAw 172 | MFoXDTQ2MTIxNzAwMDAwMFowKDEmMCQGA1UEAwwdWXViaWNvIE9wZW5QR1AgQXR0 173 | ZXN0YXRpb24gQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQClkKck 174 | +NEH+iSVLjbOvvreMlvkK4DZ7aETLusDfkEDy5+cv8SHtKSVcYfKhkST1l/5kbyx 175 | WAnxLRr+aYP52830qkDfYY1OE/IQG76BdWaGZJuMU4cdUPQR21Y7JB+ELHNMQHav 176 | 3CmregKVqIRB6vgwWq/6AM37VKqKNTsBUmrAyihX/vY/kS3L1cP/NCPhUC9Gqab2 177 | zohxXansjz92+4/dbN1cKDSGI8kVmoLpLbCf/CqGE4lWen0HxMCo/zIZo0nlGS7G 178 | rEAqN+PRRwiemBZhwBzeYiCLkh7qaqO4O1eWCNLjkJeLwIZ/uyRTESbaFoXOxqFp 179 | FjIyEjMYIdRXfaHVAgMBAAGjZjBkMB0GA1UdDgQWBBT7/MlvyfSnaal2RJH3cc8m 180 | ZS4SSjAfBgNVHSMEGDAWgBT7/MlvyfSnaal2RJH3cc8mZS4SSjASBgNVHRMBAf8E 181 | CDAGAQH/AgEBMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAK+TP 182 | HgYNIFTy+2PXpxmPVnNOcJRcVykAxaLJAAxey2BXy9xmU7lzHbl2x23Lw3kH7Crr 183 | RqG67WGcwSZzvWWEcbq4zmX3vnu3FOFlqKFhU164tod4cXz1JGsTgfXaPRvoKJAo 184 | XMotYH/u2UY/K8jmqycgEyHAFc9wx1v/q0H6p4WgbXLu2oBzRodHokgK/6EbIbR+ 185 | Jok3xJ+5haGcMCCz2A8RBah4dxPDNeaz3tSkAjrtwLANV79hAZv2g9CZX6z0H2Zy 186 | HhK6CLTg2MfwT0NxS3Am76k2opXSqbk8k5nnNFSYFuvgxunQxUOB+3M+gWHmVTh8 187 | 7yaamyNndwmhhIAgeA== 188 | -----END CERTIFICATE-----` 189 | 190 | func yubicoCA() (*x509.Certificate, error) { 191 | b, _ := pem.Decode([]byte(yubicoPGPCAPEM)) 192 | if b == nil { 193 | return nil, fmt.Errorf("failed to decode yubico pem data") 194 | } 195 | return x509.ParseCertificate(b.Bytes) 196 | } 197 | -------------------------------------------------------------------------------- /pkg/pgp/verify_test.go: -------------------------------------------------------------------------------- 1 | package pgp 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | /* test cases: 11 | 12 | # test the certificate chains: 13 | x 1. test attestation cert not signed by the correct attestation signer 14 | x 2. test the attestation signer is not signed by the yubico root 15 | 16 | # test policy assertions: 17 | 1. touch policy (none, one, multiple) 18 | 2. keysources policy (generated, imported) 19 | 3. allowed slots (aut, sign, enc) 20 | 4. allowed card holder (none, one, multiple) 21 | */ 22 | 23 | // TestVerify_NotSignedByYubiCoRoot: 24 | // TestVerify_NotSignedByOnDeviceAttestationKey: 25 | // 26 | // test the certificate chains. The signature chain of a yubikey attestation cert 27 | // looks like: 28 | // yubico root -> 29 | // YubiKey OPGP Attestation -> (this key+crt is stored on the yubikey) 30 | // Attestation Cert (an attestation covering the key in one of the 31 | // slots on the yubikey, eg: an Auth, Sign, or Encrypt key) 32 | // 33 | func TestVerify_NotSignedByYubiCoRoot(t *testing.T) { 34 | rootA := fakeYubiRoot(t) 35 | rootB := fakeYubiRoot(t) 36 | signer := fakeAttestSigner(t, rootB) 37 | v := verifier{Root: rootA.Certificate} 38 | 39 | attestationCert := makeOPGPAttestationCert(t, signer, opgpAttestation{}) 40 | req := VerificationRequest{ 41 | AttestCert: attestationCert, 42 | AttestSignerCert: signer.Certificate, 43 | } 44 | 45 | _, err := v.verify(req) 46 | assert.NotNil(t, err) 47 | 48 | verifyErrs := err.(VerificationErrors) 49 | assert.Contains(t, verifyErrs, errors.New("attestation signer certificate is not signed by the YubiCo OpenPGP Root CA: crypto/rsa: verification error")) 50 | } 51 | 52 | func TestVerify_NotSignedByOnDeviceAttestationKey(t *testing.T) { 53 | root := fakeYubiRoot(t) 54 | signerA := fakeAttestSigner(t, root) 55 | signerB := fakeAttestSigner(t, root) 56 | v := verifier{Root: root.Certificate} 57 | 58 | attestationCert := makeOPGPAttestationCert(t, signerB, opgpAttestation{}) 59 | req := VerificationRequest{ 60 | AttestCert: attestationCert, 61 | AttestSignerCert: signerA.Certificate, 62 | } 63 | 64 | _, err := v.verify(req) 65 | assert.NotNil(t, err) 66 | 67 | verifyErrs := err.(VerificationErrors) 68 | assert.Contains(t, verifyErrs, errors.New("attestation certificate not signed by device's attestation signer key: crypto/rsa: verification error")) 69 | } 70 | 71 | func TestVerify_Policies(t *testing.T) { 72 | root := fakeYubiRoot(t) 73 | signer := fakeAttestSigner(t, root) 74 | v := verifier{Root: root.Certificate} 75 | 76 | // NOTE: all of these tests assume the signature chain is valid 77 | tests := []struct { 78 | name string 79 | attestContents opgpAttestation 80 | policy Policy 81 | expectedErrs *VerificationErrors 82 | }{ 83 | { 84 | name: "empty policies passes policy test", 85 | attestContents: opgpAttestation{}, 86 | policy: Policy{}, 87 | expectedErrs: nil, 88 | }, 89 | { 90 | name: "allowed touch policy passes policy test", 91 | attestContents: opgpAttestation{ 92 | touchpolicy: TouchPolicyPermanent, 93 | }, 94 | policy: Policy{ 95 | AllowedTouchPolicies: []TouchPolicy{TouchPolicyPermanent}, 96 | }, 97 | expectedErrs: nil, 98 | }, 99 | { 100 | name: "dis-allowed touch policy fails policy test", 101 | attestContents: opgpAttestation{ 102 | touchpolicy: TouchPolicyDisabled, 103 | }, 104 | policy: Policy{ 105 | AllowedTouchPolicies: []TouchPolicy{TouchPolicyEnabled}, 106 | }, 107 | expectedErrs: &VerificationErrors{errors.New("Touch Policy 'Disabled' is not allowed")}, 108 | }, 109 | { 110 | name: "allowed key source passes policy test", 111 | attestContents: opgpAttestation{ 112 | keysource: KeysourceGenerated, 113 | }, 114 | policy: Policy{ 115 | AllowedKeySources: []Keysource{KeysourceGenerated}, 116 | }, 117 | expectedErrs: nil, 118 | }, 119 | { 120 | name: "dis-allowed key source fails policy test", 121 | attestContents: opgpAttestation{ 122 | keysource: KeysourceImported, 123 | }, 124 | policy: Policy{ 125 | AllowedKeySources: []Keysource{KeysourceGenerated}, 126 | }, 127 | expectedErrs: &VerificationErrors{errors.New("Key Source 'Imported' is not allowed")}, 128 | }, 129 | { 130 | name: "allowed key slots passes policy test", 131 | attestContents: opgpAttestation{ 132 | slot: SlotEncrypt, 133 | }, 134 | policy: Policy{ 135 | AllowedSlots: []Slot{SlotEncrypt}, 136 | }, 137 | expectedErrs: nil, 138 | }, 139 | { 140 | name: "disallowed key slots fails policy test", 141 | attestContents: opgpAttestation{ 142 | slot: SlotEncrypt, 143 | }, 144 | policy: Policy{ 145 | AllowedSlots: []Slot{SlotAuthenticate}, 146 | }, 147 | expectedErrs: &VerificationErrors{errors.New("Slot 'ENC' not allowed by policy")}, 148 | }, 149 | { 150 | name: "allowed cardholder name passes policy test", 151 | attestContents: opgpAttestation{ 152 | cardholder: "Alice Crypto", 153 | }, 154 | policy: Policy{ 155 | AllowedCardholders: []string{"Alice Crypto"}, 156 | }, 157 | expectedErrs: nil, 158 | }, 159 | { 160 | name: "disallowed cardholder name fails policy test", 161 | attestContents: opgpAttestation{ 162 | cardholder: "Bob Hacker", 163 | }, 164 | policy: Policy{ 165 | AllowedCardholders: []string{"Alice Crypto"}, 166 | }, 167 | expectedErrs: &VerificationErrors{errors.New("Unexpected cardholder 'Bob Hacker'")}, 168 | }, 169 | } 170 | 171 | for _, tc := range tests { 172 | t.Run(tc.name, func(t *testing.T) { 173 | attestationCert := makeOPGPAttestationCert(t, signer, tc.attestContents) 174 | 175 | req := VerificationRequest{ 176 | AttestCert: attestationCert, 177 | AttestSignerCert: signer.Certificate, 178 | Policy: tc.policy, 179 | } 180 | _, err := v.verify(req) 181 | // spew.Dump(err) 182 | 183 | if tc.expectedErrs == nil { 184 | assert.Nil(t, err) 185 | } else { 186 | verifyErrs := err.(VerificationErrors) 187 | assert.Equal(t, *tc.expectedErrs, verifyErrs) 188 | } 189 | }) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /pkg/piv/attestation.go: -------------------------------------------------------------------------------- 1 | // Portions of code from https://github.com/go-piv/piv-go/blob/e6548dd11f020eb8a3922086893dee86537b47ce/piv/key.go are 2 | // reproduced here and modified. Motivations for this are: 3 | // - avoid having to link against platform native smartcard libraries such as pcsc 4 | // which is not needed for attestation certificate parsing. 5 | // - Conform to the interface desired by the yk-attest-verify application. 6 | // The copyright notice is included below: 7 | 8 | // Copyright 2020 Google LLC 9 | // Modifications 2020 Joe Miller 10 | // 11 | // Licensed under the Apache License, Version 2.0 (the "License"); 12 | // you may not use this file except in compliance with the License. 13 | // You may obtain a copy of the License at 14 | // 15 | // https://www.apache.org/licenses/LICENSE-2.0 16 | // 17 | // Unless required by applicable law or agreed to in writing, software 18 | // distributed under the License is distributed on an "AS IS" BASIS, 19 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | // See the License for the specific language governing permissions and 21 | // limitations under the License. 22 | 23 | package piv 24 | 25 | import ( 26 | "crypto/x509" 27 | "crypto/x509/pkix" 28 | "encoding/asn1" 29 | "encoding/json" 30 | "fmt" 31 | ) 32 | 33 | var ( 34 | extIDFirmwareVersion = asn1.ObjectIdentifier([]int{1, 3, 6, 1, 4, 1, 41482, 3, 3}) 35 | extIDSerialNumber = asn1.ObjectIdentifier([]int{1, 3, 6, 1, 4, 1, 41482, 3, 7}) 36 | extIDKeyPolicy = asn1.ObjectIdentifier([]int{1, 3, 6, 1, 4, 1, 41482, 3, 8}) 37 | extIDFormFactor = asn1.ObjectIdentifier([]int{1, 3, 6, 1, 4, 1, 41482, 3, 9}) 38 | ) 39 | 40 | type Slot string 41 | 42 | // Slot represents the YubiKey card slot that is covered by the attestation. 43 | // $ yubico-piv-tool -h 44 | // 9a is for PIV Authentication 45 | // 9c is for Digital Signature (PIN always checked) 46 | // 9d is for Key Management 47 | // 9e is for Card Authentication (PIN never checked) 48 | // 82-95 is for Retired Key Management 49 | // f9 is for Attestation 50 | const ( 51 | SlotAuthenticate = Slot("9a") 52 | SlotSignature = Slot("9c") 53 | SlotKeyManagement = Slot("9d") 54 | SlotKeyCardAuth = Slot("9e") 55 | ) 56 | 57 | // Version encodes a major, minor, and patch version. 58 | type Version struct { 59 | Major int 60 | Minor int 61 | Patch int 62 | } 63 | 64 | // Formfactor enumerates the physical set of forms a key can take. USB-A vs. 65 | // USB-C and Keychain vs. Nano. 66 | type Formfactor int 67 | 68 | // Formfactors recognized by this package. 69 | const ( 70 | FormfactorUnspecified Formfactor = iota 71 | FormfactorUSBAKeychain 72 | FormfactorUSBANano 73 | FormfactorUSBCKeychain 74 | FormfactorUSBCNano 75 | FormfactorUSBCLightningKeychain 76 | ) 77 | 78 | var formfactorNames = []string{ 79 | FormfactorUnspecified: "Unspecified", 80 | FormfactorUSBAKeychain: "USB-A Keychain", 81 | FormfactorUSBANano: "USB-A Nano", 82 | FormfactorUSBCKeychain: "USB-C Keychain", 83 | FormfactorUSBCNano: "USB-C Nano", 84 | FormfactorUSBCLightningKeychain: "USB-C + Lightning Keychain", 85 | } 86 | 87 | func (f Formfactor) String() string { 88 | return formfactorNames[f] 89 | } 90 | 91 | // MarshalJSON encodes value into String(). 92 | func (f Formfactor) MarshalJSON() ([]byte, error) { 93 | return json.Marshal(f.String()) 94 | } 95 | 96 | // PINPolicy represents PIN requirements when signing or decrypting with an 97 | // asymmetric key in a given slot. 98 | type PINPolicy int 99 | 100 | // PIN policies supported by this package. 101 | // 102 | // BUG(ericchiang): Caching for PINPolicyOnce isn't supported on YubiKey 103 | // versions older than 4.3.0 due to issues with verifying if a PIN is needed. 104 | // If specified, a PIN will be required for every operation. 105 | const ( 106 | PINPolicyNever PINPolicy = iota + 1 107 | PINPolicyOnce 108 | PINPolicyAlways 109 | ) 110 | 111 | var pinpolicyNames = []string{ 112 | PINPolicyNever: "Never", 113 | PINPolicyOnce: "Once", 114 | PINPolicyAlways: "Always", 115 | } 116 | 117 | func (p PINPolicy) String() string { 118 | return pinpolicyNames[p] 119 | } 120 | 121 | // MarshalJSON encodes value into String(). 122 | func (p PINPolicy) MarshalJSON() ([]byte, error) { 123 | return json.Marshal(p.String()) 124 | } 125 | 126 | // TouchPolicy represents proof-of-presence requirements when signing or 127 | // decrypting with asymmetric key in a given slot. 128 | type TouchPolicy int 129 | 130 | // Touch policies supported by this package. 131 | const ( 132 | TouchPolicyNever TouchPolicy = iota + 1 133 | TouchPolicyAlways 134 | TouchPolicyCached 135 | ) 136 | 137 | var touchPolicyNames = []string{ 138 | TouchPolicyNever: "Never", 139 | TouchPolicyAlways: "Always", 140 | TouchPolicyCached: "Cached", 141 | } 142 | 143 | func (t TouchPolicy) String() string { 144 | return touchPolicyNames[t] 145 | } 146 | 147 | // MarshalJSON encodes value into String(). 148 | func (t TouchPolicy) MarshalJSON() ([]byte, error) { 149 | return json.Marshal(t.String()) 150 | } 151 | 152 | // Attestation returns additional information about a key attested to be on a 153 | // card. 154 | type Attestation struct { 155 | // Slot is the key slot 156 | Slot Slot 157 | 158 | // Version of the YubiKey's firmware. 159 | Version Version 160 | 161 | // Serial is the YubiKey's serial number. 162 | Serial uint32 163 | 164 | // Formfactor indicates the physical type of the YubiKey. 165 | // 166 | // Formfactor may be empty Formfactor(0) for some YubiKeys. 167 | Formfactor Formfactor 168 | 169 | // PINPolicy set on the slot. 170 | PINPolicy PINPolicy 171 | 172 | // TouchPolicy set on the slot. 173 | TouchPolicy TouchPolicy 174 | } 175 | 176 | // ParseAttestation parses a YubiKey PIV attestation certificate and returns 177 | // an Attestation. 178 | func ParseAttestation(attestCert *x509.Certificate) (*Attestation, error) { 179 | var a Attestation 180 | for _, ext := range attestCert.Extensions { 181 | if err := a.addExt(ext); err != nil { 182 | return nil, fmt.Errorf("parsing extension: %v", err) 183 | } 184 | } 185 | slot, err := parseSlot(attestCert.Subject.CommonName) 186 | if err != nil { 187 | return nil, fmt.Errorf("parsing slot: %v", err) 188 | } 189 | a.Slot = slot 190 | return &a, nil 191 | } 192 | 193 | func (a *Attestation) addExt(e pkix.Extension) error { 194 | switch { 195 | case e.Id.Equal(extIDFirmwareVersion): 196 | // XXX(joe): unlike OPGP attestation certs that encode the version as 3 byte ASN.1 Octet-String 197 | // the version in PIV attestations appears to lack any ASN.1 tag information, so we 198 | // take the 3 bytes directly without any asn1.Unmarshal(): 199 | // var version []byte 200 | // if _, err := asn1.Unmarshal(e.Value, &version); err != nil { 201 | // return fmt.Errorf("parsing version: %v", err) 202 | // } 203 | version := e.Value 204 | if len(version) != 3 { 205 | return fmt.Errorf("expected 3 bytes for firmware version, got: %d", len(version)) 206 | } 207 | a.Version = Version{ 208 | Major: int(version[0]), 209 | Minor: int(version[1]), 210 | Patch: int(version[2]), 211 | } 212 | 213 | case e.Id.Equal(extIDSerialNumber): 214 | var serial int64 215 | if _, err := asn1.Unmarshal(e.Value, &serial); err != nil { 216 | return fmt.Errorf("parsing serial number: %v", err) 217 | } 218 | if serial < 0 { 219 | return fmt.Errorf("serial number was negative: %d", serial) 220 | } 221 | a.Serial = uint32(serial) 222 | 223 | case e.Id.Equal(extIDKeyPolicy): 224 | // XXX(joe): keypolicy is encoded directly as 2 bytes. It is not marshaled into a specific ASN.1 225 | // type, so we take the 2 bytes directly. 226 | // var keypolicy []byte 227 | // if _, err := asn1.Unmarshal(e.Value, &keypolicy); err != nil { 228 | // return fmt.Errorf("parsing keypolicy: %v", err) 229 | // } 230 | keypolicy := e.Value 231 | if len(keypolicy) != 2 { 232 | return fmt.Errorf("expected 2 bytes from key policy, got: %d", len(keypolicy)) 233 | } 234 | switch keypolicy[0] { 235 | case 0x01: 236 | a.PINPolicy = PINPolicyNever 237 | case 0x02: 238 | a.PINPolicy = PINPolicyOnce 239 | case 0x03: 240 | a.PINPolicy = PINPolicyAlways 241 | default: 242 | return fmt.Errorf("unrecognized pin policy: 0x%x", keypolicy[0]) 243 | } 244 | switch keypolicy[1] { 245 | case 0x01: 246 | a.TouchPolicy = TouchPolicyNever 247 | case 0x02: 248 | a.TouchPolicy = TouchPolicyAlways 249 | case 0x03: 250 | a.TouchPolicy = TouchPolicyCached 251 | default: 252 | return fmt.Errorf("unrecognized touch policy: 0x%x", keypolicy[1]) 253 | } 254 | 255 | case e.Id.Equal(extIDFormFactor): 256 | // XXX(joe): formfactor is encoded directly as 1 byte. It is not marshaled into an ASN.1 type 257 | var formfactor []byte 258 | // if _, err := asn1.Unmarshal(e.Value, &formfactor); err != nil { 259 | // return fmt.Errorf("parsing formfactor: %v", err) 260 | // } 261 | formfactor = e.Value 262 | if len(formfactor) != 1 { 263 | return fmt.Errorf("expected 1 byte from formfactor, got: %d", len(formfactor)) 264 | } 265 | switch formfactor[0] { 266 | case 0x01: 267 | a.Formfactor = FormfactorUSBAKeychain 268 | case 0x02: 269 | a.Formfactor = FormfactorUSBANano 270 | case 0x03: 271 | a.Formfactor = FormfactorUSBCKeychain 272 | case 0x04: 273 | a.Formfactor = FormfactorUSBCNano 274 | case 0x05: 275 | a.Formfactor = FormfactorUSBCLightningKeychain 276 | default: 277 | return fmt.Errorf("unrecognized formfactor: 0x%x", formfactor[0]) 278 | } 279 | } 280 | return nil 281 | } 282 | 283 | // parseSlot parses the common-name from the attestation cert's subject. The format 284 | // is described in: https://developers.yubico.com/PIV/Introduction/PIV_attestation.html 285 | // Subject will be the string "YubiKey PIV Attestation " with the 286 | // attested slot appended. 287 | func parseSlot(subject string) (Slot, error) { 288 | if len(subject) < 2 { 289 | return Slot(""), fmt.Errorf("subject less than 2 chars, unable to determine slot") 290 | } 291 | slot := subject[len(subject)-2:] 292 | 293 | switch slot { 294 | case "9a": 295 | return SlotAuthenticate, nil 296 | case "9c": 297 | return SlotSignature, nil 298 | case "9d": 299 | return SlotKeyManagement, nil 300 | case "9e": 301 | return SlotKeyCardAuth, nil 302 | } 303 | return Slot(""), fmt.Errorf("Unknown slot '%v'", slot) 304 | } 305 | -------------------------------------------------------------------------------- /pkg/piv/attestation_test.go: -------------------------------------------------------------------------------- 1 | package piv 2 | 3 | import ( 4 | "crypto/x509" 5 | "crypto/x509/pkix" 6 | "encoding/asn1" 7 | "fmt" 8 | "testing" 9 | "time" 10 | 11 | "github.com/joemiller/certin" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestParseAttestation(t *testing.T) { 17 | root := fakeYubiRoot(t) 18 | signer := fakeAttestSigner(t, root) 19 | 20 | cfg := pivAttestation{ 21 | version: []byte{4, 3, 1}, 22 | slot: SlotAuthenticate, 23 | serial: 1234, 24 | formfactor: FormfactorUSBCKeychain, 25 | pinpolicy: PINPolicyAlways, 26 | touchpolicy: TouchPolicyAlways, 27 | } 28 | attestationCert := makePIVAttestationCert(t, signer, cfg) 29 | parsedAttestation, err := ParseAttestation(attestationCert) 30 | assert.NoError(t, err) 31 | assert.Equal(t, SlotAuthenticate, parsedAttestation.Slot) 32 | assert.Equal(t, uint32(1234), parsedAttestation.Serial) 33 | assert.Equal(t, FormfactorUSBCKeychain, parsedAttestation.Formfactor) 34 | assert.Equal(t, TouchPolicyAlways, parsedAttestation.TouchPolicy) 35 | 36 | expectedVersion := Version{4, 3, 1} 37 | assert.Equal(t, expectedVersion, parsedAttestation.Version) 38 | } 39 | 40 | type pivAttestation struct { 41 | version []byte 42 | slot Slot 43 | serial uint32 44 | formfactor Formfactor 45 | pinpolicy PINPolicy 46 | touchpolicy TouchPolicy 47 | } 48 | 49 | // Create an attestation cert and sign it with the attestation signer key. 50 | // Sane defaults will be used for nil or zero values where possible so that callers 51 | // can supply values for only things they're interested in testing. 52 | func makePIVAttestationCert(t *testing.T, signer *certin.KeyAndCert, cfg pivAttestation) *x509.Certificate { 53 | if cfg.slot == "" { 54 | cfg.slot = SlotSignature // default slot is "SIG" if not specified 55 | } 56 | 57 | if cfg.version == nil { 58 | cfg.version = []byte{4, 3, 0} // default version if not specified 59 | } 60 | // version, err := asn1.Marshal(cfg.version) 61 | // require.Nil(t, err) 62 | 63 | serial, err := asn1.Marshal(int64(cfg.serial)) 64 | require.Nil(t, err) 65 | 66 | if cfg.formfactor == 0 { 67 | cfg.formfactor = FormfactorUSBAKeychain // default if not specified 68 | } 69 | // formfactor, err := asn1.Marshal([]byte{byte(cfg.formfactor)}) 70 | // require.Nil(t, err) 71 | formfactor := []byte{byte(cfg.formfactor)} 72 | 73 | if cfg.pinpolicy == 0 { 74 | cfg.pinpolicy = PINPolicyNever 75 | } 76 | if cfg.touchpolicy == 0 { 77 | cfg.touchpolicy = TouchPolicyNever 78 | } 79 | // keypolicy, err := asn1.Marshal([]byte{ 80 | // byte(cfg.pinpolicy), 81 | // byte(cfg.touchpolicy), 82 | // }) 83 | // require.Nil(t, err) 84 | keypolicy := []byte{byte(cfg.pinpolicy), byte(cfg.touchpolicy)} 85 | 86 | attestationCertTemplate := &x509.Certificate{ 87 | Subject: pkix.Name{ 88 | CommonName: fmt.Sprintf("YubiKey PIV Attestation %s", cfg.slot), 89 | }, 90 | NotBefore: time.Now().Add(-30 * time.Second), 91 | NotAfter: time.Now().Add(24 * time.Hour), 92 | ExtraExtensions: []pkix.Extension{ 93 | {Id: extIDFirmwareVersion, Critical: false, Value: cfg.version}, //version}, 94 | {Id: extIDSerialNumber, Critical: false, Value: serial}, 95 | {Id: extIDKeyPolicy, Critical: false, Value: keypolicy}, 96 | {Id: extIDFormFactor, Critical: false, Value: formfactor}, 97 | }, 98 | } 99 | attestation, err := certin.NewCertFromX509Template(signer, "rsa-2048", attestationCertTemplate) 100 | if err != nil { 101 | t.Fatal(err) 102 | } 103 | return attestation.Certificate 104 | } 105 | 106 | func fakeYubiRoot(t *testing.T) *certin.KeyAndCert { 107 | root, err := certin.NewCert(nil, certin.Request{CN: "Yubico PIV Root CA Serial 263751"}) 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | return root 112 | } 113 | 114 | func fakeAttestSigner(t *testing.T, root *certin.KeyAndCert) *certin.KeyAndCert { 115 | signer, err := certin.NewCert(root, certin.Request{CN: "Yubikey PIV Attestation"}) 116 | if err != nil { 117 | t.Fatal(err) 118 | } 119 | return signer 120 | } 121 | -------------------------------------------------------------------------------- /pkg/piv/verify.go: -------------------------------------------------------------------------------- 1 | // Portions of code from https://github.com/go-piv/piv-go/blob/e6548dd11f020eb8a3922086893dee86537b47ce/piv/key.go are 2 | // reproduced here and modified. Motivations for this are: 3 | // - avoid having to link against platform native smartcard libraries such as pcsc 4 | // which is not needed for attestation certificate parsing. 5 | // - Conform to the interface desired by the yk-attest-verify application. 6 | // The copyright notice is included below: 7 | 8 | // Copyright 2020 Google LLC 9 | // Modifications 2020 Joe Miller 10 | // 11 | // Licensed under the Apache License, Version 2.0 (the "License"); 12 | // you may not use this file except in compliance with the License. 13 | // You may obtain a copy of the License at 14 | // 15 | // https://www.apache.org/licenses/LICENSE-2.0 16 | // 17 | // Unless required by applicable law or agreed to in writing, software 18 | // distributed under the License is distributed on an "AS IS" BASIS, 19 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | // See the License for the specific language governing permissions and 21 | // limitations under the License. 22 | 23 | package piv 24 | 25 | import ( 26 | "crypto/x509" 27 | "encoding/pem" 28 | "fmt" 29 | "strings" 30 | ) 31 | 32 | // Policy represents a set of allowed contents of a YubiKey PIV attestation certificate. 33 | type Policy struct { 34 | AllowedSlots []Slot 35 | AllowedPINPolicies []PINPolicy 36 | AllowedTouchPolicies []TouchPolicy 37 | } 38 | 39 | // VerificationRequest contains a Yubikey Attestation certificate signed by a 40 | // attestation signer key. 41 | // 42 | // Attestation (AttestCert) certs can be generated with the `yubico-piv-tool` utility 43 | // 44 | // # generate an attestation cert against the 9a slot: 45 | // yubico-piv-tool --action=attest --slot=9a >piv-attest.pem 46 | // 47 | // # export the signer cert: 48 | // yubico-piv-tool --action=read-certificate --slot=f9 >piv-attestation-signer.pem 49 | // 50 | type VerificationRequest struct { 51 | AttestCert *x509.Certificate 52 | AttestSignerCert *x509.Certificate 53 | Policy Policy 54 | } 55 | 56 | // VerificationErrors holds errors representing policy violations from a verification request. 57 | type VerificationErrors []error 58 | 59 | // Error implements the error interface for VerificationErrors and returns a 60 | // summary of the error messages. To inspect the list of errors individually you 61 | // would cast the err to VerificationError and inspect the list. 62 | // errs := err.(VerificationErrors) 63 | func (ve VerificationErrors) Error() string { 64 | if len(ve) == 0 { 65 | return "" 66 | } 67 | 68 | s := []string{} 69 | for _, e := range ve { 70 | s = append(s, e.Error()) 71 | } 72 | return strings.Join(s, "\n") 73 | } 74 | 75 | // VerifyAttestation verifies the signature chain of an attestation cert and evaluates 76 | // the attributes in the attestation against a list of policies. If the cert chain 77 | // is valid and all policy rules apply nil is returned. Otherwise an error that 78 | // may be cast to .(VerificationErrors) will be returned. This accumulator contains 79 | // a slice of one or more errors representing policy violations. 80 | func VerifyAttestation(req VerificationRequest) (*Attestation, error) { 81 | var v verifier 82 | return v.verify(req) 83 | } 84 | 85 | type verifier struct { 86 | Root *x509.Certificate 87 | } 88 | 89 | func (v *verifier) verify(req VerificationRequest) (*Attestation, error) { 90 | var errs VerificationErrors 91 | 92 | root := v.Root 93 | if root == nil { 94 | ca, err := yubicoCA() 95 | if err != nil { 96 | errs = append(errs, fmt.Errorf("parsing YubiCo Root CA: %v", err)) 97 | return nil, errs 98 | } 99 | root = ca 100 | } 101 | 102 | // Verify signatures: 103 | // The Attestation Signer Cert from the yubikey must be signed by YubiCo's attestation root 104 | if err := verifySignature(root, req.AttestSignerCert); err != nil { 105 | errs = append(errs, fmt.Errorf("attestation signer certificate is not signed by YubiCo PIV Root CA: %v", err)) 106 | } 107 | // The Attestation Cert must be signed by the Attestation Signer Cert 108 | if err := verifySignature(req.AttestSignerCert, req.AttestCert); err != nil { 109 | errs = append(errs, fmt.Errorf("attestation certificate not signed by device's attestation signer key: %v", err)) 110 | } 111 | 112 | attestation, err := ParseAttestation(req.AttestCert) 113 | if err != nil { 114 | errs = append(errs, fmt.Errorf("Unable to parse attestation cert: %v", err)) 115 | return nil, errs 116 | } 117 | 118 | // Verify the attestation specifies an allowed TouchPolicy. 119 | if len(req.Policy.AllowedTouchPolicies) > 0 { 120 | found := false 121 | for _, pol := range req.Policy.AllowedTouchPolicies { 122 | if attestation.TouchPolicy == pol { 123 | found = true 124 | break 125 | } 126 | } 127 | if !found { 128 | errs = append(errs, fmt.Errorf("Touch Policy '%v' is not allowed", attestation.TouchPolicy)) 129 | } 130 | } 131 | 132 | if len(req.Policy.AllowedPINPolicies) > 0 { 133 | found := false 134 | for _, pol := range req.Policy.AllowedPINPolicies { 135 | if attestation.PINPolicy == pol { 136 | found = true 137 | break 138 | } 139 | } 140 | if !found { 141 | errs = append(errs, fmt.Errorf("PIN Policy '%v' is not allowed", attestation.PINPolicy)) 142 | } 143 | } 144 | 145 | if len(req.Policy.AllowedSlots) > 0 { 146 | found := false 147 | for _, slot := range req.Policy.AllowedSlots { 148 | if attestation.Slot == slot { 149 | found = true 150 | break 151 | } 152 | } 153 | if !found { 154 | errs = append(errs, fmt.Errorf("Slot '%v' not allowed by policy", attestation.Slot)) 155 | } 156 | } 157 | 158 | // check if errs is empty and if so return nil explicitly, otherwise 159 | // errs will always be != nil 160 | if len(errs) == 0 { 161 | return attestation, nil 162 | } 163 | return attestation, errs 164 | } 165 | 166 | func verifySignature(parent, c *x509.Certificate) error { 167 | return parent.CheckSignature(c.SignatureAlgorithm, c.RawTBSCertificate, c.Signature) 168 | } 169 | 170 | // yubicoPIVCAPEM is the PEM encoded attestation certificate used by Yubico for PIV keys. 171 | // 172 | // https://developers.yubico.com/PIV/Introduction/PIV_attestation.html 173 | // https://developers.yubico.com/PIV/Introduction/piv-attestation-ca.pem 174 | const yubicoPIVCAPEM = `-----BEGIN CERTIFICATE----- 175 | MIIDFzCCAf+gAwIBAgIDBAZHMA0GCSqGSIb3DQEBCwUAMCsxKTAnBgNVBAMMIFl1 176 | YmljbyBQSVYgUm9vdCBDQSBTZXJpYWwgMjYzNzUxMCAXDTE2MDMxNDAwMDAwMFoY 177 | DzIwNTIwNDE3MDAwMDAwWjArMSkwJwYDVQQDDCBZdWJpY28gUElWIFJvb3QgQ0Eg 178 | U2VyaWFsIDI2Mzc1MTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMN2 179 | cMTNR6YCdcTFRxuPy31PabRn5m6pJ+nSE0HRWpoaM8fc8wHC+Tmb98jmNvhWNE2E 180 | ilU85uYKfEFP9d6Q2GmytqBnxZsAa3KqZiCCx2LwQ4iYEOb1llgotVr/whEpdVOq 181 | joU0P5e1j1y7OfwOvky/+AXIN/9Xp0VFlYRk2tQ9GcdYKDmqU+db9iKwpAzid4oH 182 | BVLIhmD3pvkWaRA2H3DA9t7H/HNq5v3OiO1jyLZeKqZoMbPObrxqDg+9fOdShzgf 183 | wCqgT3XVmTeiwvBSTctyi9mHQfYd2DwkaqxRnLbNVyK9zl+DzjSGp9IhVPiVtGet 184 | X02dxhQnGS7K6BO0Qe8CAwEAAaNCMEAwHQYDVR0OBBYEFMpfyvLEojGc6SJf8ez0 185 | 1d8Cv4O/MA8GA1UdEwQIMAYBAf8CAQEwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3 186 | DQEBCwUAA4IBAQBc7Ih8Bc1fkC+FyN1fhjWioBCMr3vjneh7MLbA6kSoyWF70N3s 187 | XhbXvT4eRh0hvxqvMZNjPU/VlRn6gLVtoEikDLrYFXN6Hh6Wmyy1GTnspnOvMvz2 188 | lLKuym9KYdYLDgnj3BeAvzIhVzzYSeU77/Cupofj093OuAswW0jYvXsGTyix6B3d 189 | bW5yWvyS9zNXaqGaUmP3U9/b6DlHdDogMLu3VLpBB9bm5bjaKWWJYgWltCVgUbFq 190 | Fqyi4+JE014cSgR57Jcu3dZiehB6UtAPgad9L5cNvua/IWRmm+ANy3O2LH++Pyl8 191 | SREzU8onbBsjMg9QDiSf5oJLKvd/Ren+zGY7 192 | -----END CERTIFICATE-----` 193 | 194 | func yubicoCA() (*x509.Certificate, error) { 195 | b, _ := pem.Decode([]byte(yubicoPIVCAPEM)) 196 | if b == nil { 197 | return nil, fmt.Errorf("failed to decode yubico pem data") 198 | } 199 | return x509.ParseCertificate(b.Bytes) 200 | } 201 | -------------------------------------------------------------------------------- /pkg/piv/verify_test.go: -------------------------------------------------------------------------------- 1 | package piv 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | /* test cases: 11 | 12 | # test the certificate chains: 13 | x 1. test attestation cert not signed by the correct attestation signer 14 | x 2. test the attestation signer is not signed by the yubico root 15 | 16 | # test policy assertions: 17 | 1. touch policy (none, one, multiple) 18 | 2. keysources policy (generated, imported) 19 | 3. allowed slots (aut, sign, enc) 20 | 4. allowed card holder (none, one, multiple) 21 | */ 22 | 23 | // TestVerify_NotSignedByYubiCoRoot: 24 | // TestVerify_NotSignedByOnDeviceAttestationKey: 25 | // 26 | // test the certificate chains. The signature chain of a yubikey attestation cert 27 | // looks like: 28 | // yubico root -> 29 | // YubiKey PIV Attestation -> (this key+crt is stored on the yubikey) 30 | // Attestation Cert (an attestation covering the key in one of the 31 | // slots on the yubikey) 32 | // 33 | func TestVerify_NotSignedByYubiCoRoot(t *testing.T) { 34 | rootA := fakeYubiRoot(t) 35 | rootB := fakeYubiRoot(t) 36 | signer := fakeAttestSigner(t, rootB) 37 | v := verifier{Root: rootA.Certificate} 38 | 39 | attestationCert := makePIVAttestationCert(t, signer, pivAttestation{}) 40 | req := VerificationRequest{ 41 | AttestCert: attestationCert, 42 | AttestSignerCert: signer.Certificate, 43 | } 44 | 45 | _, err := v.verify(req) 46 | assert.NotNil(t, err) 47 | 48 | verifyErrs := err.(VerificationErrors) 49 | assert.Contains(t, verifyErrs, errors.New("attestation signer certificate is not signed by YubiCo PIV Root CA: crypto/rsa: verification error")) 50 | } 51 | 52 | func TestVerify_NotSignedByOnDeviceAttestationKey(t *testing.T) { 53 | root := fakeYubiRoot(t) 54 | signerA := fakeAttestSigner(t, root) 55 | signerB := fakeAttestSigner(t, root) 56 | v := verifier{Root: root.Certificate} 57 | 58 | attestationCert := makePIVAttestationCert(t, signerB, pivAttestation{}) 59 | req := VerificationRequest{ 60 | AttestCert: attestationCert, 61 | AttestSignerCert: signerA.Certificate, 62 | } 63 | 64 | _, err := v.verify(req) 65 | assert.NotNil(t, err) 66 | 67 | verifyErrs := err.(VerificationErrors) 68 | assert.Contains(t, verifyErrs, errors.New("attestation certificate not signed by device's attestation signer key: crypto/rsa: verification error")) 69 | } 70 | 71 | func TestVerify_Policies(t *testing.T) { 72 | root := fakeYubiRoot(t) 73 | signer := fakeAttestSigner(t, root) 74 | v := verifier{Root: root.Certificate} 75 | 76 | // NOTE: all of these tests assume the signature chain is valid 77 | tests := []struct { 78 | name string 79 | attestContents pivAttestation 80 | policy Policy 81 | expectedErrs *VerificationErrors 82 | }{ 83 | { 84 | name: "empty policies passes policy test", 85 | attestContents: pivAttestation{}, 86 | policy: Policy{}, 87 | expectedErrs: nil, 88 | }, 89 | { 90 | name: "allowed touch policy passes policy test", 91 | attestContents: pivAttestation{ 92 | touchpolicy: TouchPolicyAlways, 93 | }, 94 | policy: Policy{ 95 | AllowedTouchPolicies: []TouchPolicy{TouchPolicyAlways}, 96 | }, 97 | expectedErrs: nil, 98 | }, 99 | { 100 | name: "dis-allowed touch policy fails policy test", 101 | attestContents: pivAttestation{ 102 | touchpolicy: TouchPolicyNever, 103 | }, 104 | policy: Policy{ 105 | AllowedTouchPolicies: []TouchPolicy{TouchPolicyAlways}, 106 | }, 107 | expectedErrs: &VerificationErrors{errors.New("Touch Policy 'Never' is not allowed")}, 108 | }, 109 | { 110 | name: "allowed key slots passes policy test", 111 | attestContents: pivAttestation{ 112 | slot: SlotSignature, 113 | }, 114 | policy: Policy{ 115 | AllowedSlots: []Slot{SlotSignature}, 116 | }, 117 | expectedErrs: nil, 118 | }, 119 | { 120 | name: "disallowed key slots fails policy test", 121 | attestContents: pivAttestation{ 122 | slot: SlotSignature, 123 | }, 124 | policy: Policy{ 125 | AllowedSlots: []Slot{SlotAuthenticate}, 126 | }, 127 | expectedErrs: &VerificationErrors{errors.New("Slot '9c' not allowed by policy")}, 128 | }, 129 | } 130 | 131 | for _, tc := range tests { 132 | t.Run(tc.name, func(t *testing.T) { 133 | attestationCert := makePIVAttestationCert(t, signer, tc.attestContents) 134 | 135 | req := VerificationRequest{ 136 | AttestCert: attestationCert, 137 | AttestSignerCert: signer.Certificate, 138 | Policy: tc.policy, 139 | } 140 | _, err := v.verify(req) 141 | // spew.Dump(err) 142 | 143 | if tc.expectedErrs == nil { 144 | assert.Nil(t, err) 145 | } else { 146 | verifyErrs := err.(VerificationErrors) 147 | assert.Equal(t, *tc.expectedErrs, verifyErrs) 148 | } 149 | }) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /pkg/pubkeys/compare.go: -------------------------------------------------------------------------------- 1 | // +build go1.15 2 | 3 | package pubkeys 4 | 5 | import ( 6 | "crypto" 7 | "crypto/ecdsa" 8 | "crypto/ed25519" 9 | "crypto/rsa" 10 | 11 | "golang.org/x/crypto/ssh" 12 | ) 13 | 14 | // Compare compares the contents of an ssh.PublicKey and a crypto.PublicKey 15 | // and returns true if they are identical. 16 | func Compare(sshkey ssh.PublicKey, certkey crypto.PublicKey) bool { 17 | // upgrade sshpub to ssh.CryptoPublicKey so we can access the underlying crypto.PublicKey 18 | sshpub := sshkey.(ssh.CryptoPublicKey) 19 | 20 | // fmt.Printf("key type %T %T", sshkey, certkey) 21 | switch k := sshpub.CryptoPublicKey().(type) { 22 | case *rsa.PublicKey: 23 | return k.Equal(certkey) 24 | case *ecdsa.PublicKey: 25 | return k.Equal(certkey) 26 | case ed25519.PublicKey: 27 | return k.Equal(certkey) 28 | } 29 | // unknown key type 30 | // fmt.Printf("unknown key type %T %T", sshkey, certkey) 31 | return false 32 | } 33 | -------------------------------------------------------------------------------- /pkg/pubkeys/compare_pre_go115.go: -------------------------------------------------------------------------------- 1 | // +build !go1.15 2 | 3 | // backport from publickey Equal() funcs introduced in go1.15: 4 | // https://go-review.googlesource.com/c/go/+/225460/ 5 | 6 | package pubkeys 7 | 8 | import ( 9 | "bytes" 10 | "crypto" 11 | "crypto/ecdsa" 12 | "crypto/ed25519" 13 | "crypto/rsa" 14 | 15 | "golang.org/x/crypto/ssh" 16 | ) 17 | 18 | func Compare(sshkey ssh.PublicKey, certkey crypto.PublicKey) bool { 19 | // upgrade sshpub to ssh.CryptoPublicKey so we can access the underlying crypto.PublicKey 20 | sshpub := sshkey.(ssh.CryptoPublicKey) 21 | 22 | switch sshk := sshpub.CryptoPublicKey().(type) { 23 | case *rsa.PublicKey: 24 | certk, ok := certkey.(*rsa.PublicKey) 25 | if !ok { 26 | return false 27 | } 28 | return sshk.N.Cmp(certk.N) == 0 && sshk.E == certk.E 29 | 30 | case *ecdsa.PublicKey: 31 | certk, ok := certkey.(*ecdsa.PublicKey) 32 | if !ok { 33 | return false 34 | } 35 | return sshk.X.Cmp(certk.X) == 0 && sshk.Y.Cmp(certk.Y) == 0 && sshk.Curve == certk.Curve 36 | 37 | case ed25519.PublicKey: 38 | certk, ok := certkey.(ed25519.PublicKey) 39 | if !ok { 40 | return false 41 | } 42 | return bytes.Equal(sshk, certk) 43 | 44 | } 45 | return false 46 | } 47 | -------------------------------------------------------------------------------- /pkg/pubkeys/compare_test.go: -------------------------------------------------------------------------------- 1 | package pubkeys_test 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/ed25519" 7 | "crypto/rsa" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/joemiller/certin" 12 | "github.com/joemiller/yk-attest-verify/pkg/pubkeys" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | "golang.org/x/crypto/ssh" 16 | ) 17 | 18 | func TestCompare(t *testing.T) { 19 | 20 | tests := []string{ 21 | "rsa-2048", "rsa-3072", "rsa-4096", 22 | "ecdsa-256", "ecdsa-384", "ecdsa-521", 23 | "ed25519", 24 | } 25 | 26 | for _, tc := range tests { 27 | t.Run(tc, func(t *testing.T) { 28 | keyA, err := certin.GenerateKey(tc) 29 | require.NoError(t, err) 30 | keyB, err := certin.GenerateKey(tc) 31 | require.NoError(t, err) 32 | 33 | var certpubkeyA crypto.PublicKey 34 | var certpubkeyB crypto.PublicKey 35 | var sshpubkeyA ssh.PublicKey 36 | var sshpubkeyB ssh.PublicKey 37 | 38 | switch { 39 | case strings.Contains(tc, "rsa"): 40 | certpubkeyA = keyA.(*rsa.PrivateKey).Public() 41 | certpubkeyB = keyB.(*rsa.PrivateKey).Public() 42 | sshpubkeyA, err = ssh.NewPublicKey(certpubkeyA) 43 | require.NoError(t, err) 44 | sshpubkeyB, err = ssh.NewPublicKey(certpubkeyB) 45 | require.NoError(t, err) 46 | 47 | case strings.Contains(tc, "ec"): 48 | certpubkeyA = keyA.(*ecdsa.PrivateKey).Public() 49 | certpubkeyB = keyB.(*ecdsa.PrivateKey).Public() 50 | sshpubkeyA, err = ssh.NewPublicKey(certpubkeyA) 51 | require.NoError(t, err) 52 | sshpubkeyB, err = ssh.NewPublicKey(certpubkeyB) 53 | require.NoError(t, err) 54 | 55 | case strings.Contains(tc, "ed25519"): 56 | certpubkeyA = keyA.(ed25519.PrivateKey).Public() 57 | certpubkeyB = keyB.(ed25519.PrivateKey).Public() 58 | sshpubkeyA, err = ssh.NewPublicKey(certpubkeyA) 59 | require.NoError(t, err) 60 | sshpubkeyB, err = ssh.NewPublicKey(certpubkeyB) 61 | require.NoError(t, err) 62 | 63 | default: 64 | t.Fatalf("unknown keytype %v", tc) 65 | } 66 | 67 | assert.True(t, pubkeys.Compare(sshpubkeyA, certpubkeyA)) 68 | assert.False(t, pubkeys.Compare(sshpubkeyA, certpubkeyB)) 69 | 70 | assert.True(t, pubkeys.Compare(sshpubkeyB, certpubkeyB)) 71 | assert.False(t, pubkeys.Compare(sshpubkeyB, certpubkeyA)) 72 | }) 73 | } 74 | } 75 | --------------------------------------------------------------------------------