pax_global_header00006660000000000000000000000064147725427450014533gustar00rootroot0000000000000052 comment=2256ecd4fc449e705cf4bddd7e8e43cd43e70520 openpubkey-0.8.0/000077500000000000000000000000001477254274500137215ustar00rootroot00000000000000openpubkey-0.8.0/.gitattributes000066400000000000000000000001021477254274500166050ustar00rootroot00000000000000# Auto detect text files and perform LF normalization * text=auto openpubkey-0.8.0/.github/000077500000000000000000000000001477254274500152615ustar00rootroot00000000000000openpubkey-0.8.0/.github/release-drafter-config.yml000066400000000000000000000020411477254274500223110ustar00rootroot00000000000000name-template: "v$RESOLVED_VERSION" tag-template: "v$RESOLVED_VERSION" categories: - title: "🚀 Features" labels: - "feat" - "feature" - "enhancement" - title: "🐛 Bug Fixes" labels: - "fix" - "bugfix" - "bug" - title: "🧰 Maintenance" labels: - "chore" change-template: "- $TITLE @$AUTHOR (#$NUMBER)" change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. version-resolver: major: labels: - "major" minor: labels: - "minor" patch: labels: - "patch" default: minor template: | ## Changes $CHANGES autolabeler: - label: "chore" files: - "*.md" branch: - '/docs{0,1}\/.+/' - '/tests{0,1}\/.+/' title: - "/docs/i" - "/test/i" - label: "bug" branch: - '/fix\/.+/' - '/revert\/.+/' title: - "/fix/i" - "/revert/i" - label: "feature" branch: - '/feature\/.+/' - '/feat\/.+/' title: - "/feat/i" openpubkey-0.8.0/.github/workflows/000077500000000000000000000000001477254274500173165ustar00rootroot00000000000000openpubkey-0.8.0/.github/workflows/go.yml000066400000000000000000000014121477254274500204440ustar00rootroot00000000000000name: Go Checks on: pull_request: paths: - "**.go" - "go.mod" - "go.sum" jobs: golangci-linter: name: Run golangci linter runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: golangci-lint uses: golangci/golangci-lint-action@v6 with: version: v1.64.7 gotest: name: Run Tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: Download dependencies run: go mod download - name: Test run: go test ./...openpubkey-0.8.0/.github/workflows/release-drafter.yml000066400000000000000000000007511477254274500231110ustar00rootroot00000000000000name: Release Drafter on: push: branches: - main pull_request: types: [opened, reopened, synchronize] permissions: contents: read jobs: update_release_draft: permissions: contents: write pull-requests: write runs-on: ubuntu-latest steps: - uses: release-drafter/release-drafter@v6 with: config-name: release-drafter-config.yml publish: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} openpubkey-0.8.0/.github/workflows/staging.yml000066400000000000000000000011371477254274500214770ustar00rootroot00000000000000name: Go Checks on: push: branches: [ "main" ] permissions: contents: write pages: write jobs: codecov: name: Push to main test runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: Test run: go test ./... - name: Update coverage report uses: ncruces/go-coverage-report@v0 with: report: true chart: true amend: true if: github.event_name == 'push' continue-on-error: true openpubkey-0.8.0/.gitignore000066400000000000000000000011061477254274500157070ustar00rootroot00000000000000# If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) # vendor/ # Go workspace file go.work configs/ .vscode/ # For policy files that exist while testing auth_id opk-ssh opk-ssh-login *DS_Store openpubkey-0.8.0/.golangci.yml000066400000000000000000000004101477254274500163000ustar00rootroot00000000000000linters: enable: # golangci-lint defaults # ref: https://golangci-lint.run/usage/linters/#enabled-by-default - errcheck - gosimple - govet - ineffassign - staticcheck - unused # additional - misspell - gofmt fast: true run: timeout: 5mopenpubkey-0.8.0/CODE-OF-CONDUCT.md000066400000000000000000000002331477254274500163520ustar00rootroot00000000000000# OpenPubkey Code of Conduct Please see our [OpenPubkey Community Code of Conduct](https://github.com/openpubkey/community/blob/main/CODE-OF-CONDUCT.md). openpubkey-0.8.0/CONTRIBUTING.md000066400000000000000000000116661477254274500161640ustar00rootroot00000000000000# Contributing to OpenPubkey Welcome to OpenPubkey! We are so excited you are here. Thank you for your interest in contributing your time and expertise to the project. The following document details contribution guidelines. # Getting Started Whether you're addressing an open issue (or filing a new one), fixing a typo in our documentation, adding to core capabilities of the project, or introducing a new use case, anyone from the community is welcome here at OpenPubkey. ## Include Licensing at the Top of Each File At the top of each file in your commit, please ensure the following is captured in a comment: ` SPDX-License-Identifier: Apache-2.0 ` ## Sign Off on Your Commits Contributors are required to sign off on their commits. A sign off certifies that you wrote the associated change or have permission to submit it as an open-source patch. All submissions are bound by the [Developer's Certificate of Origin 1.1](https://developercertificate.org/) and [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ``` Developer's Certificate of Origin 1.1 By making a contribution to this project, I certify that: (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. (d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. ``` Your sign off can be added manually to your commit, i.e., `Signed-off-by: Jane Doe `. Then, you can create a signed off commit using the flag `-s` or `--signoff`: `$ git commit -s -m "This is my signed off commit."`. To verify that your commit was signed off, check your latest log output: ``` $ git log -1 commit Author: Jane Doe Date: Thurs Nov 9 06:14:13 2023 -0400 This is my signed off commit. Signed-off-by: Jane Doe ``` ## Pull Request (PR) Process OpenPubkey is managed from the `main` branch. To ensure your contribution is reviewed, all pull requests must be made against the `main` branch. PRs must include a brief summary of what the change is, any issues associated with the change, and any fixes the change addresses. Please include the relevant link(s) for any fixed issues. Pull requests do not have to pass all automated checks before being opened, but all checks must pass before merging. This can be useful if you need help figuring out why a required check is failing. Our automated PR checks verify that: 1. All unit tests pass, which can be done locally by running `go test ./...`. 2. The code has been formatted correctly, according to `go fmt`. 3. There are no obvious errors, according to `go vet`. ## Testing OpenPubkey Locally To build OpenPubkey, ensure you have Go version `>= 1.23` installed. To verify which version you have installed, try `go version`. To run the [Google example](https://github.com/openpubkey/openpubkey/tree/main/examples/google): 1. Navigate to the `examples/google/` directory. 2. Execute `go build` 3. Execute `./google login` to generate a valid PK token using Google as your OIDC provider. 4. Execute `./google sign` to use the PK token generated in (3) to sign a verifiable message. # Contributing Roles Contributors include anyone in the technical community who contributes code, documentation, or other technical artifacts to the OpenPubkey project. Committers are Contributors who have earned the ability to modify (“commit”) source code, documentation or other technical artifacts in a project’s repository. Note that Committers are still required to submit pull requests. A Contributor may become a Committer by a majority approval of the existing Committers. A Committer may be removed by a majority approval of the other existing Committers. # Current Committers The Committers of OpenPubkey are: 1. Ethan Heilman (@EthanHeilman) 2. Jonny Stoten (@jonnystoten) 3. Lucie Mugnier (@lgmugnier) # Copyright By contributing to this repository, you agree to license your work under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). Any work contributed where you are not the original author must display a license header with the original author(s) and source. openpubkey-0.8.0/LICENSE000066400000000000000000000260741477254274500147370ustar00rootroot00000000000000Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. openpubkey-0.8.0/README.md000066400000000000000000000277221477254274500152120ustar00rootroot00000000000000# OpenPubkey [![Go Coverage](https://github.com/openpubkey/openpubkey/wiki/coverage.svg)](https://raw.githack.com/wiki/openpubkey/openpubkey/coverage.html) ## Overview OpenPubkey is a protocol for leveraging OpenID Providers (OPs) to bind identities to public keys. It adds user- or workload-generated public keys to [OpenID Connect (OIDC)](https://openid.net/developers/how-connect-works/), enabling identities to sign messages or artifacts under their OIDC identity. We represent this binding as a PK Token. This token proves control of the OIDC identity and the associated private key at a specific time, as long as a verifier trusts the OP. Put another way, the PK Token provides the same assurances as a certificate issued by a Certificate Authority (CA) but critically, does not require adding a CA. Instead, the OP fulfills the role of the CA. This token can be distributed alongside signatures in the same way as a certificate. OpenPubkey does not add any new trusted parties beyond what is required for OpenID Connect. It is fully compatible with existing OpenID Providers (Google, Azure/Microsoft, Okta, OneLogin, Keycloak) without any changes to the OpenID Provider. Companies building on OpenPubkey include: * [Docker, Inc](https://www.docker.com/) is building a public container registry where [OpenPubkey is used to sign Docker Official Images](https://www.docker.com/blog/signing-docker-official-images-using-openpubkey/). * [BastionZero](https://www.bastionzero.com/) uses OpenPubkey to provide secure remote access to infrastructure. OpenPubkey is a Linux Foundation project. It is open source and licensed under the Apache 2.0 license. This project presently provides an OpenPubkey client and verifier for creating and verifying PK Tokens from Google’s OP (for users) and GitHub’s OP (for workloads). ## Getting Started Let's walk through a simple message signing example. For conciseness we omit the error handling code. The full code for this example can be found in [./examples/simple/example.go](./examples/simple/example.go). We start by configuring the OP (OpenID Provider) our client and verifier will use. In this example we use Google as our OP. ```golang opOptions := providers.GetDefaultGoogleOpOptions() opOptions.GQSign = signGQ op := providers.NewGoogleOpWithOptions(opOptions) ``` Next we create the OpenPubkey client and call `opkClient.Auth`: ```golang opkClient, err := client.New(op) pkt, err := opkClient.Auth(context.Background()) ``` The function `opkClient.Auth` opens a browser window to the OP, Google in this case, which then prompts the user to authenticate their identity. If the user authenticates successfully the client will generate and return a PK Token, `pkt`. The PK Token, `pkt`, along with the client's signing key can then be used to sign messages: ```golang msg := []byte("All is discovered - flee at once") signedMsg, err := pkt.NewSignedMessage(msg, opkClient.GetSigner()) ``` To verify a signed message, we first verify that the PK Token `pkt` is issued by the OP (Google). Then we use the PK Token to verify the signed message. ```golang pktVerifier, err := verifier.New(provider) err = pktVerifier.VerifyPKToken(context.Background(), pkt) msg, err := pkt.VerifySignedMessage(signedMsg) ``` To run this example type: `go run .\examples\simple\example.go`. This will open a browser window to Google. If you authenticate to Google successfully, you should see: `Verification successful: anon.author.aardvark@gmail.com (https://accounts.google.com) signed the message 'All is discovered - flee at once'` where `anon.author.aardvark@gmail.com` is your gmail address. ## How Does OpenPubkey Work? OpenPubkey supports both workload identities and user identities. Let's look at how this works for users and then show how to extend OpenPubkey to workloads. ### OpenPubkey and User Identities In OpenID Connect (OIDC) users authenticate to an OP (OpenID Provider), and the OP grants the user an ID Token. These ID Tokens are signed by the OP and contain claims made by the OP about the user such as the user's email address. Important to OpenPubkey is the `nonce` claim in the ID Token. The `nonce` claim in the ID Token is a random value sent to the OP by the user's client during authentication with the OP. OpenPubkey follows the OpenID Connect authentication protocol with the OP, but it transmits a `nonce` value set to the cryptographic hash of both the user's public key and a random value so that the `nonce` is still cryptographically random, but any party that speaks OpenPubkey can check that ID Token contains the user's public key. From the perspective of the OP, the `nonce` looks just like a random value. Let's look at an example where a user, Alice, leverages OpenPubkey to get her OpenID Provider, `google.com`, to bind her OIDC identity, `alice@acme.co`, to her public key `alice-pubkey`. To do this, Alice invokes her OpenPubkey client. 1. Alice's OpenPubkey client generates a fresh key pair for Alice, (`alice-pubkey`, `alice-signkey`), and a random value `rz`. The client then computes the `nonce=crypto.SHA3_256(upk=alice-pubkey, alg=ES256, rz=crypto.Rand())`. The value `alg` is set to the algorithm of Alice's key pair. 2. Alice's OpenPubkey client then initiates OIDC authentication flow with the OP, `google.com`, and sends the `nonce` to the OP. 3. The OP requests that Alice consents to issuing an ID Token and provides credentials (i.e., username and password) to authenticate to her OP (`Google`). 4. If Alice successfully authenticates, the OP builds an ID Token containing claims about Alice. Critically, this ID Token contains the `nonce` claim generated by Alice's client to commit to Alice's public key. The OP then signs this ID Token under its signing key and sends the ID Token to Alice. The ID Token is a JSON Web Signature (JWS) and follows the structure shown below: ``` payload: { "iss": "https://accounts.google.com", "aud": "878305696756-6maur39hl2psmk23imilg8af815ih9oi.apps.googleusercontent.com", "sub": "123456789010", "email": "alice@acme.co", "nonce": 'crypto.SHA3_256(upk=alice-pubkey, alg=ES256, rz=crypto.Rand(), typ="CIC")', "name": "Alice Example", ... } signatures: [ {"protected": {"alg": "RS256", "kid": "1234...", "typ": "JWT"}, "signature": SIGN(google-signkey, (payload, signatures[0].protected))` }, ] ``` At this point, Alice has an ID Token, signed by `google.com` (the OP). Anyone can download the OP's (`google.com`) public keys from `google.com`'s well-known JSON Web Key Set (JWKS) URI )[www.googleapis.com/oauth2/v3/cert](https://www.googleapis.com/oauth2/v3/cert)) and verify that this ID Token committing to Alice's public key was actually signed by `google.com`. If Alice reveals the values of `alice-pubkey`, `alg`, and `rz`, anyone can verify that the `nonce` in the ID Token is the hash of `upk=alice-pubkey, alg=ES256, rz=crypto.Rand()`. Thus, Alice now has a ID Token signed by Google that cryptography binding her identity, `alice@acme.co`, to her public key, `alice-pubkey`. ### PK Tokens A PK Token is simply an extension of the ID Token that bundles together the ID Token with values committed to in the ID Token `nonce`. Because ID Tokens are JSON Web Signatures (JWS) and a JWS can have more than one signature, we extend the ID Token into a PK Token by appending a second signature/protected header. Alice simply sets the values she committed to in the `nonce` as a JWS protected header and signs the ID Token payload and this protected header under her signing key, `alice-signkey`. This signature acts as cryptographic proof that the user knows the secret signing key corresponding to the public key. Notice the additional signature entry in the PK Token example below (as compared to the ID Token example above): ``` "payload": { "iss": "https://accounts.google.com", "aud": "878305696756-6maur39hl2psmk23imilg8af815ih9oi.apps.googleusercontent.com", "sub": "123456789010", "email": "alice@acme.co", "nonce": , "name": "Alice Example", ... } "signatures": [ {"protected": {"alg": "RS256", "kid": "1234...", "typ": "JWT"}, "signature": }, {"protected": {"upk": alice-pubkey, "alg": "EC256", "rz": crypto.Rand(), "typ": "CIC"}, "signature": }, ] ``` The PK Token can be presented to an OpenPubkey verifier, which uses OIDC to obtain the OP’s public key and verify the OP's signature in the ID Token. It then use the values in the protected header to extract the user's public key. ### OpenPubkey and Workload Identities Just like OpenID Connect, OpenPubkey supports both user identities and workload identities. The workload identity setting is very similar to the user identity setting with one major difference. Workload OpenID Providers, such as `github.com`, do not include a `nonce` claim in the ID Token. Unlike user identity providers, they allow the workload to specify an `aud`(audience) claim. Thus workload identity functions in a similar fashion as user identity but rather than commit to the public key in the `nonce`, we use the `aud` claim instead. ### GQ Signatures To Prevent Replay Attacks Although not present in the original [OpenPubkey paper](https://eprint.iacr.org/2023/296), GQ signatures have now been integrated so that the OpenID Provider's (OP) signature can be stripped from the ID Token and a proof of the OP's signature published in its place. This prevents the ID Token within the PK Token from being used against any OIDC resource providers as the original signature has been removed without compromising any of the assurances that the original OP's signature provided. We follow the approach specified in the following paper: [Reducing Trust in Automated Certificate Authorities via Proofs-of-Authentication](https://arxiv.org/abs/2307.08201). For user-identity scenarios where the PK Token is not made public, GQ signatures are not required. GQ Signatures are required for all current workload-identity use cases. ## How To Use OpenPubkey OpenPubkey is driven by its use cases. You can find all available use cases in the [examples folder](./examples/). We expect this list to continue growing (and if you have an idea for an additional use case, please [file an issue](#file-an-issue), raise the idea in a [community meeting](#get-involved-with-our-community), or send a message in our [Slack channel](#join-our-slack)! ## How To Develop With OpenPubkey As we work to get this repository ready for `v 1.0`, you can check out the [examples folder](./examples/) for more information about OpenPubkey's different use cases. In the meantime, we would love for the community to contribute more use cases. See [below](#get-involved-with-our-community) for guidance on joining our community. ## Governance and Contributing ### File An Issue For feature requests, bug reports, technical questions and requests, please open an issue. We ask that you review [existing issues](https://github.com/openpubkey/openpubkey/issues) before filing a new one to ensure your issue has not already been addressed. If you have found what you believe to be a security vulnerability, *DO NOT file an issue*. Instead, please follow our [security disclosure policy](./SECURITY.md). ### Code of Conduct Before contributing to OpenPubkey, please review our [Code of Conduct](./CODE-OF-CONDUCT.md). ### Contribute To OpenPubkey To learn more about how to contribute, see [CONTRIBUTING.md](./CONTRIBUTING.md). ### Get Involved With Our Community To get involved with our community, see our [community repo](https://github.com/openpubkey/community/). You’ll find details such as when the next community and technical steering committee meetings are. ### Join Our Slack Find us over on the [OpenSSF Slack](https://openssf.org/getinvolved/) in the `#openpubkey` channel. ### Report A Security Issue To report a security issue, please follow our [security disclosure policy](./SECURITY.md). ## FAQ See the [FAQ](./docs/FAQ.md) for answers to Frequently Asked Questions about OpenPubkey. openpubkey-0.8.0/SECURITY.md000066400000000000000000000007061477254274500155150ustar00rootroot00000000000000# Security Policy ## Reporting a Vulnerability **Please do not file a public ticket** mentioning the vulnerability or issue. To privately report security issues or vulnerabilities send your report to security@bastionzero.com (not for support). A report should include: - a summary of the issue, - the steps needed to reproduce the issue, - the potential security impact and, if found, any proposed mitigation. We do not currently offer bug bounties.openpubkey-0.8.0/cert/000077500000000000000000000000001477254274500146565ustar00rootroot00000000000000openpubkey-0.8.0/cert/cert.go000066400000000000000000000065451477254274500161540ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cert import ( "crypto" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" "encoding/json" "encoding/pem" "fmt" "math/big" "time" "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken" ) // CreateX509Cert generates a self-signed x509 cert from a PK token // - OP 'sub' claim is mapped to the CN and SANs fields // - User public key is mapped to the RawSubjectPublicKeyInfo field // - Raw PK token is mapped to the SubjectKeyId field func CreateX509Cert(pkToken *pktoken.PKToken, signer crypto.Signer) ([]byte, error) { template, err := PktToX509Template(pkToken) if err != nil { return nil, fmt.Errorf("error creating X.509 template: %w", err) } // create a self-signed X.509 certificate certDER, err := x509.CreateCertificate(rand.Reader, template, template, signer.Public(), signer) if err != nil { return nil, fmt.Errorf("error creating X.509 certificate: %w", err) } certBlock := &pem.Block{Type: "CERTIFICATE", Bytes: certDER} return pem.EncodeToMemory(certBlock), nil } // PktToX509Template takes a PK Token and returns a X.509 certificate template // with the fields of the template set to the values in the X509 func PktToX509Template(pkt *pktoken.PKToken) (*x509.Certificate, error) { pktJson, err := json.Marshal(pkt) if err != nil { return nil, fmt.Errorf("error marshalling PK token to JSON: %w", err) } // get subject identifier from pk token idtClaims := new(oidc.OidcClaims) if err := json.Unmarshal(pkt.Payload, idtClaims); err != nil { return nil, err } cic, err := pkt.GetCicValues() if err != nil { return nil, err } upk := cic.PublicKey() var rawkey interface{} // This is the raw key, like *rsa.PrivateKey or *ecdsa.PrivateKey if err := upk.Raw(&rawkey); err != nil { return nil, err } // encode ephemeral public key ecPub, err := x509.MarshalPKIXPublicKey(rawkey) if err != nil { return nil, fmt.Errorf("error marshalling public key: %w", err) } template := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: idtClaims.Subject}, RawSubjectPublicKeyInfo: ecPub, NotBefore: time.Now(), NotAfter: time.Now().Add(365 * 24 * time.Hour), // valid for 1 year KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, BasicConstraintsValid: true, DNSNames: []string{idtClaims.Subject}, IsCA: false, ExtraExtensions: []pkix.Extension{{ // OID for OIDC Issuer extension Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, Critical: false, Value: []byte(idtClaims.Issuer), }}, SubjectKeyId: pktJson, } return template, nil } openpubkey-0.8.0/cert/cert_test.go000066400000000000000000000046471477254274500172140ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cert import ( "context" "crypto/x509" "encoding/json" "encoding/pem" "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestCreateX509Cert(t *testing.T) { alg := jwa.ES256 // generate pktoken signer, err := util.GenKeyPair(alg) require.NoError(t, err) providerOpts := providers.DefaultMockProviderOpts() providerOpts.GQSign = true op, _, _, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) opkClient, err := client.New(op, client.WithSigner(signer, alg)) require.NoError(t, err) pkToken, err := opkClient.Auth(context.Background()) require.NoError(t, err) // create x509 cert from pk token cert, err := CreateX509Cert(pkToken, signer) require.NoError(t, err) p, _ := pem.Decode(cert) result, err := x509.ParseCertificate(p.Bytes) require.NoError(t, err) // test cert SubjectKeyId field contains PK token pkTokenJSON, err := json.Marshal(pkToken) require.NoError(t, err) require.Equal(t, string(pkTokenJSON), string(result.SubjectKeyId), "certificate subject key id does not match PK token") // test cert RawSubjectPublicKeyInfo field contains ephemeral public key ecPub, err := x509.MarshalPKIXPublicKey(signer.Public()) require.NoError(t, err) require.Equal(t, string(ecPub), string(result.RawSubjectPublicKeyInfo), "certificate raw subject public key info does not match ephemeral public key") // test cert common name == pktoken sub claim var payload struct { Subject string `json:"sub"` } err = json.Unmarshal(pkToken.Payload, &payload) require.NoError(t, err) require.Equal(t, payload.Subject, result.Subject.CommonName, "cert common name does not equal pk token sub claim") } openpubkey-0.8.0/client/000077500000000000000000000000001477254274500151775ustar00rootroot00000000000000openpubkey-0.8.0/client/choosers/000077500000000000000000000000001477254274500170245ustar00rootroot00000000000000openpubkey-0.8.0/client/choosers/chooser.tmpl000066400000000000000000000021471477254274500213700ustar00rootroot00000000000000 OpenPubkey: OpenID Providers

OpenPubkey:


openpubkey-0.8.0/client/choosers/static/000077500000000000000000000000001477254274500203135ustar00rootroot00000000000000openpubkey-0.8.0/client/choosers/static/buttons/000077500000000000000000000000001477254274500220115ustar00rootroot00000000000000openpubkey-0.8.0/client/choosers/static/buttons/azure-dark.svg000066400000000000000000000163661477254274500246130ustar00rootroot00000000000000MS-SymbolLockupopenpubkey-0.8.0/client/choosers/static/buttons/azure-light.svg000066400000000000000000000166001477254274500247700ustar00rootroot00000000000000MS-SymbolLockupopenpubkey-0.8.0/client/choosers/static/buttons/gitlab-dark.svg000066400000000000000000000027561477254274500247250ustar00rootroot00000000000000 GitLab-SignIn Sign in with GitLab openpubkey-0.8.0/client/choosers/static/buttons/gitlab-light.svg000066400000000000000000000027561477254274500251130ustar00rootroot00000000000000 GitLab-SignIn Sign in with GitLab openpubkey-0.8.0/client/choosers/static/buttons/google-dark.svg000066400000000000000000000437621477254274500247410ustar00rootroot00000000000000 openpubkey-0.8.0/client/choosers/static/buttons/google-light.svg000066400000000000000000000437601477254274500251250ustar00rootroot00000000000000 openpubkey-0.8.0/client/choosers/web_chooser.go000066400000000000000000000161341477254274500216570ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package choosers import ( "context" "embed" "errors" "fmt" "html/template" "io/fs" "net" "net/http" "net/http/httptest" "sort" "strings" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/util" "github.com/sirupsen/logrus" ) //go:embed static/* var staticFiles embed.FS //go:embed chooser.tmpl var chooserTemplateFile string // To add support for an OP to the the WebChooser: // 1. Add the OP to IssuerToName func // 2. Add the OP to the html template file: `chooser.tmpl` // 3. Add the OP to the data which is supplied to `chooserTemplate.Execute(w, data)` // // Note that the web chooser can only support BrowserOpenIdProvider // TODO: This should be an enum that can also autogenerate what gets passed to the template type WebChooser struct { OpList []providers.BrowserOpenIdProvider opSelected providers.BrowserOpenIdProvider OpenBrowser bool useMockServer bool mockServer *httptest.Server server *http.Server } func NewWebChooser(opList []providers.BrowserOpenIdProvider, openBrowser bool) *WebChooser { return &WebChooser{ OpList: opList, OpenBrowser: openBrowser, useMockServer: false, } } func (wc *WebChooser) ChooseOp(ctx context.Context) (providers.OpenIdProvider, error) { if wc.opSelected != nil { return nil, fmt.Errorf("provider has already been chosen") } providerMap := map[string]providers.BrowserOpenIdProvider{} for _, provider := range wc.OpList { if providerName, err := IssuerToName(provider.Issuer()); err != nil { return nil, err } else { if _, ok := providerMap[providerName]; ok { return nil, fmt.Errorf("provider in web chooser found with duplicate issuer: %s", provider.Issuer()) } providerMap[providerName] = provider } } opCh := make(chan providers.BrowserOpenIdProvider, 1) errCh := make(chan error, 1) mux := http.NewServeMux() staticContent, err := fs.Sub(staticFiles, "static") if err != nil { return nil, err } chooserTemplate, err := template.New("chooser-page").Parse(chooserTemplateFile) if err != nil { return nil, err } mux.HandleFunc("/chooser", func(w http.ResponseWriter, r *http.Request) { type Provider struct { Name string Button string } data := struct { Providers []Provider }{} sortedProviderNames := make([]string, 0) for providerName := range providerMap { sortedProviderNames = append(sortedProviderNames, providerName) } // Sort the provider names sort.Strings(sortedProviderNames) for _, providerName := range sortedProviderNames { if providerName == "google" { data.Providers = append(data.Providers, Provider{ Name: providerName, Button: "google-light.svg", }) continue } if providerName == "azure" { data.Providers = append(data.Providers, Provider{ Name: providerName, Button: "azure-dark.svg", }) continue } if providerName == "gitlab" { data.Providers = append(data.Providers, Provider{ Name: providerName, Button: "gitlab-light.svg", }) continue } data.Providers = append(data.Providers, Provider{ Name: providerName, Button: "", }) } w.Header().Set("Content-Type", "text/html") if err := chooserTemplate.Execute(w, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } }) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent)))) mux.HandleFunc("/select/", func(w http.ResponseWriter, r *http.Request) { // Once we redirect to the OP localhost webserver, we can shutdown the web chooser localhost server shutdownServer := func() { go func() { // Put this in a go func so that it will not block the redirect if wc.server != nil { if err := wc.server.Shutdown(ctx); err != nil { logrus.Errorf("Failed to shutdown http server: %v", err) } } }() } defer shutdownServer() opName := r.URL.Query().Get("op") if opName == "" { errorString := "missing op parameter" http.Error(w, errorString, http.StatusBadRequest) errCh <- errors.New(errorString) return } if op, ok := providerMap[opName]; !ok { errorString := fmt.Sprintf("unknown OpenID Provider: %s", opName) http.Error(w, errorString, http.StatusBadRequest) errCh <- errors.New(errorString) return } else { opCh <- op redirectUriCh := make(chan string, 1) op.ReuseBrowserWindowHook(redirectUriCh) redirectUri := <-redirectUriCh http.Redirect(w, r, redirectUri, http.StatusFound) } }) if wc.useMockServer { wc.mockServer = httptest.NewUnstartedServer(mux) wc.mockServer.Start() } else { listener, err := net.Listen("tcp", "localhost:0") if err != nil { return nil, fmt.Errorf("failed to bind to an available port: %w", err) } wc.server = &http.Server{Handler: mux} go func() { err = wc.server.Serve(listener) if err != nil && err != http.ErrServerClosed { logrus.Error(err) } }() var loginURI string if listener.Addr().(*net.TCPAddr).IP.String() == "127.0.0.1" { // For consistency in output messages in our code base we use localhost rather than 127.0.0.1 port := listener.Addr().(*net.TCPAddr).Port loginURI = fmt.Sprintf("http://localhost:%d/chooser", port) } else { loginURI = fmt.Sprintf("http://%s/chooser", listener.Addr().String()) } if wc.OpenBrowser { logrus.Infof("Opening browser to %s", loginURI) if err := util.OpenUrl(loginURI); err != nil { logrus.Errorf("Failed to open url: %v", err) } } else { // If wc.OpenBrowser is false, tell the user what URL to open. // This is useful when a user wants to use a different browser than the default one. logrus.Infof("Open your browser to: %s ", loginURI) } } select { case <-ctx.Done(): return nil, ctx.Err() case err := <-errCh: return nil, err case wc.opSelected = <-opCh: return wc.opSelected, nil } } func IssuerToName(issuer string) (string, error) { switch { case strings.HasPrefix(issuer, "https://accounts.google.com"): return "google", nil case strings.HasPrefix(issuer, "https://login.microsoftonline.com"): return "azure", nil case strings.HasPrefix(issuer, "https://gitlab.com"): return "gitlab", nil default: if strings.HasPrefix(issuer, "https://") { // Returns issuer without the "https://" prefix and without any path remaining on the url // e.g. https://accounts.google.com/fdsfa/fdsafsad -> accounts.google.com return strings.Split(strings.TrimPrefix(issuer, "https://"), "/")[0], nil } return "", fmt.Errorf("invalid OpenID Provider issuer: %s", issuer) } } openpubkey-0.8.0/client/choosers/web_chooser_test.go000066400000000000000000000130521477254274500227120ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package choosers import ( "context" "net/http" "testing" "time" "github.com/openpubkey/openpubkey/providers" "github.com/stretchr/testify/require" ) func TestGoogleSelection(t *testing.T) { redirectUri := "http://example.com" testCases := []struct { name string providerName string issuerPrefix string httpCodeExpected int errorString string }{ {name: "select google", providerName: "google", httpCodeExpected: http.StatusOK}, {name: "select azure", providerName: "azure", httpCodeExpected: http.StatusOK}, {name: "select gitlab", providerName: "gitlab", httpCodeExpected: http.StatusOK}, {name: "select bad provider", providerName: "fakeProvider", httpCodeExpected: http.StatusBadRequest, errorString: "unknown OpenID Provider"}, {name: "select no provider", providerName: "", httpCodeExpected: http.StatusBadRequest, errorString: "missing op parameter"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { googleOpOptions := providers.GetDefaultGoogleOpOptions() googleOp := providers.NewGoogleOpWithOptions(googleOpOptions) azureOpOptions := providers.GetDefaultAzureOpOptions() azureOp := providers.NewAzureOpWithOptions(azureOpOptions) gitlabOpOptions := providers.GetDefaultGitlabOpOptions() gitlabOp := providers.NewGitlabOpWithOptions(gitlabOpOptions) webChooser := WebChooser{ OpList: []providers.BrowserOpenIdProvider{ googleOp, azureOp, gitlabOp, }, OpenBrowser: false, useMockServer: true, } var chooserErr error var op providers.OpenIdProvider testRunDone := make(chan struct{}) go func() { defer close(testRunDone) // If something goes wrong in this go func, this unittest will hang // until it times out. If you are running into such an issue // check if check if anything here is failing. op, chooserErr = webChooser.ChooseOp(context.Background()) if tc.errorString != "" { require.ErrorContains(t, chooserErr, tc.errorString) require.Nil(t, op) return } require.NoError(t, chooserErr) require.NotNil(t, op) // trigger the redirect so the HTTP GET below will complete switch tc.providerName { case "google": googleOp.(*providers.StandardOp).TriggerBrowserWindowHook(redirectUri) case "azure": azureOp.(*providers.StandardOp).TriggerBrowserWindowHook(redirectUri) case "gitlab": gitlabOp.(*providers.StandardOp).TriggerBrowserWindowHook(redirectUri) default: // Trigger azure even if the provider doesn't match to sure this test finishes azureOp.(*providers.StandardOp).TriggerBrowserWindowHook(redirectUri) } }() // Wait until the server is listening require.Eventually(t, func() bool { return chooserErr != nil || webChooser.mockServer != nil && webChooser.mockServer.URL != "" }, 3*time.Second, 100*time.Millisecond) if chooserErr != nil { if tc.errorString != "" { require.ErrorContains(t, chooserErr, tc.errorString) } else { require.NoError(t, chooserErr) } } // Make a request to the server to trigger Google selection and get redirect resp, err := http.Get(webChooser.mockServer.URL + "/select?op=" + tc.providerName) if tc.httpCodeExpected != http.StatusOK { require.Equal(t, tc.httpCodeExpected, resp.StatusCode) } else { require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) op, err = webChooser.ChooseOp(context.Background()) require.ErrorContains(t, err, "provider has already been chosen") require.Nil(t, op) } // Since we use a go func inside the test, we need to ensure it finishes before we move on to the next test select { case <-testRunDone: require.Nil(t, nil) case <-time.After(5 * time.Second): t.Fatal("test timed out") } }) } } func TestDuplicateProviderError(t *testing.T) { googleOpOptions := providers.GetDefaultGoogleOpOptions() googleOp := providers.NewGoogleOpWithOptions(googleOpOptions) webChooser := WebChooser{ OpList: []providers.BrowserOpenIdProvider{googleOp, googleOp}, OpenBrowser: false, useMockServer: true, } op, err := webChooser.ChooseOp(context.Background()) require.ErrorContains(t, err, "provider in web chooser found with duplicate issuer: https://accounts.google.com") require.Nil(t, op) } func TestIssuerToName(t *testing.T) { name, err := IssuerToName("https://accounts.google.com") require.NoError(t, err) require.Equal(t, "google", name) name, err = IssuerToName("https://login.microsoftonline.com") require.NoError(t, err) require.Equal(t, "azure", name) name, err = IssuerToName("https://gitlab.com") require.NoError(t, err) require.Equal(t, "gitlab", name) name, err = IssuerToName("https://noterror.example.com") require.NoError(t, err) require.Equal(t, "noterror.example.com", name) name, err = IssuerToName("error.example.com") require.ErrorContains(t, err, "invalid OpenID Provider issuer: error.example.com") require.Equal(t, "", name) } openpubkey-0.8.0/client/client.go000066400000000000000000000212371477254274500170110ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package client import ( "context" "crypto" "fmt" "net/http" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/pktoken/clientinstance" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/util" "github.com/openpubkey/openpubkey/verifier" ) type OpenIdProvider = providers.OpenIdProvider type BrowserOpenIdProvider = providers.BrowserOpenIdProvider type PKTokenVerifier interface { VerifyPKToken(ctx context.Context, pkt *pktoken.PKToken, extraChecks ...verifier.Check) error } type OpkClient struct { Op OpenIdProvider cosP *CosignerProvider signer crypto.Signer alg jwa.KeyAlgorithm pkToken *pktoken.PKToken refreshToken []byte accessToken []byte } // ClientOpts contains options for constructing an OpkClient type ClientOpts func(o *OpkClient) // WithSigner allows the caller to inject their own signer and algorithm. // Use this option if to generate to bring your own user key pair. If this // option is not set the OpkClient constructor will automatically generate // a signer, i.e., key pair. // Example use: // // signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) // WithSigner(signer, jwa.ES256) func WithSigner(signer crypto.Signer, alg jwa.KeyAlgorithm) ClientOpts { return func(o *OpkClient) { o.signer = signer o.alg = alg } } // WithCosignerProvider specifies what cosigner provider should be used to // cosign the PK Token. If this is not specified then the cosigning setup // is skipped. func WithCosignerProvider(cosP *CosignerProvider) ClientOpts { return func(o *OpkClient) { o.cosP = cosP } } // New returns a new client.OpkClient. The op argument should be the // OpenID Provider you want to authenticate against. func New(op OpenIdProvider, opts ...ClientOpts) (*OpkClient, error) { client := &OpkClient{ Op: op, signer: nil, alg: nil, } for _, applyOpt := range opts { applyOpt(client) } if client.alg == nil && client.signer != nil { return nil, fmt.Errorf("signer specified but alg is nil, must specify alg of signer") } if client.signer == nil { // Generate signer for specified alg. If no alg specified, defaults to ES256 if client.alg == nil { client.alg = jwa.ES256 } signer, err := util.GenKeyPair(client.alg) if err != nil { return nil, fmt.Errorf("failed to create key pair for client: %w ", err) } client.signer = signer } return client, nil } type AuthOptsStruct struct { extraClaims map[string]any } type AuthOpts func(a *AuthOptsStruct) // WithExtraClaim specifies additional values to be included in the // CIC. These claims will be include in the CIC protected header and // will be hashed into the commitment claim in the ID Token. The // commitment claim is typically the nonce or aud claim in the ID Token. // Example use: // // WithExtraClaim("claimKey", "claimValue") func WithExtraClaim(k string, v string) AuthOpts { return func(a *AuthOptsStruct) { if a.extraClaims == nil { a.extraClaims = map[string]any{} } a.extraClaims[k] = v } } // Auth returns a PK Token by running the OpenPubkey protocol. It will first // authenticate to the configured OpenID Provider (OP) and receive an ID Token. // Using this ID Token it will generate a PK Token. If a Cosigner has been // configured it will also attempt to get the PK Token cosigned. func (o *OpkClient) Auth(ctx context.Context, opts ...AuthOpts) (*pktoken.PKToken, error) { authOpts := &AuthOptsStruct{ extraClaims: map[string]any{}, } for _, applyOpt := range opts { applyOpt(authOpts) } // If no Cosigner is set then do standard OIDC authentication if o.cosP == nil { pkt, err := o.oidcAuth(ctx, o.signer, o.alg, authOpts.extraClaims) if err != nil { return nil, err } o.pkToken = pkt return o.pkToken.DeepCopy() } // If a Cosigner is set then check that will support doing Cosigner auth if browserOp, ok := o.Op.(BrowserOpenIdProvider); !ok { return nil, fmt.Errorf("OP supplied does not have support for MFA Cosigner") } else { redirCh := make(chan string, 1) browserOp.HookHTTPSession(func(w http.ResponseWriter, r *http.Request) { redirectUri := <-redirCh http.Redirect(w, r, redirectUri, http.StatusFound) }) pkt, err := o.oidcAuth(ctx, o.signer, o.alg, authOpts.extraClaims) if err != nil { return nil, err } pktCos, err := o.cosP.RequestToken(ctx, o.signer, pkt, redirCh) if err != nil { return nil, err } o.pkToken = pktCos return o.pkToken.DeepCopy() } } // oidcAuth performs the OpenIdConnect part of the protocol. // Auth is the exposed function that should be called. func (o *OpkClient) oidcAuth( ctx context.Context, signer crypto.Signer, alg jwa.KeyAlgorithm, extraClaims map[string]any, ) (*pktoken.PKToken, error) { // keep track of any additional verifierChecks for the verifier verifierChecks := []verifier.Check{} // Use our signing key to generate a JWK key and set the "alg" header jwkKey, err := jwk.PublicKeyOf(signer.Public()) if err != nil { return nil, err } err = jwkKey.Set(jwk.AlgorithmKey, alg) if err != nil { return nil, err } // Use provided public key to generate client instance claims cic, err := clientinstance.NewClaims(jwkKey, extraClaims) if err != nil { return nil, fmt.Errorf("failed to instantiate client instance claims: %w", err) } tokens, err := o.Op.RequestTokens(ctx, cic) if err != nil { return nil, fmt.Errorf("error requesting OIDC tokens from OpenID Provider: %w", err) } idToken := tokens.IDToken o.refreshToken = tokens.RefreshToken o.accessToken = tokens.AccessToken // Sign over the payload from the ID token and client instance claims cicToken, err := cic.Sign(signer, alg, idToken) if err != nil { return nil, fmt.Errorf("error creating cic token: %w", err) } // Combine our ID token and signature over the cic to create our PK Token pkt, err := pktoken.New(idToken, cicToken) if err != nil { return nil, fmt.Errorf("error creating PK Token: %w", err) } pktVerifier, err := verifier.New(o.Op) if err != nil { return nil, err } if err := pktVerifier.VerifyPKToken(ctx, pkt, verifierChecks...); err != nil { return nil, fmt.Errorf("error verifying PK Token: %w", err) } return pkt, nil } // Refresh uses a Refresh Token to request a fresh ID Token and Access Token from an OpenID Provider. // It provides a way to refresh the Access and ID Tokens for an OpenID Provider that supports refresh requests, // allowing the client to continue making authenticated requests without requiring the user to re-authenticate. func (o *OpkClient) Refresh(ctx context.Context) (*pktoken.PKToken, error) { if tokensOp, ok := o.Op.(providers.RefreshableOpenIdProvider); ok { if o.refreshToken == nil { return nil, fmt.Errorf("no refresh token set") } if o.pkToken == nil { return nil, fmt.Errorf("no PK Token set, run Auth() to create a PK Token first") } tokens, err := tokensOp.RefreshTokens(ctx, o.refreshToken) if err != nil { return nil, fmt.Errorf("error requesting ID token: %w", err) } o.pkToken.FreshIDToken = tokens.IDToken o.refreshToken = tokens.RefreshToken o.accessToken = tokens.AccessToken return o.pkToken.DeepCopy() } return nil, fmt.Errorf("OP (issuer=%s) does not support OIDC refresh requests", o.Op.Issuer()) } // GetOp returns the OpenID Provider the OpkClient has been configured to use func (o *OpkClient) GetOp() OpenIdProvider { return o.Op } // GetCosP returns the MFA Cosigner Provider the OpkClient has been // configured to use func (o *OpkClient) GetCosP() *CosignerProvider { return o.cosP } // GetSigner returns the client's key pair (Public Key, Signing Key) func (o *OpkClient) GetSigner() crypto.Signer { return o.signer } // GetAlg returns the algorithm of the client's key pair // (Public Key, Signing Key) func (o *OpkClient) GetAlg() jwa.KeyAlgorithm { return o.alg } func (o *OpkClient) SetPKToken(pkt *pktoken.PKToken) { o.pkToken = pkt } // GetPKToken returns a deep copy of client's current PK Token func (o *OpkClient) GetPKToken() (*pktoken.PKToken, error) { return o.pkToken.DeepCopy() } openpubkey-0.8.0/client/cos.go000066400000000000000000000206751477254274500163240ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package client import ( "context" "crypto" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strings" "time" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/cosigner/msgs" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/util" "github.com/sirupsen/logrus" ) type CosignerProvider struct { Issuer string CallbackPath string } func (c *CosignerProvider) RequestToken(ctx context.Context, signer crypto.Signer, pkt *pktoken.PKToken, redirCh chan string) (*pktoken.PKToken, error) { // Find an unused port listener, err := net.Listen("tcp", ":0") if err != nil { return nil, fmt.Errorf("failed to bind to an available port: %w", err) } port := listener.Addr().(*net.TCPAddr).Port host := fmt.Sprintf("localhost:%d", port) redirectURI := fmt.Sprintf("http://%s%s", host, c.CallbackPath) // We set the buffer size to one and then in the CallbackPath handler we // ensure only said either 0 or 1 message to a channel before returning. // This prevents blocking inside CallbackPath handler when it attempts to // write to the channel. If the callbackPath handler is called twice by the // user's web browser the second call will block on a channel until the cxt // is marked as done. sigCh := make(chan []byte, 1) errCh := make(chan error, 1) // This is where we get the authcode from the Cosigner mux := http.NewServeMux() mux.Handle(c.CallbackPath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cosSig, err := func() ([]byte, error) { // Get authcode from Cosigner via Cosigner redirecting user's browser window params := r.URL.Query() if _, ok := params["authcode"]; !ok { return nil, fmt.Errorf("cosigner did not return an authcode in the URI") } authcode := params["authcode"][0] // This is the authcode issued by the cosigner not the OP // Sign authcode from cosigner under PK Token and send signed authcode to Cosigner sig2, err := pkt.NewSignedMessage([]byte(authcode), signer) if err != nil { return nil, fmt.Errorf("cosigner client hit error when building authcode URI: %w", err) } authcodeSigUri, err := c.authcodeURI(sig2) if err != nil { return nil, fmt.Errorf("cosigner client hit error when building authcode URI: %w", err) } res, err := http.Get(authcodeSigUri) if err != nil { return nil, fmt.Errorf("error requesting MFA cosigner signature: %w", err) } // Receive response from Cosigner that has cosigner signature on PK Token resBody, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("error reading MFA cosigner signature response: %w", err) } cosSig, err := util.Base64DecodeForJWT(resBody) if err != nil { return nil, fmt.Errorf("error reading MFA cosigner signature response: %w", err) } // Success return cosSig, nil }() if err != nil { // Write the error message to the user if _, err := w.Write([]byte(err.Error())); err != nil { logrus.Error(err) } select { case errCh <- err: case <-ctx.Done(): return } } else { if _, err := w.Write([]byte("You may now close this window")); err != nil { logrus.Error(err) } select { case sigCh <- cosSig: case <-ctx.Done(): return } } }), ) server := &http.Server{ Addr: host, Handler: mux, } logrus.Infof("listening on http://%s/", host) logrus.Info("press ctrl+c to stop") go func() { err := server.Serve(listener) if err != nil && err != http.ErrServerClosed { logrus.Error(err) } }() defer func() { if err := server.Shutdown(ctx); err != nil { logrus.Error(err) } }() pktJson, err := json.Marshal(pkt) if err != nil { return nil, fmt.Errorf("cosigner client hit error serializing PK Token: %w", err) } initAuthMsgJson, nonce, err := c.CreateInitAuthSig(redirectURI) if err != nil { return nil, fmt.Errorf("hit error creating init auth signed message: %w", err) } sig1, err := pkt.NewSignedMessage(initAuthMsgJson, signer) if err != nil { return nil, fmt.Errorf("cosigner client hit error init auth signed message: %w", err) } redirUri, err := c.initAuthURI(pktJson, sig1) if err != nil { return nil, fmt.Errorf("cosigner client hit error when building init auth URI: %w", err) } select { // Trigger redirect of user's browser window to a URI controlled by the Cosigner sending the PK Token in the URI case redirCh <- redirUri: case <-ctx.Done(): return nil, ctx.Err() } select { case cosSig := <-sigCh: // Received cosigner signature // To be safe we perform these checks before adding the cosSig to the pktoken if err := c.ValidateCos(cosSig, nonce, redirectURI); err != nil { return nil, err } if err := pkt.AddSignature(cosSig, pktoken.COS); err != nil { return nil, fmt.Errorf("error in adding cosigner signature to PK Token: %w", err) } return pkt, nil case err := <-errCh: return nil, err case <-ctx.Done(): return nil, ctx.Err() } } func (c *CosignerProvider) initAuthURI(pktJson []byte, sig1 []byte) (string, error) { pktB63 := util.Base64EncodeForJWT(pktJson) if uri, err := url.Parse(c.Issuer); err != nil { return "", err } else { uri := uri.JoinPath("mfa-auth-init") v := uri.Query() v.Add("pkt", string(pktB63)) v.Add("sig1", string(sig1)) uri.RawQuery = v.Encode() // URI Should be: https:///mfa-auth-init?pkt=&sig1= return uri.String(), nil } } func (c *CosignerProvider) authcodeURI(sig2 []byte) (string, error) { if uri, err := url.Parse(c.Issuer); err != nil { return "", err } else { uri := uri.JoinPath("sign") v := uri.Query() v.Add("sig2", string(sig2)) uri.RawQuery = v.Encode() // URI Should be: https:///sign?&sig2= return uri.String(), nil } } func (c *CosignerProvider) ValidateCos(cosSig []byte, expectedNonce string, expectedRedirectURI string) error { cosSigParsed, err := jws.Parse(cosSig) if err != nil { return fmt.Errorf("failed to parse Cosigner signature: %w", err) } if len(cosSigParsed.Signatures()) != 1 { return fmt.Errorf("the Cosigner signature does not have the correct number of signatures: %w", err) } ph := cosSigParsed.Signatures()[0].ProtectedHeaders() nonceRet, ok := ph.Get("nonce") if !ok { return fmt.Errorf("nonce not set in Cosigner signature protected header") } if expectedNonce != nonceRet { return fmt.Errorf("incorrect nonce set in Cosigner signature") } ruriRet, ok := ph.Get("ruri") if !ok { return fmt.Errorf("ruri (redirect URI) not set in Cosigner signature protected header") } if expectedRedirectURI != ruriRet { return fmt.Errorf("unexpected ruri (redirect URI) set in Cosigner signature, got %s expected %s", ruriRet, expectedRedirectURI) } issRet, ok := ph.Get("iss") if !ok { return fmt.Errorf("iss (Cosigner Issuer) not set in Cosigner signature protected header") } if c.Issuer != issRet { return fmt.Errorf("unexpected iss (Cosigner Issuer) set in Cosigner signature, expected %s", c.Issuer) } return nil } // CreateInitAuthSig generates a random nonce, validates the redirectURI, // creates an InitMFAAuth message, marshals it to JSON, // and returns the JSON message along with the nonce. func (c *CosignerProvider) CreateInitAuthSig(redirectURI string) ([]byte, string, error) { bits := 256 rBytes := make([]byte, bits/8) if _, err := rand.Read(rBytes); err != nil { return nil, "", err } if !strings.HasSuffix(redirectURI, c.CallbackPath) { return nil, "", fmt.Errorf("redirectURI (%s) does not end in expected callbackPath (%s)", redirectURI, c.CallbackPath) } nonce := hex.EncodeToString(rBytes) msg := msgs.InitMFAAuth{ Issuer: c.Issuer, RedirectUri: redirectURI, TimeSigned: time.Now().Unix(), Nonce: nonce, } msgJson, err := json.Marshal(msg) if err != nil { return nil, "", err } return msgJson, nonce, nil } openpubkey-0.8.0/client/cos_test.go000066400000000000000000000031241477254274500173510ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package client import ( "fmt" "testing" "github.com/stretchr/testify/require" ) func TestCosSimple(t *testing.T) { cosP := CosignerProvider{ Issuer: "https://example.com", CallbackPath: "/mfaredirect", } redirectURI := fmt.Sprintf("%s/%s", "http://localhost:5555", cosP.CallbackPath) initAuthSig, nonce, err := cosP.CreateInitAuthSig(redirectURI) require.NotNil(t, initAuthSig) require.NotNil(t, nonce) require.NoError(t, err) pktJson := []byte("fake pkt bytes") sig1 := []byte("fake signature one bytes") authUri, err := cosP.initAuthURI(pktJson, sig1) require.NotNil(t, authUri) require.Equal(t, "https://example.com/mfa-auth-init?pkt=ZmFrZSBwa3QgYnl0ZXM&sig1=fake+signature+one+bytes", authUri) require.NoError(t, err) sig2 := []byte("fake signature two bytes") authCodeUri, err := cosP.authcodeURI(sig2) require.NotNil(t, authCodeUri) require.Equal(t, "https://example.com/sign?sig2=fake+signature+two+bytes", authCodeUri) require.NoError(t, err) } openpubkey-0.8.0/client/opkclient_test.go000066400000000000000000000145331477254274500205630ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package client_test import ( "context" "crypto/rsa" "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/gq" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestClient(t *testing.T) { clientID := "test-client-id" commitType := providers.CommitTypesEnum.NONCE_CLAIM testCases := []struct { name string gq bool signer bool signerAlg jwa.KeyAlgorithm extraClaims map[string]string }{ {name: "without GQ", gq: false, signer: false}, {name: "with GQ", gq: true, signer: false}, {name: "with GQ, with signer", gq: true, signer: true, signerAlg: jwa.RS256}, {name: "with GQ, with signer, with empty extraClaims", gq: true, signer: true, signerAlg: jwa.ES256, extraClaims: map[string]string{}}, {name: "with GQ, with signer, with extraClaims", gq: true, signer: true, signerAlg: jwa.ES256, extraClaims: map[string]string{"extra": "yes"}}, {name: "with GQ, with extraClaims", gq: true, signer: false, extraClaims: map[string]string{"extra": "yes", "aaa": "bbb"}}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { var c *client.OpkClient providerOpts := providers.MockProviderOpts{ Issuer: "mockIssuer", ClientID: clientID, GQSign: tc.gq, NumKeys: 2, CommitType: commitType, VerifierOpts: providers.ProviderVerifierOpts{ CommitType: commitType, SkipClientIDCheck: false, GQOnly: false, ClientID: clientID, }, } op, _, _, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) require.NoError(t, err, tc.name) if tc.signer { signer, err := util.GenKeyPair(tc.signerAlg) require.NoError(t, err, tc.name) c, err = client.New(op, client.WithSigner(signer, tc.signerAlg)) require.NoError(t, err, tc.name) require.Equal(t, signer, c.GetSigner(), tc.name) require.Equal(t, tc.signerAlg, c.GetAlg(), tc.name) } else { c, err = client.New(op) require.NoError(t, err, tc.name) } var pkt *pktoken.PKToken if tc.extraClaims != nil { extraClaimsOpts := []client.AuthOpts{} for k, v := range tc.extraClaims { extraClaimsOpts = append(extraClaimsOpts, client.WithExtraClaim(k, v)) } pkt, err = c.Auth(context.Background(), extraClaimsOpts...) require.NoError(t, err, tc.name) cicPH, err := pkt.Cic.ProtectedHeaders().AsMap(context.TODO()) require.NoError(t, err, tc.name) for k, v := range tc.extraClaims { require.Equal(t, v, cicPH[k], tc.name) } } else { pkt, err = c.Auth(context.Background()) require.NoError(t, err, tc.name) } providerAlg, ok := pkt.ProviderAlgorithm() require.True(t, ok, "missing algorithm", tc.name) if tc.gq { require.Equal(t, gq.GQ256, providerAlg, tc.name) // Verify our GQ signature opPubKey, err := op.PublicKeyByToken(context.Background(), pkt.OpToken) require.NoError(t, err, tc.name) rsaKey, ok := opPubKey.PublicKey.(*rsa.PublicKey) require.Equal(t, true, ok) ok, err = gq.GQ256VerifyJWT(rsaKey, pkt.OpToken) require.NoError(t, err, tc.name) require.True(t, ok, "error verifying OP GQ signature on PK Token (ID Token invalid)") } else { // Expect alg to be RS256 alg when not signing with GQ require.Equal(t, jwa.RS256, providerAlg, tc.name) } cic, err := pkt.GetCicValues() require.NoError(t, err) err = op.VerifyIDToken(context.Background(), pkt.OpToken, cic) require.NoError(t, err, tc.name) pktRefreshed, err := c.Refresh(context.Background()) require.NoError(t, err) require.NotNil(t, pktRefreshed) // TODO: Add Verification of Refreshed ID Token }) } } func TestClientRefreshErrorHandling(t *testing.T) { signerAlg := jwa.ES256 providerOpts := providers.DefaultMockProviderOpts() op, _, _, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) signer, err := util.GenKeyPair(signerAlg) require.NoError(t, err) c, err := client.New(op, client.WithSigner(signer, jwa.ES256)) require.NoError(t, err) _, err = c.Refresh(context.Background()) require.ErrorContains(t, err, "no refresh token set") // Now that we have called Auth refresh should work pkt1, err := c.Auth(context.Background()) require.NoError(t, err) pkt1Com, err := pkt1.Compact() require.NoError(t, err) pkt2, err := c.Refresh(context.Background()) require.NoError(t, err) pkt2Com, err := pkt2.Compact() require.NoError(t, err) require.NotEqual(t, string(pkt1Com), string(pkt2Com)) pkt3, err := c.GetPKToken() require.NoError(t, err) pkt3com, err := pkt3.Compact() require.NoError(t, err) require.Equal(t, pkt2Com, pkt3com) // Nil out PK Token in client so check we catch the error of a nil PK Token c.SetPKToken(nil) _, err = c.Refresh(context.Background()) require.ErrorContains(t, err, "no PK Token set, run Auth() to create a PK Token first") } func TestClientRefreshNotSupported(t *testing.T) { signerAlg := jwa.ES256 providerOpts := providers.DefaultMockProviderOpts() op, _, _, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) // Removes RefreshTokens from Op so we can test that client // handles Op's that can't refresh tokens opRefreshUnsupported := providers.NewNonRefreshableOp(op) signer, err := util.GenKeyPair(signerAlg) require.NoError(t, err) c, err := client.New(opRefreshUnsupported, client.WithSigner(signer, jwa.ES256)) require.NoError(t, err) pkt, err := c.Auth(context.Background()) require.NoError(t, err) require.NotNil(t, pkt) _, err = c.Refresh(context.Background()) require.ErrorContains(t, err, "does not support OIDC refresh requests") } openpubkey-0.8.0/cosigner/000077500000000000000000000000001477254274500155325ustar00rootroot00000000000000openpubkey-0.8.0/cosigner/authcosigner.go000066400000000000000000000075041477254274500205620ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cosigner import ( "crypto" "encoding/json" "fmt" "time" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/cosigner/msgs" "github.com/openpubkey/openpubkey/pktoken" ) type AuthCosigner struct { Cosigner Issuer string KeyID string AuthStateStore AuthStateStore } func New(signer crypto.Signer, alg jwa.SignatureAlgorithm, issuer, keyID string, store AuthStateStore) (*AuthCosigner, error) { return &AuthCosigner{ Cosigner: Cosigner{ Alg: alg, Signer: signer}, Issuer: issuer, KeyID: keyID, AuthStateStore: store, }, nil } func (c *AuthCosigner) InitAuth(pkt *pktoken.PKToken, sig []byte) (string, error) { msg, err := pkt.VerifySignedMessage(sig) if err != nil { return "", fmt.Errorf("failed to verify sig: %w", err) } var initMFAAuth *msgs.InitMFAAuth if err := json.Unmarshal(msg, &initMFAAuth); err != nil { return "", fmt.Errorf("failed to parse InitMFAAuth message: %w", err) } else if initMFAAuth.Issuer != c.Issuer { return "", fmt.Errorf("signed message is for wrong cosigner, got issuer=(%s), expected issuer=(%s)", initMFAAuth.Issuer, c.Issuer) } else if time.Since(time.Unix(initMFAAuth.TimeSigned, 0)).Minutes() > 2 { return "", fmt.Errorf("timestamp (%d) in InitMFAAuth message too old, current time is (%d)", initMFAAuth.TimeSigned, time.Now().Unix()) } else if time.Until(time.Unix(initMFAAuth.TimeSigned, 0)).Minutes() > 2 { return "", fmt.Errorf("timestamp (%d) in InitMFAAuth message too far in the future, current time is (%d)", initMFAAuth.TimeSigned, time.Now().Unix()) } else if authID, err := c.AuthStateStore.CreateNewAuthSession(pkt, initMFAAuth.RedirectUri, initMFAAuth.Nonce); err != nil { return "", err } else { return authID, nil } } func (c *AuthCosigner) NewAuthcode(authID string) (string, error) { return c.AuthStateStore.CreateAuthcode(authID) } func (c *AuthCosigner) RedeemAuthcode(sig []byte) ([]byte, error) { msg, err := jws.Parse(sig) if err != nil { return nil, fmt.Errorf("failed to parse sig: %s", err) } authcode := string(msg.Payload()) // We need redemption to be inside of our mutexes to ensure the same authcode can't be redeemed if requested at the same moment if authState, authID, err := c.AuthStateStore.RedeemAuthcode(authcode); err != nil { return nil, err } else { pkt := authState.Pkt _, err := pkt.VerifySignedMessage(sig) // We check this after redeeming the authcode, so can't try the same correct authcode twice if err != nil { return nil, fmt.Errorf("error verifying sig: %w", err) } return c.IssueSignature(pkt, authState, authID) } } func (c *AuthCosigner) IssueSignature(pkt *pktoken.PKToken, authState AuthState, authID string) ([]byte, error) { protected := pktoken.CosignerClaims{ Issuer: c.Issuer, KeyID: c.KeyID, Algorithm: c.Alg.String(), AuthID: authID, AuthTime: time.Now().Unix(), IssuedAt: time.Now().Unix(), Expiration: time.Now().Add(time.Hour).Unix(), RedirectURI: authState.RedirectURI, Nonce: authState.Nonce, Typ: string(pktoken.COS), } // Now that our mfa has authenticated the user, we can add our signature return c.Cosign(pkt, protected) } openpubkey-0.8.0/cosigner/authcosigner_test.go000066400000000000000000000151111477254274500216120ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cosigner_test import ( "crypto" "crypto/rand" "fmt" "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/cosigner" cosmock "github.com/openpubkey/openpubkey/cosigner/mocks" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/pktoken/mocks" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestInitAuth(t *testing.T) { cos := CreateAuthCosigner(t) alg := jwa.ES256 signer, err := util.GenKeyPair(alg) require.NoError(t, err, "failed to generate key pair") pkt, err := mocks.GenerateMockPKToken(t, signer, alg) require.NoError(t, err, "failed to generate mock PK Token") cosP := client.CosignerProvider{ Issuer: "https://example.com", CallbackPath: "/mfaredirect", } redirectURI := fmt.Sprintf("%s/%s", "http://localhost:5555", cosP.CallbackPath) initAuthMsgJson, _, _ := cosP.CreateInitAuthSig(redirectURI) sig, _ := pkt.NewSignedMessage(initAuthMsgJson, signer) authID1, err := cos.InitAuth(pkt, sig) require.NoError(t, err, "failed to initiate auth") require.NotEmpty(t, authID1) emptySig := []byte{} authID2, err := cos.InitAuth(pkt, emptySig) require.ErrorContains(t, err, "failed to verify sig: invalid byte sequence") require.Empty(t, authID2) } func TestRedeemAuthcode(t *testing.T) { cos := CreateAuthCosigner(t) alg := jwa.ES256 signer, err := util.GenKeyPair(alg) require.NoError(t, err, "failed to generate key pair") pkt, err := mocks.GenerateMockPKToken(t, signer, alg) require.NoError(t, err, "failed to generate mock PK Token") cosP := client.CosignerProvider{ Issuer: "https://example.com", CallbackPath: "/mfaredirect", } redirectURI := fmt.Sprintf("%s/%s", "http://localhost:5555", cosP.CallbackPath) diffSigner, err := util.GenKeyPair(alg) require.NoError(t, err, "failed to generate key pair") diffPkt, err := mocks.GenerateMockPKToken(t, diffSigner, alg) require.NoError(t, err, "failed to generate mock PK Token") tests := []struct { pkt *pktoken.PKToken signer crypto.Signer wantError bool }{ {pkt: pkt, signer: signer, wantError: false}, {pkt: pkt, signer: diffSigner, wantError: true}, {pkt: diffPkt, signer: diffSigner, wantError: false}, {pkt: diffPkt, signer: signer, wantError: true}, } for i, tc := range tests { initAuthMsgJson, _, err := cosP.CreateInitAuthSig(redirectURI) require.NoError(t, err, "test %d: CreateInitAuthSig err: %v", i+1, err) sig, err := tc.pkt.NewSignedMessage(initAuthMsgJson, tc.signer) require.NoError(t, err, "test %d: NewSignedMessage err: %v", i+1, err) authID, err := cos.InitAuth(tc.pkt, sig) if !tc.wantError { require.NoError(t, err, "test %d: expected: nil, got: %v", i+1, err) } authcode, err := cos.NewAuthcode(authID) if !tc.wantError { require.NoError(t, err, "test %d: NewAuthcode err: %v", i+1, err) } acSig, err := tc.pkt.NewSignedMessage([]byte(authcode), tc.signer) require.NoError(t, err, "test %d: expected: nil, got: %v", i+1, err) cosSig, err := cos.RedeemAuthcode(acSig) if tc.wantError { require.Error(t, err, "test %d: expected error, got: %v", i+1, err) } else { require.NoError(t, err, "test %d: expected: nil, got: %v", i+1, err) require.NotNil(t, cosSig, "test %d: expected not nil, got: %v", i+1, cosSig) } } } func TestCanOnlyRedeemAuthcodeOnce(t *testing.T) { alg := jwa.ES256 signer, _ := util.GenKeyPair(alg) pkt, err := mocks.GenerateMockPKToken(t, signer, alg) require.NoError(t, err, "failed to generate mock PK Token") cos := CreateAuthCosigner(t) cosP := client.CosignerProvider{ Issuer: "https://example.com", CallbackPath: "/mfaredirect", } redirectURI := fmt.Sprintf("%s/%s", "http://localhost:5555", cosP.CallbackPath) // reuse the same authcode twice, it should fail initAuthMsgJson, nonce, err := cosP.CreateInitAuthSig(redirectURI) require.NoError(t, err, "CreateInitAuthSig err: %v", err) sig, err := pkt.NewSignedMessage(initAuthMsgJson, signer) require.NoError(t, err, "NewSignedMessage err: %v", err) authID, err := cos.InitAuth(pkt, sig) require.Empty(t, err) authcode, err := cos.NewAuthcode(authID) require.Empty(t, err) acSig1, err := pkt.NewSignedMessage([]byte(authcode), signer) require.Empty(t, err) acSig2, err := pkt.NewSignedMessage([]byte(authcode), signer) require.Empty(t, err) cosSig, err := cos.RedeemAuthcode(acSig1) require.NotEmpty(t, cosSig) require.Empty(t, err) err = cosP.ValidateCos(cosSig, nonce, redirectURI) require.NoError(t, err, "ValidateCos err: %v", err) // Should fail because authcode has already been issued cosSig, err = cos.RedeemAuthcode(acSig2) require.Empty(t, cosSig) require.ErrorContains(t, err, "authcode has already been redeemed") } func TestNewAuthcodeFailure(t *testing.T) { cosAlg := jwa.ES256 cosSigner, err := util.GenKeyPair(cosAlg) require.NoError(t, err, "failed to generate key pair") hmacKey := []byte{0x1, 0x2, 0x3} store := cosmock.NewAuthStateInMemoryStore(hmacKey) cos := cosigner.AuthCosigner{ Cosigner: cosigner.Cosigner{ Alg: cosAlg, Signer: cosSigner, }, Issuer: "https://example.com", KeyID: "kid1234", AuthStateStore: store, } // Ensure failure if AuthID not recorded by cosigner authID := "123456789ABCEF123456789ABCEF123456789ABCEF123456789ABCEF" authcode, err := cos.NewAuthcode(authID) require.ErrorContains(t, err, "no such authID") require.Empty(t, authcode) } func CreateAuthCosigner(t *testing.T) *cosigner.AuthCosigner { cosAlg := jwa.ES256 signer, err := util.GenKeyPair(cosAlg) require.NoError(t, err, "failed to generate key pair") issuer := "https://example.com" keyID := "kid1234" hmacKey := make([]byte, 64) _, err = rand.Read(hmacKey) require.NoError(t, err, "failed to create auth cosigner") store := cosmock.NewAuthStateInMemoryStore(hmacKey) authCosigner, err := cosigner.New(signer, cosAlg, issuer, keyID, store) require.NoError(t, err, "failed to create auth cosigner") return authCosigner } openpubkey-0.8.0/cosigner/authidissuer.go000066400000000000000000000027631477254274500206020ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cosigner import ( "crypto" "crypto/hmac" "encoding/binary" "encoding/hex" "fmt" "sync/atomic" ) type AuthIDIssuer struct { authIdIter atomic.Uint64 hmacKey []byte } func NewAuthIDIssuer(hmacKey []byte) *AuthIDIssuer { return &AuthIDIssuer{ authIdIter: atomic.Uint64{}, hmacKey: hmacKey, } } func (i *AuthIDIssuer) CreateAuthID(timeNow uint64) (string, error) { authIdInt := i.authIdIter.Add(1) iterAndTime := []byte{} iterAndTime = binary.LittleEndian.AppendUint64(iterAndTime, uint64(authIdInt)) iterAndTime = binary.LittleEndian.AppendUint64(iterAndTime, timeNow) mac := hmac.New(crypto.SHA3_256.New, i.hmacKey) if n, err := mac.Write(iterAndTime); err != nil { return "", err } else if n != 16 { return "", fmt.Errorf("unexpected number of bytes read by HMAC, expected 16, got %d", n) } else { return hex.EncodeToString(mac.Sum(nil)), nil } } openpubkey-0.8.0/cosigner/authidissuer_test.go000066400000000000000000000024701477254274500216340ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cosigner import ( "testing" "github.com/stretchr/testify/require" ) func TestAuthIDs(t *testing.T) { hmacKey := []byte{0x1, 0x2, 0x3} aid := NewAuthIDIssuer(hmacKey) // Test if we get the same value if we supply exact the same time unixTime := uint64(5) authID1, err := aid.CreateAuthID(unixTime) require.NoError(t, err, "failed to create auth ID") authID2, err := aid.CreateAuthID(unixTime) require.NoError(t, err, "failed to create auth ID") require.NotEqualValues(t, authID1, authID2) require.Equal(t, "644117927902f52d3949804c7ce417509d9437eb1240a9bf75725c9f61d5b424", authID1) require.Equal(t, "f7d16adcef9f7d0e72139f0edae98db64c2db1f0cb8b59468d4766e91126f4eb", authID2) } openpubkey-0.8.0/cosigner/authstate.go000066400000000000000000000060111477254274500200610ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cosigner import ( "encoding/json" "fmt" "strings" "github.com/openpubkey/openpubkey/pktoken" ) type AuthState struct { Pkt *pktoken.PKToken Issuer string // ID Token issuer (iss) Aud string // ID Token audience (aud) Sub string // ID Token subject ID (sub) Username string // ID Token email or username DisplayName string // ID Token display name (or username if none given) RedirectURI string // Redirect URI Nonce string // Nonce supplied by user AuthcodeIssued bool // Has an authcode been issued for this auth session AuthcodeRedeemed bool // Was the pkt cosigned } func NewAuthState(pkt *pktoken.PKToken, ruri string, nonce string) (*AuthState, error) { var claims struct { Issuer string `json:"iss"` Aud any `json:"aud"` Sub string `json:"sub"` Email string `json:"email"` } if err := json.Unmarshal(pkt.Payload, &claims); err != nil { return nil, fmt.Errorf("failed to unmarshal PK Token: %w", err) } // An audience can be a string or an array of strings. // // RFC-7519 JSON Web Token (JWT) says: // "In the general case, the "aud" value is an array of case- // sensitive strings, each containing a StringOrURI value. In the // special case when the JWT has one audience, the "aud" value MAY be a // single case-sensitive string containing a StringOrURI value." // https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3 var audience string switch t := claims.Aud.(type) { case string: audience = t case []any: audList := []string{} for _, v := range t { audList = append(audList, v.(string)) } audience = strings.Join(audList, ",") default: return nil, fmt.Errorf("failed to deserialize aud (audience) claim in ID Token: %T", t) } return &AuthState{ Pkt: pkt, Issuer: claims.Issuer, Aud: audience, Sub: claims.Sub, Username: claims.Email, DisplayName: strings.Split(claims.Email, "@")[0], //TODO: Use full name from ID Token RedirectURI: ruri, Nonce: nonce, AuthcodeRedeemed: false, AuthcodeIssued: false, }, nil } type UserKey struct { Issuer string // ID Token issuer (iss) Aud string // ID Token audience (aud) Sub string // ID Token subject ID (sub) } func (as AuthState) UserKey() UserKey { return UserKey{Issuer: as.Issuer, Aud: as.Aud, Sub: as.Sub} } openpubkey-0.8.0/cosigner/authstate_test.go000066400000000000000000000032721477254274500211260ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cosigner_test import ( "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/cosigner" "github.com/openpubkey/openpubkey/pktoken/mocks" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestAuthState(t *testing.T) { // Generate the key pair for our cosigner alg := jwa.ES256 signer, err := util.GenKeyPair(alg) require.NoError(t, err, "failed to generate key pair") pkt, err := mocks.GenerateMockPKToken(t, signer, alg) require.NoError(t, err) ruri := "http://example.com/redirect" nonce := "test-nonce" authState, err := cosigner.NewAuthState(pkt, ruri, nonce) require.NoError(t, err, "failed to create auth state") require.NotNil(t, authState, "auth state is nil") require.Equal(t, ruri, authState.RedirectURI, "redirect uri mismatch") userKey := authState.UserKey() require.NotNil(t, userKey, "user key is nil") require.Equal(t, "mockIssuer", userKey.Issuer, "issuer mismatch") require.Equal(t, "empty", userKey.Aud, "aud mismatch") require.Equal(t, "me", userKey.Sub, "issuer mismatch") } openpubkey-0.8.0/cosigner/authstatestore.go000066400000000000000000000020771477254274500211460ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cosigner import ( "github.com/openpubkey/openpubkey/pktoken" ) type AuthStateStore interface { CreateNewAuthSession(pkt *pktoken.PKToken, ruri string, nonce string) (authID string, err error) LookupAuthState(authID string) (*AuthState, bool) UpdateAuthState(authID string, authState AuthState) error CreateAuthcode(authID string) (authcode string, err error) RedeemAuthcode(authcode string) (authState AuthState, authID string, err error) } openpubkey-0.8.0/cosigner/cosigner.go000066400000000000000000000022371477254274500176760ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cosigner import ( "crypto" "encoding/json" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/pktoken" ) type Cosigner struct { Alg jwa.KeyAlgorithm Signer crypto.Signer } func (c *Cosigner) Cosign(pkt *pktoken.PKToken, cosClaims pktoken.CosignerClaims) ([]byte, error) { jsonBytes, err := json.Marshal(cosClaims) if err != nil { return nil, err } var headers map[string]any if err := json.Unmarshal(jsonBytes, &headers); err != nil { return nil, err } return pkt.SignToken(c.Signer, c.Alg, headers) } openpubkey-0.8.0/cosigner/cosigner_test.go000066400000000000000000000033741477254274500207400ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cosigner_test import ( "testing" "time" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/cosigner" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/pktoken/mocks" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestSimpleCosigner(t *testing.T) { // Generate the key pair for our cosigner alg := jwa.ES256 signer, err := util.GenKeyPair(alg) require.NoError(t, err, "failed to generate key pair") cos := &cosigner.Cosigner{ Alg: alg, Signer: signer, } pkt, err := mocks.GenerateMockPKToken(t, signer, alg) require.NoError(t, err) cosignerClaims := pktoken.CosignerClaims{ Issuer: "example.com", KeyID: "none", Algorithm: cos.Alg.String(), AuthID: "none", AuthTime: time.Now().Unix(), IssuedAt: time.Now().Unix(), Expiration: time.Now().Add(time.Hour).Unix(), RedirectURI: "none", Nonce: "test-nonce", Typ: "COS", } cosToken, err := cos.Cosign(pkt, cosignerClaims) require.NoError(t, err, "failed cosign PK Token") require.NotNil(t, cosToken, "cosign signature is nil") } openpubkey-0.8.0/cosigner/cosignerverifier.go000066400000000000000000000054611477254274500214340ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cosigner import ( "context" "fmt" "time" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/discover" "github.com/openpubkey/openpubkey/pktoken" ) type DefaultCosignerVerifier struct { issuer string options CosignerVerifierOpts } type CosignerVerifierOpts struct { // Strict specifies whether or not a pk token MUST contain a signature by this cosigner. // Defaults to true. Strict *bool // Allows users to set custom function for discovering public key of Cosigner DiscoverPublicKey *discover.PublicKeyFinder } func NewCosignerVerifier(issuer string, options CosignerVerifierOpts) *DefaultCosignerVerifier { v := &DefaultCosignerVerifier{ issuer: issuer, options: options, } // If no custom DiscoverPublicKey function is set, set default if v.options.DiscoverPublicKey == nil { v.options.DiscoverPublicKey = discover.DefaultPubkeyFinder() } // If strict is not set, then default it to true if v.options.Strict == nil { v.options.Strict = new(bool) *v.options.Strict = true } return v } func (v *DefaultCosignerVerifier) Issuer() string { return v.issuer } func (v *DefaultCosignerVerifier) Strict() bool { return *v.options.Strict } func (v *DefaultCosignerVerifier) VerifyCosigner(ctx context.Context, pkt *pktoken.PKToken) error { if pkt.Cos == nil { return fmt.Errorf("no cosigner signature") } // Parse our header header, err := pkt.ParseCosignerClaims() if err != nil { return err } if v.issuer != header.Issuer { return fmt.Errorf("cosigner issuer (%s) doesn't match expected issuer (%s)", header.Issuer, v.issuer) } keyRecord, err := v.options.DiscoverPublicKey.ByKeyID(ctx, v.issuer, header.KeyID) if err != nil { return err } key := keyRecord.PublicKey alg := keyRecord.Alg // Check if it's expired if time.Now().After(time.Unix(header.Expiration, 0)) { return fmt.Errorf("cosigner signature expired") } if header.Algorithm != alg { return fmt.Errorf("key (kid=%s) has alg (%s) which doesn't match alg (%s) in protected", header.KeyID, alg, header.Algorithm) } jwsPubkey := jws.WithKey(jwa.KeyAlgorithmFrom(alg), key) _, err = jws.Verify(pkt.CosToken, jwsPubkey) return err } openpubkey-0.8.0/cosigner/cosignerverifier_test.go000066400000000000000000000054321477254274500224710ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package cosigner_test import ( "context" "encoding/json" "testing" "time" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/openpubkey/openpubkey/cosigner" "github.com/openpubkey/openpubkey/discover" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/pktoken/mocks" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestCosignerVerifier(t *testing.T) { // Generate the key pair for our cosigner alg := jwa.ES256 signer, err := util.GenKeyPair(alg) require.NoError(t, err, "failed to generate key pair") cos := &cosigner.Cosigner{ Alg: alg, Signer: signer, } pkt, err := mocks.GenerateMockPKToken(t, signer, alg) require.NoError(t, err) fakeIssuer := "https://example.com" kid := "1234" cosignerClaims := pktoken.CosignerClaims{ Issuer: fakeIssuer, KeyID: kid, Algorithm: cos.Alg.String(), AuthID: "none", AuthTime: time.Now().Unix(), IssuedAt: time.Now().Unix(), Expiration: time.Now().Add(time.Hour).Unix(), RedirectURI: "none", Nonce: "test-nonce", Typ: "COS", } cosToken, err := cos.Cosign(pkt, cosignerClaims) require.NoError(t, err, "failed cosign PK Token") require.NotNil(t, cosToken, "cosign signature is nil") err = pkt.AddSignature(cosToken, pktoken.COS) require.NoError(t, err, "failed to add cosign signature to pk token") mockPublicKeyFinder := func(ctx context.Context, issuer string) ([]byte, error) { keySet := jwk.NewSet() jwkKey, err := jwk.PublicKeyOf(signer) if err != nil { return nil, err } if err := jwkKey.Set(jwk.AlgorithmKey, alg); err != nil { return nil, err } if err := jwkKey.Set(jwk.KeyIDKey, kid); err != nil { return nil, err } if err := keySet.AddKey(jwkKey); err != nil { return nil, err } return json.MarshalIndent(keySet, "", " ") } cosVerifier := cosigner.NewCosignerVerifier(fakeIssuer, cosigner.CosignerVerifierOpts{ DiscoverPublicKey: &discover.PublicKeyFinder{ JwksFunc: mockPublicKeyFinder, }, }) err = cosVerifier.VerifyCosigner(context.Background(), pkt) require.NoError(t, err, "failed to verify cosigned pk token") } openpubkey-0.8.0/cosigner/mocks/000077500000000000000000000000001477254274500166465ustar00rootroot00000000000000openpubkey-0.8.0/cosigner/mocks/authstatestore.go000066400000000000000000000133531477254274500222610ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package mocks import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "strings" "sync" "time" "github.com/openpubkey/openpubkey/cosigner" "github.com/openpubkey/openpubkey/pktoken" ) // This is intended for testing purposes. The locking strategy used is not // particularly efficient. Anyone building a Cosigner should use the interface // above to replace this in-memory store with a database. type AuthStateInMemoryStore struct { AuthIDIssuer *cosigner.AuthIDIssuer AuthStateMap map[string]*cosigner.AuthState AuthCodeMap map[string]string AuthStateMapLock sync.RWMutex AuthcodeMapLock sync.RWMutex } func NewAuthStateInMemoryStore(hmacKey []byte) *AuthStateInMemoryStore { return &AuthStateInMemoryStore{ AuthStateMap: make(map[string]*cosigner.AuthState), AuthCodeMap: make(map[string]string), AuthcodeMapLock: sync.RWMutex{}, AuthStateMapLock: sync.RWMutex{}, AuthIDIssuer: cosigner.NewAuthIDIssuer(hmacKey), } } // Writes to the AuthState are not concurrency safe, do not write func (s *AuthStateInMemoryStore) LookupAuthState(authID string) (*cosigner.AuthState, bool) { s.AuthStateMapLock.RLock() as, ok := s.AuthStateMap[authID] s.AuthStateMapLock.RUnlock() return as, ok // Pass by value to prevent writes to the original } func (s *AuthStateInMemoryStore) UpdateAuthState(authID string, authState cosigner.AuthState) error { s.AuthStateMapLock.Lock() defer s.AuthStateMapLock.Unlock() if _, ok := s.AuthStateMap[authID]; !ok { return fmt.Errorf("failed to upload auth session because authID specified matches no session") } else { s.AuthStateMap[authID] = &authState return nil } } func (s *AuthStateInMemoryStore) CreateNewAuthSession(pkt *pktoken.PKToken, ruri string, nonce string) (string, error) { var claims struct { Issuer string `json:"iss"` Aud any `json:"aud"` Sub string `json:"sub"` Email string `json:"email"` } if err := json.Unmarshal(pkt.Payload, &claims); err != nil { return "", fmt.Errorf("failed to unmarshal PK Token: %w", err) } // An audience can be a string or an array of strings. // // RFC-7519 JSON Web Token (JWT) says: // "In the general case, the "aud" value is an array of case- // sensitive strings, each containing a StringOrURI value. In the // special case when the JWT has one audience, the "aud" value MAY be a // single case-sensitive string containing a StringOrURI value." // https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3 var audience string switch t := claims.Aud.(type) { case string: audience = t case []any: audList := []string{} for _, v := range t { audList = append(audList, v.(string)) } audience = strings.Join(audList, ",") default: return "", fmt.Errorf("failed to deserialize aud (audience) claim in ID Token: %T", t) } authState := &cosigner.AuthState{ Pkt: pkt, Issuer: claims.Issuer, Aud: audience, Sub: claims.Sub, Username: claims.Email, DisplayName: strings.Split(claims.Email, "@")[0], //TODO: Use full name from ID Token RedirectURI: ruri, Nonce: nonce, AuthcodeRedeemed: false, AuthcodeIssued: false, } if authID, err := s.AuthIDIssuer.CreateAuthID(uint64(time.Now().Unix())); err != nil { return "", err } else { s.AuthStateMapLock.Lock() if _, ok := s.AuthStateMap[authID]; ok { return "", fmt.Errorf("specified authID is already in use") } s.AuthStateMap[authID] = authState s.AuthStateMapLock.Unlock() return authID, nil } } func (s *AuthStateInMemoryStore) CreateAuthcode(authID string) (string, error) { authCodeBytes := make([]byte, 32) if _, err := rand.Read(authCodeBytes); err != nil { return "", err } authcode := hex.EncodeToString(authCodeBytes) // We take a full read write lock here to ensure we don't issue an authcode twice for the same session s.AuthStateMapLock.Lock() defer s.AuthStateMapLock.Unlock() if authState, ok := s.AuthStateMap[authID]; !ok { return "", fmt.Errorf("no such authID") } else if authState.AuthcodeIssued { return "", fmt.Errorf("authcode already issued for this authID") } else { s.AuthcodeMapLock.Lock() defer s.AuthcodeMapLock.Unlock() if _, ok := s.AuthCodeMap[authcode]; ok { return "", fmt.Errorf("authcode collision implies randomness failure in RNG") } authState.AuthcodeIssued = true s.AuthCodeMap[authcode] = authID return authcode, nil } } func (s *AuthStateInMemoryStore) RedeemAuthcode(authcode string) (cosigner.AuthState, string, error) { s.AuthcodeMapLock.RLock() authID, authcodeFound := s.AuthCodeMap[authcode] s.AuthcodeMapLock.RUnlock() if !authcodeFound { return cosigner.AuthState{}, "", fmt.Errorf("invalid authcode") } else { s.AuthStateMapLock.Lock() defer s.AuthStateMapLock.Unlock() authState := s.AuthStateMap[authID] if !authState.AuthcodeIssued { // This should never happen return cosigner.AuthState{}, "", fmt.Errorf("no authcode issued for this authID") } if authState.AuthcodeRedeemed { return cosigner.AuthState{}, "", fmt.Errorf("authcode has already been redeemed") } authState.AuthcodeRedeemed = true return *authState, authID, nil } } openpubkey-0.8.0/cosigner/msgs/000077500000000000000000000000001477254274500165035ustar00rootroot00000000000000openpubkey-0.8.0/cosigner/msgs/msgs.go000066400000000000000000000014501477254274500200030ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package msgs type InitMFAAuth struct { Issuer string `json:"iss"` RedirectUri string `json:"ruri"` TimeSigned int64 `json:"time"` Nonce string `json:"nonce"` } openpubkey-0.8.0/discover/000077500000000000000000000000001477254274500155375ustar00rootroot00000000000000openpubkey-0.8.0/discover/discover.go000066400000000000000000000160161477254274500177100ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package discover import ( "context" "crypto" "crypto/ecdsa" "crypto/rsa" "encoding/json" "fmt" "io" "net/http" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/gq" "github.com/openpubkey/openpubkey/util" oidcclient "github.com/zitadel/oidc/v3/pkg/client" ) type PublicKeyRecord struct { PublicKey crypto.PublicKey Alg string Issuer string } func NewPublicKeyRecord(key jwk.Key, issuer string) (*PublicKeyRecord, error) { var pubKey interface{} if key.Algorithm() == jwa.RS256 { pubKey = new(rsa.PublicKey) } else if key.Algorithm() == jwa.ES256 { pubKey = new(ecdsa.PublicKey) } else if key.Algorithm().String() == "" { // OPs such as azure (microsoft) do not specify alg in their JWKS. To // handle this case, assume no alg in JWKS means RSA as OIDC requires // OPs use RSA. pubKey = new(rsa.PublicKey) } else { return nil, fmt.Errorf("JWK has unsupported alg (%s)", key.Algorithm()) } err := key.Raw(&pubKey) if err != nil { return nil, fmt.Errorf("failed to decode public key: %w", err) } var alg string if key.Algorithm().String() == "" { alg = jwa.RS256.String() } else { alg = key.Algorithm().String() } return &PublicKeyRecord{ PublicKey: pubKey, Alg: alg, Issuer: issuer, }, nil } func DefaultPubkeyFinder() *PublicKeyFinder { return &PublicKeyFinder{ JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) { return GetJwksByIssuer(ctx, issuer, nil) }, } } type JwksFetchFunc func(ctx context.Context, issuer string) ([]byte, error) type PublicKeyFinder struct { JwksFunc JwksFetchFunc } // GetJwksByIssuer fetches the JWKS from the issuer's JWKS endpoint found at the // issuer's well-known configuration. It doesn't attempt to parse the response // but instead returns the JSON bytes of the JWKS. If httpClient is nil, then // http.DefaultClient is used when fetching. func GetJwksByIssuer(ctx context.Context, issuer string, httpClient *http.Client) ([]byte, error) { if httpClient == nil { httpClient = http.DefaultClient } discConf, err := oidcclient.Discover(ctx, issuer, httpClient) if err != nil { return nil, fmt.Errorf("failed to call OIDC discovery endpoint: %w", err) } request, err := http.NewRequestWithContext(ctx, "GET", discConf.JwksURI, nil) if err != nil { return nil, err } response, err := httpClient.Do(request) if err != nil { return nil, err } defer response.Body.Close() resp, err := httpClient.Get(discConf.JwksURI) if err != nil { return nil, fmt.Errorf("failed to fetch to JWKS: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("received non-200 from JWKS URI: %s", http.StatusText(response.StatusCode)) } return io.ReadAll(resp.Body) } func (f *PublicKeyFinder) fetchAndParseJwks(ctx context.Context, issuer string) (jwk.Set, error) { jwksJson, err := f.JwksFunc(ctx, issuer) if err != nil { return nil, fmt.Errorf(`failed to fetch JWKS: %w`, err) } jwks := jwk.NewSet() if err := json.Unmarshal(jwksJson, jwks); err != nil { return nil, fmt.Errorf(`failed to unmarshal JWKS: %w`, err) } return jwks, nil } // ByToken looks up an OP public key in the JWKS using the KeyID (kid) in the // protected header from the supplied token. func (f *PublicKeyFinder) ByToken(ctx context.Context, issuer string, token []byte) (*PublicKeyRecord, error) { jwt, err := jws.Parse(token) if err != nil { return nil, fmt.Errorf("error parsing JWK in JWKS: %w", err) } // a JWT is guaranteed to have exactly one signature headers := jwt.Signatures()[0].ProtectedHeaders() if headers.Algorithm() == gq.GQ256 { origHeadersJson, err := util.Base64DecodeForJWT([]byte(headers.KeyID())) if err != nil { return nil, fmt.Errorf("error base64 decoding GQ kid: %w", err) } // If GQ then replace the GQ headers with the original headers err = json.Unmarshal(origHeadersJson, &headers) if err != nil { return nil, fmt.Errorf("error unmarshalling GQ kid to original headers: %w", err) } } // Use the KeyID (kid) in the headers from the supplied token to look up the public key return f.ByKeyID(ctx, issuer, headers.KeyID()) } // ByKeyID looks up an OP public key in the JWKS using the KeyID (kid) supplied. // If no KeyID (kid) exists in the header and there is only one key in the JWKS, // that key is returned. This is useful for cases where an OP may not set a KeyID // (kid) in the JWT header. // // The JWT RFC states that it is acceptable to not use a KeyID (kid) if there is // only one key in the JWKS: // "The "kid" (key ID) parameter is used to match a specific key. This is used, // for instance, to choose among a set of keys within a JWK Set // during key rollover. The structure of the "kid" value is // unspecified. When "kid" values are used within a JWK Set, different // keys within the JWK Set SHOULD use distinct "kid" values. (One // example in which different keys might use the same "kid" value is if // they have different "kty" (key type) values but are considered to be // equivalent alternatives by the application using them.) The "kid" // value is a case-sensitive string. Use of this member is OPTIONAL. // When used with JWS or JWE, the "kid" value is used to match a JWS or // JWE "kid" Header Parameter value." - RFC 7517 // https://datatracker.ietf.org/doc/html/rfc7517#section-4.5 func (f *PublicKeyFinder) ByKeyID(ctx context.Context, issuer string, keyID string) (*PublicKeyRecord, error) { jwks, err := f.fetchAndParseJwks(ctx, issuer) if err != nil { return nil, fmt.Errorf(`failed to fetch JWK set: %w`, err) } // If keyID is blank and there is only one key in the JWKS, return that key key, ok := jwks.LookupKeyID(keyID) if ok { return NewPublicKeyRecord(key, issuer) } return nil, fmt.Errorf("no matching public key found for kid %s", keyID) } func (f *PublicKeyFinder) ByJKT(ctx context.Context, issuer string, jkt string) (*PublicKeyRecord, error) { jwks, err := f.fetchAndParseJwks(ctx, issuer) if err != nil { return nil, err } it := jwks.Keys(ctx) for it.Next(ctx) { key := it.Pair().Value.(jwk.Key) jktOfKey, err := key.Thumbprint(crypto.SHA256) if err != nil { return nil, fmt.Errorf("error computing Thumbprint of key in JWKS: %w", err) } jktOfKeyB64 := util.Base64EncodeForJWT(jktOfKey) if jkt == string(jktOfKeyB64) { return NewPublicKeyRecord(key, issuer) } } return nil, fmt.Errorf("no matching public key found for jkt %s", jkt) } openpubkey-0.8.0/discover/discover_test.go000066400000000000000000000263441477254274500207540ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package discover import ( "context" "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/rsa" "encoding/json" "fmt" "testing" "time" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/gq" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestNewPublicKeyRecord(t *testing.T) { commonIssuer := "https://example.com" tests := []struct { name string keyJson map[string]string expectedAlg string shouldError bool }{ { name: "alg=RS256", keyJson: map[string]string{ jwk.AlgorithmKey: "RS256", jwk.KeyTypeKey: "RSA", jwk.RSAEKey: "AQAB", jwk.RSANKey: "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", }, expectedAlg: jwa.RS256.String(), }, { name: "alg=ES256", keyJson: map[string]string{ jwk.AlgorithmKey: "ES256", jwk.KeyTypeKey: "EC", jwk.ECDSACrvKey: "P-256", jwk.ECDSAXKey: "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", jwk.ECDSAYKey: "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", jwk.ECDSADKey: "870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE", }, expectedAlg: jwa.ES256.String(), }, { name: "alg is missing", keyJson: map[string]string{ jwk.KeyTypeKey: "RSA", jwk.KeyUsageKey: "sig", jwk.RSANKey: "vRIL3aZt-xVqOZgMOr71ltWe9YY2Wf_B28C4Jl2nBSTEcFnf_eqOHZ8yzUBbLc4Nti2_ETcCsTUNuzS368BWkSgxc45JBH1wFSoWNFUSXaPt8mRwJYTF0H32iNhw_tBb9mvdQVgVs4Ci0dVJRYiz-ilk3PeO8wzlwRuwWIsaKFYlMyOKG9DVFbg93DmP5Tjq3C3oJlATyhAiJJc1T2trEP8960an33dDEaWwVAHh3c_34meAO4R6kLzIq0JnSsZMYB9O_6bMyIlzxmdZ8F442SynCUHxhnIh3yZew-xDdeHr6Ofl7KeVUcvSiZP9X44CaVJvknXQbBYNl-H7YF5RgQ", jwk.RSAEKey: "AQAB", }, // If "alg" key is missing, code assumes algorithm is RSA/RS256 expectedAlg: jwa.RS256.String(), }, { name: "alg is unknown", keyJson: map[string]string{ jwk.AlgorithmKey: "RS512", jwk.KeyTypeKey: "RSA", jwk.KeyUsageKey: "sig", jwk.RSANKey: "vRIL3aZt-xVqOZgMOr71ltWe9YY2Wf_B28C4Jl2nBSTEcFnf_eqOHZ8yzUBbLc4Nti2_ETcCsTUNuzS368BWkSgxc45JBH1wFSoWNFUSXaPt8mRwJYTF0H32iNhw_tBb9mvdQVgVs4Ci0dVJRYiz-ilk3PeO8wzlwRuwWIsaKFYlMyOKG9DVFbg93DmP5Tjq3C3oJlATyhAiJJc1T2trEP8960an33dDEaWwVAHh3c_34meAO4R6kLzIq0JnSsZMYB9O_6bMyIlzxmdZ8F442SynCUHxhnIh3yZew-xDdeHr6Ofl7KeVUcvSiZP9X44CaVJvknXQbBYNl-H7YF5RgQ", jwk.RSAEKey: "AQAB", }, shouldError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Logf("TestNewPublicKeyRecord: keyJson: %#v", tt.keyJson) keyJsonBytes, err := json.Marshal(tt.keyJson) require.NoError(t, err, "failed to marshal keyJson map into JSON") key, err := jwk.ParseKey(keyJsonBytes) require.NoError(t, err, "jwk.ParseKey failed to parse keyJsonBytes") gotPublicKeyRecord, err := NewPublicKeyRecord(key, commonIssuer) if tt.shouldError { require.Error(t, err) } else { require.NoError(t, err, "NewPublicKeyRecord should succeed") require.Equal(t, tt.expectedAlg, gotPublicKeyRecord.Alg) require.Equal(t, commonIssuer, gotPublicKeyRecord.Issuer) require.NotNil(t, gotPublicKeyRecord.PublicKey) } }) } } func TestPublicKeyFinder(t *testing.T) { ctx := context.Background() issuer := "testIssuer" publicKeys := []crypto.PublicKey{} keyIDs := []string{} algs := []string{} idTokens := [][]byte{} for i := 0; i < 4; i++ { algOp := "RS256" signer, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) publicKeys = append(publicKeys, signer.Public()) keyIDs = append(keyIDs, fmt.Sprintf("%d", i)) algs = append(algs, algOp) idToken := CreateIDToken(t, issuer, signer, algOp, keyIDs[i]) idTokens = append(idTokens, idToken) } // Let's add something unexpected signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) publicKeys = append(publicKeys, signer.Public()) keyIDs = append(keyIDs, "ABCDEF") algs = append(algs, string(jwa.ES256)) idToken := CreateIDToken(t, issuer, signer, string(jwa.ES256), "ABCDEF") idTokens = append(idTokens, idToken) mockJwks, err := MockGetJwksByIssuer(publicKeys, keyIDs, algs) require.NoError(t, err) finder := &PublicKeyFinder{ JwksFunc: mockJwks, } for i := 0; i < len(publicKeys); i++ { pubkeyRecord, err := finder.ByKeyID(ctx, issuer, keyIDs[i]) require.NoError(t, err) require.Equal(t, publicKeys[i], pubkeyRecord.PublicKey) require.Equal(t, algs[i], pubkeyRecord.Alg) require.Equal(t, issuer, pubkeyRecord.Issuer) } for i := 0; i < len(publicKeys); i++ { pubkeyRecord, err := finder.ByToken(ctx, issuer, idTokens[i]) require.NoError(t, err) require.Equal(t, publicKeys[i], pubkeyRecord.PublicKey) require.Equal(t, algs[i], pubkeyRecord.Alg) require.Equal(t, issuer, pubkeyRecord.Issuer) } for i := 0; i < len(publicKeys); i++ { jwk, err := jwk.FromRaw(publicKeys[i]) require.NoError(t, err) jkt, err := jwk.Thumbprint(crypto.SHA256) require.NoError(t, err) jktB64 := util.Base64EncodeForJWT(jkt) pubkeyRecord, err := finder.ByJKT(ctx, issuer, string(jktB64)) require.NoError(t, err) require.Equal(t, publicKeys[i], pubkeyRecord.PublicKey) require.Equal(t, algs[i], pubkeyRecord.Alg) require.Equal(t, issuer, pubkeyRecord.Issuer) } // Test failure cases pubkeyRecord, err := finder.ByKeyID(ctx, issuer, "not-a-key-id") require.Error(t, err) require.Nil(t, pubkeyRecord) wrongSigner, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) wrongIdToken := CreateIDToken(t, issuer, wrongSigner, "RS256", "not-a-key-id") pubkeyRecord, err = finder.ByToken(ctx, issuer, wrongIdToken) require.EqualError(t, err, "no matching public key found for kid not-a-key-id") require.Nil(t, pubkeyRecord) // Tests we don't return the wrong Public Key even if not kid is supplied wrongIdToken2 := CreateIDToken(t, issuer, wrongSigner, "RS256", "") pubkeyRecord, err = finder.ByToken(ctx, issuer, wrongIdToken2) require.EqualError(t, err, "no matching public key found for kid ") require.Nil(t, pubkeyRecord) wrongJKT := "not-a-jkt" pubkeyRecord, err = finder.ByJKT(ctx, issuer, wrongJKT) require.EqualError(t, err, "no matching public key found for jkt not-a-jkt") require.Nil(t, pubkeyRecord) } func TestByTokenWhenOnePublicKey(t *testing.T) { ctx := context.Background() issuer := "testIssuer" publicKeys := []crypto.PublicKey{} algs := []string{} idTokens := [][]byte{} algOp := "RS256" signer, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) publicKeys = append(publicKeys, signer.Public()) algs = append(algs, algOp) idToken := CreateIDToken(t, issuer, signer, algOp, "") idTokens = append(idTokens, idToken) mockJwks, err := MockGetJwksByIssuer(publicKeys, nil, algs) require.NoError(t, err) finder := &PublicKeyFinder{ JwksFunc: mockJwks, } for i := 0; i < len(publicKeys); i++ { pubkeyRecord, err := finder.ByKeyID(ctx, issuer, "1234") require.EqualError(t, err, "no matching public key found for kid 1234", "no kid (keyID) ByKeyID should return nothing") require.Nil(t, pubkeyRecord) } for i := 0; i < len(publicKeys); i++ { pubkeyRecord, err := finder.ByToken(ctx, issuer, idTokens[i]) require.NoError(t, err) require.Equal(t, publicKeys[i], pubkeyRecord.PublicKey) require.Equal(t, algs[i], pubkeyRecord.Alg) require.Equal(t, issuer, pubkeyRecord.Issuer) } for i := 0; i < len(publicKeys); i++ { jwk, err := jwk.FromRaw(publicKeys[i]) require.NoError(t, err) jkt, err := jwk.Thumbprint(crypto.SHA256) require.NoError(t, err) jktB64 := util.Base64EncodeForJWT(jkt) pubkeyRecord, err := finder.ByJKT(ctx, issuer, string(jktB64)) require.NoError(t, err) require.Equal(t, publicKeys[i], pubkeyRecord.PublicKey) require.Equal(t, algs[i], pubkeyRecord.Alg) require.Equal(t, issuer, pubkeyRecord.Issuer) } } func TestGQTokens(t *testing.T) { ctx := context.Background() issuer := "testIssuer" publicKeys := []crypto.PublicKey{} keyIDs := []string{} algs := []string{} idTokens := [][]byte{} for i := 0; i < 4; i++ { algOp := "RS256" signer, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) publicKeys = append(publicKeys, signer.Public()) keyIDs = append(keyIDs, fmt.Sprintf("%d", i)) algs = append(algs, algOp) idToken := CreateIDToken(t, issuer, signer, algOp, keyIDs[i]) rsaKey, ok := signer.Public().(*rsa.PublicKey) require.True(t, ok) gqToken, err := gq.GQ256SignJWT(rsaKey, idToken) require.NoError(t, err) idTokens = append(idTokens, gqToken) } mockJwks, err := MockGetJwksByIssuer(publicKeys, keyIDs, algs) require.NoError(t, err) finder := &PublicKeyFinder{ JwksFunc: mockJwks, } for i := 0; i < len(publicKeys); i++ { pubkeyRecord, err := finder.ByKeyID(ctx, issuer, keyIDs[i]) require.NoError(t, err) require.Equal(t, publicKeys[i], pubkeyRecord.PublicKey) require.Equal(t, algs[i], pubkeyRecord.Alg) require.Equal(t, issuer, pubkeyRecord.Issuer) } for i := 0; i < len(publicKeys); i++ { pubkeyRecord, err := finder.ByToken(ctx, issuer, idTokens[i]) require.NoError(t, err) require.Equal(t, publicKeys[i], pubkeyRecord.PublicKey) require.Equal(t, algs[i], pubkeyRecord.Alg) require.Equal(t, issuer, pubkeyRecord.Issuer) } for i := 0; i < len(publicKeys); i++ { jwk, err := jwk.FromRaw(publicKeys[i]) require.NoError(t, err) jkt, err := jwk.Thumbprint(crypto.SHA256) require.NoError(t, err) jktB64 := util.Base64EncodeForJWT(jkt) pubkeyRecord, err := finder.ByJKT(ctx, issuer, string(jktB64)) require.NoError(t, err) require.Equal(t, publicKeys[i], pubkeyRecord.PublicKey) require.Equal(t, algs[i], pubkeyRecord.Alg) require.Equal(t, issuer, pubkeyRecord.Issuer) } } func CreateIDToken(t *testing.T, issuer string, signer crypto.Signer, alg string, kid string) []byte { headers := jws.NewHeaders() err := headers.Set(jws.AlgorithmKey, alg) require.NoError(t, err) // This lets us test JKT behavior when there is no kid if kid != "" { err := headers.Set(jws.KeyIDKey, kid) require.NoError(t, err) } err = headers.Set(jws.TypeKey, "JWT") require.NoError(t, err) payload := map[string]any{ "sub": "me", "aud": "also me", "iss": issuer, "iat": time.Now().Unix(), } payloadBytes, err := json.Marshal(payload) require.NoError(t, err) idToken, err := jws.Sign( payloadBytes, jws.WithKey( jwa.KeyAlgorithmFrom(alg), signer, jws.WithProtectedHeaders(headers), ), ) require.NoError(t, err) return idToken } openpubkey-0.8.0/discover/mocks.go000066400000000000000000000041751477254274500172110ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package discover import ( "context" "crypto" "encoding/json" "github.com/lestrrat-go/jwx/v2/jwk" ) func MockGetJwksByIssuer(publicKeys []crypto.PublicKey, keyIDs []string, algs []string) (JwksFetchFunc, error) { // Create JWKS (JWK Set) jwks := jwk.NewSet() for i, publicKey := range publicKeys { jwkKey, err := jwk.PublicKeyOf(publicKey) if err != nil { return nil, err } if err := jwkKey.Set(jwk.AlgorithmKey, algs[i]); err != nil { return nil, err } if keyIDs != nil { if err := jwkKey.Set(jwk.KeyIDKey, keyIDs[i]); err != nil { return nil, err } } // Put our jwk into a set if err := jwks.AddKey(jwkKey); err != nil { return nil, err } } jwksJson, err := json.Marshal(jwks) if err != nil { return nil, err } return func(ctx context.Context, issuer string) ([]byte, error) { return jwksJson, nil }, nil } func MockGetJwksByIssuerOneKey(publicKey crypto.PublicKey, keyID string, alg string) (JwksFetchFunc, error) { // Create JWKS (JWK Set) jwkKey, err := jwk.PublicKeyOf(publicKey) if err != nil { return nil, err } if err := jwkKey.Set(jwk.AlgorithmKey, alg); err != nil { return nil, err } if err := jwkKey.Set(jwk.KeyIDKey, keyID); err != nil { return nil, err } // Put our jwk into a set jwks := jwk.NewSet() if err := jwks.AddKey(jwkKey); err != nil { return nil, err } jwksJson, err := json.Marshal(jwks) if err != nil { return nil, err } return func(ctx context.Context, issuer string) ([]byte, error) { return jwksJson, nil }, nil } openpubkey-0.8.0/docs/000077500000000000000000000000001477254274500146515ustar00rootroot00000000000000openpubkey-0.8.0/docs/FAQ.md000066400000000000000000000233001477254274500156000ustar00rootroot00000000000000# OpenPubkey FAQ ## What is the difference between Sigstore and OpenPubkey? OpenPubkey cannot really be compared to Sigstore. Sigstore is an end-to-end artifact signing solution, whereas OpenPubkey only binds public keys to OIDC identities for use as part of a larger signing solution. OpenPubkey is complementary to Sigstore, for example https://github.com/sigstore/fulcio/issues/1056. As stated in the Related Work section of [the OpenPubkey paper](https://eprint.iacr.org/2023/296.pdf): > Sigstore [31, 42] is an open source project for signing and verifying software artifacts. Users can sign under their OpenID Connect identity by using the sigstore Fulcio Certificate Authority [43] which uses an immutable log to store a mapping between an OpenID Connect ID Token and a short lived public key enabling parties to attribute signatures to identities. The Fulcio CA (Certificate Authority) is trusted to create this mapping between an ID Token and a public key. Using OpenPubkey this trust can be eliminated as OpenPubkey does not need a trusted party to map ID Tokens to public keys. The Fulcio CA could in turn help OpenPubkey by acting as a public OpenPubkey verifier and OP public key database. ## How does OpenPubkey ensure the nonce claim functions as nonce? In the user-identity scenario the CIC (Client-Instance Claims) that contains the user's public key is hashed to the `nonce` claim in the ID Token. As OIDC requires that this field never repeat, OpenPubkey includes a random value, rz, in the CIC. Thus the hash of the CIC is always different and random. This maintains the required properties needed by the `nonce` claim in OIDC. ```golang CIC = {'rz': crypto.random(), 'upk': , 'alg': 'EC256'} IDToken.nonce = SHA3(CIC) ``` ## Does OpenPubkey present a privacy leak? The PK Tokens used by OpenPubkey contain the claims from the OIDC ID Tokens of the signer, so making them public necessarily makes those claims public too. This may include elements of the signer’s identity such as the signer’s name or email addresses. It is up to users of OpenPubkey as to whether or not PK Tokens are made public. In a public artifact signing scenario, it could be argued that these claims are the very claims upon which trust in the artifact should be based. However, some OIDC providers may include claims that the signer may wish to keep private. Users of OpenPubkey should consider carefully which OIDC providers to integrate with. Most OpenID Providers (OP) allow you to scope the fields in the ID Tokens. For instance Google’s OP is by default scoped to only include userinfo-email claims: name, email address and icon. Given that the purpose of OpenPubkey is to enable parties to verify that a particular identity, e.g., ethan@bastion.com, produced a particular signature, if you do not want signatures to be associated with OIDC identities, then OpenPubkey may not be a good fit for your use case. In other OpenPubkey deployment scenarios, such as those employed by BastionZero, the ID Tokens are not made publicly available. ## Can the ID Tokens contained in PK Token be replayed against OIDC Resource Providers? Although not present in the original OpenPubkey paper, GQ signatures have now been integrated so that the OpenID Provider's (OP) signature can be stripped from the ID Token, and a proof of the OP's signature published in its place. This prevents the ID Token present in the PK Token from being used against any OIDC resource providers as the original signature has been removed without compromising any of the assurances that the original OP's signature provided. We follow the approach specified in the paper: [Reducing Trust in Automated Certificate Authorities via Proofs-of-Authentication.](https://arxiv.org/abs/2307.08201). For user-identity scenarios in which the PK Token is not made public, GQ signatures are not required. GQ Signatures are required for all current workload-identity use cases. ## Is it a problem that GQ Signatures only work with RSA signatures? No because the OpenID Connect spec requires that all OPs (OpenID Providers) support RSA signatures. > OPs MUST support signing ID Tokens with the RSA SHA-256 algorithm (an alg value of RS256), unless the OP only supports returning ID Tokens from the Token Endpoint (as is the case for the Authorization Code Flow) and only allows Clients to register specifying none as the requested ID Token signing algorithm. From [OpenID Connect Core 1.0 - Section 15.1 Mandatory to Implement Features for All OpenID Providers](https://openid.net/specs/openid-connect-core-1_0.html#ServerMTI). ## What about key management? OpenPubkey assumes that all identity-held key pairs are ephemeral. You generate them as needed and delete them when you are done. No key management headaches. ## Should I be putting so much trust in a single OpenID Provider (OP)? Currently anyone using OIDC is trusting a single OpenID Provider. OpenPubkey improves on this by providing an optional protocol for the user-identity scenario (interactive browser authentication with the OIDC provider) that removes the OP as a single point of compromise. This protocol independently authenticates the user via MultiFactor Authentication (MFA) and then cosigns the user's PK Token. We call this the MFA-cosigner. If you desire to remove the OIDC Provider as a single point of compromise, consider requiring the use of an OpenPubkey MFA Cosigner. In the workload identity scenario, this is not supported. ## How does OpenPubkey handle OP (OpenID Provider) public key rollover? OPs (OpenID Providers) issue ID Tokens by signing them. As required by OpenID Connect, OPs make their public keys avalaible at a JWKS (JSON Web Key Set) URI. Anyone can download the OP's public keys from the JWKS URI and verify an OP's signatures on an ID Token. The location of the JWKS URI is defined in the OPs ["/.well-known/openid-configuration"](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig). OPs rotate the public and signing keys they use for ID Tokens. | OpenID Provider | .well-known/openid-configuration | JWKS URI | ~key rotation | | -------------| ------------- | ------------- | ------------- | | Google | https://accounts.google.com/.well-known/openid-configuration | https://www.googleapis.com/oauth2/v3/certs |~14 days | | GitHub Actions | https://token.actions.githubusercontent.com/.well-known/openid-configuration | https://token.actions.githubusercontent.com/.well-known/jwks |~84 days | | Gitlab | https://gitlab.com/.well-known/openid-configuration | https://gitlab.com/oauth/discovery/keys |? days | | Microsoft | https://login.microsoftonline.com/common/.well-known/openid-configuration | https://login.microsoftonline.com/common/discovery/v2.0/keys |? days | OpenPubkey relies on verifiers being able to check the OP's signature on the ID Token's contained in the PK Token. For many use cases, such as authenticating access to a server, a user can request a new ID Token after the OP rotates their keys. Such use cases do not require that PK Tokens remain verifiable beyond an OP key rotation. However in the case in which the PK Token is being used to generate a public signature, it is necessary that verifiers can check the OP's signature on an ID Token even after the OP rotates their keys. Below are a few of the proposed methods of ensuring the verifiability of signatures after the OP rotates signing keys. This is a non-exhaustive list. ### What about OP Public Keys via TUF (The Update Framework)? Docker has proposed including a log of past OP public keys in the signed [TUF](https://theupdateframework.io/) state which is distributed with Docker Offical Images (DOIs). This seems like a natural fit for the Docker use case as DOIs already depend on the integrity of TUF. ### Can you tell me more about certificate transparency logs? [Reducing Trust in Automated Certificate Authorities via Proofs-of-Authentication](https://arxiv.org/pdf/2307.08201.pdf) proposes JWT Ledger, an approach that employs a certificate transparency log and audit mechanism to ensure ID Token's can continue to be verified even after the OP public keys are rotated off of the OP's JWKS URI. It states: > To decrease the risk that the JWK Ledger will present false information to users, this ledger is backed by a transparency log. > The pace of updates to this log should be relatively low, occurring > only when the IdP rotates verification keys. Therefore, witnesses > for the log can verify the current state of the key set on each update; they also check that no entries other than the given key set > change have been added to the log. Clients can requires a quorum > of witnesses on the JWK Ledger digest. When a client requests > the key set for a given timestamp, the ledger serves two entries. > The client checks that the timestamp of the first entry precedes > the requested timestamp, that the timestamp of the second entry > follows the requested timestamp, and that the entries are adjacent > in the log. This convinces the client that the key set was valid at > the given time. This is similar to the approach sketched in _Appendix C: Archival Verification With Certificate Transparency Logs_ of the [OpenPubkey paper](https://eprint.iacr.org/2023/296.pdf). ### What about archival verifiers? _Section 3.5.3: Archival Verification_ of the [OpenPubkey paper](https://eprint.iacr.org/2023/296.pdf) proposes archival verifiers which store a log of all past OP public keys. This approach is simple and does not require a transparency log. The main disadvantage is that you can't verify signatures from before the verifier started logging public keys. ## What are some other resources I can read to learn more about OpenPubkey? We love this question! See our wiki for the [OpenPubkey Reading List](https://github.com/openpubkey/openpubkey/wiki/OpenPubkey-Reading-List). openpubkey-0.8.0/docs/idtokens.md000066400000000000000000000100231477254274500170070ustar00rootroot00000000000000# ID Token Zoo This document contains example ID Tokens from different providers. We do include the signatures. ## Azure ### ID Token ```json { "payload": { "ver": "2.0", "iss": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0", "sub": "AAAAAAAAAAAAAAAAAAAAAJ8PFm0pjpXKQouYRalE11g", "aud": "bd345b9c-6902-400d-9e18-45abdf0f698f", "exp": 1737500954, "iat": 1737414254, "nbf": 1737414254, "preferred_username": "alice@gmail.com", "oid": "00000000-0000-0000-7862-618d09e9fa0e", "email": "alice@gmail.com", "tid": "9188040d-6c67-4c5b-b112-36a304b66dad", "nonce": "pElF-ABr22cAQOTAC0qpxI83OH14Hu7fjRSWzS6ViLY", "aio": "DoD*c*IDip3fgOs3T8dIIBWw!JwcIwhQCwMcpNinmjEss4Ifu0PKKMPCiuJOXBAtX8OObt*128kwC7cPM97!AHy8mw1kRA9P5dcw6wlj8doC1j5nn03eNizuiwI9JMgdD1I0rfWBClENOSqDUg4ODsuPds!G1NtVGt6bxfRJrM81" }, "protected": { "typ": "JWT", "alg": "RS256", "kid": "aB0xDdGXk535PvewBP9Hl5pf7wc" } } ``` ### Refreshed ID Token ```json { "payload": { "ver": "2.0", "iss": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0", "sub": "AAAAAAAAAAAAAAAAAAAAAJ8PFm0pjpXKQouYRalE11g", "aud": "bd345b9c-6902-400d-9e18-45abdf0f698f", "exp": 1737500957, "iat": 1737414257, "nbf": 1737414257, "preferred_username": "eth3rs@gmail.com", "oid": "00000000-0000-0000-7862-618d09e9fa0e", "email": "eth3rs@gmail.com", "tid": "9188040d-6c67-4c5b-b112-36a304b66dad", "aio": "DmyH0vHZqJZRcv752O*ph8K!x!EOAIk!e1nSWrtlGxUlMccl2n5qHa9vY4YtAfWma!VI3BqIfsfzYdrrMDW22D5qIRAOYDP9utExgqTitwxNf83p*3nLqtCTnND!GVwhM35BGCaLqsA9MF8gu1dbUfTPgYxD4yTOH3sZq3hDyAbY" }, "protected": { "typ": "JWT", "alg": "RS256", "kid": "aB0xDdGXk535PvewBP9Hl5pf7wc" } } ``` ## Google ### ID Token ```json { "payload": { "iss": "https://accounts.google.com", "azp": "992028499768-ce9juclb3vvckh23r83fjkmvf1lvjq18.apps.googleusercontent.com", "aud": "992028499768-ce9juclb3vvckh23r83fjkmvf1lvjq18.apps.googleusercontent.com", "sub": "103030642802723203118", "email": "alice@gmail.com", "email_verified": true, "at_hash": "fTQQ5_pA8i-Zx_Bif9PVrA", "nonce": "oZifhxF_UkB0AG6K1hPyEsvDCHuqW3QsyK4O_XIlTzU", "name": "Alice Example", "picture": "https://lh3.googleusercontent.com/a/ACg8ocJie7Hgt4fitN0_GWXaFHYBuy1UMkr_ufkLXTW7MEpA_UMx1XlU=s96-c", "given_name": "Alice", "family_name": "Example", "iat": 1737415178, "exp": 1737418778 }, "protected": { "alg": "RS256", "kid": "6337be6364f3824008d0e9003f50bb6b43d5a9c6", "typ": "JWT" } } ``` ### Refreshed ID Token ```json { "payload": { "iss": "https://accounts.google.com", "azp": "992028499768-ce9juclb3vvckh23r83fjkmvf1lvjq18.apps.googleusercontent.com", "aud": "992028499768-ce9juclb3vvckh23r83fjkmvf1lvjq18.apps.googleusercontent.com", "sub": "103030642802723203118", "email": "alice@gmail.com", "email_verified": true, "at_hash": "kUOiPWtlZegMkNufB3eDaA", "name": "Alice Example", "picture": "https://lh3.googleusercontent.com/a/ACg8ocJie7Hgt4fitN0_GWXaFHYBuy1UMkr_ufkLXTW7MEpA_UMx1XlU=s96-c", "given_name": "Alice", "family_name": "Example", "iat": 1737415180, "exp": 1737418780 }, "protected": { "alg": "RS256", "kid": "6337be6364f3824008d0e9003f50bb6b43d5a9c6", "typ": "JWT" } } ``` ## Hello.dev ### ID Token This was generated using with gitlab claims enabled ```json { "payload": { "iss": "https://issuer.hello.coop", "aud": "app_HelloDeveloperPlayground_Iq2", "nonce": "26fe2dc0-b405-40cb-bfdc-e26aee0564ce", "jti": "jti_0FmofYWcyShupLOb9X3YwaIl_EVL", "sub": "8dfb4b1a-2b9e-4f59-a2dc-33e6806f3fe0", "tenant": "personal", "email": "alice@gmail.com", "email_verified": true, "gitlab": { "id": "76205622", "username": "Alice" }, "iat": 1743381952, "exp": 1743382252 } "protected": { "alg": "RS256", "typ": "JWT", "kid": "2025-01-15T16:56:49.668Z_735-1c5" }, } ``` openpubkey-0.8.0/docs/pktoken.md000066400000000000000000001166321477254274500166570ustar00rootroot00000000000000# A Guide to PK Tokens OpenPubkey works by binding a public key to an identity described in an ID Token. To provide verifiers with all the needed information to verify this binding between identity and public key we include additional information by extending the ID Token with additional signatures. This is possible because ID Tokens are JSON Web Signatures (JWS) and can support multiple signatures. We call an ID Token extended in this fashion a PK Token (Public Key Token). In this document we provide needed background on JSON Web Signatures (JWS), ID Tokens and PK Tokens. Then we describe how our different types of PK Tokens work using examples. In the next section we provide our compact serialization format. Finally we provide a short guide to using the patterns we developed for generic multi-signature JWS. - [A Guide to PK Tokens](#a-guide-to-pk-tokens) - [JSON Web Signatures (JWS) and JSON Web Tokens (JWT)](#json-web-signatures-jws-and-json-web-tokens-jwt) - [PK Tokens](#pk-tokens) - [Protected Header Claims](#protected-header-claims) - [Required Claims](#required-claims) - [Custom Claims](#custom-claims) - [Signature Type (typ)](#signature-type-typ) - [Commitment Mechanism](#commitment-mechanism) - [Types of Signatures in a PK Token](#types-of-signatures-in-a-pk-token) - [OP (OpenID Provider) Signature](#op-openid-provider-signature) - [CIC (Client-Instance Claims) Signature](#cic-client-instance-claims-signature) - [COS (Cosigner) Signature](#cos-cosigner-signature) - [Types of PK Tokens](#types-of-pk-tokens) - [Nonce-Commitment PK Token - Google Example](#nonce-commitment-pk-token---google-example) - [Nonce-Commitment](#nonce-commitment) - [MFA Cosigner Signature](#mfa-cosigner-signature) - [GQ Signed Nonce-Commitment PK Tokens - Google Example](#gq-signed-nonce-commitment-pk-tokens---google-example) - [GQ Signatures](#gq-signatures) - [GQ Signature Protected Header](#gq-signature-protected-header) - [GQ Signing](#gq-signing) - [GQ Signed Audience-bound PK Tokens - GitHub Example](#gq-signed-audience-bound-pk-tokens---github-example) - [Audience-Commitment](#audience-commitment) - [GQ Signatures Are Required For Aud-Commitment PK Tokens](#gq-signatures-are-required-for-aud-commitment-pk-tokens) - [GQ-Commitment PK Tokens (Gitlab-CI Example)](#gq-commitment-pk-tokens-gitlab-ci-example) - [GQ-Commitment to the CIC](#gq-commitment-to-the-cic) - [Security](#security) - [ZK PK Tokens (Under development)](#zk-pk-tokens-under-development) - [PK Token Compact Serialization](#pk-token-compact-serialization) - [Refreshed Payload and Signature](#refreshed-payload-and-signature) - [Notes](#notes) - [GQ Signatures Background](#gq-signatures-background) - [Our JWS Conventions](#our-jws-conventions) - [The TYP Pattern](#the-typ-pattern) - [Protected Header Claims](#protected-header-claims-1) - [Compact Representations of a JWS with Two or More Signatures](#compact-representations-of-a-jws-with-two-or-more-signatures) ## JSON Web Signatures (JWS) and JSON Web Tokens (JWT) A [JWS (JSON Web Signature)](https://www.rfc-editor.org/rfc/rfc7515.html) is a signed message format. The message which is signed is called the payload. It supports 1 or more signatures. Each signature has a protected header, denoted as `protected` in JSON, which specifies metadata about the signature such as the signing algorithm (`alg`) and the key ID (`kid`) of the public key which should be used to verify the signature. ```json {"payload": "message payload" "signatures": [ { "protected": {"alg": "RS256", "kid": "1234"}, "signature": "signature-1" }, { "protected": {"alg": "RS256", "kid": "5678"}, "signature": "signature-2" }, { "protected": {"alg": "RS256", "kid": "9123"}, "signature": "signature-3" }, ]} ``` Note that each signature in a JWS signs the payload and the protected header associated with that signature. All signatures sign the same payload; no signature signs another signature's protected header. In the example above RSA signature-2 is computed as `RSA-SIGN(SK, ("message payload", {"alg": "RS256", "kid": "1234"}))`. [JWT (JSON Web Token)](https://datatracker.ietf.org/doc/html/rfc7519) is a type of JWS used by one party, the issuer, to make claims about another party, the subject. The issuer includes their identity in the JWT using the `iss` claim. JWTs are defined as having only one signature, the signature of the issuer. ```json "payload": { "iss": "https://jwt.example.com", "claim-1": "value-1", "claim-2": "value-2", } "signatures": [ { "protected": {"alg": "RS256", "kid": "1234"}, "signature": "RSA signature-1" } ] ``` An ID Token is a type of JWT used in the OpenID Connect protocol by an OP (OpenID Provider) to make claims about an identity. An OP is simply what an IDP (Identity Provider) is called in OpenID Connect. Here is an example ID Token issued by `https://issuer.example.com` making claims about the subject `alice@example.com`. ```JSON {"payload":{ "iss": "https://issuer.example.com", "aud": "audience-id", "sub": "104852002444754136271", "nonce": "fsTLlOIUqtJHomMB2t6HymoAqJi-wORIFtg3y8c65VY", "email": "alice@example.com"}, "signatures":[ { "protected":{ "alg": "RS256", "kid": "1234", "typ": "JWT" }, "signature": "GqjU... (Issuer's signature)" } ]} ``` ## PK Tokens Now let's look at what happens when we extend this ID Token with additional signatures to create a PK Token. ```JSON {"payload":{ "iss": "https://issuer.example.com", "aud": "audience-id", "sub": "104852002444754136271", "nonce": "fsTLlOIUqtJHomMB2t6HymoAqJi-wORIFtg3y8c65VY", "email": "alice@example.com"}, "signatures":[ { "protected":{ "alg": "RS256", "kid": "1234", "typ": "JWT" }, "signature": "GqjU... (Issuer's signature)" }, { "protected": { "alg": "ES256", "typ": "CIC", "rz": "b9522b5c4cff90687ec6787236184659e077a619b82827227114108440fec26a", "upk": { "alg": "ES256", "crv": "P-256", "kty": "EC", "x": "cvqyUFNs1OUdRcDSmzJfS7ynuTHAjlDqoeinCZy_r1Q", "y": "Whl5jJUIz7ujFvlB5Hzhaz6DIlpyWQmIIA3J7VMj53o" }}, "signature":"TBPZa... (Clients's signature)" }, { "protected":{ "alg": "ES256", "auth_time": 1722113589, "eid": "6e38311685191ac62e8647e8263f98ad1d57752dab14a5b83298c0d70fe19942", "exp": 1722117189, "iat": 1722113589, "iss": "https://mfa-cosigner.openpubkey.com", "kid": "7890", "nonce": "a958dd0574393cc3fa0e423b4d060009b44ecc710d1ff7af0e4cc74b9fc99649", "ruri": "http://localhost:54435/mfacallback", "typ": "COS" }, "signature":"hiyDh... (Cosigner's signature)" } ]} ``` ### Protected Header Claims Protected headers in JWS [(RFC-7515 Section 2)](https://datatracker.ietf.org/doc/html/rfc7515#section-2) are used in JWS as a place to put signature header parameters. These parameters are metadata necessary to verify the signature. For instance, parameters such as, `alg` specifies the algorithm used to generate the signature and `kid`, which is the Key ID of the public key used to verify the signature, may be included in the protected header. In PK Tokens we use protected headers in a new way namely to store claims the signature is making about the identity. Next, we will look at why we need to use protected headers in this new way. In an ID Token or JWT, the claims the issuer is making are set in the payload. However this isn't sufficient for OpenPubkey, signing parties other than the issuer may wish to add additional claims along with their signature. For instance a signing party who independently authenticated the identity, might want to add an additional claim specifying the time at which this independent authentication took place. This party can not update the payload without breaking the OP's signature on the payload. Our solution is to allow signing parties to specify additional claims in the protected header that signing party creates. This enables the signing party to add claims without requiring any new signatures from any other parties. #### Required Claims In PK Tokens the following claims are required and assumed to exist `alg`, `typ` and `kid` for all OpenPubkey generated signatures except OP (OpenID Providers) generated signatures. OP (OpenID Providers) operate outside the OpenPubkey protocol we can not assume all OPs will generate an OP signature and OP protected header that will define a `typ` or `kid`. OP signatures are required by OpenID Connect to specify an `alg`. For OP signatures we have special logic to account for cases in which a `typ` or `kid` is not set. In all other signatures in a PK Token, OpenPubkey can enforce that `alg`, `typ` and `kid` are specified. #### Custom Claims OpenPubkey allows third parties to extend OpenPubkey and specify any custom claims in the protected header of PK Token signatures. The only rule is that custom claims can not use the keys: `alg`, `typ` and `kid`. For instance Docker uses the custom claim `att` in the CIC protected header to ensure [a particular PK Token can only be used to verify a particular object.](https://github.com/openpubkey/openpubkey/issues/33) ### Signature Type (typ) We use the `typ` value in the protected header of each signature to distinguish the "type" of signature it is. This is already an established pattern with OpenID Provider signatures in ID Tokens having `typ=JWT`. As shown, we have three signatures types: 1. `typ=JWT` **OP (OpenID Provider) signature and protected header:** The first signature is the signature of the party that issued the ID Token, that is, the signature of the OpenID provider. 2. `typ=CIC` **CIC (Client-Instance Claims) signature and protected header:** The second signature is generated by the identity's client. This signature's protected header contains the identity's public key. 3. `typ=COS` **COS (Cosigner) signature and protected header:** The third signature is the COS (Cosigner) signature. The Cosigner is a third party who has independently authenticated the identity. It exists to remove the OpenID Provider as a single point of compromise. The COS signature is optional and not every PK Token will have one. It is up to OpenPubkey verifiers to decide if they require a Cosigner signature or not. ### Commitment Mechanism **CIC Always Committed to by OP's Signature:** The CIC protected header contains the identity's public key and associated metadata. To bind the identity's public key to the ID Token, the CIC is always committed to in the payload or the OP's protected header. Why payload or OP's protected header? Because the OP's signature signs both the payload and the protected header. This means the binding between identity and public key is made by the party that authenticated the identity and determined the identity is are who they say they are. In our PK Token zoo below we describe four different mechanisms that the CIC can be committed to under the OP's signature: | Commitment Type | Commitment Location | Claim name | | ------------- |-------------- |--------------| | Nonce-Commitment | `payload` | `nonce` | | Audience-Commitment | `payload` | `aud` | | GQ-Commitment | `OP-protected header`| `CIC` | | ZK-Commitment | `OP-protected header`| `CIC` | ### Types of Signatures in a PK Token #### OP (OpenID Provider) Signature This is signature of the OpenID Provider that created the payload and signed it to create the ID Token. This signature is responsible for binding the identity claims in the payload to that identity's public key. Typically OP signature/protected header along with the payload is the ID Token. For some more advanced forms of PK Tokens such as GQ or ZK PK Tokens, we transform OP signature and protected header. In these cases, you can no longer recover the ID Token from the PK Token, but even when we alter the OP signature and protected header in this way, we don't break the cryptographic binding between payload and OpenID Provider that the signature provides. Some OpenID Providers do not specify `typ`. To get around this we classify a signature as being from the OpenID Provider if either `typ=JWT` or `typ` is not defined. #### CIC (Client-Instance Claims) Signature The Client-Instance is the identity's OpenPubkey client. The CIC are the claims made about the identity by this client. The CIC signature performs two functions. First, it provides the needed data to allow verifiers to check that an ID Token has a binding to the user's public key by opening the commitment. Second, it functions as a [Proof-of-Possession](https://csrc.nist.gov/glossary/term/proof_of_possession) showing that the identity's knows the signing key associated with the public key `upk`. This Proof-of-Possession prevents rogue key attacks in which an attacker associates their identity with another identity's public key and then attempts to claim that they produced signatures by the other identity. The CIC always contains the following claims (and may contain other claims): * `alg` - the algorithm of both the signature and the identity's public key. * `upk` - the JWK (JSON Web Key) of the identity's public key. UPK stands for User's Public Key. * `typ` - this value is always set to `CIC` to identify this protected header and signature pair as the CIC. #### COS (Cosigner) Signature The Cosigner is a third party who issues a signature if they are able to authenticate the identity in the ID Token. This authentication must be independent of the OpenID Provider's authentication. The purpose of the Cosigner is enable OpenPubkey to maintain security even if the OpenID Provider becomes malicious. Cosigning is an optional feature of OpenPubkey. In such cases there is no COS signature. The claims in a COS signature are: * auth_time - When the cosigner authenticated the identity (unix epoch) * eid - Authentication id. * exp - Expiration time of cosigner signature (unix epoch) * iat - Issued at time of this signature. May differ from auth_time because of refresh. (unix epoch) * iss - Issuer, the cosigner which issued this signature. This can be used to look up the cosigner JWKS URI to get this cosigner's public keys. * nonce - Nonce supplied by the user. This should not match the nonce in the payload. * ruri - Redirect URI that was used by the cosigner to send the client-instance the auth_code. ## Types of PK Tokens In this section we use actual PK Tokens from to illustrate the types of PK Tokens. OpenPubkey has a number of different types of PK Tokens. A full list includes: | PK Token Type | OP Support | Identity type | | ------------- |-------------- | ------------- | | Nonce-Commitment | Google, Azure, Okta | Human | | GQ Signed Nonce-Commitment | Google, Azure, Okta | Human | | GQ Signed Audience-Commitment | GitHub, GCP | Machine | | GQ-Commitment | GitLab-CI | Machine | | ZKP PK Tokens | Google, Azure, Okta, Github, Gitlab-CI | Human+Machine| ### Nonce-Commitment PK Token - Google Example This PK Token is an Google issued ID Token that has been extended with two signatures. ```JSON {"payload":{ "iss": "https://accounts.google.com", "azp": "992028499768-ce9juclb3vvckh23r83fjkmvf1lvjq18.apps.googleusercontent.com", "aud": "992028499768-ce9juclb3vvckh23r83fjkmvf1lvjq18.apps.googleusercontent.com", "sub": "104852002444754136271", "email": "anon.author.aardvark@gmail.com", "email_verified": true, "at_hash": "4yj5j65fR9VuqPDZYJTadA", "nonce": "fsTLlOIUqtJHomMB2t6HymoAqJi-wORIFtg3y8c65VY", "name": "Anonymous Author", "picture": "https://lh3.googleusercontent.com/a/ACg8ocIdbWtaAGFsizjWVh7Q6C-XDBuSoUOpf7d7nGqgNQ-9yHmenNA=s96-c", "given_name": "Anonymous", "family_name": "Author", "iat": 1722113587, "exp": 1722117187}, "signatures":[ { "protected":{ "alg": "RS256", "kid": "e26d917b1fe8de13382aa7cc9a1d6e93262f33e2", "typ": "JWT" }, "signature":"Zbli..." }, { "protected": { "alg": "ES256", "rz": "b9522b5c4cff90687ec6787236184659e077a619b82827227114108440fec26a", "typ": "CIC", "upk": { "alg": "ES256", "crv": "P-256", "kty": "EC", "x": "cvqyUFNs1OUdRcDSmzJfS7ynuTHAjlDqoeinCZy_r1Q", "y": "Whl5jJUIz7ujFvlB5Hzhaz6DIlpyWQmIIA3J7VMj53o" }}, "signature":"TBPZa..." }, { "protected":{ "alg": "ES256", "auth_time": 1722113589, "eid": "6e38311685191ac62e8647e8263f98ad1d57752dab14a5b83298c0d70fe19942", "exp": 1722117189, "iat": 1722113589, "iss": "http://localhost:3003", "kid": "b5eed4577745938ac3ed505229ed8b845bdbce5bd2a0820a2e5d405ceb836303", "nonce": "a958dd0574393cc3fa0e423b4d060009b44ecc710d1ff7af0e4cc74b9fc99649", "ruri": "http://mfa-cosigner.example.com:54435/mfacallback", "typ": "COS" }, "signature":"hiyDh..." } ]} ``` #### Nonce-Commitment Our example PK Token uses a *nonce-commitment* to bind the identity's public key `upk` (user public key) to the ID Token. That is, the `nonce` value in the payload commits to CIC's protected header which includes the identity's public key. By commits we mean that the nonce has been set to be the hash of the CIC's protected header: ```ascii nonce = SHA3( Base64({"alg":"ES256", "rz":"301a510ffd19b4888cdec7b9dda62192cfa06d85936cabb1afdd1a015ad44137", "typ":"CIC", "upk":{"alg":"ES256","crv":"P-256","kty":"EC" "x":"8JAMvpmdrhiKJi9A79LHPj5CPKlyztHHEkCr6tntyq8", "y":"jRqKcX8wIU24ffb5GI6z9XlePqlP1DOxvlEwvp0wC5s"} })) ``` Notice that the value which is hashed to generate the `nonce` value in the payload is exactly the value given in the protected header of the CIC. Put another way, the CIC allows the verifier to open the commitment to the identity's public key in the nonce. The value `rz` is randomly chosen by the identity's OpenPubkey client, a.k.a. the client-instance, to ensure that each time a `nonce` is generated, it is always unique and random. The value `upk` is the identity's public key. #### MFA Cosigner Signature We have included in a Cosigner signature in this example to show what what one looks like. Given that the Cosigner signature does not not differ much between examples we have omitted it from the other examples. Note that PK Tokens can function without a Cosigner signature, a verifier can choose to require or not require one based on a verifier's security needs. ### GQ Signed Nonce-Commitment PK Tokens - Google Example This PK Token is for the same Google account as the prior example including the same use of the nonce-commitment, the only difference is that we have replaced Google's RSA signature and protected header with a GQ signature. ```JSON { "payload": { "iss": "https://accounts.google.com", "azp": "992028499768-ce9juclb3vvckh23r83fjkmvf1lvjq18.apps.googleusercontent.com", "aud": "992028499768-ce9juclb3vvckh23r83fjkmvf1lvjq18.apps.googleusercontent.com", "sub": "104852002444754136271", "email": "anon.author.aardvark@gmail.com", "email_verified": true, "at_hash": "7y8qnEw17J6BDoqvz6ydbg", "nonce": "8IpXCsOcYBGcCJmXJMFOpBjz4-kPXwDhYi3hm_DFM_U", "name": "Anonymous Author", "picture": "https://lh3.googleusercontent.com/a/ACg8ocIdbWtaAGFsizjWVh7Q6C-XDBuSoUOpf7d7nGqgNQ-9yHmenNA=s96-c", "given_name": "Anonymous", "family_name": "Author", "iat": 1722717291, "exp": 1722720891 }, "signatures": [ { "protected": { "alg": "GQ256", "jkt": "w0bhEOa9d4qxGtKLGhwySJ2VZtRxPA-0abeIC9C-zPQ", "kid": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImUyNmQ5MTdiMWZlOGRlMTMzODJhYTdjYzlhMWQ2ZTkzMjYyZjMzZTIiLCJ0eXAiOiJKV1QifQ", "typ": "JWT" }, "signature": "hAo4...(5504 base64 characters)" }, { "protected": { "alg": "ES256", "extra": "yes", "rz": "656f65b99da5d649ea315a52343add3642f14c7ff8d4ebce8ee33a2f4a4b41e0", "typ": "CIC", "upk": { "alg": "ES256", "crv": "P-256", "kty": "EC", "x": "PnzpEjQZ7bsCl2ZExs7dbFQlVzggv-_t50QuzZZWcoc", "y": "1Z-xC6JZL2eAO57ovFJCstnBcMsOiqsGF1NJLyqq1F4" } }, "signature": "dsWL..." } ] } ``` #### GQ Signatures A [GQ (Guillou and Quisquater) signature](https://link.springer.com/content/pdf/10.1007/0-387-34799-2_16.pdf) is a [Proof of Knowledge (PoK)](https://en.wikipedia.org/wiki/Proof_of_knowledge) of an RSA signature that keeps the RSA signature secret. It provides the same guarantee that the ID Token was signed by OpenID Provider, but it keeps the original RSA signature secret, preventing the ID Token in the PK Token from being used an ID Token. This works because a standard OpenID Connect-compliant service only accepts RSA signature and so will reject an ID Token that has a GQ signature instead. **Why do this?** A non-GQ PK Token contains the ID Token signed and issued by the OP (OpenID Provider). Anyone who sees hte PK Token could extract the ID Token including the RSA signature issued by OP from the PK Token and then to replay this ID Token to authenticate as the subject (remember ID Tokens are bearer authentication secrets). A correctly configured service would reject such a replayed ID Token because the audience (`aud`) value in the ID Token would not match the audience that the service expects. This is because ID Tokens issued for OpenPubkey will have a different audience than an ID Token issued for use in another service. Unfortunately, a common misconfiguration is that services do not check the audience claim in the ID Token. **To prevent such replay attacks in OpenPubkey we use GQ signatures when a PK Token will be publicly posted.** Note that in cases where a PK Token is merely used to authenticate to a server and is not made publicly available, no GQ signature is needed. For instance when OpenPubkey is used in SSH, SSH3, TLS and web authentication we do not need to use GQ signatures. When OpenPubkey is used for software artifact signing where the signatures and PK Tokens will be posted to a public ledger, then GQ signatures or Zero Knowledge Proofs should be used. #### GQ Signature Protected Header When we replace an RSA signature in a PK Token with a GQ signature, we also replace the protected header of the RSA signature. GQ signatures not only provide a Proof-of-Knowledge of the original RSA signature, but they also enable anyone who knows the original RSA signature to sign a message using the RSA signature as the signing key. Using this property the GQ signature acts a signature, signing the payload and the new protected header using the original RSA signature as the signing secret. The required claims in a GQ signature protected header are: * **alg** - To signal that the GQ protected header is for a GQ signature we set `alg=GQ256`. * **kid** - We set the `kid` (Key ID) of the new GQ protected to the Base64 encoding of the original RSA protected header `"kid": Base64({"alg":"RS256","kid":"e26d917b1fe8de13382aa7cc9a1d6e93262f33e2","typ":"JWT"})`. This is required because verifying the GQ signature requires the original protected header of the RSA signature. * **jtk** - To ensure we can always lookup and find the correct public key to verify a GQ signed ID Token, we record the `jkt` (JSON Key Thumbprint) of the OP's public key in the GQ signature's protected header. The original OP's public key for the RSA signature is needed to verify the GQ signature. This is needed because OP (OpenID Provider) are not required to set a `kid` (Key ID) or use a unique `kid` for the public keys they use in their JWKS. While it is extremely rare for an OP to not use a `kid` or to recycle a `kid`, such behavior is standards compliant and we must ensure we can handle this behavior and uniquely identify the OP public key used to verify the signature. * **typ** - The typ (type) is always `typ=JWT` to signal is the OP's signature. #### GQ Signing We sign the payload and the GQ protected header using the RSA signature as the signing key. For the complete details on the GQ signing see our package [gq.SignJWT](https://github.com/openpubkey/openpubkey/blob/main/gq/sign.go#L108) ```golang origHeaders, payload, signature, err := jws.SplitCompact(jwt) if err != nil { return nil, err } signingPayload := util.JoinJWTSegments(origHeaders, payload) headers := jws.NewHeaders() err = headers.Set(jws.AlgorithmKey, GQ256) if err != nil { return nil, err } err = headers.Set(jws.TypeKey, "JWT") if err != nil { return nil, err } err = headers.Set(jws.KeyIDKey, string(origHeaders)) ``` ### GQ Signed Audience-bound PK Tokens - GitHub Example Audience-bound PK Tokens are very similar to Nonce-Bound PK Tokens. The main difference is that instead of committing to the CIC in the `nonce` claim, we commit to the CIC in the `aud` (audience) claim. ```JSON {"payload":{ "jti": "d41b4ff2-6f05-41ce-98e8-f0c06e05902f", "sub": "repo:openpubkey/gha-test:ref:refs/heads/main", "aud": "LEQE668yEBBpVxKfi4SvIkl8wFxn55TdzNF79aEomIA", "ref": "refs/heads/main", "sha": "6b906a4153c61a2486973a1347db8300dc9ce3ee", "repository": "openpubkey/gha-test", "repository_owner": "openpubkey", "repository_owner_id": "145685596", "run_id": "10133323083", "run_number": "38", "run_attempt": "1", "repository_visibility": "public", "repository_id": "771245825", "actor_id": "274814", "actor": "EthanHeilman", "workflow": "Go Checks", "head_ref": "", "base_ref": "", "event_name": "push", "ref_protected": "false", "ref_type": "branch", "workflow_ref": "openpubkey/gha-test/.github/workflows/test.yml@refs/heads/main", "workflow_sha": "6b906a4153c61a2486973a1347db8300dc9ce3ee", "job_workflow_ref": "openpubkey/gha-test/.github/workflows/test.yml@refs/heads/main", "job_workflow_sha": "6b906a4153c61a2486973a1347db8300dc9ce3ee", "runner_environment": "github-hosted", "iss": "https://token.actions.githubusercontent.com", "nbf": 1722185193, "exp": 1722186093, "iat": 1722185793} "signatures":[ { "protected":{ "alg": "GQ256", "jkt": "UJCvHZiaJcaQDc2tJbP_kgtgxB-OcKd1lwD76M3riUY", "kid": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ikh5cTROQVRBanNucUM3bWRydEFoaHJDUjJfUSIsImtpZCI6IjFGMkFCODM0MDRDMDhFQzlFQTBCQjk5REFFRDAyMTg2QjA5MURCRjQifQ", "typ": "JWT"}, "signature":"h4lbgJd... (5504 base64 characters)" }, { "protected": { "alg": "ES256", "rz": "bca0353ea63adbfce72032ab7d8fb7940def3488ca0765546a89d46760113c70", "typ": "CIC", "upk": { "alg": "ES256", "crv": "P-256", "kty": "EC", "x": "5BP8B8bXgf0OFxHLJS5LSFlPOsfdIvf2tJU_3mwTGNE", "y": "7KzWJi88qdZOI_j-kUG2aPjkzEA7IGMXFp1f-jdt28I" }}, "signature":"5dfaFj..." } ]} ``` #### Audience-Commitment Machine identity ID Token issuance flows typically allow the identity requesting the ID Token to specify any value for the audience `aud`. Most machine identity flows do not use a `nonce`. To support OpenPubkey we use the `aud` in exactly the same way as the `nonce` commitment: ```ascii nonce = SHA3( BASE64URL({"alg": "ES256", "rz": "bca0353ea63adbfce72032ab7d8fb7940def3488ca0765546a89d46760113c70", "typ": "CIC", "upk": { "alg": "ES256", "crv": "P-256", "kty": "EC", "x": "5BP8B8bXgf0OFxHLJS5LSFlPOsfdIvf2tJU_3mwTGNE", "y": "7KzWJi88qdZOI_j-kUG2aPjkzEA7IGMXFp1f-jdt28I" }})) ``` User identity ID Token issuance flows set the `aud` to a unique identifier to scope the ID Token to a particular service or OIDC client. This is done, among other reasons to prevent a malicious service from replaying the ID Tokens it receives from users to impersonate those users to another service. Allowing the requesting party to specify the `aud` as is done in machine identity would be insecure for user identity. However it is both secure and the primary pattern in machine identity flows. #### GQ Signatures Are Required For Aud-Commitment PK Tokens The main use case of machine identity audience-commitment PK Tokens is to create publicly published signatures. In such cases, GQ signatures or ZK proofs should always be used. This eliminates the risks of accidental misconfigurations where a GQ signature should be used but is not used. ### GQ-Commitment PK Tokens (Gitlab-CI Example) GQ-commitment PK Tokens are designed for the case where an OP can not support a nonce-commitment or an audience-commitment. Instead the GQ signature itself functions as the binding between the identity's public key (and CIC protected header) and the ID Token. So far we have only encountered one OP which can't support a nonce-commitment or an aud-commitment: GitLab-CI. **Critical:** GQ-commitment PK Tokens should only be used if an OP can not support nonce or audience-commitment PK Tokens. Never verify a GQ-bound PK Token unless the issuing OP only supports GQ-bound PK Tokens. ```JSON {"payload":{ "namespace_id": "84329880", "namespace_path": "openpubkey", "project_id": "56004559", "project_path": "openpubkey/gl-test", "user_id": "20558032", "user_login": "ethan.r.heilman", "user_email": "ethan.r.heilman@gmail.com", "user_access_level": "owner", "pipeline_id": "1391152406", "pipeline_source": "push", "job_id": "7446098166", "ref": "main", "ref_type": "branch", "ref_path": "refs/heads/main", "ref_protected": "true", "groups_direct": [ "openpubkey" ], "runner_id": 12270852, "runner_environment": "gitlab-hosted", "sha": "9898863c7dcd844a6fc3c70191769b8d07567f57", "project_visibility": "public", "ci_config_ref_uri": "gitlab.com/openpubkey/gl-test//.gitlab-ci.yml@refs/heads/main", "ci_config_sha": "9898863c7dcd844a6fc3c70191769b8d07567f57", "jti": "3a8b958c-0117-4d61-8d84-90b186efc4e7", "iat": 1722189366, "nbf": 1722189361, "exp": 1722192966, "iss": "https://gitlab.com", "sub": "project_path:openpubkey/gl-test:ref_type:branch:ref:main", "aud": "OPENPUBKEY-PKTOKEN:1234" }, "signatures":[ {"protected":{ "alg": "GQ256", "cic": "HVIF0m3zCwEsAZSFjTiyQFU982qF2UZXSpCE__F6IbE", "jkt": "4i3sFE7sxqNPOT7FdvcGA1ZVGGI_r-tsDXnEuYT4ZqE", "kid": "eyJraWQiOiI0aTNzRkU3c3hxTlBPVDdGZHZjR0ExWlZHR0lfci10c0RYbkV1WVQ0WnFFIiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ", "typ": "JWT" }, "signature":"18nN... (5504 base64 characters)"}, {"protected":{ "alg": "ES256", "rz": "600e69b29d89651591836d2598f6813a9a74b9e4124ddb81bee1561299c3590e", "typ": "CIC", "upk": { "alg": "ES256", "crv": "P-256", "kty": "EC", "x": "c63goURlnP5vbJbt4chtOHTHwg6Yvy4h6_aw3Zc2A5o", "y": "pfsH8--s5c8u4DxXto0sN4g5n6SjlXn1WjzaKXrr9b4" } }, "signature":"xpt6tTEzAqPrXCzAL6UCdiIliPPejRmn2sW1_RSxCt3WUQN38s3z_MURF3Bd8mlncU1UhWwSMCAcwDijm-Hc6w"} ]} ``` #### GQ-Commitment to the CIC For Google and github we bind the CIC (Client Instance Claims) that contains the user's public key to the ID Token by putting a hashed commitment to the CIC in one of the claims of the ID Token. For Google we put the commitment in the `nonce` claim, for github we put the commitment in the audience (`aud`) claim. Gitlab-CI does not provide a `nonce` claim and does not allow a running job/workflow to specify the audience. While Gitlab does allow customizing the audience, this custom audience is a fixed configuration value and can not be set per request. As such we can not use audience-commitment PK Tokens for GitLab-CI. As GitLab-CI is for machine identity it does not support specifying a nonce and thus nonce-commitment PK Tokens are not available either. GQ-commitment solves this problem posed by GitLab-CI. Remember that with GQ signatures, we use the OP's RSA signature on the ID Token to generate a GQ signature that signs both the payload and the GQ protected header. Therefore, we can simply put the commitment to the identity's public key, i.e. the hash of the CIC, in the GQ protected header which is signed by the GQ signature. GQ-bound PK Tokens were introduced in PR: [Adds GitLab-ci OP using GQ commitment binding](https://github.com/openpubkey/openpubkey/pull/143). In a GQ-bound PK Token the GQ protected header contains a claim `cic` where: ```ascii cic = "SHA3( Base64({"alg": "ES256", "rz": "600e69b29d89651591836d2598f6813a9a74b9e4124ddb81bee1561299c3590e", "typ": "CIC", "upk": { "alg": "ES256", "crv": "P-256", "kty": "EC", "x": "c63goURlnP5vbJbt4chtOHTHwg6Yvy4h6_aw3Zc2A5o", "y": "pfsH8--s5c8u4DxXto0sN4g5n6SjlXn1WjzaKXrr9b4" })) ``` and the `aud` is always prefixed with `"OPENPUBKEY-PKTOKEN:"`. In the next section we explain why we require the audience to be prefixed this way. #### Security If configured correctly, GQ-bound PK Tokens offer the same security as nonce or audience bound PK Tokens. However GQ-commitment PK Tokens do introduce a security risk from misconfiguration not present in the other cases. The risk is that if a GQ signature is not used and an attacker learns the ID Token, the attacker can compute the GQ signature from the RSA signature on a CIC protected header of the attacker's choosing. We mitigate this risk by: - Requiring that PK Tokens employing GQ-commitments must have "OPENPUBKEY-PKTOKEN:" prefixed in the audience (`aud`) and that they are not considered valid PK Tokens if they do not have this prefix. This also prevents attacks in which an ID Token not intended for use as a PK Token is replayed in a PK Token. This rule does not apply to other types of PK Tokens. - Requiring that OpenPubkey clients only verify GQ-Bound PK Tokens for OPs like GitLab-CI that are confirmed to not support nonce or audience commitments. ### ZK PK Tokens (Under development) Similar to our approach of GQ signatures, we can use ZKP (Zero Knowledge Proofs) to provide privacy-enhanced PK Tokens, and if needed use the ZKP as a binding mechanism. This is currently under discussion and development in the issue: [Proposed zklogin JWS](https://github.com/openpubkey/openpubkey/issues/101). ## PK Token Compact Serialization [RFC-7515 Section 7.1](https://datatracker.ietf.org/doc/html/rfc7515#section-7.1) describes a compact serialization format for a JWS (JSON Web Signatures): ```ascii BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload) || '.' || BASE64URL(JWS Signature) ``` The compact serialization standard does not support a JWS with more than one signature. As our PK Tokens have at least two signatures, we invented a compact serialization format for a JWS with more than one signature: ```ascii BASE64URL(JWS Payload) || ':' || BASE64URL(UTF8(JWS Protected Header-OP)) || ':' || BASE64URL(UTF8(JWS Signature-OP)) || ':' || BASE64URL(UTF8(JWS Protected Header-CIC)) || ':' || BASE64URL(UTF8(JWS Signature-CIC)) || ':' || BASE64URL(UTF8(JWS Protected Header-COS)) || ':' || BASE64URL(UTF8(JWS Signature-COS)) || ':' || ``` ### Refreshed Payload and Signature If this JWS represents a PK Token, then we may wish to refresh the ID Token. Refreshed ID Tokens typically do not contain the nonce in the initial ID Token. As such, for nonce-commitment PK Tokens, we need to transmit both the initial ID Token that has the nonce-commitment and the refreshed ID Token. To do this, we use the following compact representation: ```ascii BASE64URL(JWS Payload) || ':' || BASE64URL(UTF8(JWS Protected Header-OP)) || ':' || BASE64URL(UTF8(JWS Signature-OP)) || ':' || BASE64URL(UTF8(JWS Protected Header-CIC)) || ':' || BASE64URL(UTF8(JWS Signature-CIC)) || ':' || BASE64URL(UTF8(JWS Protected Header-COS)) || ':' || BASE64URL(UTF8(JWS Signature-COS)) || '.' || BASE64URL(JWS Refreshed Payload) || '.' || BASE64URL(UTF8(JWS Refreshed Protected Header-OP)) || ',' || BASE64URL(UTF8(JWS Refreshed Signature-OP)) || '.' || ``` # Notes Additional notes ## GQ Signatures Background GQ (Guillou and Quisquater) signatures were invented in the paper ['A “Paradoxical” Indentity-Based Signature Scheme Resulting from Zero-Knowledge' (1988).](https://link.springer.com/content/pdf/10.1007/0-387-34799-2_16.pdf) GQ signatures were standardized in GQ1 in [ISO/IEC 14888-2:2008](https://www.iso.org/standard/44227.html). Our GQ signatures are based on this standard but we have increased the security parameter to 256-bits. ## Our JWS Conventions In this section we discuss how the patterns we use for PK Tokens can be more broadly used for JSON Web Signatures in general. ### The TYP Pattern Because a PK Token always has more two or more signatures we have been forced to think about how to effectively organize a JWS (JSON Web Signatures) that is two or more signatures. If a JWS has only one signature, then the `iss` (issuer) claim in the payload identifies the party that generated the signature. Once you have two or more signatures, how do you determine which signature matches the issuer? How do you determine the identity of signer that generated each signature? One approach is to use signature order. For instance, we could specify that the first signature in the signatures list is the party that created the payload, the second signature is the cosigner, and so on. This approach has two major drawbacks. First, the order of the signatures is not signed or enforced in anyway. This means we can not assume that software and libraries won't reorder the signature list, breaking our ability to match signers to signatures. Second, this approach doesn't solve the problem of optional signers, who may or may not be required. Instead we use the `typ` (type) key in the protected header to specify the different types of signatures. For instance if the protected header of a signature has `typ=COS` it is a cosigner signature. In JWT's it is customary to have a `typ=JWT`. We extend this so that signatures each signing party in a protocol specifies their role in the `typ` key of their protected header. ### Protected Header Claims In a JWT the claims the issuer is making are set in the payload. However this isn't sufficient for OpenPubkey, parties may wish to add additional claims along with their signature with their signature. For instance a signing party who independently authenticated the identity, might want to add an additional claim specifying the time at which this independent authentication took place. This signature can not update the payload without breaking the signature that already signed the payload. Our solution is to allow signing parties to specify additional claims in their protected header. This enables the party to add claims without requiring any new signatures from any other parties and also makes it clear who added these claims. ### Compact Representations of a JWS with Two or More Signatures [RFC-7515 Section 7.1](https://datatracker.ietf.org/doc/html/rfc7515#section-7.1) describes a compact serialization format for a JWS (JSON Web Signatures): ```ascii BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload) || '.' || BASE64URL(JWS Signature) ``` This compact representation does not support a JWS with more than one signature. As our PK Tokens have at least two signatures we invented a compact serialization format for a JWS with more than one signature: ```ascii BASE64URL(JWS Payload) || ':' || BASE64URL(UTF8(JWS Protected Header-1)) || ':' || BASE64URL(UTF8(JWS Signature-1)) || ':' || BASE64URL(UTF8(JWS Protected Header-2)) || ':' || BASE64URL(UTF8(JWS Signature-2)) || ':' || ... BASE64URL(UTF8(JWS Protected Header-N)) || ':' || BASE64URL(UTF8(JWS Signature-N)) ``` We use `:` as a delimiter rather than `.` to avoid confusion arising from someone attempting to parse this as a standard single signature JWS. openpubkey-0.8.0/examples/000077500000000000000000000000001477254274500155375ustar00rootroot00000000000000openpubkey-0.8.0/examples/gitlab/000077500000000000000000000000001477254274500170015ustar00rootroot00000000000000openpubkey-0.8.0/examples/gitlab/example.go000066400000000000000000000054041477254274500207660ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package gitlab_example import ( "context" "encoding/base64" "fmt" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/verifier" ) type Opts struct { altOp providers.OpenIdProvider } func SignWithGitlab(opts ...Opts) error { var op providers.OpenIdProvider // If an alternative OP is provided, use that instead of the default. // Currently only used for testing where a mockOP is provided. if len(opts) > 0 && opts[0].altOp != nil { op = opts[0].altOp } else { // Creates OpenID Provider (OP) configuration, this will be used to request the ID Token from Gitlab op = providers.NewGitlabOpFromEnvironment("OPENPUBKEY_JWT") } // Creates a new OpenPubkey client opkClient, err := client.New(op) if err != nil { return err } // Generates a PK Token by authorizing to the OpenID Provider pkt, err := opkClient.Auth(context.Background()) if err != nil { return err } // Serialize the PK Token to JSON so we can print it. Typically this // serialization of the PK Token would be sent with the signed message pktJson, err := pkt.MarshalJSON() if err != nil { return err } fmt.Println("pkt:", string(pktJson)) pktCom, _ := pkt.Compact() b64pktCom := base64.StdEncoding.EncodeToString(pktCom) fmt.Println("pkt compact:", string(b64pktCom)) // Create a verifier to check that the PK Token is well formed // The OPK client does this as well, but for the purposes of the // example we show how a relying party might verify a PK Token verifier, err := verifier.New(op) if err != nil { return err } // Verify the PK Token. We supply the OP (gitlab) we wish to verify against err = verifier.VerifyPKToken(context.Background(), pkt) if err != nil { return err } // Sign a message over the user's public key in the PK Token msg := []byte("All is discovered - flee at once") signedMsg, err := pkt.NewSignedMessage(msg, opkClient.GetSigner()) if err != nil { return err } fmt.Println("signedMsg:", string(signedMsg)) // Verify the signed message _, err = pkt.VerifySignedMessage(signedMsg) if err != nil { return err } fmt.Println("Success!") return nil } openpubkey-0.8.0/examples/gitlab/example_test.go000066400000000000000000000024661477254274500220320ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package gitlab_example import ( "testing" "github.com/openpubkey/openpubkey/providers" "github.com/stretchr/testify/require" ) func TestGitlabExample(t *testing.T) { providerOpts := providers.MockProviderOpts{ Issuer: "mockIssuer", ClientID: "mockClient-ID", GQSign: true, NumKeys: 2, CommitType: providers.CommitTypesEnum.GQ_BOUND, VerifierOpts: providers.ProviderVerifierOpts{ SkipClientIDCheck: true, GQOnly: true, CommitType: providers.CommitTypesEnum.GQ_BOUND, }, } op, _, _, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) opts := Opts{ altOp: op, } err = SignWithGitlab(opts) require.NoError(t, err) } openpubkey-0.8.0/examples/mfa/000077500000000000000000000000001477254274500163025ustar00rootroot00000000000000openpubkey-0.8.0/examples/mfa/example.go000066400000000000000000000046771477254274500203020ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package main import ( "context" "fmt" "os" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/cosigner" "github.com/openpubkey/openpubkey/examples/mfa/mfacosigner" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/verifier" ) func main() { provider := providers.NewGoogleOp() cosignerProvider := client.CosignerProvider{ Issuer: "http://localhost:3003", CallbackPath: "/mfacallback", } if len(os.Args) < 2 { fmt.Printf("Example MFA Cosigner: command choices are: login, mfa") return } command := os.Args[1] switch command { case "login": opk, err := client.New(provider, client.WithCosignerProvider(&cosignerProvider), ) if err != nil { fmt.Println(err) return } pkt, err := opk.Auth(context.TODO()) if err != nil { fmt.Println(err) return } fmt.Println("New PK token generated") // Verify our pktoken including the cosigner signature cosVerifier := cosigner.NewCosignerVerifier(cosignerProvider.Issuer, cosigner.CosignerVerifierOpts{}) verifier, err := verifier.New(provider, verifier.WithCosignerVerifiers(cosVerifier)) if err != nil { fmt.Println(err) return } if err := verifier.VerifyPKToken(context.TODO(), pkt); err != nil { fmt.Println("Failed to verify PK token:", err) os.Exit(1) } else { fmt.Println("PK token verified successfully!") } os.Exit(0) case "mfa": rpID := "localhost" serverUri := "http://localhost:3003" rpOrigin := "http://localhost:3003" rpDisplayName := "OpenPubkey" _, err := mfacosigner.NewMfaCosignerHttpServer(serverUri, rpID, rpOrigin, rpDisplayName) if err != nil { fmt.Println("error starting mfa server: ", err) return } default: fmt.Println("Unrecognized command:", command) fmt.Printf("Example MFA Cosigner: command choices are: login, mfa") } } openpubkey-0.8.0/examples/mfa/mfacosigner/000077500000000000000000000000001477254274500205775ustar00rootroot00000000000000openpubkey-0.8.0/examples/mfa/mfacosigner/jwks/000077500000000000000000000000001477254274500215555ustar00rootroot00000000000000openpubkey-0.8.0/examples/mfa/mfacosigner/jwks/jwksserver.go000066400000000000000000000047171477254274500243220ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package jwks import ( "crypto" "crypto/ecdsa" "crypto/elliptic" "encoding/hex" "encoding/json" "fmt" "net" "net/http" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "golang.org/x/crypto/sha3" ) type JwksServer struct { uri string jwksBytes []byte } // A very simple JWKS server for our MFA Cosigner example code. func NewJwksServer(signer crypto.Signer, alg jwa.SignatureAlgorithm) (*JwksServer, string, error) { // Compute the kid (Key ID) as the SHA-3 of the public key pubkey := signer.Public().(*ecdsa.PublicKey) // TODO: handle non-ecdsa signers pubkeyBytes := elliptic.Marshal(pubkey, pubkey.X, pubkey.Y) pubkeyHash := sha3.Sum256(pubkeyBytes) kid := hex.EncodeToString(pubkeyHash[:]) // Generate our JWKS using our signing key jwkKey, err := jwk.PublicKeyOf(signer) if err != nil { return nil, "", err } jwkKey.Set(jwk.AlgorithmKey, alg) jwkKey.Set(jwk.KeyIDKey, kid) // Put our jwk into a set keySet := jwk.NewSet() keySet.AddKey(jwkKey) // Now convert our key set into the raw bytes for printing later keySetBytes, _ := json.MarshalIndent(keySet, "", " ") if err != nil { return nil, "", err } // Find an empty port listener, err := net.Listen("tcp", ":0") if err != nil { return nil, "", fmt.Errorf("failed to bind to an available port: %w", err) } server := &JwksServer{ uri: fmt.Sprintf("http://localhost:%d", listener.Addr().(*net.TCPAddr).Port), jwksBytes: keySetBytes, } // Host our JWKS at a localhost url mux := http.NewServeMux() mux.HandleFunc("/.well-known/jwks.json", server.printJWKS) go func() { http.Serve(listener, mux) }() return server, kid, nil } func (s *JwksServer) URI() string { return s.uri } func (s *JwksServer) printJWKS(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write(s.jwksBytes) } openpubkey-0.8.0/examples/mfa/mfacosigner/mfacosigner.go000066400000000000000000000107601477254274500234270ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package mfacosigner import ( "crypto" "crypto/rand" "fmt" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/cosigner" "github.com/openpubkey/openpubkey/cosigner/mocks" ) func NewUser(as *cosigner.AuthState) *user { return &user{ id: []byte(as.Sub), username: as.Username, displayName: as.DisplayName, } } // This is intended as an example. Both sessionMap and users are not concurrency safe. type MfaCosigner struct { *cosigner.AuthCosigner webAuthn *webauthn.WebAuthn sessionMap map[string]*webauthn.SessionData users map[cosigner.UserKey]*user } func New(signer crypto.Signer, alg jwa.SignatureAlgorithm, issuer, keyID string, cfg *webauthn.Config) (*MfaCosigner, error) { hmacKey := make([]byte, 64) if _, err := rand.Read(hmacKey); err != nil { return nil, err } wauth, err := webauthn.New(cfg) if err != nil { return nil, err } authCos, err := cosigner.New(signer, alg, issuer, keyID, mocks.NewAuthStateInMemoryStore(hmacKey)) if err != nil { return nil, err } return &MfaCosigner{ AuthCosigner: authCos, webAuthn: wauth, sessionMap: make(map[string]*webauthn.SessionData), users: make(map[cosigner.UserKey]*user), }, nil } func (c *MfaCosigner) CheckIsRegistered(authID string) bool { authState, _ := c.AuthStateStore.LookupAuthState(authID) userKey := authState.UserKey() return c.IsRegistered(userKey) } func (c *MfaCosigner) IsRegistered(userKey cosigner.UserKey) bool { _, ok := c.users[userKey] return ok } func (c *MfaCosigner) BeginRegistration(authID string) (*protocol.CredentialCreation, error) { authState, _ := c.AuthStateStore.LookupAuthState(authID) userKey := authState.UserKey() if c.IsRegistered(userKey) { return nil, fmt.Errorf("already has a webauthn device registered for this user") } user := NewUser(authState) credCreation, session, err := c.webAuthn.BeginRegistration(user) if err != nil { return nil, err } c.sessionMap[authID] = session return credCreation, err } func (c *MfaCosigner) FinishRegistration(authID string, parsedResponse *protocol.ParsedCredentialCreationData) error { authState, _ := c.AuthStateStore.LookupAuthState(authID) session := c.sessionMap[authID] userKey := authState.UserKey() if c.IsRegistered(userKey) { return fmt.Errorf("already has a webauthn device registered for this user") } user := NewUser(authState) credential, err := c.webAuthn.CreateCredential(user, *session, parsedResponse) if err != nil { return err } user.AddCredential(*credential) // TODO: Should use some mechanism to ensure that a registration session // can't overwrite the result of another registration session for the same // user if the user interleaved their registration sessions. It is a very // unlikely possibility but it would be good to rule it out. c.users[userKey] = user return nil } func (c *MfaCosigner) BeginLogin(authID string) (*protocol.CredentialAssertion, error) { authState, _ := c.AuthStateStore.LookupAuthState(authID) userKey := authState.UserKey() if user, ok := c.users[userKey]; !ok { return nil, fmt.Errorf("user does not exist for userkey given %s", userKey) } else if credAssertion, session, err := c.webAuthn.BeginLogin(user); err != nil { return nil, err } else { c.sessionMap[authID] = session return credAssertion, err } } func (c *MfaCosigner) FinishLogin(authID string, parsedResponse *protocol.ParsedCredentialAssertionData) (string, string, error) { authState, _ := c.AuthStateStore.LookupAuthState(authID) session := c.sessionMap[authID] userKey := authState.UserKey() _, err := c.webAuthn.ValidateLogin(c.users[userKey], *session, parsedResponse) if err != nil { return "", "", err } if authcode, err := c.NewAuthcode(authID); err != nil { return "", "", err } else { return authcode, authState.RedirectURI, nil } } openpubkey-0.8.0/examples/mfa/mfacosigner/mfacosigner_test.go000066400000000000000000000130371477254274500244660ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package mfacosigner import ( "fmt" "testing" "time" "github.com/go-webauthn/webauthn/webauthn" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/client" wauthnmock "github.com/openpubkey/openpubkey/examples/mfa/mfacosigner/mocks" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/pktoken/mocks" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestFullFlow(t *testing.T) { // Step 0: Setup // Create our PK Token and signer alg := jwa.ES256 signer, err := util.GenKeyPair(alg) require.NoError(t, err) pkt, err := mocks.GenerateMockPKToken(t, signer, alg) require.NoError(t, err) // Create our MFA Cosigner cosSigner, err := util.GenKeyPair(alg) require.NoError(t, err) kid := "test-kid" cosignerURI := "https://example.com" rpID := "http://localhost" RPOrigin := "http://localhost" // WebAuthn configuration cfg := &webauthn.Config{ RPDisplayName: "OpenPubkey", RPID: rpID, RPOrigin: RPOrigin, } cos, err := New(cosSigner, alg, cosignerURI, kid, cfg) require.NoError(t, err) // Create our MFA device wauthnDevice, err := wauthnmock.NewWebauthnDevice(rpID) require.NoError(t, err) // Init MFA Cosigner flow cosP := client.CosignerProvider{ Issuer: "https://example.com", CallbackPath: "/mfaredirect", } redirectURI := fmt.Sprintf("%s%s", "http://localhost:5555", cosP.CallbackPath) initAuthMsgJson, _, err := cosP.CreateInitAuthSig(redirectURI) sig, err := pkt.NewSignedMessage(initAuthMsgJson, signer) authID, err := cos.InitAuth(pkt, sig) require.NoError(t, err) // Register MFA device createCreation, err := cos.BeginRegistration(authID) require.NoError(t, err) require.NotNil(t, createCreation, "expected cred creation to not be nil") credCreationResp, err := wauthnDevice.RegResp(createCreation) require.NoError(t, err) err = cos.FinishRegistration(authID, credCreationResp) require.NoError(t, err) // Login MFA device credAssert, err := cos.BeginLogin(authID) require.NoError(t, err) loginResp, err := wauthnDevice.LoginResp(credAssert) require.NoError(t, err) authcode, ruriRet, err := cos.FinishLogin(authID, loginResp) require.NoError(t, err) require.NotNil(t, credAssert, "expected cred creation to not be nil") require.Equal(t, redirectURI, ruriRet) // Sign the authcode // and exchange it with the Cosigner to get the PK Token cosigned authcodeSig, err := pkt.NewSignedMessage([]byte(authcode), signer) require.NoError(t, err) cosSig, err := cos.RedeemAuthcode(authcodeSig) require.NoError(t, err) require.NotNil(t, cosSig, "expected pktCos to be cosigned") err = pkt.AddSignature(cosSig, pktoken.COS) require.NoError(t, err) } func TestBadCosSigTyp(t *testing.T) { // This a regression test for a bug where we overwrote the cosigner // signature typ claim rather than checked the claim. // TODO: This test should eventually be moved into pktoken tests // and cover all possible typ claims and outcomes. // Create our PK Token and signer alg := jwa.ES256 signer, err := util.GenKeyPair(alg) require.NoError(t, err) pkt, err := mocks.GenerateMockPKToken(t, signer, alg) require.NoError(t, err) // Create our MFA Cosigner cosSigner, err := util.GenKeyPair(alg) require.NoError(t, err) // WebAuthn configuration cfg := &webauthn.Config{ RPDisplayName: "OpenPubkey", RPID: "http://example.com", RPOrigin: "http://example.com", } kid := "test-kid" cosignerURI := "https://example.com" cos, err := New(cosSigner, alg, cosignerURI, kid, cfg) require.NoError(t, err) tests := []struct { typ string wantError bool errorContains string }{ {typ: string(pktoken.COS), wantError: false, errorContains: ""}, {typ: "", wantError: true, errorContains: "incorrect 'typ' claim in protected, expected (COS)"}, {typ: string(pktoken.CIC), wantError: true, errorContains: "incorrect 'typ' claim in protected, expected (COS)"}, {typ: "JWT", wantError: true, errorContains: "incorrect 'typ' claim in protected, expected (COS)"}, {typ: "abcd", wantError: true, errorContains: "incorrect 'typ' claim in protected, expected (COS)"}, } for i, tc := range tests { protected := pktoken.CosignerClaims{ Issuer: "https://example.com", KeyID: kid, Algorithm: string(alg), AuthID: "test-auth-id", AuthTime: time.Now().Unix(), IssuedAt: time.Now().Unix(), Expiration: time.Now().Add(time.Hour).Unix(), RedirectURI: "http://localhost:5555/mfaredirect", Nonce: "23EE", Typ: tc.typ, } // Change the signatures typ claim cosSig, err := cos.Cosign(pkt, protected) require.NoError(t, err) err = pkt.AddSignature(cosSig, pktoken.COS) if tc.wantError { require.ErrorContains(t, err, "incorrect 'typ' claim in protected, expected (COS)", "test %d for typ %s", i+1, tc.typ, err) } else { require.NoError(t, err, "test %d for typ %s: expected: nil, got: %v", i+1, tc.typ, err) } } } openpubkey-0.8.0/examples/mfa/mfacosigner/mocks/000077500000000000000000000000001477254274500217135ustar00rootroot00000000000000openpubkey-0.8.0/examples/mfa/mfacosigner/mocks/webauthn.go000066400000000000000000000125201477254274500240570ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package mocks import ( "crypto" "crypto/ecdsa" "crypto/rand" "crypto/sha256" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol/webauthncbor" "github.com/go-webauthn/webauthn/protocol/webauthncose" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/util" ) // For testing purposes we create a WebAuthn device to run the client part of the protocol type WebAuthnDevice struct { signer crypto.Signer PubkeyCbor []byte RpID string RpIDHash []byte Userhandle []byte RawID []byte AuthFlags byte Counter uint32 } func NewWebauthnDevice(rpID string) (*WebAuthnDevice, error) { alg := jwa.ES256 signer, err := util.GenKeyPair(alg) if err != nil { return nil, err } pubkey := signer.Public().(*ecdsa.PublicKey) pubkeyCbor := webauthncose.EC2PublicKeyData{ PublicKeyData: webauthncose.PublicKeyData{ KeyType: int64(webauthncose.EllipticKey), Algorithm: int64(webauthncose.AlgES256), }, Curve: int64(webauthncose.AlgES256), XCoord: pubkey.X.Bytes(), YCoord: pubkey.Y.Bytes(), } pubkeyCborBytes, err := webauthncbor.Marshal(pubkeyCbor) if err != nil { return nil, err } rpIDHash := sha256.Sum256([]byte(rpID)) return &WebAuthnDevice{ signer: signer, PubkeyCbor: pubkeyCborBytes, RpID: rpID, RpIDHash: rpIDHash[:], // Checked by Webauthn RP to distinguish between different // users accounts sharing the same device with the same RP. // userHandle == user.WebAuthnID()? // // Not all devices can store a user handle it is allowed to be null // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/userHandle // // In OpenPubkey MFA Cosigner RP we set this to the ID Token sub Userhandle: nil, // The ID of the public key credential held by the device RawID: []byte{5, 1, 1, 1, 1}, // Flag 0x41 has two bits set. 0b001 and 0b101 // FlagUserPresent Bit 00000001 - the user is present (UP flag) // FlagUserVerified Bit 00000100 - user is verified using a biometric or PIN (UV flag) AuthFlags: 0x41, // Signature counter, used to identify cloned devices see https://www.w3.org/TR/webauthn/#signature-counter Counter: 0, }, nil } func (wa *WebAuthnDevice) RegResp(createCreation *protocol.CredentialCreation) (*protocol.ParsedCredentialCreationData, error) { wa.Userhandle = []byte(createCreation.Response.User.ID.(protocol.URLEncodedBase64)) return &protocol.ParsedCredentialCreationData{ Response: protocol.ParsedAttestationResponse{ CollectedClientData: protocol.CollectedClientData{ Type: protocol.CeremonyType("webauthn.create"), Challenge: createCreation.Response.Challenge.String(), Origin: createCreation.Response.RelyingParty.ID, }, AttestationObject: protocol.AttestationObject{ Format: "none", AuthData: protocol.AuthenticatorData{ RPIDHash: wa.RpIDHash, Counter: wa.Counter, Flags: protocol.AuthenticatorFlags(wa.AuthFlags), AttData: protocol.AttestedCredentialData{ AAGUID: make([]byte, 16), CredentialID: wa.RawID, CredentialPublicKey: wa.PubkeyCbor, }, }, }, Transports: []protocol.AuthenticatorTransport{protocol.USB, protocol.NFC, "fake"}, }, }, nil } func (wa *WebAuthnDevice) LoginResp(credAssert *protocol.CredentialAssertion) (*protocol.ParsedCredentialAssertionData, error) { loginRespData := &protocol.ParsedCredentialAssertionData{ ParsedPublicKeyCredential: protocol.ParsedPublicKeyCredential{ // Checked by Webauthn RP to see if public key supplied is on the // allowlist of public keys for this user: // parsedResponse.RawID == session.AllowedCredentialIDs? RawID: wa.RawID, }, Response: protocol.ParsedAssertionResponse{ CollectedClientData: protocol.CollectedClientData{ Type: protocol.CeremonyType("webauthn.get"), Challenge: credAssert.Response.Challenge.String(), Origin: wa.RpID, }, AuthenticatorData: protocol.AuthenticatorData{ RPIDHash: wa.RpIDHash, Counter: wa.Counter, Flags: protocol.AuthenticatorFlags(wa.AuthFlags), }, UserHandle: wa.Userhandle, // Not a required field: }, } return wa.SignLoginChallenge(loginRespData) } func (wa *WebAuthnDevice) SignLoginChallenge(loginRespData *protocol.ParsedCredentialAssertionData) (*protocol.ParsedCredentialAssertionData, error) { clientDataHash := sha256.Sum256(loginRespData.Raw.AssertionResponse.ClientDataJSON) sigData := append(loginRespData.Raw.AssertionResponse.AuthenticatorData, clientDataHash[:]...) sigHash := sha256.Sum256(sigData) sigWebauthn, err := wa.signer.Sign(rand.Reader, sigHash[:], crypto.SHA256) if err != nil { return nil, err } loginRespData.Response.Signature = sigWebauthn return loginRespData, nil } openpubkey-0.8.0/examples/mfa/mfacosigner/server.go000066400000000000000000000153121477254274500224360ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package mfacosigner import ( "encoding/json" "fmt" "net/http" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/examples/mfa/mfacosigner/jwks" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/util" ) type Server struct { cosigner *MfaCosigner jwksUri string } func NewMfaCosignerHttpServer(serverUri, rpID, rpOrigin, RPDisplayName string) (*Server, error) { server := &Server{} // WebAuthn configuration cfg := &webauthn.Config{ RPDisplayName: RPDisplayName, RPID: rpID, RPOrigin: rpOrigin, } // Generate the key pair for our cosigner alg := jwa.ES256 signer, err := util.GenKeyPair(alg) if err != nil { return nil, err } jwksServer, kid, err := jwks.NewJwksServer(signer, alg) if err != nil { return nil, err } jwksHost := jwksServer.URI() server.jwksUri = fmt.Sprintf("%s/.well-known/jwks.json", jwksHost) issuer := rpOrigin fmt.Println("JWKS hosted at", server.jwksUri) server.cosigner, err = New(signer, alg, issuer, kid, cfg) if err != nil { return nil, err } mux := http.NewServeMux() mux.Handle("/", http.FileServer(http.Dir("mfacosigner/static"))) mux.HandleFunc("/mfa-auth-init", server.initAuth) mux.HandleFunc("/check-registration", server.checkIfRegistered) mux.HandleFunc("/register/begin", server.beginRegistration) mux.HandleFunc("/register/finish", server.finishRegistration) mux.HandleFunc("/login/begin", server.beginLogin) mux.HandleFunc("/login/finish", server.finishLogin) mux.HandleFunc("/sign", server.signPkt) mux.HandleFunc("/.well-known/openid-configuration", server.wellKnownConf) err = http.ListenAndServe(":3003", mux) return server, err } func (s *Server) URI() string { return s.cosigner.Issuer } func (s *Server) initAuth(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { return } pktB64 := []byte(r.URL.Query().Get("pkt")) pktJson, err := util.Base64DecodeForJWT(pktB64) if err != nil { return } var pkt *pktoken.PKToken if err := json.Unmarshal(pktJson, &pkt); err != nil { return } sig := []byte(r.URL.Query().Get("sig1")) authID, err := s.cosigner.InitAuth(pkt, sig) if err != nil { http.Error(w, "Error initiating authentication", http.StatusInternalServerError) return } mfapage := fmt.Sprintf("/?authid=%s", authID) http.Redirect(w, r, mfapage, http.StatusFound) } func (s *Server) checkIfRegistered(w http.ResponseWriter, r *http.Request) { authID, err := GetAuthID(r) if err != nil { http.Error(w, "Error in authID", http.StatusInternalServerError) return } registered := s.cosigner.CheckIsRegistered(authID) response, _ := json.Marshal(map[string]bool{ "isRegistered": registered, }) w.WriteHeader(200) w.Write(response) } func GetAuthID(r *http.Request) (string, error) { if err := r.ParseForm(); err != nil { return "", err } return string([]byte(r.URL.Query().Get("authid"))), nil } func (s *Server) beginRegistration(w http.ResponseWriter, r *http.Request) { authID, err := GetAuthID(r) if err != nil { http.Error(w, "Error in authID", http.StatusInternalServerError) return } options, err := s.cosigner.BeginRegistration(authID) optionsJson, err := json.Marshal(options) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write(optionsJson) } func (s *Server) finishRegistration(w http.ResponseWriter, r *http.Request) { authID, err := GetAuthID(r) if err != nil { http.Error(w, "Error in authID", http.StatusInternalServerError) return } parsedResponse, err := protocol.ParseCredentialCreationResponse(r) if err != nil { http.Error(w, "Error in parsing credential", http.StatusInternalServerError) return } err = s.cosigner.FinishRegistration(authID, parsedResponse) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(201) fmt.Println("MFA registration complete") } func (s *Server) beginLogin(w http.ResponseWriter, r *http.Request) { authID, err := GetAuthID(r) if err != nil { http.Error(w, "Error in authID", http.StatusInternalServerError) return } options, err := s.cosigner.BeginLogin(authID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } optionsJson, err := json.Marshal(options) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write(optionsJson) } func (s *Server) finishLogin(w http.ResponseWriter, r *http.Request) { authID, err := GetAuthID(r) if err != nil { http.Error(w, "Error in authID", http.StatusInternalServerError) return } parsedResponse, err := protocol.ParseCredentialRequestResponse(r) if err != nil { http.Error(w, "Error in parsing credential", http.StatusInternalServerError) return } authcode, ruri, err := s.cosigner.FinishLogin(authID, parsedResponse) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } redirectURIl := fmt.Sprintf("%s?authcode=%s", ruri, authcode) response, _ := json.Marshal(map[string]string{ "redirect_uri": redirectURIl, }) w.WriteHeader(201) w.Write(response) } func (s *Server) signPkt(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } sig := []byte(r.URL.Query().Get("sig2")) if cosSig, err := s.cosigner.RedeemAuthcode(sig); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } else { cosSigB64 := util.Base64EncodeForJWT(cosSig) w.WriteHeader(201) w.Write(cosSigB64) } } func (s *Server) wellKnownConf(w http.ResponseWriter, r *http.Request) { type WellKnown struct { Issuer string `json:"issuer"` JwksUri string `json:"jwks_uri"` } wk := WellKnown{ Issuer: s.cosigner.Issuer, JwksUri: s.jwksUri, } wkJson, err := json.Marshal(wk) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(200) w.Write(wkJson) } openpubkey-0.8.0/examples/mfa/mfacosigner/static/000077500000000000000000000000001477254274500220665ustar00rootroot00000000000000openpubkey-0.8.0/examples/mfa/mfacosigner/static/index.html000066400000000000000000000142061477254274500240660ustar00rootroot00000000000000
Authenticating with MFA...
openpubkey-0.8.0/examples/mfa/mfacosigner/user.go000066400000000000000000000024101477254274500221010ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package mfacosigner import ( "github.com/go-webauthn/webauthn/webauthn" ) type user struct { id []byte username string displayName string credentials []webauthn.Credential } var _ webauthn.User = (*user)(nil) func (u *user) WebAuthnID() []byte { return u.id } func (u *user) WebAuthnName() string { return u.username } func (u *user) WebAuthnDisplayName() string { return u.displayName } func (u *user) WebAuthnIcon() string { return "" } func (u *user) AddCredential(cred webauthn.Credential) { u.credentials = append(u.credentials, cred) } func (u *user) WebAuthnCredentials() []webauthn.Credential { return u.credentials } openpubkey-0.8.0/examples/simple/000077500000000000000000000000001477254274500170305ustar00rootroot00000000000000openpubkey-0.8.0/examples/simple/example.go000066400000000000000000000061601477254274500210150ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package main import ( "context" "fmt" "github.com/goccy/go-json" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/verifier" ) func Sign(op client.OpenIdProvider) ([]byte, []byte, error) { // Create a OpenPubkey client, this automatically generates a fresh // key pair (public key, signing key). The public key is added to any // PK Tokens the client generates opkClient, err := client.New(op) if err != nil { return nil, nil, err } // Generate a PK Token by authenticating to the OP (Google) pkt, err := opkClient.Auth(context.Background()) if err != nil { return nil, nil, err } // Use the signing key that the client just generated to sign the message msg := []byte("All is discovered - flee at once") signedMsg, err := pkt.NewSignedMessage(msg, opkClient.GetSigner()) if err != nil { return nil, nil, err } // Serialize the PK Token as JSON and distribute it with the signed message pktJson, err := json.Marshal(pkt) if err != nil { return nil, nil, err } return pktJson, signedMsg, nil } func Verify(op client.OpenIdProvider, pktJson []byte, signedMsg []byte) error { // Create a PK Token object from the PK Token JSON pkt := new(pktoken.PKToken) err := json.Unmarshal(pktJson, &pkt) if err != nil { return err } // Verify that PK Token is issued by the OP you wish to use pktVerifier, err := verifier.New(op) if err != nil { return err } err = pktVerifier.VerifyPKToken(context.Background(), pkt) if err != nil { return err } // Check that the message verifies under the user's public key in the PK Token msg, err := pkt.VerifySignedMessage(signedMsg) if err != nil { return err } // Get the signer's email address from ID Token inside the PK Token idtClaims := new(oidc.OidcClaims) if err := json.Unmarshal(pkt.Payload, idtClaims); err != nil { return err } fmt.Printf("Verification successful: %s (%s) signed the message '%s'\n", idtClaims.Email, idtClaims.Issuer, string(msg)) return nil } func main() { opOptions := providers.GetDefaultGoogleOpOptions() // Change this to true to turn on GQ signatures opOptions.GQSign = false op := providers.NewGoogleOpWithOptions(opOptions) pktJson, signedMsg, err := Sign(op) if err != nil { fmt.Println("Failed to sign message:", err) return } err = Verify(op, pktJson, signedMsg) if err != nil { fmt.Println("Failed to verify message:", err) return } } openpubkey-0.8.0/examples/simple/example_test.go000066400000000000000000000027121477254274500220530ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package main import ( "testing" "github.com/openpubkey/openpubkey/providers" "github.com/stretchr/testify/require" ) func TestGitlabExample(t *testing.T) { providerOpts := providers.MockProviderOpts{ Issuer: "mockIssuer", GQSign: true, NumKeys: 2, CommitType: providers.CommitTypesEnum.GQ_BOUND, VerifierOpts: providers.ProviderVerifierOpts{ CommitType: providers.CommitTypesEnum.GQ_BOUND, SkipClientIDCheck: true, // For GQCommitments we skip this check since the audience is set to the hardcoded prefix GQOnly: true, }, } op, _, _, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) pktJson, signedMsg, err := Sign(op) require.NoError(t, err) require.NotNil(t, pktJson) require.NotNil(t, signedMsg) err = Verify(op, pktJson, signedMsg) require.NoError(t, err) } openpubkey-0.8.0/examples/web/000077500000000000000000000000001477254274500163145ustar00rootroot00000000000000openpubkey-0.8.0/examples/web/example.go000066400000000000000000000127551477254274500203100ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package main import ( "bytes" "context" "crypto" "crypto/ecdsa" "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "os" "os/signal" "path" "syscall" "github.com/awnumar/memguard" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/client/choosers" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/util" "github.com/openpubkey/openpubkey/verifier" "golang.org/x/crypto/sha3" ) var ( // File names for when we save or load our pktoken and the corresponding signing key skFileName = "key.pem" pktFileName = "pktoken.json" ) func main() { // Safely terminate in case of an interrupt signal memguard.CatchInterrupt() // Purge the session when we return defer memguard.Purge() if len(os.Args) < 2 { fmt.Printf("OpenPubkey: command choices are login, sign, and cert") return } gqSign := false // Directory for saving data outputDir := "output/google" command := os.Args[1] switch command { case "login": if err := login(outputDir, gqSign); err != nil { fmt.Println("Error logging in:", err) } else { fmt.Println("Login successful!") } case "sign": message := "sign me!!" if err := sign(message, outputDir); err != nil { fmt.Println("Failed to sign test message:", err) } default: fmt.Println("Unrecognized command:", command) } } func login(outputDir string, gqSign bool) error { googleOpOptions := providers.GetDefaultGoogleOpOptions() googleOpOptions.GQSign = gqSign googleOp := providers.NewGoogleOpWithOptions(googleOpOptions) azureOpOptions := providers.GetDefaultAzureOpOptions() azureOpOptions.GQSign = gqSign azureOp := providers.NewAzureOpWithOptions(azureOpOptions) ctx, cancel := context.WithCancel(context.Background()) sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigs fmt.Printf("Received shutdown signal, exiting... %v\n", sigs) cancel() }() openBrowser := true op, err := choosers.NewWebChooser( []providers.BrowserOpenIdProvider{googleOp, azureOp}, openBrowser, ).ChooseOp(ctx) if err != nil { return err } opkClient, err := client.New(op) if err != nil { return err } pkt, err := opkClient.Auth(ctx, client.WithExtraClaim("extra", "yes")) if err != nil { return err } // Pretty print our json token pktJson, err := json.MarshalIndent(pkt, "", " ") if err != nil { return err } fmt.Println(string(pktJson)) newPkt, err := opkClient.Refresh(ctx) if err != nil { return err } fmt.Println("refreshed ID Token", string(newPkt.FreshIDToken)) pktCom, err := pkt.Compact() if err != nil { return err } fmt.Println("Compact", len(pktCom), string(pktCom)) // Verify that PK Token is issued by the OP you wish to use and that it has a refreshed ID Token ops := []verifier.ProviderVerifier{googleOp, azureOp} pktVerifier, err := verifier.NewFromMany(ops, verifier.RequireRefreshedIDToken()) if err != nil { return err } err = pktVerifier.VerifyPKToken(context.Background(), newPkt) if err != nil { return err } // Save our signer and pktoken by writing them to a file return saveLogin(outputDir, opkClient.GetSigner().(*ecdsa.PrivateKey), newPkt) } func sign(message string, outputDir string) error { signer, pkt, err := loadLogin(outputDir) if err != nil { return fmt.Errorf("failed to load client state: %w", err) } msgHashSum := sha3.Sum256([]byte(message)) sig, err := signer.Sign(rand.Reader, msgHashSum[:], crypto.SHA256) if err != nil { return err } fmt.Println("Signed Message:", message) fmt.Println("Praise Sigma:", base64.StdEncoding.EncodeToString(sig)) fmt.Println("Hash:", hex.EncodeToString(msgHashSum[:])) fmt.Println("Cert:") pktJson, err := json.Marshal(pkt) if err != nil { return err } // Pretty print our json token var prettyJSON bytes.Buffer if err := json.Indent(&prettyJSON, pktJson, "", " "); err != nil { return err } fmt.Println(prettyJSON.String()) return nil } func saveLogin(outputDir string, sk *ecdsa.PrivateKey, pkt *pktoken.PKToken) error { if err := os.MkdirAll(outputDir, 0777); err != nil { return err } skFilePath := path.Join(outputDir, skFileName) if err := util.WriteSKFile(skFilePath, sk); err != nil { return err } pktFilePath := path.Join(outputDir, pktFileName) pktJson, err := json.Marshal(pkt) if err != nil { return err } return os.WriteFile(pktFilePath, pktJson, 0600) } func loadLogin(outputDir string) (crypto.Signer, *pktoken.PKToken, error) { skFilePath := path.Join(outputDir, skFileName) key, err := util.ReadSKFile(skFilePath) if err != nil { return nil, nil, err } pktFilePath := path.Join(outputDir, pktFileName) pktJson, err := os.ReadFile(pktFilePath) if err != nil { return nil, nil, err } var pkt *pktoken.PKToken if err := json.Unmarshal(pktJson, &pkt); err != nil { return nil, nil, err } return key, pkt, nil } openpubkey-0.8.0/examples/x509/000077500000000000000000000000001477254274500162445ustar00rootroot00000000000000openpubkey-0.8.0/examples/x509/ca/000077500000000000000000000000001477254274500166275ustar00rootroot00000000000000openpubkey-0.8.0/examples/x509/ca/ca.go000066400000000000000000000132521477254274500175440ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package ca import ( "bytes" "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/json" "encoding/pem" "fmt" "math/big" "time" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/cert" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/verifier" ) type Ca struct { pksk *ecdsa.PrivateKey Alg jwa.KeyAlgorithm // CaCertBytes []byte RootCertPem []byte op client.OpenIdProvider } func New(op client.OpenIdProvider) (*Ca, error) { ca := Ca{ op: op, } alg := string(jwa.ES256) err := ca.KeyGen(alg) if err != nil { return nil, err } return &ca, nil } func (a *Ca) KeyGen(alg string) error { a.Alg = jwa.KeyAlgorithmFrom(alg) pksk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return err } a.pksk = pksk caTemplate := &x509.Certificate{ SerialNumber: big.NewInt(2019), Subject: pkix.Name{ Organization: []string{"Openpubkey-test-ca-cert"}, Country: []string{"International"}, Province: []string{""}, Locality: []string{""}, StreetAddress: []string{"255 Test St."}, PostalCode: []string{""}, }, NotBefore: time.Now(), NotAfter: time.Now().AddDate(10, 0, 0), IsCA: true, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageCodeSigning}, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, BasicConstraintsValid: true, } caBytes, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &a.pksk.PublicKey, a.pksk) if err != nil { return err } caPEM := new(bytes.Buffer) pem.Encode(caPEM, &pem.Block{ Type: "CERTIFICATE", Bytes: caBytes, }) a.RootCertPem = caPEM.Bytes() return nil } func (a *Ca) CheckPKToken(pktJson []byte) (*pktoken.PKToken, error) { pkt := new(pktoken.PKToken) if err := json.Unmarshal(pktJson, pkt); err != nil { return nil, err } verifier, err := verifier.New(a.op) if err != nil { return nil, err } if err := verifier.VerifyPKToken(context.TODO(), pkt); err != nil { return nil, fmt.Errorf("failed to verify PK token: %w", err) } return pkt, nil } func (a *Ca) PktToSignedX509(pktJson []byte) ([]byte, error) { pkt, err := a.CheckPKToken(pktJson) if err != nil { return nil, err } pktUpk, err := ExtractRawPubkey(pkt) if err != nil { return nil, err } subTemplate, err := cert.PktToX509Template(pkt) if err != nil { return nil, err } rootCert, _ := pem.Decode(a.RootCertPem) if rootCert == nil { return nil, fmt.Errorf("failed to parse certificate PEM") } caTemplate, err := x509.ParseCertificate(rootCert.Bytes) if err != nil { return nil, err } subCertBytes, err := x509.CreateCertificate(rand.Reader, subTemplate, caTemplate, pktUpk, a.pksk) if err != nil { return nil, err } subCert, err := x509.ParseCertificate(subCertBytes) if err != nil { return nil, err } var pemSubCert bytes.Buffer err = pem.Encode(&pemSubCert, &pem.Block{Type: "CERTIFICATE", Bytes: subCert.Raw}) if err != nil { return nil, err } return pemSubCert.Bytes(), nil } // VerifyPktCert checks that the X509 cert is signed by the CA and that // the PK Token in the cert matches the public key in the cert. func (a *Ca) VerifyPktCert(issuedCertPEM []byte) error { roots := x509.NewCertPool() ok := roots.AppendCertsFromPEM([]byte(a.RootCertPem)) if !ok { return fmt.Errorf("failed to parse root certificate") } block, _ := pem.Decode([]byte(issuedCertPEM)) if block == nil { return fmt.Errorf("failed to parse certificate PEM") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return fmt.Errorf("failed to parse certificate PEM: %w", err) } _, err = cert.Verify(x509.VerifyOptions{ Roots: roots, Intermediates: x509.NewCertPool(), KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}, }) if err != nil { return fmt.Errorf("failed to verify certificate: %w", err) } pktJson := cert.SubjectKeyId pkt := new(pktoken.PKToken) if err := json.Unmarshal(pktJson, pkt); err != nil { return err } pktUpk, err := ExtractRawPubkey(pkt) if err != nil { return err } certPublickey := cert.PublicKey.(*ecdsa.PublicKey) if !certPublickey.Equal(pktUpk) { return fmt.Errorf("public key in cert does not match PK Token's public key") } certPublicKeyBytes, err := x509.MarshalPKIXPublicKey(certPublickey) if err := json.Unmarshal(pktJson, pkt); err != nil { return err } if string(cert.RawSubjectPublicKeyInfo) != string(certPublicKeyBytes) { return fmt.Errorf("certificate raw subject public key info does not match ephemeral public key") } // Verification succeeds return nil } func ExtractRawPubkey(pkt *pktoken.PKToken) (interface{}, error) { cic, err := pkt.GetCicValues() if err != nil { return nil, err } upk := cic.PublicKey() var rawUpk interface{} // This is the raw key, like *rsa.PrivateKey or *ecdsa.PrivateKey if err := upk.Raw(&rawUpk); err != nil { return nil, err } return rawUpk, nil } openpubkey-0.8.0/examples/x509/ca/ca_test.go000066400000000000000000000037561477254274500206130ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package ca import ( "context" "crypto/ecdsa" "crypto/x509" "encoding/json" "encoding/pem" "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/stretchr/testify/require" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/util" ) func TestCACertCreation(t *testing.T) { providerOpts := providers.DefaultMockProviderOpts() op, _, _, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) certAuth, err := New(op) require.NoError(t, err) err = certAuth.KeyGen(string(jwa.ES256)) require.NoError(t, err) userAlg := jwa.ES256 userSigningKey, err := util.GenKeyPair(userAlg) require.NoError(t, err) client, err := client.New(op, client.WithSigner(userSigningKey, userAlg)) require.NoError(t, err) pkt, err := client.Auth(context.Background()) require.NoError(t, err) pktJson, err := json.Marshal(pkt) require.NoError(t, err) pemSubCert, err := certAuth.PktToSignedX509(pktJson) require.NoError(t, err) decodeBlock, _ := pem.Decode(pemSubCert) cc, err := x509.ParseCertificate(decodeBlock.Bytes) require.NoError(t, err) certPubkey := cc.PublicKey.(*ecdsa.PublicKey) _, err = jws.Verify(pkt.CicToken, jws.WithKey(jwa.ES256, certPubkey)) require.NoError(t, err) err = certAuth.VerifyPktCert(pemSubCert) require.NoError(t, err) } openpubkey-0.8.0/examples/x509/example.go000066400000000000000000000044201477254274500202260ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package main import ( "context" "encoding/json" "fmt" "os" "github.com/awnumar/memguard" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/examples/x509/ca" "github.com/openpubkey/openpubkey/providers" ) func main() { // Safely terminate in case of an interrupt signal memguard.CatchInterrupt() // Purge the session when we return defer memguard.Purge() if len(os.Args) < 2 { fmt.Printf("OpenPubkey: command choices are login, sign, and cert") return } command := os.Args[1] switch command { case "login": opOpts := providers.GetDefaultGoogleOpOptions() opOpts.GQSign = true op := providers.NewGoogleOp() if err := login(op); err != nil { fmt.Println("Error logging in:", err) } else { fmt.Println("Login and X509 issuance successful!") } default: fmt.Println("Unrecognized command:", command) } } func login(op client.OpenIdProvider) error { opkClient, err := client.New( op, ) if err != nil { return err } pkt, err := opkClient.Auth(context.Background()) if err != nil { return err } // Pretty print our json token pktJson, err := json.MarshalIndent(pkt, "", " ") if err != nil { return err } CertAuth, err := ca.New(op) if err != nil { return err } pemSubCert, err := CertAuth.PktToSignedX509(pktJson) if err != nil { return err } fmt.Println("Issued Cert: \n", string(pemSubCert)) msg := []byte("All is discovered - flee at once") signedMsg, err := pkt.NewSignedMessage(msg, opkClient.GetSigner()) if err != nil { return err } println("Signed Message: \n", string(signedMsg)) err = CertAuth.VerifyPktCert(pemSubCert) if err != nil { return err } return nil } openpubkey-0.8.0/examples/x509/example_test.go000066400000000000000000000023441477254274500212700ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package main import ( "testing" "github.com/openpubkey/openpubkey/providers" "github.com/stretchr/testify/require" ) func TestSimpleExample(t *testing.T) { providerOpts := providers.MockProviderOpts{ Issuer: "mockIssuer", GQSign: true, NumKeys: 2, CommitType: providers.CommitTypesEnum.AUD_CLAIM, VerifierOpts: providers.ProviderVerifierOpts{ CommitType: providers.CommitTypesEnum.AUD_CLAIM, SkipClientIDCheck: true, GQOnly: true, }, } op, _, _, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) err = login(op) require.NoError(t, err) } openpubkey-0.8.0/go.mod000066400000000000000000000041371477254274500150340ustar00rootroot00000000000000module github.com/openpubkey/openpubkey go 1.23.7 require ( filippo.io/bigmod v0.0.3 github.com/awnumar/memguard v0.22.3 github.com/go-webauthn/webauthn v0.8.6 github.com/google/uuid v1.6.0 github.com/lestrrat-go/jwx/v2 v2.0.21 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 github.com/zitadel/oidc/v3 v3.23.2 golang.org/x/crypto v0.35.0 ) require ( github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/kr/pretty v0.3.0 // indirect github.com/rogpeppe/go-internal v1.8.1 // indirect github.com/zitadel/logging v0.6.0 // indirect github.com/zitadel/schema v1.3.0 // indirect go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/metric v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect golang.org/x/net v0.36.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) require ( github.com/awnumar/memcall v0.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/fxamacker/cbor/v2 v2.4.0 // indirect github.com/go-webauthn/x v0.1.4 // indirect github.com/goccy/go-json v0.10.2 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/go-tpm v0.9.0 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.5 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/muhlemmer/gu v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/x448/float16 v0.8.4 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d golang.org/x/oauth2 v0.25.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) openpubkey-0.8.0/go.sum000066400000000000000000000254341477254274500150640ustar00rootroot00000000000000filippo.io/bigmod v0.0.3 h1:qmdCFHmEMS+PRwzrW6eUrgA4Q3T8D6bRcjsypDMtWHM= filippo.io/bigmod v0.0.3/go.mod h1:WxGvOYE0OUaBC2N112Dflb3CjOnMBuNRA2UWZc2UbPE= github.com/awnumar/memcall v0.1.2 h1:7gOfDTL+BJ6nnbtAp9+HQzUFjtP1hEseRQq8eP055QY= github.com/awnumar/memcall v0.1.2/go.mod h1:S911igBPR9CThzd/hYQQmTc9SWNu3ZHIlCGaWsWsoJo= github.com/awnumar/memguard v0.22.3 h1:b4sgUXtbUjhrGELPbuC62wU+BsPQy+8lkWed9Z+pj0Y= github.com/awnumar/memguard v0.22.3/go.mod h1:mmGunnffnLHlxE5rRgQc3j+uwPZ27eYb61ccr8Clz2Y= github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88= github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-webauthn/webauthn v0.8.6 h1:bKMtL1qzd2WTFkf1mFTVbreYrwn7dsYmEPjTq6QN90E= github.com/go-webauthn/webauthn v0.8.6/go.mod h1:emwVLMCI5yx9evTTvr0r+aOZCdWJqMfbRhF0MufyUog= github.com/go-webauthn/x v0.1.4 h1:sGmIFhcY70l6k7JIDfnjVBiAAFEssga5lXIUXe0GtAs= github.com/go-webauthn/x v0.1.4/go.mod h1:75Ug0oK6KYpANh5hDOanfDI+dvPWHk788naJVG/37H8= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk= github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/jeremija/gosubmit v0.2.7 h1:At0OhGCFGPXyjPYAsCchoBUhE099pcBXmsb4iZqROIc= github.com/jeremija/gosubmit v0.2.7/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc v1.0.5 h1:bsTfiH8xaKOJPrg1R+E3iE/AWZr/x0Phj9PBTG/OLUk= github.com/lestrrat-go/httprc v1.0.5/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= github.com/lestrrat-go/jwx/v2 v2.0.21 h1:jAPKupy4uHgrHFEdjVjNkUgoBKtVDgrQPB/h55FHrR0= github.com/lestrrat-go/jwx/v2 v2.0.21/go.mod h1:09mLW8zto6bWL9GbwnqAli+ArLf+5M33QLQPDggkUWM= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/zitadel/logging v0.6.0 h1:t5Nnt//r+m2ZhhoTmoPX+c96pbMarqJvW1Vq6xFTank= github.com/zitadel/logging v0.6.0/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= github.com/zitadel/oidc/v3 v3.23.2 h1:vRUM6SKudr6WR/lqxue4cvCbgR+IdEJGVBklucKKXgk= github.com/zitadel/oidc/v3 v3.23.2/go.mod h1:9snlhm3W/GNURqxtchjL1AAuClWRZ2NTkn9sLs1WYfM= github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= openpubkey-0.8.0/gq/000077500000000000000000000000001477254274500143305ustar00rootroot00000000000000openpubkey-0.8.0/gq/benchmark_test.go000066400000000000000000000055211477254274500176530ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package gq import ( "crypto/rand" "crypto/rsa" "testing" "github.com/stretchr/testify/require" ) var result error var boolResult bool type testTuple struct { rsaPublicKey *rsa.PublicKey token []byte } func BenchmarkSigning(b *testing.B) { // Generate test matrix matrix, err := generateTestMatrix(b.N) require.NoError(b, err) // Reset the benchmark timer to exclude setup time b.ResetTimer() var signerVerifier SignerVerifier for i := 0; i < b.N; i++ { signerVerifier, err = NewSignerVerifier(matrix[i].rsaPublicKey, 256) require.NoError(b, err) _, err = signerVerifier.SignJWT(matrix[i].token) require.NoError(b, err) } // Avoid compiler optimisations eliminating the function under test and artificially lowering the run time of the benchmark // ref: https://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go result = err } func BenchmarkVerifying(b *testing.B) { // Generate test matrix matrix, err := generateTestMatrix(b.N) require.NoError(b, err) // Generate signatures using matrix gqSignedTokens := [][]byte{} for i := 0; i < b.N; i++ { signerVerifier, err := NewSignerVerifier(matrix[i].rsaPublicKey, 256) require.NoError(b, err) sig, err := signerVerifier.SignJWT(matrix[i].token) require.NoError(b, err) gqSignedTokens = append(gqSignedTokens, sig) } // Reset the benchmark timer to exclude setup time b.ResetTimer() var ok bool for i := 0; i < b.N; i++ { signerVerifier, err := NewSignerVerifier(matrix[i].rsaPublicKey, 256) require.NoError(b, err) ok = signerVerifier.VerifyJWT(gqSignedTokens[i]) require.True(b, ok, "Failed to verify signature!") } // Avoid compiler optimisations eliminating the function under test and artificially lowering the run time of the benchmark // ref: https://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go boolResult = ok } func generateTestMatrix(n int) ([]testTuple, error) { tests := []testTuple{} for i := 0; i < n; i++ { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, err } jwt, err := createOIDCToken(key, "very_fake_audience_claim") if err != nil { return nil, err } tests = append(tests, testTuple{rsaPublicKey: &key.PublicKey, token: jwt}) } return tests, nil } openpubkey-0.8.0/gq/bigutil.go000066400000000000000000000021001477254274500163070ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package gq import ( "math/big" "filippo.io/bigmod" ) // leaks only the size of x func modAsInt(x *bigmod.Modulus) *big.Int { return new(big.Int).SetBytes(x.Nat().Bytes(x)) } // leaks only the size of x func natAsInt(x *bigmod.Nat, m *bigmod.Modulus) *big.Int { return new(big.Int).SetBytes(x.Bytes(m)) } // leaks only the size of x func intAsNat(x *big.Int, m *bigmod.Modulus) (*bigmod.Nat, error) { return bigmod.NewNat().SetBytes(x.Bytes(), m) } openpubkey-0.8.0/gq/gq.go000066400000000000000000000142061477254274500152710ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package gq import ( "crypto/rsa" "fmt" "io" "math/big" "filippo.io/bigmod" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "golang.org/x/crypto/sha3" ) var GQ256 = jwa.SignatureAlgorithm("GQ256") func init() { jwa.RegisterSignatureAlgorithm(GQ256) } type OptsStruct struct { extraClaims map[string]any } type Opts func(a *OptsStruct) // WithExtraClaim specifies additional values to be included in the // GQ signed JWT. These claims will be included in the protected header // of the JWT // Example use: // // WithExtraClaim("claimKey", "claimValue") func WithExtraClaim(k string, v string) Opts { return func(a *OptsStruct) { if a.extraClaims == nil { a.extraClaims = map[string]any{} } a.extraClaims[k] = v } } // GQ256SignJWT takes a rsaPublicKey and signed JWT and computes a GQ1 signature // on the JWT. It returns a JWT whose RSA signature has been replaced by // the GQ signature. It is wrapper around SignerVerifier.SignJWT // an additional check that the correct rsa public key has been supplied. // Use this instead of SignerVerifier.SignJWT. func GQ256SignJWT(rsaPublicKey *rsa.PublicKey, jwt []byte, opts ...Opts) ([]byte, error) { _, err := jws.Verify(jwt, jws.WithKey(jwa.RS256, rsaPublicKey)) if err != nil { return nil, fmt.Errorf("incorrect public key supplied when GQ signing jwt: %w", err) } sv, err := New256SignerVerifier(rsaPublicKey) if err != nil { return nil, fmt.Errorf("error creating GQ signer: %w", err) } gqJWT, err := sv.SignJWT(jwt, opts...) if err != nil { return nil, fmt.Errorf("error creating GQ signature: %w", err) } return gqJWT, nil } // GQ256VerifyJWT verifies a GQ1 signature over GQ signed JWT func GQ256VerifyJWT(rsaPublicKey *rsa.PublicKey, gqToken []byte) (bool, error) { sv, err := New256SignerVerifier(rsaPublicKey) if err != nil { return false, fmt.Errorf("error creating GQ signer: %w", err) } return sv.VerifyJWT(gqToken), nil } // Signer allows for creating GQ1 signatures messages. type Signer interface { // Sign creates a GQ1 signature over the given message with the given GQ1 private number. Sign(private []byte, message []byte) ([]byte, error) // SignJWT creates a GQ1 signature over the JWT token's header/payload with a GQ1 private number derived from the JWT signature. // // This works because a GQ1 private number can be calculated as the inverse mod n of an RSA signature, where n is the public RSA modulus. SignJWT(jwt []byte, opts ...Opts) ([]byte, error) } // Signer allows for verifying GQ1 signatures. type Verifier interface { // Verify verifies a GQ1 signature over a message, using the public identity of the signer. Verify(signature []byte, identity []byte, message []byte) bool // Compatible with SignJWT, this function verifies the GQ1 signature of the presented JSON Web Token. VerifyJWT(jwt []byte) bool } // SignerVerifier combines the Signer and Verifier interfaces. type SignerVerifier interface { Signer Verifier } type signerVerifier struct { // n is the RSA public modulus (what Go's RSA lib calls N) n *bigmod.Modulus // v is the RSA public exponent (what Go's RSA lib calls E) v *big.Int // nBytes is the length of n in bytes nBytes int // vBytes is the length of v in bytes vBytes int // t is the signature length parameter t int } // Creates a new SignerVerifier specifically for GQ256, meaning the security parameter is 256. func New256SignerVerifier(publicKey *rsa.PublicKey) (SignerVerifier, error) { return NewSignerVerifier(publicKey, 256) } // NewSignerVerifier creates a SignerVerifier from the RSA public key of the trusted third-party which creates // the GQ1 private numbers. // // The securityParameter parameter is the level of desired security in bits. 256 is recommended. func NewSignerVerifier(publicKey *rsa.PublicKey, securityParameter int) (SignerVerifier, error) { if publicKey.E != 65537 { // Danger: Currently it is unsafe to use this library with a RSA exponent other than 65537. // This issue is being tracked in https://github.com/openpubkey/openpubkey/issues/230 return nil, fmt.Errorf("only 65537 is currently supported, unsupported RSA public key exponent: %d", publicKey.E) } n, v, nBytes, vBytes, err := parsePublicKey(publicKey) t := securityParameter / (vBytes * 8) return &signerVerifier{n, v, nBytes, vBytes, t}, err } func parsePublicKey(publicKey *rsa.PublicKey) (n *bigmod.Modulus, v *big.Int, nBytes int, vBytes int, err error) { n, err = bigmod.NewModulusFromBig(publicKey.N) if err != nil { return } v = big.NewInt(int64(publicKey.E)) nLen := n.BitLen() vLen := v.BitLen() - 1 // note the -1; GQ1 only ever uses the (length of v) - 1, so we can just do this here rather than throughout nBytes = bytesForBits(nLen) vBytes = bytesForBits(vLen) return } func bytesForBits(bits int) int { return (bits + 7) / 8 } var hash = func(byteCount int, data ...[]byte) ([]byte, error) { rng := sha3.NewShake256() for _, d := range data { rng.Write(d) } return randomBytes(rng, byteCount) } func randomBytes(rng io.Reader, byteCount int) ([]byte, error) { bytes := make([]byte, byteCount) _, err := io.ReadFull(rng, bytes) if err != nil { return nil, err } return bytes, nil } func OriginalJWTHeaders(jwt []byte) ([]byte, error) { token, err := jws.Parse(jwt) if err != nil { return nil, err } // a JWT is guaranteed to have exactly one signature headers := token.Signatures()[0].ProtectedHeaders() if headers.Algorithm() != GQ256 { return nil, fmt.Errorf("expected GQ256 alg, got %s", headers.Algorithm()) } origHeaders := []byte(headers.KeyID()) return origHeaders, nil } openpubkey-0.8.0/gq/gq_iso_test.go000066400000000000000000000117071477254274500172050ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package gq import ( "crypto/sha1" "encoding/hex" "math/big" "testing" "filippo.io/bigmod" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) // The test vector specifies hex values for each of the signature scheme's data // elements, as well as the expected content of the signature const nHex = "D37B4534B4B788AE23E1E4719A395BBFF8A98EDBDCB3992306C513AAA95E9A335221998C20CD1344CA50C59193B84437FFC1E91E5EBEF9587615875102A7E83624DA4F72CAF28D1DF429652346D6F203E17C65288790F6F6D97835216B49F5932728A967D6D36561621FF38DFC185DFA5A160962E7C8E087CE90897B16EA4EA1" const vHex = "010000000000000000000D" const qHex = "3BED38CEBB1219BC068774E0E2655CDEF67FE547BCF2D9FA9FE167B1E63B2F101A1483D38A8F24EDE365A3E44F4F10ADECEA7B30D042C14C162477B8184AE6CFAA78441B1FDFB0B223ABCD528B61F313D859FCF9C26FCAF9E4D9DA9BA83E9D2FDA041E8CCBF90056C31D654B546C1A7F6729A8DD8E68512F39E3B6F07959CE61" const idHex = "416C657820416D706C65" const gHex = "3E641A22D0D0747D4ACC71884D3DFF2B2ADFDC1703B5A74EFD8333AB8C4377BB2A9B48E707F73409ABFBCD2DED69F52B16A145CE062FE6BD712C1952110DFB2316C5F3F321922ED375A4DEB8C41FA79BCAD86B0EA0D8FF02C9D0D5911BFF1E87DBCF073F71F18C08EB944AE84883A1E13FB1DEA123B5B1EFEA2A92635BD5D88F" const rHex = "487CDB0041BEED0323FDD3DEC8542584FA0E6CB990FAD5878DB34E9BEDDC95B65D22790C108E218407ED7F7D686657BAB5A28EF81C2E24985B56E37D9934E195A38A835CC02CEE8EBA2F56C87663E332976F5A3720DACA120BCD3DF0AEF6FD78582EBFCEE6D05E06172A871EAB0E8F5FC22DDB600F541B87CF8E147358374406" const sigHex = "99394F1D15924C0374CF80C7274CD9F232903A6423D9327156F69743EAEF03E1EFEDFDA8474C97F6570D9EF53C6CE2AE2BA68D01FFF9AA82068214BCD775B95CC297DDC38A63741AB3166B58275E0FB728D26DB18A2C3F14B621CF3863F8648B3149FE896348BE73D37E2F06E6E26C84C044984C09C658300B58EC2383E3B0A1F1390D62B772A69F37B5" var mISO = []byte("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopqo") var sigISO []byte var qISO []byte var idISO []byte var svISO signerVerifier // Test our signer using the values specified in ISO/IEC 14888-2:2008 func TestSignerISO(t *testing.T) { // The test vector specifies that h(W||M) use SHA-1 h := hash hash = sha1Hash // Instead of a true random value for r, use the value from the test vector rn := randomNumbers randomNumbers = hardcodedRandomISO // restore default function definitions after the test defer func() { hash = h randomNumbers = rn }() encodedSig, err := svISO.Sign(qISO, mISO) require.NoError(t, err) sig, err := util.Base64DecodeForJWT(encodedSig) require.NoError(t, err) require.Equal(t, sig, sigISO, "Signature does not match expected value") } // Test our verifier using the values specified in ISO/IEC 14888-2:2008 func TestVerifierISO(t *testing.T) { // The test vector specifies that h(W||M) use SHA-1 h := hash hash = sha1Hash // The test vector formats Id with PSS. Instead of implementing this // encoding ourselves, we hardcode the value for G given in the standard ep := encodePKCS1v15 encodePKCS1v15 = pssEncodedId // restore default function definitions after the test defer func() { hash = h encodePKCS1v15 = ep }() encodedSigISO := util.Base64EncodeForJWT(sigISO) ok := svISO.Verify(encodedSigISO, idISO, mISO) require.True(t, ok, "Signature verification failed") } func init() { vBytes, err := hex.DecodeString(vHex) if err != nil { panic(err) } v := new(big.Int).SetBytes(vBytes) nBytes, err := hex.DecodeString(nHex) if err != nil { panic(err) } n, err := bigmod.NewModulusFromBig(new(big.Int).SetBytes(nBytes)) if err != nil { panic(err) } svISO = signerVerifier{ n: n, v: v, nBytes: 128, vBytes: 10, t: 1, } sigISO, err = hex.DecodeString(sigHex) if err != nil { panic(err) } qISO, err = hex.DecodeString(qHex) if err != nil { panic(err) } idISO, err = hex.DecodeString(idHex) if err != nil { panic(err) } } var pssEncodedId = func(k int, data []byte) []byte { em, _ := hex.DecodeString(gHex) return em } var hardcodedRandomISO = func(t int, n *bigmod.Modulus) ([]*bigmod.Nat, error) { ys := make([]*bigmod.Nat, 1) rRaw, err := hex.DecodeString(rHex) if err != nil { return nil, err } r, err := bigmod.NewNat().SetBytes(rRaw, n) if err != nil { return nil, err } ys[0] = r return ys, nil } var sha1Hash = func(byteCount int, data ...[]byte) ([]byte, error) { hash := sha1.New() for _, d := range data { hash.Write(d) } return hash.Sum(nil)[:byteCount], nil } openpubkey-0.8.0/gq/gq_test.go000066400000000000000000000157551477254274500163420ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package gq import ( "crypto/rand" "crypto/rsa" "encoding/json" "testing" "time" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestSignVerifyJWT(t *testing.T) { oidcPrivKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) oidcPubKey := &oidcPrivKey.PublicKey idToken, err := createOIDCToken(oidcPrivKey, "test") require.NoError(t, err) signerVerifier, err := NewSignerVerifier(oidcPubKey, 256) require.NoError(t, err) gqToken, err := signerVerifier.SignJWT(idToken) require.NoError(t, err) ok := signerVerifier.VerifyJWT(gqToken) require.True(t, ok, "signature verification failed") } func TestGQ256SignJWT(t *testing.T) { oidcPrivKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) idToken, err := createOIDCToken(oidcPrivKey, "test") require.NoError(t, err) gqToken1, err := GQ256SignJWT(&oidcPrivKey.PublicKey, idToken) require.NoError(t, err) ok, err := GQ256VerifyJWT(&oidcPrivKey.PublicKey, gqToken1) require.NoError(t, err) require.True(t, ok) // Test that we throw the correct error if the wrong RSA public key is sent to SignJWT oidcPrivKeyWrong, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) require.NotEqual(t, oidcPrivKey, oidcPrivKeyWrong) gqToken2, err := GQ256SignJWT(&oidcPrivKeyWrong.PublicKey, idToken) require.EqualError(t, err, "incorrect public key supplied when GQ signing jwt: could not verify message using any of the signatures or keys") require.Nil(t, gqToken2) // Test specifying with extra claims expKey1 := "key1" // Expected claim keys, values expValue1 := "value1" expKey2 := "key2" expValue2 := "value2" gqTokenExtraClaims, err := GQ256SignJWT(&oidcPrivKey.PublicKey, idToken, WithExtraClaim(expKey1, expValue1), WithExtraClaim(expKey2, expValue2)) require.NoError(t, err) retValue1, ok, err := getClaimInProtected(expKey1, gqTokenExtraClaims) require.NoError(t, err) require.True(t, ok) require.Equal(t, expValue1, retValue1) retValue2, ok, err := getClaimInProtected(expKey2, gqTokenExtraClaims) require.NoError(t, err) require.True(t, ok) require.Equal(t, expValue2, retValue2) // Test that we don't find a claim we didn't add retClaimValue, ok, err := getClaimInProtected("noSuchKey", gqTokenExtraClaims) require.NoError(t, err) require.False(t, ok, "we didn't add this claim, yet somehow it was in the protected header") require.Nil(t, retClaimValue) // Test that we throw the correct error if reserved header is used gqTokenReservedClaim, err := GQ256SignJWT(&oidcPrivKey.PublicKey, idToken, WithExtraClaim("alg", "ES256")) require.Error(t, err, "use of reserved claim name, alg, should throw an error") require.EqualError(t, err, "error creating GQ signature: use of reserved header name, alg, in additional headers", "incorrect error throw") require.Nil(t, gqTokenReservedClaim) } func TestVerifyModifiedIdPayload(t *testing.T) { oidcPrivKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) oidcPubKey := &oidcPrivKey.PublicKey idToken, err := createOIDCToken(oidcPrivKey, "test") require.NoError(t, err) // modify the ID Token payload to detect IdP signature invalidity via GQ verify modifiedToken, err := modifyTokenPayload(idToken, "fail") require.NoError(t, err) _, err = jws.Verify(modifiedToken, jws.WithKey(jwa.RS256, oidcPubKey)) require.Error(t, err, "ID token signature should fail for modified token") signerVerifier, err := NewSignerVerifier(oidcPubKey, 256) require.NoError(t, err) gqToken, err := signerVerifier.SignJWT(modifiedToken) require.NoError(t, err) ok := signerVerifier.VerifyJWT(gqToken) require.False(t, ok, "GQ signature verification passed for invalid payload") } func TestVerifyModifiedGqPayload(t *testing.T) { oidcPrivKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) oidcPubKey := &oidcPrivKey.PublicKey idToken, err := createOIDCToken(oidcPrivKey, "test") require.NoError(t, err) signerVerifier, err := NewSignerVerifier(oidcPubKey, 256) require.NoError(t, err) gqToken, err := signerVerifier.SignJWT(idToken) require.NoError(t, err) // modify the ID Token payload to detect GQ signature invalidity modifiedToken, err := modifyTokenPayload(gqToken, "fail") require.NoError(t, err) ok := signerVerifier.VerifyJWT(modifiedToken) require.False(t, ok, "GQ signature verification passed for invalid payload") } func TestRejectUnsupportedPublicKey(t *testing.T) { oidcPrivKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) oidcPrivKey.E = 3 oidcPubKey := &oidcPrivKey.PublicKey signerVerifier, err := NewSignerVerifier(oidcPubKey, 256) require.ErrorContains(t, err, "only 65537 is currently supported, unsupported RSA public key exponent") require.Nil(t, signerVerifier) } func modifyTokenPayload(token []byte, audience string) ([]byte, error) { headers, _, signature, err := jws.SplitCompact(token) if err != nil { return nil, err } newPayload := map[string]any{ "sub": "1", "iss": "test", "aud": audience, "iat": time.Now().Unix(), } modifiedPayload, err := json.Marshal(newPayload) if err != nil { return nil, err } newToken := util.JoinJWTSegments(headers, util.Base64EncodeForJWT(modifiedPayload), signature) return newToken, nil } func createOIDCToken(oidcPrivKey *rsa.PrivateKey, audience string) ([]byte, error) { alg := jwa.RS256 // RSASSA-PKCS-v1.5 using SHA-256 oidcHeader := jws.NewHeaders() err := oidcHeader.Set(jws.AlgorithmKey, alg) if err != nil { return nil, err } err = oidcHeader.Set(jws.TypeKey, "JWT") if err != nil { return nil, err } oidcPayload := map[string]any{ "sub": "1", "iss": "test", "aud": audience, "iat": time.Now().Unix(), } payloadBytes, err := json.Marshal(oidcPayload) if err != nil { return nil, err } return jws.Sign( payloadBytes, jws.WithKey( alg, oidcPrivKey, jws.WithProtectedHeaders(oidcHeader), ), ) } func getClaimInProtected(claimKey string, token []byte) (any, bool, error) { headersB64, _, _, err := jws.SplitCompact(token) if err != nil { return nil, false, err } headersJson, err := util.Base64DecodeForJWT(headersB64) if err != nil { return nil, false, err } headers := jws.NewHeaders() err = json.Unmarshal(headersJson, &headers) if err != nil { return nil, false, err } claimValue, ok := headers.Get(claimKey) return claimValue, ok, nil } openpubkey-0.8.0/gq/rsa.go000066400000000000000000000027651477254274500154560ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package gq import ( "crypto" "crypto/sha256" ) // Hardcoded padding prefix for SHA-256 from https://github.com/golang/go/blob/eca5a97340e6b475268a522012f30e8e25bb8b8f/src/crypto/rsa/pkcs1v15.go#L268 var prefix = []byte{0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20} // encodePKCS1v15 is taken from the go stdlib, see [crypto/rsa.SignPKCS1v15]. // // https://github.com/golang/go/blob/eca5a97340e6b475268a522012f30e8e25bb8b8f/src/crypto/rsa/pkcs1v15.go#L287-L317 var encodePKCS1v15 = func(k int, data []byte) []byte { hashLen := crypto.SHA256.Size() tLen := len(prefix) + hashLen // EM = 0x00 || 0x01 || PS || 0x00 || T em := make([]byte, k) em[1] = 1 for i := 2; i < k-tLen-1; i++ { em[i] = 0xff } copy(em[k-tLen:k-hashLen], prefix) hashed := sha256.Sum256(data) copy(em[k-hashLen:k], hashed[:]) return em } openpubkey-0.8.0/gq/sign.go000066400000000000000000000154351477254274500156270ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package gq import ( "crypto/rand" "encoding/json" "fmt" "math/big" "filippo.io/bigmod" "github.com/awnumar/memguard" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/util" ) // Sign creates a GQ1 signature over the given message with the given GQ1 private number. // // Comments throughout refer to stages as specified in the ISO/IEC 14888-2 standard. func (sv *signerVerifier) Sign(private []byte, message []byte) ([]byte, error) { n, v, t := sv.n, sv.v, sv.t vBytes := sv.vBytes M := message Q, err := bigmod.NewNat().SetBytes(private, n) if err != nil { return nil, err } // Stage 1 - select t numbers, each consisting of nBytes random bytes. // In order to guarantee our operation is constant time, we deviate slightly // from the standard and directly select an integer less than n r, err := randomNumbers(t, sv.n) if err != nil { return nil, err } // Stage 2 - calculate test number W // for i from 1 to t, compute W_i <- r_i^v mod n // combine to form W var W []byte for i := 0; i < t; i++ { W_i := bigmod.NewNat().Exp(r[i], v.Bytes(), n) W = append(W, W_i.Bytes(n)...) } // Stage 3 - calculate question number R // hash W and M and take first t*vBytes bytes as R R, err := hash(t*vBytes, W, M) if err != nil { return nil, err } // split R into t numbers each consisting of vBytes bytes Rs := make([]*bigmod.Nat, t) for i := 0; i < t; i++ { Rs[i], err = new(bigmod.Nat).SetBytes(R[i*vBytes:(i+1)*vBytes], n) if err != nil { return nil, err } } // Stage 4 - calculate witness number S // for i from 1 to t, compute S_i <- r_i * Q^{R_i} mod n // combine to form S var S []byte for i := 0; i < t; i++ { S_i := bigmod.NewNat().Exp(Q, Rs[i].Bytes(n), n) S_i.Mul(r[i], n) S = append(S, S_i.Bytes(n)...) } // proof is combination of R and S return encodeProof(R, S), nil } func (sv *signerVerifier) SignJWT(jwt []byte, opts ...Opts) ([]byte, error) { options := &OptsStruct{} for _, applyOpt := range opts { applyOpt(options) } // Ensure that someone doesn't use a reserved protected header claim name for _, reserved := range []string{"alg", "typ", "kid"} { if _, ok := options.extraClaims[reserved]; ok { return nil, fmt.Errorf("use of reserved header name, %s, in additional headers", reserved) } } origHeaders, payload, signature, err := jws.SplitCompact(jwt) if err != nil { return nil, err } signingPayload := util.JoinJWTSegments(origHeaders, payload) headers := jws.NewHeaders() err = headers.Set(jws.AlgorithmKey, GQ256) if err != nil { return nil, err } err = headers.Set(jws.TypeKey, "JWT") if err != nil { return nil, err } err = headers.Set(jws.KeyIDKey, string(origHeaders)) if err != nil { return nil, err } for k, v := range options.extraClaims { if err = headers.Set(k, v); err != nil { return nil, err } } headersJSON, err := json.Marshal(headers) if err != nil { return nil, err } headersEnc := util.Base64EncodeForJWT(headersJSON) // When jwt is parsed it's split into base64-encoded bytes, but // we need the raw signature to calculate mod inverse decodedSig, err := util.Base64DecodeForJWT(signature) if err != nil { return nil, err } // GQ1 private number (Q) is inverse of RSA signature mod n private, err := sv.modInverse(memguard.NewBufferFromBytes(decodedSig)) if err != nil { return nil, err } defer private.Destroy() gqSig, err := sv.Sign(private.Bytes(), signingPayload) if err != nil { return nil, err } // Now make a new GQ-signed token gqToken := util.JoinJWTSegments(headersEnc, payload, gqSig) return gqToken, nil } // modInverse finds the modular multiplicative inverse of the value stored in b // // All operations involving the secret value are performed either with constant- // time methods or with blinding (if sv has a source of randomness) func (sv *signerVerifier) modInverse(b *memguard.LockedBuffer) (*memguard.LockedBuffer, error) { x, err := bigmod.NewNat().SetBytes(b.Bytes(), sv.n) if err != nil { return nil, err } nInt := natAsInt(sv.n.Nat(), sv.n) var r *big.Int var rConstant, xr *bigmod.Nat // Apply RSA blinding to the ModInverse operation. // Translates the technique formerly used in the Go Standard Library before they // switched to bigmod in late 2022. Since bigmod does not yet support constant-time // ModInverse, we perform the blinding so that the value of the private key is not // detectable via side channel. // Ref: https://github.com/golang/go/blob/5f60f844beb0581a19cb425a3338d79d322a7db2/src/crypto/rsa/rsa.go#L567-L596 // // For a secret value x, the idea is to find m = 1/x mod n by calculating // rm/r mod n ==> r/(xr) mod n, where r is a random value for { // draw r r, err = rand.Int(rand.Reader, nInt) if err != nil { return nil, err } // compute xr = x * r xr, err = intAsNat(r, sv.n) if err != nil { return nil, err } xr.Mul(x, sv.n) // check that xr has a multiplicative inverse mod n. It is exceedingly // rare but technically possible for it not to, in which case we need // to draw a new value for r xrInt := natAsInt(xr, sv.n) inverse := new(big.Int).ModInverse(xrInt, nInt) if inverse != nil { break } } // overwrite x with the blinded value x = xr // calculate m/r mod n m := natAsInt(x, sv.n).ModInverse(natAsInt(x, sv.n), nInt) mConstant, err := intAsNat(m, sv.n) if err != nil { return nil, err } // remove the blinding by multiplying m/r by r rConstant, err = intAsNat(r, sv.n) if err != nil { return nil, err } mConstant.Mul(rConstant, sv.n) mFinal := natAsInt(mConstant, sv.n) // need to allocate memory for fixed length slice using FillBytes ret := make([]byte, len(b.Bytes())) defer b.Destroy() return memguard.NewBufferFromBytes(mFinal.FillBytes(ret)), nil } func encodeProof(R, S []byte) []byte { var bin []byte bin = append(bin, R...) bin = append(bin, S...) return util.Base64EncodeForJWT(bin) } var randomNumbers = func(t int, n *bigmod.Modulus) ([]*bigmod.Nat, error) { nInt := modAsInt(n) ys := make([]*bigmod.Nat, t) for i := 0; i < t; i++ { r, err := rand.Int(rand.Reader, nInt) if err != nil { return nil, err } ys[i], err = intAsNat(r, n) if err != nil { return nil, err } } return ys, nil } openpubkey-0.8.0/gq/verify.go000066400000000000000000000066701477254274500161740ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package gq import ( "bytes" "fmt" "math/big" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/util" ) // Verify verifies a GQ1 signature over a message, using the public identity of the signer. // // Comments throughout refer to stages as specified in the ISO/IEC 14888-2 standard. func (sv *signerVerifier) Verify(proof []byte, identity []byte, message []byte) bool { n, v, t := modAsInt(sv.n), sv.v, sv.t nBytes, vBytes := sv.nBytes, sv.vBytes M := message // Stage 0 - reject proof if it's the wrong size based on t R, S, err := sv.decodeProof(proof) if err != nil { return false } // Stage 1 - create public number G // currently this hardcoded to use PKCS#1 v1.5 padding as the format mechanism paddedIdentity := encodePKCS1v15(nBytes, identity) G := new(big.Int).SetBytes(paddedIdentity) // Stage 2 - parse signature numbers and recalculate test number W* // split R into t strings, each consisting of vBytes bytes Rs := make([]*big.Int, t) for i := 0; i < t; i++ { Rs[i] = new(big.Int).SetBytes(R[i*vBytes : (i+1)*vBytes]) } // split S into t strings, each consisting of nBytes bytes Ss := make([]*big.Int, t) for i := 0; i < t; i++ { s_i := new(big.Int).SetBytes(S[i*nBytes : (i+1)*nBytes]) // reject if S_i = 0 or >= n if s_i.Cmp(big.NewInt(0)) == 0 || s_i.Cmp(n) != -1 { return false } Ss[i] = s_i } // recalculate test number W* // for i from 1 to t, compute W*_i <- S_i^v * G^{R_i} mod n // combine to form W* var Wstar []byte for i := 0; i < t; i++ { l := new(big.Int).Exp(Ss[i], v, n) r := new(big.Int).Exp(G, Rs[i], n) Wstar_i := new(big.Int).Mul(l, r) Wstar_i.Mod(Wstar_i, n) b := make([]byte, nBytes) Wstar = append(Wstar, Wstar_i.FillBytes(b)...) } // Stage 3 - recalculate question number R* // hash W* and M and take first t*vBytes bytes as R* Rstar, err := hash(t*vBytes, Wstar, M) if err != nil { // TODO: this can only happen if there's some error reading /dev/urandom or something // so should we return the proper error? return false } // Stage 4 - accept or reject depending on whether R and R* are identical return bytes.Equal(R, Rstar) } func (sv *signerVerifier) VerifyJWT(jwt []byte) bool { origHeaders, err := OriginalJWTHeaders(jwt) if err != nil { return false } _, payload, signature, err := jws.SplitCompact(jwt) if err != nil { return false } signingPayload := util.JoinJWTSegments(origHeaders, payload) return sv.Verify(signature, signingPayload, signingPayload) } func (sv *signerVerifier) decodeProof(s []byte) (R, S []byte, err error) { bin, err := util.Base64DecodeForJWT(s) if err != nil { return nil, nil, err } rSize := sv.vBytes * sv.t sSize := sv.nBytes * sv.t if len(bin) != rSize+sSize { return nil, nil, fmt.Errorf("not the correct size") } R = bin[:rSize] S = bin[rSize:] return R, S, nil } openpubkey-0.8.0/oidc/000077500000000000000000000000001477254274500146375ustar00rootroot00000000000000openpubkey-0.8.0/oidc/jws.go000066400000000000000000000074241477254274500160000ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package oidc import ( "fmt" ) type Jws struct { Payload string `json:"payload"` // Base64 encoded Signatures []Signature `json:"signatures"` // Base64 encoded } type SigOptStruct struct { PublicHeader map[string]any } type SigOpts func(a *SigOptStruct) // WithPublicHeader species that a public header be included in the // signature. Public headers aren't Base64 encoded because they aren't signed. // Example use: WithPublicHeader(map[string]any{"key1": "abc", "key2": "def"}) func WithPublicHeader(publicHeader map[string]any) SigOpts { return func(o *SigOptStruct) { o.PublicHeader = publicHeader } } func (j *Jws) AddSignature(token []byte, opts ...SigOpts) error { sigOpts := &SigOptStruct{} for _, applyOpt := range opts { applyOpt(sigOpts) } protected, payload, signature, err := SplitCompact(token) if err != nil { return err } if j.Payload != string(payload) { return fmt.Errorf("payload in compact token does not match existing payload in jws, expected=(%s), got=(%s)", string(j.Payload), string(payload)) } sig := Signature{ Protected: string(protected), Public: sigOpts.PublicHeader, Signature: string(signature), } if j.Signatures == nil { j.Signatures = []Signature{} } j.Signatures = append(j.Signatures, sig) return nil } func (j *Jws) GetToken(i int) ([]byte, error) { if i < len(j.Signatures) && i >= 0 { return []byte(j.Signatures[i].Protected + "." + j.Payload + "." + j.Signatures[i].Signature), nil } else { return nil, fmt.Errorf("no signature at index i (%d), len(signatures) (%d)", i, len(j.Signatures)) } } func (j *Jws) GetTokenByTyp(typ string) ([]byte, error) { matchingTokens := []Signature{} for _, v := range j.Signatures { if typFound, err := v.GetTyp(); err != nil { return nil, err } else { // Both the JWS standard and the OIDC standard states that typ is case sensitive // so we treat it as case sensitive as well // // "The typ (type) header parameter is used to declare the type of the // signed content. The typ value is case sensitive." // https://openid.net/specs/draft-jones-json-web-signature-04.html#ReservedHeaderParameterName // // "The "typ" (type) Header Parameter is used by JWS applications to // declare the media type [IANA.MediaTypes] of this complete JWS. // [..] Per RFC 2045 [RFC2045], all media type values, subtype values, and // parameter names are case insensitive. However, parameter values are case // sensitive unless otherwise specified for the specific parameter." // https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.9 if typFound == typ { matchingTokens = append(matchingTokens, v) } } } if len(matchingTokens) > 1 { // Currently we only have one token per token typ. We can change this later // for COS tokens. This check prevents hidden tokens, where one token of // the same typ hides another token of the same typ. return nil, fmt.Errorf("more than one token found, all current token typs are unique") } else if len(matchingTokens) == 0 { // if typ not found return nil return nil, nil } else { return []byte(matchingTokens[0].Protected + "." + j.Payload + "." + matchingTokens[0].Signature), nil } } openpubkey-0.8.0/oidc/jws_test.go000066400000000000000000000106001477254274500170250ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package oidc import ( "encoding/json" "fmt" "testing" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestJwsMarshaling(t *testing.T) { payloadJson := []byte(`{"a": "1", "b": 2}`) // payloadB64 := string(util.Base64EncodeForJWT(tc.payload)) testCases := []struct { name string payload []byte tokens []string publicHeaders []map[string]any expectedJson string }{ {name: "with only payload", payload: payloadJson, expectedJson: `{"payload":"eyJhIjogIjEiLCAiYiI6IDJ9","signatures":[]}`, publicHeaders: []map[string]any{}, }, {name: "with one token", payload: payloadJson, tokens: []string{ "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ.eyJhIjogIjEiLCAiYiI6IDJ9.ZmFrZXNpZ25hdHVyZQ=="}, expectedJson: `{"payload":"eyJhIjogIjEiLCAiYiI6IDJ9","signatures":[{"protected":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ","signature":"ZmFrZXNpZ25hdHVyZQ=="}]}`, publicHeaders: []map[string]any{}, }, {name: "with two tokens", payload: payloadJson, tokens: []string{ "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ.eyJhIjogIjEiLCAiYiI6IDJ9.ZmFrZXNpZ25hdHVyZQ==", "eyJhbGciOiJSUzI1NiIsInR5cCI6IkNJQyIsImtpZCI6IjEyMzQifQ.eyJhIjogIjEiLCAiYiI6IDJ9.YW5vdGhlcmZha2VzaWc="}, publicHeaders: []map[string]any{}, expectedJson: `{"payload":"eyJhIjogIjEiLCAiYiI6IDJ9","signatures":[{"protected":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ","signature":"ZmFrZXNpZ25hdHVyZQ=="},{"protected":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkNJQyIsImtpZCI6IjEyMzQifQ","signature":"YW5vdGhlcmZha2VzaWc="}]}`, }, {name: "with three tokens and public header", payload: payloadJson, tokens: []string{ "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ.eyJhIjogIjEiLCAiYiI6IDJ9.ZmFrZXNpZ25hdHVyZQ==", "eyJhbGciOiJSUzI1NiIsInR5cCI6IkNJQyIsImtpZCI6IjEyMzQifQ.eyJhIjogIjEiLCAiYiI6IDJ9.YW5vdGhlcmZha2VzaWc=", "eyJhbGciOiJSUzI1NiIsInR5cCI6IkNPUyIsImtpZCI6IjEyMzQifQ.eyJhIjogIjEiLCAiYiI6IDJ9.ZXh0cmFmYWtlc2ln"}, publicHeaders: []map[string]any{{"a": "1", "b": 2}, nil, nil}, expectedJson: `{"payload":"eyJhIjogIjEiLCAiYiI6IDJ9","signatures":[{"protected":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ","header":{"a":"1","b":2},"signature":"ZmFrZXNpZ25hdHVyZQ=="},{"protected":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkNJQyIsImtpZCI6IjEyMzQifQ","signature":"YW5vdGhlcmZha2VzaWc="},{"protected":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkNPUyIsImtpZCI6IjEyMzQifQ","signature":"ZXh0cmFmYWtlc2ln"}]}`, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { jwsObj := Jws{ Payload: string(util.Base64EncodeForJWT(tc.payload)), Signatures: []Signature{}, } for i, v := range tc.tokens { token := []byte(v) if i < len(tc.publicHeaders) { public := tc.publicHeaders[i] err := jwsObj.AddSignature(token, WithPublicHeader(public)) require.NoError(t, err) } else { err := jwsObj.AddSignature(token) require.NoError(t, err) } } if len(tc.tokens) > 0 { token, err := jwsObj.GetTokenByTyp("JWT") require.NoError(t, err) require.NotNil(t, token) token, err = jwsObj.GetToken(0) require.NoError(t, err) require.NotNil(t, token) } jwsJson, err := json.Marshal(jwsObj) require.NoError(t, err) require.NotNil(t, jwsJson) fmt.Println(string(jwsJson)) require.Equal(t, tc.expectedJson, string(jwsJson), "marshalled json doesn't match expected json") var jwsObjUnmarshalled Jws err = json.Unmarshal(jwsJson, &jwsObjUnmarshalled) require.NoError(t, err) JwsEqual(t, jwsObj, jwsObjUnmarshalled) }) } } func JwsEqual(t *testing.T, j1 Jws, j2 Jws) { require.Equal(t, j1.Payload, j2.Payload, "payloads don't match") require.Equal(t, len(j1.Signatures), len(j1.Signatures)) } openpubkey-0.8.0/oidc/jwt.go000066400000000000000000000050231477254274500157720ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package oidc import ( "fmt" ) type Jwt struct { payload string payloadClaims *OidcClaims signature *Signature raw []byte } func NewJwt(token []byte) (*Jwt, error) { protected, payload, signature, err := SplitCompact(token) if err != nil { return nil, err } idt := &Jwt{ payload: string(payload), signature: &Signature{ Protected: string(protected), Signature: string(signature), }, raw: token, } if err := ParseJWTSegment(protected, &idt.signature.protectedClaims); err != nil { return nil, fmt.Errorf("error parsing protected: %w", err) } if err := ParseJWTSegment(payload, &idt.payloadClaims); err != nil { return nil, fmt.Errorf("error parsing payload: %w", err) } return idt, nil } func (i *Jwt) GetClaims() *OidcClaims { return i.payloadClaims } func (i *Jwt) GetPayload() string { return i.payload } func (i *Jwt) GetSignature() *Signature { return i.signature } func (i *Jwt) GetRaw() []byte { return i.raw } // Compares two JWTs and determines if they are for the same identity (subject) func SameIdentity(t1, t2 []byte) error { token1, err := NewJwt(t1) if err != nil { return err } token2, err := NewJwt(t2) if err != nil { return err } // Subject identity can only be established within the same issuer if token1.GetClaims().Issuer != token2.GetClaims().Issuer { return fmt.Errorf("tokens have different issuers") } if token1.GetClaims().Subject != token2.GetClaims().Subject { return fmt.Errorf("token have a different subject claims") } return nil } // RequireOlder returns an error if t1 is not older than t2 func RequireOlder(t1, t2 []byte) error { token1, err := NewJwt(t1) if err != nil { return err } token2, err := NewJwt(t2) if err != nil { return err } // Check which token was issued first if token1.GetClaims().IssuedAt > token2.GetClaims().IssuedAt { return fmt.Errorf("tokens not issued in correct order") } return nil } openpubkey-0.8.0/oidc/jwt_test.go000066400000000000000000000147361477254274500170440ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package oidc import ( "testing" "github.com/stretchr/testify/require" ) func TestJwtMarshaling(t *testing.T) { testCases := []struct { name string payload string protected string sig string expectedAud string }{ {name: "Happy case", // {"iss":"https://example.com","sub":"123","aud":"abc","exp":34,"iat":12,"email":"alice@example.com","nonce":"0x0BEE"} payload: "eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiMTIzIiwiYXVkIjoiYWJjIiwiZXhwIjozNCwiaWF0IjoxMiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSIsIm5vbmNlIjoiMHgwQkVFIn0", // {"alg":"RS256","typ":"JWT","kid":"1234"} protected: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ", sig: "ZmFrZXNpZ25hdHVyZQ", // fakesignature expectedAud: "abc", }, {name: "Happy case (aud is list)", // {"iss":"https://example.com","sub":"123","aud":["abc","def"],"exp":34,"iat":12,"email":"alice@example.com","nonce":"0x0BEE"} payload: "eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiMTIzIiwiYXVkIjpbImFiYyIsImRlZiJdLCJleHAiOjM0LCJpYXQiOjEyLCJlbWFpbCI6ImFsaWNlQGV4YW1wbGUuY29tIiwibm9uY2UiOiIweDBCRUUifQ", protected: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ", // {"alg":"RS256","typ":"JWT","kid":"1234"} sig: "ZmFrZXNpZ25hdHVyZQ", // fakesignature expectedAud: "abc,def", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { jwtCompact := []byte(tc.protected + "." + tc.payload + "." + tc.sig) jwt, err := NewJwt(jwtCompact) require.NoError(t, err) require.NotNil(t, jwt) require.Equal(t, tc.expectedAud, jwt.GetClaims().Audience) typ, err := jwt.GetSignature().GetTyp() require.NoError(t, err) require.Equal(t, "JWT", typ) pHeader := jwt.GetSignature().GetProtectedClaims() require.NotNil(t, pHeader) require.Equal(t, "RS256", pHeader.Alg) }) } } func TestJwtCompare(t *testing.T) { testCases := []struct { name string t1, t2 string expIdErr, expAgeErr string }{ {name: "Happy case", // {"alg":"RS256","typ":"JWT","kid":"1234"}.{"iss":"https://example.com","sub":"123","aud":"abc","exp":34,"iat":12,"email":"alice@example.com","nonce":"0x0BEE"}.fakesignature t1: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiMTIzIiwiYXVkIjoiYWJjIiwiZXhwIjozNCwiaWF0IjoxMiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSIsIm5vbmNlIjoiMHgwQkVFIn0.ZmFrZXNpZ25hdHVyZQ", // {"alg":"RS256","typ":"JWT","kid":"1234"}.{"iss":"https://example.com","sub":"123","aud":"abc","exp":35,"iat":12,"email":"alice@example.com"}.fakesignature t2: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiMTIzIiwiYXVkIjoiYWJjIiwiZXhwIjozNSwiaWF0IjoxMiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSJ9.ZmFrZXNpZ25hdHVyZQ", }, {name: "Different Subjects", // {"alg":"RS256","typ":"JWT","kid":"1234"}.{"iss":"https://example.com","sub":"123","aud":"abc","exp":34,"iat":12,"email":"alice@example.com","nonce":"0x0BEE"}.fakesignature t1: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiMTIzIiwiYXVkIjoiYWJjIiwiZXhwIjozNCwiaWF0IjoxMiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSIsIm5vbmNlIjoiMHgwQkVFIn0.ZmFrZXNpZ25hdHVyZQ", // {"alg":"RS256","typ":"JWT","kid":"1234"}.{"iss":"https://example.com","sub":"567","aud":"abc","exp":35,"iat":12,"email":"alice@example.com"}.fakesignature t2: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiNTY3IiwiYXVkIjoiYWJjIiwiZXhwIjozNSwiaWF0IjoxMiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSJ9.ZmFrZXNpZ25hdHVyZQ", expIdErr: "token have a different subject claims", }, {name: "Different Issuers", // {"alg":"RS256","typ":"JWT","kid":"1234"}.{"iss":"https://example.com","sub":"123","aud":"abc","exp":34,"iat":12,"email":"alice@example.com","nonce":"0x0BEE"}.fakesignature t1: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiMTIzIiwiYXVkIjoiYWJjIiwiZXhwIjozNCwiaWF0IjoxMiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSIsIm5vbmNlIjoiMHgwQkVFIn0.ZmFrZXNpZ25hdHVyZQ", // {"alg":"RS256","typ":"JWT","kid":"1234"}.{"iss":"https://notexample.com","sub":"123","aud":"abc","exp":35,"iat":12,"email":"alice@example.com"}.fakesignature t2: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL25vdGV4YW1wbGUuY29tIiwic3ViIjoiMTIzIiwiYXVkIjoiYWJjIiwiZXhwIjozNSwiaWF0IjoxMiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSJ9.ZmFrZXNpZ25hdHVyZQ", expIdErr: "tokens have different issuers", }, {name: "Age mismatch t1 issued after t2", // {"alg":"RS256","typ":"JWT","kid":"1234"}.{"iss":"https://example.com","sub":"123","aud":"abc","exp":34,"iat":12,"email":"alice@example.com","nonce":"0x0BEE"}.fakesignature t1: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiMTIzIiwiYXVkIjoiYWJjIiwiZXhwIjozNCwiaWF0IjoxMiwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSIsIm5vbmNlIjoiMHgwQkVFIn0.ZmFrZXNpZ25hdHVyZQ", // {"alg":"RS256","typ":"JWT","kid":"1234"}.{"iss":"https://example.com","sub":"123","aud":"abc","exp":35,"iat":10,"email":"alice@example.com"}.fakesignature t2: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIiwic3ViIjoiMTIzIiwiYXVkIjoiYWJjIiwiZXhwIjozNSwiaWF0IjoxMCwiZW1haWwiOiJhbGljZUBleGFtcGxlLmNvbSJ9.ZmFrZXNpZ25hdHVyZQ", expAgeErr: "tokens not issued in correct order", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { err := SameIdentity([]byte(tc.t1), []byte(tc.t2)) if tc.expIdErr != "" { require.ErrorContains(t, err, tc.expIdErr) } else { require.NoError(t, err) } err = RequireOlder([]byte(tc.t1), []byte(tc.t2)) if tc.expAgeErr != "" { require.ErrorContains(t, err, tc.expAgeErr) } else { require.NoError(t, err) } }) } } openpubkey-0.8.0/oidc/oidc.go000066400000000000000000000051531477254274500161100ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package oidc import ( "bytes" "encoding/json" "fmt" "strings" "github.com/openpubkey/openpubkey/util" ) type OidcClaims struct { Issuer string `json:"iss"` Subject string `json:"sub"` Audience string `json:"-"` Expiration int64 `json:"exp"` IssuedAt int64 `json:"iat"` Email string `json:"email,omitempty"` Nonce string `json:"nonce,omitempty"` Username string `json:"preferred_username,omitempty"` FirstName string `json:"given_name,omitempty"` LastName string `json:"family_name,omitempty"` } // Implement UnmarshalJSON for custom handling during JSON unmarshalling func (id *OidcClaims) UnmarshalJSON(data []byte) error { // unmarshal audience claim separately to account for []string, https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3 type Alias OidcClaims aux := &struct { Audience any `json:"aud"` *Alias }{ Alias: (*Alias)(id), } if err := json.Unmarshal(data, &aux); err != nil { return err } switch t := aux.Audience.(type) { case string: id.Audience = t case []any: audList := []string{} for _, v := range t { audList = append(audList, v.(string)) } id.Audience = strings.Join(audList, ",") default: id.Audience = "" } return nil } // SplitCompact splits a JWT and returns its three parts // separately: protected headers, payload and signature. // This is copied from github.com/lestrrat-go/jwx/v2/jws.SplitCompact // We include it here so so that jwx is not a dependency of simpleJws func SplitCompact(src []byte) ([]byte, []byte, []byte, error) { parts := bytes.Split(src, []byte(".")) if len(parts) != 3 { return nil, nil, nil, fmt.Errorf(`invalid number of segments`) } return parts[0], parts[1], parts[2], nil } func ParseJWTSegment(segment []byte, v any) error { segmentJSON, err := util.Base64DecodeForJWT(segment) if err != nil { return fmt.Errorf("error decoding segment: %w", err) } err = json.Unmarshal(segmentJSON, v) if err != nil { return fmt.Errorf("error parsing segment: %w", err) } return nil } openpubkey-0.8.0/oidc/sig.go000066400000000000000000000032011477254274500157440ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package oidc import ( "encoding/json" "github.com/openpubkey/openpubkey/util" ) type Signature struct { Protected string `json:"protected"` // Base64 encoded protectedClaims *ProtectedClaims // Unmarshalled protected claims Public map[string]interface{} `json:"header,omitempty"` Signature string `json:"signature"` // Base64 encoded } type ProtectedClaims struct { Alg string `json:"alg"` Jkt string `json:"jkt,omitempty"` KeyID string `json:"kid,omitempty"` Type string `json:"typ,omitempty"` CIC string `json:"cic,omitempty"` } func (s *Signature) GetTyp() (string, error) { decodedProtected, err := util.Base64DecodeForJWT([]byte(s.Protected)) if err != nil { return "", err } type protectedTyp struct { Typ string `json:"typ"` } var ph protectedTyp err = json.Unmarshal(decodedProtected, &ph) if err != nil { return "", err } return ph.Typ, nil } func (s *Signature) GetProtectedClaims() *ProtectedClaims { return s.protectedClaims } openpubkey-0.8.0/oidc/tokens.go000066400000000000000000000001441477254274500164700ustar00rootroot00000000000000package oidc type Tokens struct { IDToken []byte RefreshToken []byte AccessToken []byte } openpubkey-0.8.0/pktoken/000077500000000000000000000000001477254274500153745ustar00rootroot00000000000000openpubkey-0.8.0/pktoken/clientinstance/000077500000000000000000000000001477254274500203775ustar00rootroot00000000000000openpubkey-0.8.0/pktoken/clientinstance/claims.go000066400000000000000000000101421477254274500221740ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package clientinstance import ( "crypto" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/util" ) // Client Instance Claims, referred also as "cic" in the OpenPubKey paper type Claims struct { publicKey jwk.Key // Claims are stored in the protected header portion of JWS signature protected map[string]any } // Client instance claims must relate to a single key pair func NewClaims(publicKey jwk.Key, claims map[string]any) (*Claims, error) { // Make sure our JWK has the algorithm header set if publicKey.Algorithm().String() == "" { return nil, fmt.Errorf("user JWK requires algorithm to be set") } // Make sure no claims are using our reserved values for _, reserved := range []string{"alg", "upk", "rz", "typ"} { if _, ok := claims[reserved]; ok { return nil, fmt.Errorf("use of reserved header name, %s, in additional headers", reserved) } } rand, err := generateRand() if err != nil { return nil, fmt.Errorf("failed to generate random value: %w", err) } // Assign required values claims["typ"] = "CIC" claims["alg"] = publicKey.Algorithm().String() claims["upk"] = publicKey claims["rz"] = rand return &Claims{ publicKey: publicKey, protected: claims, }, nil } func ParseClaims(protected map[string]any) (*Claims, error) { // Get our standard headers and make sure they match up if _, ok := protected["rz"]; !ok { return nil, fmt.Errorf(`missing required "rz" claim`) } upk, ok := protected["upk"] if !ok { return nil, fmt.Errorf(`missing required "upk" claim`) } upkBytes, err := json.Marshal(upk) if err != nil { return nil, err } upkjwk, err := jwk.ParseKey(upkBytes) if err != nil { return nil, err } alg, ok := protected["alg"] if !ok { return nil, fmt.Errorf(`missing required "alg" claim`) } else if alg != upkjwk.Algorithm() { return nil, fmt.Errorf(`provided "alg" value different from algorithm provided in "upk" jwk`) } return &Claims{ publicKey: upkjwk, protected: protected, }, nil } func (c *Claims) PublicKey() jwk.Key { return c.publicKey } func (c *Claims) KeyAlgorithm() jwa.KeyAlgorithm { return c.publicKey.Algorithm() } // Returns a hash of all client instance claims which includes a random value func (c *Claims) Hash() ([]byte, error) { buf, err := json.Marshal(c.protected) if err != nil { return nil, err } return util.B64SHA3_256(buf), nil } // This function signs the payload of the provided token with the protected headers // as defined by the client instance claims and returns a jwt in compact form. func (c *Claims) Sign(signer crypto.Signer, algorithm jwa.KeyAlgorithm, token []byte) ([]byte, error) { _, payload, _, err := jws.SplitCompact(token) if err != nil { return nil, err } // We need to make sure we're signing the decoded bytes payloadDecoded, err := util.Base64DecodeForJWT(payload) if err != nil { return nil, err } headers := jws.NewHeaders() for key, val := range c.protected { if err := headers.Set(key, val); err != nil { return nil, err } } cicToken, err := jws.Sign( payloadDecoded, jws.WithKey( algorithm, signer, jws.WithProtectedHeaders(headers), ), ) if err != nil { return nil, err } return cicToken, nil } func generateRand() (string, error) { bits := 256 rBytes := make([]byte, bits/8) _, err := rand.Read(rBytes) if err != nil { return "", err } rz := hex.EncodeToString(rBytes) return rz, nil } openpubkey-0.8.0/pktoken/compact.go000066400000000000000000000061501477254274500173530ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package pktoken import ( "bytes" "fmt" "github.com/openpubkey/openpubkey/oidc" ) // CompactPKToken creates a compact representation of a PK Token from a list of tokens func CompactPKToken(tokens [][]byte, freshIDToken []byte) ([]byte, error) { if len(tokens) == 0 { return nil, fmt.Errorf("no tokens provided") } compact := [][]byte{} var payload []byte for _, tok := range tokens { tokProtected, tokPayload, tokSig, err := oidc.SplitCompact(tok) if err != nil { return nil, err } if payload != nil { if !bytes.Equal(payload, tokPayload) { return nil, fmt.Errorf("payloads in tokens are not the same got %s and %s", payload, tokPayload) } } else { payload = tokPayload } compact = append(compact, tokProtected, tokSig) } // prepend the payload to the front compact = append([][]byte{payload}, compact...) pktCom := bytes.Join(compact, []byte(":")) // If we have a refreshed ID Token, append it to the compact representation using "." if freshIDToken != nil { if len(bytes.Split(freshIDToken, []byte("."))) != 3 { // Compact ID Token should be reformated as Base64(protected)"."Base64(payload)"."Base64(signature) return nil, fmt.Errorf("invalid refreshed ID Token") } pktCom = bytes.Join([][]byte{pktCom, freshIDToken}, []byte(".")) } return pktCom, nil } // SplitCompactPKToken breaks a compact representation of a PK Token into its constituent tokens func SplitCompactPKToken(pktCom []byte) ([][]byte, []byte, error) { tokensBytes, freshIDToken, _ := bytes.Cut(pktCom, []byte(".")) tokensParts := bytes.Split(tokensBytes, []byte(":")) if freshIDToken != nil && len(bytes.Split(freshIDToken, []byte("."))) != 3 { // Compact ID Token should be reformated as Base64(protected)"."Base64(payload)"."Base64(signature) return nil, nil, fmt.Errorf("invalid refreshed ID Token") } // Compact PK Token with refreshed ID Token should have at least 3 parts and should be: // Base64(payload)":"Base64(protected1)":"Base64(signature1)"...":"Base64(protectedN)":"Base64(signatureN)" if len(tokensParts) < 3 || len(tokensParts)%2 != 1 { return nil, nil, fmt.Errorf("invalid number of segments, got %d", len(tokensParts)) } tokens := [][]byte{} payload := tokensParts[0] for i := 1; i < len(tokensParts); i += 2 { // We return each token in JWT compact format (Base64(protected)"."Base64(payload)"."Base64(signature)) token := bytes.Join([][]byte{tokensParts[i], payload, tokensParts[i+1]}, []byte(".")) tokens = append(tokens, token) } return tokens, freshIDToken, nil } openpubkey-0.8.0/pktoken/compact_test.go000066400000000000000000000147661477254274500204260ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package pktoken import ( "testing" "github.com/stretchr/testify/require" ) func TestBuildCompact(t *testing.T) { testCases := []struct { name string tokens [][]byte freshIDToken []byte expError string expPktCom []byte }{ {name: "happy case one tokens", tokens: [][]byte{ // base64(one fake protected).base64(fake payload).base64(one fake sig) []byte(`MWZha2Vwcm90ZWN0ZWQ.ZmFrZSBwYXlsb2Fk.b25lIGZha2Ugc2ln`)}, expPktCom: []byte(`ZmFrZSBwYXlsb2Fk:MWZha2Vwcm90ZWN0ZWQ:b25lIGZha2Ugc2ln`), }, {name: "happy case two tokens", tokens: [][]byte{ // base64(one fake protected).base64(fake payload).base64(one fake sig) []byte(`MWZha2Vwcm90ZWN0ZWQ.ZmFrZSBwYXlsb2Fk.b25lIGZha2Ugc2ln`), // base64(two fake protected).base64(fake payload).base64(two fake sig) []byte(`dHdvIGZha2UgcHJvdGVjdGVk.ZmFrZSBwYXlsb2Fk.dHdvIGZha2Ugc2ln`)}, expPktCom: []byte(`ZmFrZSBwYXlsb2Fk:MWZha2Vwcm90ZWN0ZWQ:b25lIGZha2Ugc2ln:dHdvIGZha2UgcHJvdGVjdGVk:dHdvIGZha2Ugc2ln`), }, {name: "happy case two tokens and refreshed ID Token", tokens: [][]byte{ // base64(one fake protected).base64(fake payload).base64(one fake sig) []byte(`MWZha2Vwcm90ZWN0ZWQ.ZmFrZSBwYXlsb2Fk.b25lIGZha2Ugc2ln`), // base64(two fake protected).base64(fake payload).base64(two fake sig) []byte(`dHdvIGZha2UgcHJvdGVjdGVk.ZmFrZSBwYXlsb2Fk.dHdvIGZha2Ugc2ln`)}, // base64(refreshed protected).base64(refreshed payload).base64(refreshed sig) freshIDToken: []byte(`cmVmcmVzaGVkIHByb3RlY3RlZA.cmVmcmVzaGVkIHBheWxvYWQ.cmVmcmVzaGVkIHNpZw`), expPktCom: []byte(`ZmFrZSBwYXlsb2Fk:MWZha2Vwcm90ZWN0ZWQ:b25lIGZha2Ugc2ln:dHdvIGZha2UgcHJvdGVjdGVk:dHdvIGZha2Ugc2ln.cmVmcmVzaGVkIHByb3RlY3RlZA.cmVmcmVzaGVkIHBheWxvYWQ.cmVmcmVzaGVkIHNpZw`), }, {name: "different payloads", expError: "payloads in tokens are not the same", tokens: [][]byte{ // base64(one fake protected).base64(fake payload).base64(one fake sig) []byte(`MWZha2Vwcm90ZWN0ZWQ.ZmFrZSBwYXlsb2Fk.b25lIGZha2Ugc2ln`), // base64(two fake protected).base64(different payload).base64(two fake sig) []byte(`dHdvIGZha2UgcHJvdGVjdGVk.ZGlmZmVyZW50IHBheWxvYWQ.dHdvIGZha2Ugc2ln`)}, }, {name: "malformed Token", expError: "invalid number of segments", tokens: [][]byte{ // base64(one fake protected).base64(fake payload).base64(one fake sig) []byte(`MWZha2Vwcm90ZWN0ZWQ.ZmFrZSBwYXlsb2Fk.b25lIGZha2Ugc2ln`), // malformed token []byte(`..ZmFrZSBwYXl.sb2Fk.dHdvIGZha2Ugc2ln`)}, }, {name: "malformed Refreshed ID Token", expError: "invalid refreshed ID Token", tokens: [][]byte{ // base64(one fake protected).base64(fake payload).base64(one fake sig) []byte(`MWZha2Vwcm90ZWN0ZWQ.ZmFrZSBwYXlsb2Fk.b25lIGZha2Ugc2ln`), // base64(two fake protected).base64(fake payload).base64(two fake sig) []byte(`dHdvIGZha2UgcHJvdGVjdGVk.ZmFrZSBwYXlsb2Fk.dHdvIGZha2Ugc2ln`)}, // base64(refreshed protected).base64(refreshed payload).base64(refreshed sig) freshIDToken: []byte(`***=BAD!!!###`), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { pktCom, err := CompactPKToken(tc.tokens, tc.freshIDToken) if tc.expError != "" { require.ErrorContains(t, err, tc.expError) } else { require.NoError(t, err) require.Equal(t, string(tc.expPktCom), string(pktCom)) } }) } } func TestFromCompact(t *testing.T) { testCases := []struct { name string expTokens [][]byte expFreshIDToken []byte expError string pktCom []byte }{ {name: "happy case one tokens", expTokens: [][]byte{ // base64(one fake protected).base64(fake payload).base64(one fake sig) []byte(`MWZha2Vwcm90ZWN0ZWQ.ZmFrZSBwYXlsb2Fk.b25lIGZha2Ugc2ln`)}, pktCom: []byte(`ZmFrZSBwYXlsb2Fk:MWZha2Vwcm90ZWN0ZWQ:b25lIGZha2Ugc2ln`), }, {name: "happy case two tokens", // base64(one fake protected).base64(fake payload).base64(one fake sig) expTokens: [][]byte{ // base64(one fake protected).base64(fake payload).base64(one fake sig) []byte(`MWZha2Vwcm90ZWN0ZWQ.ZmFrZSBwYXlsb2Fk.b25lIGZha2Ugc2ln`), // base64(two fake protected).base64(fake payload).base64(two fake sig) []byte(`dHdvIGZha2UgcHJvdGVjdGVk.ZmFrZSBwYXlsb2Fk.dHdvIGZha2Ugc2ln`)}, pktCom: []byte(`ZmFrZSBwYXlsb2Fk:MWZha2Vwcm90ZWN0ZWQ:b25lIGZha2Ugc2ln:dHdvIGZha2UgcHJvdGVjdGVk:dHdvIGZha2Ugc2ln`), }, {name: "happy case two tokens and refreshed ID Token", expTokens: [][]byte{ // base64(one fake protected).base64(fake payload).base64(one fake sig) []byte(`MWZha2Vwcm90ZWN0ZWQ.ZmFrZSBwYXlsb2Fk.b25lIGZha2Ugc2ln`), // base64(two fake protected).base64(fake payload).base64(two fake sig) []byte(`dHdvIGZha2UgcHJvdGVjdGVk.ZmFrZSBwYXlsb2Fk.dHdvIGZha2Ugc2ln`)}, // base64(refreshed protected).base64(refreshed payload).base64(refreshed sig) expFreshIDToken: []byte(`cmVmcmVzaGVkIHByb3RlY3RlZA.cmVmcmVzaGVkIHBheWxvYWQ.cmVmcmVzaGVkIHNpZw`), pktCom: []byte(`ZmFrZSBwYXlsb2Fk:MWZha2Vwcm90ZWN0ZWQ:b25lIGZha2Ugc2ln:dHdvIGZha2UgcHJvdGVjdGVk:dHdvIGZha2Ugc2ln.cmVmcmVzaGVkIHByb3RlY3RlZA.cmVmcmVzaGVkIHBheWxvYWQ.cmVmcmVzaGVkIHNpZw`), }, {name: "malformed Compact PK Token (invalid number of segments)", expError: "invalid number of segments", pktCom: []byte(`dHdvIGZha2Ugc2ln:dHdvIGZha2Ugc2ln.cmVmcmVzaGVkIHByb3RlY3RlZA.cmVmcmVzaGVkIHBheWxvYWQ.cmVmcmVzaGVkIHNpZw`), }, {name: "malformed Compact PK Token (invalid refreshed ID Token)", expError: "invalid refreshed ID Token", pktCom: []byte(`ZmFrZSBwYXlsb2Fk:MWZha2Vwcm90ZWN0ZWQ:b25lIGZha2Ugc2ln:dHdvIGZha2UgcHJvdGVjdGVk:dHdvIGZha2Ugc2ln.BAD.REFRESHED.ID.TOKEN`), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { tokens, freshIDToken, err := SplitCompactPKToken(tc.pktCom) if tc.expError != "" { require.ErrorContains(t, err, tc.expError) } else { require.NoError(t, err) require.Equal(t, tc.expTokens, tokens) require.Equal(t, tc.expFreshIDToken, freshIDToken) } }) } } openpubkey-0.8.0/pktoken/cos.go000066400000000000000000000041741477254274500165150ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package pktoken import ( "encoding/json" "fmt" ) type CosignerClaims struct { Issuer string `json:"iss"` KeyID string `json:"kid"` Algorithm string `json:"alg"` AuthID string `json:"eid"` AuthTime int64 `json:"auth_time"` IssuedAt int64 `json:"iat"` // may differ from auth_time because of refresh Expiration int64 `json:"exp"` RedirectURI string `json:"ruri"` Nonce string `json:"nonce"` Typ string `json:"typ"` } func (p *PKToken) ParseCosignerClaims() (*CosignerClaims, error) { protected, err := json.Marshal(p.Cos.ProtectedHeaders()) if err != nil { return nil, err } var claims CosignerClaims if err := json.Unmarshal(protected, &claims); err != nil { return nil, err } // Check that all fields are present var missing []string if claims.Issuer == "" { missing = append(missing, `iss`) } if claims.KeyID == "" { missing = append(missing, `kid`) } if claims.Algorithm == "" { missing = append(missing, `alg`) } if claims.AuthID == "" { missing = append(missing, `eid`) } if claims.AuthTime == 0 { missing = append(missing, `auth_time`) } if claims.IssuedAt == 0 { missing = append(missing, `iat`) } if claims.Expiration == 0 { missing = append(missing, `exp`) } if claims.RedirectURI == "" { missing = append(missing, `ruri`) } if claims.Nonce == "" { missing = append(missing, `nonce`) } if len(missing) > 0 { return nil, fmt.Errorf("cosigner protect header missing required headers: %v", missing) } return &claims, nil } openpubkey-0.8.0/pktoken/mocks/000077500000000000000000000000001477254274500165105ustar00rootroot00000000000000openpubkey-0.8.0/pktoken/mocks/pktoken.go000066400000000000000000000110441477254274500205120ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package mocks import ( "context" "crypto" "fmt" "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/pktoken/clientinstance" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/providers/mocks" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func GenerateMockPKToken(t *testing.T, signingKey crypto.Signer, alg jwa.KeyAlgorithm) (*pktoken.PKToken, error) { options := &MockPKTokenOpts{ GQSign: false, CommitType: providers.CommitTypesEnum.NONCE_CLAIM, CorrectCicHash: true, CorrectCicSig: true, } pkt, _, err := GenerateMockPKTokenWithOpts(t, signingKey, alg, mocks.DefaultIDTokenTemplate(), options) return pkt, err } func GenerateMockPKTokenGQ(t *testing.T, signingKey crypto.Signer, alg jwa.KeyAlgorithm) (*pktoken.PKToken, error) { options := &MockPKTokenOpts{ GQSign: true, CommitType: providers.CommitTypesEnum.NONCE_CLAIM, CorrectCicHash: true, CorrectCicSig: true, } pkt, _, err := GenerateMockPKTokenWithOpts(t, signingKey, alg, mocks.DefaultIDTokenTemplate(), options) return pkt, err } type MockPKTokenOpts struct { GQSign bool CommitType providers.CommitType GQOnly bool CorrectCicHash bool CorrectCicSig bool } func GenerateMockPKTokenWithOpts(t *testing.T, signingKey crypto.Signer, alg jwa.KeyAlgorithm, idtTemplate mocks.IDTokenTemplate, options *MockPKTokenOpts) (*pktoken.PKToken, *mocks.MockProviderBackend, error) { jwkKey, err := jwk.PublicKeyOf(signingKey) if err != nil { return nil, nil, err } err = jwkKey.Set(jwk.AlgorithmKey, alg) if err != nil { return nil, nil, err } cic, err := clientinstance.NewClaims(jwkKey, map[string]any{}) require.NoError(t, err) // Set gqOnly to gqCommitment since gqCommitment requires gqOnly gqOnly := options.CommitType.GQCommitment providerOpts := providers.MockProviderOpts{ GQSign: options.GQSign, CommitType: options.CommitType, NumKeys: 2, VerifierOpts: providers.ProviderVerifierOpts{ SkipClientIDCheck: false, GQOnly: gqOnly, CommitType: options.CommitType, ClientID: "mockClient-ID", }, } op, backend, _, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) opSignKey, keyID, _ := backend.RandomSigningKey() idtTemplate.KeyID = keyID idtTemplate.SigningKey = opSignKey switch options.CommitType { case providers.CommitTypesEnum.NONCE_CLAIM: idtTemplate.CommitFunc = mocks.AddNonceCommit case providers.CommitTypesEnum.AUD_CLAIM: idtTemplate.CommitFunc = mocks.AddAudCommit case providers.CommitTypesEnum.GQ_BOUND: default: return nil, nil, fmt.Errorf("unknown CommitType: %v", options.CommitType) } backend.SetIDTokenTemplate(&idtTemplate) tokens, err := op.RequestTokens(context.Background(), cic) if err != nil { return nil, nil, err } idToken := tokens.IDToken // Return a PK Token where the CIC which doesn't match the commitment if !options.CorrectCicHash { // overwrite the cic with a new cic with a different hash cic, err = clientinstance.NewClaims(jwkKey, map[string]any{"cause": "differentCicHash"}) if err != nil { return nil, nil, err } } // Return a PK Token where the CIC that is signed by the wrong key if !options.CorrectCicSig { // overwrite the signkey with a new key signingKey, err = util.GenKeyPair(alg) require.NoError(t, err) jwkKey, err = jwk.PublicKeyOf(signingKey) if err != nil { return nil, nil, err } err = jwkKey.Set(jwk.AlgorithmKey, alg) if err != nil { return nil, nil, err } } // Sign mock id token payload with cic headers cicToken, err := cic.Sign(signingKey, jwkKey.Algorithm(), idToken) if err != nil { return nil, nil, err } // Combine two tokens into a PK Token pkt, err := pktoken.New(idToken, cicToken) if err != nil { return nil, nil, err } return pkt, backend, nil } openpubkey-0.8.0/pktoken/osm.go000066400000000000000000000100571477254274500165240ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package pktoken import ( "crypto" "fmt" "github.com/lestrrat-go/jwx/v2/jws" ) // Options configures VerifySignedMessage behavior type Options struct { Typ string // Override for the expected typ value } type OptionFunc func(*Options) // WithTyp sets a custom typ value for verification func WithTyp(typ string) OptionFunc { return func(o *Options) { o.Typ = typ } } // NewSignedMessage signs a message with the signer provided. The signed // message is OSM (OpenPubkey Signed Message) which is a type of // JWS (JSON Web Signature). OSMs commit to the PK Token which was used // to generate the OSM. func (p *PKToken) NewSignedMessage(content []byte, signer crypto.Signer) ([]byte, error) { cic, err := p.GetCicValues() if err != nil { return nil, err } pktHash, err := p.Hash() if err != nil { return nil, err } // Create our headers as defined by section 3.5 of the OpenPubkey paper protected := jws.NewHeaders() if err := protected.Set("alg", cic.PublicKey().Algorithm()); err != nil { return nil, err } if err := protected.Set("kid", pktHash); err != nil { return nil, err } if err := protected.Set("typ", "osm"); err != nil { return nil, err } return jws.Sign( content, jws.WithKey( cic.PublicKey().Algorithm(), signer, jws.WithProtectedHeaders(protected), ), ) } // VerifySignedMessage verifies that an OSM (OpenPubkey Signed Message) using // the public key in this PK Token. If verification is successful, // VerifySignedMessage returns the content of the signed message. Otherwise // it returns an error explaining why verification failed. // // Note: VerifySignedMessage does not check this the PK Token is valid. // The PK Token should always be verified first before calling // VerifySignedMessage func (p *PKToken) VerifySignedMessage(osm []byte, options ...OptionFunc) ([]byte, error) { // Default options opts := Options{ Typ: "osm", // Default to "osm" for backward compatibility } // Apply provided options for _, opt := range options { opt(&opts) } cic, err := p.GetCicValues() if err != nil { return nil, err } message, err := jws.Parse(osm) if err != nil { return nil, err } // Check that our OSM headers are correct if len(message.Signatures()) != 1 { return nil, fmt.Errorf("expected only one signature on jwt, received %d", len(message.Signatures())) } protected := message.Signatures()[0].ProtectedHeaders() // Verify typ header matches expected value from options typ, ok := protected.Get("typ") if !ok { return nil, fmt.Errorf("missing required header `typ`") } if typ != opts.Typ { return nil, fmt.Errorf(`incorrect "typ" header, expected %q but received %s`, opts.Typ, typ) } // Verify key algorithm header matches cic if protected.Algorithm() != cic.PublicKey().Algorithm() { return nil, fmt.Errorf(`incorrect "alg" header, expected %s but received %s`, cic.PublicKey().Algorithm(), protected.Algorithm()) } // Verify kid header matches hash of pktoken kid, ok := protected.Get("kid") if !ok { return nil, fmt.Errorf("missing required header `kid`") } pktHash, err := p.Hash() if err != nil { return nil, fmt.Errorf("unable to hash PK Token: %w", err) } if kid != string(pktHash) { return nil, fmt.Errorf(`incorrect "kid" header, expected %s but received %s`, pktHash, kid) } _, err = jws.Verify(osm, jws.WithKey(cic.PublicKey().Algorithm(), cic.PublicKey())) if err != nil { return nil, err } // Return the osm payload return message.Payload(), nil } openpubkey-0.8.0/pktoken/osm_test.go000066400000000000000000000075361477254274500175730ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package pktoken import ( "crypto/rand" "crypto/rsa" "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jws" "github.com/stretchr/testify/require" ) func TestVerifySignedMessage_TypOverride(t *testing.T) { // Generate a test RSA key pair key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) // Convert RSA public key to JWK and set "alg" to RS256 jwkKey, err := jwk.FromRaw(&key.PublicKey) require.NoError(t, err) err = jwkKey.Set(jwk.AlgorithmKey, jwa.RS256) require.NoError(t, err, "failed to set JWK alg") // Use a consistent payload for both Op and Cic payload := []byte("consistent payload") // Create a mock Cic signature with "rz" and "upk" claims cicProtected := jws.NewHeaders() require.NoError(t, cicProtected.Set(jws.AlgorithmKey, jwa.RS256)) require.NoError(t, cicProtected.Set(jws.TypeKey, "CIC")) require.NoError(t, cicProtected.Set("rz", "test-randomness")) // Required "rz" claim require.NoError(t, cicProtected.Set("upk", jwkKey)) // Required "upk" claim with matching alg cicOsm, err := jws.Sign(payload, jws.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(cicProtected))) require.NoError(t, err, "failed to create mock Cic") // Create a mock Op signature (required by PKToken) opProtected := jws.NewHeaders() require.NoError(t, opProtected.Set(jws.AlgorithmKey, jwa.RS256)) require.NoError(t, opProtected.Set(jws.TypeKey, "JWT")) // OIDC type opToken, err := jws.Sign(payload, jws.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(opProtected))) require.NoError(t, err, "failed to create mock Op") // Initialize PKToken with Op and Cic signatures p, err := New(opToken, cicOsm) require.NoError(t, err, "failed to create PKToken") // Get the real hash to use as "kid" hash, err := p.Hash() require.NoError(t, err, "failed to compute PKToken hash") // Test 1: Verify message with "osm" typ osmProtected := jws.NewHeaders() require.NoError(t, osmProtected.Set(jws.AlgorithmKey, jwa.RS256)) require.NoError(t, osmProtected.Set(jws.KeyIDKey, hash)) // Use real hash as "kid" require.NoError(t, osmProtected.Set(jws.TypeKey, "osm")) osm, err := jws.Sign([]byte("test message"), jws.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(osmProtected))) require.NoError(t, err, "failed to create mock osm") result, err := p.VerifySignedMessage(osm) require.NoError(t, err, "expected no error for osm") require.Equal(t, "test message", string(result)) // Test 2: Verify message with "JWT" typ jwtProtected := jws.NewHeaders() require.NoError(t, jwtProtected.Set(jws.AlgorithmKey, jwa.RS256)) require.NoError(t, jwtProtected.Set(jws.KeyIDKey, hash)) // Use real hash as "kid" require.NoError(t, jwtProtected.Set(jws.TypeKey, "JWT")) jwtOsm, err := jws.Sign([]byte("jwt message"), jws.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(jwtProtected))) require.NoError(t, err, "failed to create mock JWT") result, err = p.VerifySignedMessage(jwtOsm, WithTyp("JWT")) require.NoError(t, err, "expected no error for JWT") require.Equal(t, "jwt message", string(result)) // Test 3: Verify failure without typ override _, err = p.VerifySignedMessage(jwtOsm) // Expects "osm" by default require.Error(t, err, "expected error for mismatched typ") } openpubkey-0.8.0/pktoken/pktoken.go000066400000000000000000000304441477254274500174030ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package pktoken import ( "bytes" "context" "crypto" "encoding/json" "fmt" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken/clientinstance" "github.com/openpubkey/openpubkey/util" _ "golang.org/x/crypto/sha3" ) type SignatureType string const ( OIDC SignatureType = "JWT" CIC SignatureType = "CIC" COS SignatureType = "COS" ) type Signature = jws.Signature type PKToken struct { raw []byte // the original, raw representation of the object Payload []byte // decoded payload Op *Signature // Provider Signature Cic *Signature // Client Signature Cos *Signature // Cosigner Signature // We keep the tokens around as unmarshalled values can no longer be verified OpToken []byte // Base64 encoded ID Token signed by the OP CicToken []byte // Base64 encoded Token signed by the Client CosToken []byte // Base64 encoded Token signed by the Cosigner // FreshIDToken is the refreshed ID Token. It has a different payload from // other tokens and must be handled separately. // It is only used for POP Authentication FreshIDToken []byte // Base64 encoded Refreshed ID Token } // New creates a new PKToken from an ID Token and a CIC Token. // It adds signatures for both tokens to the PK Token and returns the PK Token. func New(idToken []byte, cicToken []byte) (*PKToken, error) { pkt := &PKToken{} if err := pkt.AddSignature(idToken, OIDC); err != nil { return nil, err } if err := pkt.AddSignature(cicToken, CIC); err != nil { return nil, err } return pkt, nil } // NewFromCompact creates a PK Token from a compact representation func NewFromCompact(pktCom []byte) (*PKToken, error) { tokens, freshIDToken, err := SplitCompactPKToken(pktCom) if err != nil { return nil, err } pkt := &PKToken{} for _, token := range tokens { parsedToken, err := oidc.NewJwt(token) if err != nil { return nil, err } typ := parsedToken.GetSignature().GetProtectedClaims().Type if typ == "" { // missing typ claim, assuming this is from the OIDC provider and set typ=OIDC=JWT // Okta is known not to set the typ parameter on their ID Tokens // The JWT RFC-7519 encourages but does not require that typ be set saying about typ // "This parameter is ignored by JWT implementations; any processing of this parameter is // performed by the JWT application. If present, it is RECOMMENDED that its value be "JWT" // to indicate that this object is a JWT." // https://datatracker.ietf.org/doc/html/rfc7519#section-5.1 typ = string(OIDC) } sigType := SignatureType(typ) if err := pkt.AddSignature(token, sigType); err != nil { return nil, err } } pkt.FreshIDToken = freshIDToken return pkt, nil } // Issuer returns the issuer (`iss`) of the ID Token in the PKToken. // It extracts the issuer from the PKToken payload and returns it as a string. func (p *PKToken) Issuer() (string, error) { var claims struct { Issuer string `json:"iss"` } if err := json.Unmarshal(p.Payload, &claims); err != nil { return "", fmt.Errorf("malformatted PK token claims: %w", err) } return claims.Issuer, nil } // Audience returns the audience (`aud`) of the ID Token in the PKToken. // The audience is also known as the client ID. func (p *PKToken) Audience() (string, error) { var claims struct { Audience string `json:"aud"` } if err := json.Unmarshal(p.Payload, &claims); err != nil { return "", fmt.Errorf("malformatted PK token claims: %w", err) } return claims.Audience, nil } // Subscriber returns the subscriber (`sub`) of the ID Token in the PKToken. // This is a unique identifier for the user at the OpenID Provider. func (p *PKToken) Subscriber() (string, error) { var claims struct { Subscriber string `json:"sub"` } if err := json.Unmarshal(p.Payload, &claims); err != nil { return "", fmt.Errorf("malformatted PK token claims: %w", err) } return claims.Subscriber, nil } // IdentityString string returns the three attributes that are used to uniquely identify a user // in the OpenID Connect protocol: the subscriber, the issuer func (p *PKToken) IdentityString() (string, error) { sub, err := p.Subscriber() if err != nil { return "", err } iss, err := p.Issuer() if err != nil { return "", err } return fmt.Sprintf("%s %s", sub, iss), nil } // Signs PK Token and then returns only the payload, header and signature as a JWT func (p *PKToken) SignToken( signer crypto.Signer, alg jwa.KeyAlgorithm, protected map[string]any, ) ([]byte, error) { headers := jws.NewHeaders() for key, val := range protected { if err := headers.Set(key, val); err != nil { return nil, fmt.Errorf("malformatted headers: %w", err) } } return jws.Sign( p.Payload, jws.WithKey( alg, signer, jws.WithProtectedHeaders(headers), ), ) } // AddSignature will add a signature to the PKToken with the specified signature type. // It takes a token byte slice and a signature type as input, and returns an error if the signature cannot be added. // // To use AddSignature, first parse the token byte slice using the jws.Parse function to obtain a jws.Message object. // You can then extract the signature from the message object using the Signatures method, and pass it to AddSignature along with the desired signature type. // // The function supports three signature types: OIDC, CIC, and COS. // These signature types correspond to the JWTs in the PK Token. // Depending on the signature type, the function will set the corresponding field in the PKToken struct (Op, Cic, or Cos) to the provided signature. // It will also set the corresponding token field (OpToken, CicToken, or CosToken) to the provided token byte slice. // // If the signature type is not recognized, an error will be returned. func (p *PKToken) AddSignature(token []byte, sigType SignatureType) error { message, err := jws.Parse(token) if err != nil { return err } // If there is no payload, we set the provided token's payload as current, otherwise // we make sure that the new payload matches current if p.Payload == nil { p.Payload = message.Payload() } else if !bytes.Equal(p.Payload, message.Payload()) { return fmt.Errorf("payload in the GQ token (%s) does not match the existing payload in the PK Token (%s)", p.Payload, message.Payload()) } signature := message.Signatures()[0] if sigType == CIC || sigType == COS { protected := signature.ProtectedHeaders() if sigTypeFound, ok := protected.Get(jws.TypeKey); !ok { return fmt.Errorf("required 'typ' claim not found in protected") } else if sigTypeFoundStr, ok := sigTypeFound.(string); !ok { return fmt.Errorf("'typ' claim in protected must be a string but was a %T", sigTypeFound) } else if sigTypeFoundStr != string(sigType) { return fmt.Errorf("incorrect 'typ' claim in protected, expected (%s), got (%s)", sigType, sigTypeFound) } } switch sigType { case OIDC: p.Op = signature p.OpToken = token case CIC: p.Cic = signature p.CicToken = token case COS: p.Cos = signature p.CosToken = token default: return fmt.Errorf("unrecognized signature type: %s", string(sigType)) } return nil } func (p *PKToken) ProviderAlgorithm() (jwa.SignatureAlgorithm, bool) { alg, ok := p.Op.ProtectedHeaders().Get(jws.AlgorithmKey) if !ok { return "", false } return alg.(jwa.SignatureAlgorithm), true } func (p *PKToken) GetCicValues() (*clientinstance.Claims, error) { cicPH, err := p.Cic.ProtectedHeaders().AsMap(context.TODO()) if err != nil { return nil, err } return clientinstance.ParseClaims(cicPH) } func (p *PKToken) Hash() (string, error) { /* We set the raw variable when unmarshalling from json (the only current string representation of a PK Token) so when we hash we use the same representation that was given for consistency. When the token being hashed is a new PK Token, we marshal it ourselves. This can introduce some issues based on how different languages format their json strings. */ message := p.raw var err error if message == nil { message, err = json.Marshal(p) if err != nil { return "", err } } hash := util.B64SHA3_256(message) return string(hash), nil } // Compact serializes a PK Token into a compact representation. func (p *PKToken) Compact() ([]byte, error) { tokens := [][]byte{} if p.OpToken != nil { tokens = append(tokens, p.OpToken) } if p.CicToken != nil { tokens = append(tokens, p.CicToken) } if p.CosToken != nil { tokens = append(tokens, p.CosToken) } return CompactPKToken(tokens, p.FreshIDToken) } func (p *PKToken) MarshalJSON() ([]byte, error) { rawJws := oidc.Jws{ Payload: string(util.Base64EncodeForJWT(p.Payload)), Signatures: []oidc.Signature{}, } var opPublicHeader map[string]any var err error if p.Op.PublicHeaders() != nil { if opPublicHeader, err = p.Op.PublicHeaders().AsMap(context.Background()); err != nil { return nil, err } } if err = rawJws.AddSignature(p.OpToken, oidc.WithPublicHeader(opPublicHeader)); err != nil { return nil, err } if err = rawJws.AddSignature(p.CicToken); err != nil { return nil, err } if p.CosToken != nil { if err = rawJws.AddSignature(p.CosToken); err != nil { return nil, err } } return json.Marshal(rawJws) } func (p *PKToken) UnmarshalJSON(data []byte) error { var rawJws oidc.Jws if err := json.Unmarshal(data, &rawJws); err != nil { return err } var parsed jws.Message if err := json.Unmarshal(data, &parsed); err != nil { return err } p.Payload = parsed.Payload() // base64 decoded opCount := 0 cicCount := 0 cosCount := 0 for i, signature := range parsed.Signatures() { // for some reason the unmarshaled signatures have empty non-nil // public headers. set them to nil instead. public := signature.PublicHeaders() pubMap, _ := public.AsMap(context.Background()) if len(pubMap) == 0 { signature.SetPublicHeaders(nil) } protected := signature.ProtectedHeaders() var sigType SignatureType typeHeader, ok := protected.Get(jws.TypeKey) if ok { sigTypeStr, ok := typeHeader.(string) if !ok { return fmt.Errorf(`provided "%s" is of wrong type, expected string`, jws.TypeKey) } sigType = SignatureType(sigTypeStr) } else { // missing typ claim, assuming this is from the OIDC provider sigType = OIDC } switch sigType { case OIDC: opCount += 1 p.Op = signature p.OpToken = []byte(rawJws.Signatures[i].Protected + "." + rawJws.Payload + "." + rawJws.Signatures[i].Signature) case CIC: cicCount += 1 p.Cic = signature p.CicToken = []byte(rawJws.Signatures[i].Protected + "." + rawJws.Payload + "." + rawJws.Signatures[i].Signature) case COS: cosCount += 1 p.Cos = signature p.CosToken = []byte(rawJws.Signatures[i].Protected + "." + rawJws.Payload + "." + rawJws.Signatures[i].Signature) default: return fmt.Errorf("unrecognized signature type: %s", sigType) } } // Do some signature count verifications if opCount == 0 { return fmt.Errorf(`at least one signature of type "oidc" or "oidc_gq" is required`) } else if opCount > 1 { return fmt.Errorf(`only one signature of type "oidc" or "oidc_gq" is allowed, found %d`, opCount) } if cicCount == 0 { return fmt.Errorf(`at least one signature of type "cic" is required`) } else if cicCount > 1 { return fmt.Errorf(`only one signature of type "cic" is allowed, found %d`, cicCount) } if cosCount > 1 { return fmt.Errorf(`only one signature of type "cos" is allowed, found %d`, cosCount) } return nil } // DeepCopy creates a complete and independent copy of this PKToken, func (p *PKToken) DeepCopy() (*PKToken, error) { pktJson, err := p.MarshalJSON() if err != nil { return nil, err } var pktCopy PKToken if err := json.Unmarshal(pktJson, &pktCopy); err != nil { return nil, err } pktCopy.FreshIDToken = p.FreshIDToken return &pktCopy, nil } openpubkey-0.8.0/pktoken/pktoken_test.go000066400000000000000000000321501477254274500204360ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package pktoken_test import ( "bytes" "crypto" _ "embed" "encoding/json" "testing" "github.com/stretchr/testify/require" "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/pktoken/mocks" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/openpubkey/openpubkey/util" ) func TestPkToken(t *testing.T) { alg := jwa.ES256 signingKey, err := util.GenKeyPair(alg) require.NoError(t, err) pkt, err := mocks.GenerateMockPKToken(t, signingKey, alg) require.NoError(t, err) testPkTokenMessageSigning(t, pkt, signingKey) testPkTokenSerialization(t, pkt) actualIssuer, err := pkt.Issuer() require.NoError(t, err) require.Equal(t, "mockIssuer", actualIssuer) actualAudience, err := pkt.Audience() require.NoError(t, err) require.Equal(t, "empty", actualAudience) actualAlg, ok := pkt.ProviderAlgorithm() require.True(t, ok) require.Equal(t, "RS256", actualAlg.String()) } func testPkTokenMessageSigning(t *testing.T, pkt *pktoken.PKToken, signingKey crypto.Signer) { // Create new OpenPubKey Signed Message (OSM) msg := "test message!" osm, err := pkt.NewSignedMessage([]byte(msg), signingKey) require.NoError(t, err) // Verify our OSM is valid payload, err := pkt.VerifySignedMessage(osm) require.NoError(t, err) require.Equal(t, msg, string(payload), "OSM payload did not match what we initially wrapped") } func testPkTokenSerialization(t *testing.T, pkt *pktoken.PKToken) { // Test json serialization/deserialization pktJson, err := json.Marshal(pkt) require.NoError(t, err) var newPkt *pktoken.PKToken err = json.Unmarshal(pktJson, &newPkt) require.NoError(t, err) newPktJson, err := json.Marshal(newPkt) require.NoError(t, err) require.JSONEq(t, string(pktJson), string(newPktJson)) // Simple Compact sanity test pktCom, err := pkt.Compact() require.NoError(t, err) require.NotNil(t, pktCom) pktFromCom, err := pktoken.NewFromCompact(pktCom) require.NoError(t, err) require.NotNil(t, pktFromCom.OpToken) require.NotNil(t, pktFromCom.CicToken) } // This test builds a PK Token from a set of test vectors and then checks // that our serialization code preserves the exact values supplied as // test vectors. This is input because even minor whitespace or ordering // changes can break signature verification. func TestPkTokenJwsUnchanged(t *testing.T) { payload := `{ "aud": "testAud", "email": "arthur.aardvark@example.com", "exp": 1708641372, "iat": 1708554972, "iss": "mockIssuer", "nonce": "iOqVQfpJsbt4gpcGUX0lJLT82bTm8PU1fwNghiGau0M", "sub": "1234567890" }` testCases := []struct { name string payload string opProtected string cicProtected string cosProtected string }{ {name: "with alphabetical order", payload: payload, opProtected: `{"alg":"RS256","typ":"JWT"}`, cicProtected: `{"alg":"ES256","rz":"872c6399f440d80a8c28935d8dd84da13ecdfc8e99b3dfbf92bdf1a3133a0b5e","typ":"CIC","upk":{"alg":"ES256","crv":"P-256","kty":"EC","x":"1UxCtDCjyb0bSz9P815sMTqGjSdF2u-sYk0egy4yigs","y":"0qQnHkOLMyQY5WwnpjaFO2TzGCtq_nFg10fI16LcexE"}}`, cosProtected: `{"alg":"ES256","auth_time":1708991378,"eid":"1234","exp":1708994978,"iat":1708991378,"iss":"example.com","kid":"1234","nonce":"test-nonce","ruri":"http://localhost:3000","typ":"COS"}`, }, {name: "with reverse alphabetical order", payload: payload, opProtected: `{"typ":"JWT","alg":"RS256"}`, cicProtected: `{"upk":{"alg":"ES256","crv":"P-256","kty":"EC","x":"1UxCtDCjyb0bSz9P815sMTqGjSdF2u-sYk0egy4yigs","y":"0qQnHkOLMyQY5WwnpjaFO2TzGCtq_nFg10fI16LcexE"}, "typ":"CIC", "rz":"872c6399f440d80a8c28935d8dd84da13ecdfc8e99b3dfbf92bdf1a3133a0b5e", "alg":"ES256"}`, cosProtected: `{"typ":"COS","ruri":"http://localhost:3000","nonce":"test-nonce","kid":"1234","iss":"example.com","iat":1708991378,"exp":1708994978,"eid":"none","auth_time":1708991378,"alg":"ES256"}`, }, {name: "with extra whitespace", payload: payload, opProtected: `{ "alg" : "RS256", "typ": "JWT" }`, cicProtected: `{ "alg" : "ES256", "rz": "872c6399f440d80a8c28935d8dd84da13ecdfc8e99b3dfbf92bdf1a3133a0b5e" , "typ":"CIC","upk":{"alg":"ES256","crv":"P-256","kty":"EC","x":"1UxCtDCjyb0bSz9P815sMTqGjSdF2u-sYk0egy4yigs","y":"0qQnHkOLMyQY5WwnpjaFO2TzGCtq_nFg10fI16LcexE"}}`, cosProtected: `{ "alg" : "ES256", "auth_time": 1708991378,"eid":"1234","exp":1708994978,"iat":1708991378,"iss":"example.com","kid":"1234","nonce":"test-nonce","ruri":"http://localhost:3000","typ":"COS"}`, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { testUnchanged(t, tc.payload, tc.opProtected, tc.cicProtected, tc.cosProtected) }) } } func testUnchanged(t *testing.T, payload string, opPheader string, cicPheader string, cosPheader string) { pkt := &pktoken.PKToken{} // Build OP Token and add it to PK Token opTokenOriginal := BuildToken(opPheader, payload, "fakeSignature OP") err := pkt.AddSignature(opTokenOriginal, pktoken.OIDC) require.NoError(t, err) // Build CIC Token and add it to PK Token cicTokenOriginal := BuildToken(cicPheader, payload, "fakeSignature") err = pkt.AddSignature(cicTokenOriginal, pktoken.CIC) require.NoError(t, err) // Build CIC Token and add it to PK Token cosTokenOriginal := BuildToken(cosPheader, payload, "fakeSignature") err = pkt.AddSignature(cosTokenOriginal, pktoken.COS) require.NoError(t, err) // Check that Marshal PK Token to Json leaves underlying signed values unchanged pktJson, err := pkt.MarshalJSON() require.NoError(t, err) // Unmarshal it into a simple JWS structure to see if the underlying values have changed var simpleJWS oidc.Jws err = json.Unmarshal(pktJson, &simpleJWS) require.NoError(t, err) opTokenUnmarshalled, err := simpleJWS.GetTokenByTyp("JWT") // JWT is the token typ used by the OP Token (ID Token) require.NoError(t, err) require.EqualValues(t, string(opTokenOriginal), string(opTokenUnmarshalled), "danger, signed values in OP Token being changed during marshalling") cicTokenUnmarshalled, err := simpleJWS.GetTokenByTyp("CIC") // CIC is the token typ used by the OP Token (ID Token) require.NoError(t, err) require.EqualValues(t, string(cicTokenOriginal), string(cicTokenUnmarshalled), "danger, signed values in CIC Token being changed during marshalling") cosTokenUnmarshalled, err := simpleJWS.GetTokenByTyp("COS") // COS is the token typ used by the OP Token (ID Token) require.NoError(t, err) require.EqualValues(t, string(cosTokenOriginal), string(cosTokenUnmarshalled), "danger, signed values in CIC Token being changed during marshalling") // Check that ToCompact and FromCompact leaves underlying signed values unchanged pktCom, err := pkt.Compact() require.NoError(t, err) pktFromCom, err := pktoken.NewFromCompact(pktCom) require.NoError(t, err) require.EqualValues(t, string(opTokenOriginal), string(pktFromCom.OpToken), "danger, signed values in OP Token being changed after compact") require.EqualValues(t, string(cicTokenOriginal), string(pktFromCom.CicToken), "danger, signed values in CIC Token being changed after compact") require.EqualValues(t, string(cosTokenOriginal), string(pktFromCom.CosToken), "danger, signed values in COS Token being changed after compact") } // This test builds a PK Token from a set of test vectors and then checks // that the ToCompact constructs the PK Token correctly and that FromCompact // reconstructs the PK Token correctly. func TestCompact(t *testing.T) { payload := `{ "aud": "testAud", "email": "arthur.aardvark@example.com", "exp": 1708641372, "iat": 1708554972, "iss": "mockIssuer", "nonce": "iOqVQfpJsbt4gpcGUX0lJLT82bTm8PU1fwNghiGau0M", "sub": "1234567890" }` testCases := []struct { name string payload string opProtected string cicProtected string cosProtected string expToCompactError string expFromCompactError string }{ {name: "Happy case with OP, CIC tokens", payload: payload, opProtected: `{"alg":"RS256","typ":"JWT"}`, cicProtected: `{"alg":"ES256","rz":"872c6399f440d80a8c28935d8dd84da13ecdfc8e99b3dfbf92bdf1a3133a0b5e","typ":"CIC","upk":{"alg":"ES256","crv":"P-256","kty":"EC","x":"1UxCtDCjyb0bSz9P815sMTqGjSdF2u-sYk0egy4yigs","y":"0qQnHkOLMyQY5WwnpjaFO2TzGCtq_nFg10fI16LcexE"}}`, }, {name: "Happy case with OP, CIC and COS tokens", payload: payload, opProtected: `{"alg":"RS256","typ":"JWT"}`, cicProtected: `{"alg":"ES256","rz":"872c6399f440d80a8c28935d8dd84da13ecdfc8e99b3dfbf92bdf1a3133a0b5e","typ":"CIC","upk":{"alg":"ES256","crv":"P-256","kty":"EC","x":"1UxCtDCjyb0bSz9P815sMTqGjSdF2u-sYk0egy4yigs","y":"0qQnHkOLMyQY5WwnpjaFO2TzGCtq_nFg10fI16LcexE"}}`, cosProtected: `{"alg":"ES256","auth_time":1708991378,"eid":"1234","exp":1708994978,"iat":1708991378,"iss":"example.com","kid":"1234","nonce":"test-nonce","ruri":"http://localhost:3000","typ":"COS"}`, }, {name: "No Tokens", expToCompactError: "no tokens provided", payload: payload, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { pkt := &pktoken.PKToken{} tokensAdded := 0 // Let's us test the case with no CIC Token. if tc.opProtected != "" { // Build OP Token and add it to PK Token opTokenOriginal := BuildToken(tc.opProtected, tc.payload, "fakeSignature OP") err := pkt.AddSignature(opTokenOriginal, pktoken.OIDC) require.NoError(t, err) tokensAdded += 1 } // Let's us test the case with no CIC Token. if tc.cicProtected != "" { // Build CIC Token and add it to PK Token cicTokenOriginal := BuildToken(tc.cicProtected, payload, "fakeSignature") err := pkt.AddSignature(cicTokenOriginal, pktoken.CIC) require.NoError(t, err) tokensAdded += 1 } // Let's us test the case with no COS Token. This is not an error case. if tc.cosProtected != "" { // Build CIC Token and add it to PK Token cosTokenOriginal := BuildToken(tc.cosProtected, payload, "fakeSignature") err := pkt.AddSignature(cosTokenOriginal, pktoken.COS) require.NoError(t, err) tokensAdded += 1 } pktCom, err := pkt.Compact() if tc.expToCompactError != "" { require.ErrorContains(t, err, tc.expToCompactError) } else { require.NoError(t, err) require.NotNil(t, pktCom) parts := bytes.Split(pktCom, []byte(":")) expParts := tokensAdded*2 + 1 require.Equal(t, expParts, len(parts), "number of expected parts in compact (%d) does not match number of parts found (%d) ", expParts, len(parts)) // Try build a PK Token from the compact representation pktFromCom, err := pktoken.NewFromCompact(pktCom) if tc.expFromCompactError != "" { require.ErrorContains(t, err, tc.expFromCompactError) } else { require.NoError(t, err) require.NotNil(t, pktFromCom) require.Equal(t, pkt.OpToken, pktFromCom.OpToken) require.Equal(t, pkt.Op, pktFromCom.Op) require.Equal(t, pkt.CicToken, pktFromCom.CicToken) require.Equal(t, pkt.Cic, pktFromCom.Cic) require.Equal(t, pkt.CosToken, pktFromCom.CosToken) require.Equal(t, pkt.Cos, pktFromCom.Cos) actualIssuer, err := pktFromCom.Issuer() require.NoError(t, err) require.Equal(t, "mockIssuer", actualIssuer) } // Test Deep Copy pktCopy1, err := pkt.DeepCopy() require.NoError(t, err) pktCopy2, err := pkt.DeepCopy() require.NoError(t, err) require.Equal(t, pktCopy1.OpToken, pktCopy2.OpToken) require.Equal(t, pktCopy1.Op, pktCopy2.Op) require.Equal(t, pktCopy1.FreshIDToken, pktCopy2.FreshIDToken) pktCopy1.OpToken = []byte("Overwritten-OP-Token") pktCopy1.Op.SetSignature([]byte{0x0}) pktCopy1.FreshIDToken = []byte("Overwritten-Fresh-ID-Token") require.NotEqual(t, pktCopy1.OpToken, pktCopy2.OpToken) require.NotEqual(t, pktCopy1.Op, pktCopy2.Op) require.NotEqual(t, pktCopy1.FreshIDToken, pktCopy2.FreshIDToken) } }) } } //go:embed test_jwk.json var test_jwk []byte // based on https://www.rfc-editor.org/rfc/rfc7638.html func TestThumprintCalculation(t *testing.T) { fromRfc := "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs" pub, err := jwk.ParseKey(test_jwk) if err != nil { t.Fatal(err) } thumb, err := pub.Thumbprint(crypto.SHA256) if err != nil { t.Fatal(err) } thumbEnc := util.Base64EncodeForJWT(thumb) if string(thumbEnc) != fromRfc { t.Fatalf("thumbprint %s did not match expected value %s", thumbEnc, fromRfc) } } func BuildToken(protected string, payload string, sig string) []byte { return util.JoinJWTSegments( util.Base64EncodeForJWT([]byte(protected)), util.Base64EncodeForJWT([]byte(payload)), util.Base64EncodeForJWT([]byte(sig))) } openpubkey-0.8.0/pktoken/test_jwk.json000066400000000000000000000006711477254274500201250ustar00rootroot00000000000000{ "kty": "RSA", "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", "e": "AQAB", "alg": "RS256", "kid": "2011-04-29" } openpubkey-0.8.0/providers/000077500000000000000000000000001477254274500157365ustar00rootroot00000000000000openpubkey-0.8.0/providers/azure.go000066400000000000000000000115711477254274500174200ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "fmt" "net/http" "time" "github.com/openpubkey/openpubkey/discover" ) // AzureOptions is an options struct that configures how providers.AzureOp // operates. See providers.GetDefaultAzureOpOptions for the recommended default // values to use when interacting with Azure as the OpenIdProvider. type AzureOptions struct { // ClientID is the client ID of the OIDC application. It should be the // expected "aud" claim in received ID tokens from the OP. ClientID string // Issuer is the OP's issuer URI for performing OIDC authorization and // discovery. Issuer string // Scopes is the list of scopes to send to the OP in the initial // authorization request. Scopes []string // RedirectURIs is the list of authorized redirect URIs that can be // redirected to by the OP after the user completes the authorization code // flow exchange. Ensure that your OIDC application is configured to accept // these URIs otherwise an error may occur. RedirectURIs []string // GQSign denotes if the received ID token should be upgraded to a GQ token // using GQ signatures. GQSign bool // OpenBrowser denotes if the client's default browser should be opened // automatically when performing the OIDC authorization flow. This value // should typically be set to true, unless performing some headless // automation (e.g. integration tests) where you don't want the browser to // open. OpenBrowser bool // HttpClient is the http.Client to use when making queries to the OP (OIDC // code exchange, refresh, verification of ID token, fetch of JWKS endpoint, // etc.). If nil, then http.DefaultClient is used. HttpClient *http.Client // IssuedAtOffset configures the offset to add when validating the "iss" and // "exp" claims of received ID tokens from the OP. IssuedAtOffset time.Duration // TenantID is the GUID of the Azure tenant/organization. Azure has a // different issuer URI for each tenant. Users that are not part of Azure // organization, which microsoft nicknames consumers have a default // tenant ID of "9188040d-6c67-4c5b-b112-36a304b66dad" // More details can be found at // https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens TenantID string } func GetDefaultAzureOpOptions() *AzureOptions { defaultTenantID := "9188040d-6c67-4c5b-b112-36a304b66dad" return &AzureOptions{ Issuer: azureIssuer(defaultTenantID), ClientID: "096ce0a3-5e72-4da8-9c86-12924b294a01", // Scopes: []string{"openid profile email"}, Scopes: []string{"openid profile email offline_access"}, // offline_access is required for refresh tokens RedirectURIs: []string{ "http://localhost:3000/login-callback", "http://localhost:10001/login-callback", "http://localhost:11110/login-callback", }, GQSign: false, OpenBrowser: true, HttpClient: nil, IssuedAtOffset: 1 * time.Minute, } } // NewAzureOp creates a Azure OP (OpenID Provider) using the // default configurations options. It uses the OIDC Relying Party (Client) // setup by the OpenPubkey project. func NewAzureOp() BrowserOpenIdProvider { options := GetDefaultAzureOpOptions() return NewAzureOpWithOptions(options) } // NewAzureOpWithOptions creates a Azure OP with configuration specified // using an options struct. This is useful if you want to use your own OIDC // Client or override the configuration. func NewAzureOpWithOptions(opts *AzureOptions) BrowserOpenIdProvider { return &StandardOp{ clientID: opts.ClientID, Scopes: opts.Scopes, RedirectURIs: opts.RedirectURIs, GQSign: opts.GQSign, OpenBrowser: opts.OpenBrowser, HttpClient: opts.HttpClient, IssuedAtOffset: opts.IssuedAtOffset, issuer: opts.Issuer, requestTokensOverrideFunc: nil, publicKeyFinder: discover.PublicKeyFinder{ JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) { return discover.GetJwksByIssuer(ctx, issuer, opts.HttpClient) }, }, } } type AzureOp = StandardOp var _ OpenIdProvider = (*AzureOp)(nil) var _ BrowserOpenIdProvider = (*AzureOp)(nil) var _ RefreshableOpenIdProvider = (*AzureOp)(nil) func azureIssuer(tenantID string) string { return fmt.Sprintf("https://login.microsoftonline.com/%s/v2.0", tenantID) } openpubkey-0.8.0/providers/config.go000066400000000000000000000020001477254274500175220ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers // Config declares the minimal interface for an OP (OpenID provider) config. It // provides methods to get configuration values for a specific OIDC client // implementation. type Config interface { // ClientID returns the registered client identifier that is valid at the OP // issuer ClientID() string // Issuer returns the OP's issuer URL identifier Issuer() string } openpubkey-0.8.0/providers/github_actions.go000066400000000000000000000122651477254274500212750ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "encoding/json" "fmt" "net/http" "net/url" "github.com/awnumar/memguard" "github.com/openpubkey/openpubkey/discover" simpleoidc "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken/clientinstance" ) const githubIssuer = "https://token.actions.githubusercontent.com" type GithubOp struct { issuer string // Change issuer to point this to a test issuer rawTokenRequestURL string tokenRequestAuthToken string publicKeyFinder discover.PublicKeyFinder requestTokensOverrideFunc func(string) (*simpleoidc.Tokens, error) } var _ OpenIdProvider = (*GithubOp)(nil) func NewGithubOpFromEnvironment() (*GithubOp, error) { tokenURL, err := getEnvVar("ACTIONS_ID_TOKEN_REQUEST_URL") if err != nil { return nil, err } token, err := getEnvVar("ACTIONS_ID_TOKEN_REQUEST_TOKEN") if err != nil { return nil, err } return NewGithubOp(tokenURL, token), nil } func NewGithubOp(tokenURL string, token string) *GithubOp { op := &GithubOp{ issuer: githubIssuer, rawTokenRequestURL: tokenURL, tokenRequestAuthToken: token, publicKeyFinder: *discover.DefaultPubkeyFinder(), requestTokensOverrideFunc: nil, } return op } func buildTokenURL(rawTokenURL, audience string) (string, error) { parsedURL, err := url.Parse(rawTokenURL) if err != nil { return "", fmt.Errorf("failed to parse URL: %w", err) } if audience == "" { return "", fmt.Errorf("audience is required") } query := parsedURL.Query() query.Set("audience", audience) parsedURL.RawQuery = query.Encode() return parsedURL.String(), nil } func (g *GithubOp) PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error) { return g.publicKeyFinder.ByToken(ctx, g.issuer, token) } func (g *GithubOp) PublicKeyByKeyId(ctx context.Context, keyID string) (*discover.PublicKeyRecord, error) { return g.publicKeyFinder.ByKeyID(ctx, g.issuer, keyID) } func (g *GithubOp) requestTokens(ctx context.Context, cicHash string) (*memguard.LockedBuffer, error) { if g.requestTokensOverrideFunc != nil { tokens, err := g.requestTokensOverrideFunc(cicHash) if err != nil { return nil, fmt.Errorf("error requesting ID Token: %w", err) } return memguard.NewBufferFromBytes(tokens.IDToken), nil } tokenURL, err := buildTokenURL(g.rawTokenRequestURL, cicHash) if err != nil { return nil, err } request, err := http.NewRequestWithContext(ctx, "GET", tokenURL, nil) if err != nil { return nil, err } request.Header.Set("Authorization", "Bearer "+g.tokenRequestAuthToken) var httpClient http.Client response, err := httpClient.Do(request) if err != nil { return nil, err } defer response.Body.Close() if response.StatusCode != http.StatusOK { return nil, fmt.Errorf("received non-200 from jwt api: %s", http.StatusText(response.StatusCode)) } rawBody, err := memguard.NewBufferFromEntireReader(response.Body) if err != nil { return nil, err } defer rawBody.Destroy() var jwt struct { Value json.RawMessage } err = json.Unmarshal(rawBody.Bytes(), &jwt) if err != nil { return nil, err } defer memguard.WipeBytes([]byte(jwt.Value)) // json.RawMessage leaves the " (quotes) on the string. We need to remove the quotes return memguard.NewBufferFromBytes(jwt.Value[1 : len(jwt.Value)-1]), nil } func (g *GithubOp) RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error) { // Define our commitment as the hash of the client instance claims commitment, err := cic.Hash() if err != nil { return nil, fmt.Errorf("error calculating client instance claim commitment: %w", err) } // Use the commitment nonce to complete the OIDC flow and get an ID token from the provider idTokenLB, err := g.requestTokens(ctx, string(commitment)) // idTokenLB is the ID Token in a memguard LockedBuffer, this is done // because the ID Token contains the OPs RSA signature which is a secret // in GQ signatures. For non-GQ signatures OPs RSA signature is considered // a public value. if err != nil { return nil, fmt.Errorf("error requesting ID Token: %w", err) } defer idTokenLB.Destroy() gqToken, err := CreateGQToken(ctx, idTokenLB.Bytes(), g) return &simpleoidc.Tokens{IDToken: gqToken}, err } func (g *GithubOp) Issuer() string { return g.issuer } func (g *GithubOp) VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error { vp := NewProviderVerifier(g.issuer, ProviderVerifierOpts{CommitType: CommitTypesEnum.AUD_CLAIM, GQOnly: true, SkipClientIDCheck: true}) return vp.VerifyIDToken(ctx, idt, cic) } openpubkey-0.8.0/providers/github_actions_test.go000066400000000000000000000216331477254274500223330ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "crypto/rand" "crypto/rsa" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/discover" "github.com/openpubkey/openpubkey/gq" "github.com/openpubkey/openpubkey/providers/mocks" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestGithubOpTableTest(t *testing.T) { issuer := githubIssuer providerOverride, err := mocks.NewMockProviderBackend(issuer, 2) require.NoError(t, err) op := &GithubOp{ issuer: githubIssuer, rawTokenRequestURL: "fakeTokenURL", tokenRequestAuthToken: "fakeToken", publicKeyFinder: providerOverride.PublicKeyFinder, requestTokensOverrideFunc: providerOverride.RequestTokensOverrideFunc, } cic := GenCIC(t) expSigningKey, expKeyID, expRecord := providerOverride.RandomSigningKey() idTokenTemplate := mocks.IDTokenTemplate{ CommitFunc: mocks.AddAudCommit, Issuer: issuer, Nonce: "empty", NoNonce: false, Aud: "empty", KeyID: expKeyID, NoKeyID: false, Alg: expRecord.Alg, NoAlg: false, ExtraClaims: map[string]any{"sha": "c7d5b5ff9b2130a53526dcc44a1f69ef0e50d003"}, SigningKey: expSigningKey, } providerOverride.SetIDTokenTemplate(&idTokenTemplate) tokens, err := op.RequestTokens(context.Background(), cic) require.NoError(t, err) idToken := tokens.IDToken require.NotNil(t, idToken) _, payloadB64, _, err := jws.SplitCompact(idToken) require.NoError(t, err) headers := extractHeaders(t, idToken) require.Equal(t, gq.GQ256, headers.Algorithm(), "github must only return GQ signed ID Tokens but we got (%s)", headers.Algorithm()) origHeadersB64, err := gq.OriginalJWTHeaders(idToken) require.NoError(t, err) origHeaders, err := util.Base64DecodeForJWT(origHeadersB64) require.NoError(t, err) require.Contains(t, string(origHeaders), "RS256") payload, err := util.Base64DecodeForJWT(payloadB64) require.NoError(t, err) payloadClaims := struct { Issuer string `json:"iss"` Subject string `json:"sub"` Audience string `json:"aud"` Nonce string `json:"nonce,omitempty"` }{} err = json.Unmarshal(payload, &payloadClaims) require.NoError(t, err) pkRecord, err := op.PublicKeyByToken(context.Background(), idToken) require.NoError(t, err) // Check that GQ Signature verifies rsaKey, ok := pkRecord.PublicKey.(*rsa.PublicKey) require.True(t, ok) ok, err = gq.GQ256VerifyJWT(rsaKey, idToken) require.NoError(t, err) require.True(t, ok) } // These two tests are regression tests for deserialization bug // that broke our ability to read the ID Token directly from the HTTP // response. To ensure we don't break this again this test stands up a server // so that all of the RequestToken code is tested. For all the other tests // we just use the standard override functions. func TestGithubOpSimpleRequest(t *testing.T) { expCicHash := "LJJfahE5cC1AgAWrMkUDL85d0oSSBcP6FJVSulzojds" // Setup expected test data expProtected := []byte(`{"alg":"RS256","kid":"1F2AB83404C08EC9EA0BB99DAED02186B091DBF4","typ":"JWT","x5t":"Hyq4NATAjsnqC7mdrtAhhrCR2_Q"}`) expPayload := []byte(`{"sub":"repo:example/fake:ref:refs/heads/main","aud":"LJJfahE5cC1AgAWrMkUDL85d0oSSBcP6FJVSulzojds","ref":"refs/heads/main","sha":\"353722c917a3f94988b826b82405ca05feddb1fe","repository":"example/fake","repository_owner":"fakeowner","iss":"https://token.actions.githubusercontent.com","nbf":1709839869,"exp":1709840769,"iat":1709840469}`) expSig := []byte(`fakesig`) expIdToken := string(util.Base64EncodeForJWT(expProtected)) + "." + string(util.Base64EncodeForJWT(expPayload)) + "." + string(util.Base64EncodeForJWT(expSig)) expResponseBody := fmt.Sprintf(`{"count":1857,"value":"%s"}`, expIdToken) testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { res.WriteHeader(http.StatusOK) _, err := res.Write([]byte(expResponseBody)) require.NoError(t, err) })) defer func() { testServer.Close() }() tokenRequestURL := testServer.URL authToken := "fakeAuthToken" op := NewGithubOp(tokenRequestURL, authToken) // Lowercase requestTokens just gets the ID Token (no GQ signing or modification) idTokenLB, err := op.requestTokens(context.TODO(), expCicHash) require.NoError(t, err) require.NotNil(t, idTokenLB) idToken := make([]byte, len(idTokenLB.Bytes())) copy(idToken, idTokenLB.Bytes()) headerB64, payloadB64, sigB64, err := jws.SplitCompact(idToken) require.NoError(t, err) header, err := util.Base64DecodeForJWT(headerB64) require.NoError(t, err) require.Equal(t, string(expProtected), string(header)) payload, err := util.Base64DecodeForJWT(payloadB64) require.NoError(t, err) require.Equal(t, string(expPayload), string(payload)) sig, err := util.Base64DecodeForJWT(sigB64) require.NoError(t, err) require.Equal(t, string(expSig), string(sig)) // Finally check the ID Token we get matches the ID Token we gave require.Equal(t, string(expIdToken), string(idToken)) } func TestGithubOpFullGQ(t *testing.T) { cic := GenCIC(t) // Setup expected test data expProtected := []byte(`{"alg":"RS256","kid":"1F2AB83404C08EC9EA0BB99DAED02186B091DBF4","typ":"JWT","x5t":"Hyq4NATAjsnqC7mdrtAhhrCR2_Q"}`) expPayload := []byte(`{"sub":"repo:example/fake:ref:refs/heads/main","aud":"LJJfahE5cC1AgAWrMkUDL85d0oSSBcP6FJVSulzojds","ref":"refs/heads/main","sha":\"353722c917a3f94988b826b82405ca05feddb1fe","repository":"example/fake","repository_owner":"fakeowner","iss":"https://token.actions.githubusercontent.com","nbf":1709839869,"exp":1709840769,"iat":1709840469}`) protected := jws.NewHeaders() err := json.Unmarshal(expProtected, protected) require.NoError(t, err) algOp := jwa.RS256 signingKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) expIdToken, err := jws.Sign( expPayload, jws.WithKey( algOp, signingKey, jws.WithProtectedHeaders(protected), ), ) require.NoError(t, err) expResponseBody := fmt.Sprintf(`{"count":1857,"value":"%s"}`, expIdToken) testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { res.WriteHeader(http.StatusOK) _, err := res.Write([]byte(expResponseBody)) require.NoError(t, err) })) defer func() { testServer.Close() }() tokenRequestURL := testServer.URL authToken := "fakeAuthToken" jwksFunc, err := discover.MockGetJwksByIssuerOneKey(signingKey.Public(), "1F2AB83404C08EC9EA0BB99DAED02186B091DBF4", string(algOp)) require.NoError(t, err) op := &GithubOp{ rawTokenRequestURL: tokenRequestURL, tokenRequestAuthToken: authToken, publicKeyFinder: discover.PublicKeyFinder{ JwksFunc: jwksFunc, }, } tokens, err := op.RequestTokens(context.Background(), cic) require.NoError(t, err) idToken := tokens.IDToken require.NotNil(t, idToken) _, payloadB64, _, err := jws.SplitCompact(idToken) require.NoError(t, err) headers := extractHeaders(t, idToken) require.Equal(t, gq.GQ256, headers.Algorithm(), "github must only return GQ signed ID Tokens but we got (%s)", headers.Algorithm()) origHeadersB64, err := gq.OriginalJWTHeaders(idToken) require.NoError(t, err) origHeaders, err := util.Base64DecodeForJWT(origHeadersB64) require.NoError(t, err) require.Equal(t, string(expProtected), string(origHeaders)) payload, err := util.Base64DecodeForJWT(payloadB64) require.NoError(t, err) require.Equal(t, string(expPayload), string(payload)) // Check that GQ Signature verifies rsaKey, ok := signingKey.Public().(*rsa.PublicKey) require.True(t, ok) ok, err = gq.GQ256VerifyJWT(rsaKey, idToken) require.NoError(t, err) require.True(t, ok) } // Simple test to ensure we don't accidentally break this simple function func TestBuildTokenURL(t *testing.T) { TokenRequestURL := "http://example.com/token-request" audience := "fakeAudience" tokenURL, err := buildTokenURL(TokenRequestURL, audience) require.NoError(t, err) require.Equal(t, "http://example.com/token-request?audience=fakeAudience", tokenURL) } func extractHeaders(t *testing.T, idToken []byte) jws.Headers { headersB64, _, _, err := jws.SplitCompact(idToken) require.NoError(t, err) headersJson, err := util.Base64DecodeForJWT(headersB64) require.NoError(t, err) headers := jws.NewHeaders() err = json.Unmarshal(headersJson, &headers) require.NoError(t, err) return headers } openpubkey-0.8.0/providers/gitlab.go000066400000000000000000000067541477254274500175430ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "net/http" "time" "github.com/openpubkey/openpubkey/discover" ) type GitlabOptions struct { // ClientID is the client ID of the OIDC application. It should be the // expected "aud" claim in received ID tokens from the OP. ClientID string // ClientSecret is the client secret of the OIDC application. Some OPs do // not require that this value is set. ClientSecret string // Issuer is the OP's issuer URI for performing OIDC authorization and // discovery. Issuer string // Scopes is the list of scopes to send to the OP in the initial // authorization request. Scopes []string // RedirectURIs is the list of authorized redirect URIs that can be // redirected to by the OP after the user completes the authorization code // flow exchange. Ensure that your OIDC application is configured to accept // these URIs otherwise an error may occur. RedirectURIs []string // GQSign denotes if the received ID token should be upgraded to a GQ token // using GQ signatures. GQSign bool // OpenBrowser denotes if the client's default browser should be opened // automatically when performing the OIDC authorization flow. This value // should typically be set to true, unless performing some headless // automation (e.g. integration tests) where you don't want the browser to // open. OpenBrowser bool // HttpClient is the http.Client to use when making queries to the OP (OIDC // code exchange, refresh, verification of ID token, fetch of JWKS endpoint, // etc.). If nil, then http.DefaultClient is used. HttpClient *http.Client // IssuedAtOffset configures the offset to add when validating the "iss" and // "exp" claims of received ID tokens from the OP. IssuedAtOffset time.Duration } func GetDefaultGitlabOpOptions() *GitlabOptions { return &GitlabOptions{ ClientID: "8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923", Issuer: gitlabIssuer, Scopes: []string{"openid email"}, RedirectURIs: []string{ "http://localhost:3000/login-callback", "http://localhost:10001/login-callback", "http://localhost:11110/login-callback", }, GQSign: false, OpenBrowser: true, HttpClient: nil, IssuedAtOffset: 1 * time.Minute, } } func NewGitlabOpWithOptions(opts *GitlabOptions) BrowserOpenIdProvider { return &StandardOp{ clientID: opts.ClientID, Scopes: opts.Scopes, RedirectURIs: opts.RedirectURIs, GQSign: opts.GQSign, OpenBrowser: opts.OpenBrowser, HttpClient: opts.HttpClient, IssuedAtOffset: opts.IssuedAtOffset, issuer: opts.Issuer, requestTokensOverrideFunc: nil, publicKeyFinder: discover.PublicKeyFinder{ JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) { return discover.GetJwksByIssuer(ctx, issuer, opts.HttpClient) }, }, } } openpubkey-0.8.0/providers/gitlab_ci.go000066400000000000000000000070451477254274500202100ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "fmt" "github.com/awnumar/memguard" "github.com/openpubkey/openpubkey/discover" simpleoidc "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken/clientinstance" ) const gitlabIssuer = "https://gitlab.com" type GitlabOp struct { issuer string // Change issuer to point this to a test issuer publicKeyFinder discover.PublicKeyFinder tokenEnvVar string requestTokensOverrideFunc func(string) (*simpleoidc.Tokens, error) } func NewGitlabOpFromEnvironmentDefault() *GitlabOp { return NewGitlabOpFromEnvironment("OPENPUBKEY_JWT") } func NewGitlabOpFromEnvironment(tokenEnvVar string) *GitlabOp { return NewGitlabOp(gitlabIssuer, tokenEnvVar) } func NewGitlabOp(issuer string, tokenEnvVar string) *GitlabOp { op := &GitlabOp{ issuer: issuer, publicKeyFinder: *discover.DefaultPubkeyFinder(), tokenEnvVar: tokenEnvVar, requestTokensOverrideFunc: nil, } return op } func (g *GitlabOp) PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error) { return g.publicKeyFinder.ByToken(ctx, g.issuer, token) } func (g *GitlabOp) PublicKeyByKeyId(ctx context.Context, keyID string) (*discover.PublicKeyRecord, error) { return g.publicKeyFinder.ByKeyID(ctx, g.issuer, keyID) } func (g *GitlabOp) RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error) { // Define our commitment as the hash of the client instance claims cicHash, err := cic.Hash() if err != nil { return nil, fmt.Errorf("error calculating client instance claim commitment: %w", err) } var idToken []byte if g.requestTokensOverrideFunc != nil { noCicHashInIDToken := "" if tokens, err := g.requestTokensOverrideFunc(noCicHashInIDToken); err != nil { return nil, fmt.Errorf("error requesting ID Token: %w", err) } else { idToken = tokens.IDToken } } else { idTokenStr, err := getEnvVar(g.tokenEnvVar) if err != nil { return nil, fmt.Errorf("error requesting ID Token: %w", err) } idToken = []byte(idTokenStr) } // idTokenLB is the ID Token in a memguard LockedBuffer, this is done // because the ID Token contains the OPs RSA signature which is a secret // in GQ signatures. For non-GQ signatures OPs RSA signature is considered // a public value. idTokenLB := memguard.NewBufferFromBytes([]byte(idToken)) defer idTokenLB.Destroy() gqToken, err := CreateGQBoundToken(ctx, idTokenLB.Bytes(), g, string(cicHash)) if err != nil { return nil, err } return &simpleoidc.Tokens{IDToken: []byte(gqToken)}, nil } func (g *GitlabOp) Issuer() string { return g.issuer } func (g *GitlabOp) VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error { vp := NewProviderVerifier(g.issuer, ProviderVerifierOpts{CommitType: CommitTypesEnum.GQ_BOUND, GQOnly: true, SkipClientIDCheck: true}, ) return vp.VerifyIDToken(ctx, idt, cic) } openpubkey-0.8.0/providers/gitlab_ci_test.go000066400000000000000000000046041477254274500212450ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "encoding/json" "testing" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/providers/mocks" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestGitlabSimpleRequest(t *testing.T) { issuer := gitlabIssuer providerOverride, err := mocks.NewMockProviderBackend(issuer, 2) require.NoError(t, err) op := &GitlabOp{ issuer: gitlabIssuer, publicKeyFinder: providerOverride.PublicKeyFinder, requestTokensOverrideFunc: providerOverride.RequestTokensOverrideFunc, } aud := AudPrefixForGQCommitment cic := GenCIC(t) expSigningKey, expKeyID, expRecord := providerOverride.RandomSigningKey() idTokenTemplate := mocks.IDTokenTemplate{ CommitFunc: mocks.NoClaimCommit, Issuer: issuer, Nonce: "empty", NoNonce: false, Aud: aud, KeyID: expKeyID, NoKeyID: false, Alg: expRecord.Alg, NoAlg: false, ExtraClaims: map[string]any{"sha": "c7d5b5ff9b2130a53526dcc44a1f69ef0e50d003"}, SigningKey: expSigningKey, } providerOverride.SetIDTokenTemplate(&idTokenTemplate) tokens, err := op.RequestTokens(context.Background(), cic) require.NoError(t, err) idToken := tokens.IDToken cicHash, err := cic.Hash() require.NoError(t, err) require.NotNil(t, cicHash) headerB64, _, _, err := jws.SplitCompact(idToken) require.NoError(t, err) headerJson, err := util.Base64DecodeForJWT(headerB64) require.NoError(t, err) headers := jws.NewHeaders() err = json.Unmarshal(headerJson, &headers) require.NoError(t, err) cicHash2, ok := headers.Get("cic") require.True(t, ok, "cic not found in GQ ID Token") require.Equal(t, string(cicHash), cicHash2, "cic hash in jwt header should match cic supplied") } openpubkey-0.8.0/providers/google.go000066400000000000000000000113451477254274500175450ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "net/http" "time" "github.com/openpubkey/openpubkey/discover" ) const googleIssuer = "https://accounts.google.com" // GoogleOptions is an options struct that configures how providers.GoogleOp // operates. See providers.GetDefaultGoogleOpOptions for the recommended default // values to use when interacting with Google as the OpenIdProvider. type GoogleOptions struct { // ClientID is the client ID of the OIDC application. It should be the // expected "aud" claim in received ID tokens from the OP. ClientID string // ClientSecret is the client secret of the OIDC application. Some OPs do // not require that this value is set. ClientSecret string // Issuer is the OP's issuer URI for performing OIDC authorization and // discovery. Issuer string // Scopes is the list of scopes to send to the OP in the initial // authorization request. Scopes []string // RedirectURIs is the list of authorized redirect URIs that can be // redirected to by the OP after the user completes the authorization code // flow exchange. Ensure that your OIDC application is configured to accept // these URIs otherwise an error may occur. RedirectURIs []string // GQSign denotes if the received ID token should be upgraded to a GQ token // using GQ signatures. GQSign bool // OpenBrowser denotes if the client's default browser should be opened // automatically when performing the OIDC authorization flow. This value // should typically be set to true, unless performing some headless // automation (e.g. integration tests) where you don't want the browser to // open. OpenBrowser bool // HttpClient is the http.Client to use when making queries to the OP (OIDC // code exchange, refresh, verification of ID token, fetch of JWKS endpoint, // etc.). If nil, then http.DefaultClient is used. HttpClient *http.Client // IssuedAtOffset configures the offset to add when validating the "iss" and // "exp" claims of received ID tokens from the OP. IssuedAtOffset time.Duration } func GetDefaultGoogleOpOptions() *GoogleOptions { return &GoogleOptions{ Issuer: googleIssuer, ClientID: "206584157355-7cbe4s640tvm7naoludob4ut1emii7sf.apps.googleusercontent.com", // The clientSecret was intentionally checked in. It holds no power. Do not report as a security issue // Google requires a ClientSecret even if this a public OIDC App ClientSecret: "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", // The client secret is a public value Scopes: []string{"openid profile email"}, RedirectURIs: []string{ "http://localhost:3000/login-callback", "http://localhost:10001/login-callback", "http://localhost:11110/login-callback", }, GQSign: false, OpenBrowser: true, HttpClient: nil, IssuedAtOffset: 1 * time.Minute, } } // NewGoogleOp creates a Google OP (OpenID Provider) using the // default configurations options. It uses the OIDC Relying Party (Client) // setup by the OpenPubkey project. func NewGoogleOp() BrowserOpenIdProvider { options := GetDefaultGoogleOpOptions() return NewGoogleOpWithOptions(options) } // NewGoogleOpWithOptions creates a Google OP with configuration specified // using an options struct. This is useful if you want to use your own OIDC // Client or override the configuration. func NewGoogleOpWithOptions(opts *GoogleOptions) BrowserOpenIdProvider { return &StandardOp{ clientID: opts.ClientID, clientSecret: opts.ClientSecret, Scopes: opts.Scopes, RedirectURIs: opts.RedirectURIs, GQSign: opts.GQSign, OpenBrowser: opts.OpenBrowser, HttpClient: opts.HttpClient, IssuedAtOffset: opts.IssuedAtOffset, issuer: opts.Issuer, requestTokensOverrideFunc: nil, publicKeyFinder: discover.PublicKeyFinder{ JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) { return discover.GetJwksByIssuer(ctx, issuer, opts.HttpClient) }, }, } } type GoogleOp = StandardOp var _ OpenIdProvider = (*GoogleOp)(nil) var _ BrowserOpenIdProvider = (*GoogleOp)(nil) var _ RefreshableOpenIdProvider = (*GoogleOp)(nil) openpubkey-0.8.0/providers/gq.go000066400000000000000000000065611477254274500167040ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "crypto" "crypto/rsa" "encoding/json" "fmt" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/gq" "github.com/openpubkey/openpubkey/util" ) func CreateGQToken(ctx context.Context, idToken []byte, op OpenIdProvider) ([]byte, error) { return createGQTokenAllParams(ctx, idToken, op, "", false) } func CreateGQBoundToken(ctx context.Context, idToken []byte, op OpenIdProvider, cicHash string) ([]byte, error) { return createGQTokenAllParams(ctx, idToken, op, cicHash, true) } func createGQTokenAllParams(ctx context.Context, idToken []byte, op OpenIdProvider, cicHash string, gqCommitment bool) ([]byte, error) { if cicHash != "" && !gqCommitment { // If gqCommitment is false, we will ignore the cicHash. This is a // misconfiguration, and we should fail because the caller is likely // expecting the cicHash to be included in the token. return nil, fmt.Errorf("misconfiguration, cicHash is set but gqCommitment is false, set gqCommitment to true to include cicHash in the gq signature") } headersB64, _, _, err := jws.SplitCompact(idToken) if err != nil { return nil, fmt.Errorf("error splitting compact ID Token: %w", err) } // TODO: We should create a util function for extracting headers from tokens headersJson, err := util.Base64DecodeForJWT(headersB64) if err != nil { return nil, fmt.Errorf("error base64 decoding ID Token headers: %w", err) } headers := jws.NewHeaders() err = json.Unmarshal(headersJson, &headers) if err != nil { return nil, fmt.Errorf("error unmarshalling ID Token headers: %w", err) } if headers.Algorithm() != "RS256" { return nil, fmt.Errorf("gq signatures require ID Token have signed with an RSA key, ID Token alg was (%s)", headers.Algorithm()) } opKey, err := op.PublicKeyByToken(ctx, idToken) if err != nil { return nil, err } if opKey.Alg != "RS256" { return nil, fmt.Errorf("gq signatures require original provider to have signed with an RSA key, jWK.alg was (%s)", opKey.Alg) } rsaKey, ok := opKey.PublicKey.(*rsa.PublicKey) if !ok { return nil, fmt.Errorf("gq signatures require original provider to have signed with an RSA key") } jktB64, err := createJkt(rsaKey) if err != nil { return nil, err } if cicHash == "" { return gq.GQ256SignJWT(rsaKey, idToken, gq.WithExtraClaim("jkt", jktB64)) } else { return gq.GQ256SignJWT(rsaKey, idToken, gq.WithExtraClaim("jkt", jktB64), gq.WithExtraClaim("cic", cicHash)) } } func createJkt(publicKey crypto.PublicKey) (string, error) { jwkKey, err := jwk.PublicKeyOf(publicKey) if err != nil { return "", err } thumbprint, err := jwkKey.Thumbprint(crypto.SHA256) if err != nil { return "", err } return string(util.Base64EncodeForJWT(thumbprint)), nil } openpubkey-0.8.0/providers/gq_test.go000066400000000000000000000073141477254274500177400ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "crypto/rsa" "testing" "github.com/openpubkey/openpubkey/gq" "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" "golang.org/x/exp/maps" ) func TestGQ(t *testing.T) { testCases := []struct { name string tokenCommitType CommitType gqCommitment bool cicHash string wrongAlg bool wrongKid bool expError string }{ {name: "happy case (nonce commit)", tokenCommitType: CommitTypesEnum.NONCE_CLAIM, }, {name: "happy case (GQ bound)", tokenCommitType: CommitTypesEnum.GQ_BOUND, gqCommitment: true, cicHash: "fake-cic-hash", }, {name: "change alg to ES256, should fail", tokenCommitType: CommitTypesEnum.GQ_BOUND, wrongAlg: true, expError: "gq signatures require ID Token have signed with an RSA key, ID Token alg was (EC256)", }, {name: "ID Token has kid that exist in OP's JWKS", tokenCommitType: CommitTypesEnum.GQ_BOUND, wrongKid: true, expError: "no matching public key found for kid wrong-kid", }, {name: "cicHash set but not GQ bound", tokenCommitType: CommitTypesEnum.GQ_BOUND, gqCommitment: false, cicHash: "fake-cic-hash", expError: "misconfiguration, cicHash is set but gqCommitment is false", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { providerOpts := DefaultMockProviderOpts() providerOpts.NumKeys = 1 // Only use 1 public key so we can find the public key used providerOpts.CommitType = tc.tokenCommitType op, backend, idtTemplate, err := NewMockProvider(providerOpts) require.NoError(t, err) expPublicKey := maps.Values(backend.GetProviderPublicKeySet())[0].PublicKey tokens, err := idtTemplate.IssueToken() require.NoError(t, err) idToken := tokens.IDToken if tc.wrongAlg { protected := util.Base64EncodeForJWT([]byte(`{"alg": "EC256","kid": "kid-0","typ": "JWT"}`)) idToken = []byte(string(protected) + ".e30.ZmFrZXNpZw") } if tc.wrongKid { protected := util.Base64EncodeForJWT([]byte(`{"alg": "RS256","kid": "wrong-kid","typ": "JWT"}`)) idToken = []byte(string(protected) + ".e30.ZmFrZXNpZw") } gqToken, err := createGQTokenAllParams(context.Background(), idToken, op, tc.cicHash, tc.gqCommitment) if tc.expError != "" { require.ErrorContains(t, err, tc.expError) } else { require.NoError(t, err) require.NotNil(t, gqToken) // Check that GQ Signature verifies verifies, err := gq.GQ256VerifyJWT(expPublicKey.(*rsa.PublicKey), gqToken) require.NoError(t, err) require.True(t, verifies) jwt, err := oidc.NewJwt(gqToken) require.NoError(t, err) typ, err := jwt.GetSignature().GetTyp() require.NoError(t, err) require.Equal(t, "JWT", typ) jktFound := jwt.GetSignature().GetProtectedClaims().Jkt require.NotEmpty(t, jktFound) expJkt, err := createJkt(expPublicKey) require.NoError(t, err) require.Equal(t, expJkt, jktFound, "JKT in GQ ID Token does not match expected JKT") } }) } } openpubkey-0.8.0/providers/mockprovider.go000066400000000000000000000166341477254274500210030ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "fmt" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/discover" simpleoidc "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken/clientinstance" "github.com/openpubkey/openpubkey/providers/mocks" ) const mockProviderIssuer = "https://accounts.example.com" var _ OpenIdProvider = (*MockProvider)(nil) type MockProviderOpts struct { Issuer string ClientID string GQSign bool NumKeys int CommitType CommitType // We keep VerifierOpts as a variable separate to let us test failures // where the mock op does something which causes a verification failure VerifierOpts ProviderVerifierOpts } func DefaultMockProviderOpts() MockProviderOpts { clientID := "test_client_id" return MockProviderOpts{ Issuer: "https://accounts.example.com", ClientID: clientID, GQSign: false, NumKeys: 2, CommitType: CommitTypesEnum.NONCE_CLAIM, VerifierOpts: ProviderVerifierOpts{ CommitType: CommitTypesEnum.NONCE_CLAIM, ClientID: clientID, SkipClientIDCheck: false, GQOnly: false, }, } } type MockProvider struct { options MockProviderOpts issuer string clientID string publicKeyFinder discover.PublicKeyFinder requestTokensOverrideFunc func(string) (*simpleoidc.Tokens, error) } // NewMockProvider creates a new mock provider with a random signing key and a random key ID. It returns the provider, // the mock backend, and the ID token template. Tests can use the mock backend to look up keys issued by the mock provider. // Tests can use the ID token template to create ID tokens and test the provider's behavior when verifying incorrectly set ID Tokens. func NewMockProvider(opts MockProviderOpts) (*MockProvider, *mocks.MockProviderBackend, *mocks.IDTokenTemplate, error) { if opts.Issuer == "" { opts.Issuer = mockProviderIssuer } mockBackend, err := mocks.NewMockProviderBackend(opts.Issuer, opts.NumKeys) if err != nil { return nil, nil, nil, err } provider := &MockProvider{ options: opts, issuer: mockBackend.Issuer, clientID: opts.ClientID, requestTokensOverrideFunc: mockBackend.RequestTokensOverrideFunc, publicKeyFinder: mockBackend.PublicKeyFinder, } providerSigner, keyID, record := mockBackend.RandomSigningKey() commitmentFunc := mocks.NoClaimCommit if opts.CommitType.Claim == "nonce" { commitmentFunc = mocks.AddNonceCommit } else if opts.CommitType.Claim == "aud" { commitmentFunc = mocks.AddAudCommit } idTokenTemplate := &mocks.IDTokenTemplate{ CommitFunc: commitmentFunc, Issuer: provider.Issuer(), Nonce: "empty", NoNonce: false, Aud: opts.ClientID, KeyID: keyID, NoKeyID: false, Alg: record.Alg, NoAlg: false, SigningKey: providerSigner, } if opts.CommitType.GQCommitment { idTokenTemplate.Aud = AudPrefixForGQCommitment } mockBackend.SetIDTokenTemplate(idTokenTemplate) return provider, mockBackend, idTokenTemplate, nil } func (m *MockProvider) requestTokens(_ context.Context, cicHash string) (*simpleoidc.Tokens, error) { return m.requestTokensOverrideFunc(cicHash) } func (m *MockProvider) RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error) { if m.options.CommitType.GQCommitment && !m.options.GQSign { // Catch misconfigurations in tests return nil, fmt.Errorf("if GQCommitment is true then GQSign must also be true") } // Define our commitment as the hash of the client instance claims cicHash, err := cic.Hash() if err != nil { return nil, fmt.Errorf("error calculating client instance claim commitment: %w", err) } tokens, err := m.requestTokens(ctx, string(cicHash)) if err != nil { return nil, err } if m.options.CommitType.GQCommitment { if tokens.IDToken, err = CreateGQBoundToken(ctx, tokens.IDToken, m, string(cicHash)); err != nil { return nil, err } } else if m.options.GQSign { if tokens.IDToken, err = CreateGQToken(ctx, tokens.IDToken, m); err != nil { return nil, err } } return tokens, nil } func (m *MockProvider) RefreshTokens(ctx context.Context, _ []byte) (*simpleoidc.Tokens, error) { tokens, err := m.requestTokensOverrideFunc("") if err != nil { return nil, err } return tokens, nil } func (m *MockProvider) PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error) { return m.publicKeyFinder.ByToken(ctx, m.issuer, token) } func (m *MockProvider) PublicKeyByKeyId(ctx context.Context, keyID string) (*discover.PublicKeyRecord, error) { return m.publicKeyFinder.ByKeyID(ctx, m.issuer, keyID) } func (m *MockProvider) Issuer() string { return m.issuer } func (m *MockProvider) ClientID() string { return m.clientID } func (m *MockProvider) VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error { m.options.VerifierOpts.DiscoverPublicKey = &m.publicKeyFinder //TODO: this should be set in the constructor once we have constructors for each OP return NewProviderVerifier(m.Issuer(), m.options.VerifierOpts).VerifyIDToken(ctx, idt, cic) } func (m *MockProvider) VerifyRefreshedIDToken(ctx context.Context, origIdt []byte, reIdt []byte) error { if err := simpleoidc.SameIdentity(origIdt, reIdt); err != nil { return fmt.Errorf("refreshed ID Token is for different subject than original ID Token: %w", err) } if err := simpleoidc.RequireOlder(origIdt, reIdt); err != nil { return fmt.Errorf("refreshed ID Token should not be issued before original ID Token: %w", err) } pkr, err := m.publicKeyFinder.ByToken(ctx, m.Issuer(), reIdt) if err != nil { return err } alg := jwa.SignatureAlgorithm(pkr.Alg) if _, err := jws.Verify(reIdt, jws.WithKey(alg, pkr.PublicKey)); err != nil { return err } return nil } // Mock provider that does not support refresh type NonRefreshableOp struct { op *MockProvider } func NewNonRefreshableOp(op *MockProvider) *NonRefreshableOp { return &NonRefreshableOp{op: op} } func (nro *NonRefreshableOp) RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error) { tokens, err := nro.op.RequestTokens(ctx, cic) return &simpleoidc.Tokens{IDToken: tokens.IDToken}, err } func (nro *NonRefreshableOp) PublicKeyByKeyId(ctx context.Context, keyID string) (*discover.PublicKeyRecord, error) { return nro.op.PublicKeyByKeyId(ctx, keyID) } func (nro *NonRefreshableOp) PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error) { return nro.op.PublicKeyByToken(ctx, token) } func (nro *NonRefreshableOp) Issuer() string { return nro.op.Issuer() } func (nro *NonRefreshableOp) VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error { return nro.op.VerifyIDToken(ctx, idt, cic) } openpubkey-0.8.0/providers/mockprovider_test.go000066400000000000000000000043421477254274500220330ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "crypto/rsa" "encoding/json" "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestMockProviderTest(t *testing.T) { providerOpts := DefaultMockProviderOpts() provider, _, idtTemplate, err := NewMockProvider(providerOpts) require.NoError(t, err) idtTemplate.ExtraClaims = map[string]interface{}{"sha": "c7d5b5ff9b2130a53526dcc44a1f69ef0e50d003"} cic := GenCIC(t) tokens, err := provider.RequestTokens(context.TODO(), cic) require.NoError(t, err) idToken := tokens.IDToken idt, err := oidc.NewJwt(idToken) require.NoError(t, err) require.Equal(t, idtTemplate.Issuer, idt.GetClaims().Issuer) require.Equal(t, "mock-refresh-token", string(tokens.RefreshToken)) require.Equal(t, "mock-access-token", string(tokens.AccessToken)) _, payloadB64, _, err := jws.SplitCompact(idToken) require.NoError(t, err) payload, err := util.Base64DecodeForJWT(payloadB64) require.NoError(t, err) payloadClaims := struct { Issuer string `json:"iss"` Subject string `json:"sub"` Audience string `json:"aud"` Nonce string `json:"nonce,omitempty"` }{} err = json.Unmarshal(payload, &payloadClaims) require.NoError(t, err) pkRecord, err := provider.PublicKeyByToken(context.Background(), idToken) require.NoError(t, err) // Check that GQ Signature verifies rsaKey, ok := pkRecord.PublicKey.(*rsa.PublicKey) require.True(t, ok) _, err = jws.Verify(idToken, jws.WithKey(jwa.RS256, rsaKey)) require.NoError(t, err) } openpubkey-0.8.0/providers/mocks/000077500000000000000000000000001477254274500170525ustar00rootroot00000000000000openpubkey-0.8.0/providers/mocks/backend.go000066400000000000000000000107021477254274500207700ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package mocks import ( "context" "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/rsa" "encoding/json" "fmt" mathrand "math/rand" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/openpubkey/openpubkey/discover" "github.com/openpubkey/openpubkey/oidc" "golang.org/x/exp/maps" ) type MockProviderBackend struct { Issuer string PublicKeyFinder discover.PublicKeyFinder ProviderSigningKeySet map[string]crypto.Signer // kid (keyId) -> signing key ProviderPublicKeySet map[string]discover.PublicKeyRecord // kid (keyId) -> PublicKeyRecord IDTokensTemplate *IDTokenTemplate } func NewMockProviderBackend(issuer string, numKeys int) (*MockProviderBackend, error) { providerSigningKeySet, providerPublicKeySet, err := CreateRS256KeySet(issuer, numKeys) if err != nil { return nil, err } return &MockProviderBackend{ Issuer: issuer, PublicKeyFinder: discover.PublicKeyFinder{ JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) { keySet := jwk.NewSet() for kid, record := range providerPublicKeySet { jwkKey, err := jwk.PublicKeyOf(record.PublicKey) if err != nil { return nil, err } if err := jwkKey.Set(jwk.AlgorithmKey, record.Alg); err != nil { return nil, err } if err := jwkKey.Set(jwk.KeyIDKey, kid); err != nil { return nil, err } // Put our jwk into a set if err := keySet.AddKey(jwkKey); err != nil { return nil, err } } return json.MarshalIndent(keySet, "", " ") }, }, ProviderSigningKeySet: providerSigningKeySet, ProviderPublicKeySet: providerPublicKeySet, }, nil } func (o *MockProviderBackend) GetPublicKeyFinder() *discover.PublicKeyFinder { return &o.PublicKeyFinder } func (o *MockProviderBackend) GetProviderPublicKeySet() map[string]discover.PublicKeyRecord { return o.ProviderPublicKeySet } func (o *MockProviderBackend) GetProviderSigningKeySet() map[string]crypto.Signer { return o.ProviderSigningKeySet } func (o *MockProviderBackend) SetIDTokenTemplate(template *IDTokenTemplate) { o.IDTokensTemplate = template } func (o *MockProviderBackend) RequestTokensOverrideFunc(cicHash string) (*oidc.Tokens, error) { o.IDTokensTemplate.AddCommit(cicHash) return o.IDTokensTemplate.IssueToken() } func (o *MockProviderBackend) RandomSigningKey() (crypto.Signer, string, discover.PublicKeyRecord) { keyIDs := maps.Keys(o.GetProviderPublicKeySet()) keyID := keyIDs[mathrand.Intn(len(keyIDs))] return o.GetProviderSigningKeySet()[keyID], keyID, o.GetProviderPublicKeySet()[keyID] } func CreateRS256KeySet(issuer string, numKeys int) (map[string]crypto.Signer, map[string]discover.PublicKeyRecord, error) { return CreateKeySet(issuer, "RS256", numKeys) } func CreateES256KeySet(issuer string, numKeys int) (map[string]crypto.Signer, map[string]discover.PublicKeyRecord, error) { return CreateKeySet(issuer, "ES256", numKeys) } func CreateKeySet(issuer string, alg string, numKeys int) (map[string]crypto.Signer, map[string]discover.PublicKeyRecord, error) { providerSigningKeySet := map[string]crypto.Signer{} providerPublicKeySet := map[string]discover.PublicKeyRecord{} for i := 0; i < numKeys; i++ { kid := fmt.Sprintf("kid-%d", i) var signingKey crypto.Signer var err error switch alg { case "ES256": if signingKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader); err != nil { return nil, nil, err } case "RS256": if signingKey, err = rsa.GenerateKey(rand.Reader, 2048); err != nil { return nil, nil, err } default: return nil, nil, fmt.Errorf("unsupported alg: %s", alg) } providerSigningKeySet[string(kid)] = signingKey providerPublicKeySet[string(kid)] = discover.PublicKeyRecord{ PublicKey: signingKey.Public(), Alg: alg, Issuer: issuer, } } return providerSigningKeySet, providerPublicKeySet, nil } openpubkey-0.8.0/providers/mocks/backend_test.go000066400000000000000000000103211477254274500220240ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package mocks import ( "context" "crypto" "fmt" "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/discover" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" "golang.org/x/exp/maps" ) func TestSimpleBackendOverride(t *testing.T) { issuer := "https://accounts.example.com/" mockBackend, err := NewMockProviderBackend(issuer, 3) require.NoError(t, err) expSigningKey, expKeyID, expRecord := mockBackend.RandomSigningKey() idTokenTemplate := IDTokenTemplate{ CommitFunc: AddAudCommit, Issuer: issuer, Nonce: "empty", NoNonce: false, Aud: "also me", KeyID: expKeyID, NoKeyID: false, Alg: expRecord.Alg, NoAlg: false, ExtraClaims: map[string]any{"extraClaim": "extraClaimValue"}, ExtraProtectedClaims: map[string]any{"extraHeader": "extraheaderValue"}, SigningKey: expSigningKey, } mockBackend.SetIDTokenTemplate(&idTokenTemplate) cicHash := util.Base64EncodeForJWT([]byte("0123456789ABCDEF0123456789ABCDEF")) tokens, err := mockBackend.RequestTokensOverrideFunc(string(cicHash)) idt := tokens.IDToken require.NoError(t, err) require.NotNil(t, idt) record, err := mockBackend.GetPublicKeyFinder().ByToken(context.Background(), issuer, idt) require.NoError(t, err) payload, err := jws.Verify(idt, jws.WithKey(jwa.KeyAlgorithmFrom(record.Alg), record.PublicKey)) require.NoError(t, err) require.Contains(t, string(payload), string(cicHash)) } func TestKeySetCreatorsConvenience(t *testing.T) { issuer := "https://accounts.example.com/" skSet, recordSet, err := CreateRS256KeySet(issuer, 2) require.NoError(t, err) CheckKeySets(t, "Happy case: CreateRS256KeySet", issuer, "RS256", skSet, recordSet) skSet, recordSet, err = CreateES256KeySet(issuer, 2) require.NoError(t, err) CheckKeySets(t, "Happy case: CreateES256KeySet", issuer, "ES256", skSet, recordSet) } func TestKeySetCreators(t *testing.T) { issuerA := "https://accounts.example.com/" issuerB := "https://diff-accounts.example.com/" for numKeys := 1; numKeys < 3; numKeys++ { testCases := []struct { name string issuer string alg string expError string }{ {name: fmt.Sprintf("Happy case (RS256): %d key(s)", numKeys), issuer: issuerA, alg: "RS256", expError: "", }, {name: fmt.Sprintf("Happy case (ES256): %d key(s)", numKeys), issuer: issuerB, alg: "ES256", expError: "", }, {name: fmt.Sprintf("Unsupported alg (ZZ404): %d key(s)", numKeys), issuer: issuerB, alg: "ZZ404", expError: "unsupported alg", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // skSet - set of signingKeys, recordSet - set of publicKeyRecords skSet, recordSet, err := CreateKeySet(tc.issuer, tc.alg, numKeys) if tc.expError != "" { require.ErrorContains(t, err, tc.expError, tc.name) } else { require.NoError(t, err, tc.name) } CheckKeySets(t, tc.name, tc.issuer, tc.alg, skSet, recordSet) }) } } } func CheckKeySets(t *testing.T, name string, issuer string, alg string, skSet map[string]crypto.Signer, recordSet map[string]discover.PublicKeyRecord) { require.ElementsMatch(t, maps.Keys(skSet), maps.Keys(recordSet)) for kid, signer := range skSet { record := recordSet[kid] require.NotNil(t, signer, name) require.NotNil(t, record, name) require.Equal(t, signer.Public(), record.PublicKey, name) require.Equal(t, issuer, record.Issuer, name) require.Equal(t, alg, record.Alg, name) } } openpubkey-0.8.0/providers/mocks/idtoken.go000066400000000000000000000067251477254274500210500ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package mocks import ( "crypto" "encoding/json" "time" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/oidc" ) type CommitmentType struct { ClaimCommitment bool ClaimName string } type IDTokenTemplate struct { CommitFunc func(*IDTokenTemplate, string) Issuer string Nonce string NoNonce bool Aud string KeyID string NoKeyID bool Alg string NoAlg bool // Even if NOAlg is true, we still need Alg to be set to generate the signature ExtraClaims map[string]any ExtraProtectedClaims map[string]any SigningKey crypto.Signer // The key we will use to sign the ID Token } func DefaultIDTokenTemplate() IDTokenTemplate { return IDTokenTemplate{ CommitFunc: AddAudCommit, Issuer: "mockIssuer", Nonce: "empty", NoNonce: false, Aud: "empty", KeyID: "mockKeyID", NoKeyID: false, Alg: "RS256", NoAlg: false, } } // AddCommit adds the commitment to the CIC to the ID Token. The // CommitmentFunc is specified allowing custom commitment functions to be specified func (t *IDTokenTemplate) AddCommit(cicHash string) { t.CommitFunc(t, cicHash) } // TODO: Rename to IssueTokens func (t *IDTokenTemplate) IssueToken() (*oidc.Tokens, error) { headers := jws.NewHeaders() if !t.NoAlg { if err := headers.Set(jws.AlgorithmKey, t.Alg); err != nil { return nil, err } } if !t.NoKeyID { if err := headers.Set(jws.KeyIDKey, t.KeyID); err != nil { return nil, err } } if err := headers.Set(jws.TypeKey, "JWT"); err != nil { return nil, err } if t.ExtraProtectedClaims != nil { for k, v := range t.ExtraProtectedClaims { if err := headers.Set(k, v); err != nil { return nil, err } } } payloadMap := map[string]any{ "sub": "me", "aud": t.Aud, "iss": t.Issuer, "iat": time.Now().Unix(), "exp": time.Now().Add(2 * time.Hour).Unix(), } if !t.NoNonce { payloadMap["nonce"] = t.Nonce } if t.ExtraClaims != nil { for k, v := range t.ExtraClaims { payloadMap[k] = v } } payloadBytes, err := json.Marshal(payloadMap) if err != nil { return nil, err } idToken, err := jws.Sign( payloadBytes, jws.WithKey( jwa.KeyAlgorithmFrom(t.Alg), t.SigningKey, jws.WithProtectedHeaders(headers), ), ) if err != nil { return nil, err } return &oidc.Tokens{ IDToken: idToken, RefreshToken: []byte("mock-refresh-token"), AccessToken: []byte("mock-access-token")}, nil } func AddNonceCommit(idtTemp *IDTokenTemplate, cicHash string) { idtTemp.Nonce = cicHash idtTemp.NoNonce = false } func AddAudCommit(idtTemp *IDTokenTemplate, cicHash string) { idtTemp.Aud = cicHash } func NoClaimCommit(idtTemp *IDTokenTemplate, cicHash string) { // Do nothing } openpubkey-0.8.0/providers/op.go000066400000000000000000000051771477254274500167150ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "fmt" "net/http" "os" "github.com/openpubkey/openpubkey/discover" simpleoidc "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken/clientinstance" ) // Interface for interacting with the OP (OpenID Provider) that only returns // an ID Token type OpenIdProvider interface { RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error) PublicKeyByKeyId(ctx context.Context, keyID string) (*discover.PublicKeyRecord, error) PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error) // Returns the OpenID provider issuer as seen in ID token e.g. "https://accounts.google.com" Issuer() string VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error } type BrowserOpenIdProvider interface { OpenIdProvider ClientID() string HookHTTPSession(h http.HandlerFunc) RefreshTokens(ctx context.Context, refreshToken []byte) (*simpleoidc.Tokens, error) VerifyRefreshedIDToken(ctx context.Context, origIdt []byte, reIdt []byte) error ReuseBrowserWindowHook(chan string) } // Interface for an OpenIdProvider that returns an ID Token, Refresh Token and Access Token type RefreshableOpenIdProvider interface { OpenIdProvider RefreshTokens(ctx context.Context, refreshToken []byte) (*simpleoidc.Tokens, error) VerifyRefreshedIDToken(ctx context.Context, origIdt []byte, reIdt []byte) error } type CommitType struct { Claim string GQCommitment bool } var CommitTypesEnum = struct { NONCE_CLAIM CommitType AUD_CLAIM CommitType GQ_BOUND CommitType }{ NONCE_CLAIM: CommitType{Claim: "nonce", GQCommitment: false}, AUD_CLAIM: CommitType{Claim: "aud", GQCommitment: false}, GQ_BOUND: CommitType{Claim: "", GQCommitment: true}, // The commitmentClaim is bound to the ID Token using only the GQ signature } func getEnvVar(name string) (string, error) { value, ok := os.LookupEnv(name) if !ok { return "", fmt.Errorf("%q environment variable not set", name) } return value, nil } openpubkey-0.8.0/providers/op_test.go000066400000000000000000000006011477254274500177370ustar00rootroot00000000000000package providers import ( "os" "testing" "github.com/stretchr/testify/require" ) func TestGetEnvVar(t *testing.T) { originalEnv := os.Getenv("TEST_ENV_VAR") os.Setenv("TEST_ENV_VAR", "test value") defer os.Setenv("TEST_ENV_VAR", originalEnv) _, err := getEnvVar("TEST_ENV_VAR") require.NoError(t, err) _, err = getEnvVar("NON_EXISTENT_ENV_VAR") require.Error(t, err) } openpubkey-0.8.0/providers/providerverifier.go000066400000000000000000000230411477254274500216530ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "crypto/rsa" "encoding/json" "fmt" "strings" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/discover" "github.com/openpubkey/openpubkey/gq" "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken/clientinstance" "github.com/openpubkey/openpubkey/util" ) const AudPrefixForGQCommitment = "OPENPUBKEY-PKTOKEN:" type DefaultProviderVerifier struct { issuer string commitType CommitType options ProviderVerifierOpts } type ProviderVerifierOpts struct { // If ClientID is specified, then verification will require that the ClientID // be present in the audience ("aud") claim of the PK token payload ClientID string // Describes the place where the cicHash is committed to in the the ID token. // For instance the nonce payload claim name where the cicHash was stored during issuance CommitType CommitType // Specifies whether to skip the Client ID check, defaults to false SkipClientIDCheck bool // Custom function for discovering public key of Provider DiscoverPublicKey *discover.PublicKeyFinder // Only allows GQ signatures, a provider signature under any other algorithm // is seen as an error GQOnly bool } // Creates a new ProviderVerifier with required fields // // issuer: Is the OpenID provider issuer as seen in ID token e.g. "https://accounts.google.com" // commitmentClaim: the ID token payload claim name where the cicHash was stored during issuance func NewProviderVerifier(issuer string, options ProviderVerifierOpts) *DefaultProviderVerifier { v := &DefaultProviderVerifier{ issuer: issuer, commitType: options.CommitType, options: options, } // If no custom DiscoverPublicKey function is set, set default if v.options.DiscoverPublicKey == nil { v.options.DiscoverPublicKey = discover.DefaultPubkeyFinder() } return v } func (v *DefaultProviderVerifier) Issuer() string { return v.issuer } func (v *DefaultProviderVerifier) VerifyIDToken(ctx context.Context, idToken []byte, cic *clientinstance.Claims) error { // Sanity check that if GQCommitment is enabled then the other options // are set correctly for doing GQ commitment verification. The intention is // to catch misconfigurations early and provide meaningful error messages. if v.options.CommitType.GQCommitment { if !v.options.GQOnly { return fmt.Errorf("GQCommitment requires that GQOnly is true, but GQOnly is (%t)", v.options.GQOnly) } if v.commitType.Claim != "" { return fmt.Errorf("GQCommitment requires that commitmentClaim is empty but commitmentClaim is (%s)", v.commitType.Claim) } if !v.options.SkipClientIDCheck { // When we bind the commitment to the ID Token using GQ Signatures, // We require that the audience is prefixed with // "OPENPUBKEY-PKTOKEN:". Thus, the audience can't be the client-id // If you are hitting this error of set SkipClientIDCheck to true return fmt.Errorf("GQCommitment requires that audience (aud) is not set to client-id") } } idt, err := oidc.NewJwt(idToken) if err != nil { return err } // Check whether Audience claim matches provided Client ID // No error is thrown if option is set to skip client ID check if err := verifyAudience(idt, v.options.ClientID); err != nil && !v.options.SkipClientIDCheck { return err } algStr := idt.GetSignature().GetProtectedClaims().Alg if algStr == "" { return fmt.Errorf("provider algorithm type missing") } alg := jwa.SignatureAlgorithm(algStr) if alg != gq.GQ256 && v.options.GQOnly { return fmt.Errorf("non-GQ signatures are not supported") } switch alg { case gq.GQ256: if err := v.verifyGQSig(ctx, idt); err != nil { return fmt.Errorf("error verifying OP GQ signature on PK Token: %w", err) } case jwa.RS256: pubKeyRecord, err := v.providerPublicKey(ctx, idToken) if err != nil { return fmt.Errorf("failed to get OP public key: %w", err) } // Ensure that the algorithm of public key from OpenID Provider matches the algorithm specified in the ID Token _, ok := pubKeyRecord.PublicKey.(*rsa.PublicKey) if !ok { return fmt.Errorf("public key is not an RSA public key") } if _, err := jws.Verify(idToken, jws.WithKey(alg, pubKeyRecord.PublicKey)); err != nil { return err } } if err := v.verifyCommitment(idt, cic); err != nil { return err } return nil } // This function takes in an OIDC Provider created ID token or GQ-signed modification of one and returns // the associated public key func (v *DefaultProviderVerifier) providerPublicKey(ctx context.Context, idToken []byte) (*discover.PublicKeyRecord, error) { return v.options.DiscoverPublicKey.ByToken(ctx, v.Issuer(), idToken) } func (v *DefaultProviderVerifier) verifyCommitment(idt *oidc.Jwt, cic *clientinstance.Claims) error { var claims map[string]any payload, err := util.Base64DecodeForJWT([]byte(idt.GetPayload())) if err != nil { return err } if err := json.Unmarshal(payload, &claims); err != nil { return err } expectedCommitment, err := cic.Hash() if err != nil { return err } var commitment any var commitmentFound bool if v.options.CommitType.GQCommitment { aud, ok := claims["aud"] if !ok { return fmt.Errorf("require audience claim prefix missing in PK Token's GQCommitment") } // To prevent attacks where a attacker takes someone else's ID Token // and turns it into a PK Token using a GQCommitment, we require that // all GQ commitments explicitly signal they want to be used as // PK Tokens. To signal this, they prefix the audience (aud) // claim with the string "OPENPUBKEY-PKTOKEN:". // We reject all GQ commitment PK Tokens that don't have this prefix // in the aud claim. if _, ok := strings.CutPrefix(aud.(string), AudPrefixForGQCommitment); !ok { return fmt.Errorf("audience claim in PK Token's GQCommitment must be prefixed by (%s), got (%s) instead", AudPrefixForGQCommitment, aud.(string)) } // Get the commitment from the GQ signed protected header claim "cic" in the ID Token commitment = idt.GetSignature().GetProtectedClaims().CIC if commitment == "" { return fmt.Errorf("missing GQ commitment") } } else { if v.commitType.Claim == "" { return fmt.Errorf("verifier configured with empty commitment claim") } commitment, commitmentFound = claims[v.commitType.Claim] if !commitmentFound { return fmt.Errorf("missing commitment claim %s", v.commitType.Claim) } } if commitment != string(expectedCommitment) { return fmt.Errorf("commitment claim doesn't match, got %q, expected %s", commitment, string(expectedCommitment)) } return nil } // verifyGQSig verifies the signature of a PK token with a GQ signature. The // parameter issuer should be the issuer of the ProviderVerifier not the // issuer of the PK Token func (v *DefaultProviderVerifier) verifyGQSig(ctx context.Context, idt *oidc.Jwt) error { algStr := idt.GetSignature().GetProtectedClaims().Alg if algStr == "" { return fmt.Errorf("missing provider algorithm header") } if algStr != gq.GQ256.String() { return fmt.Errorf("signature is not of type GQ") } origHeaders, err := originalTokenHeaders(idt.GetRaw()) if err != nil { return fmt.Errorf("malformed ID Token headers: %w", err) } origAlg := origHeaders.Algorithm() if origAlg != jwa.RS256 { return fmt.Errorf("expected original headers to contain RS256 alg, got %s", origAlg) } if idt.GetClaims().Issuer == "" { return fmt.Errorf("missing issuer in payload: %s", idt.GetPayload()) } if idt.GetClaims().Issuer != v.issuer { return fmt.Errorf("issuer of ID Token (%s) doesn't match expected issuer (%s)", idt.GetClaims().Issuer, v.issuer) } publicKeyRecord, err := v.options.DiscoverPublicKey.ByToken(ctx, v.Issuer(), idt.GetRaw()) if err != nil { return fmt.Errorf("failed to get provider public key: %w", err) } rsaKey, ok := publicKeyRecord.PublicKey.(*rsa.PublicKey) if !ok { return fmt.Errorf("jwk is not an RSA key") } ok, err = gq.GQ256VerifyJWT(rsaKey, idt.GetRaw()) if err != nil { return err } if !ok { return fmt.Errorf("error verifying OP GQ signature on PK Token (ID Token invalid)") } return nil } func originalTokenHeaders(token []byte) (jws.Headers, error) { origHeadersB64, err := gq.OriginalJWTHeaders(token) if err != nil { return nil, fmt.Errorf("malformatted PK token headers: %w", err) } origHeaders, err := util.Base64DecodeForJWT(origHeadersB64) if err != nil { return nil, fmt.Errorf("error decoding original token headers: %w", err) } headers := jws.NewHeaders() err = json.Unmarshal(origHeaders, &headers) if err != nil { return nil, fmt.Errorf("error parsing segment: %w", err) } return headers, nil } func verifyAudience(idt *oidc.Jwt, clientID string) error { if idt.GetClaims().Audience == "" { return fmt.Errorf("missing audience claim") } for _, audience := range strings.Split(idt.GetClaims().Audience, ",") { if audience == clientID { return nil } } return fmt.Errorf("audience does not contain clientID %s, aud = %v", clientID, idt.GetClaims().Audience) } openpubkey-0.8.0/providers/providerverifier_test.go000066400000000000000000000150341477254274500227150ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "fmt" "testing" "github.com/openpubkey/openpubkey/providers/mocks" "github.com/stretchr/testify/require" ) func TestProviderVerifier(t *testing.T) { NONCE_CLAIM := CommitTypesEnum.NONCE_CLAIM AUD_CLAIM := CommitTypesEnum.AUD_CLAIM GQ_BOUND := CommitTypesEnum.GQ_BOUND EMPTY_COMMIT := CommitType{ Claim: "", GQCommitment: false, } correctAud := AudPrefixForGQCommitment clientID := "test-client-id" issuer := "mockIssuer" testCases := []struct { name string aud string clientID string expError string pvGQSign bool pvGQOnly bool tokenGQSign bool tokenCommitType CommitType pvCommitType CommitType SkipClientIDCheck bool IssuedAtClaim int64 correctCicHash bool }{ {name: "Claim Commitment happy case", aud: clientID, clientID: clientID, tokenCommitType: NONCE_CLAIM, pvCommitType: NONCE_CLAIM, expError: "", correctCicHash: true}, {name: "Claim Commitment (aud) happy case", tokenCommitType: AUD_CLAIM, pvCommitType: AUD_CLAIM, expError: "", SkipClientIDCheck: true, correctCicHash: true}, {name: "Claim Commitment wrong audience", aud: "wrong clientID", clientID: clientID, tokenCommitType: NONCE_CLAIM, pvCommitType: NONCE_CLAIM, expError: "audience does not contain clientID", correctCicHash: true}, {name: "Claim Commitment no commitment claim", aud: clientID, clientID: clientID, tokenCommitType: EMPTY_COMMIT, pvCommitType: EMPTY_COMMIT, expError: "verifier configured with empty commitment claim", tokenGQSign: false, correctCicHash: true}, {name: "Claim Commitment wrong CIC", aud: clientID, clientID: clientID, tokenCommitType: NONCE_CLAIM, pvCommitType: NONCE_CLAIM, expError: "commitment claim doesn't match", tokenGQSign: false, correctCicHash: false}, {name: "Claim Commitment GQ happy case", aud: clientID, clientID: clientID, tokenCommitType: NONCE_CLAIM, pvCommitType: NONCE_CLAIM, expError: "", tokenGQSign: true, correctCicHash: true}, {name: "Claim Commitment GQ wrong CIC", aud: clientID, clientID: clientID, tokenCommitType: NONCE_CLAIM, pvCommitType: NONCE_CLAIM, expError: "commitment claim doesn't match", tokenGQSign: true, correctCicHash: false}, {name: "GQ Commitment happy case", aud: correctAud, expError: "", tokenCommitType: GQ_BOUND, pvCommitType: GQ_BOUND, tokenGQSign: true, pvGQOnly: true, SkipClientIDCheck: true, correctCicHash: true}, {name: "GQ Commitment wrong aud prefix", aud: "bad value", expError: "audience claim in PK Token's GQCommitment must be prefixed by", tokenCommitType: GQ_BOUND, pvCommitType: GQ_BOUND, tokenGQSign: true, pvGQOnly: true, SkipClientIDCheck: true, correctCicHash: true}, {name: "GQ Commitment providerVerifier not using GQ Commitment", aud: correctAud, expError: "commitment claim doesn't match", tokenCommitType: GQ_BOUND, pvCommitType: NONCE_CLAIM, tokenGQSign: true, pvGQOnly: true, SkipClientIDCheck: true, correctCicHash: true}, {name: "GQ Commitment wrong CIC", aud: correctAud, expError: "commitment claim doesn't match", tokenCommitType: GQ_BOUND, pvCommitType: GQ_BOUND, tokenGQSign: true, pvGQOnly: true, SkipClientIDCheck: true, correctCicHash: false}, {name: "GQ Commitment check client id", aud: correctAud, expError: "GQCommitment requires that audience (aud) is not set to client-id", tokenCommitType: GQ_BOUND, pvCommitType: GQ_BOUND, tokenGQSign: true, pvGQOnly: true}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { idtTemplate := mocks.IDTokenTemplate{ Issuer: issuer, Nonce: "empty", NoNonce: false, Aud: "empty", Alg: "RS256", NoAlg: false, ExtraClaims: map[string]any{}, } switch tc.tokenCommitType.Claim { case "nonce": idtTemplate.CommitFunc = mocks.AddNonceCommit case "aud": idtTemplate.CommitFunc = mocks.AddAudCommit default: idtTemplate.CommitFunc = mocks.NoClaimCommit } if tc.aud != "" { idtTemplate.Aud = tc.aud } if tc.IssuedAtClaim != 0 { idtTemplate.ExtraClaims["iat"] = tc.IssuedAtClaim } cic := GenCICExtra(t, map[string]any{}) // Set gqOnly to gqCommitment since gqCommitment requires gqOnly pvGQOnly := tc.tokenCommitType.GQCommitment skipClientIDCheck := false //TODO: This should be taken from the testcase providerOpts := MockProviderOpts{ Issuer: issuer, ClientID: clientID, GQSign: tc.tokenGQSign, NumKeys: 2, CommitType: tc.tokenCommitType, VerifierOpts: ProviderVerifierOpts{ CommitType: tc.pvCommitType, ClientID: clientID, SkipClientIDCheck: skipClientIDCheck, GQOnly: pvGQOnly, }, } op, backendMock, _, err := NewMockProvider(providerOpts) require.NoError(t, err) opSignKey, keyID, _ := backendMock.RandomSigningKey() idtTemplate.KeyID = keyID idtTemplate.SigningKey = opSignKey backendMock.SetIDTokenTemplate(&idtTemplate) tokens, err := op.RequestTokens(context.Background(), cic) require.NoError(t, err) idToken := tokens.IDToken if tc.name == "GQ Commitment happy case" { fmt.Println("here") } pv := NewProviderVerifier(issuer, ProviderVerifierOpts{ CommitType: tc.pvCommitType, DiscoverPublicKey: &backendMock.PublicKeyFinder, GQOnly: tc.pvGQOnly, ClientID: tc.clientID, SkipClientIDCheck: tc.SkipClientIDCheck, }) // Change the CIC we test against so it doesn't match the commitment if !tc.correctCicHash { // overwrite the cic with a new cic with a different hash cic = GenCICExtra(t, map[string]any{"cause": "differentCicHash"}) } err = pv.VerifyIDToken(context.Background(), idToken, cic) if tc.expError != "" { require.ErrorContains(t, err, tc.expError) } else { require.NoError(t, err) } }) } } openpubkey-0.8.0/providers/standard_provider.go000066400000000000000000000270721477254274500220070ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "fmt" "net/http" "time" "github.com/google/uuid" "github.com/openpubkey/openpubkey/discover" simpleoidc "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken/clientinstance" "github.com/openpubkey/openpubkey/util" "github.com/sirupsen/logrus" "github.com/zitadel/oidc/v3/pkg/client/rp" "github.com/zitadel/oidc/v3/pkg/oidc" ) type StandardOp struct { clientID string clientSecret string Scopes []string RedirectURIs []string GQSign bool OpenBrowser bool HttpClient *http.Client IssuedAtOffset time.Duration issuer string server *http.Server publicKeyFinder discover.PublicKeyFinder requestTokensOverrideFunc func(string) (*simpleoidc.Tokens, error) httpSessionHook http.HandlerFunc reuseBrowserWindowHook chan string } var _ OpenIdProvider = (*StandardOp)(nil) var _ BrowserOpenIdProvider = (*StandardOp)(nil) var _ RefreshableOpenIdProvider = (*StandardOp)(nil) func (s *StandardOp) requestTokens(ctx context.Context, cicHash string) (*simpleoidc.Tokens, error) { if s.requestTokensOverrideFunc != nil { return s.requestTokensOverrideFunc(cicHash) } redirectURI, ln, err := FindAvailablePort(s.RedirectURIs) if err != nil { return nil, err } logrus.Infof("listening on http://%s/", ln.Addr().String()) logrus.Info("press ctrl+c to stop") mux := http.NewServeMux() s.server = &http.Server{Handler: mux} cookieHandler, err := configCookieHandler() if err != nil { return nil, err } options := []rp.Option{ rp.WithCookieHandler(cookieHandler), rp.WithVerifierOpts( rp.WithIssuedAtOffset(s.IssuedAtOffset), rp.WithNonce( func(ctx context.Context) string { return cicHash })), } options = append(options, rp.WithPKCE(cookieHandler)) if s.HttpClient != nil { options = append(options, rp.WithHTTPClient(s.HttpClient)) } // The reason we don't set the relyingParty on the struct and reuse it, // is because refresh requests require a slightly different set of // options. For instance we want the option to check the nonce (WithNonce) // here, but in RefreshTokens we don't want that option set because // a refreshed ID token doesn't have a nonce. relyingParty, err := rp.NewRelyingPartyOIDC(ctx, s.issuer, s.clientID, s.clientSecret, redirectURI.String(), s.Scopes, options...) if err != nil { return nil, fmt.Errorf("error creating provider: %w", err) } state := func() string { return uuid.New().String() } shutdownServer := func() { if err := s.server.Shutdown(ctx); err != nil { logrus.Errorf("Failed to shutdown http server: %v", err) } } chTokens := make(chan *oidc.Tokens[*oidc.IDTokenClaims], 1) chErr := make(chan error, 1) mux.Handle("/login", rp.AuthURLHandler(state, relyingParty, rp.WithURLParam("nonce", cicHash), // Select account requires that the user click the account they want to use. // Results in better UX than just automatically dropping them into their // only signed in account. // See prompt parameter in OIDC spec https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest rp.WithPromptURLParam("consent"), rp.WithURLParam("access_type", "offline")), ) marshalToken := func(w http.ResponseWriter, r *http.Request, retTokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty) { if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) chErr <- err return } chTokens <- retTokens // If defined the OIDC client hands over control of the HTTP server session to the OpenPubkey client. // Useful for redirecting the user's browser window that just finished OIDC Auth flow to the // MFA Cosigner Auth URI. if s.httpSessionHook != nil { s.httpSessionHook(w, r) defer shutdownServer() // If no http session hook is set, we do server shutdown in RequestTokens } else { if _, err := w.Write([]byte("You may now close this window")); err != nil { logrus.Error(err) } } } callbackPath := redirectURI.Path mux.Handle(callbackPath, rp.CodeExchangeHandler(marshalToken, relyingParty)) go func() { err := s.server.Serve(ln) if err != nil && err != http.ErrServerClosed { logrus.Error(err) } }() loginURI := fmt.Sprintf("http://localhost:%s/login", redirectURI.Port()) // If reuseBrowserWindowHook is set, don't open a new browser window // instead redirect the user's existing browser window if s.reuseBrowserWindowHook != nil { s.reuseBrowserWindowHook <- loginURI } else if s.OpenBrowser { logrus.Infof("Opening browser to %s ", loginURI) if err := util.OpenUrl(loginURI); err != nil { logrus.Errorf("Failed to open url: %v", err) } } else { // If s.OpenBrowser is false, tell the user what URL to open. // This is useful when a user wants to use a different browser than the default one. logrus.Infof("Open your browser to: %s ", loginURI) } // If httpSessionHook is not defined shutdown the server when done, // otherwise keep it open for the httpSessionHook // If httpSessionHook is set we handle both possible cases to ensure // the server is shutdown: // 1. We shut it down if an error occurs in the marshalToken handler // 2. We shut it down if the marshalToken handler completes if s.httpSessionHook == nil { defer shutdownServer() } select { case <-ctx.Done(): return nil, ctx.Err() case err := <-chErr: if s.httpSessionHook != nil { defer shutdownServer() } return nil, err case retTokens := <-chTokens: // retTokens is a zitadel/oidc struct. We turn it into our simpler token struct return &simpleoidc.Tokens{ IDToken: []byte(retTokens.IDToken), RefreshToken: []byte(retTokens.RefreshToken), AccessToken: []byte(retTokens.AccessToken)}, nil } } func (s *StandardOp) RequestTokens(ctx context.Context, cic *clientinstance.Claims) (*simpleoidc.Tokens, error) { // Define our commitment as the hash of the client instance claims cicHash, err := cic.Hash() if err != nil { return nil, fmt.Errorf("error calculating client instance claim commitment: %w", err) } tokens, err := s.requestTokens(ctx, string(cicHash)) if err != nil { return nil, err } if s.GQSign { idToken := tokens.IDToken if gqToken, err := CreateGQToken(ctx, idToken, s); err != nil { return nil, err } else { tokens.IDToken = gqToken return tokens, nil } } return tokens, nil } func (s *StandardOp) RefreshTokens(ctx context.Context, refreshToken []byte) (*simpleoidc.Tokens, error) { cookieHandler, err := configCookieHandler() if err != nil { return nil, err } options := []rp.Option{ rp.WithCookieHandler(cookieHandler), rp.WithVerifierOpts( rp.WithIssuedAtOffset(s.IssuedAtOffset), rp.WithNonce(nil), // disable nonce check ), } options = append(options, rp.WithPKCE(cookieHandler)) if s.HttpClient != nil { options = append(options, rp.WithHTTPClient(s.HttpClient)) } // The redirect URI is not sent in the refresh request so we set it to an empty string. // According to the OIDC spec the only values send on a refresh request are: // client_id, client_secret, grant_type, refresh_token, and scope. // https://openid.net/specs/openid-connect-core-1_0.html#RefreshingAccessToken redirectURI := "" relyingParty, err := rp.NewRelyingPartyOIDC(ctx, s.issuer, s.clientID, s.clientSecret, redirectURI, s.Scopes, options...) if err != nil { return nil, fmt.Errorf("failed to create RP to verify token: %w", err) } retTokens, err := rp.RefreshTokens[*oidc.IDTokenClaims](ctx, relyingParty, string(refreshToken), "", "") if err != nil { return nil, err } if retTokens.RefreshToken == "" { // Google does not rotate refresh tokens, the one you get at the // beginning is the only one you'll ever get. This may not be true // of OPs. retTokens.RefreshToken = string(refreshToken) } return &simpleoidc.Tokens{ IDToken: []byte(retTokens.IDToken), RefreshToken: []byte(retTokens.RefreshToken), AccessToken: []byte(retTokens.AccessToken)}, nil } func (s *StandardOp) PublicKeyByToken(ctx context.Context, token []byte) (*discover.PublicKeyRecord, error) { return s.publicKeyFinder.ByToken(ctx, s.issuer, token) } func (s *StandardOp) PublicKeyByKeyId(ctx context.Context, keyID string) (*discover.PublicKeyRecord, error) { return s.publicKeyFinder.ByKeyID(ctx, s.issuer, keyID) } func (s *StandardOp) Issuer() string { return s.issuer } func (s *StandardOp) ClientID() string { return s.clientID } func (s *StandardOp) VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error { vp := NewProviderVerifier( s.issuer, ProviderVerifierOpts{ CommitType: CommitTypesEnum.NONCE_CLAIM, ClientID: s.clientID, DiscoverPublicKey: &s.publicKeyFinder, }) return vp.VerifyIDToken(ctx, idt, cic) } func (s *StandardOp) VerifyRefreshedIDToken(ctx context.Context, origIdt []byte, reIdt []byte) error { if err := simpleoidc.SameIdentity(origIdt, reIdt); err != nil { return fmt.Errorf("refreshed ID Token is for different subject than original ID Token: %w", err) } if err := simpleoidc.RequireOlder(origIdt, reIdt); err != nil { return fmt.Errorf("refreshed ID Token should not be issued before original ID Token: %w", err) } options := []rp.Option{} if s.HttpClient != nil { options = append(options, rp.WithHTTPClient(s.HttpClient)) } redirectURI := "" relyingParty, err := rp.NewRelyingPartyOIDC(ctx, s.issuer, s.clientID, s.clientSecret, redirectURI, s.Scopes, options...) if err != nil { return fmt.Errorf("failed to create RP to verify token: %w", err) } _, err = rp.VerifyIDToken[*oidc.IDTokenClaims](ctx, string(reIdt), relyingParty.IDTokenVerifier()) return err } // HookHTTPSession provides a means to hook the HTTP Server session resulting // from the OpenID Provider sending an authcode to the OIDC client by // redirecting the user's browser with the authcode supplied in the URI. // If this hook is set, it will be called after the receiving the authcode // but before send an HTTP response to the user. The code which sets this hook // can choose what HTTP response to server to the user. // // We use this so that we can redirect the user web browser window to // the MFA Cosigner URI after the user finishes the OIDC Auth flow. This // method is only available to browser based providers. func (s *StandardOp) HookHTTPSession(h http.HandlerFunc) { s.httpSessionHook = h } // ReuseBrowserWindow is needed so that do not open more than one browser window. // If we are using a web based OpenID Provider chooser, we have already opened one // window on the user's browser. We should reuse that window here rather than // opening a second browser window. func (s *StandardOp) ReuseBrowserWindowHook(h chan string) { s.reuseBrowserWindowHook = h } // GetBrowserWindowHook ris used by testing to trigger the redirect without // calling out the OP. This is hidden by not including in the interface. func (s *StandardOp) TriggerBrowserWindowHook(uri string) { s.reuseBrowserWindowHook <- uri } openpubkey-0.8.0/providers/standard_provider_test.go000066400000000000000000000055211477254274500230410ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "context" "encoding/json" "testing" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/providers/mocks" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestGoogleSimpleRequest(t *testing.T) { gqSign := false issuer := googleIssuer providerOverride, err := mocks.NewMockProviderBackend(issuer, 2) require.NoError(t, err) op := &GoogleOp{ issuer: googleIssuer, publicKeyFinder: providerOverride.PublicKeyFinder, requestTokensOverrideFunc: providerOverride.RequestTokensOverrideFunc, } cic := GenCIC(t) expSigningKey, expKeyID, expRecord := providerOverride.RandomSigningKey() idTokenTemplate := mocks.IDTokenTemplate{ CommitFunc: mocks.AddNonceCommit, Issuer: issuer, Nonce: "empty", NoNonce: false, Aud: "also me", KeyID: expKeyID, NoKeyID: false, Alg: expRecord.Alg, NoAlg: false, ExtraClaims: map[string]any{"extraClaim": "extraClaimValue"}, ExtraProtectedClaims: map[string]any{"extraHeader": "extraheaderValue"}, SigningKey: expSigningKey, } providerOverride.SetIDTokenTemplate(&idTokenTemplate) tokens, err := op.RequestTokens(context.Background(), cic) require.NoError(t, err) idToken := tokens.IDToken cicHash, err := cic.Hash() require.NoError(t, err) require.NotNil(t, cicHash) headerB64, payloadB64, _, err := jws.SplitCompact(idToken) require.NoError(t, err) headerJson, err := util.Base64DecodeForJWT(headerB64) require.NoError(t, err) if gqSign { headers := jws.NewHeaders() err = json.Unmarshal(headerJson, &headers) require.NoError(t, err) cicHash2, ok := headers.Get("cic") require.True(t, ok, "cic not found in GQ ID Token") require.Equal(t, string(cicHash), cicHash2, "cic hash in jwt header should match cic supplied") } else { payload, err := util.Base64DecodeForJWT(payloadB64) require.NoError(t, err) require.Contains(t, string(payload), string(cicHash)) } require.Equal(t, "mock-refresh-token", string(tokens.RefreshToken)) require.Equal(t, "mock-access-token", string(tokens.AccessToken)) } openpubkey-0.8.0/providers/test.go000066400000000000000000000025361477254274500172520ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/openpubkey/openpubkey/pktoken/clientinstance" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func GenCIC(t *testing.T) *clientinstance.Claims { return GenCICExtra(t, map[string]any{}) } func GenCICExtra(t *testing.T, extraClaims map[string]any) *clientinstance.Claims { alg := jwa.ES256 signer, err := util.GenKeyPair(alg) require.NoError(t, err) jwkKey, err := jwk.PublicKeyOf(signer) require.NoError(t, err) err = jwkKey.Set(jwk.AlgorithmKey, alg) require.NoError(t, err) cic, err := clientinstance.NewClaims(jwkKey, extraClaims) require.NoError(t, err) return cic } openpubkey-0.8.0/providers/utils.go000066400000000000000000000055171477254274500174350ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "crypto/rand" "fmt" "io" "net" "net/url" "strings" httphelper "github.com/zitadel/oidc/v3/pkg/http" ) // FindAvailablePort attempts to open a listener on localhost until it finds one or runs out of redirectURIs to try func FindAvailablePort(redirectURIs []string) (*url.URL, net.Listener, error) { var ln net.Listener var lnErr error for _, v := range redirectURIs { redirectURI, err := url.Parse(v) if err != nil { return nil, nil, fmt.Errorf("malformed redirectURI specified, redirectURI was %s", v) } if !(strings.HasPrefix(redirectURI.Host, "localhost") || strings.HasPrefix(redirectURI.Host, "127.0.0.1") || strings.HasPrefix(redirectURI.Host, "0:0:0:0:0:0:0:1") || strings.HasPrefix(redirectURI.Host, "::1")) { return nil, nil, fmt.Errorf("redirectURI must be localhost, redirectURI was %s", redirectURI.Host) } lnStr := fmt.Sprintf("localhost:%s", redirectURI.Port()) ln, lnErr = net.Listen("tcp", lnStr) if lnErr == nil { return redirectURI, ln, nil } } return nil, nil, fmt.Errorf("failed to start a listener for the callback from the OP, got %w", lnErr) } func configCookieHandler() (*httphelper.CookieHandler, error) { // I've been unable to determine a scenario in which setting a hashKey and blockKey // on the cookie provide protection in the localhost redirect URI case. However I // see no harm in setting it. hashKey := make([]byte, 64) if _, err := io.ReadFull(rand.Reader, hashKey); err != nil { return nil, fmt.Errorf("failed to generate random keys for cookie storage") } blockKey := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, blockKey); err != nil { return nil, fmt.Errorf("failed to generate random keys for cookie storage") } // OpenPubkey uses a localhost redirect URI to receive the authcode // from the OP. Localhost redirects use http not https. Thus, we should // not set these cookies as secure-only. This should be changed if // OpenPubkey added support for non-localhost redirect URIs. // WithUnsecure() is equivalent to not setting the 'secure' attribute // flag in an HTTP Set-Cookie header (see https://http.dev/set-cookie#secure) return httphelper.NewCookieHandler(hashKey, blockKey, httphelper.WithUnsecure()), nil } openpubkey-0.8.0/providers/utils_test.go000066400000000000000000000052751477254274500204750ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package providers import ( "fmt" "net" "net/url" "testing" "github.com/stretchr/testify/require" ) func TestFindAvailablePort(t *testing.T) { redirects := []string{ "http://localhost:21111/login-callback", "http://localhost:21012/login-callback", "http://localhost:30125/login-callback", } testCases := []struct { name string expRedirectURI string expError string portsToBlock int }{ {name: "Happy case", expRedirectURI: "http://localhost:21111/login-callback", expError: "", portsToBlock: 0}, {name: "Happy case: first port in use", expRedirectURI: "http://localhost:21012/login-callback", expError: "", portsToBlock: 1}, {name: "Happy case: first and second port in use", expRedirectURI: "http://localhost:30125/login-callback", expError: "", portsToBlock: 2}, {name: "Check error when all ports in use", expRedirectURI: "", expError: "failed to start a listener for the callback", portsToBlock: len(redirects)}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { blockedPorts := []net.Listener{} // Simulate the case where a port is in use and the provider has to use a different redirect URI for i := 0; i < tc.portsToBlock; i++ { parsedUrl, err := url.Parse(redirects[i]) require.NoError(t, err) lnStr := fmt.Sprintf("localhost:%s", parsedUrl.Port()) ln, err := net.Listen("tcp", lnStr) require.NoError(t, err) blockedPorts = append(blockedPorts, ln) } foundURI, ln, err := FindAvailablePort(redirects) if tc.expError != "" { require.Error(t, err) require.ErrorContains(t, err, tc.expError) require.Nil(t, ln) require.Nil(t, foundURI) } else { require.NoError(t, err) require.Equal(t, tc.expRedirectURI, foundURI.String()) require.NotNil(t, ln) err = ln.Close() require.NoError(t, err) } for _, lis := range blockedPorts { err := lis.Close() require.NoError(t, err) } }) } } func TestConfigCookieHandler(t *testing.T) { cookieHandler, err := configCookieHandler() require.NoError(t, err) require.NotNil(t, cookieHandler) } openpubkey-0.8.0/util/000077500000000000000000000000001477254274500146765ustar00rootroot00000000000000openpubkey-0.8.0/util/base64.go000066400000000000000000000025261477254274500163160ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package util import ( "encoding/base64" ) var rawURLEncoding = base64.RawURLEncoding.Strict() func Base64EncodeForJWT(decoded []byte) []byte { return base64Encode(decoded, rawURLEncoding) } func Base64DecodeForJWT(encoded []byte) ([]byte, error) { return base64Decode(encoded, rawURLEncoding) } func base64Encode(decoded []byte, encoding *base64.Encoding) []byte { encoded := make([]byte, encoding.EncodedLen(len(decoded))) encoding.Encode(encoded, decoded) return encoded } func base64Decode(encoded []byte, encoding *base64.Encoding) ([]byte, error) { decoded := make([]byte, encoding.DecodedLen(len(encoded))) n, err := encoding.Decode(decoded, encoded) if err != nil { return nil, err } return decoded[:n], nil } openpubkey-0.8.0/util/bytes.go000066400000000000000000000015131477254274500163530ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package util import "bytes" func JoinJWTSegments(segments ...[]byte) []byte { return JoinBytes('.', segments...) } func JoinBytes(sep byte, things ...[]byte) []byte { return bytes.Join(things, []byte{sep}) } openpubkey-0.8.0/util/files.go000066400000000000000000000035741477254274500163400ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package util import ( "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" "fmt" "os" "github.com/lestrrat-go/jwx/v2/jwa" ) func SKToX509Bytes(sk *ecdsa.PrivateKey) ([]byte, error) { x509Encoded, err := x509.MarshalECPrivateKey(sk) if err != nil { return nil, err } return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: x509Encoded}), nil } func WriteSKFile(fpath string, sk *ecdsa.PrivateKey) error { pemBytes, err := SKToX509Bytes(sk) if err != nil { return err } return os.WriteFile(fpath, pemBytes, 0600) } func ReadSKFile(fpath string) (*ecdsa.PrivateKey, error) { pemBytes, err := os.ReadFile(fpath) if err != nil { return nil, err } block, _ := pem.Decode([]byte(pemBytes)) return x509.ParseECPrivateKey(block.Bytes) } func GenKeyPair(alg jwa.KeyAlgorithm) (crypto.Signer, error) { switch alg { case jwa.ES256: return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) case jwa.RS256: // RSASSA-PKCS-v1.5 using SHA-256 return rsa.GenerateKey(rand.Reader, 2048) default: return nil, fmt.Errorf("unsupported algorithm: %s", alg.String()) } } func B64SHA3_256(msg []byte) []byte { h := crypto.SHA3_256.New() h.Write(msg) image := h.Sum(nil) return Base64EncodeForJWT(image) } openpubkey-0.8.0/util/launch.go000066400000000000000000000022361477254274500165020ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package util import ( "os/exec" "runtime" ) // https://stackoverflow.com/questions/39320371/how-start-web-server-to-open-page-in-browser-in-golang // open opens the specified URL in the default browser of the user. func OpenUrl(url string) error { var cmd string var args []string switch runtime.GOOS { case "windows": cmd = "cmd" args = []string{"/c", "start"} case "darwin": cmd = "open" default: // "linux", "freebsd", "openbsd", "netbsd" cmd = "xdg-open" } args = append(args, url) return exec.Command(cmd, args...).Start() } openpubkey-0.8.0/verifier/000077500000000000000000000000001477254274500155345ustar00rootroot00000000000000openpubkey-0.8.0/verifier/expiration.go000066400000000000000000000116341477254274500202520ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package verifier import ( "fmt" "time" "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken" ) type ExpirationPolicy struct { maxAge time.Duration checkMaxAge bool checkExpClaim bool checkRefreshed bool } var ExpirationPolicies = struct { OIDC ExpirationPolicy // This uses the OpenID Connect expiration claim OIDC_REFRESHED ExpirationPolicy // This uses the OpenID Connect expiration claim on the ID Token, if that has expired. It checks the expiration on the refreshed ID Token, a.k.a., the fresh ID Token MAX_AGE_24HOURS ExpirationPolicy // This replaces the OpenID Connect expiration claim with OpenPubkey 24 expiration MAX_AGE_48HOURS ExpirationPolicy MAX_AGE_1WEEK ExpirationPolicy NEVER_EXPIRE ExpirationPolicy // ID Token will never expire until the OpenID Provider rotates the ID Token }{ OIDC: ExpirationPolicy{maxAge: 0, checkMaxAge: false, checkExpClaim: true}, OIDC_REFRESHED: ExpirationPolicy{maxAge: 0, checkMaxAge: false, checkExpClaim: false, checkRefreshed: true}, MAX_AGE_24HOURS: ExpirationPolicy{maxAge: 24 * time.Hour, checkMaxAge: true, checkExpClaim: false}, MAX_AGE_48HOURS: ExpirationPolicy{maxAge: 2 * 24 * time.Hour, checkMaxAge: true, checkExpClaim: false}, MAX_AGE_1WEEK: ExpirationPolicy{maxAge: 7 * 24 * time.Hour, checkMaxAge: true, checkExpClaim: false}, NEVER_EXPIRE: ExpirationPolicy{maxAge: 0, checkMaxAge: false, checkExpClaim: false}, } // CheckExpiration checks the expiration of the PK Token against the expiration // policy. func (ep ExpirationPolicy) CheckExpiration(pkt *pktoken.PKToken) error { idt, err := oidc.NewJwt(pkt.OpToken) if err != nil { return err } idtClaims := idt.GetClaims() if ep.checkExpClaim { _, err := verifyNotExpired(idtClaims.Expiration) if err != nil { return err } } if ep.checkRefreshed { expired, err := verifyNotExpired(idtClaims.Expiration) // If the id token is expired, verify against the refreshed id token if expired { if pkt.FreshIDToken == nil { return fmt.Errorf("ID token is expired and no refresh token found") } freshIdt, err := oidc.NewJwt(pkt.FreshIDToken) if err != nil { return err } _, err = verifyNotExpired(freshIdt.GetClaims().Expiration) if err != nil { return err } } else if err != nil { // an non-expiration error occurred return err } } if ep.checkMaxAge { _, err := checkMaxAge(idtClaims.IssuedAt, int64(ep.maxAge.Seconds())) if err != nil { return err } } return nil } // verifyNotExpired checks the expiration of the ID Token using the exp claim. // If expired, returns true and set an error. If an error prevents checking // expiration it return false and the error. func verifyNotExpired(expiration int64) (bool, error) { if expiration == 0 { return false, fmt.Errorf("missing expiration claim") } if expiration < 0 { return false, fmt.Errorf("expiration must be must be greater than zero (issuedAt = %v)", expiration) } // JWT expiration is "Seconds Since the Epoch" // RFC-7519 -Section 2 https://www.rfc-editor.org/rfc/rfc7519#section-2 expirationTime := time.Unix(expiration, 0) if time.Now().After(expirationTime) { return true, fmt.Errorf("the ID token has expired (exp = %v)", expiration) } return false, nil } // checkMaxAge checks the max age of the ID Token using the issuedAt claim. // If expired, returns true and set an error. If an error prevents checking // expiration it return false and the error. func checkMaxAge(issuedAt int64, maxAge int64) (bool, error) { if issuedAt == 0 { return false, fmt.Errorf("missing issuedAt claim") } if issuedAt < 0 { return false, fmt.Errorf("issuedAt must be must be greater than zero (issuedAt = %v)", issuedAt) } if !(maxAge > 0) { return false, fmt.Errorf("maxAge configuration must be greater than zero (maxAge = %v)", maxAge) } // Ensure we throw an error is something goes wrong and we get parameters so large they overflow if (issuedAt + maxAge) < issuedAt { return false, fmt.Errorf("invalid values (issuedAt = %v, maxAge = %v)", issuedAt, maxAge) } expirationTime := time.Unix(issuedAt+maxAge, 0) if time.Now().After(expirationTime) { return true, fmt.Errorf("the PK token has expired based on maxAge (issuedAt = %v, maxAge = %v, expiratedAt = %v)", issuedAt, maxAge, expirationTime) } return false, nil } openpubkey-0.8.0/verifier/expiration_test.go000066400000000000000000000223611477254274500213100ustar00rootroot00000000000000// Copyright 2025 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package verifier import ( "encoding/json" "math" "testing" "time" "github.com/openpubkey/openpubkey/oidc" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/util" "github.com/stretchr/testify/require" ) func TestExpirationPolicy(t *testing.T) { claimsUnexpired := oidc.OidcClaims{ Expiration: time.Now().Add(1 * time.Hour).Unix(), IssuedAt: time.Now().Add(-1 * time.Hour).Unix(), } unexpiredPkt := &pktoken.PKToken{} unexpiredPkt.OpToken = CreateCompact(t, claimsUnexpired) err := ExpirationPolicies.OIDC.CheckExpiration(unexpiredPkt) require.NoError(t, err) err = ExpirationPolicies.MAX_AGE_24HOURS.CheckExpiration(unexpiredPkt) require.NoError(t, err) err = ExpirationPolicies.MAX_AGE_48HOURS.CheckExpiration(unexpiredPkt) require.NoError(t, err) err = ExpirationPolicies.MAX_AGE_1WEEK.CheckExpiration(unexpiredPkt) require.NoError(t, err) err = ExpirationPolicies.NEVER_EXPIRE.CheckExpiration(unexpiredPkt) require.NoError(t, err) claimsOidcExpired := oidc.OidcClaims{ Expiration: time.Now().Add(-1 * time.Hour).Unix(), IssuedAt: time.Now().Add(-2 * time.Hour).Unix(), } oidcExpiredPkt := &pktoken.PKToken{} oidcExpiredPkt.OpToken = CreateCompact(t, claimsOidcExpired) err = ExpirationPolicies.OIDC.CheckExpiration(oidcExpiredPkt) require.ErrorContains(t, err, "the ID token has expired") err = ExpirationPolicies.MAX_AGE_24HOURS.CheckExpiration(oidcExpiredPkt) require.NoError(t, err) err = ExpirationPolicies.MAX_AGE_48HOURS.CheckExpiration(oidcExpiredPkt) require.NoError(t, err) err = ExpirationPolicies.MAX_AGE_1WEEK.CheckExpiration(oidcExpiredPkt) require.NoError(t, err) err = ExpirationPolicies.NEVER_EXPIRE.CheckExpiration(oidcExpiredPkt) require.NoError(t, err) claimsMaxAge1Day := oidc.OidcClaims{ Expiration: time.Now().Add(1 * time.Hour).Unix(), IssuedAt: time.Now().Add(-25 * time.Hour).Unix(), } maxAge1DayExpiredPkt := &pktoken.PKToken{} maxAge1DayExpiredPkt.OpToken = CreateCompact(t, claimsMaxAge1Day) err = ExpirationPolicies.OIDC.CheckExpiration(maxAge1DayExpiredPkt) require.NoError(t, err) err = ExpirationPolicies.MAX_AGE_24HOURS.CheckExpiration(maxAge1DayExpiredPkt) require.ErrorContains(t, err, "the PK token has expired based on maxAge") err = ExpirationPolicies.MAX_AGE_48HOURS.CheckExpiration(maxAge1DayExpiredPkt) require.NoError(t, err) err = ExpirationPolicies.MAX_AGE_1WEEK.CheckExpiration(maxAge1DayExpiredPkt) require.NoError(t, err) err = ExpirationPolicies.NEVER_EXPIRE.CheckExpiration(maxAge1DayExpiredPkt) require.NoError(t, err) claimsMaxAge2Day := oidc.OidcClaims{ Expiration: time.Now().Add(1 * time.Hour).Unix(), IssuedAt: time.Now().Add(-3 * 24 * time.Hour).Unix(), } maxAge2DayExpiredPkt := &pktoken.PKToken{} maxAge2DayExpiredPkt.OpToken = CreateCompact(t, claimsMaxAge2Day) err = ExpirationPolicies.OIDC.CheckExpiration(maxAge2DayExpiredPkt) require.NoError(t, err) err = ExpirationPolicies.MAX_AGE_24HOURS.CheckExpiration(maxAge2DayExpiredPkt) require.ErrorContains(t, err, "the PK token has expired based on maxAge") err = ExpirationPolicies.MAX_AGE_48HOURS.CheckExpiration(maxAge2DayExpiredPkt) require.ErrorContains(t, err, "the PK token has expired based on maxAge") err = ExpirationPolicies.MAX_AGE_1WEEK.CheckExpiration(maxAge2DayExpiredPkt) require.NoError(t, err) err = ExpirationPolicies.NEVER_EXPIRE.CheckExpiration(maxAge2DayExpiredPkt) require.NoError(t, err) claimsMaxAge1Week := oidc.OidcClaims{ Expiration: time.Now().Add(1 * time.Hour).Unix(), IssuedAt: time.Now().Add(-8 * 24 * time.Hour).Unix(), } maxAge1WeekExpiredPkt := &pktoken.PKToken{} maxAge1WeekExpiredPkt.OpToken = CreateCompact(t, claimsMaxAge1Week) err = ExpirationPolicies.OIDC.CheckExpiration(maxAge1WeekExpiredPkt) require.NoError(t, err) err = ExpirationPolicies.MAX_AGE_24HOURS.CheckExpiration(maxAge1WeekExpiredPkt) require.ErrorContains(t, err, "the PK token has expired based on maxAge") err = ExpirationPolicies.MAX_AGE_48HOURS.CheckExpiration(maxAge1WeekExpiredPkt) require.ErrorContains(t, err, "the PK token has expired based on maxAge") err = ExpirationPolicies.MAX_AGE_1WEEK.CheckExpiration(maxAge1WeekExpiredPkt) require.ErrorContains(t, err, "the PK token has expired based on maxAge") err = ExpirationPolicies.NEVER_EXPIRE.CheckExpiration(maxAge1WeekExpiredPkt) require.NoError(t, err) claimsBothExpire := oidc.OidcClaims{ Expiration: time.Now().Add(-10 * time.Hour).Unix(), IssuedAt: time.Now().Add(-8000 * 24 * time.Hour).Unix(), } bothExpiredPkt := &pktoken.PKToken{} bothExpiredPkt.OpToken = CreateCompact(t, claimsBothExpire) err = ExpirationPolicies.OIDC.CheckExpiration(bothExpiredPkt) require.ErrorContains(t, err, "the ID token has expired") err = ExpirationPolicies.MAX_AGE_24HOURS.CheckExpiration(bothExpiredPkt) require.ErrorContains(t, err, "the PK token has expired based on maxAge") err = ExpirationPolicies.MAX_AGE_48HOURS.CheckExpiration(bothExpiredPkt) require.ErrorContains(t, err, "the PK token has expired based on maxAge") err = ExpirationPolicies.MAX_AGE_1WEEK.CheckExpiration(bothExpiredPkt) require.ErrorContains(t, err, "the PK token has expired based on maxAge") err = ExpirationPolicies.NEVER_EXPIRE.CheckExpiration(bothExpiredPkt) require.NoError(t, err) noClaimsPkt := &pktoken.PKToken{} err = ExpirationPolicies.OIDC.CheckExpiration(noClaimsPkt) require.ErrorContains(t, err, "invalid number of segments") // OIDC Refreshed tests err = ExpirationPolicies.OIDC_REFRESHED.CheckExpiration(bothExpiredPkt) require.ErrorContains(t, err, "ID token is expired and no refresh token found") refreshedPkt := &pktoken.PKToken{} refreshedPkt.OpToken = CreateCompact(t, claimsBothExpire) refreshedPkt.FreshIDToken = CreateCompact(t, claimsUnexpired) refreshedPkt.FreshIDToken = CreateCompact(t, claimsUnexpired) err = ExpirationPolicies.OIDC_REFRESHED.CheckExpiration(refreshedPkt) require.NoError(t, err) refreshedPkt.FreshIDToken = CreateCompact(t, claimsBothExpire) err = ExpirationPolicies.OIDC_REFRESHED.CheckExpiration(refreshedPkt) require.ErrorContains(t, err, "the ID token has expired") zeroExp := oidc.OidcClaims{ Expiration: 0, } refreshedPkt.FreshIDToken = CreateCompact(t, zeroExp) err = ExpirationPolicies.OIDC_REFRESHED.CheckExpiration(refreshedPkt) require.ErrorContains(t, err, "missing expiration claim") refreshedPkt.FreshIDToken = []byte("") err = ExpirationPolicies.OIDC_REFRESHED.CheckExpiration(refreshedPkt) require.ErrorContains(t, err, "invalid number of segments") refreshedPkt.OpToken = CreateCompact(t, zeroExp) refreshedPkt.FreshIDToken = CreateCompact(t, claimsUnexpired) err = ExpirationPolicies.OIDC_REFRESHED.CheckExpiration(refreshedPkt) require.ErrorContains(t, err, "missing expiration claim") } func TestIDTokenExpiration(t *testing.T) { oneHourFromNow := time.Now().Add(1 * time.Hour) expired, err := verifyNotExpired(oneHourFromNow.Unix()) require.NoError(t, err) require.False(t, expired) oneHourAgo := time.Now().Add(-1 * time.Hour) expired, err = verifyNotExpired(oneHourAgo.Unix()) require.ErrorContains(t, err, "the ID token has expired") require.True(t, expired) expired, err = verifyNotExpired(0) require.ErrorContains(t, err, "missing expiration claim") require.False(t, expired) expired, err = verifyNotExpired(-1) require.ErrorContains(t, err, "expiration must be must be greater than zero") require.False(t, expired) } func TestMaxAgeExpiration(t *testing.T) { twoHoursAgo := time.Now().Add(-2 * time.Hour) maxAgeThreeHours := int64(3 * 60 * 60) // 3 hours in seconds expired, err := checkMaxAge(twoHoursAgo.Unix(), maxAgeThreeHours) require.NoError(t, err) require.False(t, expired) maxAgeOneHour := int64(1 * 60 * 60) // 3 hours in seconds expired, err = checkMaxAge(twoHoursAgo.Unix(), maxAgeOneHour) require.ErrorContains(t, err, "the PK token has expired based on maxAge") require.True(t, expired) expired, err = checkMaxAge(0, 1) require.ErrorContains(t, err, "missing issuedAt claim") require.False(t, expired) expired, err = checkMaxAge(-1, 1) require.ErrorContains(t, err, "issuedAt must be must be greater than zero") require.False(t, expired) expired, err = checkMaxAge(twoHoursAgo.Unix(), 0) require.ErrorContains(t, err, "maxAge configuration must be greater than zero") require.False(t, expired) expired, err = checkMaxAge(math.MaxInt64, math.MaxInt64) require.ErrorContains(t, err, "invalid values") require.False(t, expired) } func CreateCompact(t *testing.T, claims oidc.OidcClaims) []byte { claimsJson, err := json.Marshal(claims) require.NoError(t, err) claimsB64 := util.Base64EncodeForJWT(claimsJson) return util.JoinJWTSegments([]byte("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjEyMzQifQ"), claimsB64, []byte("ZmFrZXNpZ25hdHVyZQ==")) } openpubkey-0.8.0/verifier/verifier.go000066400000000000000000000166061477254274500177070ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package verifier import ( "context" "fmt" "github.com/lestrrat-go/jwx/v2/jws" "github.com/openpubkey/openpubkey/cosigner" "github.com/openpubkey/openpubkey/gq" "github.com/openpubkey/openpubkey/pktoken" "github.com/openpubkey/openpubkey/pktoken/clientinstance" ) type ProviderVerifier interface { // Returns the OpenID provider issuer as seen in ID token e.g. "https://accounts.google.com" Issuer() string VerifyIDToken(ctx context.Context, idt []byte, cic *clientinstance.Claims) error } type ProviderVerifierExpires struct { ProviderVerifier Expiration ExpirationPolicy } func (p ProviderVerifierExpires) ExpirationPolicy() ExpirationPolicy { return p.Expiration } type RefreshableProviderVerifier interface { VerifyRefreshedIDToken(ctx context.Context, origIdt []byte, reIdt []byte) error } type CosignerVerifier interface { Issuer() string Strict() bool // Whether or not a given cosigner MUST be present for successful verification VerifyCosigner(ctx context.Context, pkt *pktoken.PKToken) error } type VerifierOpts func(*Verifier) error // RequireRefreshedIDToken instructs the verifier to check that // an unexpired, refreshed ID token is set on the PKToken. func RequireRefreshedIDToken() VerifierOpts { return func(v *Verifier) error { v.requireRefreshedIDToken = true return nil } } func WithExpirationPolicy(expirationPolicy ExpirationPolicy) VerifierOpts { return func(v *Verifier) error { v.defaultExpirationPolicy = &expirationPolicy return nil } } func WithCosignerVerifiers(verifiers ...*cosigner.DefaultCosignerVerifier) VerifierOpts { return func(v *Verifier) error { for _, verifier := range verifiers { if _, ok := v.cosigners[verifier.Issuer()]; ok { return fmt.Errorf("cosigner verifier found with duplicate issuer: %s", verifier.Issuer()) } v.cosigners[verifier.Issuer()] = verifier } return nil } } type Check func(*Verifier, *pktoken.PKToken) error func GQOnly() Check { return func(_ *Verifier, pkt *pktoken.PKToken) error { alg, ok := pkt.ProviderAlgorithm() if !ok { return fmt.Errorf("missing provider algorithm header") } if alg != gq.GQ256 { return fmt.Errorf("non-GQ signatures are not supported") } return nil } } type Verifier struct { providers map[string]ProviderVerifier cosigners map[string]CosignerVerifier // Sets the default expiration policy to use defaultExpirationPolicy *ExpirationPolicy requireRefreshedIDToken bool } func New(verifier ProviderVerifier, options ...VerifierOpts) (*Verifier, error) { return NewFromMany([]ProviderVerifier{verifier}, options...) } func NewFromMany(verifiers []ProviderVerifier, options ...VerifierOpts) (*Verifier, error) { v := &Verifier{ providers: map[string]ProviderVerifier{}, cosigners: map[string]CosignerVerifier{}, // For user access we override the ID Token expiration claim // and instead have tokens expire after 24 hours so that // users don't have log back in every hour. defaultExpirationPolicy: &ExpirationPolicies.MAX_AGE_24HOURS, } for _, verifier := range verifiers { if _, ok := v.providers[verifier.Issuer()]; ok { return nil, fmt.Errorf("provider verifier found with duplicate issuer: %s", verifier.Issuer()) } v.providers[verifier.Issuer()] = verifier } for _, option := range options { if err := option(v); err != nil { return nil, err } } if v.defaultExpirationPolicy == nil { // Default to 24 hours if no expiration policy is set v.defaultExpirationPolicy = &ExpirationPolicies.MAX_AGE_24HOURS } return v, nil } // Verifies whether a PK token is valid and matches all expected claims. // // extraChecks: Allows for optional specification of additional checks func (v *Verifier) VerifyPKToken( ctx context.Context, pkt *pktoken.PKToken, extraChecks ...Check, ) error { // Don't even bother doing anything if the user's isn't valid if err := verifyCicSignature(pkt); err != nil { return fmt.Errorf("error verifying client signature on PK Token: %w", err) } issuer, err := pkt.Issuer() if err != nil { return err } providerVerifier, ok := v.providers[issuer] if !ok { var knownIssuers []string for k := range v.providers { knownIssuers = append(knownIssuers, k) } return fmt.Errorf("unrecognized issuer: %s, issuers known: %v", issuer, knownIssuers) } cic, err := pkt.GetCicValues() if err != nil { return err } if err := providerVerifier.VerifyIDToken(ctx, pkt.OpToken, cic); err != nil { return err } // If expiration has been set for this provider verifier use it to check expiration if providerVerifierExpires, ok := providerVerifier.(ProviderVerifierExpires); ok { if err := providerVerifierExpires.ExpirationPolicy().CheckExpiration(pkt); err != nil { return err } } else if err := v.defaultExpirationPolicy.CheckExpiration(pkt); err != nil { // Otherwise use the default expiration policy return err } if v.requireRefreshedIDToken { if reProviderVerifier, ok := providerVerifier.(RefreshableProviderVerifier); !ok { return fmt.Errorf("refreshed ID Token verification required but provider verifier (issuer=%s) does not support it", issuer) } else { if pkt.FreshIDToken == nil { return fmt.Errorf("no refreshed ID Token set") } if err := reProviderVerifier.VerifyRefreshedIDToken(ctx, pkt.OpToken, pkt.FreshIDToken); err != nil { return err } } } if len(v.cosigners) > 0 { if pkt.Cos == nil { // If there's no cosigner signature and any provided cosigner verifiers are strict, then return error for _, cosignerVerifier := range v.cosigners { if cosignerVerifier.Strict() { return fmt.Errorf("missing required cosigner signature by %s", cosignerVerifier.Issuer()) } } } else { cosignerClaims, err := pkt.ParseCosignerClaims() if err != nil { return err } cosignerVerifier, ok := v.cosigners[cosignerClaims.Issuer] if !ok { // If other cosigners are present, do we accept? return fmt.Errorf("unrecognized cosigner %s", cosignerClaims.Issuer) } // Verify cosigner signature if err := cosignerVerifier.VerifyCosigner(ctx, pkt); err != nil { return err } // If any other cosigner verifiers are set to strict but aren't present, then return error for _, cosignerVerifier := range v.cosigners { if cosignerVerifier.Strict() && cosignerVerifier.Issuer() != cosignerClaims.Issuer { return fmt.Errorf("missing required cosigner signature by %s", cosignerVerifier.Issuer()) } } } } // Cycles through any provided additional checks and returns the first error, if any. for _, check := range extraChecks { if err := check(v, pkt); err != nil { return err } } return nil } func verifyCicSignature(pkt *pktoken.PKToken) error { cic, err := pkt.GetCicValues() if err != nil { return err } _, err = jws.Verify(pkt.CicToken, jws.WithKey(cic.PublicKey().Algorithm(), cic.PublicKey())) return err } openpubkey-0.8.0/verifier/verifier_test.go000066400000000000000000000360231477254274500207410ustar00rootroot00000000000000// Copyright 2024 OpenPubkey // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package verifier_test import ( "context" "crypto/rand" "crypto/rsa" "testing" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/openpubkey/openpubkey/client" "github.com/openpubkey/openpubkey/discover" pktoken_mocks "github.com/openpubkey/openpubkey/pktoken/mocks" "github.com/openpubkey/openpubkey/providers" "github.com/openpubkey/openpubkey/providers/mocks" "github.com/openpubkey/openpubkey/util" "github.com/openpubkey/openpubkey/verifier" "github.com/stretchr/testify/require" ) func NewMockOpenIdProvider(gqSign bool, issuer string, clientID string, extraClaims map[string]any) (providers.OpenIdProvider, *mocks.MockProviderBackend, error) { providerOpts := providers.MockProviderOpts{ Issuer: issuer, ClientID: clientID, GQSign: gqSign, NumKeys: 2, CommitType: providers.CommitTypesEnum.NONCE_CLAIM, VerifierOpts: providers.ProviderVerifierOpts{ CommitType: providers.CommitTypesEnum.NONCE_CLAIM, SkipClientIDCheck: false, GQOnly: false, ClientID: clientID, }, } op, mockBackend, _, err := providers.NewMockProvider(providerOpts) if err != nil { return nil, nil, err } expSigningKey, expKeyID, expRecord := mockBackend.RandomSigningKey() idTokenTemplate := &mocks.IDTokenTemplate{ CommitFunc: mocks.AddNonceCommit, Issuer: op.Issuer(), Aud: clientID, KeyID: expKeyID, Alg: expRecord.Alg, ExtraClaims: extraClaims, SigningKey: expSigningKey, } mockBackend.SetIDTokenTemplate(idTokenTemplate) return op, mockBackend, nil } func TestVerifier(t *testing.T) { issuer := "issuer-provider" clientID := "verifier" commitType := providers.CommitTypesEnum.NONCE_CLAIM noGQSign := false GQSign := true provider, backend, err := NewMockOpenIdProvider(noGQSign, issuer, clientID, map[string]any{ "aud": clientID, }) require.NoError(t, err) providerGQ, backendGQ, err := NewMockOpenIdProvider(GQSign, issuer+"-gq", clientID, map[string]any{ "aud": clientID, }) require.NoError(t, err) opkClient, err := client.New(provider) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) require.NoError(t, err) // The below vanilla check is redundant since there is a final verification step as part of the PK token issuance pktVerifier, err := verifier.New(provider) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt) require.NoError(t, err) // Check if it handles more than one verifier pktVerifierTwoProviders, err := verifier.NewFromMany([]verifier.ProviderVerifier{provider, providerGQ}) require.NoError(t, err) opkClient, err = client.New(providerGQ) require.NoError(t, err) pktGQ, err := opkClient.Auth(context.Background()) require.NoError(t, err) err = pktVerifierTwoProviders.VerifyPKToken(context.Background(), pktGQ) require.NoError(t, err) // Check if verification fails with incorrect issuer wrongIssuer := "https://evil.com/" providerVerifier := providers.NewProviderVerifier(wrongIssuer, providers.ProviderVerifierOpts{CommitType: commitType, SkipClientIDCheck: true}) pktVerifier, err = verifier.New(providerVerifier) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt) require.Error(t, err) // Check if verification fails with incorrect commitment claim wrongCommitmentClaim := providers.CommitType{Claim: "evil"} providerVerifier = providers.NewProviderVerifier(provider.Issuer(), providers.ProviderVerifierOpts{CommitType: wrongCommitmentClaim, SkipClientIDCheck: true}) pktVerifier, err = verifier.New(providerVerifier) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt) require.Error(t, err) // When "aud" claim is a single string, check that Client ID is verified when specified correctly providerVerifier = providers.NewProviderVerifier(provider.Issuer(), providers.ProviderVerifierOpts{CommitType: commitType, ClientID: clientID, DiscoverPublicKey: &backend.PublicKeyFinder}) pktVerifier, err = verifier.New(providerVerifier) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt) require.NoError(t, err) // When "aud" claim is a single string, check that an incorrect Client ID when specified, fails wrongClientID := "super_evil" providerVerifier = providers.NewProviderVerifier(provider.Issuer(), providers.ProviderVerifierOpts{CommitType: commitType, ClientID: wrongClientID, DiscoverPublicKey: &backend.PublicKeyFinder}) pktVerifier, err = verifier.New(providerVerifier) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt) require.Error(t, err) // If audience is a list of strings, make sure verification holds. We use // extraClaims because it is resolved just token creation time, allowing // us to bypass the clientID being set by the constructor. provider, backend, err = NewMockOpenIdProvider(noGQSign, issuer, clientID, map[string]any{ "aud": []string{clientID}, }) require.NoError(t, err) opkClient, err = client.New(provider) require.NoError(t, err) pkt, err = opkClient.Auth(context.Background()) require.NoError(t, err) // When "aud" claim is a list of strings, check that Client ID is verified when specified correctly providerVerifier = providers.NewProviderVerifier(provider.Issuer(), providers.ProviderVerifierOpts{CommitType: commitType, ClientID: clientID, DiscoverPublicKey: &backend.PublicKeyFinder}) pktVerifier, err = verifier.New(providerVerifier) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt) require.NoError(t, err) // When "aud" claim is a list of strings, check that an incorrect Client ID when specified, fails providerVerifier = providers.NewProviderVerifier(provider.Issuer(), providers.ProviderVerifierOpts{CommitType: commitType, ClientID: wrongClientID, DiscoverPublicKey: &backend.PublicKeyFinder}) pktVerifier, err = verifier.New(providerVerifier) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt) require.Error(t, err) // Specify a custom public key discoverer that returns the incorrect key and check that verification fails alg := jwa.RS256 signer, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) jwksFunc, err := discover.MockGetJwksByIssuerOneKey(signer.Public(), pkt.Op.ProtectedHeaders().KeyID(), string(alg)) require.NoError(t, err) providerVerifier = providers.NewProviderVerifier(provider.Issuer(), providers.ProviderVerifierOpts{ CommitType: commitType, ClientID: clientID, DiscoverPublicKey: &discover.PublicKeyFinder{ JwksFunc: jwksFunc, }, }) pktVerifier, err = verifier.New(providerVerifier) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt) require.Error(t, err) // When the PK token does not have a GQ signature but only GQ signatures are allowed, check that verification fails providerVerifier = providers.NewProviderVerifier(provider.Issuer(), providers.ProviderVerifierOpts{CommitType: commitType, DiscoverPublicKey: &backend.PublicKeyFinder}) pktVerifier, err = verifier.New(providerVerifier) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt, verifier.GQOnly()) require.Error(t, err) // When the PK token has a GQ signature and only GQ signatures are allowed, check that verification succeeds opkClient, err = client.New(providerGQ) require.NoError(t, err) pkt, err = opkClient.Auth(context.Background()) require.NoError(t, err) providerVerifier = providers.NewProviderVerifier(providerGQ.Issuer(), providers.ProviderVerifierOpts{CommitType: commitType, ClientID: clientID, DiscoverPublicKey: &backendGQ.PublicKeyFinder}) pktVerifier, err = verifier.New(providerVerifier) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt, verifier.GQOnly()) require.NoError(t, err) } func TestVerifierRefreshedIDToken(t *testing.T) { issuer := "issuer-provider" clientID := "verifier" // commitType := providers.CommitTypesEnum.NONCE_CLAIM noGQSign := false provider, _, err := NewMockOpenIdProvider(noGQSign, issuer, clientID, map[string]any{ "aud": clientID, }) require.NoError(t, err) opkClient, err := client.New(provider) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) require.NoError(t, err) pktVerifier, err := verifier.New(provider, verifier.RequireRefreshedIDToken()) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt) require.ErrorContains(t, err, "no refreshed ID Token set") rePkt, err := opkClient.Refresh(context.Background()) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), rePkt) require.NoError(t, err) } func TestVerifierExpirationPolicy(t *testing.T) { issuer := "issuer-provider" clientID := "verifier" noGQSign := false provider, mockBackend, err := NewMockOpenIdProvider(noGQSign, issuer, clientID, map[string]any{ "aud": clientID, }) require.NoError(t, err) // Set the expiration time to 1 second past January 1, 1970 mockBackend.IDTokensTemplate.ExtraClaims = map[string]any{"exp": 1} opkClient, err := client.New(provider) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) require.NoError(t, err) pktVerifier, err := verifier.New(provider, verifier.WithExpirationPolicy(verifier.ExpirationPolicies.NEVER_EXPIRE), ) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt) require.NoError(t, err) pktVerifierWithExp, err := verifier.New(provider, verifier.WithExpirationPolicy(verifier.ExpirationPolicies.OIDC), ) require.NoError(t, err) err = pktVerifierWithExp.VerifyPKToken(context.Background(), pkt) require.ErrorContains(t, err, "the ID token has expired (exp = 1)") } func TestCICSignature(t *testing.T) { clientID := "test_client_id" alg := jwa.ES256 cicSigner, err := util.GenKeyPair(alg) require.NoError(t, err) sigFailure := "error verifying client signature on PK Token" testCases := []struct { name string expError string commitType providers.CommitType correctCicSig bool skipClientIDCheck bool }{ {name: "happy case", expError: "", commitType: providers.CommitTypesEnum.NONCE_CLAIM, correctCicSig: true, skipClientIDCheck: false}, {name: "bad sig: nonce", expError: sigFailure, commitType: providers.CommitTypesEnum.NONCE_CLAIM, correctCicSig: false, skipClientIDCheck: false}, {name: "bad sig: aud", expError: sigFailure, commitType: providers.CommitTypesEnum.AUD_CLAIM, correctCicSig: false, skipClientIDCheck: true}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { idtTemplate := mocks.DefaultIDTokenTemplate() if !tc.skipClientIDCheck { idtTemplate.Aud = clientID } tokenOpts := &pktoken_mocks.MockPKTokenOpts{ GQSign: false, CommitType: providers.CommitTypesEnum.NONCE_CLAIM, CorrectCicHash: true, CorrectCicSig: tc.correctCicSig, } if tc.commitType.Claim == "nonce" { idtTemplate.CommitFunc = mocks.AddNonceCommit } else if tc.commitType.Claim == "aud" { idtTemplate.CommitFunc = mocks.AddAudCommit } else { idtTemplate.CommitFunc = mocks.NoClaimCommit } pkt, backendMock, err := pktoken_mocks.GenerateMockPKTokenWithOpts(t, cicSigner, alg, idtTemplate, tokenOpts) require.NoError(t, err) pktVerifier, err := verifier.New(providers.NewProviderVerifier(idtTemplate.Issuer, providers.ProviderVerifierOpts{ ClientID: clientID, CommitType: tc.commitType, SkipClientIDCheck: tc.skipClientIDCheck, DiscoverPublicKey: &backendMock.PublicKeyFinder, })) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt) if tc.expError != "" { require.ErrorContains(t, err, tc.expError) } else { require.NoError(t, err) } }) } } func TestGQCommitment(t *testing.T) { gqBindingAud := providers.AudPrefixForGQCommitment + "1234" testCases := []struct { name string aud string expError string gqSign bool gqCommitment bool gqOnly bool }{ {name: "happy case", aud: gqBindingAud, expError: "", gqSign: true, gqCommitment: true, gqOnly: true}, {name: "wrong aud prefix", aud: "bad value", expError: "error verifying PK Token: audience claim in PK Token's GQCommitment must be prefixed by", gqSign: true, gqCommitment: true, gqOnly: true}, {name: "gqSign is false", aud: providers.AudPrefixForGQCommitment, expError: "if GQCommitment is true then GQSign must also be true", gqSign: false, gqCommitment: true, gqOnly: true}, {name: "gqCommitment is false", aud: providers.AudPrefixForGQCommitment, expError: "verifier configured with empty commitment claim", gqSign: true, gqCommitment: false, gqOnly: true}, {name: "gqOnly is false", aud: providers.AudPrefixForGQCommitment, expError: "error verifying PK Token: GQCommitment requires that GQOnly is true, but GQOnly is (false)", gqSign: true, gqCommitment: true, gqOnly: false}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { skipClientIDCheck := true commitType := providers.CommitType{ GQCommitment: tc.gqCommitment, } clientID := "test_client_id" providerOpts := providers.MockProviderOpts{ ClientID: clientID, GQSign: tc.gqSign, NumKeys: 2, CommitType: commitType, VerifierOpts: providers.ProviderVerifierOpts{ CommitType: commitType, SkipClientIDCheck: skipClientIDCheck, GQOnly: tc.gqOnly, ClientID: clientID, }, } provider, _, idtTemplate, err := providers.NewMockProvider(providerOpts) require.NoError(t, err) idtTemplate.Aud = tc.aud require.NoError(t, err) opkClient, err := client.New(provider) require.NoError(t, err) pkt, err := opkClient.Auth(context.Background()) if tc.expError != "" { require.ErrorContains(t, err, tc.expError) } else { require.NoError(t, err) cicHash, ok := pkt.Op.ProtectedHeaders().Get("cic") if tc.gqCommitment == false { require.False(t, ok) require.Nil(t, cicHash) } else { require.True(t, ok) require.NotNil(t, cicHash) cic, err := pkt.GetCicValues() require.NoError(t, err) require.NotNil(t, cic) cicHashFromCIC, err := cic.Hash() require.NoError(t, err) require.Equal(t, string(cicHashFromCIC), cicHash, "CIC does not match cicHash in GQ commitment") } require.NoError(t, err) pktVerifier, err := verifier.New(provider) require.NoError(t, err) err = pktVerifier.VerifyPKToken(context.Background(), pkt) require.NoError(t, err) } }) } }