├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── cmd ├── gen_ec │ └── main.go ├── gen_pkcs1 │ └── main.go └── jwkset │ └── main.go ├── constants.go ├── constants_test.go ├── error_test.go ├── examples ├── default_http_client │ └── main.go ├── http_server │ └── main.go ├── individual_keys │ └── main.go └── storage_operations │ ├── go.mod │ ├── go.sum │ └── main.go ├── go.mod ├── go.sum ├── http.go ├── http_test.go ├── jwk.go ├── jwk_test.go ├── marshal.go ├── marshal_test.go ├── storage.go ├── storage_test.go ├── website ├── .dockerignore ├── Dockerfile ├── README.md ├── cmd │ └── server │ │ └── main.go ├── config.go ├── constant.go ├── css.sh ├── embed.go ├── go.mod ├── go.sum ├── handle │ ├── api │ │ ├── inspect.go │ │ ├── new_gen.go │ │ ├── pem_gen.go │ │ └── util.go │ └── template │ │ ├── generate.go │ │ ├── index.go │ │ └── inspect.go ├── input.css ├── package-lock.json ├── package.json ├── server │ └── server.go ├── static │ ├── apple-touch-icon.png │ ├── css │ │ ├── all.min.css │ │ └── tailwind.min.css │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── js │ │ ├── generate.js │ │ ├── inspect.js │ │ └── wrapper.js │ ├── robots.txt │ └── webfonts │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.woff2 │ │ ├── fa-solid-900.ttf │ │ └── fa-solid-900.woff2 ├── tailwind.config.js └── templates │ ├── generate.gohtml │ ├── index.gohtml │ ├── inspect.gohtml │ └── wrapper.gohtml ├── x509.go ├── x509_gen.sh └── x509_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: MicahParks 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.*json 2 | node_modules 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Micah Parks 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Reference](https://pkg.go.dev/badge/github.com/MicahParks/jwkset.svg)](https://pkg.go.dev/github.com/MicahParks/jwkset) 2 | 3 | # JWK Set (JSON Web Key Set) 4 | 5 | This is a JWK Set (JSON Web Key Set) implementation written in Golang. 6 | 7 | The goal of this project is to provide a complete implementation of JWK and JWK Sets within the constraints of the 8 | Golang standard library, without implementing any cryptographic algorithms. For example, `Ed25519` is supported, but 9 | `Ed448` is not, because the Go standard library does not have a high level implementation of `Ed448`. 10 | 11 | If you would like to generate or validate a JWK without writing any Golang code, please visit 12 | the [Generate a JWK Set](#generate-a-jwk-set) section. 13 | 14 | If you would like to have a JWK Set client to help verify JWTs without writing any Golang code, you can use the 15 | [JWK Set Client Proxy (JCP) project](https://github.com/MicahParks/jcp) perform JWK Set client operations in the 16 | language of your choice using an OpenAPI interface. 17 | 18 | # Generate a JWK Set 19 | 20 | If you would like to generate a JWK Set without writing Golang code, this project publishes utilities to generate a JWK 21 | Set from: 22 | 23 | * PEM encoded X.509 Certificates 24 | * PEM encoded public keys 25 | * PEM encoded private keys 26 | 27 | The PEM block type is used to infer which key type to decode. Reference the [Supported keys](#supported-keys) section 28 | for a list of supported cryptographic key types. 29 | 30 | ## Website 31 | 32 | Visit [https://jwkset.com](https://jwkset.com) to use the web interface for this project. You can self-host this website 33 | by following the instructions in the `README.md` in 34 | the [website](https://github.com/MicahParks/jwkset/tree/master/website) directory. 35 | 36 | ## Command line 37 | 38 | Gather your PEM encoded keys or certificates and use the `cmd/jwkset` command line tool to generate a JWK Set. 39 | 40 | **Install** 41 | 42 | ``` 43 | go install github.com/MicahParks/jwkset/cmd/jwkset@latest 44 | ``` 45 | 46 | **Usage** 47 | 48 | ``` 49 | jwkset mykey.pem mycert.crt 50 | ``` 51 | 52 | ## Custom server 53 | 54 | This project can be used in creating a custom JWK Set server. A good place to start is `examples/http_server/main.go`. 55 | 56 | # Golang JWK Set client 57 | 58 | If you are using [`github.com/golang-jwt/jwt/v5`](https://github.com/golang-jwt/jwt) take a look 59 | at [`github.com/MicahParks/keyfunc/v3`](https://github.com/MicahParks/keyfunc). 60 | 61 | This project can be used to create JWK Set clients. An HTTP client is provided. See a snippet of the usage 62 | from `examples/default_http_client/main.go` below. 63 | 64 | ## Create a JWK Set client from the server's HTTP URL. 65 | 66 | ```go 67 | jwks, err := jwkset.NewDefaultHTTPClient([]string{server.URL}) 68 | if err != nil { 69 | log.Fatalf("Failed to create client JWK set. Error: %s", err) 70 | } 71 | ``` 72 | 73 | ## Read a key from the client. 74 | 75 | ```go 76 | jwk, err = jwks.KeyRead(ctx, myKeyID) 77 | if err != nil { 78 | log.Fatalf("Failed to read key from client JWK set. Error: %s", err) 79 | } 80 | ``` 81 | 82 | # Supported keys 83 | 84 | This project supports the following key types: 85 | 86 | * [Edwards-curve Digital Signature Algorithm (EdDSA)](https://en.wikipedia.org/wiki/EdDSA) (Ed25519 only) 87 | * Go Types: `ed25519.PrivateKey` and `ed25519.PublicKey` 88 | * [Elliptic-curve Diffie–Hellman (ECDH)](https://en.wikipedia.org/wiki/Elliptic-curve_Diffie%E2%80%93Hellman) (X25519 89 | only) 90 | * Go Types: `*ecdh.PrivateKey` and `*ecdh.PublicKey` 91 | * [Elliptic Curve Digital Signature Algorithm (ECDSA)](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm) 92 | * Go Types: `*ecdsa.PrivateKey` and `*ecdsa.PublicKey` 93 | * [Rivest–Shamir–Adleman (RSA)](https://en.wikipedia.org/wiki/RSA_(cryptosystem)) 94 | * Go Types: `*rsa.PrivateKey` and `*rsa.PublicKey` 95 | * [HMAC](https://en.wikipedia.org/wiki/HMAC), [AES Key Wrap](https://en.wikipedia.org/wiki/Key_Wrap), and other 96 | symmetric keys 97 | * Go Type: `[]byte` 98 | 99 | Cryptographic keys can be added, deleted, and read from the JWK Set. A JSON representation of the JWK Set can be created 100 | for hosting via HTTPS. This project includes an in-memory storage implementation, but an interface is provided for more 101 | advanced use cases. 102 | 103 | # Notes 104 | 105 | This project aims to implement the relevant RFCs to the fullest extent possible using the Go standard library, but does 106 | not implement any cryptographic algorithms itself. 107 | 108 | * RFC 8037 adds support for `Ed448`, `X448`, and `secp256k1`, but there is no Golang standard library support for these 109 | key types. 110 | * In order to be compatible with non-RFC compliant JWK Set providers, this project does not strictly enforce JWK 111 | parameters that are integers and have extra or missing leading padding. See the release notes 112 | of [`v0.5.15`](https://github.com/MicahParks/jwkset/releases/tag/v0.5.15) for details. 113 | * `Base64url Encoding` requires that all trailing `=` characters be removed. This project automatically strips any 114 | trailing `=` characters in an attempt to be compatible with improper implementations of JWK. 115 | * This project does not currently support JWK Set encryption using JWE. This would involve implementing the relevant JWE 116 | specifications. It may be implemented in the future if there is interest. Open a GitHub issue to express interest. 117 | 118 | # Related projects 119 | 120 | ## [`github.com/MicahParks/keyfunc`](https://github.com/MicahParks/keyfunc) 121 | 122 | A JWK Set client for the [`github.com/golang-jwt/jwt/v5`](https://github.com/golang-jwt/jwt) project. 123 | -------------------------------------------------------------------------------- /cmd/gen_ec/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "crypto/rand" 7 | "crypto/x509" 8 | "encoding/pem" 9 | "log" 10 | "os" 11 | ) 12 | 13 | const ( 14 | logFmt = "%s\nError: %s" 15 | privFile = "ec256SEC1Priv.pem" 16 | ) 17 | 18 | func main() { 19 | priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 20 | if err != nil { 21 | log.Fatalf(logFmt, "Failed to generate EC key.", err) 22 | } 23 | 24 | pemBytes, err := x509.MarshalECPrivateKey(priv) 25 | if err != nil { 26 | log.Fatalf(logFmt, "Failed to marshal EC private key.", err) 27 | } 28 | block := &pem.Block{ 29 | Type: "EC PRIVATE KEY", 30 | Bytes: pemBytes, 31 | } 32 | out := pem.EncodeToMemory(block) 33 | 34 | err = os.WriteFile(privFile, out, 0644) 35 | if err != nil { 36 | log.Fatalf(logFmt, "Failed to write EC private key.", err) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /cmd/gen_pkcs1/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "log" 9 | "os" 10 | ) 11 | 12 | const ( 13 | logFmt = "%s\nError: %s" 14 | privFile = "rsa2048PKCS1Priv.pem" 15 | pubFile = "rsa2048PKCS1Pub.pem" 16 | ) 17 | 18 | func main() { 19 | priv, err := rsa.GenerateKey(rand.Reader, 2048) 20 | if err != nil { 21 | log.Fatalf(logFmt, "Failed to generate RSA key.", err) 22 | } 23 | 24 | block := &pem.Block{ 25 | Type: "RSA PRIVATE KEY", 26 | Bytes: x509.MarshalPKCS1PrivateKey(priv), 27 | } 28 | out := pem.EncodeToMemory(block) 29 | 30 | err = os.WriteFile(privFile, out, 0644) 31 | if err != nil { 32 | log.Fatalf(logFmt, "Failed to write RSA private key.", err) 33 | } 34 | 35 | pub := &priv.PublicKey 36 | block = &pem.Block{ 37 | Type: "RSA PUBLIC KEY", 38 | Bytes: x509.MarshalPKCS1PublicKey(pub), 39 | } 40 | out = pem.EncodeToMemory(block) 41 | 42 | err = os.WriteFile(pubFile, out, 0644) 43 | if err != nil { 44 | log.Fatalf(logFmt, "Failed to write RSA public key.", err) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /cmd/jwkset/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/x509" 7 | "encoding/json" 8 | "encoding/pem" 9 | "log/slog" 10 | "os" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/MicahParks/jwkset" 15 | ) 16 | 17 | const ( 18 | logErr = "error" 19 | ) 20 | 21 | func main() { 22 | ctx, cancel := context.WithCancel(context.Background()) 23 | defer cancel() 24 | l := slog.New(slog.NewTextHandler(os.Stderr, nil)) 25 | 26 | allPEM := os.Getenv("PEM") 27 | if allPEM == "" { 28 | s := strings.Builder{} 29 | if len(os.Args) < 2 { 30 | l.Error("Please provide a list of PEM encoded files as CLI arguments or set the PEM environment variable.") 31 | os.Exit(1) 32 | } 33 | for _, fileName := range os.Args[1:] { 34 | b, err := os.ReadFile(fileName) 35 | if err != nil { 36 | l.Error("Failed to read file.", 37 | "fileName", fileName, 38 | ) 39 | os.Exit(1) 40 | } 41 | s.Write(bytes.TrimSpace(b)) 42 | s.WriteRune('\n') 43 | } 44 | allPEM = s.String() 45 | } 46 | 47 | jwks := jwkset.NewMemoryStorage() 48 | 49 | i := 0 50 | const kidPrefix = "UniqueKeyID" 51 | allPEMB := []byte(allPEM) 52 | for { 53 | metadata := jwkset.JWKMetadataOptions{} 54 | i++ 55 | block, rest := pem.Decode(allPEMB) 56 | if block == nil { 57 | break 58 | } 59 | allPEMB = rest 60 | switch block.Type { 61 | case "CERTIFICATE": 62 | cert, err := jwkset.LoadCertificate(block.Bytes) 63 | if err != nil { 64 | l.Error("Failed to load certificates.", 65 | logErr, err, 66 | ) 67 | os.Exit(1) 68 | } 69 | metadata.KID = kidPrefix + strconv.Itoa(i) 70 | x509Options := jwkset.JWKX509Options{ 71 | X5C: []*x509.Certificate{cert}, 72 | } 73 | options := jwkset.JWKOptions{ 74 | Metadata: metadata, 75 | X509: x509Options, 76 | } 77 | jwk, err := jwkset.NewJWKFromX5C(options) 78 | if err != nil { 79 | l.Error("Failed to create JWK from X5C.", 80 | logErr, err, 81 | ) 82 | os.Exit(1) 83 | } 84 | err = jwks.KeyWrite(ctx, jwk) 85 | if err != nil { 86 | l.Error("Failed to write JWK.", 87 | logErr, err, 88 | ) 89 | os.Exit(1) 90 | } 91 | default: 92 | key, err := jwkset.LoadX509KeyInfer(block) 93 | if err != nil { 94 | l.Error("Failed to load X509 key.", 95 | logErr, err, 96 | ) 97 | os.Exit(1) 98 | } 99 | metadata.KID = kidPrefix + strconv.Itoa(i) 100 | marshalOptions := jwkset.JWKMarshalOptions{ 101 | Private: true, 102 | } 103 | options := jwkset.JWKOptions{ 104 | Marshal: marshalOptions, 105 | Metadata: metadata, 106 | } 107 | jwk, err := jwkset.NewJWKFromKey(key, options) 108 | if err != nil { 109 | l.Error("Failed to create JWK from key.", 110 | logErr, err, 111 | ) 112 | os.Exit(1) 113 | } 114 | err = jwks.KeyWrite(ctx, jwk) 115 | if err != nil { 116 | l.Error("Failed to write JWK.", 117 | logErr, err, 118 | ) 119 | os.Exit(1) 120 | } 121 | } 122 | } 123 | 124 | marshal, err := jwks.Marshal(ctx) 125 | if err != nil { 126 | l.Error("Failed to marshal JWK set.", 127 | logErr, err, 128 | ) 129 | os.Exit(1) 130 | } 131 | 132 | b, err := json.MarshalIndent(marshal, "", " ") 133 | if err != nil { 134 | l.Error("Failed to marshal JSON.", 135 | logErr, err, 136 | ) 137 | os.Exit(1) 138 | } 139 | 140 | _, _ = os.Stdout.Write(b) 141 | } 142 | -------------------------------------------------------------------------------- /constants.go: -------------------------------------------------------------------------------- 1 | package jwkset 2 | 3 | const ( 4 | // HeaderKID is a JWT header for the key ID. 5 | HeaderKID = "kid" 6 | ) 7 | 8 | // These are string constants set in https://www.iana.org/assignments/jose/jose.xhtml 9 | // See their respective types for more information. 10 | const ( 11 | AlgHS256 ALG = "HS256" 12 | AlgHS384 ALG = "HS384" 13 | AlgHS512 ALG = "HS512" 14 | AlgRS256 ALG = "RS256" 15 | AlgRS384 ALG = "RS384" 16 | AlgRS512 ALG = "RS512" 17 | AlgES256 ALG = "ES256" 18 | AlgES384 ALG = "ES384" 19 | AlgES512 ALG = "ES512" 20 | AlgPS256 ALG = "PS256" 21 | AlgPS384 ALG = "PS384" 22 | AlgPS512 ALG = "PS512" 23 | AlgNone ALG = "none" 24 | AlgRSA1_5 ALG = "RSA1_5" 25 | AlgRSAOAEP ALG = "RSA-OAEP" 26 | AlgRSAOAEP256 ALG = "RSA-OAEP-256" 27 | AlgA128KW ALG = "A128KW" 28 | AlgA192KW ALG = "A192KW" 29 | AlgA256KW ALG = "A256KW" 30 | AlgDir ALG = "dir" 31 | AlgECDHES ALG = "ECDH-ES" 32 | AlgECDHESA128KW ALG = "ECDH-ES+A128KW" 33 | AlgECDHESA192KW ALG = "ECDH-ES+A192KW" 34 | AlgECDHESA256KW ALG = "ECDH-ES+A256KW" 35 | AlgA128GCMKW ALG = "A128GCMKW" 36 | AlgA192GCMKW ALG = "A192GCMKW" 37 | AlgA256GCMKW ALG = "A256GCMKW" 38 | AlgPBES2HS256A128KW ALG = "PBES2-HS256+A128KW" 39 | AlgPBES2HS384A192KW ALG = "PBES2-HS384+A192KW" 40 | AlgPBES2HS512A256KW ALG = "PBES2-HS512+A256KW" 41 | AlgA128CBCHS256 ALG = "A128CBC-HS256" 42 | AlgA192CBCHS384 ALG = "A192CBC-HS384" 43 | AlgA256CBCHS512 ALG = "A256CBC-HS512" 44 | AlgA128GCM ALG = "A128GCM" 45 | AlgA192GCM ALG = "A192GCM" 46 | AlgA256GCM ALG = "A256GCM" 47 | AlgEdDSA ALG = "EdDSA" 48 | AlgRS1 ALG = "RS1" // Prohibited. 49 | AlgRSAOAEP384 ALG = "RSA-OAEP-384" 50 | AlgRSAOAEP512 ALG = "RSA-OAEP-512" 51 | AlgA128CBC ALG = "A128CBC" // Prohibited. 52 | AlgA192CBC ALG = "A192CBC" // Prohibited. 53 | AlgA256CBC ALG = "A256CBC" // Prohibited. 54 | AlgA128CTR ALG = "A128CTR" // Prohibited. 55 | AlgA192CTR ALG = "A192CTR" // Prohibited. 56 | AlgA256CTR ALG = "A256CTR" // Prohibited. 57 | AlgHS1 ALG = "HS1" // Prohibited. 58 | AlgES256K ALG = "ES256K" 59 | 60 | CrvP256 CRV = "P-256" 61 | CrvP384 CRV = "P-384" 62 | CrvP521 CRV = "P-521" 63 | CrvEd25519 CRV = "Ed25519" 64 | CrvEd448 CRV = "Ed448" 65 | CrvX25519 CRV = "X25519" 66 | CrvX448 CRV = "X448" 67 | CrvSECP256K1 CRV = "secp256k1" 68 | 69 | KeyOpsSign KEYOPS = "sign" 70 | KeyOpsVerify KEYOPS = "verify" 71 | KeyOpsEncrypt KEYOPS = "encrypt" 72 | KeyOpsDecrypt KEYOPS = "decrypt" 73 | KeyOpsWrapKey KEYOPS = "wrapKey" 74 | KeyOpsUnwrapKey KEYOPS = "unwrapKey" 75 | KeyOpsDeriveKey KEYOPS = "deriveKey" 76 | KeyOpsDeriveBits KEYOPS = "deriveBits" 77 | 78 | KtyEC KTY = "EC" 79 | KtyOKP KTY = "OKP" 80 | KtyRSA KTY = "RSA" 81 | KtyOct KTY = "oct" 82 | 83 | UseEnc USE = "enc" 84 | UseSig USE = "sig" 85 | ) 86 | 87 | // ALG is a set of "JSON Web Signature and Encryption Algorithms" types from 88 | // https://www.iana.org/assignments/jose/jose.xhtml as defined in 89 | // https://www.rfc-editor.org/rfc/rfc7518#section-7.1 90 | type ALG string 91 | 92 | func (alg ALG) IANARegistered() bool { 93 | switch alg { 94 | case AlgHS256, AlgHS384, AlgHS512, AlgRS256, AlgRS384, AlgRS512, AlgES256, AlgES384, AlgES512, AlgPS256, AlgPS384, 95 | AlgPS512, AlgNone, AlgRSA1_5, AlgRSAOAEP, AlgRSAOAEP256, AlgA128KW, AlgA192KW, AlgA256KW, AlgDir, AlgECDHES, 96 | AlgECDHESA128KW, AlgECDHESA192KW, AlgECDHESA256KW, AlgA128GCMKW, AlgA192GCMKW, AlgA256GCMKW, 97 | AlgPBES2HS256A128KW, AlgPBES2HS384A192KW, AlgPBES2HS512A256KW, AlgA128CBCHS256, AlgA192CBCHS384, 98 | AlgA256CBCHS512, AlgA128GCM, AlgA192GCM, AlgA256GCM, AlgEdDSA, AlgRS1, AlgRSAOAEP384, AlgRSAOAEP512, AlgA128CBC, 99 | AlgA192CBC, AlgA256CBC, AlgA128CTR, AlgA192CTR, AlgA256CTR, AlgHS1, AlgES256K, "": 100 | return true 101 | } 102 | return false 103 | } 104 | func (alg ALG) String() string { 105 | return string(alg) 106 | } 107 | 108 | // CRV is a set of "JSON Web Key Elliptic Curve" types from https://www.iana.org/assignments/jose/jose.xhtml as 109 | // mentioned in https://www.rfc-editor.org/rfc/rfc7518.html#section-6.2.1.1 110 | type CRV string 111 | 112 | func (crv CRV) IANARegistered() bool { 113 | switch crv { 114 | case CrvP256, CrvP384, CrvP521, CrvEd25519, CrvEd448, CrvX25519, CrvX448, CrvSECP256K1, "": 115 | return true 116 | } 117 | return false 118 | } 119 | func (crv CRV) String() string { 120 | return string(crv) 121 | } 122 | 123 | // KEYOPS is a set of "JSON Web Key Operations" from https://www.iana.org/assignments/jose/jose.xhtml as mentioned in 124 | // https://www.rfc-editor.org/rfc/rfc7517#section-4.3 125 | type KEYOPS string 126 | 127 | func (keyopts KEYOPS) IANARegistered() bool { 128 | switch keyopts { 129 | case KeyOpsSign, KeyOpsVerify, KeyOpsEncrypt, KeyOpsDecrypt, KeyOpsWrapKey, KeyOpsUnwrapKey, KeyOpsDeriveKey, 130 | KeyOpsDeriveBits: 131 | return true 132 | } 133 | return false 134 | } 135 | func (keyopts KEYOPS) String() string { 136 | return string(keyopts) 137 | } 138 | 139 | // KTY is a set of "JSON Web Key Types" from https://www.iana.org/assignments/jose/jose.xhtml as mentioned in 140 | // https://www.rfc-editor.org/rfc/rfc7517#section-4.1 141 | type KTY string 142 | 143 | func (kty KTY) IANARegistered() bool { 144 | switch kty { 145 | case KtyEC, KtyOKP, KtyRSA, KtyOct: 146 | return true 147 | } 148 | return false 149 | } 150 | func (kty KTY) String() string { 151 | return string(kty) 152 | } 153 | 154 | // USE is a set of "JSON Web Key Use" types from https://www.iana.org/assignments/jose/jose.xhtml as mentioned in 155 | // https://www.rfc-editor.org/rfc/rfc7517#section-4.2 156 | type USE string 157 | 158 | func (use USE) IANARegistered() bool { 159 | switch use { 160 | case UseEnc, UseSig, "": 161 | return true 162 | } 163 | return false 164 | } 165 | func (use USE) String() string { 166 | return string(use) 167 | } 168 | -------------------------------------------------------------------------------- /constants_test.go: -------------------------------------------------------------------------------- 1 | package jwkset 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | const ( 8 | invalid = "invalid" 9 | ) 10 | 11 | func TestALG(t *testing.T) { 12 | a := AlgHS256 13 | if a.String() != string(a) { 14 | t.Errorf("Failed to get proper string from String method.") 15 | } 16 | if !a.IANARegistered() { 17 | t.Errorf("Failed to validate valid ALG.") 18 | } 19 | a = invalid 20 | if a.IANARegistered() { 21 | t.Errorf("Do not validate invalid ALG.") 22 | } 23 | } 24 | 25 | func TestCRV(t *testing.T) { 26 | c := CrvP256 27 | if c.String() != string(c) { 28 | t.Errorf("Failed to get proper string from String method.") 29 | } 30 | if !c.IANARegistered() { 31 | t.Errorf("Failed to validate valid CRV.") 32 | } 33 | c = invalid 34 | if c.IANARegistered() { 35 | t.Errorf("Do not validate invalid CRV.") 36 | } 37 | } 38 | 39 | func TestKEYOPS(t *testing.T) { 40 | k := KeyOpsSign 41 | if k.String() != string(k) { 42 | t.Errorf("Failed to get proper string from String method.") 43 | } 44 | if !k.IANARegistered() { 45 | t.Errorf("Failed to validate valid KEYOPS.") 46 | } 47 | k = invalid 48 | if k.IANARegistered() { 49 | t.Errorf("Do not validate invalid KEYOPS.") 50 | } 51 | } 52 | 53 | func TestKTY(t *testing.T) { 54 | k := KtyEC 55 | if k.String() != string(k) { 56 | t.Errorf("Failed to get proper string from String method.") 57 | } 58 | if !k.IANARegistered() { 59 | t.Errorf("Failed to validate valid KTY.") 60 | } 61 | k = invalid 62 | if k.IANARegistered() { 63 | t.Errorf("Do not validate invalid KTY.") 64 | } 65 | } 66 | 67 | func TestUSE(t *testing.T) { 68 | u := UseEnc 69 | if u.String() != string(u) { 70 | t.Errorf("Failed to get proper string from String method.") 71 | } 72 | if !u.IANARegistered() { 73 | t.Errorf("Failed to validate valid USE.") 74 | } 75 | u = invalid 76 | if u.IANARegistered() { 77 | t.Errorf("Do not validate invalid USE.") 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /error_test.go: -------------------------------------------------------------------------------- 1 | package jwkset 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | var ( 12 | errStorage = errors.New("storage error") 13 | ) 14 | 15 | type storageError struct{} 16 | 17 | func (s storageError) KeyDelete(_ context.Context, _ string) (ok bool, err error) { 18 | return false, errStorage 19 | } 20 | func (s storageError) KeyReplaceAll(_ context.Context, _ []JWK) error { 21 | return errStorage 22 | } 23 | func (s storageError) KeyRead(_ context.Context, _ string) (JWK, error) { 24 | return JWK{}, errStorage 25 | } 26 | func (s storageError) KeyReadAll(_ context.Context) ([]JWK, error) { 27 | return nil, errStorage 28 | } 29 | func (s storageError) KeyWrite(_ context.Context, _ JWK) error { 30 | return errStorage 31 | } 32 | 33 | func (s storageError) JSON(_ context.Context) (json.RawMessage, error) { 34 | return nil, errStorage 35 | } 36 | func (s storageError) JSONPublic(_ context.Context) (json.RawMessage, error) { 37 | return nil, errStorage 38 | } 39 | func (s storageError) JSONPrivate(_ context.Context) (json.RawMessage, error) { 40 | return nil, errStorage 41 | } 42 | func (s storageError) JSONWithOptions(_ context.Context, _ JWKMarshalOptions, _ JWKValidateOptions) (json.RawMessage, error) { 43 | return nil, errStorage 44 | } 45 | func (s storageError) Marshal(_ context.Context) (JWKSMarshal, error) { 46 | return JWKSMarshal{}, errStorage 47 | } 48 | func (s storageError) MarshalWithOptions(_ context.Context, _ JWKMarshalOptions, _ JWKValidateOptions) (JWKSMarshal, error) { 49 | return JWKSMarshal{}, errStorage 50 | } 51 | 52 | func TestStorageError(t *testing.T) { 53 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 54 | defer cancel() 55 | 56 | jwks := storageError{} 57 | 58 | _, err := jwks.JSONPublic(ctx) 59 | if err == nil { 60 | t.Fatalf("Expected error, but got none.") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /examples/default_http_client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/ed25519" 7 | "crypto/rand" 8 | "log" 9 | "net/http" 10 | "net/http/httptest" 11 | 12 | "github.com/MicahParks/jwkset" 13 | ) 14 | 15 | const myKeyID = "my-key-id" 16 | 17 | func main() { 18 | ctx, cancel := context.WithCancel(context.Background()) 19 | defer cancel() 20 | 21 | // Set up the server. 22 | serverStore := jwkset.NewMemoryStorage() 23 | _, priv, err := ed25519.GenerateKey(rand.Reader) 24 | if err != nil { 25 | log.Fatalf("Failed to generate key pair for server. Error: %s", err) 26 | } 27 | metadata := jwkset.JWKMetadataOptions{ 28 | KID: myKeyID, 29 | } 30 | jwkOptions := jwkset.JWKOptions{ 31 | Metadata: metadata, 32 | } 33 | jwk, err := jwkset.NewJWKFromKey(priv, jwkOptions) 34 | if err != nil { 35 | log.Fatalf("Failed to create JWK for server. Error: %s", err) 36 | } 37 | err = serverStore.KeyWrite(ctx, jwk) 38 | if err != nil { 39 | log.Fatalf("Failed to write JWK for server. Error: %s", err) 40 | } 41 | rawJWKS, err := serverStore.JSONPrivate(ctx) 42 | if err != nil { 43 | log.Fatalf("Failed to get JWK set for server. Error: %s", err) 44 | } 45 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | _, _ = w.Write(rawJWKS) 47 | })) 48 | 49 | // Create a JWK Set client from the server's HTTP URL. 50 | jwks, err := jwkset.NewDefaultHTTPClient([]string{server.URL}) 51 | if err != nil { 52 | log.Fatalf("Failed to create client JWK set. Error: %s", err) 53 | } 54 | 55 | // Read a key from the client. 56 | jwk, err = jwks.KeyRead(ctx, myKeyID) 57 | if err != nil { 58 | log.Fatalf("Failed to read key from client JWK set. Error: %s", err) 59 | } 60 | 61 | // Verify the key is correct. (Optional) 62 | if !bytes.Equal(jwk.Key().(ed25519.PrivateKey), priv) { 63 | log.Fatalf("Client JWK set returned the wrong key.") 64 | } 65 | println("The correct key was returned and is ready to be used from the client storage.") 66 | } 67 | -------------------------------------------------------------------------------- /examples/http_server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "log" 8 | "net/http" 9 | "os" 10 | 11 | "github.com/MicahParks/jwkset" 12 | ) 13 | 14 | const ( 15 | logFmt = "%s\nError: %s" 16 | ) 17 | 18 | func main() { 19 | ctx := context.Background() 20 | logger := log.New(os.Stdout, "", 0) 21 | 22 | jwkSet := jwkset.NewMemoryStorage() 23 | 24 | // Create an RSA key. 25 | key, err := rsa.GenerateKey(rand.Reader, 4096) 26 | if err != nil { 27 | logger.Fatalf(logFmt, "Failed to generate RSA key.", err) 28 | } 29 | 30 | // Create the JWK options. 31 | metadata := jwkset.JWKMetadataOptions{ 32 | KID: "my-key-id", // Not technically required, but is required for JWK Set operations using this package. 33 | } 34 | options := jwkset.JWKOptions{ 35 | Metadata: metadata, 36 | } 37 | 38 | // Create the JWK from the key and options. 39 | jwk, err := jwkset.NewJWKFromKey(key, options) 40 | if err != nil { 41 | logger.Fatalf(logFmt, "Failed to create JWK from key.", err) 42 | } 43 | 44 | // Write the key to the JWK Set storage. 45 | err = jwkSet.KeyWrite(ctx, jwk) 46 | if err != nil { 47 | logger.Fatalf(logFmt, "Failed to store RSA key.", err) 48 | } 49 | 50 | http.HandleFunc("/jwks.json", func(writer http.ResponseWriter, request *http.Request) { 51 | // TODO Cache the JWK Set so storage isn't called for every request. 52 | response, err := jwkSet.JSONPublic(request.Context()) 53 | if err != nil { 54 | logger.Printf(logFmt, "Failed to get JWK Set JSON.", err) 55 | writer.WriteHeader(http.StatusInternalServerError) 56 | return 57 | } 58 | 59 | writer.Header().Set("Content-Type", "application/json") 60 | _, _ = writer.Write(response) 61 | }) 62 | 63 | logger.Print("Visit: http://localhost:8080/jwks.json") 64 | logger.Fatalf("Failed to listen and serve: %s", http.ListenAndServe(":8080", nil)) 65 | } 66 | -------------------------------------------------------------------------------- /examples/individual_keys/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/ed25519" 5 | "crypto/rand" 6 | "encoding/json" 7 | "log" 8 | "os" 9 | 10 | "github.com/MicahParks/jwkset" 11 | ) 12 | 13 | const logFmt = "%s\nError: %s" 14 | 15 | func main() { 16 | logger := log.New(os.Stdout, "", 0) 17 | 18 | // Create an EdDSA key. 19 | public, _, err := ed25519.GenerateKey(rand.Reader) 20 | if err != nil { 21 | logger.Fatalf(logFmt, "Failed to generate EdDSA key.", err) 22 | } 23 | 24 | // Create the JWK options. 25 | metadata := jwkset.JWKMetadataOptions{ 26 | KID: "my-key-id", // Not technically required, but is required for JWK Set operations using this package. 27 | } 28 | options := jwkset.JWKOptions{ 29 | Metadata: metadata, 30 | } 31 | 32 | // Create the JWK from the key and options. 33 | jwk, err := jwkset.NewJWKFromKey(public, options) 34 | if err != nil { 35 | logger.Fatalf(logFmt, "Failed to create JWK from key.", err) 36 | } 37 | 38 | // Use the marshal type to marshal the key into a raw JSON. 39 | j, err := json.MarshalIndent(jwk.Marshal(), "", " ") 40 | if err != nil { 41 | logger.Fatalf(logFmt, "Failed to marshal JSON.", err) 42 | } 43 | println(string(j)) 44 | 45 | // Create a new JWK from the raw JSON. 46 | jwk, err = jwkset.NewJWKFromRawJSON(j, jwkset.JWKMarshalOptions{}, jwkset.JWKValidateOptions{}) 47 | if err != nil { 48 | logger.Fatalf(logFmt, "Failed to create JWK from raw JSON.", err) 49 | } 50 | println(jwk.Marshal().KID) 51 | } 52 | -------------------------------------------------------------------------------- /examples/storage_operations/go.mod: -------------------------------------------------------------------------------- 1 | module readme 2 | 3 | go 1.21 4 | 5 | replace github.com/MicahParks/jwkset => ../.. 6 | 7 | require ( 8 | github.com/MicahParks/jwkset v0.8.0 9 | github.com/google/uuid v1.6.0 10 | ) 11 | 12 | require golang.org/x/time v0.9.0 // indirect 13 | -------------------------------------------------------------------------------- /examples/storage_operations/go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 2 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 3 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 4 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 5 | -------------------------------------------------------------------------------- /examples/storage_operations/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/ecdsa" 6 | "crypto/ed25519" 7 | "crypto/elliptic" 8 | "crypto/rand" 9 | "crypto/rsa" 10 | "encoding/base64" 11 | "log" 12 | "os" 13 | 14 | "github.com/google/uuid" 15 | 16 | "github.com/MicahParks/jwkset" 17 | ) 18 | 19 | const logFmt = "%s\nError: %s" 20 | 21 | func main() { 22 | ctx := context.Background() 23 | logger := log.New(os.Stdout, "", 0) 24 | 25 | // Create a new JWK Set using memory-backed storage. 26 | jwkSet := jwkset.NewMemoryStorage() 27 | 28 | // Create a new ECDSA key and store it in the JWK Set. 29 | ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 30 | if err != nil { 31 | logger.Fatalf(logFmt, "Failed to generate ECDSA key.", err) 32 | } 33 | ecID := uuid.NewString() 34 | jwk, err := newKeyDefaultOptions(ec, ecID) 35 | if err != nil { 36 | logger.Fatalf(logFmt, "Failed to create JWK from ECDSA key.", err) 37 | } 38 | err = jwkSet.KeyWrite(ctx, jwk) 39 | if err != nil { 40 | logger.Fatalf(logFmt, "Failed to store ECDSA key.", err) 41 | } 42 | 43 | // Create a new EdDSA key and store it in the JWK Set. 44 | _, ed, err := ed25519.GenerateKey(rand.Reader) 45 | if err != nil { 46 | logger.Fatalf(logFmt, "Failed to generate EdDSA key.", err) 47 | } 48 | edID := uuid.NewString() 49 | jwk, err = newKeyDefaultOptions(ed, edID) 50 | if err != nil { 51 | logger.Fatalf(logFmt, "Failed to create JWK from EdDSA key.", err) 52 | } 53 | err = jwkSet.KeyWrite(ctx, jwk) 54 | if err != nil { 55 | logger.Fatalf(logFmt, "Failed to store EdDSA key.", err) 56 | } 57 | 58 | // Create a new RSA key and store it in the JWK Set. 59 | r, err := rsa.GenerateKey(rand.Reader, 4096) 60 | if err != nil { 61 | logger.Fatalf(logFmt, "Failed to generate RSA key.", err) 62 | } 63 | rID := uuid.NewString() 64 | jwk, err = newKeyDefaultOptions(r, rID) 65 | if err != nil { 66 | logger.Fatalf(logFmt, "Failed to create JWK from RSA key.", err) 67 | } 68 | err = jwkSet.KeyWrite(ctx, jwk) 69 | if err != nil { 70 | logger.Fatalf(logFmt, "Failed to store RSA key.", err) 71 | } 72 | 73 | // Create a new HMAC key and store it in the JWK Set. 74 | hmacSecret := []byte("my_hmac_secret") 75 | hid := uuid.NewString() 76 | jwk, err = newKeyDefaultOptions(hmacSecret, hid) 77 | if err != nil { 78 | logger.Fatalf(logFmt, "Failed to create JWK from HMAC key.", err) 79 | } 80 | err = jwkSet.KeyWrite(ctx, jwk) 81 | if err != nil { 82 | logger.Fatalf(logFmt, "Failed to store HMAC key.", err) 83 | } 84 | 85 | // Print the JSON representation of the JWK Set. 86 | jsonRepresentation, err := jwkSet.JSONPublic(ctx) 87 | if err != nil { 88 | logger.Fatalf(logFmt, "Failed to get JSON representation.", err) 89 | } 90 | logger.Println("Initial JSON representation:") 91 | logger.Println(string(jsonRepresentation)) 92 | 93 | // Delete the previously added RSA key from the JWK Set, then reprint the JSON representation. 94 | _, err = jwkSet.KeyDelete(ctx, rID) 95 | if err != nil { 96 | logger.Fatalf(logFmt, "Failed to delete RSA key.", err) 97 | } 98 | jsonRepresentation, err = jwkSet.JSONPublic(ctx) 99 | if err != nil { 100 | logger.Fatalf(logFmt, "Failed to get JSON representation.", err) 101 | } 102 | logger.Println("Deleted RSA key:") 103 | logger.Println(string(jsonRepresentation)) 104 | 105 | // Delete the previously added ECDSA key from the JWK Set, add a new one, then reprint the JSON representation. 106 | _, err = jwkSet.KeyDelete(ctx, ecID) 107 | if err != nil { 108 | logger.Fatalf(logFmt, "Failed to delete ECDSA key.", err) 109 | } 110 | ec, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 111 | if err != nil { 112 | logger.Fatalf(logFmt, "Failed to generate ECDSA key.", err) 113 | } 114 | jwk, err = newKeyDefaultOptions(ec, uuid.NewString()) 115 | if err != nil { 116 | logger.Fatalf(logFmt, "Failed to create JWK from ECDSA key.", err) 117 | } 118 | err = jwkSet.KeyWrite(ctx, jwk) 119 | if err != nil { 120 | logger.Fatalf(logFmt, "Failed to store ECDSA key.", err) 121 | } 122 | jsonRepresentation, err = jwkSet.JSONPublic(ctx) 123 | if err != nil { 124 | logger.Fatalf(logFmt, "Failed to get JSON representation.", err) 125 | } 126 | logger.Println("Deleted ECDSA key and added a new one:") 127 | logger.Println(string(jsonRepresentation)) 128 | 129 | // Read the previously added EdDSA key from the JWK Set, the print its private key. 130 | jwk, err = jwkSet.KeyRead(ctx, edID) 131 | if err != nil { 132 | logger.Fatalf(logFmt, "Failed to read EdDSA key.", err) 133 | } 134 | edKey, ok := jwk.Key().(ed25519.PrivateKey) 135 | if !ok { 136 | logger.Fatalf(logFmt, "Failed to cast EdDSA key.", err) 137 | } 138 | logger.Printf("Retrieved EdDSA private key Base64RawURL: %s", base64.RawURLEncoding.EncodeToString(edKey)) 139 | 140 | // Read the previously added HMAC key from the JWK Set, the print it. 141 | jwk, err = jwkSet.KeyRead(ctx, hid) 142 | if err != nil { 143 | logger.Fatalf(logFmt, "Failed to read HMAC key.", err) 144 | } 145 | hKey, ok := jwk.Key().([]byte) 146 | if !ok { 147 | logger.Fatalf(logFmt, "Failed to cast HMAC key.", err) 148 | } 149 | logger.Printf("Retrieved HMAC secret: %s", hKey) 150 | } 151 | 152 | func newKeyDefaultOptions(key any, keyID string) (jwkset.JWK, error) { 153 | marshal := jwkset.JWKMarshalOptions{ 154 | Private: true, 155 | } 156 | metadata := jwkset.JWKMetadataOptions{ 157 | KID: keyID, 158 | } 159 | options := jwkset.JWKOptions{ 160 | Marshal: marshal, 161 | Metadata: metadata, 162 | } 163 | return jwkset.NewJWKFromKey(key, options) 164 | } 165 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/MicahParks/jwkset 2 | 3 | go 1.21 4 | 5 | require golang.org/x/time v0.9.0 6 | 7 | retract ( 8 | v0.6.0 // Potential race condition in refresh goroutine: https://github.com/MicahParks/jwkset/pull/42 9 | [v0.5.16, v0.5.21] // HTTP client only overwrites and appends JWK to local cache during refresh: https://github.com/MicahParks/jwkset/issues/40 10 | [v0.5.0, v0.5.15] // HTTP client only overwrites and appends JWK to local cache during refresh: https://github.com/MicahParks/jwkset/issues/40 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 2 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 3 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package jwkset 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | "time" 10 | 11 | "golang.org/x/time/rate" 12 | ) 13 | 14 | var ( 15 | // ErrNewClient fails to create a new JWK Set client. 16 | ErrNewClient = errors.New("failed to create new JWK Set client") 17 | ) 18 | 19 | // HTTPClientOptions are options for creating a new JWK Set client. 20 | type HTTPClientOptions struct { 21 | // Given contains keys known from outside HTTP URLs. 22 | Given Storage 23 | // HTTPURLs are a mapping of HTTP URLs to JWK Set endpoints to storage implementations for the keys located at the 24 | // URL. If empty, HTTP will not be used. 25 | HTTPURLs map[string]Storage 26 | // PrioritizeHTTP is a flag that indicates whether keys from the HTTP URL should be prioritized over keys from the 27 | // given storage. 28 | PrioritizeHTTP bool 29 | // RateLimitWaitMax is the timeout for waiting for rate limiting to end. 30 | RateLimitWaitMax time.Duration 31 | // RefreshUnknownKID is non-nil to indicate that remote HTTP resources should be refreshed if a key with an unknown 32 | // key ID is trying to be read. This makes reading methods block until the context is over, a key with the matching 33 | // key ID is found in a refreshed remote resource, or all refreshes complete. 34 | RefreshUnknownKID *rate.Limiter 35 | } 36 | 37 | // Client is a JWK Set client. 38 | type httpClient struct { 39 | given Storage 40 | httpURLs map[string]Storage 41 | prioritizeHTTP bool 42 | rateLimitWaitMax time.Duration 43 | refreshUnknownKID *rate.Limiter 44 | } 45 | 46 | // NewHTTPClient creates a new JWK Set client from remote HTTP resources. 47 | func NewHTTPClient(options HTTPClientOptions) (Storage, error) { 48 | if options.Given == nil && len(options.HTTPURLs) == 0 { 49 | return nil, fmt.Errorf("%w: no given keys or HTTP URLs", ErrNewClient) 50 | } 51 | for u, store := range options.HTTPURLs { 52 | if store == nil { 53 | var err error 54 | options.HTTPURLs[u], err = NewStorageFromHTTP(u, HTTPClientStorageOptions{}) 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to create HTTP client storage for %q: %w", u, errors.Join(err, ErrNewClient)) 57 | } 58 | } 59 | } 60 | given := options.Given 61 | if given == nil { 62 | given = NewMemoryStorage() 63 | } 64 | c := httpClient{ 65 | given: given, 66 | httpURLs: options.HTTPURLs, 67 | prioritizeHTTP: options.PrioritizeHTTP, 68 | rateLimitWaitMax: options.RateLimitWaitMax, 69 | refreshUnknownKID: options.RefreshUnknownKID, 70 | } 71 | return c, nil 72 | } 73 | 74 | // NewDefaultHTTPClient creates a new JWK Set client with default options from remote HTTP resources. 75 | // 76 | // The default behavior is to: 77 | // 1. Refresh remote HTTP resources every hour. 78 | // 2. Prioritize keys from remote HTTP resources over keys from the given storage. 79 | // 3. Refresh remote HTTP resources if a key with an unknown key ID is trying to be read, with a rate limit of 5 minutes. 80 | // 4. Log to slog.Default() if a refresh fails. 81 | func NewDefaultHTTPClient(urls []string) (Storage, error) { 82 | return NewDefaultHTTPClientCtx(context.Background(), urls) 83 | } 84 | 85 | // NewDefaultHTTPClientCtx is the same as NewDefaultHTTPClient, but with a context that can end the refresh goroutine. 86 | func NewDefaultHTTPClientCtx(ctx context.Context, urls []string) (Storage, error) { 87 | clientOptions := HTTPClientOptions{ 88 | HTTPURLs: make(map[string]Storage), 89 | RateLimitWaitMax: time.Minute, 90 | RefreshUnknownKID: rate.NewLimiter(rate.Every(5*time.Minute), 1), 91 | } 92 | for _, u := range urls { 93 | refreshErrorHandler := func(ctx context.Context, err error) { 94 | slog.Default().ErrorContext(ctx, "Failed to refresh HTTP JWK Set from remote HTTP resource.", 95 | "error", err, 96 | "url", u, 97 | ) 98 | } 99 | options := HTTPClientStorageOptions{ 100 | Ctx: ctx, 101 | NoErrorReturnFirstHTTPReq: true, 102 | RefreshErrorHandler: refreshErrorHandler, 103 | RefreshInterval: time.Hour, 104 | } 105 | c, err := NewStorageFromHTTP(u, options) 106 | if err != nil { 107 | return nil, fmt.Errorf("failed to create HTTP client storage for %q: %w", u, errors.Join(err, ErrNewClient)) 108 | } 109 | clientOptions.HTTPURLs[u] = c 110 | } 111 | return NewHTTPClient(clientOptions) 112 | } 113 | 114 | func (c httpClient) KeyDelete(ctx context.Context, keyID string) (ok bool, err error) { 115 | ok, err = c.given.KeyDelete(ctx, keyID) 116 | if err != nil && !errors.Is(err, ErrKeyNotFound) { 117 | return false, fmt.Errorf("failed to delete key with ID %q from given storage due to error: %w", keyID, err) 118 | } 119 | if ok { 120 | return true, nil 121 | } 122 | for _, store := range c.httpURLs { 123 | ok, err = store.KeyDelete(ctx, keyID) 124 | if err != nil && !errors.Is(err, ErrKeyNotFound) { 125 | return false, fmt.Errorf("failed to delete key with ID %q from HTTP storage due to error: %w", keyID, err) 126 | } 127 | if ok { 128 | return true, nil 129 | } 130 | } 131 | return false, nil 132 | } 133 | func (c httpClient) KeyRead(ctx context.Context, keyID string) (jwk JWK, err error) { 134 | if !c.prioritizeHTTP { 135 | jwk, err = c.given.KeyRead(ctx, keyID) 136 | switch { 137 | case errors.Is(err, ErrKeyNotFound): 138 | // Do nothing. 139 | case err != nil: 140 | return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in given storage due to error: %w", keyID, err) 141 | default: 142 | return jwk, nil 143 | } 144 | } 145 | for _, store := range c.httpURLs { 146 | jwk, err = store.KeyRead(ctx, keyID) 147 | switch { 148 | case errors.Is(err, ErrKeyNotFound): 149 | continue 150 | case err != nil: 151 | return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in HTTP storage due to error: %w", keyID, err) 152 | default: 153 | return jwk, nil 154 | } 155 | } 156 | if c.prioritizeHTTP { 157 | jwk, err = c.given.KeyRead(ctx, keyID) 158 | switch { 159 | case errors.Is(err, ErrKeyNotFound): 160 | // Do nothing. 161 | case err != nil: 162 | return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in given storage due to error: %w", keyID, err) 163 | default: 164 | return jwk, nil 165 | } 166 | } 167 | if c.refreshUnknownKID != nil { 168 | var cancel context.CancelFunc = func() {} 169 | if c.rateLimitWaitMax > 0 { 170 | ctx, cancel = context.WithTimeout(ctx, c.rateLimitWaitMax) 171 | } 172 | defer cancel() 173 | err = c.refreshUnknownKID.Wait(ctx) 174 | if err != nil { 175 | return JWK{}, fmt.Errorf("failed to wait for JWK Set refresh rate limiter due to error: %w", err) 176 | } 177 | for _, store := range c.httpURLs { 178 | s, ok := store.(httpStorage) 179 | if !ok { 180 | continue 181 | } 182 | err = s.refresh(ctx) 183 | if err != nil { 184 | if s.options.RefreshErrorHandler != nil { 185 | s.options.RefreshErrorHandler(ctx, err) 186 | } 187 | continue 188 | } 189 | jwk, err = store.KeyRead(ctx, keyID) 190 | switch { 191 | case errors.Is(err, ErrKeyNotFound): 192 | // Do nothing. 193 | case err != nil: 194 | return JWK{}, fmt.Errorf("failed to find JWT key with ID %q in HTTP storage due to error: %w", keyID, err) 195 | default: 196 | return jwk, nil 197 | } 198 | } 199 | } 200 | return JWK{}, fmt.Errorf("%w %q", ErrKeyNotFound, keyID) 201 | } 202 | func (c httpClient) KeyReadAll(ctx context.Context) ([]JWK, error) { 203 | jwks, err := c.given.KeyReadAll(ctx) 204 | if err != nil { 205 | return nil, fmt.Errorf("failed to snapshot given keys due to error: %w", err) 206 | } 207 | for u, store := range c.httpURLs { 208 | j, err := store.KeyReadAll(ctx) 209 | if err != nil { 210 | return nil, fmt.Errorf("failed to snapshot HTTP keys from %q due to error: %w", u, err) 211 | } 212 | jwks = append(jwks, j...) 213 | } 214 | return jwks, nil 215 | } 216 | func (c httpClient) KeyReplaceAll(ctx context.Context, given []JWK) error { 217 | err := c.given.KeyReplaceAll(ctx, given) 218 | if err != nil { 219 | return fmt.Errorf("failed to delete all keys from given storage due to error: %w", err) 220 | } 221 | var returnErr error 222 | for _, store := range c.httpURLs { 223 | err = store.KeyReplaceAll(ctx, make([]JWK, 0)) 224 | if err != nil { 225 | returnErr = errors.Join(returnErr, fmt.Errorf("failed to delete all keys: %w", err)) 226 | } 227 | } 228 | return returnErr 229 | } 230 | func (c httpClient) KeyWrite(ctx context.Context, jwk JWK) error { 231 | return c.given.KeyWrite(ctx, jwk) 232 | } 233 | 234 | func (c httpClient) JSON(ctx context.Context) (json.RawMessage, error) { 235 | m, err := c.combineStorage(ctx) 236 | if err != nil { 237 | return nil, fmt.Errorf("failed to combine storage due to error: %w", err) 238 | } 239 | return m.JSON(ctx) 240 | } 241 | func (c httpClient) JSONPublic(ctx context.Context) (json.RawMessage, error) { 242 | m, err := c.combineStorage(ctx) 243 | if err != nil { 244 | return nil, fmt.Errorf("failed to combine storage due to error: %w", err) 245 | } 246 | return m.JSONPublic(ctx) 247 | } 248 | func (c httpClient) JSONPrivate(ctx context.Context) (json.RawMessage, error) { 249 | m, err := c.combineStorage(ctx) 250 | if err != nil { 251 | return nil, fmt.Errorf("failed to combine storage due to error: %w", err) 252 | } 253 | return m.JSONPrivate(ctx) 254 | } 255 | func (c httpClient) JSONWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (json.RawMessage, error) { 256 | m, err := c.combineStorage(ctx) 257 | if err != nil { 258 | return nil, fmt.Errorf("failed to combine storage due to error: %w", err) 259 | } 260 | return m.JSONWithOptions(ctx, marshalOptions, validationOptions) 261 | } 262 | func (c httpClient) Marshal(ctx context.Context) (JWKSMarshal, error) { 263 | m, err := c.combineStorage(ctx) 264 | if err != nil { 265 | return JWKSMarshal{}, fmt.Errorf("failed to combine storage due to error: %w", err) 266 | } 267 | return m.Marshal(ctx) 268 | } 269 | func (c httpClient) MarshalWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (JWKSMarshal, error) { 270 | m, err := c.combineStorage(ctx) 271 | if err != nil { 272 | return JWKSMarshal{}, fmt.Errorf("failed to combine storage due to error: %w", err) 273 | } 274 | return m.MarshalWithOptions(ctx, marshalOptions, validationOptions) 275 | } 276 | 277 | func (c httpClient) combineStorage(ctx context.Context) (Storage, error) { 278 | jwks, err := c.KeyReadAll(ctx) 279 | if err != nil { 280 | return nil, fmt.Errorf("failed to snapshot keys due to error: %w", err) 281 | } 282 | m := NewMemoryStorage() 283 | for _, jwk := range jwks { 284 | err = m.KeyWrite(ctx, jwk) 285 | if err != nil { 286 | return nil, fmt.Errorf("failed to write key to memory storage due to error: %w", err) 287 | } 288 | } 289 | return m, nil 290 | } 291 | -------------------------------------------------------------------------------- /http_test.go: -------------------------------------------------------------------------------- 1 | package jwkset 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "sync" 10 | "testing" 11 | "time" 12 | ) 13 | 14 | func TestClient(t *testing.T) { 15 | ctx, cancel := context.WithCancel(context.Background()) 16 | defer cancel() 17 | 18 | kid := "my-key-id" 19 | secret := []byte("my-hmac-secret") 20 | serverStore := NewMemoryStorage() 21 | marshalOptions := JWKMarshalOptions{ 22 | Private: true, 23 | } 24 | metadata := JWKMetadataOptions{ 25 | KID: kid, 26 | } 27 | options := JWKOptions{ 28 | Marshal: marshalOptions, 29 | Metadata: metadata, 30 | } 31 | jwk, err := NewJWKFromKey(secret, options) 32 | if err != nil { 33 | t.Fatalf("Failed to create a JWK from the given HMAC secret.\nError: %s", err) 34 | } 35 | err = serverStore.KeyWrite(ctx, jwk) 36 | if err != nil { 37 | t.Fatalf("Failed to write the given JWK to the store.\nError: %s", err) 38 | } 39 | rawJWKS, err := serverStore.JSON(ctx) 40 | if err != nil { 41 | t.Fatalf("Failed to get the JSON.\nError: %s", err) 42 | } 43 | 44 | rawJWKSMux := sync.RWMutex{} 45 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 46 | rawJWKSMux.RLock() 47 | defer rawJWKSMux.RUnlock() 48 | _, _ = w.Write(rawJWKS) 49 | })) 50 | 51 | clientStore, err := NewDefaultHTTPClient([]string{server.URL}) 52 | if err != nil { 53 | t.Fatalf("Failed to create a new HTTP client.\nError: %s", err) 54 | } 55 | 56 | jwk, err = clientStore.KeyRead(ctx, kid) 57 | if err != nil { 58 | t.Fatalf("Failed to read the JWK.\nError: %s", err) 59 | } 60 | 61 | if !bytes.Equal(jwk.Key().([]byte), secret) { 62 | t.Fatalf("The key read from the HTTP client did not match the original key.") 63 | } 64 | 65 | jwks, err := clientStore.KeyReadAll(ctx) 66 | if err != nil { 67 | t.Fatalf("Failed to read all the JWKs.\nError: %s", err) 68 | } 69 | if len(jwks) != 1 { 70 | t.Fatalf("Expected to read 1 JWK, but got %d.", len(jwks)) 71 | } 72 | if !bytes.Equal(jwks[0].Key().([]byte), secret) { 73 | t.Fatalf("The key read from the HTTP client did not match the original key.") 74 | } 75 | 76 | ok, err := clientStore.KeyDelete(ctx, kid) 77 | if err != nil { 78 | t.Fatalf("Failed to delete the JWK.\nError: %s", err) 79 | } 80 | if !ok { 81 | t.Fatalf("Expected the key to be deleted.") 82 | } 83 | 84 | err = clientStore.KeyWrite(ctx, jwk) 85 | if err != nil { 86 | t.Fatalf("Failed to write the JWK.\nError: %s", err) 87 | } 88 | jwk, err = clientStore.KeyRead(ctx, kid) 89 | if err != nil { 90 | t.Fatalf("Failed to read the JWK.\nError: %s", err) 91 | } 92 | if !bytes.Equal(jwk.Key().([]byte), secret) { 93 | t.Fatalf("The key read from the HTTP client did not match the original key.") 94 | } 95 | 96 | otherKeyID := myKeyID + "2" 97 | options.Metadata.KID = otherKeyID 98 | otherSecret := []byte("my-other-hmac-secret") 99 | jwk, err = NewJWKFromKey(otherSecret, options) 100 | if err != nil { 101 | t.Fatalf("Failed to create a JWK from the given HMAC secret.\nError: %s", err) 102 | } 103 | err = serverStore.KeyWrite(ctx, jwk) 104 | if err != nil { 105 | t.Fatalf("Failed to write the given JWK to the store.\nError: %s", err) 106 | } 107 | rawJWKSMux.Lock() 108 | rawJWKS, err = serverStore.JSON(ctx) 109 | rawJWKSMux.Unlock() 110 | if err != nil { 111 | t.Fatalf("Failed to get the JSON.\nError: %s", err) 112 | } 113 | 114 | jwk, err = clientStore.KeyRead(ctx, otherKeyID) 115 | if err != nil { 116 | t.Fatalf("Failed to read the JWK.\nError: %s", err) 117 | } 118 | if !bytes.Equal(jwk.Key().([]byte), otherSecret) { 119 | t.Fatalf("The key read from the HTTP client did not match the original key.") 120 | } 121 | 122 | otherOtherKey := myKeyID + "3" 123 | options.Metadata.KID = otherOtherKey 124 | otherOtherSecret := []byte("my-other-other-hmac-secret") 125 | jwk, err = NewJWKFromKey(otherOtherSecret, options) 126 | if err != nil { 127 | t.Fatalf("Failed to create a JWK from the given HMAC secret.\nError: %s", err) 128 | } 129 | err = serverStore.KeyWrite(ctx, jwk) 130 | if err != nil { 131 | t.Fatalf("Failed to write the given JWK to the store.\nError: %s", err) 132 | } 133 | rawJWKSMux.Lock() 134 | rawJWKS, err = serverStore.JSON(ctx) 135 | rawJWKSMux.Unlock() 136 | if err != nil { 137 | t.Fatalf("Failed to get the JSON.\nError: %s", err) 138 | } 139 | shortCtx, shortCancel := context.WithTimeout(ctx, 100*time.Millisecond) 140 | defer shortCancel() 141 | jwk, err = clientStore.KeyRead(shortCtx, otherOtherKey) 142 | if err == nil || !strings.HasSuffix(err.Error(), "rate: Wait(n=1) would exceed context deadline") { 143 | t.Fatalf("Expected to exceed context deadline, but got %s.", err) 144 | } 145 | } 146 | 147 | func TestClientCacheReplacement(t *testing.T) { 148 | ctx, cancel := context.WithCancel(context.Background()) 149 | defer cancel() 150 | 151 | kid := "my-key-id" 152 | secret := []byte("my-hmac-secret") 153 | serverStore := NewMemoryStorage() 154 | marshalOptions := JWKMarshalOptions{ 155 | Private: true, 156 | } 157 | metadata := JWKMetadataOptions{ 158 | KID: kid, 159 | } 160 | options := JWKOptions{ 161 | Marshal: marshalOptions, 162 | Metadata: metadata, 163 | } 164 | jwk, err := NewJWKFromKey(secret, options) 165 | if err != nil { 166 | t.Fatalf("Failed to create a JWK from the given HMAC secret.\nError: %s", err) 167 | } 168 | err = serverStore.KeyWrite(ctx, jwk) 169 | if err != nil { 170 | t.Fatalf("Failed to write the given JWK to the store.\nError: %s", err) 171 | } 172 | rawJWKS, err := serverStore.JSON(ctx) 173 | if err != nil { 174 | t.Fatalf("Failed to get the JSON.\nError: %s", err) 175 | } 176 | 177 | rawJWKSMux := sync.RWMutex{} 178 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 179 | rawJWKSMux.RLock() 180 | defer rawJWKSMux.RUnlock() 181 | _, _ = w.Write(rawJWKS) 182 | })) 183 | defer server.Close() 184 | 185 | refreshInterval := 50 * time.Millisecond 186 | httpOptions := HTTPClientStorageOptions{ 187 | Ctx: ctx, 188 | RefreshInterval: refreshInterval, 189 | } 190 | clientStore, err := NewStorageFromHTTP(server.URL, httpOptions) 191 | if err != nil { 192 | t.Fatalf("Failed to create a new HTTP client.\nError: %s", err) 193 | } 194 | 195 | jwk, err = clientStore.KeyRead(ctx, kid) 196 | if err != nil { 197 | t.Fatalf("Failed to read the JWK.\nError: %s", err) 198 | } 199 | 200 | if !bytes.Equal(jwk.Key().([]byte), secret) { 201 | t.Fatalf("The key read from the HTTP client did not match the original key.") 202 | } 203 | 204 | jwks, err := clientStore.KeyReadAll(ctx) 205 | if err != nil { 206 | t.Fatalf("Failed to read all the JWKs.\nError: %s", err) 207 | } 208 | if len(jwks) != 1 { 209 | t.Fatalf("Expected to read 1 JWK, but got %d.", len(jwks)) 210 | } 211 | if !bytes.Equal(jwks[0].Key().([]byte), secret) { 212 | t.Fatalf("The key read from the HTTP client did not match the original key.") 213 | } 214 | 215 | otherKeyID := myKeyID + "2" 216 | options.Metadata.KID = otherKeyID 217 | otherSecret := []byte("my-other-hmac-secret") 218 | jwk, err = NewJWKFromKey(otherSecret, options) 219 | if err != nil { 220 | t.Fatalf("Failed to create a JWK from the given HMAC secret.\nError: %s", err) 221 | } 222 | err = serverStore.KeyWrite(ctx, jwk) 223 | if err != nil { 224 | t.Fatalf("Failed to write the given JWK to the store.\nError: %s", err) 225 | } 226 | ok, err := serverStore.KeyDelete(ctx, kid) 227 | if err != nil { 228 | t.Fatalf("Failed to delete the given JWK from the store.\nError: %s", err) 229 | } 230 | if !ok { 231 | t.Fatalf("Expected the key to be deleted.") 232 | } 233 | rawJWKSMux.Lock() 234 | rawJWKS, err = serverStore.JSON(ctx) 235 | rawJWKSMux.Unlock() 236 | if err != nil { 237 | t.Fatalf("Failed to get the JSON.\nError: %s", err) 238 | } 239 | time.Sleep(2 * refreshInterval) 240 | 241 | jwks, err = clientStore.KeyReadAll(ctx) 242 | if err != nil { 243 | t.Fatalf("Failed to read the JWK.\nError: %s", err) 244 | } 245 | if len(jwks) != 1 { 246 | t.Fatalf("Expected to read 1 JWK, but got %d.", len(jwks)) 247 | } 248 | if jwks[0].marshal.KID != otherKeyID { 249 | t.Fatalf("The key read from the HTTP client did not match the original key.") 250 | } 251 | if !bytes.Equal(jwks[0].Key().([]byte), otherSecret) { 252 | t.Fatalf("The key read from the HTTP client did not match the original key.") 253 | } 254 | } 255 | 256 | func TestClientHTTPURLs(t *testing.T) { 257 | ctx, cancel := context.WithCancel(context.Background()) 258 | defer cancel() 259 | 260 | kid := "my-key-id" 261 | secret := []byte("my-hmac-secret") 262 | serverStore := NewMemoryStorage() 263 | marshalOptions := JWKMarshalOptions{ 264 | Private: true, 265 | } 266 | metadata := JWKMetadataOptions{ 267 | KID: kid, 268 | } 269 | options := JWKOptions{ 270 | Marshal: marshalOptions, 271 | Metadata: metadata, 272 | } 273 | jwk, err := NewJWKFromKey(secret, options) 274 | if err != nil { 275 | t.Fatalf("Failed to create a JWK from the given HMAC secret.\nError: %s", err) 276 | } 277 | err = serverStore.KeyWrite(ctx, jwk) 278 | if err != nil { 279 | t.Fatalf("Failed to write the given JWK to the store.\nError: %s", err) 280 | } 281 | rawJWKS, err := serverStore.JSON(ctx) 282 | if err != nil { 283 | t.Fatalf("Failed to get the JSON.\nError: %s", err) 284 | } 285 | 286 | rawJWKSMux := sync.RWMutex{} 287 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 288 | rawJWKSMux.RLock() 289 | defer rawJWKSMux.RUnlock() 290 | _, _ = w.Write(rawJWKS) 291 | })) 292 | defer server.Close() 293 | 294 | clientOptions := HTTPClientOptions{ 295 | HTTPURLs: map[string]Storage{server.URL: nil}, 296 | } 297 | store, err := NewHTTPClient(clientOptions) 298 | if err != nil { 299 | t.Fatalf("Failed to create a new HTTP client.\nError: %s", err) 300 | } 301 | 302 | jwks, err := store.KeyReadAll(ctx) 303 | if err != nil { 304 | t.Fatalf("Failed to read the JWK.\nError: %s", err) 305 | } 306 | if len(jwks) != 1 { 307 | t.Fatalf("Expected to read 1 JWK, but got %d.", len(jwks)) 308 | } 309 | if !bytes.Equal(jwks[0].Key().([]byte), secret) { 310 | t.Fatalf("The key read from the HTTP client did not match the original key.") 311 | } 312 | } 313 | 314 | func TestClientError(t *testing.T) { 315 | _, err := NewHTTPClient(HTTPClientOptions{}) 316 | if err == nil { 317 | t.Fatalf("Expected an error when creating a new HTTP client without any URLs.") 318 | } 319 | } 320 | 321 | func TestClientJSON(t *testing.T) { 322 | c := httpClient{ 323 | given: NewMemoryStorage(), 324 | } 325 | testJSON(context.Background(), t, c) 326 | } 327 | 328 | func TestHTTPClientKeyReplaceAll(t *testing.T) { 329 | ctx, cancel := context.WithCancel(context.Background()) 330 | defer cancel() 331 | 332 | givenStore := NewMemoryStorage() 333 | givenKey := newStorageTestJWK(t, hmacKey1, kidWritten) 334 | err := givenStore.KeyWrite(ctx, givenKey) 335 | if err != nil { 336 | t.Fatalf("Failed to write key to given store.\nError: %s", err) 337 | } 338 | 339 | httpStore := NewMemoryStorage() 340 | httpKey := newStorageTestJWK(t, hmacKey2, kidWritten2) 341 | err = httpStore.KeyWrite(ctx, httpKey) 342 | if err != nil { 343 | t.Fatalf("Failed to write key to HTTP store.\nError: %s", err) 344 | } 345 | 346 | client := httpClient{ 347 | given: givenStore, 348 | httpURLs: map[string]Storage{"https://example.com": httpStore}, 349 | } 350 | 351 | keys, err := client.KeyReadAll(ctx) 352 | if err != nil { 353 | t.Fatalf("Failed to read all keys before replace.\nError: %s", err) 354 | } 355 | if len(keys) != 2 { 356 | t.Fatalf("Expected 2 keys before replace, got %d.", len(keys)) 357 | } 358 | 359 | newKey := newStorageTestJWK(t, []byte("new key"), "new-kid") 360 | err = client.KeyReplaceAll(ctx, []JWK{newKey}) 361 | if err != nil { 362 | t.Fatalf("KeyReplaceAll failed.\nError: %s", err) 363 | } 364 | 365 | givenKeys, err := givenStore.KeyReadAll(ctx) 366 | if err != nil { 367 | t.Fatalf("Failed to read all keys from given store after replace.\nError: %s", err) 368 | } 369 | if len(givenKeys) != 1 { 370 | t.Fatalf("Expected 1 key in given store after replace, got %d.", len(givenKeys)) 371 | } 372 | if givenKeys[0].Marshal().KID != "new-kid" { 373 | t.Fatalf("Unexpected key ID in given store after replace. Got %q, expected %q.", givenKeys[0].Marshal().KID, "new-kid") 374 | } 375 | 376 | httpKeys, err := httpStore.KeyReadAll(ctx) 377 | if err != nil { 378 | t.Fatalf("Failed to read all keys from HTTP store after replace.\nError: %s", err) 379 | } 380 | if len(httpKeys) != 0 { 381 | t.Fatalf("Expected 0 keys in HTTP store after replace, got %d.", len(httpKeys)) 382 | } 383 | 384 | allKeys, err := client.KeyReadAll(ctx) 385 | if err != nil { 386 | t.Fatalf("Failed to read all keys after replace.\nError: %s", err) 387 | } 388 | if len(allKeys) != 1 { 389 | t.Fatalf("Expected 1 key after replace, got %d.", len(allKeys)) 390 | } 391 | if allKeys[0].Marshal().KID != "new-kid" { 392 | t.Fatalf("Unexpected key ID after replace. Got %q, expected %q.", allKeys[0].Marshal().KID, "new-kid") 393 | } 394 | if !bytes.Equal(allKeys[0].Key().([]byte), []byte("new key")) { 395 | t.Fatalf("Unexpected key material after replace.") 396 | } 397 | } 398 | -------------------------------------------------------------------------------- /jwk.go: -------------------------------------------------------------------------------- 1 | package jwkset 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/ecdsa" 7 | "crypto/ed25519" 8 | "crypto/rsa" 9 | "crypto/x509" 10 | "encoding/base64" 11 | "encoding/json" 12 | "errors" 13 | "fmt" 14 | "io" 15 | "math/big" 16 | "net/http" 17 | "net/url" 18 | "slices" 19 | "time" 20 | ) 21 | 22 | var ( 23 | // ErrPadding indicates that there is invalid padding. 24 | ErrPadding = errors.New("padding error") 25 | ) 26 | 27 | // JWK represents a JSON Web Key. 28 | type JWK struct { 29 | key any 30 | marshal JWKMarshal 31 | options JWKOptions 32 | } 33 | 34 | // JWKMarshalOptions are used to specify options for JSON marshaling a JWK. 35 | type JWKMarshalOptions struct { 36 | // Private is used to indicate that the JWK's private key material should be JSON marshaled and unmarshalled. This 37 | // includes symmetric and asymmetric keys. Setting this to true is the only way to marshal and unmarshal symmetric 38 | // keys. 39 | Private bool 40 | } 41 | 42 | // JWKX509Options holds the X.509 certificate information for a JWK. This data structure is not used for JSON marshaling. 43 | type JWKX509Options struct { 44 | // X5C contains a chain of one or more PKIX certificates. The PKIX certificate containing the key value MUST be the 45 | // first certificate. 46 | X5C []*x509.Certificate // The PKIX certificate containing the key value MUST be the first certificate. 47 | 48 | // X5T is calculated automatically. 49 | // X5TS256 is calculated automatically. 50 | 51 | // X5U Is a URI that refers to a resource for an X.509 public key certificate or certificate chain. 52 | X5U string // https://www.rfc-editor.org/rfc/rfc7517#section-4.6 53 | } 54 | 55 | // JWKValidateOptions are used to specify options for validating a JWK. 56 | type JWKValidateOptions struct { 57 | /* 58 | This package intentionally does not confirm if certificate's usage or compare that to the JWK's use parameter. 59 | Please open a GitHub issue if you think this should be an option. 60 | */ 61 | // CheckX509ValidTime is used to indicate that the X.509 certificate's valid time should be checked. 62 | CheckX509ValidTime bool 63 | // GetX5U is used to get and validate the X.509 certificate from the X5U URI. Use DefaultGetX5U for the default 64 | // behavior. 65 | GetX5U func(x5u *url.URL) ([]*x509.Certificate, error) 66 | // SkipAll is used to skip all validation. 67 | SkipAll bool 68 | // SkipKeyOps is used to skip validation of the key operations (key_ops). 69 | SkipKeyOps bool 70 | // SkipMetadata skips checking if the JWKMetadataOptions match the JWKMarshal. 71 | SkipMetadata bool 72 | // SkipUse is used to skip validation of the key use (use). 73 | SkipUse bool 74 | // SkipX5UScheme is used to skip checking if the X5U URI scheme is https. 75 | SkipX5UScheme bool 76 | // StrictPadding is used to indicate that the JWK should be validated with strict padding. 77 | StrictPadding bool 78 | } 79 | 80 | // JWKMetadataOptions are direct passthroughs into the JWKMarshal. 81 | type JWKMetadataOptions struct { 82 | // ALG is the algorithm (alg). 83 | ALG ALG 84 | // KID is the key ID (kid). 85 | KID string 86 | // KEYOPS is the key operations (key_ops). 87 | KEYOPS []KEYOPS 88 | // USE is the key use (use). 89 | USE USE 90 | } 91 | 92 | // JWKOptions are used to specify options for marshaling a JSON Web Key. 93 | type JWKOptions struct { 94 | Marshal JWKMarshalOptions 95 | Metadata JWKMetadataOptions 96 | Validate JWKValidateOptions 97 | X509 JWKX509Options 98 | } 99 | 100 | // NewJWKFromKey uses the given key and options to create a JWK. It is possible to provide a private key with an X.509 101 | // certificate, which will be validated to contain the correct public key. 102 | func NewJWKFromKey(key any, options JWKOptions) (JWK, error) { 103 | marshal, err := keyMarshal(key, options) 104 | if err != nil { 105 | return JWK{}, fmt.Errorf("failed to marshal JSON Web Key: %w", err) 106 | } 107 | switch key.(type) { 108 | case ed25519.PrivateKey, ed25519.PublicKey: 109 | if options.Metadata.ALG == "" { 110 | options.Metadata.ALG = AlgEdDSA 111 | } else if options.Metadata.ALG != AlgEdDSA { 112 | return JWK{}, fmt.Errorf("%w: invalid ALG for Ed25519 key: %q", ErrOptions, options.Metadata.ALG) 113 | } 114 | } 115 | j := JWK{ 116 | key: key, 117 | marshal: marshal, 118 | options: options, 119 | } 120 | err = j.Validate() 121 | if err != nil { 122 | return JWK{}, fmt.Errorf("failed to validate JSON Web Key: %w", err) 123 | } 124 | return j, nil 125 | } 126 | 127 | // NewJWKFromRawJSON uses the given raw JSON to create a JWK. 128 | func NewJWKFromRawJSON(j json.RawMessage, marshalOptions JWKMarshalOptions, validateOptions JWKValidateOptions) (JWK, error) { 129 | marshal := JWKMarshal{} 130 | err := json.Unmarshal(j, &marshal) 131 | if err != nil { 132 | return JWK{}, fmt.Errorf("failed to unmarshal JSON Web Key: %w", err) 133 | } 134 | return NewJWKFromMarshal(marshal, marshalOptions, validateOptions) 135 | } 136 | 137 | // NewJWKFromMarshal transforms a JWKMarshal into a JWK. 138 | func NewJWKFromMarshal(marshal JWKMarshal, marshalOptions JWKMarshalOptions, validateOptions JWKValidateOptions) (JWK, error) { 139 | j, err := keyUnmarshal(marshal, marshalOptions, validateOptions) 140 | if err != nil { 141 | return JWK{}, fmt.Errorf("failed to unmarshal JSON Web Key: %w", err) 142 | } 143 | err = j.Validate() 144 | if err != nil { 145 | return JWK{}, fmt.Errorf("failed to validate JSON Web Key: %w", err) 146 | } 147 | return j, nil 148 | } 149 | 150 | // NewJWKFromX5C uses the X.509 X5C information in the options to create a JWK. 151 | func NewJWKFromX5C(options JWKOptions) (JWK, error) { 152 | if len(options.X509.X5C) == 0 { 153 | return JWK{}, fmt.Errorf("%w: no X.509 certificates provided", ErrOptions) 154 | } 155 | cert := options.X509.X5C[0] 156 | marshal, err := keyMarshal(cert.PublicKey, options) 157 | if err != nil { 158 | return JWK{}, fmt.Errorf("failed to marshal JSON Web Key: %w", err) 159 | } 160 | 161 | if cert.PublicKeyAlgorithm == x509.Ed25519 { 162 | if options.Metadata.ALG != "" && options.Metadata.ALG != AlgEdDSA { 163 | return JWK{}, fmt.Errorf("%w: ALG in metadata does not match ALG in X.509 certificate", errors.Join(ErrOptions, ErrX509Mismatch)) 164 | } 165 | options.Metadata.ALG = AlgEdDSA 166 | } 167 | 168 | j := JWK{ 169 | key: options.X509.X5C[0].PublicKey, 170 | marshal: marshal, 171 | options: options, 172 | } 173 | err = j.Validate() 174 | if err != nil { 175 | return JWK{}, fmt.Errorf("failed to validate JSON Web Key: %w", err) 176 | } 177 | return j, nil 178 | } 179 | 180 | // NewJWKFromX5U uses the X.509 X5U information in the options to create a JWK. 181 | func NewJWKFromX5U(options JWKOptions) (JWK, error) { 182 | if options.X509.X5U == "" { 183 | return JWK{}, fmt.Errorf("%w: no X.509 URI provided", ErrOptions) 184 | } 185 | u, err := url.ParseRequestURI(options.X509.X5U) 186 | if err != nil { 187 | return JWK{}, fmt.Errorf("failed to parse X5U URI: %w", errors.Join(ErrOptions, err)) 188 | } 189 | if !options.Validate.SkipX5UScheme && u.Scheme != "https" { 190 | return JWK{}, fmt.Errorf("%w: X5U URI scheme must be https", errors.Join(ErrOptions)) 191 | } 192 | get := options.Validate.GetX5U 193 | if get == nil { 194 | get = DefaultGetX5U 195 | } 196 | certs, err := get(u) 197 | if err != nil { 198 | return JWK{}, fmt.Errorf("failed to get X5U URI: %w", err) 199 | } 200 | options.X509.X5C = certs 201 | jwk, err := NewJWKFromX5C(options) 202 | if err != nil { 203 | return JWK{}, fmt.Errorf("failed to create JWK from fetched X5U assets: %w", err) 204 | } 205 | return jwk, nil 206 | } 207 | 208 | // Key returns the public or private cryptographic key associated with the JWK. 209 | func (j JWK) Key() any { 210 | return j.key 211 | } 212 | 213 | // Marshal returns Go type that can be marshalled into JSON. 214 | func (j JWK) Marshal() JWKMarshal { 215 | return j.marshal 216 | } 217 | 218 | // X509 returns the X.509 certificate information for the JWK. 219 | func (j JWK) X509() JWKX509Options { 220 | return j.options.X509 221 | } 222 | 223 | // Validate validates the JWK. The JWK is automatically validated when created from a function in this package. 224 | func (j JWK) Validate() error { 225 | if j.options.Validate.SkipAll { 226 | return nil 227 | } 228 | if !j.marshal.KTY.IANARegistered() { 229 | return fmt.Errorf("%w: invalid or unsupported key type %q", ErrJWKValidation, j.marshal.KTY) 230 | } 231 | 232 | if !j.options.Validate.SkipUse && !j.marshal.USE.IANARegistered() { 233 | return fmt.Errorf("%w: invalid or unsupported key use %q", ErrJWKValidation, j.marshal.USE) 234 | } 235 | 236 | if !j.options.Validate.SkipKeyOps { 237 | for _, o := range j.marshal.KEYOPS { 238 | if !o.IANARegistered() { 239 | return fmt.Errorf("%w: invalid or unsupported key_opt %q", ErrJWKValidation, o) 240 | } 241 | } 242 | } 243 | 244 | if !j.options.Validate.SkipMetadata { 245 | if j.marshal.ALG != j.options.Metadata.ALG { 246 | return fmt.Errorf("%w: ALG in marshal does not match ALG in options", errors.Join(ErrJWKValidation, ErrOptions)) 247 | } 248 | if j.marshal.KID != j.options.Metadata.KID { 249 | return fmt.Errorf("%w: KID in marshal does not match KID in options", errors.Join(ErrJWKValidation, ErrOptions)) 250 | } 251 | if !slices.Equal(j.marshal.KEYOPS, j.options.Metadata.KEYOPS) { 252 | return fmt.Errorf("%w: KEYOPS in marshal does not match KEYOPS in options", errors.Join(ErrJWKValidation, ErrOptions)) 253 | } 254 | if j.marshal.USE != j.options.Metadata.USE { 255 | return fmt.Errorf("%w: USE in marshal does not match USE in options", errors.Join(ErrJWKValidation, ErrOptions)) 256 | } 257 | } 258 | 259 | if len(j.options.X509.X5C) > 0 { 260 | cert := j.options.X509.X5C[0] 261 | i := cert.PublicKey 262 | switch k := j.key.(type) { 263 | // ECDH keys are not used to sign certificates. 264 | case *ecdsa.PublicKey: 265 | pub, ok := i.(*ecdsa.PublicKey) 266 | if !ok { 267 | return fmt.Errorf("%w: Golang key is type *ecdsa.Public but X.509 public key was of type %T", errors.Join(ErrJWKValidation, ErrX509Mismatch), i) 268 | } 269 | if !k.Equal(pub) { 270 | return fmt.Errorf("%w: Golang *ecdsa.PublicKey does not match the X.509 public key", errors.Join(ErrJWKValidation, ErrX509Mismatch)) 271 | } 272 | case ed25519.PublicKey: 273 | pub, ok := i.(ed25519.PublicKey) 274 | if !ok { 275 | return fmt.Errorf("%w: Golang key is type ed25519.PublicKey but X.509 public key was of type %T", errors.Join(ErrJWKValidation, ErrX509Mismatch), i) 276 | } 277 | if !bytes.Equal(k, pub) { 278 | return fmt.Errorf("%w: Golang ed25519.PublicKey does not match the X.509 public key", errors.Join(ErrJWKValidation, ErrX509Mismatch)) 279 | } 280 | case *rsa.PublicKey: 281 | pub, ok := i.(*rsa.PublicKey) 282 | if !ok { 283 | return fmt.Errorf("%w: Golang key is type *rsa.PublicKey but X.509 public key was of type %T", errors.Join(ErrJWKValidation, ErrX509Mismatch), i) 284 | } 285 | if !k.Equal(pub) { 286 | return fmt.Errorf("%w: Golang *rsa.PublicKey does not match the X.509 public key", errors.Join(ErrJWKValidation, ErrX509Mismatch)) 287 | } 288 | default: 289 | return fmt.Errorf("%w: Golang key is type %T, which is not supported, so it cannot be compared to given X.509 certificates", errors.Join(ErrJWKValidation, ErrUnsupportedKey, ErrX509Mismatch), j.key) 290 | } 291 | if cert.PublicKeyAlgorithm == x509.Ed25519 { 292 | if j.marshal.ALG != AlgEdDSA { 293 | return fmt.Errorf("%w: ALG in marshal does not match ALG in X.509 certificate", errors.Join(ErrJWKValidation, ErrX509Mismatch)) 294 | } 295 | } 296 | if j.options.Validate.CheckX509ValidTime { 297 | now := time.Now() 298 | if now.Before(cert.NotBefore) { 299 | return fmt.Errorf("%w: X.509 certificate is not yet valid", ErrJWKValidation) 300 | } 301 | if now.After(cert.NotAfter) { 302 | return fmt.Errorf("%w: X.509 certificate is expired", ErrJWKValidation) 303 | } 304 | } 305 | } 306 | 307 | marshalled, err := keyMarshal(j.key, j.options) 308 | if err != nil { 309 | return fmt.Errorf("failed to marshal JSON Web Key: %w", errors.Join(ErrJWKValidation, err)) 310 | } 311 | 312 | // Remove automatically computed thumbprints if not set in given JWK. 313 | if j.marshal.X5T == "" { 314 | marshalled.X5T = "" 315 | } 316 | if j.marshal.X5TS256 == "" { 317 | marshalled.X5TS256 = "" 318 | } 319 | 320 | canComputeThumbprint := len(j.marshal.X5C) > 0 321 | if j.marshal.X5T != marshalled.X5T && canComputeThumbprint { 322 | return fmt.Errorf("%w: X5T in marshal does not match X5T in marshalled", ErrJWKValidation) 323 | } 324 | if j.marshal.X5TS256 != marshalled.X5TS256 && canComputeThumbprint { 325 | return fmt.Errorf("%w: X5TS256 in marshal does not match X5TS256 in marshalled", ErrJWKValidation) 326 | } 327 | if j.marshal.CRV != marshalled.CRV { 328 | return fmt.Errorf("%w: CRV in marshal does not match CRV in marshalled", ErrJWKValidation) 329 | } 330 | switch j.marshal.KTY { 331 | case KtyEC: 332 | err = cmpBase64Int(j.marshal.X, marshalled.X, j.options.Validate.StrictPadding) 333 | if err != nil { 334 | return fmt.Errorf("%w: X in marshal does not match X in marshalled", errors.Join(ErrJWKValidation, err)) 335 | } 336 | err = cmpBase64Int(j.marshal.Y, marshalled.Y, j.options.Validate.StrictPadding) 337 | if err != nil { 338 | return fmt.Errorf("%w: Y in marshal does not match Y in marshalled", errors.Join(ErrJWKValidation, err)) 339 | } 340 | err = cmpBase64Int(j.marshal.D, marshalled.D, j.options.Validate.StrictPadding) 341 | if err != nil { 342 | return fmt.Errorf("%w: D in marshal does not match D in marshalled", errors.Join(ErrJWKValidation, err)) 343 | } 344 | case KtyOKP: 345 | if j.marshal.X != marshalled.X { 346 | return fmt.Errorf("%w: X in marshal does not match X in marshalled", ErrJWKValidation) 347 | } 348 | if j.marshal.D != marshalled.D { 349 | return fmt.Errorf("%w: D in marshal does not match D in marshalled", ErrJWKValidation) 350 | } 351 | case KtyRSA: 352 | err = cmpBase64Int(j.marshal.D, marshalled.D, j.options.Validate.StrictPadding) 353 | if err != nil { 354 | return fmt.Errorf("%w: D in marshal does not match D in marshalled", errors.Join(ErrJWKValidation, err)) 355 | } 356 | err = cmpBase64Int(j.marshal.N, marshalled.N, j.options.Validate.StrictPadding) 357 | if err != nil { 358 | return fmt.Errorf("%w: N in marshal does not match N in marshalled", errors.Join(ErrJWKValidation, err)) 359 | } 360 | err = cmpBase64Int(j.marshal.E, marshalled.E, j.options.Validate.StrictPadding) 361 | if err != nil { 362 | return fmt.Errorf("%w: E in marshal does not match E in marshalled", errors.Join(ErrJWKValidation, err)) 363 | } 364 | err = cmpBase64Int(j.marshal.P, marshalled.P, j.options.Validate.StrictPadding) 365 | if err != nil { 366 | return fmt.Errorf("%w: P in marshal does not match P in marshalled", errors.Join(ErrJWKValidation, err)) 367 | } 368 | err = cmpBase64Int(j.marshal.Q, marshalled.Q, j.options.Validate.StrictPadding) 369 | if err != nil { 370 | return fmt.Errorf("%w: Q in marshal does not match Q in marshalled", errors.Join(ErrJWKValidation, err)) 371 | } 372 | err = cmpBase64Int(j.marshal.DP, marshalled.DP, j.options.Validate.StrictPadding) 373 | if err != nil { 374 | return fmt.Errorf("%w: DP in marshal does not match DP in marshalled", errors.Join(ErrJWKValidation, err)) 375 | } 376 | err = cmpBase64Int(j.marshal.DQ, marshalled.DQ, j.options.Validate.StrictPadding) 377 | if err != nil { 378 | return fmt.Errorf("%w: DQ in marshal does not match DQ in marshalled", errors.Join(ErrJWKValidation, err)) 379 | } 380 | if len(j.marshal.OTH) != len(marshalled.OTH) { 381 | return fmt.Errorf("%w: OTH in marshal does not match OTH in marshalled", ErrJWKValidation) 382 | } 383 | for i, o := range j.marshal.OTH { 384 | err = cmpBase64Int(o.R, marshalled.OTH[i].R, j.options.Validate.StrictPadding) 385 | if err != nil { 386 | return fmt.Errorf("%w: OTH index %d in marshal does not match OTH in marshalled", errors.Join(ErrJWKValidation, err), i) 387 | } 388 | err = cmpBase64Int(o.D, marshalled.OTH[i].D, j.options.Validate.StrictPadding) 389 | if err != nil { 390 | return fmt.Errorf("%w: OTH index %d in marshal does not match OTH in marshalled", errors.Join(ErrJWKValidation, err), i) 391 | } 392 | err = cmpBase64Int(o.T, marshalled.OTH[i].T, j.options.Validate.StrictPadding) 393 | if err != nil { 394 | return fmt.Errorf("%w: OTH index %d in marshal does not match OTH in marshalled", errors.Join(ErrJWKValidation, err), i) 395 | } 396 | } 397 | case KtyOct: 398 | err = cmpBase64Int(j.marshal.K, marshalled.K, j.options.Validate.StrictPadding) 399 | if err != nil { 400 | return fmt.Errorf("%w: K in marshal does not match K in marshalled", errors.Join(ErrJWKValidation, err)) 401 | } 402 | default: 403 | return fmt.Errorf("%w: invalid or unsupported key type %q", ErrJWKValidation, j.marshal.KTY) 404 | } 405 | 406 | // Saved for last because it may involve a network request. 407 | if j.marshal.X5U != "" || j.options.X509.X5U != "" { 408 | if j.marshal.X5U != j.options.X509.X5U { 409 | return fmt.Errorf("%w: X5U in marshal does not match X5U in options", errors.Join(ErrJWKValidation, ErrOptions)) 410 | } 411 | u, err := url.ParseRequestURI(j.marshal.X5U) 412 | if err != nil { 413 | return fmt.Errorf("failed to parse X5U URI: %w", errors.Join(ErrJWKValidation, ErrOptions, err)) 414 | } 415 | if !j.options.Validate.SkipX5UScheme && u.Scheme != "https" { 416 | return fmt.Errorf("%w: X5U URI scheme must be https", errors.Join(ErrJWKValidation, ErrOptions)) 417 | } 418 | if j.options.Validate.GetX5U != nil { 419 | certs, err := j.options.Validate.GetX5U(u) 420 | if err != nil { 421 | return fmt.Errorf("failed to get X5U URI: %w", errors.Join(ErrJWKValidation, ErrOptions, err)) 422 | } 423 | if len(certs) == 0 { 424 | return fmt.Errorf("%w: X5U URI did not return any certificates", errors.Join(ErrJWKValidation, ErrOptions)) 425 | } 426 | larger := certs 427 | smaller := j.options.X509.X5C 428 | if len(j.options.X509.X5C) > len(certs) { 429 | larger = j.options.X509.X5C 430 | smaller = certs 431 | } 432 | for i, c := range smaller { 433 | if !c.Equal(larger[i]) { 434 | return fmt.Errorf("%w: the X5C and X5U (remote resource) parameters are not a full or partial match", errors.Join(ErrJWKValidation, ErrOptions)) 435 | } 436 | } 437 | } 438 | } 439 | 440 | return nil 441 | } 442 | 443 | // DefaultGetX5U is the default implementation of the GetX5U field for JWKValidateOptions. 444 | func DefaultGetX5U(u *url.URL) ([]*x509.Certificate, error) { 445 | timeout := time.Minute 446 | ctx, cancel := context.WithTimeoutCause(context.Background(), timeout, fmt.Errorf("%w: timeout of %s reached", ErrGetX5U, timeout.String())) 447 | defer cancel() 448 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) 449 | if err != nil { 450 | return nil, fmt.Errorf("failed to create X5U request: %w", errors.Join(ErrGetX5U, err)) 451 | } 452 | resp, err := http.DefaultClient.Do(req) 453 | if err != nil { 454 | return nil, fmt.Errorf("failed to do X5U request: %w", errors.Join(ErrGetX5U, err)) 455 | } 456 | defer resp.Body.Close() 457 | if resp.StatusCode != http.StatusOK { 458 | return nil, fmt.Errorf("%w: X5U request returned status code %d", ErrGetX5U, resp.StatusCode) 459 | } 460 | b, err := io.ReadAll(resp.Body) 461 | if err != nil { 462 | return nil, fmt.Errorf("failed to read X5U response body: %w", errors.Join(ErrGetX5U, err)) 463 | } 464 | certs, err := LoadCertificates(b) 465 | if err != nil { 466 | return nil, fmt.Errorf("failed to parse X5U response body: %w", errors.Join(ErrGetX5U, err)) 467 | } 468 | return certs, nil 469 | } 470 | 471 | func cmpBase64Int(first, second string, strictPadding bool) error { 472 | if first == second { 473 | return nil 474 | } 475 | var b []byte 476 | var err error 477 | if strictPadding { 478 | b, err = base64.RawURLEncoding.DecodeString(first) 479 | if err != nil { 480 | return fmt.Errorf("failed to decode Base64 raw URL decode first string: %w", err) 481 | } 482 | } else { 483 | b, err = base64urlTrailingPadding(first) 484 | if err != nil { 485 | return fmt.Errorf("failed to decode Base64 URL (remove trailing padding) decode first string: %w", err) 486 | } 487 | } 488 | fLen := len(b) 489 | f := new(big.Int).SetBytes(b) 490 | if strictPadding { 491 | b, err = base64.RawURLEncoding.DecodeString(second) 492 | if err != nil { 493 | return fmt.Errorf("failed to decode Base64 raw URL decode second string: %w", err) 494 | } 495 | } else { 496 | b, err = base64urlTrailingPadding(second) 497 | if err != nil { 498 | return fmt.Errorf("failed to decode Base64 URL (remove trailing padding) decode second string: %w", err) 499 | } 500 | } 501 | sLen := len(b) 502 | s := new(big.Int).SetBytes(b) 503 | if f.Cmp(s) != 0 { 504 | return fmt.Errorf("%w: the parsed integers do not match", ErrJWKValidation) 505 | } 506 | if strictPadding && fLen != sLen { 507 | return fmt.Errorf("%w: the Base64 raw URL inputs do not have matching padding", errors.Join(ErrJWKValidation, ErrPadding)) 508 | } 509 | return nil 510 | } 511 | -------------------------------------------------------------------------------- /jwk_test.go: -------------------------------------------------------------------------------- 1 | package jwkset 2 | 3 | import ( 4 | "context" 5 | "crypto/ecdh" 6 | "crypto/ed25519" 7 | "crypto/x509" 8 | "encoding/base64" 9 | "encoding/json" 10 | "encoding/pem" 11 | "errors" 12 | "math/big" 13 | "testing" 14 | "time" 15 | ) 16 | 17 | const ( 18 | anyStr = "any" 19 | invalidStr = "invalid" 20 | ) 21 | 22 | func TestNewJWKFromRawJSON(t *testing.T) { 23 | marshalOptions := JWKMarshalOptions{ 24 | Private: true, 25 | } 26 | jwk, err := NewJWKFromRawJSON([]byte(edExpected), marshalOptions, JWKValidateOptions{}) 27 | if err != nil { 28 | t.Fatalf("Failed to create JWK from raw JSON. %s", err) 29 | } 30 | if jwk.Marshal().KID != edID { 31 | t.Fatalf("Incorrect KID. %s", jwk.Marshal().KID) 32 | } 33 | 34 | _, err = NewJWKFromRawJSON([]byte("invalid"), JWKMarshalOptions{}, JWKValidateOptions{}) 35 | if err == nil { 36 | t.Fatal("Expected an error.") 37 | } 38 | } 39 | 40 | func TestJSON(t *testing.T) { 41 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 42 | defer cancel() 43 | 44 | jwks := NewMemoryStorage() 45 | testJSON(ctx, t, jwks) 46 | } 47 | 48 | func TestThumbprint(t *testing.T) { 49 | type thumbprintScenario int 50 | const ( 51 | thumbprintScenarioCorrect thumbprintScenario = iota 52 | thumbprintScenarioIncorrect 53 | thumbprintScenarioMissing 54 | thumbprintScenarioNoCert 55 | ) 56 | testCases := []struct { 57 | name string 58 | x5tScenario thumbprintScenario 59 | x5tS256Scenario thumbprintScenario 60 | }{ 61 | { 62 | name: "CorrectX5TAndX5T#S256", 63 | }, 64 | { 65 | name: "MissingX5T", 66 | x5tScenario: thumbprintScenarioMissing, 67 | }, 68 | { 69 | name: "MissingX5T#S256", 70 | x5tS256Scenario: thumbprintScenarioMissing, 71 | }, 72 | { 73 | name: "MissingX5TAndX5T#S256", 74 | x5tScenario: thumbprintScenarioMissing, 75 | x5tS256Scenario: thumbprintScenarioMissing, 76 | }, 77 | { 78 | name: "IncorrectX5T", 79 | x5tScenario: thumbprintScenarioIncorrect, 80 | }, 81 | { 82 | name: "IncorrectX5T#S256", 83 | x5tS256Scenario: thumbprintScenarioIncorrect, 84 | }, 85 | { 86 | name: "IncorrectX5TAndX5T#S256", 87 | x5tScenario: thumbprintScenarioIncorrect, 88 | x5tS256Scenario: thumbprintScenarioIncorrect, 89 | }, 90 | { 91 | name: "NoCertX5T", 92 | x5tScenario: thumbprintScenarioNoCert, 93 | x5tS256Scenario: thumbprintScenarioMissing, 94 | }, 95 | { 96 | name: "NoCertX5T#S256", 97 | x5tScenario: thumbprintScenarioMissing, 98 | x5tS256Scenario: thumbprintScenarioNoCert, 99 | }, 100 | { 101 | name: "NoCertX5TAndX5T#S256", 102 | x5tScenario: thumbprintScenarioNoCert, 103 | x5tS256Scenario: thumbprintScenarioNoCert, 104 | }, 105 | } 106 | for _, tc := range testCases { 107 | t.Run(tc.name, func(t *testing.T) { 108 | block, _ := pem.Decode([]byte(ed25519Cert)) 109 | cert, err := LoadCertificate(block.Bytes) 110 | if err != nil { 111 | t.Fatalf("Failed to load certificate. %s", err) 112 | } 113 | metadata := JWKMetadataOptions{ 114 | KID: myKeyID, 115 | } 116 | x509Options := JWKX509Options{ 117 | X5C: []*x509.Certificate{cert}, 118 | } 119 | options := JWKOptions{ 120 | Metadata: metadata, 121 | X509: x509Options, 122 | } 123 | jwk, err := NewJWKFromKey(cert.PublicKey, options) 124 | if err != nil { 125 | t.Fatalf("Failed to create JWK from key. %s", err) 126 | } 127 | marshal := jwk.Marshal() 128 | switch tc.x5tScenario { 129 | case thumbprintScenarioCorrect: 130 | // Do nothing. 131 | case thumbprintScenarioIncorrect: 132 | marshal.X5T = invalidStr 133 | case thumbprintScenarioMissing: 134 | marshal.X5T = "" 135 | case thumbprintScenarioNoCert: 136 | marshal.X5C = nil 137 | } 138 | switch tc.x5tS256Scenario { 139 | case thumbprintScenarioCorrect: 140 | // Do nothing. 141 | case thumbprintScenarioIncorrect: 142 | marshal.X5TS256 = invalidStr 143 | case thumbprintScenarioMissing: 144 | marshal.X5TS256 = "" 145 | case thumbprintScenarioNoCert: 146 | marshal.X5C = nil 147 | } 148 | jwk, err = NewJWKFromMarshal(marshal, JWKMarshalOptions{}, JWKValidateOptions{}) 149 | if err != nil { 150 | if tc.x5tScenario == thumbprintScenarioIncorrect || tc.x5tS256Scenario == thumbprintScenarioIncorrect { 151 | return 152 | } 153 | t.Fatalf("Failed to create JWK from marshal. %s", err) 154 | } 155 | if jwk.Marshal().KID != myKeyID { 156 | t.Fatalf("Incorrect KID. %s", jwk.Marshal().KID) 157 | } 158 | }) 159 | } 160 | } 161 | 162 | func TestJWK_Validate(t *testing.T) { 163 | jwk := JWK{} 164 | err := jwk.Validate() 165 | if err == nil { 166 | t.Fatalf("Expected to fail validation for empty JWK.") 167 | } 168 | 169 | jwk.options.Validate.SkipAll = true 170 | err = jwk.Validate() 171 | if err != nil { 172 | t.Fatalf("Failed to skip validation. %s", err) 173 | } 174 | jwk.options.Validate.SkipAll = false 175 | 176 | jwk.marshal.KTY = KtyOKP 177 | jwk.marshal.USE = invalidStr 178 | err = jwk.Validate() 179 | if err == nil { 180 | t.Fatalf("Expected to fail validation for invalid use.") 181 | } 182 | jwk.marshal.USE = "" 183 | 184 | jwk.marshal.KEYOPS = []KEYOPS{invalidStr} 185 | err = jwk.Validate() 186 | if err == nil { 187 | t.Fatalf("Expected to fail validation for invalid key operations.") 188 | } 189 | jwk.marshal.KEYOPS = nil 190 | 191 | jwk.options.Metadata.ALG = AlgEdDSA 192 | err = jwk.Validate() 193 | if err == nil { 194 | t.Fatalf("Expected to fail validation for options not matching algorithm.") 195 | } 196 | jwk.options.Metadata.ALG = "" 197 | 198 | jwk.options.Metadata.KID = anyStr 199 | err = jwk.Validate() 200 | if err == nil { 201 | t.Fatalf("Expected to fail validation for options not matching key ID.") 202 | } 203 | jwk.options.Metadata.KID = "" 204 | 205 | jwk.options.Metadata.KEYOPS = []KEYOPS{KeyOpsSign} 206 | err = jwk.Validate() 207 | if err == nil { 208 | t.Fatalf("Expected to fail validation for options not matching key operations.") 209 | } 210 | jwk.options.Metadata.KEYOPS = nil 211 | 212 | jwk.options.Metadata.USE = UseSig 213 | err = jwk.Validate() 214 | if err == nil { 215 | t.Fatalf("Expected to fail validation for options not matching use.") 216 | } 217 | jwk.options.Metadata.USE = "" 218 | } 219 | 220 | func TestJWK_Validate_Padding(t *testing.T) { 221 | const invalidRSAModulusPadding = ` 222 | { 223 | "kty": "RSA", 224 | "n": "AOpF5dwoCpmW2Th5kBaKDZmygOlyQSJm3JqwGvPTTViHCs4ZitlLF9za9-DPxP3zoNaryEYlFfLhYOFVS7mUjMGtLNTkLafBSIIoF28sy_z1GruxJ2aFchazBimxI1B0MXTKdIw4V268klrOECO5FIcHar7EV9W0XqToFon3oVvHWw3qkPV4o-A7Gdrh3Yh7vRUE_T5XCLYD9jO41nAqYhWYRGN-Kxu51x6VMa595TXTrpzgYGDba1MLQzB9qcHRIvRskt7Gh8M0zgcyo6c6jvktaEzh0j2kdL2JCAFHhMXUZedRUOpeqkEehpxDDR0Deiz7UPlMe6l8Ots97Wm357bgajDcxnqaGGEF5GIkr7xHw15DrTfOWPY35f0sHjNTOn9AU2bPWTy6oHZPhoFjHdSNp3UOIunnf1eXRlTa7YZ5PLmbFFyjNNSnQdcOHgKx1lJExJqXCAJ2pBkp0dX65uiqCLz4WZBcmCHGToi4mvQ5wpFqgUJ_6N8HXpP5ZLZ-hQ", 225 | "e": "AQAB" 226 | }` 227 | jwk, err := NewJWKFromRawJSON([]byte(invalidRSAModulusPadding), JWKMarshalOptions{}, JWKValidateOptions{}) 228 | if err != nil { 229 | t.Fatalf("Failed to create JWK from raw JSON. %s", err) 230 | } 231 | err = jwk.Validate() 232 | if err != nil { 233 | t.Fatalf("Failed to validate RSA JWK with acceptably invalid padding. %s", err) 234 | } 235 | jwk.options.Validate.StrictPadding = true 236 | err = jwk.Validate() 237 | if !errors.Is(err, ErrPadding) { 238 | t.Fatalf("Expected to fail validation for invalid RSA modulus padding.") 239 | } 240 | 241 | const invalidECDSAPadding = ` 242 | { 243 | "kty": "EC", 244 | "crv": "P-521", 245 | "x": "aQnZOuwyXH1APmjESTgHLVUH49Ry19Ay7hgHiOB4Nsv5m_JN18wW-ByFtGtHatVJ_OHL5TuLOTSsp8ctniKTn3E", 246 | "y": "TZAwFszO_oiyvncIviOJdi8MU8VDfZo8Y3q0Z-AxaPDUFQS8aRDCHUzukj6RCNZsRCWd0HGOayIhV_uQZrB_Xbc", 247 | "d": "AZHsd9nLaXHFWH4wjiW5XcCrIO9AWl4Y0aV64kagRFPnWjljC6VxCsFF5IM0vTzCWKdlwFLEIgJO0pfwWlQMXKef" 248 | } 249 | ` 250 | jwk, err = NewJWKFromRawJSON([]byte(invalidECDSAPadding), JWKMarshalOptions{}, JWKValidateOptions{}) 251 | if err != nil { 252 | t.Fatalf("Failed to create JWK from raw JSON. %s", err) 253 | } 254 | err = jwk.Validate() 255 | if err != nil { 256 | t.Fatalf("Failed to validate ECDSA JWK with acceptably invalid padding. %s", err) 257 | } 258 | jwk.options.Validate.StrictPadding = true 259 | err = jwk.Validate() 260 | if !errors.Is(err, ErrPadding) { 261 | t.Fatalf("Expected to fail validation for invalid ECDSA padding.") 262 | } 263 | } 264 | 265 | func TestCmpBase64Int(t *testing.T) { 266 | intA := int64(123_456_789) 267 | bytesA := big.NewInt(intA).Bytes() 268 | intB := int64(987_654_321) 269 | bytesB := big.NewInt(intB).Bytes() 270 | testCases := []struct { 271 | name string 272 | first string 273 | second string 274 | strict bool 275 | wantErr bool 276 | }{ 277 | { 278 | name: "SameStrict", 279 | first: base64.RawURLEncoding.EncodeToString(bytesA), 280 | second: base64.RawURLEncoding.EncodeToString(bytesA), 281 | strict: true, 282 | wantErr: false, 283 | }, 284 | { 285 | name: "SameNotStrict", 286 | first: base64.RawURLEncoding.EncodeToString(bytesA), 287 | second: base64.RawURLEncoding.EncodeToString(bytesA), 288 | strict: false, 289 | wantErr: false, 290 | }, 291 | { 292 | name: "DifferentPaddingStrict", 293 | first: base64.RawURLEncoding.EncodeToString(bytesA), 294 | second: base64.URLEncoding.EncodeToString(bytesA), 295 | strict: true, 296 | wantErr: true, 297 | }, 298 | { 299 | name: "DifferentPaddingNotStrict", 300 | first: base64.RawURLEncoding.EncodeToString(bytesA), 301 | second: base64.URLEncoding.EncodeToString(bytesA), 302 | strict: false, 303 | wantErr: false, 304 | }, 305 | { 306 | name: "DifferentStrict", 307 | first: base64.RawURLEncoding.EncodeToString(bytesA), 308 | second: base64.RawURLEncoding.EncodeToString(bytesB), 309 | strict: true, 310 | wantErr: true, 311 | }, 312 | { 313 | name: "DifferentNotStrict", 314 | first: base64.RawURLEncoding.EncodeToString(bytesA), 315 | second: base64.RawURLEncoding.EncodeToString(bytesB), 316 | strict: false, 317 | wantErr: true, 318 | }, 319 | } 320 | for _, tc := range testCases { 321 | t.Run(tc.name, func(t *testing.T) { 322 | err := cmpBase64Int(tc.first, tc.second, tc.strict) 323 | if tc.wantErr == (err == nil) { 324 | t.Fatalf("Expected error %v, got %v", tc.wantErr, err) 325 | } 326 | }) 327 | } 328 | } 329 | 330 | func testJSON(ctx context.Context, t *testing.T, jwks Storage) { 331 | b, err := base64.RawURLEncoding.DecodeString(x25519PrivateKey) 332 | if err != nil { 333 | t.Fatalf("Failed to decode ECDH X25519 private key. %s", err) 334 | } 335 | x25519Priv, err := ecdh.X25519().NewPrivateKey(b) 336 | if err != nil { 337 | t.Fatalf("Failed to generate ECDH X25519 key. %s", err) 338 | } 339 | writeKey(ctx, t, jwks, x25519Priv, x25519ID, true) 340 | 341 | block, _ := pem.Decode([]byte(ecPrivateKey)) 342 | eKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) 343 | if err != nil { 344 | t.Fatalf("Failed to parse EC private key. %s", err) 345 | } 346 | writeKey(ctx, t, jwks, eKey, eID, true) 347 | 348 | edPriv, err := base64.RawURLEncoding.DecodeString(edPrivateKey) 349 | if err != nil { 350 | t.Fatalf("Failed to decode EdDSA private key. %s", err) 351 | } 352 | edPub, err := base64.RawURLEncoding.DecodeString(edPublicKey) 353 | if err != nil { 354 | t.Fatalf("Failed to decode EdDSA public key. %s", err) 355 | } 356 | ed := ed25519.PrivateKey(append(edPriv, edPub...)) 357 | writeKey(ctx, t, jwks, ed, edID, true) 358 | 359 | block, _ = pem.Decode([]byte(rsaPrivateKey)) 360 | rKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) 361 | if err != nil { 362 | t.Fatalf("Failed to parse RSA private key. %s", err) 363 | } 364 | writeKey(ctx, t, jwks, rKey, rID, true) 365 | 366 | hKey := []byte(hmacSecret) 367 | writeKey(ctx, t, jwks, hKey, hID, true) 368 | 369 | jsonRepresentation, err := jwks.JSONPublic(ctx) 370 | if err != nil { 371 | t.Fatalf("Failed to get JSON. %s", err) 372 | } 373 | compareJSON(t, jsonRepresentation, false) 374 | 375 | jsonRepresentation, err = jwks.JSONPrivate(ctx) 376 | if err != nil { 377 | t.Fatalf("Failed to get JSON. %s", err) 378 | } 379 | compareJSON(t, jsonRepresentation, true) 380 | } 381 | 382 | func compareJSON(t *testing.T, actual json.RawMessage, private bool) { 383 | type jwksUnmarshal struct { 384 | Keys []map[string]any `json:"keys"` 385 | } 386 | 387 | var keys jwksUnmarshal 388 | err := json.Unmarshal(actual, &keys) 389 | if err != nil { 390 | t.Fatalf("Failed to unmarshal actual JSON. %s", err) 391 | } 392 | 393 | wrongLength := false 394 | var expectedKeys int 395 | if private && len(keys.Keys) != 5 { 396 | expectedKeys = 5 397 | wrongLength = true 398 | } else if !private && len(keys.Keys) != 4 { 399 | expectedKeys = 4 400 | wrongLength = true 401 | } 402 | if wrongLength { 403 | t.Fatalf("Expected %d keys. Got %d. HMAC keys should not have a JSON representation.", expectedKeys, len(keys.Keys)) 404 | } 405 | 406 | for _, key := range keys.Keys { 407 | kty, ok := key["kty"].(string) 408 | if !ok { 409 | t.Fatal("Failed to get key type.") 410 | } 411 | 412 | var expectedJSON json.RawMessage 413 | var matchingAttributes []string 414 | switch KTY(kty) { 415 | case KtyEC: 416 | expectedJSON = json.RawMessage(ecExpected) 417 | matchingAttributes = []string{"kty", "kid", "crv", "x", "y"} 418 | if private { 419 | matchingAttributes = append(matchingAttributes, "d") 420 | } 421 | case KtyOKP: 422 | matchingAttributes = []string{"crv", "kty", "kid", "x"} 423 | if private { 424 | matchingAttributes = append(matchingAttributes, "d") 425 | } 426 | switch CRV(key["crv"].(string)) { 427 | case CrvEd25519: 428 | matchingAttributes = append(matchingAttributes, "alg") 429 | expectedJSON = json.RawMessage(edExpected) 430 | case CrvX25519: 431 | expectedJSON = json.RawMessage(x25519Expected) 432 | default: 433 | t.Fatalf("Unknown OKP curve %q.", key["crv"].(string)) 434 | } 435 | case KtyRSA: 436 | expectedJSON = json.RawMessage(rsaExpected) 437 | matchingAttributes = []string{"kty", "kid", "n", "e"} 438 | if private { 439 | matchingAttributes = append(matchingAttributes, "d", "p", "q", "dp", "dq", "qi") 440 | } 441 | case KtyOct: 442 | if private { 443 | expectedJSON = json.RawMessage(hmacExpected) 444 | matchingAttributes = []string{"kty", "kid", "k"} 445 | } else { 446 | t.Fatal("HMAC keys should not have a JSON representation.") 447 | } 448 | } 449 | var expectedMap map[string]any 450 | err = json.Unmarshal(expectedJSON, &expectedMap) 451 | if err != nil { 452 | t.Fatalf("Failed to unmarshal expected JSON. %s", err) 453 | } 454 | 455 | for _, attribute := range matchingAttributes { 456 | actualAttr, ok := key[attribute].(string) 457 | if !ok { 458 | t.Fatalf("Failed to get actual attribute %s.", attribute) 459 | } 460 | expectedAttr, ok := expectedMap[attribute].(string) 461 | if !ok { 462 | t.Fatalf("Failed to get expected attribute %s.", attribute) 463 | } 464 | if actualAttr != expectedAttr { 465 | t.Fatalf("Attribute %s does not match.\n Actual: %q\n Expected: %q", attribute, actualAttr, expectedAttr) 466 | } 467 | } 468 | } 469 | } 470 | 471 | func writeKey(ctx context.Context, t *testing.T, jwks Storage, key any, keyID string, private bool) { 472 | marshal := JWKMarshalOptions{ 473 | Private: private, 474 | } 475 | metadata := JWKMetadataOptions{ 476 | KID: keyID, 477 | } 478 | options := JWKOptions{ 479 | Marshal: marshal, 480 | Metadata: metadata, 481 | } 482 | jwk, err := NewJWKFromKey(key, options) 483 | if err != nil { 484 | t.Fatalf("Failed to create JWK from key ID %q. %s", keyID, err) 485 | } 486 | err = jwks.KeyWrite(ctx, jwk) 487 | if err != nil { 488 | t.Fatalf("Failed to write key ID %q. %s", keyID, err) 489 | } 490 | } 491 | 492 | const ( 493 | x25519ID = "myX25519Key" 494 | eID = "myECKey" 495 | edID = "myEdDSAKey" 496 | hID = "myHMACKey" 497 | rID = "myRSAKey" 498 | ) 499 | 500 | /* 501 | These assets were generated using this tool: 502 | https://mkjwk.org/ 503 | */ 504 | const ( 505 | x25519Expected = `{ 506 | "kty": "OKP", 507 | "d": "GIu7AbclXA1FtVswPBUileBckbJu2B9UUhZPTebrox4", 508 | "crv": "X25519", 509 | "kid": "myX25519Key", 510 | "x": "fGMcCrO_gWS7rva_PpXiS7D5-2OppjZQLlZmdRUSN0g" 511 | }` 512 | x25519PrivateKey = `GIu7AbclXA1FtVswPBUileBckbJu2B9UUhZPTebrox4` 513 | ecExpected = `{ 514 | "kty": "EC", 515 | "d": "Vp3epfDd9viOo1w6Co7DpIP2lPnqwIB8HcOrI7Jt0II", 516 | "crv": "P-256", 517 | "kid": "myECKey", 518 | "x": "24yKWYrRffYdpQzbnkzbhABivplltO-eimNwqK3xeAM", 519 | "y": "qGxS4s4TH35_VK4Bk119s16tFGKegwHJc3pL2p2Zy30" 520 | }` 521 | ecPrivateKey = `-----BEGIN PRIVATE KEY----- 522 | MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCBWnd6l8N32+I6jXDoK 523 | jsOkg/aU+erAgHwdw6sjsm3Qgg== 524 | -----END PRIVATE KEY-----` 525 | edExpected = `{ 526 | "alg": "EdDSA", 527 | "kty": "OKP", 528 | "d": "tKqo1bnSif18g2hE0D7zPDNgSTKQKwBMEl2UvhJZ-bs", 529 | "crv": "Ed25519", 530 | "kid": "myEdDSAKey", 531 | "x": "eX81_IFCbcbhBDD-wgUYbYk8E6DLnPnl39YXx_ru7ao" 532 | }` 533 | edPrivateKey = "tKqo1bnSif18g2hE0D7zPDNgSTKQKwBMEl2UvhJZ-bs" 534 | edPublicKey = "eX81_IFCbcbhBDD-wgUYbYk8E6DLnPnl39YXx_ru7ao" 535 | hmacExpected = `{ 536 | "kty": "oct", 537 | "kid": "myHMACKey", 538 | "k": "bXlITUFDU2VjcmV0" 539 | }` 540 | rsaExpected = `{ 541 | "p": "5x2fw5e3bz20IxlbU3Jxn9OOAeMuVGqC-BP2XYk6-2T9T_TeKRgEEIoHtt0lre3QZrefB-6UjNfXU6pfuMr4BsSpT-tAjiUI1c8EmHC5hhpCDJ8LWekWrTJDPApfQjpZK-HO0UdIZCIILyVr82KuZax5RKBMTMfDPjF2NQxwqFc", 542 | "kty": "RSA", 543 | "q": "wbu1LZuDBRq8PZ-G2SJNU_t-b1Zev3Hn6iLFNYF5Y3CYRVtAg_TWpErfrM-4YUXucLQGsLOaCnRNQ81GXFb9e6W7sY8UeyAlqFxxtm0FZ2CnpxxS9EYq57AP5EfpyOi7DNUe0fe0wTwC5o_sq-pMOeCsuWgiXjgTpDoydwtjIFM", 544 | "d": "c0w8JqtmAAX5TC5Ba0KaGft-uAi-Q0rngcob_l8dVcF6pRqN0QKhwZAKKlb57hwHLdzl6Rc9YmVjWBemVo-Pi-ZKpeXSnkxFEc_50NMMGOp4TIjBaoJcrQ3KP5T7djwPc0aZ51z7XtUZ8Q_G0gEGAywnG6zUTJlqS8ctybBcol0LDl--Ps52I2pupZ1RiIRsgPF0zrGTsGrnxdtFVxOVRqNTZ26fEOSqRRTXxC4PNN4PDR2OSTDc-G1F_OPGJutgPnt7dpgw0vAkGD0b4FxtMXTXoS3cgB5zug4ySi9-1jBvAvNkWt0i3OoYPPLarDjlesRTHs5P_iOWjt6nFBeLpQ", 545 | "e": "AQAB", 546 | "kid": "myRSAKey", 547 | "qi": "eJxCTg3NoEUcK8eCMBp0ukJ1SZD11UbWrL-Js6YaAr-Mx5nrWozMfcyaerSrwGYcCmD3Ga3bhv28TyGCujCsT35aWqOyi9S51M8AJ6VoiLgYSufuI7DnlUHjKpoPezhSM-RWW1QFdLR9InCBsfQctiy0Hf8IjaKqtPotx6zTR2E", 548 | "dp": "xHCRkxYpfAveSNcMoOjtWwPd-Ay5HFdL6sBM70PtNjCofoWLLzKSgdxQokVl-Wfhcu0v5vYKnYv4Icz2f4NFPbt6jctPm4Iu-Ex1g3yMtEctTL0CUPGlrKDENQw723bsxDeyKn-EMFgczLXqA30k7painIoDUF-avAoehwiD2RE", 549 | "dq": "BK60wlVv5T-wLQ0eBUF-_PinJanAwH_QSyhr-88VUAH4rDR4argQOAhXP6YFntRB3xd60eqFXptRAsKDYNf5aHOpBbGfnRo5zsftN6uK5eTAKJnWp3DKuK7Ys3vJesGlQ7oi9JA4HjOFHm18GuuezAdSJWkO65gPYXjGn3n2-2E", 550 | "n": "rubLp0fQtgIIy1xq-fM-mDxlobK7qUf1UIH4DQHUSWXzauvRNaV2cj4iIhooJVej24v0EOH3ZNzdt8MTj7X9r5P1GSIFfNydcP_00T8zeYec0x7XjdNsZ2EY5rYV3Eo-rRivz08y5622Bt82o0td4QvMovmYKGwTKIiIe0mCByOOVbIACPEvZsCiI-Fbd_ovFv1zAl_-G8DAXCQHz-MwpW_ouZmdlnFz0kMCPf58cEUvLCczt4C8xCRYYqQyz84Nal0BiZ4x8ZiZ6k_z8SRN_QB5bk9aetwKgjBPWsBpwnuccXjGyGqSIWa91tTxeGMC4nsHWT89LDH_0dn-9DZ0NQ" 551 | }` 552 | rsaPrivateKey = `-----BEGIN PRIVATE KEY----- 553 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCu5sunR9C2AgjL 554 | XGr58z6YPGWhsrupR/VQgfgNAdRJZfNq69E1pXZyPiIiGiglV6Pbi/QQ4fdk3N23 555 | wxOPtf2vk/UZIgV83J1w//TRPzN5h5zTHteN02xnYRjmthXcSj6tGK/PTzLnrbYG 556 | 3zajS13hC8yi+ZgobBMoiIh7SYIHI45VsgAI8S9mwKIj4Vt3+i8W/XMCX/4bwMBc 557 | JAfP4zClb+i5mZ2WcXPSQwI9/nxwRS8sJzO3gLzEJFhipDLPzg1qXQGJnjHxmJnq 558 | T/PxJE39AHluT1p63AqCME9awGnCe5xxeMbIapIhZr3W1PF4YwLiewdZPz0sMf/R 559 | 2f70NnQ1AgMBAAECggEAc0w8JqtmAAX5TC5Ba0KaGft+uAi+Q0rngcob/l8dVcF6 560 | pRqN0QKhwZAKKlb57hwHLdzl6Rc9YmVjWBemVo+Pi+ZKpeXSnkxFEc/50NMMGOp4 561 | TIjBaoJcrQ3KP5T7djwPc0aZ51z7XtUZ8Q/G0gEGAywnG6zUTJlqS8ctybBcol0L 562 | Dl++Ps52I2pupZ1RiIRsgPF0zrGTsGrnxdtFVxOVRqNTZ26fEOSqRRTXxC4PNN4P 563 | DR2OSTDc+G1F/OPGJutgPnt7dpgw0vAkGD0b4FxtMXTXoS3cgB5zug4ySi9+1jBv 564 | AvNkWt0i3OoYPPLarDjlesRTHs5P/iOWjt6nFBeLpQKBgQDnHZ/Dl7dvPbQjGVtT 565 | cnGf044B4y5UaoL4E/ZdiTr7ZP1P9N4pGAQQige23SWt7dBmt58H7pSM19dTql+4 566 | yvgGxKlP60COJQjVzwSYcLmGGkIMnwtZ6RatMkM8Cl9COlkr4c7RR0hkIggvJWvz 567 | Yq5lrHlEoExMx8M+MXY1DHCoVwKBgQDBu7Utm4MFGrw9n4bZIk1T+35vVl6/cefq 568 | IsU1gXljcJhFW0CD9NakSt+sz7hhRe5wtAaws5oKdE1DzUZcVv17pbuxjxR7ICWo 569 | XHG2bQVnYKenHFL0RirnsA/kR+nI6LsM1R7R97TBPALmj+yr6kw54Ky5aCJeOBOk 570 | OjJ3C2MgUwKBgQDEcJGTFil8C95I1wyg6O1bA934DLkcV0vqwEzvQ+02MKh+hYsv 571 | MpKB3FCiRWX5Z+Fy7S/m9gqdi/ghzPZ/g0U9u3qNy0+bgi74THWDfIy0Ry1MvQJQ 572 | 8aWsoMQ1DDvbduzEN7Iqf4QwWBzMteoDfSTulqKcigNQX5q8Ch6HCIPZEQKBgASu 573 | tMJVb+U/sC0NHgVBfvz4pyWpwMB/0Esoa/vPFVAB+Kw0eGq4EDgIVz+mBZ7UQd8X 574 | etHqhV6bUQLCg2DX+WhzqQWxn50aOc7H7TeriuXkwCiZ1qdwyriu2LN7yXrBpUO6 575 | IvSQOB4zhR5tfBrrnswHUiVpDuuYD2F4xp959vthAoGAeJxCTg3NoEUcK8eCMBp0 576 | ukJ1SZD11UbWrL+Js6YaAr+Mx5nrWozMfcyaerSrwGYcCmD3Ga3bhv28TyGCujCs 577 | T35aWqOyi9S51M8AJ6VoiLgYSufuI7DnlUHjKpoPezhSM+RWW1QFdLR9InCBsfQc 578 | tiy0Hf8IjaKqtPotx6zTR2E= 579 | -----END PRIVATE KEY-----` 580 | ) 581 | -------------------------------------------------------------------------------- /storage.go: -------------------------------------------------------------------------------- 1 | package jwkset 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "slices" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | var ( 16 | // ErrKeyNotFound is returned by a Storage implementation when a key is not found. 17 | ErrKeyNotFound = errors.New("key not found") 18 | // ErrInvalidHTTPStatusCode is returned when the HTTP status code is invalid. 19 | ErrInvalidHTTPStatusCode = errors.New("invalid HTTP status code") 20 | ) 21 | 22 | // Storage handles storage operations for a JWKSet. 23 | type Storage interface { 24 | // KeyDelete deletes a key from the storage. It will return ok as true if the key was present for deletion. 25 | KeyDelete(ctx context.Context, keyID string) (ok bool, err error) 26 | // KeyRead reads a key from the storage. If the key is not present, it returns ErrKeyNotFound. Any pointers returned 27 | // should be considered read-only. 28 | KeyRead(ctx context.Context, keyID string) (JWK, error) 29 | // KeyReadAll reads a snapshot of all keys from storage. As with ReadKey, any pointers returned should be 30 | // considered read-only. 31 | KeyReadAll(ctx context.Context) ([]JWK, error) 32 | // KeyReplaceAll replaces all the keys in storage. All existing keys will be deleted and replaced with the given. 33 | KeyReplaceAll(ctx context.Context, given []JWK) error 34 | // KeyWrite writes a key to the storage. If the key already exists, it will be overwritten. After writing a key, 35 | // any pointers written should be considered owned by the underlying storage. 36 | KeyWrite(ctx context.Context, jwk JWK) error 37 | 38 | // JSON creates the JSON representation of the JWKSet. 39 | JSON(ctx context.Context) (json.RawMessage, error) 40 | // JSONPublic creates the JSON representation of the public keys in JWKSet. 41 | JSONPublic(ctx context.Context) (json.RawMessage, error) 42 | // JSONPrivate creates the JSON representation of the JWKSet public and private key material. 43 | JSONPrivate(ctx context.Context) (json.RawMessage, error) 44 | // JSONWithOptions creates the JSON representation of the JWKSet with the given options. These options override whatever 45 | // options are set on the individual JWKs. 46 | JSONWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (json.RawMessage, error) 47 | // Marshal transforms the JWK Set's current state into a Go type that can be marshaled into JSON. 48 | Marshal(ctx context.Context) (JWKSMarshal, error) 49 | // MarshalWithOptions transforms the JWK Set's current state into a Go type that can be marshaled into JSON with the 50 | // given options. These options override whatever options are set on the individual JWKs. 51 | MarshalWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (JWKSMarshal, error) 52 | } 53 | 54 | var _ Storage = &MemoryJWKSet{} 55 | 56 | type MemoryJWKSet struct { 57 | set []JWK 58 | mux sync.RWMutex 59 | } 60 | 61 | // NewMemoryStorage creates a new in-memory Storage implementation. 62 | func NewMemoryStorage() *MemoryJWKSet { 63 | return &MemoryJWKSet{} 64 | } 65 | 66 | func (m *MemoryJWKSet) KeyDelete(_ context.Context, keyID string) (ok bool, err error) { 67 | m.mux.Lock() 68 | defer m.mux.Unlock() 69 | for i, jwk := range m.set { 70 | if jwk.Marshal().KID == keyID { 71 | m.set = append(m.set[:i], m.set[i+1:]...) 72 | return true, nil 73 | } 74 | } 75 | return ok, nil 76 | } 77 | func (m *MemoryJWKSet) KeyRead(_ context.Context, keyID string) (JWK, error) { 78 | m.mux.RLock() 79 | defer m.mux.RUnlock() 80 | for _, jwk := range m.set { 81 | if jwk.Marshal().KID == keyID { 82 | return jwk, nil 83 | } 84 | } 85 | return JWK{}, fmt.Errorf("%w: kid %q", ErrKeyNotFound, keyID) 86 | } 87 | func (m *MemoryJWKSet) KeyReadAll(_ context.Context) ([]JWK, error) { 88 | m.mux.RLock() 89 | defer m.mux.RUnlock() 90 | return slices.Clone(m.set), nil 91 | } 92 | func (m *MemoryJWKSet) KeyReplaceAll(_ context.Context, given []JWK) error { 93 | m.mux.Lock() 94 | defer m.mux.Unlock() 95 | m.set = given 96 | return nil 97 | } 98 | func (m *MemoryJWKSet) KeyWrite(_ context.Context, jwk JWK) error { 99 | m.mux.Lock() 100 | defer m.mux.Unlock() 101 | m.set = append(m.set, jwk) 102 | return nil 103 | } 104 | func (m *MemoryJWKSet) JSON(ctx context.Context) (json.RawMessage, error) { 105 | jwks, err := m.Marshal(ctx) 106 | if err != nil { 107 | return nil, fmt.Errorf("failed to marshal JWK Set: %w", err) 108 | } 109 | return json.Marshal(jwks) 110 | } 111 | func (m *MemoryJWKSet) JSONPublic(ctx context.Context) (json.RawMessage, error) { 112 | return m.JSONWithOptions(ctx, JWKMarshalOptions{}, JWKValidateOptions{}) 113 | } 114 | func (m *MemoryJWKSet) JSONPrivate(ctx context.Context) (json.RawMessage, error) { 115 | marshalOptions := JWKMarshalOptions{ 116 | Private: true, 117 | } 118 | return m.JSONWithOptions(ctx, marshalOptions, JWKValidateOptions{}) 119 | } 120 | func (m *MemoryJWKSet) JSONWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (json.RawMessage, error) { 121 | jwks, err := m.MarshalWithOptions(ctx, marshalOptions, validationOptions) 122 | if err != nil { 123 | return nil, fmt.Errorf("failed to marshal JWK Set with options: %w", err) 124 | } 125 | return json.Marshal(jwks) 126 | } 127 | func (m *MemoryJWKSet) Marshal(ctx context.Context) (JWKSMarshal, error) { 128 | keys, err := m.KeyReadAll(ctx) 129 | if err != nil { 130 | return JWKSMarshal{}, fmt.Errorf("failed to read snapshot of all keys from storage: %w", err) 131 | } 132 | jwks := JWKSMarshal{} 133 | for _, key := range keys { 134 | jwks.Keys = append(jwks.Keys, key.Marshal()) 135 | } 136 | return jwks, nil 137 | } 138 | func (m *MemoryJWKSet) MarshalWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (JWKSMarshal, error) { 139 | jwks := JWKSMarshal{} 140 | 141 | keys, err := m.KeyReadAll(ctx) 142 | if err != nil { 143 | return JWKSMarshal{}, fmt.Errorf("failed to read snapshot of all keys from storage: %w", err) 144 | } 145 | 146 | for _, key := range keys { 147 | options := key.options 148 | options.Marshal = marshalOptions 149 | options.Validate = validationOptions 150 | marshal, err := keyMarshal(key.Key(), options) 151 | if err != nil { 152 | if errors.Is(err, ErrOptions) { 153 | continue 154 | } 155 | return JWKSMarshal{}, fmt.Errorf("failed to marshal key: %w", err) 156 | } 157 | jwks.Keys = append(jwks.Keys, marshal) 158 | } 159 | 160 | return jwks, nil 161 | } 162 | 163 | // HTTPClientStorageOptions are used to configure the behavior of NewStorageFromHTTP. 164 | type HTTPClientStorageOptions struct { 165 | // Client is the HTTP client to use for requests. 166 | // 167 | // This defaults to http.DefaultClient. 168 | Client *http.Client 169 | 170 | // Ctx is used when performing HTTP requests. It is also used to end the refresh goroutine when it's no longer 171 | // needed. 172 | // 173 | // This defaults to context.Background(). 174 | Ctx context.Context 175 | 176 | // HTTPExpectedStatus is the expected HTTP status code for the HTTP request. 177 | // 178 | // This defaults to http.StatusOK. 179 | HTTPExpectedStatus int 180 | 181 | // HTTPMethod is the HTTP method to use for the HTTP request. 182 | // 183 | // This defaults to http.MethodGet. 184 | HTTPMethod string 185 | 186 | // HTTPTimeout is the timeout for the HTTP request. When the Ctx option is also provided, this value is used for a 187 | // child context. 188 | // 189 | // This defaults to time.Minute. 190 | HTTPTimeout time.Duration 191 | 192 | // NoErrorReturnFirstHTTPReq will create the Storage without error if the first HTTP request fails. 193 | NoErrorReturnFirstHTTPReq bool 194 | 195 | // RefreshErrorHandler is a function that consumes errors that happen during an HTTP refresh. This is only effectual 196 | // if RefreshInterval is set. 197 | // 198 | // If NoErrorReturnFirstHTTPReq is set, this function will be called when if the first HTTP request fails. 199 | RefreshErrorHandler func(ctx context.Context, err error) 200 | 201 | // RefreshInterval is the interval at which the HTTP URL is refreshed and the JWK Set is processed. This option will 202 | // launch a "refresh goroutine" to refresh the remote HTTP resource at the given interval. 203 | // 204 | // Provide the Ctx option to end the goroutine when it's no longer needed. 205 | RefreshInterval time.Duration 206 | 207 | // Storage is the underlying storage implementation to use. 208 | // 209 | // This defaults to NewMemoryStorage(). 210 | Storage Storage 211 | 212 | // ValidateOptions are the options to use when validating the JWKs. 213 | ValidateOptions JWKValidateOptions 214 | } 215 | 216 | type httpStorage struct { 217 | options HTTPClientStorageOptions 218 | refresh func(ctx context.Context) error 219 | Storage 220 | } 221 | 222 | // NewStorageFromHTTP creates a new Storage implementation that processes a remote HTTP resource for a JWK Set. If 223 | // the RefreshInterval option is not set, the remote HTTP resource will be requested and processed before returning. If 224 | // the RefreshInterval option is set, a background goroutine will be launched to refresh the remote HTTP resource and 225 | // not block the return of this function. 226 | func NewStorageFromHTTP(remoteJWKSetURL string, options HTTPClientStorageOptions) (Storage, error) { 227 | if options.Client == nil { 228 | options.Client = http.DefaultClient 229 | } 230 | if options.Ctx == nil { 231 | options.Ctx = context.Background() 232 | } 233 | if options.HTTPExpectedStatus == 0 { 234 | options.HTTPExpectedStatus = http.StatusOK 235 | } 236 | if options.HTTPTimeout == 0 { 237 | options.HTTPTimeout = time.Minute 238 | } 239 | if options.HTTPMethod == "" { 240 | options.HTTPMethod = http.MethodGet 241 | } 242 | store := options.Storage 243 | if store == nil { 244 | store = NewMemoryStorage() 245 | } 246 | _, err := url.ParseRequestURI(remoteJWKSetURL) 247 | if err != nil { 248 | return nil, fmt.Errorf("failed to parse given URL %q: %w", remoteJWKSetURL, err) 249 | } 250 | 251 | refresh := func(ctx context.Context) error { 252 | req, err := http.NewRequestWithContext(ctx, options.HTTPMethod, remoteJWKSetURL, nil) 253 | if err != nil { 254 | return fmt.Errorf("failed to create HTTP request for JWK Set refresh: %w", err) 255 | } 256 | resp, err := options.Client.Do(req) 257 | if err != nil { 258 | return fmt.Errorf("failed to perform HTTP request for JWK Set refresh: %w", err) 259 | } 260 | //goland:noinspection GoUnhandledErrorResult 261 | defer resp.Body.Close() 262 | if resp.StatusCode != options.HTTPExpectedStatus { 263 | return fmt.Errorf("%w: %d", ErrInvalidHTTPStatusCode, resp.StatusCode) 264 | } 265 | var jwks JWKSMarshal 266 | err = json.NewDecoder(resp.Body).Decode(&jwks) 267 | if err != nil { 268 | return fmt.Errorf("failed to decode JWK Set response: %w", err) 269 | } 270 | newSet := make([]JWK, len(jwks.Keys)) 271 | for i, marshal := range jwks.Keys { 272 | marshalOptions := JWKMarshalOptions{ 273 | Private: true, 274 | } 275 | jwk, err := NewJWKFromMarshal(marshal, marshalOptions, options.ValidateOptions) 276 | if err != nil { 277 | return fmt.Errorf("failed to create JWK from JWK Marshal: %w", err) 278 | } 279 | newSet[i] = jwk 280 | } 281 | err = store.KeyReplaceAll(ctx, newSet) // Clear local cache in case of key revocation. 282 | if err != nil { 283 | return fmt.Errorf("failed to delete all keys from storage: %w", err) 284 | } 285 | return nil 286 | } 287 | 288 | if options.RefreshInterval != 0 { 289 | go func() { // Refresh goroutine. 290 | ticker := time.NewTicker(options.RefreshInterval) 291 | defer ticker.Stop() 292 | for { 293 | select { 294 | case <-options.Ctx.Done(): 295 | return 296 | case <-ticker.C: 297 | ctx, cancel := context.WithTimeout(options.Ctx, options.HTTPTimeout) 298 | err := refresh(ctx) 299 | cancel() 300 | if err != nil && options.RefreshErrorHandler != nil { 301 | options.RefreshErrorHandler(ctx, err) 302 | } 303 | } 304 | } 305 | }() 306 | } 307 | 308 | s := httpStorage{ 309 | options: options, 310 | refresh: refresh, 311 | Storage: store, 312 | } 313 | 314 | ctx, cancel := context.WithTimeout(options.Ctx, options.HTTPTimeout) 315 | defer cancel() 316 | err = refresh(ctx) 317 | cancel() 318 | if err != nil { 319 | if options.NoErrorReturnFirstHTTPReq { 320 | if options.RefreshErrorHandler != nil { 321 | options.RefreshErrorHandler(ctx, err) 322 | } 323 | return s, nil 324 | } 325 | return nil, fmt.Errorf("failed to perform first HTTP request for JWK Set: %w", err) 326 | } 327 | 328 | return s, nil 329 | } 330 | -------------------------------------------------------------------------------- /storage_test.go: -------------------------------------------------------------------------------- 1 | package jwkset 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | const ( 12 | kidMissing = "kid missing" 13 | kidWritten = "kid written" 14 | kidWritten2 = "kid written 2" 15 | ) 16 | 17 | var ( 18 | hmacKey1 = []byte("hamc key 1") 19 | hmacKey2 = []byte("hamc key 2") 20 | ) 21 | 22 | type storageTestParams struct { 23 | ctx context.Context 24 | cancel context.CancelFunc 25 | jwks Storage 26 | } 27 | 28 | func TestMemoryKeyDelete(t *testing.T) { 29 | params := setupMemory() 30 | defer params.cancel() 31 | store := params.jwks 32 | 33 | jwk := newStorageTestJWK(t, hmacKey1, kidWritten) 34 | err := store.KeyWrite(params.ctx, jwk) 35 | if err != nil { 36 | t.Fatalf("Failed to write key. %s", err) 37 | } 38 | 39 | ok, err := store.KeyDelete(params.ctx, kidMissing) 40 | if err != nil { 41 | t.Fatalf("Failed to delete missing key. %s", err) 42 | } 43 | if ok { 44 | t.Fatalf("Deleted missing key.") 45 | } 46 | 47 | ok, err = store.KeyDelete(params.ctx, kidWritten) 48 | if err != nil { 49 | t.Fatalf("Failed to delete written key. %s", err) 50 | } 51 | if !ok { 52 | t.Fatalf("Failed to delete written key.") 53 | } 54 | } 55 | 56 | func TestMemoryKeyRead(t *testing.T) { 57 | params := setupMemory() 58 | defer params.cancel() 59 | store := params.jwks 60 | 61 | jwk := newStorageTestJWK(t, hmacKey1, kidWritten) 62 | err := store.KeyWrite(params.ctx, jwk) 63 | if err != nil { 64 | t.Fatalf("Failed to write key. %s", err) 65 | } 66 | 67 | _, err = store.KeyRead(params.ctx, kidMissing) 68 | if !errors.Is(err, ErrKeyNotFound) { 69 | t.Fatalf("Should have specific error when reading missing key.\n Actual: %s\n Expected: %s", err, ErrKeyNotFound) 70 | } 71 | 72 | key, err := store.KeyRead(params.ctx, kidWritten) 73 | if err != nil { 74 | t.Fatalf("Failed to read written key. %s", err) 75 | } 76 | 77 | if !bytes.Equal(key.Key().([]byte), hmacKey1) { 78 | t.Fatalf("Read key does not match written key.") 79 | } 80 | ok, err := store.KeyDelete(params.ctx, kidWritten) 81 | if err != nil { 82 | t.Fatalf("Failed to delete written key. %s", err) 83 | } 84 | if !ok { 85 | t.Fatalf("Failed to delete written key.") 86 | } 87 | 88 | jwk = newStorageTestJWK(t, hmacKey2, kidWritten) 89 | err = store.KeyWrite(params.ctx, jwk) 90 | if err != nil { 91 | t.Fatalf("Failed to overwrite key. %s", err) 92 | } 93 | 94 | key, err = store.KeyRead(params.ctx, kidWritten) 95 | if err != nil { 96 | t.Fatalf("Failed to read written key. %s", err) 97 | } 98 | 99 | if !bytes.Equal(key.Key().([]byte), hmacKey2) { 100 | t.Fatalf("Read key does not match written key.") 101 | } 102 | 103 | ok, err = store.KeyDelete(params.ctx, kidWritten) 104 | if err != nil { 105 | t.Fatalf("Failed to delete written key. %s", err) 106 | } 107 | if !ok { 108 | t.Fatalf("Failed to delete written key.") 109 | } 110 | 111 | _, err = store.KeyRead(params.ctx, kidWritten) 112 | if !errors.Is(err, ErrKeyNotFound) { 113 | t.Fatalf("Should have specific error when reading missing key.\n Actual: %s\n Expected: %s", err, ErrKeyNotFound) 114 | } 115 | } 116 | 117 | func TestMemoryKeyReadAll(t *testing.T) { 118 | params := setupMemory() 119 | defer params.cancel() 120 | store := params.jwks 121 | 122 | jwk := newStorageTestJWK(t, hmacKey1, kidWritten) 123 | err := store.KeyWrite(params.ctx, jwk) 124 | if err != nil { 125 | t.Fatalf("Failed to write key 1. %s", err) 126 | } 127 | 128 | jwk = newStorageTestJWK(t, hmacKey2, kidWritten2) 129 | err = store.KeyWrite(params.ctx, jwk) 130 | if err != nil { 131 | t.Fatalf("Failed to write key 2. %s", err) 132 | } 133 | 134 | keys, err := store.KeyReadAll(params.ctx) 135 | if err != nil { 136 | t.Fatalf("Failed to snapshot keys. %s", err) 137 | } 138 | if len(keys) != 2 { 139 | t.Fatalf("Snapshot should have 2 keys. %d", len(keys)) 140 | } 141 | 142 | kid1Found := false 143 | kid2Found := false 144 | for _, jwk := range keys { 145 | if !kid1Found && jwk.Marshal().KID == kidWritten { 146 | kid1Found = true 147 | if !bytes.Equal(jwk.Key().([]byte), hmacKey1) { 148 | t.Fatalf("Snapshot key does not match written key.") 149 | } 150 | } else if !kid2Found && jwk.Marshal().KID == kidWritten2 { 151 | kid2Found = true 152 | if !bytes.Equal(jwk.Key().([]byte), hmacKey2) { 153 | t.Fatalf("Snapshot key does not match written key.") 154 | } 155 | } else { 156 | t.Fatalf("Snapshot key has unexpected key ID.") 157 | } 158 | } 159 | } 160 | 161 | func TestMemoryKeyReplaceAll(t *testing.T) { 162 | params := setupMemory() 163 | defer params.cancel() 164 | store := params.jwks 165 | 166 | jwk1 := newStorageTestJWK(t, hmacKey1, kidWritten) 167 | err := store.KeyWrite(params.ctx, jwk1) 168 | if err != nil { 169 | t.Fatalf("Failed to write key 1.\nError: %s", err) 170 | } 171 | 172 | jwk2 := newStorageTestJWK(t, hmacKey2, kidWritten2) 173 | err = store.KeyWrite(params.ctx, jwk2) 174 | if err != nil { 175 | t.Fatalf("Failed to write key 2.\nError: %s", err) 176 | } 177 | 178 | keys, err := store.KeyReadAll(params.ctx) 179 | if err != nil { 180 | t.Fatalf("Failed to read all keys.\nError: %s", err) 181 | } 182 | if len(keys) != 2 { 183 | t.Fatalf("Expected 2 keys before replace, got %d.", len(keys)) 184 | } 185 | 186 | given := newStorageTestJWK(t, []byte("new key"), "new-kid") 187 | err = store.KeyReplaceAll(params.ctx, []JWK{given}) 188 | if err != nil { 189 | t.Fatalf("Failed to replace all keys.\nError: %s", err) 190 | } 191 | 192 | keys, err = store.KeyReadAll(params.ctx) 193 | if err != nil { 194 | t.Fatalf("Failed to read all keys after replace.\nError: %s", err) 195 | } 196 | if len(keys) != 1 { 197 | t.Fatalf("Expected 1 key after replace, got %d.", len(keys)) 198 | } 199 | if keys[0].Marshal().KID != "new-kid" { 200 | t.Fatalf("Unexpected key ID after replace. Got %q, expected %q.", keys[0].Marshal().KID, "new-kid") 201 | } 202 | if !bytes.Equal(keys[0].Key().([]byte), []byte("new key")) { 203 | t.Fatalf("Unexpected key material after replace.") 204 | } 205 | } 206 | 207 | func TestMemoryKeyWrite(t *testing.T) { 208 | params := setupMemory() 209 | defer params.cancel() 210 | store := params.jwks 211 | 212 | jwk := newStorageTestJWK(t, hmacKey1, kidWritten) 213 | err := store.KeyWrite(params.ctx, jwk) 214 | if err != nil { 215 | t.Fatalf("Failed to write key. %s", err) 216 | } 217 | 218 | jwk = newStorageTestJWK(t, hmacKey2, kidWritten) 219 | err = store.KeyWrite(params.ctx, jwk) 220 | if err != nil { 221 | t.Fatalf("Failed to overwrite key. %s", err) 222 | } 223 | } 224 | 225 | func setupMemory() (params storageTestParams) { 226 | jwkSet := NewMemoryStorage() 227 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 228 | params = storageTestParams{ 229 | ctx: ctx, 230 | cancel: cancel, 231 | jwks: jwkSet, 232 | } 233 | return params 234 | } 235 | 236 | func newStorageTestJWK(t *testing.T, key any, keyID string) JWK { 237 | marshal := JWKMarshalOptions{ 238 | Private: true, 239 | } 240 | metadata := JWKMetadataOptions{ 241 | KID: keyID, 242 | } 243 | options := JWKOptions{ 244 | Marshal: marshal, 245 | Metadata: metadata, 246 | } 247 | jwk, err := NewJWKFromKey(key, options) 248 | if err != nil { 249 | t.Fatalf("Failed to create JWK. %s", err) 250 | } 251 | return jwk 252 | } 253 | -------------------------------------------------------------------------------- /website/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | -------------------------------------------------------------------------------- /website/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1 AS builder 2 | WORKDIR /app 3 | COPY . . 4 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "-s -w" -o jwksetcom -trimpath cmd/server/*.go 5 | 6 | # CA certificates required for reCAPTCHA verification on jwkset.com 7 | FROM alpine:latest 8 | COPY --from=builder /app/jwksetcom /jwksetcom 9 | USER 10001 10 | ENV CONFIG_JSON='{}' 11 | CMD ["/jwksetcom"] 12 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # jwkset.com 2 | 3 | This is the open source project for the website https://jwkset.com. This website is a part of 4 | the https://github.com/MicahParks/jwkset open source project. 5 | 6 | # Self-host 7 | 8 | This website can work with private cryptographic keys. Only work with private keys when using a self-hosted instance of 9 | this website. 10 | 11 | Use the pre-built Docker container to self-host this website. 12 | 13 | ``` 14 | docker run --rm -p 8080:8080 micahparks/jwksetcom 15 | ``` 16 | 17 | You can then find the website hosted at http://localhost:8080 18 | -------------------------------------------------------------------------------- /website/cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | hh "github.com/MicahParks/httphandle" 10 | hhconst "github.com/MicahParks/httphandle/constant" 11 | "github.com/MicahParks/httphandle/middleware" 12 | 13 | jsc "github.com/MicahParks/jwkset/website" 14 | "github.com/MicahParks/jwkset/website/handle/api" 15 | "github.com/MicahParks/jwkset/website/handle/template" 16 | "github.com/MicahParks/jwkset/website/server" 17 | ) 18 | 19 | func main() { 20 | ctx, cancel := context.WithCancel(context.Background()) 21 | defer cancel() 22 | 23 | setupArgs := hh.SetupArgs{ 24 | Static: jsc.Static, 25 | Templates: jsc.Templates, 26 | } 27 | conf, err := hh.Setup[jsc.Config](setupArgs) 28 | if err != nil { 29 | log.Fatalf(hhconst.LogFmt, "Failed to setup.", err) 30 | } 31 | l := conf.Logger 32 | 33 | srv := server.NewServer(conf.Conf, l) 34 | 35 | apiHandlers := []hh.API[server.Server]{ 36 | &api.Inspect{}, 37 | &api.NewGen{}, 38 | &api.PemGen{}, 39 | } 40 | templateHandlers := []hh.Template[server.Server]{ 41 | &template.Index{}, 42 | &template.Generate{}, 43 | &template.Inspect{}, 44 | } 45 | attachArgs := hh.AttachArgs[server.Server]{ 46 | API: apiHandlers, 47 | Files: conf.Files, 48 | MiddlewareOpts: middleware.GlobalDefaults, 49 | Template: templateHandlers, 50 | Templater: conf.Templater, 51 | } 52 | 53 | mux := http.NewServeMux() 54 | err = hh.Attach(attachArgs, srv, mux) 55 | if err != nil { 56 | l.ErrorContext(ctx, "Failed to attach handlers.", 57 | hhconst.LogErr, err, 58 | ) 59 | return 60 | } 61 | 62 | l.InfoContext(ctx, "Starting server.", 63 | "devClick", "http://localhost:8080", 64 | ) 65 | serveArgs := hh.ServeArgs{ 66 | Logger: l.With("httphandle", true), 67 | Port: 8080, 68 | ShutdownFunc: srv.Shutdown, 69 | ShutdownTimeout: 5 * time.Second, 70 | } 71 | hh.Serve(serveArgs, mux) 72 | } 73 | -------------------------------------------------------------------------------- /website/config.go: -------------------------------------------------------------------------------- 1 | package jwksetcom 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/MicahParks/jsontype" 7 | ) 8 | 9 | type Config struct { 10 | DMode bool `json:"devMode"` 11 | ReCAPTCHA ReCAPTCHA `json:"reCAPTCHA"` 12 | } 13 | 14 | func (c Config) DefaultsAndValidate() (Config, error) { 15 | if c.ReCAPTCHA.SiteKey != "" { 16 | if c.ReCAPTCHA.Secret == "" { 17 | return Config{}, fmt.Errorf(`%w: missing reCAPTCHA "secret" config`, jsontype.ErrDefaultsAndValidate) 18 | } 19 | if c.ReCAPTCHA.ScoreMin == 0 { 20 | return Config{}, fmt.Errorf(`%w: missing reCAPTCHA "scoreMin" config`, jsontype.ErrDefaultsAndValidate) 21 | } 22 | if len(c.ReCAPTCHA.Hostname) == 0 { 23 | return Config{}, fmt.Errorf(`%w: missing reCAPTCHA "hostname" config`, jsontype.ErrDefaultsAndValidate) 24 | } 25 | } 26 | return c, nil 27 | } 28 | func (c Config) DevMode() bool { 29 | return c.DMode 30 | } 31 | 32 | type ReCAPTCHA struct { 33 | Hostname []string `json:"hostname"` 34 | ScoreMin float64 `json:"scoreMin"` 35 | Secret string `json:"secret"` 36 | SiteKey string `json:"siteKey"` 37 | } 38 | -------------------------------------------------------------------------------- /website/constant.go: -------------------------------------------------------------------------------- 1 | package jwksetcom 2 | 3 | import ( 4 | hhconst "github.com/MicahParks/httphandle/constant" 5 | ) 6 | 7 | const ( 8 | LinkGitHub = "https://github.com/MicahParks/jwkset/blob/master/website/README.md" 9 | PathAPIInspect = "/api/inspect" 10 | PathAPINewGen = "/api/new-gen" 11 | PathAPIPemGen = "/api/pem-gen" 12 | PathGenerate = "/generate" 13 | PathInspect = "/inspect" 14 | TemplateWrapper = "wrapper.gohtml" 15 | ) 16 | 17 | type Link struct{} 18 | 19 | func (l Link) GitHub() string { 20 | return LinkGitHub 21 | } 22 | 23 | type Path struct{} 24 | 25 | func (p Path) APIInspect() string { 26 | return PathAPIInspect 27 | } 28 | func (p Path) APINewGen() string { 29 | return PathAPINewGen 30 | } 31 | func (p Path) APIPemGen() string { 32 | return PathAPIPemGen 33 | } 34 | func (p Path) Generate() string { 35 | return PathGenerate 36 | } 37 | func (p Path) Index() string { 38 | return hhconst.PathIndex 39 | } 40 | func (p Path) Inspect() string { 41 | return PathInspect 42 | } 43 | -------------------------------------------------------------------------------- /website/css.sh: -------------------------------------------------------------------------------- 1 | npx tailwindcss --minify --watch --input ./input.css --output ./static/css/tailwind.min.css 2 | -------------------------------------------------------------------------------- /website/embed.go: -------------------------------------------------------------------------------- 1 | package jwksetcom 2 | 3 | import ( 4 | "embed" 5 | ) 6 | 7 | //go:embed static 8 | var Static embed.FS 9 | 10 | //go:embed templates 11 | var Templates embed.FS 12 | -------------------------------------------------------------------------------- /website/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/MicahParks/jwkset/website 2 | 3 | go 1.21.5 4 | 5 | require ( 6 | github.com/MicahParks/httphandle v0.5.8 7 | github.com/MicahParks/jsontype v0.6.1 8 | github.com/MicahParks/jwkset v0.9.4 9 | github.com/MicahParks/recaptcha v0.0.5 10 | ) 11 | 12 | require ( 13 | github.com/MicahParks/templater v0.0.3 // indirect 14 | github.com/google/uuid v1.6.0 // indirect 15 | github.com/jackc/pgpassfile v1.0.0 // indirect 16 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 17 | github.com/jackc/pgx/v5 v5.7.1 // indirect 18 | golang.org/x/crypto v0.31.0 // indirect 19 | golang.org/x/text v0.21.0 // indirect 20 | golang.org/x/time v0.9.0 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /website/go.sum: -------------------------------------------------------------------------------- 1 | github.com/MicahParks/httphandle v0.5.8 h1:oDj7SOehS0ZgfshqEmXjBGUuf4jlCgXYo8yLn8kaEh0= 2 | github.com/MicahParks/httphandle v0.5.8/go.mod h1:TsKDADgU5TG9PnfgtXMo643eMOUifg0nGiZzDG3fiKQ= 3 | github.com/MicahParks/jsontype v0.6.1 h1:yFiDEOgSCDT+Es8k17PYZkvpqbZJ9GxJH2ioeVGvgt0= 4 | github.com/MicahParks/jsontype v0.6.1/go.mod h1:PVeg4g8eHt4QDlhe56X1sWzRuHiVlCg4m0vgkpEso/Y= 5 | github.com/MicahParks/jwkset v0.9.3 h1:IRi5UGoF8zOkLEaaT7Q9kVBbWY1PSgeMki4wY64T3JM= 6 | github.com/MicahParks/jwkset v0.9.3/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= 7 | github.com/MicahParks/jwkset v0.9.4 h1:r378Kv9HmdF4irNEc303UfQepODk8+CXyJFUX7J0u6Y= 8 | github.com/MicahParks/jwkset v0.9.4/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= 9 | github.com/MicahParks/recaptcha v0.0.5 h1:RvKq7E1BZJtz5ubSkBun20jXxIsMWt2oZ0ppTJOzX1A= 10 | github.com/MicahParks/recaptcha v0.0.5/go.mod h1:aFv3iZDDs6Pbi6tRpUm8gofaTUnDxOQ27x5KsK0CZwE= 11 | github.com/MicahParks/templater v0.0.3 h1:RzWpZMVWMaO+1Uolla1lCLNlwkmusbQQjAXLSCKZnYo= 12 | github.com/MicahParks/templater v0.0.3/go.mod h1:N8bUCJg9gdP+hDAZAzfeYuvKZuuMH/MVOKqT3YcH+9g= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 17 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 18 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 19 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 20 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 21 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 22 | github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= 23 | github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= 24 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 25 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 26 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 27 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 28 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 29 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 30 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 31 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 32 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 33 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 34 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 35 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 36 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 37 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 38 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 39 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 40 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 42 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 43 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 44 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 45 | -------------------------------------------------------------------------------- /website/handle/api/inspect.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdh" 6 | "crypto/ecdsa" 7 | "crypto/ed25519" 8 | "crypto/rsa" 9 | "crypto/x509" 10 | "encoding/json" 11 | "encoding/pem" 12 | "fmt" 13 | "net/http" 14 | 15 | "github.com/MicahParks/httphandle/api" 16 | hhconst "github.com/MicahParks/httphandle/constant" 17 | jt "github.com/MicahParks/jsontype" 18 | "github.com/MicahParks/jwkset" 19 | 20 | jsc "github.com/MicahParks/jwkset/website" 21 | "github.com/MicahParks/jwkset/website/server" 22 | ) 23 | 24 | type InspectReq struct { 25 | JWK string `json:"jwk"` 26 | } 27 | 28 | func (i InspectReq) DefaultsAndValidate() (InspectReq, error) { 29 | if i.JWK == "" { 30 | return i, fmt.Errorf(`%w: "jwk" attribute requried`, jt.ErrDefaultsAndValidate) 31 | } 32 | return i, nil 33 | } 34 | 35 | type InspectResp struct { 36 | JWK string `json:"jwk"` 37 | PKCS8 string `json:"pkcs8"` 38 | PKIX string `json:"pkix"` 39 | } 40 | 41 | type Inspect struct { 42 | s server.Server 43 | } 44 | 45 | func (i *Inspect) ApplyMiddleware(h http.Handler) http.Handler { 46 | return h 47 | } 48 | func (i *Inspect) Authorize(w http.ResponseWriter, r *http.Request) (authorized bool, modified *http.Request) { 49 | return authReCAPTCHA("inspect", i.s, w, r) 50 | } 51 | func (i *Inspect) ContentType() (request, response string) { 52 | return hhconst.ContentTypeJSON, hhconst.ContentTypeJSON 53 | } 54 | func (i *Inspect) HTTPMethod() string { 55 | return http.MethodPost 56 | } 57 | func (i *Inspect) Initialize(s server.Server) error { 58 | i.s = s 59 | return nil 60 | } 61 | func (i *Inspect) Respond(r *http.Request) (code int, body []byte, err error) { 62 | reqData, l, ctx, code, body, err := api.ExtractJSON[InspectReq](r) 63 | if err != nil { 64 | return api.ErrorResponse(ctx, code, "Failed to JSON parse request body.") 65 | } 66 | 67 | marshal := jwkset.JWKMarshal{} 68 | err = json.Unmarshal([]byte(reqData.JWK), &marshal) 69 | if err != nil { 70 | return api.ErrorResponse(ctx, http.StatusUnprocessableEntity, "Failed to JSON parse JWK.") 71 | } 72 | 73 | marshalOptions := jwkset.JWKMarshalOptions{ 74 | Private: true, 75 | } 76 | jwk, err := jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) 77 | if err != nil { 78 | return api.ErrorResponse(ctx, http.StatusUnprocessableEntity, fmt.Sprintf("Failed to validate JWK: %s.", err)) 79 | } 80 | key := jwk.Key() 81 | 82 | b, err := json.MarshalIndent(jwk.Marshal(), "", " ") 83 | if err != nil { 84 | return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to JSON marshal JWK: %s.", err)) 85 | } 86 | resp := InspectResp{ 87 | JWK: string(b), 88 | } 89 | 90 | type publicKeyer interface { 91 | Public() crypto.PublicKey 92 | } 93 | 94 | var priv, pub []byte 95 | var block *pem.Block 96 | switch k := key.(type) { 97 | case []byte: 98 | case *ecdh.PrivateKey, ed25519.PrivateKey, *ecdsa.PrivateKey, *rsa.PrivateKey: 99 | priv, err = x509.MarshalPKCS8PrivateKey(k) 100 | if err != nil { 101 | return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to PKCS #8 marshal private key: %s.", err)) 102 | } 103 | pub, err = x509.MarshalPKIXPublicKey(k.(publicKeyer).Public()) 104 | if err != nil { 105 | return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to PKIX marshal public key: %s.", err)) 106 | } 107 | case *ecdh.PublicKey, ed25519.PublicKey, *ecdsa.PublicKey, *rsa.PublicKey: 108 | pub, err = x509.MarshalPKIXPublicKey(k) 109 | if err != nil { 110 | return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to PKIX marshal public key: %s.", err)) 111 | } 112 | default: 113 | return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Unknown key cryptographic key type: %T.", k)) 114 | } 115 | 116 | if priv != nil { 117 | block = &pem.Block{ 118 | Type: "PRIVATE KEY", 119 | Bytes: priv, 120 | } 121 | resp.PKCS8 = string(pem.EncodeToMemory(block)) 122 | } 123 | block = &pem.Block{ 124 | Type: "PUBLIC KEY", 125 | Bytes: pub, 126 | } 127 | resp.PKIX = string(pem.EncodeToMemory(block)) 128 | 129 | l.InfoContext(ctx, "Inspected JWK.") 130 | 131 | return api.RespondJSON(ctx, http.StatusOK, resp) 132 | } 133 | func (i *Inspect) URLPattern() string { 134 | return jsc.PathAPIInspect 135 | } 136 | -------------------------------------------------------------------------------- /website/handle/api/new_gen.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "crypto/ecdh" 5 | "crypto/ecdsa" 6 | "crypto/ed25519" 7 | "crypto/elliptic" 8 | "crypto/rand" 9 | "crypto/rsa" 10 | "crypto/x509" 11 | "encoding/json" 12 | "encoding/pem" 13 | "fmt" 14 | "net/http" 15 | 16 | "github.com/MicahParks/httphandle/api" 17 | hhconst "github.com/MicahParks/httphandle/constant" 18 | jt "github.com/MicahParks/jsontype" 19 | "github.com/MicahParks/jwkset" 20 | 21 | jsc "github.com/MicahParks/jwkset/website" 22 | "github.com/MicahParks/jwkset/website/server" 23 | ) 24 | 25 | type NewGenRespData struct { 26 | JWK string `json:"jwk"` 27 | PKCS8 string `json:"pkcs8"` 28 | PKIX string `json:"pkix"` 29 | } 30 | 31 | const ( 32 | KeyTypeRSA keyType = "RSA" 33 | KeyTypeECDSA keyType = "ECDSA" 34 | KeyTypeEd25519 keyType = "Ed25519" 35 | KeyTypeX25519 keyType = "X25519" 36 | KeyTypeSymmetric keyType = "Symmetric" 37 | ) 38 | 39 | type keyType string 40 | 41 | func (k keyType) valid() bool { 42 | switch k { 43 | case KeyTypeRSA, KeyTypeECDSA, KeyTypeEd25519, KeyTypeX25519, KeyTypeSymmetric: 44 | return true 45 | default: 46 | return false 47 | } 48 | } 49 | 50 | type NewGenReqData struct { 51 | ALG jwkset.ALG `json:"alg"` 52 | KEYOPS []jwkset.KEYOPS `json:"keyops"` 53 | KeyType keyType `json:"keyType"` 54 | KID string `json:"kid"` 55 | USE jwkset.USE `json:"use"` 56 | 57 | RSABits int `json:"rsaBits"` 58 | ECCurve jwkset.CRV `json:"ecCurve"` 59 | } 60 | 61 | func (n NewGenReqData) DefaultsAndValidate() (NewGenReqData, error) { 62 | if !n.ALG.IANARegistered() { 63 | return n, fmt.Errorf(`%w: "alg" attribute is not a known IANA registered value`, jt.ErrDefaultsAndValidate) 64 | } 65 | for _, o := range n.KEYOPS { 66 | if !o.IANARegistered() { 67 | return n, fmt.Errorf(`%w: "keyops" attribute is not a known IANA registered value`, jt.ErrDefaultsAndValidate) 68 | } 69 | } 70 | if !n.KeyType.valid() { 71 | return n, fmt.Errorf(`%w: unknown key type`, jt.ErrDefaultsAndValidate) 72 | } 73 | if !n.USE.IANARegistered() { 74 | return n, fmt.Errorf(`%w: "use" attribute is not a known IANA registered value`, jt.ErrDefaultsAndValidate) 75 | } 76 | switch n.KeyType { 77 | case KeyTypeRSA: 78 | switch n.RSABits { 79 | case 1024, 2048, 4096: 80 | default: 81 | return n, fmt.Errorf(`%w: "rsaBits" attribute must be 1024, 2048, or 4096`, jt.ErrDefaultsAndValidate) 82 | } 83 | case KeyTypeECDSA: 84 | switch n.ECCurve { 85 | case jwkset.CrvP256, jwkset.CrvP384, jwkset.CrvP521: 86 | default: 87 | return n, fmt.Errorf(`%w: "ecCurve" attribute must be "P-256", "P-384", or "P-521"`, jt.ErrDefaultsAndValidate) 88 | } 89 | } 90 | return n, nil 91 | } 92 | 93 | type NewGen struct { 94 | s server.Server 95 | } 96 | 97 | func (n *NewGen) ApplyMiddleware(h http.Handler) http.Handler { 98 | return h 99 | } 100 | func (n *NewGen) Authorize(w http.ResponseWriter, r *http.Request) (authorized bool, modified *http.Request) { 101 | return authReCAPTCHA("newGen", n.s, w, r) 102 | } 103 | func (n *NewGen) ContentType() (request, response string) { 104 | return hhconst.ContentTypeJSON, hhconst.ContentTypeJSON 105 | } 106 | func (n *NewGen) HTTPMethod() string { 107 | return http.MethodPost 108 | } 109 | func (n *NewGen) Initialize(s server.Server) error { 110 | n.s = s 111 | return nil 112 | } 113 | func (n *NewGen) Respond(r *http.Request) (code int, body []byte, err error) { 114 | reqData, l, ctx, code, body, err := api.ExtractJSON[NewGenReqData](r) 115 | if err != nil { 116 | return api.ErrorResponse(ctx, code, "Failed to JSON parse request body.") 117 | } 118 | 119 | var priv any 120 | var pub any 121 | switch reqData.KeyType { 122 | case KeyTypeRSA: 123 | rsaPriv, err := rsa.GenerateKey(rand.Reader, reqData.RSABits) 124 | if err != nil { 125 | l.ErrorContext(ctx, 126 | "Failed to generate RSA private key.", 127 | hhconst.LogErr, err, 128 | ) 129 | return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) 130 | } 131 | priv = rsaPriv 132 | pub = rsaPriv.Public() 133 | l.InfoContext(ctx, "Generated RSA private key.") 134 | case KeyTypeECDSA: 135 | var crv elliptic.Curve 136 | switch reqData.ECCurve { 137 | case jwkset.CrvP256: 138 | crv = elliptic.P256() 139 | case jwkset.CrvP384: 140 | crv = elliptic.P384() 141 | case jwkset.CrvP521: 142 | crv = elliptic.P521() 143 | default: 144 | l.ErrorContext(ctx, "Failed to generate EC private key due to unhandled curve.") 145 | return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) 146 | } 147 | ecPriv, err := ecdsa.GenerateKey(crv, rand.Reader) 148 | if err != nil { 149 | l.ErrorContext(ctx, 150 | "Failed to generate EC private key.", 151 | hhconst.LogErr, err, 152 | ) 153 | return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) 154 | } 155 | priv = ecPriv 156 | pub = ecPriv.Public() 157 | l.InfoContext(ctx, "Generated EC private key.") 158 | case KeyTypeEd25519: 159 | _, edPriv, err := ed25519.GenerateKey(rand.Reader) 160 | if err != nil { 161 | l.ErrorContext(ctx, "Failed to generate Ed25519 private key.") 162 | return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) 163 | } 164 | priv = edPriv 165 | pub = edPriv.Public() 166 | l.InfoContext(ctx, "Generated Ed25519 private key.") 167 | case KeyTypeX25519: 168 | xPriv, err := ecdh.X25519().GenerateKey(rand.Reader) 169 | if err != nil { 170 | l.ErrorContext(ctx, "Failed to generate X25519 private key.") 171 | return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) 172 | } 173 | priv = xPriv 174 | pub = xPriv.Public() 175 | l.InfoContext(ctx, "Generated X25519 private key.") 176 | case KeyTypeSymmetric: 177 | b := make([]byte, 64) 178 | _, err := rand.Read(b) 179 | if err != nil { 180 | l.ErrorContext(ctx, "Failed to generate octet sequence.") 181 | return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) 182 | } 183 | priv = b 184 | l.InfoContext(ctx, "Generated octet sequence.") 185 | default: 186 | l.ErrorContext(ctx, "Failed to generate key due to unhandled key type.") 187 | return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) 188 | } 189 | 190 | marshalOptions := jwkset.JWKMarshalOptions{ 191 | Private: true, 192 | } 193 | metadata := jwkset.JWKMetadataOptions{ 194 | ALG: reqData.ALG, 195 | KID: reqData.KID, 196 | KEYOPS: reqData.KEYOPS, 197 | USE: reqData.USE, 198 | } 199 | options := jwkset.JWKOptions{ 200 | Marshal: marshalOptions, 201 | Metadata: metadata, 202 | } 203 | jwk, err := jwkset.NewJWKFromKey(priv, options) 204 | if err != nil { 205 | l.ErrorContext(ctx, "Failed to create JWK from key.", 206 | hhconst.LogErr, err, 207 | ) 208 | return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) 209 | } 210 | 211 | j, err := json.MarshalIndent(jwk.Marshal(), "", " ") 212 | if err != nil { 213 | l.ErrorContext(ctx, "Failed to marshal JWK.", 214 | hhconst.LogErr, err, 215 | ) 216 | return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) 217 | } 218 | 219 | var pkcs8 string 220 | var pkix string 221 | if reqData.KeyType != KeyTypeSymmetric { 222 | p, err := x509.MarshalPKCS8PrivateKey(priv) 223 | if err != nil { 224 | l.InfoContext(ctx, "Failed to marshal private key to PKCS8.") 225 | return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) 226 | } 227 | block := &pem.Block{ 228 | Type: "PRIVATE KEY", 229 | Bytes: p, 230 | } 231 | pkcs8 = string(pem.EncodeToMemory(block)) 232 | p, err = x509.MarshalPKIXPublicKey(pub) 233 | if err != nil { 234 | l.InfoContext(ctx, "Failed to marshal public key to PKIX.") 235 | return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) 236 | } 237 | block = &pem.Block{ 238 | Type: "PUBLIC KEY", 239 | Bytes: p, 240 | } 241 | pkix = string(pem.EncodeToMemory(block)) 242 | } 243 | 244 | respData := NewGenRespData{ 245 | JWK: string(j), 246 | PKCS8: pkcs8, 247 | PKIX: pkix, 248 | } 249 | 250 | return api.RespondJSON(ctx, http.StatusOK, respData) 251 | } 252 | func (n *NewGen) URLPattern() string { 253 | return jsc.PathAPINewGen 254 | } 255 | -------------------------------------------------------------------------------- /website/handle/api/pem_gen.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/pem" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/MicahParks/httphandle/api" 10 | hhconst "github.com/MicahParks/httphandle/constant" 11 | jt "github.com/MicahParks/jsontype" 12 | "github.com/MicahParks/jwkset" 13 | 14 | jsc "github.com/MicahParks/jwkset/website" 15 | "github.com/MicahParks/jwkset/website/server" 16 | ) 17 | 18 | type PemGenRespData struct { 19 | JWK string `json:"jwk"` 20 | } 21 | 22 | type PemGenReqData struct { 23 | ALG jwkset.ALG `json:"alg"` 24 | KEYOPS []jwkset.KEYOPS `json:"keyops"` 25 | KID string `json:"kid"` 26 | PEM string `json:"pem"` 27 | USE jwkset.USE `json:"use"` 28 | } 29 | 30 | func (p PemGenReqData) DefaultsAndValidate() (PemGenReqData, error) { 31 | if p.PEM == "" { 32 | return p, fmt.Errorf(`%w: "pem" attribute requried`, jt.ErrDefaultsAndValidate) 33 | } 34 | if !p.ALG.IANARegistered() { 35 | return p, fmt.Errorf(`%w: "alg" attribute invalid`, jt.ErrDefaultsAndValidate) 36 | } 37 | for _, o := range p.KEYOPS { 38 | if !o.IANARegistered() { 39 | return p, fmt.Errorf(`%w: "keyops" attribute invalid`, jt.ErrDefaultsAndValidate) 40 | } 41 | } 42 | if !p.USE.IANARegistered() { 43 | return p, fmt.Errorf(`%w: "use" attribute invalid`, jt.ErrDefaultsAndValidate) 44 | } 45 | return p, nil 46 | } 47 | 48 | type PemGen struct { 49 | s server.Server 50 | } 51 | 52 | func (p *PemGen) ApplyMiddleware(h http.Handler) http.Handler { 53 | return h 54 | } 55 | func (p *PemGen) Authorize(w http.ResponseWriter, r *http.Request) (authorized bool, modified *http.Request) { 56 | return authReCAPTCHA("pemGen", p.s, w, r) 57 | } 58 | func (p *PemGen) ContentType() (request, response string) { 59 | return hhconst.ContentTypeJSON, hhconst.ContentTypeJSON 60 | } 61 | func (p *PemGen) HTTPMethod() string { 62 | return http.MethodPost 63 | } 64 | func (p *PemGen) Initialize(s server.Server) error { 65 | p.s = s 66 | return nil 67 | } 68 | func (p *PemGen) Respond(r *http.Request) (code int, body []byte, err error) { 69 | reqData, l, ctx, code, body, err := api.ExtractJSON[PemGenReqData](r) 70 | if err != nil { 71 | return api.ErrorResponse(ctx, code, "Failed to JSON parse request body.") 72 | } 73 | 74 | rawPEM := []byte(reqData.PEM) 75 | block, _ := pem.Decode(rawPEM) 76 | if block == nil { 77 | return api.ErrorResponse(ctx, http.StatusBadRequest, fmt.Sprintf("Failed to decode PEM block.")) 78 | } 79 | 80 | marshalOptions := jwkset.JWKMarshalOptions{ 81 | Private: true, 82 | } 83 | metadata := jwkset.JWKMetadataOptions{ 84 | ALG: reqData.ALG, 85 | KID: reqData.KID, 86 | KEYOPS: reqData.KEYOPS, 87 | USE: reqData.USE, 88 | } 89 | 90 | var jwk jwkset.JWK 91 | switch block.Type { 92 | case "CERTIFICATE": 93 | certs, err := jwkset.LoadCertificates(rawPEM) 94 | if err != nil { 95 | return api.ErrorResponse(ctx, http.StatusBadRequest, fmt.Sprintf("Failed to load certificates: %s.", err)) 96 | } 97 | x509Options := jwkset.JWKX509Options{ 98 | X5C: certs, 99 | } 100 | options := jwkset.JWKOptions{ 101 | Marshal: marshalOptions, 102 | Metadata: metadata, 103 | X509: x509Options, 104 | } 105 | jwk, err = jwkset.NewJWKFromX5C(options) 106 | if err != nil { 107 | return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to create JWK from X5C: %s.", err)) 108 | } 109 | l.InfoContext(ctx, "Created JWK from certificate.") 110 | default: 111 | key, err := jwkset.LoadX509KeyInfer(block) 112 | if err != nil { 113 | return api.ErrorResponse(ctx, http.StatusBadRequest, fmt.Sprintf("Failed to load X509 key: %s.", err)) 114 | } 115 | options := jwkset.JWKOptions{ 116 | Marshal: marshalOptions, 117 | Metadata: metadata, 118 | } 119 | jwk, err = jwkset.NewJWKFromKey(key, options) 120 | if err != nil { 121 | return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to create JWK from key: %s.", err)) 122 | } 123 | l.InfoContext(ctx, "Created JWK from key.") 124 | } 125 | 126 | j, err := json.MarshalIndent(jwk.Marshal(), "", " ") 127 | if err != nil { 128 | return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to marshal JSON: %s.", err)) 129 | } 130 | 131 | respData := PemGenRespData{ 132 | JWK: string(j), 133 | } 134 | 135 | return api.RespondJSON(ctx, http.StatusOK, respData) 136 | } 137 | func (p *PemGen) URLPattern() string { 138 | return jsc.PathAPIPemGen 139 | } 140 | -------------------------------------------------------------------------------- /website/handle/api/util.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | 7 | "github.com/MicahParks/httphandle/api" 8 | hhconst "github.com/MicahParks/httphandle/constant" 9 | "github.com/MicahParks/httphandle/middleware/ctxkey" 10 | "github.com/MicahParks/recaptcha" 11 | 12 | "github.com/MicahParks/jwkset/website/server" 13 | ) 14 | 15 | func authReCAPTCHA(action string, s server.Server, w http.ResponseWriter, r *http.Request) (bool, *http.Request) { 16 | if s.Conf.ReCAPTCHA.SiteKey == "" { 17 | return true, r 18 | } 19 | ctx := r.Context() 20 | l := ctx.Value(ctxkey.Logger).(*slog.Logger) 21 | token := r.Header.Get("g-recaptcha-response") 22 | resp, err := s.Verifier.Verify(ctx, token, "") 23 | if err != nil { 24 | l.InfoContext(ctx, "Failed to verify reCAPTCHA response.", 25 | hhconst.LogErr, err, 26 | ) 27 | return api.AuthorizeError(ctx, http.StatusUnauthorized, "reCAPTCHA verification failed.", w) 28 | } 29 | options := recaptcha.V3ResponseCheckOptions{ 30 | Action: []string{action}, 31 | Hostname: s.Conf.ReCAPTCHA.Hostname, 32 | Score: s.Conf.ReCAPTCHA.ScoreMin, 33 | } 34 | err = resp.Check(options) 35 | if err != nil { 36 | l.InfoContext(ctx, "Failed reCAPTCHA response check.", 37 | hhconst.LogErr, err, 38 | ) 39 | return false, r 40 | } 41 | return true, r 42 | } 43 | -------------------------------------------------------------------------------- /website/handle/template/generate.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "net/http" 5 | 6 | hh "github.com/MicahParks/httphandle" 7 | "github.com/MicahParks/httphandle/middleware" 8 | 9 | jsc "github.com/MicahParks/jwkset/website" 10 | "github.com/MicahParks/jwkset/website/server" 11 | ) 12 | 13 | type GenerateData struct { 14 | WrapperData *server.WrapperData 15 | } 16 | 17 | type Generate struct { 18 | s server.Server 19 | } 20 | 21 | func (i *Generate) ApplyMiddleware(h http.Handler) http.Handler { 22 | cache := middleware.CreateCacheControl(middleware.CacheDefaults) 23 | return cache(middleware.EncodeGzip(h)) 24 | } 25 | func (i *Generate) Authorize(_ http.ResponseWriter, r *http.Request) (authorized bool, modified *http.Request, skipTemplate bool) { 26 | return true, r, false 27 | } 28 | func (i *Generate) Initialize(s server.Server) error { 29 | i.s = s 30 | return nil 31 | } 32 | func (i *Generate) Respond(r *http.Request) (meta hh.TemplateRespMeta, templateData any, wrapperData hh.WrapperData) { 33 | w := i.s.WrapperData(r) 34 | w.Title = "Generate - JWK Set" 35 | w.Description = "Generate a new JSON Web Key Set or make one from existing PEM encoded keys." 36 | tData := GenerateData{} 37 | tData.WrapperData = w 38 | return meta, tData, w 39 | } 40 | func (i *Generate) TemplateName() string { 41 | return "generate.gohtml" 42 | } 43 | func (i *Generate) URLPattern() string { 44 | return jsc.PathGenerate 45 | } 46 | func (i *Generate) WrapperTemplateName() string { 47 | return jsc.TemplateWrapper 48 | } 49 | -------------------------------------------------------------------------------- /website/handle/template/index.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "net/http" 5 | 6 | hh "github.com/MicahParks/httphandle" 7 | hhconst "github.com/MicahParks/httphandle/constant" 8 | "github.com/MicahParks/httphandle/middleware" 9 | 10 | jsc "github.com/MicahParks/jwkset/website" 11 | "github.com/MicahParks/jwkset/website/server" 12 | ) 13 | 14 | type IndexData struct { 15 | WrapperData *server.WrapperData 16 | } 17 | 18 | type Index struct { 19 | s server.Server 20 | } 21 | 22 | func (i *Index) ApplyMiddleware(h http.Handler) http.Handler { 23 | cache := middleware.CreateCacheControl(middleware.CacheDefaults) 24 | return cache(middleware.EncodeGzip(h)) 25 | } 26 | func (i *Index) Authorize(_ http.ResponseWriter, r *http.Request) (authorized bool, modified *http.Request, skipTemplate bool) { 27 | return true, r, false 28 | } 29 | func (i *Index) Initialize(s server.Server) error { 30 | i.s = s 31 | return nil 32 | } 33 | func (i *Index) Respond(req *http.Request) (meta hh.TemplateRespMeta, templateData any, wrapperData hh.WrapperData) { 34 | w := i.s.WrapperData(req) 35 | w.Title = "Home - JWK Set" 36 | w.Description = "A website for JSON Web Key Sets. Generate and inspect JSON Web Keys. Compatible with PEM encoded assets." 37 | tData := IndexData{} 38 | tData.WrapperData = w 39 | return meta, tData, w 40 | } 41 | func (i *Index) TemplateName() string { 42 | return "index.gohtml" 43 | } 44 | func (i *Index) URLPattern() string { 45 | return hhconst.PathIndex 46 | } 47 | func (i *Index) WrapperTemplateName() string { 48 | return jsc.TemplateWrapper 49 | } 50 | -------------------------------------------------------------------------------- /website/handle/template/inspect.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "net/http" 5 | 6 | hh "github.com/MicahParks/httphandle" 7 | "github.com/MicahParks/httphandle/middleware" 8 | 9 | jsc "github.com/MicahParks/jwkset/website" 10 | "github.com/MicahParks/jwkset/website/server" 11 | ) 12 | 13 | type InspectData struct { 14 | WrapperData *server.WrapperData 15 | } 16 | 17 | type Inspect struct { 18 | s server.Server 19 | } 20 | 21 | func (i *Inspect) ApplyMiddleware(h http.Handler) http.Handler { 22 | cache := middleware.CreateCacheControl(middleware.CacheDefaults) 23 | return cache(middleware.EncodeGzip(h)) 24 | } 25 | func (i *Inspect) Authorize(_ http.ResponseWriter, r *http.Request) (authorized bool, modified *http.Request, skipTemplate bool) { 26 | return true, r, false 27 | } 28 | func (i *Inspect) Initialize(s server.Server) error { 29 | i.s = s 30 | return nil 31 | } 32 | func (i *Inspect) Respond(r *http.Request) (meta hh.TemplateRespMeta, templateData any, wrapperData hh.WrapperData) { 33 | w := i.s.WrapperData(r) 34 | w.Title = "Inspect - JWK Set" 35 | w.Description = "Inspect a JSON Web Key Set validity and extract public and private keys as PKIX and PKCS #8 assets." 36 | tData := InspectData{} 37 | tData.WrapperData = w 38 | return meta, tData, w 39 | } 40 | func (i *Inspect) TemplateName() string { 41 | return "inspect.gohtml" 42 | } 43 | func (i *Inspect) URLPattern() string { 44 | return jsc.PathInspect 45 | } 46 | func (i *Inspect) WrapperTemplateName() string { 47 | return jsc.TemplateWrapper 48 | } 49 | -------------------------------------------------------------------------------- /website/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@tailwindcss/aspect-ratio": "^0.4.2", 4 | "@tailwindcss/forms": "^0.5.7", 5 | "@tailwindcss/typography": "^0.5.10", 6 | "tailwindcss": "^3.3.5" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /website/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | "strings" 8 | 9 | hh "github.com/MicahParks/httphandle" 10 | hhconst "github.com/MicahParks/httphandle/constant" 11 | "github.com/MicahParks/recaptcha" 12 | 13 | jsc "github.com/MicahParks/jwkset/website" 14 | ) 15 | 16 | type NavItem struct { 17 | Active bool 18 | Name string 19 | Href string 20 | } 21 | 22 | type WrapperData struct { 23 | Link jsc.Link 24 | Path jsc.Path 25 | NavItems []NavItem 26 | ReCAPTCHASiteKey string 27 | Result hh.TemplateDataResult 28 | Title string 29 | Description string 30 | } 31 | 32 | func (w *WrapperData) SetResult(result hh.TemplateDataResult) { 33 | w.Result = result 34 | } 35 | 36 | type Server struct { 37 | Conf jsc.Config 38 | Verifier recaptcha.VerifierV3 39 | l *slog.Logger 40 | } 41 | 42 | func NewServer(conf jsc.Config, l *slog.Logger) Server { 43 | verifier := recaptcha.NewVerifierV3(conf.ReCAPTCHA.Secret, recaptcha.VerifierV3Options{}) 44 | return Server{ 45 | Conf: conf, 46 | l: l, 47 | Verifier: verifier, 48 | } 49 | } 50 | 51 | func (s Server) ErrorTemplate(meta hh.TemplateRespMeta, r *http.Request, w http.ResponseWriter) { 52 | s.l.ErrorContext(r.Context(), "Failed to execute template.") 53 | } 54 | func (s Server) Logger() *slog.Logger { 55 | return s.l 56 | } 57 | func (s Server) NotFound(w http.ResponseWriter, r *http.Request) { 58 | http.Redirect(w, r, hhconst.PathIndex, http.StatusFound) 59 | } 60 | func (s Server) Shutdown(_ context.Context) error { 61 | return nil 62 | } 63 | func (s Server) WrapperData(r *http.Request) *WrapperData { 64 | navItems := []NavItem{ 65 | { 66 | Name: "Home", 67 | Href: hhconst.PathIndex, 68 | }, 69 | { 70 | Name: "Generate", 71 | Href: jsc.PathGenerate, 72 | }, 73 | { 74 | Name: "Inspect", 75 | Href: jsc.PathInspect, 76 | }, 77 | { 78 | Name: "GitHub", 79 | Href: jsc.LinkGitHub, 80 | }, 81 | } 82 | for i := range navItems { 83 | if navItems[i].Href == hhconst.PathIndex { 84 | if r.URL.Path == "/" { 85 | navItems[i].Active = true 86 | } 87 | } else if strings.HasPrefix(r.URL.Path, navItems[i].Href) { 88 | navItems[i].Active = true 89 | } 90 | } 91 | return &WrapperData{ 92 | ReCAPTCHASiteKey: s.Conf.ReCAPTCHA.SiteKey, 93 | Result: hh.TemplateDataResult{}, 94 | NavItems: navItems, 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /website/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MicahParks/jwkset/3835d6db7bad553e8bad7f505e5007b948bcbf07/website/static/apple-touch-icon.png -------------------------------------------------------------------------------- /website/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MicahParks/jwkset/3835d6db7bad553e8bad7f505e5007b948bcbf07/website/static/favicon-16x16.png -------------------------------------------------------------------------------- /website/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MicahParks/jwkset/3835d6db7bad553e8bad7f505e5007b948bcbf07/website/static/favicon-32x32.png -------------------------------------------------------------------------------- /website/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MicahParks/jwkset/3835d6db7bad553e8bad7f505e5007b948bcbf07/website/static/favicon.ico -------------------------------------------------------------------------------- /website/static/js/generate.js: -------------------------------------------------------------------------------- 1 | const keyTypeRSA = 'RSA'; 2 | const keyTypeECDSA = 'ECDSA'; 3 | const keyTypeEd25519 = 'Ed25519'; 4 | const keyTypeX25519 = 'X25519'; 5 | const keyTypeSymmetric = 'Symmetric'; 6 | 7 | $(function () { 8 | let newGenButton = $('#new-gen-button'); 9 | let newKeyType = $('input[name="new-key-type"]'); 10 | let newKeyID = $('#new-key-id'); 11 | let newKeyAlg = $('#new-key-alg'); 12 | let newKeyAlgOptional = $('#new-key-alg-optional'); 13 | let newUse = $('#new-key-use'); 14 | let newKeyOpSign = $('#new-key-op-sign'); 15 | let newKeyOpVerify = $('#new-key-op-verify'); 16 | let newKeyOpEncrypt = $('#new-key-op-encrypt'); 17 | let newKeyOpDecrypt = $('#new-key-op-decrypt'); 18 | let newKeyOpWrapKey = $('#new-key-op-wrap-key'); 19 | let newKeyOpUnwrapKey = $('#new-key-op-unwrap-key'); 20 | let newKeyOpDeriveKey = $('#new-key-op-derive-key'); 21 | let newKeyOpDeriveBits = $('#new-key-op-derive-bits'); 22 | let newRSABitsChoice = $('input[name="new-rsa-bits"]'); 23 | let newECDSACurveChoice = $('input[name="new-ecdsa-curve"]'); 24 | let newRSABits = $('#new-rsa-bits'); 25 | let newECDSACurve = $('#new-ecdsa-curve'); 26 | let newGenCopyJWK = $('#new-gen-copy-jwk'); 27 | let newGenCopyPKCS8 = $('#new-gen-copy-pkcs8'); 28 | let newGenCopyPKIX = $('#new-gen-copy-pkix'); 29 | let newGenResults = $('#new-gen-results'); 30 | let newGenJWKResult = $('#new-gen-jwk-result'); 31 | let newGenPKCS8Result = $('#new-gen-pkcs8-result'); 32 | let newGenPKIXResult = $('#new-gen-pkix-result'); 33 | let newGenPKCS8 = $('#new-gen-pkcs8'); 34 | let newGenPKIX = $('#new-gen-pkix'); 35 | let newResultButton = $('#new-result-button'); 36 | let newResultText = $('#new-result-text'); 37 | let newResultsList = $('#new-results-list'); 38 | 39 | let pemGenButton = $('#pem-gen-button'); 40 | let pemInput = $('#pem-input'); 41 | let pemKeyID = $('#pem-key-id'); 42 | let pemKeyAlg = $('#pem-key-alg'); 43 | let pemKeyUse = $('#pem-key-use'); 44 | let pemKeyOpSign = $('#pem-key-op-sign'); 45 | let pemKeyOpVerify = $('#pem-key-op-verify'); 46 | let pemKeyOpEncrypt = $('#pem-key-op-encrypt'); 47 | let pemKeyOpDecrypt = $('#pem-key-op-decrypt'); 48 | let pemKeyOpWrapKey = $('#pem-key-op-wrap-key'); 49 | let pemKeyOpUnwrapKey = $('#pem-key-op-unwrap-key'); 50 | let pemKeyOpDeriveKey = $('#pem-key-op-derive-key'); 51 | let pemKeyOpDeriveBits = $('#pem-key-op-derive-bits'); 52 | let pemGenCopyJWK = $('#pem-gen-copy-jwk'); 53 | let pemGenResults = $('#pem-gen-results'); 54 | let pemGenJWKResult = $('#pem-gen-jwk-result'); 55 | let pemResultButton = $('#pem-result-button'); 56 | let pemResultText = $('#pem-result-text'); 57 | let pemResultsList = $('#pem-results-list'); 58 | pemKeyID.val(crypto.randomUUID()); 59 | 60 | pemGenCopyJWK.on('click', function () { 61 | navigator.clipboard.writeText(pemGenJWKResult.text()); 62 | }); 63 | newGenCopyJWK.on('click', function () { 64 | navigator.clipboard.writeText(newGenJWKResult.text()); 65 | }); 66 | newGenCopyPKCS8.on('click', function () { 67 | navigator.clipboard.writeText(newGenPKCS8Result.text()); 68 | }); 69 | newGenCopyPKIX.on('click', function () { 70 | navigator.clipboard.writeText(newGenPKIXResult.text()); 71 | }); 72 | 73 | function pemGenCompete(jqXHR, status) { 74 | switch (jqXHR.status) { 75 | case 200: 76 | pemResultText.text('The PEM is valid. The JWK results are below.'); 77 | pemResultButton.removeClass('bg-red-600').addClass('bg-green-600'); 78 | pemResultButton.contents().filter(function () { 79 | return this.nodeType === 3; 80 | }).first().replaceWith('Valid'); 81 | pemResultButton.find('i').removeClass('fa-circle-xmark').addClass('fa-circle-check'); 82 | unhide(pemGenResults); 83 | unhide(pemResultsList); 84 | pemGenJWKResult.text(jqXHR.responseJSON.data.jwk); 85 | break; 86 | default: 87 | let message = jqXHR.responseJSON?.data?.message; 88 | pemResultText.text(`The PEM generation failed. ${message}`); 89 | pemResultButton.removeClass('bg-green-600').addClass('bg-red-600'); 90 | pemResultButton.contents().filter(function () { 91 | return this.nodeType === 3; 92 | }).first().replaceWith('Invalid'); 93 | unhide(pemGenResults); 94 | hide(pemResultsList); 95 | pemResultButton.find('i').removeClass('fa-circle-check').addClass('fa-circle-xmark'); 96 | } 97 | scroll(pemGenResults); 98 | } 99 | 100 | pemGenButton.on('click', function () { 101 | let keyOps = []; 102 | if (pemKeyOpSign.is(':checked')) { 103 | keyOps.push('sign'); 104 | } 105 | if (pemKeyOpVerify.is(':checked')) { 106 | keyOps.push('verify'); 107 | } 108 | if (pemKeyOpEncrypt.is(':checked')) { 109 | keyOps.push('encrypt'); 110 | } 111 | if (pemKeyOpDecrypt.is(':checked')) { 112 | keyOps.push('decrypt'); 113 | } 114 | if (pemKeyOpWrapKey.is(':checked')) { 115 | keyOps.push('wrapKey'); 116 | } 117 | if (pemKeyOpUnwrapKey.is(':checked')) { 118 | keyOps.push('unwrapKey'); 119 | } 120 | if (pemKeyOpDeriveKey.is(':checked')) { 121 | keyOps.push('deriveKey'); 122 | } 123 | if (pemKeyOpDeriveBits.is(':checked')) { 124 | keyOps.push('deriveBits'); 125 | } 126 | let data = { 127 | alg: pemKeyAlg.val(), 128 | keyops: keyOps, 129 | kid: pemKeyID.val(), 130 | pem: pemInput.val(), 131 | use: pemKeyUse.val(), 132 | }; 133 | postReCAPTCHA('pemGen', pemGenCompete, data, pathAPIPemGen, reCAPTCHASiteKey); 134 | }); 135 | 136 | pemInput.on('input', function () { 137 | let val = pemInput.val(); 138 | if (val === '') { 139 | pemGenButton.prop('disabled', true); 140 | pemGenButton.removeClass('cursor-pointer').addClass('cursor-not-allowed'); 141 | pemGenButton.removeClass('bg-indigo-600 hover:bg-indigo-500').addClass('bg-indigo-400'); 142 | } else { 143 | pemGenButton.prop('disabled', false); 144 | pemGenButton.removeClass('cursor-not-allowed').addClass('cursor-pointer'); 145 | pemGenButton.removeClass('bg-indigo-400').addClass('bg-indigo-600 hover:bg-indigo-500'); 146 | } 147 | }); 148 | 149 | function newGenCompete(jqXHR, status) { 150 | switch (jqXHR.status) { 151 | case 200: 152 | newResultText.text('The results from the new key generation.'); 153 | newResultButton.removeClass('bg-red-600').addClass('bg-green-600'); 154 | newResultButton.contents().filter(function () { 155 | return this.nodeType === 3; 156 | }).first().replaceWith('Valid'); 157 | newResultButton.find('i').removeClass('fa-circle-xmark').addClass('fa-circle-check'); 158 | unhide(newGenResults); 159 | unhide(newResultsList); 160 | newGenJWKResult.text(jqXHR.responseJSON.data.jwk); 161 | if (jqXHR.responseJSON.data.pkcs8) { 162 | newGenPKCS8Result.text(jqXHR.responseJSON.data.pkcs8); 163 | unhide(newGenPKCS8); 164 | } else { 165 | hide(newGenPKCS8); 166 | } 167 | if (jqXHR.responseJSON.data.pkix) { 168 | newGenPKIXResult.text(jqXHR.responseJSON.data.pkix); 169 | unhide(newGenPKIX); 170 | } else { 171 | hide(newGenPKIX); 172 | } 173 | break; 174 | default: 175 | let message = jqXHR.responseJSON?.data?.message; 176 | newResultText.text(`Key generation failed. ${message}`); 177 | newResultButton.removeClass('bg-green-600').addClass('bg-red-600'); 178 | newResultButton.contents().filter(function () { 179 | return this.nodeType === 3; 180 | }).first().replaceWith('Invalid'); 181 | unhide(newGenResults); 182 | hide(newResultsList); 183 | newResultButton.find('i').removeClass('fa-circle-check').addClass('fa-circle-xmark'); 184 | } 185 | scroll(newGenResults); 186 | } 187 | 188 | newGenButton.on('click', function () { 189 | let keyOps = []; 190 | if (newKeyOpSign.is(':checked')) { 191 | keyOps.push('sign'); 192 | } 193 | if (newKeyOpVerify.is(':checked')) { 194 | keyOps.push('verify'); 195 | } 196 | if (newKeyOpEncrypt.is(':checked')) { 197 | keyOps.push('encrypt'); 198 | } 199 | if (newKeyOpDecrypt.is(':checked')) { 200 | keyOps.push('decrypt'); 201 | } 202 | if (newKeyOpWrapKey.is(':checked')) { 203 | keyOps.push('wrapKey'); 204 | } 205 | if (newKeyOpUnwrapKey.is(':checked')) { 206 | keyOps.push('unwrapKey'); 207 | } 208 | if (newKeyOpDeriveKey.is(':checked')) { 209 | keyOps.push('deriveKey'); 210 | } 211 | if (newKeyOpDeriveBits.is(':checked')) { 212 | keyOps.push('deriveBits'); 213 | } 214 | let data = { 215 | alg: newKeyAlg.val(), 216 | keyops: keyOps, 217 | keyType: newKeyType.filter(':checked').val(), 218 | kid: newKeyID.val(), 219 | use: newUse.val(), 220 | rsaBits: parseInt(newRSABitsChoice.filter(':checked').val()), 221 | ecCurve: newECDSACurveChoice.filter(':checked').val(), 222 | }; 223 | postReCAPTCHA('newGen', newGenCompete, data, pathAPINewGen, reCAPTCHASiteKey); 224 | }); 225 | 226 | let rsaAlgs = [ 227 | 'RS256', 228 | 'RS384', 229 | 'RS512', 230 | 'PS256', 231 | 'PS384', 232 | 'PS512', 233 | 'RSA1_5', 234 | 'RSA-OAEP', 235 | 'RSA-OAEP-256', 236 | 'RSA-OAEP-384', 237 | 'RSA-OAEP-512', 238 | ]; // RS1 Prohibited. 239 | let ecdsaAlgs = [ 240 | 'ES256', 241 | 'ES384', 242 | 'ES512', 243 | 'ES256K', 244 | ]; 245 | let ed25519Algs = [ 246 | 'EdDSA', 247 | ]; 248 | let x25519Algs = [ 249 | 'ECDH-ES', 250 | 'ECDH-ES+A128KW', 251 | 'ECDH-ES+A192KW', 252 | 'ECDH-ES+A256KW', 253 | ]; 254 | let symmetricAlgs = [ 255 | 'HS256', 256 | 'HS384', 257 | 'HS512', 258 | 'dir', 259 | 'A128KW', 260 | 'A192KW', 261 | 'A256KW', 262 | 'A128GCMKW', 263 | 'A192GCMKW', 264 | 'A256GCMKW', 265 | 'PBES2-HS256+A128KW', 266 | 'PBES2-HS384+A192KW', 267 | 'PBES2-HS512+A256KW', 268 | 'A128CBC-HS256', 269 | 'A192CBC-HS384', 270 | 'A256CBC-HS512', 271 | 'A128GCM', 272 | 'A192GCM', 273 | 'A256GCM', 274 | ]; 275 | 276 | const emptySelection = ''; 277 | pemKeyAlg.append(emptySelection); 278 | let pemAlgs = rsaAlgs.concat(ecdsaAlgs, ed25519Algs, x25519Algs); 279 | for (let i = 0; i < pemAlgs.length; i++) { 280 | let alg = pemAlgs[i]; 281 | pemKeyAlg.append(``); 282 | } 283 | 284 | function keyPanelChange(keyType) { 285 | newKeyID.val(crypto.randomUUID()); 286 | let algs; 287 | switch (keyType) { 288 | case keyTypeRSA: 289 | hide(newECDSACurve); 290 | unhide(newRSABits); 291 | unhide(newKeyAlgOptional); 292 | algs = rsaAlgs; 293 | break; 294 | case keyTypeECDSA: 295 | unhide(newECDSACurve); 296 | hide(newRSABits); 297 | unhide(newKeyAlgOptional); 298 | algs = ecdsaAlgs; 299 | break; 300 | case keyTypeEd25519: 301 | hide(newECDSACurve); 302 | hide(newRSABits); 303 | hide(newKeyAlgOptional); 304 | algs = ed25519Algs; 305 | break; 306 | case keyTypeX25519: 307 | hide(newECDSACurve); 308 | hide(newRSABits); 309 | unhide(newKeyAlgOptional); 310 | algs = x25519Algs; 311 | break; 312 | case keyTypeSymmetric: 313 | hide(newECDSACurve); 314 | hide(newRSABits); 315 | unhide(newKeyAlgOptional); 316 | algs = symmetricAlgs; 317 | break; 318 | default: 319 | algs = []; 320 | console.log('Unknown key type: ' + keyType); 321 | break; 322 | } 323 | newKeyAlg.empty(); 324 | if (keyType === keyTypeEd25519) { 325 | newKeyAlg.prop('disabled', true); 326 | newKeyAlg.append(''); 327 | } else { 328 | newKeyAlg.prop('disabled', false); 329 | newKeyAlg.append(emptySelection); 330 | for (let i = 0; i < algs.length; i++) { 331 | let alg = algs[i]; 332 | newKeyAlg.append(``); 333 | } 334 | } 335 | let selectionSignature = ''; 336 | let selectionEncryption = ''; 337 | switch (keyType) { 338 | case keyTypeECDSA: 339 | newUse.empty(); 340 | newUse.append(emptySelection); 341 | newUse.append(selectionSignature); 342 | break; 343 | case keyTypeEd25519: 344 | newUse.empty(); 345 | newUse.append(emptySelection); 346 | newUse.append(selectionSignature); 347 | break; 348 | case keyTypeX25519: 349 | newUse.empty(); 350 | newUse.append(emptySelection); 351 | newUse.append(selectionEncryption); 352 | break; 353 | default: 354 | newUse.empty(); 355 | newUse.append(emptySelection); 356 | newUse.append(selectionSignature); 357 | newUse.append(selectionEncryption); 358 | break; 359 | } 360 | } 361 | 362 | keyPanelChange(keyTypeRSA); 363 | 364 | newKeyType.on('change', function () { 365 | let keyType = newKeyType.filter(':checked').val(); 366 | keyPanelChange(keyType); 367 | }); 368 | 369 | function radioChange(r) { 370 | r.on('change', function () { 371 | // Remove the active class from all options 372 | let parent = r.parent(); 373 | const selected = 'bg-indigo-600 text-white hover:bg-indigo-500'; 374 | const unselected = 'ring-1 ring-inset ring-gray-300 bg-white text-gray-900 hover:bg-gray-50'; 375 | parent.removeClass(selected); 376 | parent.addClass(unselected); 377 | 378 | // Add the active class to the selected option 379 | let t = $(this).parent(); 380 | t.removeClass(unselected); 381 | t.addClass(selected); 382 | }); 383 | } 384 | 385 | radioChange(newRSABitsChoice); 386 | radioChange(newECDSACurveChoice); 387 | }); 388 | -------------------------------------------------------------------------------- /website/static/js/inspect.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | let jwkInspectButton = $('#jwk-inspect-button'); 3 | let jwkInput = $('#jwk-input'); 4 | let inspectResults = $('#inspect-results'); 5 | let jwkResult = $('#jwk-result'); 6 | let jwkResultText = $('#jwk-result-text'); 7 | let pkcs8Result = $('#pkcs8-result'); 8 | let pkcs8ResultText = $('#pkcs8-result-text'); 9 | let pkixResult = $('#pkix-result'); 10 | let pkixResultText = $('#pkix-result-text'); 11 | let resultButton = $('#result-button'); 12 | let resultText = $('#result-text'); 13 | 14 | jwkInput.on('input', function () { 15 | let jwk = jwkInput.val(); 16 | if (jwk) { 17 | jwkInspectButton.prop('disabled', false); 18 | jwkInspectButton.removeClass('cursor-not-allowed').addClass('cursor-pointer'); 19 | jwkInspectButton.removeClass('bg-indigo-400').addClass('bg-indigo-600 hover:bg-indigo-500'); 20 | } else { 21 | jwkInspectButton.prop('disabled', true); 22 | jwkInspectButton.removeClass('cursor-pointer').addClass('cursor-not-allowed'); 23 | jwkInspectButton.removeClass('bg-indigo-600 hover:bg-indigo-500').addClass('bg-indigo-400'); 24 | } 25 | }); 26 | 27 | function complete(jqXHR, status) { 28 | switch (jqXHR.status) { 29 | case 200: 30 | resultText.text('The JWK is valid. The parsing results are below.'); 31 | resultButton.removeClass('bg-red-600').addClass('bg-green-600'); 32 | resultButton.contents().filter(function () { 33 | return this.nodeType === 3; 34 | }).first().replaceWith('Valid'); 35 | resultButton.find('i').removeClass('fa-circle-xmark').addClass('fa-circle-check'); 36 | unhide(inspectResults); 37 | unhide(jwkResult); 38 | jwkResultText.text(jqXHR.responseJSON.data.jwk); 39 | let pkcs8 = jqXHR.responseJSON.data.pkcs8; 40 | if (pkcs8) { 41 | pkcs8ResultText.text(pkcs8); 42 | unhide(pkcs8Result); 43 | } else { 44 | hide(pkcs8Result); 45 | } 46 | let pkix = jqXHR.responseJSON.data.pkix; 47 | if (pkix) { 48 | pkixResultText.text(pkix); 49 | unhide(pkixResult); 50 | } else { 51 | hide(pkixResult); 52 | } 53 | scroll(inspectResults); 54 | break; 55 | default: 56 | let message = jqXHR.responseJSON?.data?.message; 57 | resultText.text(`The JWK is invalid. ${message}`); 58 | resultButton.removeClass('bg-green-600').addClass('bg-red-600'); 59 | resultButton.contents().filter(function () { 60 | return this.nodeType === 3; 61 | }).first().replaceWith('Invalid'); 62 | resultButton.find('i').removeClass('fa-circle-check').addClass('fa-circle-xmark'); 63 | unhide(inspectResults); 64 | hide(jwkResult); 65 | hide(pkcs8Result); 66 | hide(pkixResult); 67 | } 68 | scroll(inspectResults); 69 | } 70 | 71 | jwkInspectButton.on('click', function () { 72 | let data = { 73 | jwk: jwkInput.val(), 74 | }; 75 | postReCAPTCHA('inspect', complete, data, pathAPIInspect, reCAPTCHASiteKey); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /website/static/js/wrapper.js: -------------------------------------------------------------------------------- 1 | function hide(j) { 2 | j.addClass('hidden'); 3 | } 4 | 5 | function unhide(j) { 6 | j.removeClass('hidden'); 7 | } 8 | 9 | $(function () { 10 | let mobileMenuButton = $('#mobile-menu-button'); 11 | let mobileMenuIcon = $('#mobile-menu-icon'); 12 | let mobileMenu = $('#mobile-menu'); 13 | mobileMenuButton.on('click', function () { 14 | mobileMenuIcon.toggleClass('fa-bars fa-x'); 15 | mobileMenu.toggleClass('hidden'); 16 | }); 17 | }); 18 | 19 | // https://stackoverflow.com/a/6677069/14797322 20 | function scroll(e) { 21 | $([document.documentElement, document.body]).animate({ 22 | scrollTop: e.offset().top 23 | }, 500); 24 | } 25 | 26 | function postReCAPTCHA(action, complete, data, postURL, siteKey) { 27 | if (reCAPTCHASiteKey === '') { 28 | $.ajax(postURL, { 29 | accepts: 'application/json', 30 | contentType: 'application/json', 31 | data: JSON.stringify(data), 32 | dataType: 'json', 33 | method: 'POST', 34 | complete: complete, 35 | }); 36 | return 37 | } 38 | grecaptcha.ready(function () { 39 | grecaptcha.execute(siteKey, {action: action}).then(function (token) { 40 | $.ajax(postURL, { 41 | accepts: 'application/json', 42 | contentType: 'application/json', 43 | data: JSON.stringify(data), 44 | dataType: 'json', 45 | headers: { 46 | 'g-recaptcha-response': token, 47 | }, 48 | method: 'POST', 49 | complete: complete, 50 | }); 51 | }); 52 | }); 53 | } -------------------------------------------------------------------------------- /website/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /website/static/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MicahParks/jwkset/3835d6db7bad553e8bad7f505e5007b948bcbf07/website/static/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /website/static/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MicahParks/jwkset/3835d6db7bad553e8bad7f505e5007b948bcbf07/website/static/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /website/static/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MicahParks/jwkset/3835d6db7bad553e8bad7f505e5007b948bcbf07/website/static/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /website/static/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MicahParks/jwkset/3835d6db7bad553e8bad7f505e5007b948bcbf07/website/static/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /website/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const defaultTheme = require('tailwindcss/defaultTheme'); 3 | module.exports = { 4 | content: ['./templates/*.gohtml'], 5 | theme: { 6 | fontFamily: { 7 | 'sans': [...defaultTheme.fontFamily.sans, '"Font Awesome 6 Pro"'] 8 | }, 9 | extend: {}, 10 | }, 11 | plugins: [ 12 | require('@tailwindcss/aspect-ratio'), 13 | require('@tailwindcss/forms'), 14 | require('@tailwindcss/typography'), 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /website/templates/index.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/MicahParks/jwkset/website/handle/template.IndexData*/ -}} 2 | 3 | {{- /*Header*/}} 4 |
5 |
6 |
7 |

8 | JWK Set 9 |

10 |

11 | A JSON Web Key Set (JWK Set) is a JSON representation of a set of cryptographic keys and metadata. JWK Sets are 12 | defined in 13 | IANA, 14 | RFC 7517, 15 | RFC 8037, 16 | and various other 17 | RFCs. 18 |

19 |
20 |
21 |
22 | {{- /*Header*/}} 23 | 24 | {{- /*Code*/}} 25 |
26 |
27 | Example JWK Set 28 |
29 |
30 |
{
 31 |   "keys": [
 32 |     {
 33 |       "kty": "EC",
 34 |       "kid": "fd415283-5b58-4372-8f97-3c5b26910d85",
 35 |       "crv": "P-256",
 36 |       "x": "pYkxEyczvZkQ7UG1rIpl6fBAQQvXmpITYv99Uf3X7aE",
 37 |       "y": "uQKi7IUrz3wwlcy1yW3HbZxiu5bQgRTfoVFDIFFHluE",
 38 |       "d": "2bkgxUvO64UL-ouu4Eib02PA39nQ-HBmrN7jESp1gag"
 39 |     },
 40 |     {
 41 |       "kty": "OKP",
 42 |       "alg": "EdDSA",
 43 |       "kid": "b86fe288-87e7-4926-891e-0e63736711ec",
 44 |       "crv": "Ed25519",
 45 |       "x": "JVuzaFQ-d6Q3AGgLerQNjRDaTwoF1jBGt3ScDhQ4Dso",
 46 |       "d": "yO5_dyngoqDMqWvcm02kSvqq0uDbTelRAXKYlCBXRas"
 47 |     },
 48 |     {
 49 |       "kty": "OKP",
 50 |       "kid": "7f68a3cc-9970-49cb-8622-c686312f3ddc",
 51 |       "crv": "X25519",
 52 |       "x": "6WnrHvj1DP7NoSnk5qrID95jbTjC0zy-jexWR0Wnjm4",
 53 |       "d": "V2cebWWmT9QX6IZ3qTBv2z9s7_u1T-8fUZDvF1fgv98"
 54 |     }
 55 |   ]
 56 | }
57 |
58 |
59 | {{- /*Code*/}} 60 | 61 | {{- /*Tools*/}} 62 |
63 |
64 | Tools 65 |
66 |
67 | 69 |
70 |

71 | Generator 72 |

73 |
74 |

75 | Generate a JWK using an existing cryptographic key or create a new one. 76 |

77 |
78 |
79 |
80 | 81 |
82 |
83 | 85 |
86 |

87 | Inspector 88 |

89 |
90 |

91 | Inspect a JWK to validate it an extract cryptographic keys. 92 |

93 |
94 |
95 |
96 | 97 |
98 |
99 |
100 |
101 | {{- /*Tools*/}} 102 | 103 | {{- /*Self-host*/}} 104 |
105 |
106 |
107 |

108 | Self-host this website 109 |

110 |

111 | This website is a part of an open source project. Self-host this website in order to work with private keys 112 | securely. 113 |

114 | 121 |
122 |
123 |
124 | {{- /*Self-host*/}} 125 | -------------------------------------------------------------------------------- /website/templates/inspect.gohtml: -------------------------------------------------------------------------------- 1 | {{- define "inspect.gohtml.header" -}} 2 | 3 | {{- end -}} 4 | {{- /*gotype: github.com/MicahParks/jwkset/website/handle/template.InspectData*/ -}} 5 | 6 | {{- /*Header*/}} 7 |
8 |
9 |
10 | 12 | Open source self-host instructions here 13 | 14 |

15 | JWK Inspector 16 |

17 |

18 | Upload a JWK to parse for validity and extract cryptographic keys in PEM encoded ASN.1 DER format for PKCS #8 or 19 | PKIX. 20 |

21 |
22 |
23 |
24 | {{- /*Header*/}} 25 | 26 |
27 | {{/*Inspector*/}} 28 |
29 |
30 |
31 |
32 |

33 | Inspect a JWK 34 |

35 |

36 | Paste a JWK to inspect it. Validity status and cryptographic keys will be returned in PEM format. 37 |
38 | Do not upload a JWK with private key material unless this website is self-hosted. 39 |

40 |
41 |
42 | 47 |
48 |
49 | 59 |
60 |
61 | {{/*Inspector*/}} 62 | 63 | {{/*Results*/}} 64 | 159 | {{/*Results*/}} 160 |
161 | -------------------------------------------------------------------------------- /website/templates/wrapper.gohtml: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/MicahParks/jwkset/website/server.WrapperData*/ -}} 2 | 3 | 4 | 5 | 6 | 7 | 9 | {{- if .ReCAPTCHASiteKey}} 10 | 11 | {{- end}} 12 | 13 | {{.Title}} 14 | 15 | {{- /*Font Awesome*/}} 16 | 17 | {{- /*Font Awesome*/}} 18 | {{- /*Favicon*/}} 19 | 20 | 21 | 22 | {{/* */}} 23 | 24 | 25 | {{- /*Favicon*/}} 26 | 27 | {{- if .Result.HeaderAdd}} 28 | {{.Result.HeaderAdd}} 29 | {{- end}} 30 | 36 | 37 | 38 | 85 |
86 |
87 |
88 |
89 | {{.Result.InnerHTML}} 90 |
91 |
92 |
93 |
94 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /x509.go: -------------------------------------------------------------------------------- 1 | package jwkset 2 | 3 | import ( 4 | "crypto/ecdh" 5 | "crypto/ecdsa" 6 | "crypto/ed25519" 7 | "crypto/rsa" 8 | "crypto/x509" 9 | "encoding/pem" 10 | "errors" 11 | "fmt" 12 | ) 13 | 14 | var ( 15 | // ErrX509Infer is returned when the key type cannot be inferred from the PEM block type. 16 | ErrX509Infer = errors.New("failed to infer X509 key type") 17 | ) 18 | 19 | // LoadCertificate loads an X509 certificate from a PEM block. 20 | func LoadCertificate(pemBlock []byte) (*x509.Certificate, error) { 21 | cert, err := x509.ParseCertificate(pemBlock) 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to parse certificates: %w", err) 24 | } 25 | switch cert.PublicKey.(type) { 26 | case *ecdsa.PublicKey, ed25519.PublicKey, *rsa.PublicKey: 27 | default: 28 | return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, cert.PublicKey) 29 | } 30 | return cert, nil 31 | } 32 | 33 | // LoadCertificates loads X509 certificates from raw PEM data. It can be useful in loading X5U remote resources. 34 | func LoadCertificates(rawPEM []byte) ([]*x509.Certificate, error) { 35 | b := make([]byte, 0) 36 | for { 37 | block, rest := pem.Decode(rawPEM) 38 | if block == nil { 39 | break 40 | } 41 | rawPEM = rest 42 | if block.Type == "CERTIFICATE" { 43 | b = append(b, block.Bytes...) 44 | } 45 | } 46 | certs, err := x509.ParseCertificates(b) 47 | if err != nil { 48 | return nil, fmt.Errorf("failed to parse certificates: %w", err) 49 | } 50 | for _, cert := range certs { 51 | switch cert.PublicKey.(type) { 52 | case *ecdsa.PublicKey, ed25519.PublicKey, *rsa.PublicKey: 53 | default: 54 | return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, cert.PublicKey) 55 | } 56 | } 57 | return certs, nil 58 | } 59 | 60 | // LoadX509KeyInfer loads an X509 key from a PEM block. 61 | func LoadX509KeyInfer(pemBlock *pem.Block) (key any, err error) { 62 | switch pemBlock.Type { 63 | case "EC PRIVATE KEY": 64 | key, err = loadECPrivate(pemBlock) 65 | case "RSA PRIVATE KEY": 66 | key, err = loadPKCS1Private(pemBlock) 67 | case "RSA PUBLIC KEY": 68 | key, err = loadPKCS1Public(pemBlock) 69 | case "PRIVATE KEY": 70 | key, err = loadPKCS8Private(pemBlock) 71 | case "PUBLIC KEY": 72 | key, err = loadPKIXPublic(pemBlock) 73 | default: 74 | return nil, ErrX509Infer 75 | } 76 | if err != nil { 77 | return nil, fmt.Errorf("failed to load key from inferred format %q: %w", key, err) 78 | } 79 | return key, nil 80 | } 81 | func loadECPrivate(pemBlock *pem.Block) (priv *ecdsa.PrivateKey, err error) { 82 | priv, err = x509.ParseECPrivateKey(pemBlock.Bytes) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to parse EC private key: %w", err) 85 | } 86 | return priv, nil 87 | } 88 | func loadPKCS1Public(pemBlock *pem.Block) (pub *rsa.PublicKey, err error) { 89 | pub, err = x509.ParsePKCS1PublicKey(pemBlock.Bytes) 90 | if err != nil { 91 | return nil, fmt.Errorf("failed to parse PKCS1 public key: %w", err) 92 | } 93 | return pub, nil 94 | } 95 | func loadPKCS1Private(pemBlock *pem.Block) (priv *rsa.PrivateKey, err error) { 96 | priv, err = x509.ParsePKCS1PrivateKey(pemBlock.Bytes) 97 | if err != nil { 98 | return nil, fmt.Errorf("failed to parse PKCS1 private key: %w", err) 99 | } 100 | return priv, nil 101 | } 102 | func loadPKCS8Private(pemBlock *pem.Block) (priv any, err error) { 103 | priv, err = x509.ParsePKCS8PrivateKey(pemBlock.Bytes) 104 | if err != nil { 105 | return nil, fmt.Errorf("failed to parse PKCS8 private key: %w", err) 106 | } 107 | switch priv.(type) { 108 | case *ecdh.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey, *rsa.PrivateKey: 109 | default: 110 | return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, priv) 111 | } 112 | return priv, nil 113 | } 114 | func loadPKIXPublic(pemBlock *pem.Block) (pub any, err error) { 115 | pub, err = x509.ParsePKIXPublicKey(pemBlock.Bytes) 116 | if err != nil { 117 | return nil, fmt.Errorf("failed to parse PKIX public key: %w", err) 118 | } 119 | switch pub.(type) { 120 | case *ecdh.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey, *rsa.PublicKey: 121 | default: 122 | return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, pub) 123 | } 124 | return pub, nil 125 | } 126 | -------------------------------------------------------------------------------- /x509_gen.sh: -------------------------------------------------------------------------------- 1 | # OpenSSL 3.0.10 1 Aug 2023 (Library: OpenSSL 3.0.10 1 Aug 2023) 2 | openssl req -newkey EC -pkeyopt ec_paramgen_curve:P-521 -noenc -keyout ec521.pem -x509 -out ec521.crt -subj "/C=US/ST=Virginia/L=Richmond/O=Micah Parks/OU=Self/CN=example.com" 3 | openssl req -newkey ED25519 -noenc -keyout ed25519.pem -x509 -out ed25519.crt -subj "/C=US/ST=Virginia/L=Richmond/O=Micah Parks/OU=Self/CN=example.com" 4 | openssl req -newkey RSA:4096 -noenc -keyout rsa4096.pem -x509 -out rsa4096.crt -subj "/C=US/ST=Virginia/L=Richmond/O=Micah Parks/OU=Self/CN=example.com" 5 | 6 | openssl pkey -in ec521.pem -pubout -out ec521pub.pem 7 | openssl pkey -in ed25519.pem -pubout -out ed25519pub.pem 8 | openssl pkey -in rsa4096.pem -pubout -out rsa4096pub.pem 9 | 10 | # For the "RSA PRIVATE KEY" (PKCS#1) and "EC PRIVATE KEY" (SEC1) formats, the PEM files are generated using the 11 | # cmd/gen_pkcs1 and cmd/gen_ec Golang programs, respectively. 12 | 13 | openssl dsaparam -out dsaparam.pem 2048 14 | openssl gendsa -out dsa.pem dsaparam.pem 15 | openssl dsa -in dsa.pem -pubout -out dsa_pub.pem 16 | --------------------------------------------------------------------------------