diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..040ac50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.*json +node_modules diff --git a/README.md b/README.md index 673deb8..5afedc2 100644 --- a/README.md +++ b/README.md @@ -1,162 +1,97 @@ -[![Go Report Card](https://goreportcard.com/badge/github.com/MicahParks/jwkset)](https://goreportcard.com/report/github.com/MicahParks/jwkset) [![Go Reference](https://pkg.go.dev/badge/github.com/MicahParks/jwkset.svg)](https://pkg.go.dev/github.com/MicahParks/jwkset) -# JWK Set -This is a JWK Set (JWKS or jwks) implementation. For a JWK Set client, -see [`github.com/MicahParks/keyfunc`](https://github.com/MicahParks/keyfunc). Cryptographic keys can be added, deleted, -and read from the JWK Set. A JSON representation of the JWK Set can be created for hosting via HTTPS. This project -includes an in-memory storage implementation, but an interface is provided for more advanced use cases. For this -implementation, a key ID (`kid`) is required. - -This project only depends on packages from the standard Go library. It has no external dependencies. - -The following key types have a JSON representation: - -| Key type | Go private key type | Go public key type | External link | -|----------|----------------------------------------------------------------------|--------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------| -| `EC` | [`*ecdsa.PrivateKey`](https://pkg.go.dev/crypto/ecdsa#PrivateKey) | [`*ecdsa.PublicKey`](https://pkg.go.dev/crypto/ecdsa#PublicKey) | [Elliptic Curve Digital Signature Algorithm (ECDSA)](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm) | -| `OKP` | [`ed25519.PrivateKey`](https://pkg.go.dev/crypto/ed25519#PrivateKey) | [`ed25519.PublicKey`](https://pkg.go.dev/crypto/ed25519#PublicKey) | [Edwards-curve Digital Signature Algorithm (EdDSA)](https://en.wikipedia.org/wiki/EdDSA) | -| `RSA` | [`*rsa.PrivateKey`](https://pkg.go.dev/crypto/rsa#PrivateKey) | [`*rsa.PublicKey`](https://pkg.go.dev/crypto/rsa#PublicKey) | [Rivest–Shamir–Adleman (RSA)](https://en.wikipedia.org/wiki/RSA_(cryptosystem)) | -| `oct` | `[]byte` | none | | - -Only the Go types listed in this table have a JSON representation. If you would like support for another key type, -please open an issue on GitHub. - -# Example HTTP server -```go -package main - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "log" - "net/http" - "os" - - "github.com/MicahParks/jwkset" -) - -const ( - logFmt = "%s\nError: %s" -) - -func main() { - ctx := context.Background() - logger := log.New(os.Stdout, "", 0) - - jwkSet := jwkset.NewMemory[any]() - - key, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - logger.Fatalf(logFmt, "Failed to generate RSA key.", err) - } - - err = jwkSet.Store.WriteKey(ctx, jwkset.NewKey[any](key, "my-key-id")) - if err != nil { - logger.Fatalf(logFmt, "Failed to store RSA key.", err) - } - - http.HandleFunc("/jwks.json", func(writer http.ResponseWriter, request *http.Request) { - // TODO Cache the JWK Set so storage isn't called for every request. - response, err := jwkSet.JSONPublic(request.Context()) - if err != nil { - logger.Printf(logFmt, "Failed to get JWK Set JSON.", err) - writer.WriteHeader(http.StatusInternalServerError) - return - } - - writer.Header().Set("Content-Type", "application/json") - _, _ = writer.Write(response) - }) - - logger.Print("Visit: http://localhost:8080/jwks.json") - logger.Fatalf("Failed to listen and serve: %s", http.ListenAndServe(":8080", nil)) -} +[![Go Reference](https://pkg.go.dev/badge/github.com/MicahParks/jwkset.svg)](https://pkg.go.dev/github.com/MicahParks/jwkset) + +# JWK Set (JSON Web Key Set) + +This is a JWK Set (JSON Web Key Set) implementation written in Golang. + +If you would like to generate or validate a JWK without writing any Golang code, please visit +the [Generate a JWK Set](#generate-a-jwk-set) section. + +If you would like to have a JWK Set client to help verify JWTs without writing any Golang code, you can use the +[JWK Set Client Proxy (JCP) project](https://github.com/MicahParks/jcp) perform JWK Set client operations in the +language of your choice using an OpenAPI interface. + +# Generate a JWK Set + +If you would like to generate a JWK Set without writing Golang code, this project publishes utilities to generate a JWK +Set from: + +* PEM encoded X.509 Certificates +* PEM encoded public keys +* PEM encoded private keys + +The PEM block type is used to infer which key type to decode. Reference the [Supported keys](#supported-keys) section +for a list of supported cryptographic key types. + +## Website + +Please visit [https://jwkset.com](https://jwkset.com) to use the web interface for this project. You can self-host this +website by following the instructions in the `README.md` in the [website](https://github.com/MicahParks/jwkset/website) +directory. + +## Command line + +Gather your PEM encoded keys or certificates and use the `cmd/jwksetinfer` command line tool to generate a JWK Set. + +**Install** + ``` +go install github.com/MicahParks/jwkset/cmd/jwksetinfer@latest +``` + +**Usage** -# Example for marshalling a single key to a JSON Web Key -```go -package main - -import ( - "crypto/ed25519" - "crypto/rand" - "encoding/json" - "log" - "os" - - "github.com/MicahParks/jwkset" -) - -const logFmt = "%s\nError: %s" - -func main() { - logger := log.New(os.Stdout, "", 0) - - // Create an EdDSA key. - _, private, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - logger.Fatalf(logFmt, "Failed to generate EdDSA key.", err) - } - - // Wrap the key in the appropriate Go type. - meta := jwkset.NewKey(private, "my-key-id") - - // Create the approrpiate options to include the private key material in the JSON representation. - options := jwkset.KeyMarshalOptions{ - AsymmetricPrivate: true, - } - - // Marshal the key to a different Go type that can be serialized to JSON. - marshal, err := jwkset.KeyMarshal(meta, options) - if err != nil { - logger.Fatalf(logFmt, "Failed to marshal key.", err) - } - - // Marshal the new type to JSON. - j, err := json.MarshalIndent(marshal, "", " ") - if err != nil { - logger.Fatalf(logFmt, "Failed to marshal JSON.", err) - } - println(string(j)) - - // Unmarshal the raw JSON into a Go type that can be deserialized into a key. - err = json.Unmarshal(j, &marshal) - if err != nil { - logger.Fatalf(logFmt, "Failed to unmarshal JSON.", err) - } - - // Create the appropriate options to include the private key material in the deserialization. - // - // If this option is not provided, the resulting key will be of the type ed25519.PublicKey. - unmarshalOptions := jwkset.KeyUnmarshalOptions{ - AsymmetricPrivate: true, - } - - // Convert the Go type back into a key with metadata. - meta, err = jwkset.KeyUnmarshal(marshal, unmarshalOptions) - if err != nil { - logger.Fatalf(logFmt, "Failed to unmarshal key.", err) - } - - // Print the key ID. - println(meta.KeyID) -} ``` +jwksetinfer mykey.pem mycert.crt +``` + +# Supported keys + +This project supports the following key types: + +* [Edwards-curve Digital Signature Algorithm (EdDSA)](https://en.wikipedia.org/wiki/EdDSA) (Ed25519 only) + * Go Types: `ed25519.PrivateKey` and `ed25519.PublicKey` +* [Elliptic-curve Diffie–Hellman (ECDH)](https://en.wikipedia.org/wiki/Elliptic-curve_Diffie%E2%80%93Hellman) (X25519 + only) + * Go Types: `*ecdh.PrivateKey` and `*ecdh.PublicKey` +* [Elliptic Curve Digital Signature Algorithm (ECDSA)](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm) + * Go Types: `*ecdsa.PrivateKey` and `*ecdsa.PublicKey` +* [Rivest–Shamir–Adleman (RSA)](https://en.wikipedia.org/wiki/RSA_(cryptosystem)) + * Go Types: `*rsa.PrivateKey` and `*rsa.PublicKey` +* [HMAC](https://en.wikipedia.org/wiki/HMAC), [AES Key Wrap](https://en.wikipedia.org/wiki/Key_Wrap), and other + symmetric keys + * Go Type: `[]byte` + +Cryptographic keys can be added, deleted, and read from the JWK Set. A JSON representation of the JWK Set can be created +for hosting via HTTPS. This project includes an in-memory storage implementation, but an interface is provided for more +advanced use cases. + +# Notes + +This project aims to implement the relevant RFCs to the fullest extent possible using the Go standard library, but does +not implement any cryptographic algorithms itself. + +* RFC 8037 adds support for `Ed448`, `X448`, and `secp256k1`, but there is no Golang standard library support for these + key types. +* RFC 7518 specifies that `Base64urlUInt` must use the "minimum number of octets" to represent the number. This can lead + to a problem with parsing JWK made by other projects that may contain leading zeros in the + non-compliant `Base64urlUInt` encoding. This error happens during JWK validation and will look + like: `failed to validate JWK: marshaled JWK does not match original JWK`. To work around this, please modify the + JWK's JSON to remove the leading zeros for a proper `Base64urlUInt` encoding. If you need help doing this, please open + a GitHub issue. +* This project does not currently support JWK Set encryption using JWE. This would involve implementing the relevant JWE + specifications. It may be implemented in the future if there is interest. Open a GitHub issue to express interest. # Test coverage -Test coverage is currently `99%`. ``` -$ go test -cover -race +$ go test -cover PASS -coverage: 98.5% of statements -ok github.com/MicahParks/jwkset 0.031s +coverage: 85.5% of statements +ok github.com/MicahParks/jwkset 0.013s ``` -# References -This project was built and tested using various RFCs and services. The services are listed below: -* [mkjwk.org](https://github.com/mitreid-connect/mkjwk.org) +# See also -See also: -* [`github.com/MicahParks/keyfunc`](https://github.com/MicahParks/keyfunc) -* [`github.com/golang-jwt/jwt/v4`](https://github.com/golang-jwt/jwt) +* [`github.com/MicahParks/jcp`](https://github.com/MicahParks/jcp) +* [`github.com/MicahParks/keyfunc`](https://github.com/MicahParks/keyfunc) \ No newline at end of file diff --git a/cmd/gen_ec/main.go b/cmd/gen_ec/main.go new file mode 100644 index 0000000..129b778 --- /dev/null +++ b/cmd/gen_ec/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "log" + "os" +) + +const ( + logFmt = "%s\nError: %s" + privFile = "ec256SEC1Priv.pem" +) + +func main() { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + log.Fatalf(logFmt, "Failed to generate EC key.", err) + } + + pemBytes, err := x509.MarshalECPrivateKey(priv) + if err != nil { + log.Fatalf(logFmt, "Failed to marshal EC private key.", err) + } + block := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: pemBytes, + } + out := pem.EncodeToMemory(block) + + err = os.WriteFile(privFile, out, 0644) + if err != nil { + log.Fatalf(logFmt, "Failed to write EC private key.", err) + } +} diff --git a/cmd/gen_pkcs1/main.go b/cmd/gen_pkcs1/main.go new file mode 100644 index 0000000..74b9479 --- /dev/null +++ b/cmd/gen_pkcs1/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "log" + "os" +) + +const ( + logFmt = "%s\nError: %s" + privFile = "rsa2048PKCS1Priv.pem" + pubFile = "rsa2048PKCS1Pub.pem" +) + +func main() { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + log.Fatalf(logFmt, "Failed to generate RSA key.", err) + } + + block := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(priv), + } + out := pem.EncodeToMemory(block) + + err = os.WriteFile(privFile, out, 0644) + if err != nil { + log.Fatalf(logFmt, "Failed to write RSA private key.", err) + } + + pub := &priv.PublicKey + block = &pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: x509.MarshalPKCS1PublicKey(pub), + } + out = pem.EncodeToMemory(block) + + err = os.WriteFile(pubFile, out, 0644) + if err != nil { + log.Fatalf(logFmt, "Failed to write RSA public key.", err) + } +} diff --git a/cmd/jwksetinfer/go.mod b/cmd/jwksetinfer/go.mod new file mode 100644 index 0000000..3abb986 --- /dev/null +++ b/cmd/jwksetinfer/go.mod @@ -0,0 +1,5 @@ +module github.com/MicahParks/jwkset/cmd/jwksetinfer + +go 1.21.4 + +require github.com/MicahParks/jwkset v0.3.1 diff --git a/cmd/jwksetinfer/go.sum b/cmd/jwksetinfer/go.sum new file mode 100644 index 0000000..c994e0e --- /dev/null +++ b/cmd/jwksetinfer/go.sum @@ -0,0 +1,2 @@ +github.com/MicahParks/jwkset v0.3.1 h1:DIVazR/elD8CLWPblrVo610TzovIDYMcvlM4X0UT0vQ= +github.com/MicahParks/jwkset v0.3.1/go.mod h1:Ob0sxSgMmQZFg4GO59PVBnfm+jtdQ1MJbfZDU90tEwM= diff --git a/cmd/jwksetinfer/go.work b/cmd/jwksetinfer/go.work new file mode 100644 index 0000000..86c9102 --- /dev/null +++ b/cmd/jwksetinfer/go.work @@ -0,0 +1,6 @@ +go 1.21.4 + +use ( + ../.. + . +) diff --git a/cmd/jwksetinfer/main.go b/cmd/jwksetinfer/main.go new file mode 100644 index 0000000..46bc3eb --- /dev/null +++ b/cmd/jwksetinfer/main.go @@ -0,0 +1,140 @@ +package main + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "log/slog" + "os" + "strconv" + "strings" + + "github.com/MicahParks/jwkset" +) + +const ( + logErr = "error" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + l := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + allPEM := os.Getenv("PEM") + if allPEM == "" { + s := strings.Builder{} + if len(os.Args) < 2 { + l.Error("Please provide a list of PEM encoded files as CLI arguments or set the PEM environment variable.") + os.Exit(1) + } + for _, fileName := range os.Args[1:] { + b, err := os.ReadFile(fileName) + if err != nil { + l.Error("Failed to read file.", + "fileName", fileName, + ) + os.Exit(1) + } + s.Write(bytes.TrimSpace(b)) + s.WriteRune('\n') + } + allPEM = s.String() + } + + jwks := jwkset.NewMemory() + + i := 0 + const kidPrefix = "UniqueKeyID" + metadata := jwkset.JWKMetadataOptions{} + allPEMB := []byte(allPEM) + for { + i++ + block, rest := pem.Decode(allPEMB) + if block == nil { + break + } + allPEMB = rest + switch block.Type { + case "CERTIFICATE": + cert, err := jwkset.LoadCertificate(block.Bytes) + if err != nil { + l.Error("Failed to load certificates.", + logErr, err, + ) + os.Exit(1) + } + metadata.KID = kidPrefix + strconv.Itoa(i) + x509Options := jwkset.JWKX509Options{ + X5C: []*x509.Certificate{cert}, + } + options := jwkset.JWKOptions{ + Metadata: metadata, + X509: x509Options, + } + jwk, err := jwkset.NewJWKFromX5C(options) + if err != nil { + l.Error("Failed to create JWK from X5C.", + logErr, err, + ) + os.Exit(1) + } + err = jwks.Store.WriteKey(ctx, jwk) + if err != nil { + l.Error("Failed to write JWK.", + logErr, err, + ) + os.Exit(1) + } + default: + key, err := jwkset.LoadX509KeyInfer(block) + if err != nil { + l.Error("Failed to load X509 key.", + logErr, err, + ) + os.Exit(1) + } + metadata.KID = kidPrefix + strconv.Itoa(i) + marshalOptions := jwkset.JWKMarshalOptions{ + Private: true, + } + options := jwkset.JWKOptions{ + Marshal: marshalOptions, + } + jwk, err := jwkset.NewJWKFromKey(key, options) + if err != nil { + l.Error("Failed to create JWK from key.", + logErr, err, + ) + os.Exit(1) + } + err = jwks.Store.WriteKey(ctx, jwk) + if err != nil { + l.Error("Failed to write JWK.", + logErr, err, + ) + os.Exit(1) + } + } + } + + marshal, err := jwks.Marshal(ctx) + if err != nil { + l.Error("Failed to marshal JWK set.", + logErr, err, + ) + os.Exit(1) + } + + b, err := json.MarshalIndent(marshal, "", " ") + if err != nil { + l.Error("Failed to marshal JSON.", + logErr, err, + ) + os.Exit(1) + } + + println(string(b)) +} diff --git a/constants.go b/constants.go index 1430c79..15e219e 100644 --- a/constants.go +++ b/constants.go @@ -1,78 +1,167 @@ package jwkset const ( - // AlgHS256 is the HMAC using SHA-256 algorithm. - AlgHS256 ALG = "HS256" - // AlgHS384 is the HMAC using SHA-384 algorithm. - AlgHS384 ALG = "HS384" - // AlgHS512 is the HMAC using SHA-512 algorithm. - AlgHS512 ALG = "HS512" - // AlgRS256 is the RSASSA-PKCS1-v1_5 using SHA-256 algorithm. - AlgRS256 ALG = "RS256" - // AlgRS384 is the RSASSA-PKCS1-v1_5 using SHA-384 algorithm. - AlgRS384 ALG = "RS384" - // AlgRS512 is the RSASSA-PKCS1-v1_5 using SHA-512 algorithm. - AlgRS512 ALG = "RS512" - // AlgES256 is the ECDSA using P-256 and SHA-256 algorithm. - AlgES256 ALG = "ES256" - // AlgES384 is the ECDSA using P-384 and SHA-384 algorithm. - AlgES384 ALG = "ES384" - // AlgES512 is the ECDSA using P-521 and SHA-512 algorithm. - AlgES512 ALG = "ES512" - // AlgPS256 is the RSASSA-PSS using SHA-256 and MGF1 with SHA-256 algorithm. - AlgPS256 ALG = "PS256" - // AlgPS384 is the RSASSA-PSS using SHA-384 and MGF1 with SHA-384 algorithm. - AlgPS384 ALG = "PS384" - // AlgPS512 is the RSASSA-PSS using SHA-512 and MGF1 with SHA-512 algorithm. - AlgPS512 ALG = "PS512" - // AlgNone is the No digital signature or MAC performed algorithm. - AlgNone ALG = "none" - // AlgEdDSA is the EdDSA algorithm. - AlgEdDSA ALG = "EdDSA" - - // KtyEC is the key type for ECDSA. - KtyEC KTY = "EC" - // KtyOKP is the key type for EdDSA. + // HeaderKID is a JWT header for the key ID. + HeaderKID = "kid" +) + +// These are string constants set in https://www.iana.org/assignments/jose/jose.xhtml +// See their respective types for more information. +const ( + AlgHS256 ALG = "HS256" + AlgHS384 ALG = "HS384" + AlgHS512 ALG = "HS512" + AlgRS256 ALG = "RS256" + AlgRS384 ALG = "RS384" + AlgRS512 ALG = "RS512" + AlgES256 ALG = "ES256" + AlgES384 ALG = "ES384" + AlgES512 ALG = "ES512" + AlgPS256 ALG = "PS256" + AlgPS384 ALG = "PS384" + AlgPS512 ALG = "PS512" + AlgNone ALG = "none" + AlgRSA1_5 ALG = "RSA1_5" + AlgRSAOAEP ALG = "RSA-OAEP" + AlgRSAOAEP256 ALG = "RSA-OAEP-256" + AlgA128KW ALG = "A128KW" + AlgA192KW ALG = "A192KW" + AlgA256KW ALG = "A256KW" + AlgDir ALG = "dir" + AlgECDHES ALG = "ECDH-ES" + AlgECDHESA128KW ALG = "ECDH-ES+A128KW" + AlgECDHESA192KW ALG = "ECDH-ES+A192KW" + AlgECDHESA256KW ALG = "ECDH-ES+A256KW" + AlgA128GCMKW ALG = "A128GCMKW" + AlgA192GCMKW ALG = "A192GCMKW" + AlgA256GCMKW ALG = "A256GCMKW" + AlgPBES2HS256A128KW ALG = "PBES2-HS256+A128KW" + AlgPBES2HS384A192KW ALG = "PBES2-HS384+A192KW" + AlgPBES2HS512A256KW ALG = "PBES2-HS512+A256KW" + AlgA128CBCHS256 ALG = "A128CBC-HS256" + AlgA192CBCHS384 ALG = "A192CBC-HS384" + AlgA256CBCHS512 ALG = "A256CBC-HS512" + AlgA128GCM ALG = "A128GCM" + AlgA192GCM ALG = "A192GCM" + AlgA256GCM ALG = "A256GCM" + AlgEdDSA ALG = "EdDSA" + AlgRS1 ALG = "RS1" // Prohibited. + AlgRSAOAEP384 ALG = "RSA-OAEP-384" + AlgRSAOAEP512 ALG = "RSA-OAEP-512" + AlgA128CBC ALG = "A128CBC" // Prohibited. + AlgA192CBC ALG = "A192CBC" // Prohibited. + AlgA256CBC ALG = "A256CBC" // Prohibited. + AlgA128CTR ALG = "A128CTR" // Prohibited. + AlgA192CTR ALG = "A192CTR" // Prohibited. + AlgA256CTR ALG = "A256CTR" // Prohibited. + AlgHS1 ALG = "HS1" // Prohibited. + AlgES256K ALG = "ES256K" + + CrvP256 CRV = "P-256" + CrvP384 CRV = "P-384" + CrvP521 CRV = "P-521" + CrvEd25519 CRV = "Ed25519" + CrvEd448 CRV = "Ed448" + CrvX25519 CRV = "X25519" + CrvX448 CRV = "X448" + CrvSECP256K1 CRV = "secp256k1" + + KeyOpsSign KEYOPS = "sign" + KeyOpsVerify KEYOPS = "verify" + KeyOpsEncrypt KEYOPS = "encrypt" + KeyOpsDecrypt KEYOPS = "decrypt" + KeyOpsWrapKey KEYOPS = "wrapKey" + KeyOpsUnwrapKey KEYOPS = "unwrapKey" + KeyOpsDeriveKey KEYOPS = "deriveKey" + KeyOpsDeriveBits KEYOPS = "deriveBits" + + KtyEC KTY = "EC" KtyOKP KTY = "OKP" - // KtyRSA is the key type for RSA. KtyRSA KTY = "RSA" - // KtyOct is the key type for octet sequences, such as HMAC. KtyOct KTY = "oct" - // CrvEd25519 is a curve for EdDSA. - CrvEd25519 CRV = "Ed25519" - // CrvP256 is a curve for ECDSA. - CrvP256 CRV = "P-256" - // CrvP384 is a curve for ECDSA. - CrvP384 CRV = "P-384" - // CrvP521 is a curve for ECDSA. - CrvP521 CRV = "P-521" - - // HeaderKID is a JWT header for the key ID. - HeaderKID = "kid" + UseEnc USE = "enc" + UseSig USE = "sig" ) // ALG is a set of "JSON Web Signature and Encryption Algorithms" types from -// https://www.iana.org/assignments/jose/jose.xhtml(JWA) as defined in +// https://www.iana.org/assignments/jose/jose.xhtml as defined in // https://www.rfc-editor.org/rfc/rfc7518#section-7.1 type ALG string +func (alg ALG) IANARegistered() bool { + switch alg { + case AlgHS256, AlgHS384, AlgHS512, AlgRS256, AlgRS384, AlgRS512, AlgES256, AlgES384, AlgES512, AlgPS256, AlgPS384, + AlgPS512, AlgNone, AlgRSA1_5, AlgRSAOAEP, AlgRSAOAEP256, AlgA128KW, AlgA192KW, AlgA256KW, AlgDir, AlgECDHES, + AlgECDHESA128KW, AlgECDHESA192KW, AlgECDHESA256KW, AlgA128GCMKW, AlgA192GCMKW, AlgA256GCMKW, + AlgPBES2HS256A128KW, AlgPBES2HS384A192KW, AlgPBES2HS512A256KW, AlgA128CBCHS256, AlgA192CBCHS384, + AlgA256CBCHS512, AlgA128GCM, AlgA192GCM, AlgA256GCM, AlgEdDSA, AlgRS1, AlgRSAOAEP384, AlgRSAOAEP512, AlgA128CBC, + AlgA192CBC, AlgA256CBC, AlgA128CTR, AlgA192CTR, AlgA256CTR, AlgHS1, AlgES256K, "": + return true + } + return false +} func (alg ALG) String() string { return string(alg) } // CRV is a set of "JSON Web Key Elliptic Curve" types from https://www.iana.org/assignments/jose/jose.xhtml as -// mentioned in https://www.rfc-editor.org/rfc/rfc7518.html#section-6.2.1.1. +// mentioned in https://www.rfc-editor.org/rfc/rfc7518.html#section-6.2.1.1 type CRV string +func (crv CRV) IANARegistered() bool { + switch crv { + case CrvP256, CrvP384, CrvP521, CrvEd25519, CrvEd448, CrvX25519, CrvX448, CrvSECP256K1, "": + return true + } + return false +} func (crv CRV) String() string { return string(crv) } +// KEYOPS is a set of "JSON Web Key Operations" from https://www.iana.org/assignments/jose/jose.xhtml as mentioned in +// https://www.rfc-editor.org/rfc/rfc7517#section-4.3 +type KEYOPS string + +func (keyopts KEYOPS) IANARegistered() bool { + switch keyopts { + case KeyOpsSign, KeyOpsVerify, KeyOpsEncrypt, KeyOpsDecrypt, KeyOpsWrapKey, KeyOpsUnwrapKey, KeyOpsDeriveKey, + KeyOpsDeriveBits: + return true + } + return false +} +func (keyopts KEYOPS) String() string { + return string(keyopts) +} + // KTY is a set of "JSON Web Key Types" from https://www.iana.org/assignments/jose/jose.xhtml as mentioned in // https://www.rfc-editor.org/rfc/rfc7517#section-4.1 type KTY string +func (kty KTY) IANARegistered() bool { + switch kty { + case KtyEC, KtyOKP, KtyRSA, KtyOct: + return true + } + return false +} func (kty KTY) String() string { return string(kty) } + +// USE is a set of "JSON Web Key Use" types from https://www.iana.org/assignments/jose/jose.xhtml as mentioned in +// https://www.rfc-editor.org/rfc/rfc7517#section-4.2 +type USE string + +func (use USE) IANARegistered() bool { + switch use { + case UseEnc, UseSig, "": + return true + } + return false +} +func (use USE) String() string { + return string(use) +} diff --git a/constants_test.go b/constants_test.go index 87bad32..98add4c 100644 --- a/constants_test.go +++ b/constants_test.go @@ -1,13 +1,61 @@ -package jwkset_test +package jwkset import ( "testing" - - "github.com/MicahParks/jwkset" ) -func TestALG_String(t *testing.T) { - if jwkset.AlgEdDSA.String() != "EdDSA" { - t.Errorf("Failed to get the string representation of the EdDSA algorithm.") +func TestALG(t *testing.T) { + a := AlgHS256 + if a.String() != string(a) { + t.Errorf("Failed to get proper string from String method.") + } +} + +func TestCRV(t *testing.T) { + c := CrvP256 + if c.String() != string(c) { + t.Errorf("Failed to get proper string from String method.") + } +} + +func TestKEYOPS(t *testing.T) { + k := KeyOpsSign + if k.String() != string(k) { + t.Errorf("Failed to get proper string from String method.") + } + if !k.IANARegistered() { + t.Errorf("Failed to validate valid KEYOPS.") + } + k = "invalid" + if k.IANARegistered() { + t.Errorf("Do not validate invalid KEYOPS.") + } +} + +func TestKTY(t *testing.T) { + k := KtyEC + if k.String() != string(k) { + t.Errorf("Failed to get proper string from String method.") + } + if !k.IANARegistered() { + t.Errorf("Failed to validate valid KTY.") + } + k = "invalid" + if k.IANARegistered() { + t.Errorf("Do not validate invalid KTY.") + } +} + +func TestUSE(t *testing.T) { + u := UseEnc + if u.String() != string(u) { + t.Errorf("Failed to get proper string from String method.") + } + if !u.IANARegistered() { + t.Errorf("Failed to validate valid USE.") + } + u = "invalid" + if u.IANARegistered() { + t.Errorf("Do not validate invalid USE.") } } diff --git a/error_test.go b/error_test.go index 0e63700..a3a8779 100644 --- a/error_test.go +++ b/error_test.go @@ -13,21 +13,18 @@ var ( errStorage = errors.New("storage error") ) -type storageError[CustomKeyMeta any] struct{} +type storageError struct{} -func (s storageError[CustomKeyMeta]) DeleteKey(ctx context.Context, keyID string) (ok bool, err error) { +func (s storageError) DeleteKey(_ context.Context, _ string) (ok bool, err error) { return false, errStorage } - -func (s storageError[CustomKeyMeta]) ReadKey(ctx context.Context, keyID string) (jwkset.KeyWithMeta[CustomKeyMeta], error) { - return jwkset.KeyWithMeta[CustomKeyMeta]{}, errStorage +func (s storageError) ReadKey(_ context.Context, _ string) (jwkset.JWK, error) { + return jwkset.JWK{}, errStorage } - -func (s storageError[CustomKeyMeta]) SnapshotKeys(ctx context.Context) ([]jwkset.KeyWithMeta[CustomKeyMeta], error) { +func (s storageError) SnapshotKeys(_ context.Context) ([]jwkset.JWK, error) { return nil, errStorage } - -func (s storageError[CustomKeyMeta]) WriteKey(ctx context.Context, meta jwkset.KeyWithMeta[CustomKeyMeta]) error { +func (s storageError) WriteKey(_ context.Context, _ jwkset.JWK) error { return errStorage } @@ -35,8 +32,8 @@ func TestStorageError(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - jwks := jwkset.NewMemory[any]() - jwks.Store = storageError[any]{} + jwks := jwkset.NewMemory() + jwks.Store = storageError{} _, err := jwks.JSONPublic(ctx) if err == nil { diff --git a/examples/http_server/go.mod b/examples/http_server/go.mod index b4e24f4..3145244 100644 --- a/examples/http_server/go.mod +++ b/examples/http_server/go.mod @@ -1,6 +1,6 @@ module httpserver -go 1.19 +go 1.21.4 replace github.com/MicahParks/jwkset => ../.. diff --git a/examples/http_server/main.go b/examples/http_server/main.go index b0c8ee5..1abe859 100644 --- a/examples/http_server/main.go +++ b/examples/http_server/main.go @@ -19,14 +19,30 @@ func main() { ctx := context.Background() logger := log.New(os.Stdout, "", 0) - jwkSet := jwkset.NewMemory[any]() + jwkSet := jwkset.NewMemory() + // Create an RSA key. key, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { logger.Fatalf(logFmt, "Failed to generate RSA key.", err) } - err = jwkSet.Store.WriteKey(ctx, jwkset.NewKey[any](key, "my-key-id")) + // Create the JWK options. + metadata := jwkset.JWKMetadataOptions{ + KID: "my-key-id", // Not technically required, but is required for JWK Set operations using this package. + } + options := jwkset.JWKOptions{ + Metadata: metadata, + } + + // Create the JWK from the key and options. + jwk, err := jwkset.NewJWKFromKey(key, options) + if err != nil { + logger.Fatalf(logFmt, "Failed to create JWK from key.", err) + } + + // Write the key to the JWK Set storage. + err = jwkSet.Store.WriteKey(ctx, jwk) if err != nil { logger.Fatalf(logFmt, "Failed to store RSA key.", err) } diff --git a/examples/individual_keys/main.go b/examples/individual_keys/main.go index 6056b4e..cd3d0f4 100644 --- a/examples/individual_keys/main.go +++ b/examples/individual_keys/main.go @@ -16,51 +16,36 @@ func main() { logger := log.New(os.Stdout, "", 0) // Create an EdDSA key. - _, private, err := ed25519.GenerateKey(rand.Reader) + public, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { logger.Fatalf(logFmt, "Failed to generate EdDSA key.", err) } - // Wrap the key in the appropriate Go type. - meta := jwkset.NewKey[any](private, "my-key-id") - - // Create the approrpiate options to include the private key material in the JSON representation. - options := jwkset.KeyMarshalOptions{ - AsymmetricPrivate: true, + // Create the JWK options. + metadata := jwkset.JWKMetadataOptions{ + KID: "my-key-id", // Not technically required, but is required for JWK Set operations using this package. + } + options := jwkset.JWKOptions{ + Metadata: metadata, } - // Marshal the key to a different Go type that can be serialized to JSON. - marshal, err := jwkset.KeyMarshal(meta, options) + // Create the JWK from the key and options. + jwk, err := jwkset.NewJWKFromKey(public, options) if err != nil { - logger.Fatalf(logFmt, "Failed to marshal key.", err) + logger.Fatalf(logFmt, "Failed to create JWK from key.", err) } - // Marshal the new type to JSON. - j, err := json.MarshalIndent(marshal, "", " ") + // Use the marshal type to marshal the key into a raw JSON. + j, err := json.MarshalIndent(jwk.Marshal(), "", " ") if err != nil { logger.Fatalf(logFmt, "Failed to marshal JSON.", err) } println(string(j)) - // Unmarshal the raw JSON into a Go type that can be deserialized into a key. - err = json.Unmarshal(j, &marshal) - if err != nil { - logger.Fatalf(logFmt, "Failed to unmarshal JSON.", err) - } - - // Create the appropriate options to include the private key material in the deserialization. - // - // If this option is not provided, the resulting key will be of the type ed25519.PublicKey. - unmarshalOptions := jwkset.KeyUnmarshalOptions{ - AsymmetricPrivate: true, - } - - // Convert the Go type back into a key with metadata. - meta, err = jwkset.KeyUnmarshal[any](marshal, unmarshalOptions) + // Create a new JWK from the raw JSON. + jwk, err = jwkset.NewJWKFromRawJSON(j, jwkset.JWKMarshalOptions{}, jwkset.JWKValidateOptions{}) if err != nil { - logger.Fatalf(logFmt, "Failed to unmarshal key.", err) + logger.Fatalf(logFmt, "Failed to create JWK from raw JSON.", err) } - - // Print the key ID. - println(meta.KeyID) + println(jwk.Marshal().KID) } diff --git a/examples/storage_operations/go.mod b/examples/storage_operations/go.mod index 5fe0b5b..6f985c6 100644 --- a/examples/storage_operations/go.mod +++ b/examples/storage_operations/go.mod @@ -1,10 +1,10 @@ module readme -go 1.19 +go 1.21.4 replace github.com/MicahParks/jwkset => ../.. require ( github.com/MicahParks/jwkset v0.0.1 - github.com/google/uuid v1.3.0 + github.com/google/uuid v1.4.0 ) diff --git a/examples/storage_operations/go.sum b/examples/storage_operations/go.sum index 3dfe1c9..8bf7db5 100644 --- a/examples/storage_operations/go.sum +++ b/examples/storage_operations/go.sum @@ -1,2 +1,3 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/examples/storage_operations/main.go b/examples/storage_operations/main.go index 00b7401..dfdc9ac 100644 --- a/examples/storage_operations/main.go +++ b/examples/storage_operations/main.go @@ -23,7 +23,7 @@ func main() { logger := log.New(os.Stdout, "", 0) // Create a new JWK Set using memory-backed storage. - jwkSet := jwkset.NewMemory[any]() + jwkSet := jwkset.NewMemory() // Create a new ECDSA key and store it in the JWK Set. ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -31,7 +31,11 @@ func main() { logger.Fatalf(logFmt, "Failed to generate ECDSA key.", err) } ecID := uuid.NewString() - err = jwkSet.Store.WriteKey(ctx, jwkset.NewKey[any](ec, ecID)) + jwk, err := newKeyDefaultOptions(ec, ecID) + if err != nil { + logger.Fatalf(logFmt, "Failed to create JWK from ECDSA key.", err) + } + err = jwkSet.Store.WriteKey(ctx, jwk) if err != nil { logger.Fatalf(logFmt, "Failed to store ECDSA key.", err) } @@ -42,7 +46,11 @@ func main() { logger.Fatalf(logFmt, "Failed to generate EdDSA key.", err) } edID := uuid.NewString() - err = jwkSet.Store.WriteKey(ctx, jwkset.NewKey[any](ed, edID)) + jwk, err = newKeyDefaultOptions(ed, edID) + if err != nil { + logger.Fatalf(logFmt, "Failed to create JWK from EdDSA key.", err) + } + err = jwkSet.Store.WriteKey(ctx, jwk) if err != nil { logger.Fatalf(logFmt, "Failed to store EdDSA key.", err) } @@ -53,7 +61,11 @@ func main() { logger.Fatalf(logFmt, "Failed to generate RSA key.", err) } rID := uuid.NewString() - err = jwkSet.Store.WriteKey(ctx, jwkset.NewKey[any](r, rID)) + jwk, err = newKeyDefaultOptions(r, rID) + if err != nil { + logger.Fatalf(logFmt, "Failed to create JWK from RSA key.", err) + } + err = jwkSet.Store.WriteKey(ctx, jwk) if err != nil { logger.Fatalf(logFmt, "Failed to store RSA key.", err) } @@ -61,10 +73,14 @@ func main() { // Create a new HMAC key and store it in the JWK Set. hmacSecret := []byte("my_hmac_secret") hid := uuid.NewString() - err = jwkSet.Store.WriteKey(ctx, jwkset.KeyWithMeta[any]{ - Key: hmacSecret, - KeyID: hid, - }) + jwk, err = newKeyDefaultOptions(hmacSecret, hid) + if err != nil { + logger.Fatalf(logFmt, "Failed to create JWK from HMAC key.", err) + } + err = jwkSet.Store.WriteKey(ctx, jwk) + if err != nil { + logger.Fatalf(logFmt, "Failed to store HMAC key.", err) + } // Print the JSON representation of the JWK Set. jsonRepresentation, err := jwkSet.JSONPublic(ctx) @@ -95,7 +111,11 @@ func main() { if err != nil { logger.Fatalf(logFmt, "Failed to generate ECDSA key.", err) } - err = jwkSet.Store.WriteKey(ctx, jwkset.NewKey[any](ec, uuid.NewString())) + jwk, err = newKeyDefaultOptions(ec, uuid.NewString()) + if err != nil { + logger.Fatalf(logFmt, "Failed to create JWK from ECDSA key.", err) + } + err = jwkSet.Store.WriteKey(ctx, jwk) if err != nil { logger.Fatalf(logFmt, "Failed to store ECDSA key.", err) } @@ -107,24 +127,38 @@ func main() { logger.Println(string(jsonRepresentation)) // Read the previously added EdDSA key from the JWK Set, the print its private key. - meta, err := jwkSet.Store.ReadKey(ctx, edID) + jwk, err = jwkSet.Store.ReadKey(ctx, edID) if err != nil { logger.Fatalf(logFmt, "Failed to read EdDSA key.", err) } - edKey, ok := meta.Key.(ed25519.PrivateKey) + edKey, ok := jwk.Key().(ed25519.PrivateKey) if !ok { logger.Fatalf(logFmt, "Failed to cast EdDSA key.", err) } logger.Printf("Retrieved EdDSA private key Base64RawURL: %s", base64.RawURLEncoding.EncodeToString(edKey)) // Read the previously added HMAC key from the JWK Set, the print it. - meta, err = jwkSet.Store.ReadKey(ctx, hid) + jwk, err = jwkSet.Store.ReadKey(ctx, hid) if err != nil { logger.Fatalf(logFmt, "Failed to read HMAC key.", err) } - hKey, ok := meta.Key.([]byte) + hKey, ok := jwk.Key().([]byte) if !ok { logger.Fatalf(logFmt, "Failed to cast HMAC key.", err) } logger.Printf("Retrieved HMAC secret: %s", hKey) } + +func newKeyDefaultOptions(key any, keyID string) (jwkset.JWK, error) { + marshal := jwkset.JWKMarshalOptions{ + Private: true, + } + metadata := jwkset.JWKMetadataOptions{ + KID: keyID, + } + options := jwkset.JWKOptions{ + Marshal: marshal, + Metadata: metadata, + } + return jwkset.NewJWKFromKey(key, options) +} diff --git a/go.mod b/go.mod index 9d97c5a..c71ded1 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/MicahParks/jwkset -go 1.19 +go 1.21.5 diff --git a/jwk.go b/jwk.go index 1878a2f..a91a10c 100644 --- a/jwk.go +++ b/jwk.go @@ -1,74 +1,372 @@ package jwkset import ( + "bytes" "context" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/x509" "encoding/json" "errors" "fmt" + "io" + "net/http" + "net/url" + "reflect" + "slices" + "time" ) -// KeyWithMeta is holds a Key and its metadata. -type KeyWithMeta[CustomKeyMeta any] struct { - ALG ALG - Custom CustomKeyMeta - Key interface{} - KeyID string +// JWK represents a JSON Web Key. +type JWK struct { + key any + marshal JWKMarshal + options JWKOptions } -// NewKey creates a new KeyWithMeta. -func NewKey[CustomKeyMeta any](key interface{}, keyID string) KeyWithMeta[CustomKeyMeta] { - return KeyWithMeta[CustomKeyMeta]{ - Key: key, - KeyID: keyID, +// JWKMarshalOptions are used to specify options for JSON marshaling a JWK. +type JWKMarshalOptions struct { + // Private is used to indicate that the JWK's private key material should be JSON marshaled and unmarshalled. This + // includes symmetric and asymmetric keys. Setting this to true is the only way to marshal and unmarshal symmetric + // keys. + Private bool +} + +// JWKX509Options holds the X.509 certificate information for a JWK. This data structure is not used for JSON marshaling. +type JWKX509Options struct { + // X5C contains a chain of one or more PKIX certificates. The PKIX certificate containing the key value MUST be the + // first certificate. + X5C []*x509.Certificate // The PKIX certificate containing the key value MUST be the first certificate. + + // X5T is calculated automatically. + // X5TS256 is calculated automatically. + + // X5U Is a URI that refers to a resource for an X.509 public key certificate or certificate chain. + X5U string // https://www.rfc-editor.org/rfc/rfc7517#section-4.6 +} + +// JWKValidateOptions are used to specify options for validating a JWK. +type JWKValidateOptions struct { + /* + This package intentionally does not confirm if certificate's usage or compare that to the JWK's use parameter. + Please open a GitHub issue if you think this should be an option. + */ + // CheckX509ValidTime is used to indicate that the X.509 certificate's valid time should be checked. + CheckX509ValidTime bool + // GetX5U is used to get and validate the X.509 certificate from the X5U URI. Use DefaultGetX5U for the default + // behavior. + GetX5U func(x5u *url.URL) ([]*x509.Certificate, error) + // SkipAll is used to skip all validation. + SkipAll bool + // SkipKeyOps is used to skip validation of the key operations (key_ops). + SkipKeyOps bool + // SkipMetadata skips checking if the JWKMetadataOptions match the JWKMarshal. + SkipMetadata bool + // SkipUse is used to skip validation of the key use (use). + SkipUse bool + // SkipX5UScheme is used to skip checking if the X5U URI scheme is https. + SkipX5UScheme bool +} + +// JWKMetadataOptions are direct passthroughs into the JWKMarshal. +type JWKMetadataOptions struct { + // ALG is the algorithm (alg). + ALG ALG + // KID is the key ID (kid). + KID string + // KEYOPS is the key operations (key_ops). + KEYOPS []KEYOPS + // USE is the key use (use). + USE USE +} + +// JWKOptions are used to specify options for marshaling a JSON Web Key. +type JWKOptions struct { + Marshal JWKMarshalOptions + Metadata JWKMetadataOptions + Validate JWKValidateOptions + X509 JWKX509Options +} + +// NewJWKFromKey uses the given key and options to create a JWK. It is possible to provide a private key with an X.509 +// certificate, which will be validated to contain the correct public key. +func NewJWKFromKey(key any, options JWKOptions) (JWK, error) { + marshal, err := keyMarshal(key, options) + if err != nil { + return JWK{}, fmt.Errorf("failed to marshal JSON Web Key: %w", err) + } + switch key.(type) { + case ed25519.PrivateKey, ed25519.PublicKey: + if options.Metadata.ALG == "" { + options.Metadata.ALG = AlgEdDSA + } else if options.Metadata.ALG != AlgEdDSA { + return JWK{}, fmt.Errorf("%w: invalid ALG for Ed25519 key: %q", ErrOptions, options.Metadata.ALG) + } + } + j := JWK{ + key: key, + marshal: marshal, + options: options, + } + err = j.Validate() + if err != nil { + return JWK{}, fmt.Errorf("failed to validate JSON Web Key: %w", err) } + return j, nil } -// JWKSet is a set of JSON Web Keys. -type JWKSet[CustomKeyMeta any] struct { - Store Storage[CustomKeyMeta] +// NewJWKFromRawJSON uses the given raw JSON to create a JWK. +func NewJWKFromRawJSON(j json.RawMessage, marshalOptions JWKMarshalOptions, validateOptions JWKValidateOptions) (JWK, error) { + marshal := JWKMarshal{} + err := json.Unmarshal(j, &marshal) + if err != nil { + return JWK{}, fmt.Errorf("failed to unmarshal JSON Web Key: %w", err) + } + return NewJWKFromMarshal(marshal, marshalOptions, validateOptions) } -// NewMemory creates a new in-memory JWKSet. -func NewMemory[CustomKeyMeta any]() JWKSet[CustomKeyMeta] { - return JWKSet[CustomKeyMeta]{ - Store: NewMemoryStorage[CustomKeyMeta](), +// NewJWKFromMarshal transforms a JWKMarshal into a JWK. +func NewJWKFromMarshal(marshal JWKMarshal, marshalOptions JWKMarshalOptions, validateOptions JWKValidateOptions) (JWK, error) { + j, err := keyUnmarshal(marshal, marshalOptions, validateOptions) + if err != nil { + return JWK{}, fmt.Errorf("failed to unmarshal JSON Web Key: %w", err) } + err = j.Validate() + if err != nil { + return JWK{}, fmt.Errorf("failed to validate JSON Web Key: %w", err) + } + return j, nil } -// JSONPublic creates the JSON representation of the public keys in JWKSet. -func (j JWKSet[CustomKeyMeta]) JSONPublic(ctx context.Context) (json.RawMessage, error) { - return j.JSONWithOptions(ctx, KeyMarshalOptions{}) +// NewJWKFromX5C uses the X.509 X5C information in the options to create a JWK. +func NewJWKFromX5C(options JWKOptions) (JWK, error) { + if len(options.X509.X5C) == 0 { + return JWK{}, fmt.Errorf("%w: no X.509 certificates provided", ErrOptions) + } + cert := options.X509.X5C[0] + marshal, err := keyMarshal(cert.PublicKey, options) + if err != nil { + return JWK{}, fmt.Errorf("failed to marshal JSON Web Key: %w", err) + } + + if cert.PublicKeyAlgorithm == x509.Ed25519 { + if options.Metadata.ALG != "" && options.Metadata.ALG != AlgEdDSA { + return JWK{}, fmt.Errorf("%w: ALG in metadata does not match ALG in X.509 certificate", errors.Join(ErrOptions, ErrX509Mismatch)) + } else { + options.Metadata.ALG = AlgEdDSA + } + } + + j := JWK{ + key: options.X509.X5C[0].PublicKey, + marshal: marshal, + options: options, + } + err = j.Validate() + if err != nil { + return JWK{}, fmt.Errorf("failed to validate JSON Web Key: %w", err) + } + return j, nil } -// JSONPrivate creates the JSON representation of the JWKSet public and private key material. -func (j JWKSet[CustomKeyMeta]) JSONPrivate(ctx context.Context) (json.RawMessage, error) { - options := KeyMarshalOptions{ - AsymmetricPrivate: true, - Symmetric: true, +// NewJWKFromX5U uses the X.509 X5U information in the options to create a JWK. +func NewJWKFromX5U(options JWKOptions) (JWK, error) { + if options.X509.X5U == "" { + return JWK{}, fmt.Errorf("%w: no X.509 URI provided", ErrOptions) + } + u, err := url.ParseRequestURI(options.X509.X5U) + if err != nil { + return JWK{}, fmt.Errorf("failed to parse X5U URI: %w", errors.Join(ErrOptions, err)) + } + if !options.Validate.SkipX5UScheme && u.Scheme != "https" { + return JWK{}, fmt.Errorf("%w: X5U URI scheme must be https", errors.Join(ErrOptions)) } - return j.JSONWithOptions(ctx, options) + get := options.Validate.GetX5U + if get == nil { + get = DefaultGetX5U + } + certs, err := get(u) + if err != nil { + return JWK{}, fmt.Errorf("failed to get X5U URI: %w", err) + } + options.X509.X5C = certs + jwk, err := NewJWKFromX5C(options) + if err != nil { + return JWK{}, fmt.Errorf("failed to create JWK from fetched X5U assets: %w", err) + } + return jwk, nil +} + +// Key returns the public or private cryptographic key associated with the JWK. +func (j JWK) Key() any { + return j.key +} + +// Marshal returns Go type that can be marshalled into JSON. +func (j JWK) Marshal() JWKMarshal { + return j.marshal +} + +// X509 returns the X.509 certificate information for the JWK. +func (j JWK) X509() JWKX509Options { + return j.options.X509 } -// JSONWithOptions creates the JSON representation of the JWKSet with the given options. -func (j JWKSet[CustomKeyMeta]) JSONWithOptions(ctx context.Context, options KeyMarshalOptions) (json.RawMessage, error) { - jwks := JWKSMarshal{} +// Validate validates the JWK. The JWK is automatically validated when created from a function in this package. +func (j JWK) Validate() error { + if j.options.Validate.SkipAll { + return nil + } + if !j.marshal.KTY.IANARegistered() { + return fmt.Errorf("%w: invalid or unsupported key type %q", ErrJWKValidation, j.marshal.KTY) + } + + if !j.options.Validate.SkipKeyOps { + for _, o := range j.marshal.KEYOPS { + if !o.IANARegistered() { + return fmt.Errorf("%w: invalid or unsupported key_opt %q", ErrJWKValidation, o) + } + } + } + + if !j.options.Validate.SkipUse && !j.marshal.USE.IANARegistered() { + return fmt.Errorf("%w: invalid or unsupported key use %q", ErrJWKValidation, j.marshal.USE) + } + + if !j.options.Validate.SkipMetadata { + if j.marshal.ALG != j.options.Metadata.ALG { + return fmt.Errorf("%w: ALG in marshal does not match ALG in options", errors.Join(ErrJWKValidation, ErrOptions)) + } + if j.marshal.KID != j.options.Metadata.KID { + return fmt.Errorf("%w: KID in marshal does not match KID in options", errors.Join(ErrJWKValidation, ErrOptions)) + } + if !slices.Equal(j.marshal.KEYOPS, j.options.Metadata.KEYOPS) { + return fmt.Errorf("%w: KEYOPS in marshal does not match KEYOPS in options", errors.Join(ErrJWKValidation, ErrOptions)) + } + if j.marshal.USE != j.options.Metadata.USE { + return fmt.Errorf("%w: USE in marshal does not match USE in options", errors.Join(ErrJWKValidation, ErrOptions)) + } + } - keys, err := j.Store.SnapshotKeys(ctx) + if len(j.options.X509.X5C) > 0 { + cert := j.options.X509.X5C[0] + i := cert.PublicKey + switch k := j.key.(type) { + // ECDH keys are not used to sign certificates. + case *ecdsa.PublicKey: + pub, ok := i.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("%w: Golang key is type *ecdsa.Public but X.509 public key was of type %T", errors.Join(ErrJWKValidation, ErrX509Mismatch), i) + } + if !k.Equal(pub) { + return fmt.Errorf("%w: Golang *ecdsa.PublicKey does not match the X.509 public key", errors.Join(ErrJWKValidation, ErrX509Mismatch)) + } + case ed25519.PublicKey: + pub, ok := i.(ed25519.PublicKey) + if !ok { + return fmt.Errorf("%w: Golang key is type ed25519.PublicKey but X.509 public key was of type %T", errors.Join(ErrJWKValidation, ErrX509Mismatch), i) + } + if !bytes.Equal(k, pub) { + return fmt.Errorf("%w: Golang ed25519.PublicKey does not match the X.509 public key", errors.Join(ErrJWKValidation, ErrX509Mismatch)) + } + case *rsa.PublicKey: + pub, ok := i.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("%w: Golang key is type *rsa.PublicKey but X.509 public key was of type %T", errors.Join(ErrJWKValidation, ErrX509Mismatch), i) + } + if !k.Equal(pub) { + return fmt.Errorf("%w: Golang *rsa.PublicKey does not match the X.509 public key", errors.Join(ErrJWKValidation, ErrX509Mismatch)) + } + default: + 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) + } + if cert.PublicKeyAlgorithm == x509.Ed25519 { + if j.marshal.ALG != AlgEdDSA { + return fmt.Errorf("%w: ALG in marshal does not match ALG in X.509 certificate", errors.Join(ErrJWKValidation, ErrX509Mismatch)) + } + } + if j.options.Validate.CheckX509ValidTime { + now := time.Now() + if now.Before(cert.NotBefore) { + return fmt.Errorf("%w: X.509 certificate is not yet valid", ErrJWKValidation) + } + if now.After(cert.NotAfter) { + return fmt.Errorf("%w: X.509 certificate is expired", ErrJWKValidation) + } + } + } + + marshalled, err := keyMarshal(j.key, j.options) if err != nil { - return nil, fmt.Errorf("failed to read snapshot of all keys from storage: %w", err) + return fmt.Errorf("failed to marshal JSON Web Key: %w", errors.Join(ErrJWKValidation, err)) + } + + ok := reflect.DeepEqual(j.marshal, marshalled) + if !ok { + return fmt.Errorf("%w: marshaled JWK does not match original JWK", ErrJWKValidation) } - for _, meta := range keys { - jwk, err := KeyMarshal(meta, options) + if j.marshal.X5U != "" || j.options.X509.X5U != "" { + if j.marshal.X5U != j.options.X509.X5U { + return fmt.Errorf("%w: X5U in marshal does not match X5U in options", errors.Join(ErrJWKValidation, ErrOptions)) + } + u, err := url.ParseRequestURI(j.marshal.X5U) if err != nil { - if errors.Is(err, ErrUnsupportedKeyType) { - // Ignore the key. - continue + return fmt.Errorf("failed to parse X5U URI: %w", errors.Join(ErrJWKValidation, ErrOptions, err)) + } + if !j.options.Validate.SkipX5UScheme && u.Scheme != "https" { + return fmt.Errorf("%w: X5U URI scheme must be https", errors.Join(ErrJWKValidation, ErrOptions)) + } + if j.options.Validate.GetX5U != nil { + certs, err := j.options.Validate.GetX5U(u) + if err != nil { + return fmt.Errorf("failed to get X5U URI: %w", errors.Join(ErrJWKValidation, ErrOptions, err)) + } + if len(certs) == 0 { + return fmt.Errorf("%w: X5U URI did not return any certificates", errors.Join(ErrJWKValidation, ErrOptions)) + } + larger := certs + smaller := j.options.X509.X5C + if len(j.options.X509.X5C) > len(certs) { + larger = j.options.X509.X5C + smaller = certs + } + for i, c := range smaller { + if !c.Equal(larger[i]) { + return fmt.Errorf("%w: the X5C and X5U (remote resource) parameters are not a full or partial match", errors.Join(ErrJWKValidation, ErrOptions)) + } } - return nil, fmt.Errorf("failed to marshal key: %w", err) } - jwks.Keys = append(jwks.Keys, jwk) } - return json.Marshal(jwks) + return nil +} + +// DefaultGetX5U is the default implementation of the GetX5U field for JWKValidateOptions. +func DefaultGetX5U(u *url.URL) ([]*x509.Certificate, error) { + timeout := time.Minute + ctx, cancel := context.WithTimeoutCause(context.Background(), timeout, fmt.Errorf("%w: timeout of %s reached", ErrGetX5U, timeout.String())) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create X5U request: %w", errors.Join(ErrGetX5U, err)) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to do X5U request: %w", errors.Join(ErrGetX5U, err)) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: X5U request returned status code %d", ErrGetX5U, resp.StatusCode) + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read X5U response body: %w", errors.Join(ErrGetX5U, err)) + } + certs, err := LoadCertificates(b) + if err != nil { + return nil, fmt.Errorf("failed to parse X5U response body: %w", errors.Join(ErrGetX5U, err)) + } + return certs, nil } diff --git a/jwk_test.go b/jwk_test.go index bb431c4..6597c28 100644 --- a/jwk_test.go +++ b/jwk_test.go @@ -2,9 +2,8 @@ package jwkset_test import ( "context" - "crypto/ecdsa" + "crypto/ecdh" "crypto/ed25519" - "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/json" @@ -15,22 +14,46 @@ import ( "github.com/MicahParks/jwkset" ) +func TestNewJWKFromRawJSON(t *testing.T) { + marshalOptions := jwkset.JWKMarshalOptions{ + Private: true, + } + jwk, err := jwkset.NewJWKFromRawJSON([]byte(edExpected), marshalOptions, jwkset.JWKValidateOptions{}) + if err != nil { + t.Fatalf("Failed to create JWK from raw JSON. %s", err) + } + if jwk.Marshal().KID != edID { + t.Fatalf("Incorrect KID. %s", jwk.Marshal().KID) + } + + _, err = jwkset.NewJWKFromRawJSON([]byte("invalid"), jwkset.JWKMarshalOptions{}, jwkset.JWKValidateOptions{}) + if err == nil { + t.Fatal("Expected an error.") + } +} + func TestJSON(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - jwks := jwkset.NewMemory[any]() + jwks := jwkset.NewMemory() + + b, err := base64.RawURLEncoding.DecodeString(x25519PrivateKey) + if err != nil { + t.Fatalf("Failed to decode ECDH X25519 private key. %s", err) + } + x25519Priv, err := ecdh.X25519().NewPrivateKey(b) + if err != nil { + t.Fatalf("Failed to generate ECDH X25519 key. %s", err) + } + writeKey(ctx, t, jwks, x25519Priv, x25519ID, false) block, _ := pem.Decode([]byte(ecPrivateKey)) eKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { t.Fatalf("Failed to parse EC private key. %s", err) } - const eID = "myECKey" - err = jwks.Store.WriteKey(ctx, jwkset.NewKey[any](eKey.(*ecdsa.PrivateKey), eID)) - if err != nil { - t.Fatalf("Failed to write EC key. %s", err) - } + writeKey(ctx, t, jwks, eKey, eID, false) edPriv, err := base64.RawURLEncoding.DecodeString(edPrivateKey) if err != nil { @@ -41,11 +64,7 @@ func TestJSON(t *testing.T) { t.Fatalf("Failed to decode EdDSA public key. %s", err) } ed := ed25519.PrivateKey(append(edPriv, edPub...)) - const edID = "myEdDSAKey" - err = jwks.Store.WriteKey(ctx, jwkset.NewKey[any](ed, edID)) - if err != nil { - t.Fatalf("Failed to write EdDSA key. %s", err) - } + writeKey(ctx, t, jwks, ed, edID, false) block, _ = pem.Decode([]byte(rsaPrivateKey)) rKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) @@ -53,20 +72,10 @@ func TestJSON(t *testing.T) { t.Fatalf("Failed to parse RSA private key. %s", err) } const rID = "myRSAKey" - err = jwks.Store.WriteKey(ctx, jwkset.NewKey[any](rKey.(*rsa.PrivateKey), rID)) - if err != nil { - t.Fatalf("Failed to write RSA key. %s", err) - } + writeKey(ctx, t, jwks, rKey, rID, false) hKey := []byte(hmacSecret) - const hID = "myHMACKey" - err = jwks.Store.WriteKey(ctx, jwkset.KeyWithMeta[any]{ - Key: hKey, - KeyID: hID, - }) - if err != nil { - t.Fatalf("Failed to write HMAC key. %s", err) - } + writeKey(ctx, t, jwks, hKey, hID, true) jsonRepresentation, err := jwks.JSONPublic(ctx) if err != nil { @@ -79,11 +88,24 @@ func TestJSON(t *testing.T) { t.Fatalf("Failed to get JSON. %s", err) } compareJSON(t, jsonRepresentation, true) + + jwks = jwkset.NewMemory() + writeKey(ctx, t, jwks, x25519Priv, x25519ID, true) + writeKey(ctx, t, jwks, eKey, eID, true) + writeKey(ctx, t, jwks, ed, edID, true) + writeKey(ctx, t, jwks, rKey, rID, true) + writeKey(ctx, t, jwks, hKey, hID, true) + + jsonRepresentation, err = jwks.JSON(ctx) + if err != nil { + t.Fatalf("Failed to get JSON. %s", err) + } + compareJSON(t, jsonRepresentation, true) } func compareJSON(t *testing.T, actual json.RawMessage, private bool) { type jwksUnmarshal struct { - Keys []map[string]interface{} `json:"keys"` + Keys []map[string]any `json:"keys"` } var keys jwksUnmarshal @@ -93,9 +115,9 @@ func compareJSON(t *testing.T, actual json.RawMessage, private bool) { } wrongLength := false - if private && len(keys.Keys) != 4 { + if private && len(keys.Keys) != 5 { wrongLength = true - } else if !private && len(keys.Keys) != 3 { + } else if !private && len(keys.Keys) != 4 { wrongLength = true } if wrongLength { @@ -118,11 +140,19 @@ func compareJSON(t *testing.T, actual json.RawMessage, private bool) { matchingAttributes = append(matchingAttributes, "d") } case jwkset.KtyOKP: - expectedJSON = json.RawMessage(edExpected) - matchingAttributes = []string{"kty", "kid", "x"} + matchingAttributes = []string{"crv", "kty", "kid", "x"} if private { matchingAttributes = append(matchingAttributes, "d") } + switch jwkset.CRV(key["crv"].(string)) { + case jwkset.CrvEd25519: + matchingAttributes = append(matchingAttributes, "alg") + expectedJSON = json.RawMessage(edExpected) + case jwkset.CrvX25519: + expectedJSON = json.RawMessage(x25519Expected) + default: + t.Fatalf("Unknown OKP curve %q.", key["crv"].(string)) + } case jwkset.KtyRSA: expectedJSON = json.RawMessage(rsaExpected) matchingAttributes = []string{"kty", "kid", "n", "e"} @@ -137,7 +167,7 @@ func compareJSON(t *testing.T, actual json.RawMessage, private bool) { t.Fatal("HMAC keys should not have a JSON representation.") } } - var expectedMap map[string]interface{} + var expectedMap map[string]any err = json.Unmarshal(expectedJSON, &expectedMap) if err != nil { t.Fatalf("Failed to unmarshal expected JSON. %s", err) @@ -159,12 +189,48 @@ func compareJSON(t *testing.T, actual json.RawMessage, private bool) { } } +func writeKey(ctx context.Context, t *testing.T, jwks jwkset.JWKSet, key any, keyID string, private bool) { + marshal := jwkset.JWKMarshalOptions{ + Private: private, + } + metadata := jwkset.JWKMetadataOptions{ + KID: keyID, + } + options := jwkset.JWKOptions{ + Marshal: marshal, + Metadata: metadata, + } + jwk, err := jwkset.NewJWKFromKey(key, options) + if err != nil { + t.Fatalf("Failed to create JWK from key ID %q. %s", keyID, err) + } + err = jwks.Store.WriteKey(ctx, jwk) + if err != nil { + t.Fatalf("Failed to write key ID %q. %s", keyID, err) + } +} + +const ( + x25519ID = "myX25519Key" + eID = "myECKey" + edID = "myEdDSAKey" + hID = "myHMACKey" +) + /* These assets were generated using this tool: https://mkjwk.org/ */ const ( - ecExpected = `{ + x25519Expected = `{ + "kty": "OKP", + "d": "GIu7AbclXA1FtVswPBUileBckbJu2B9UUhZPTebrox4", + "crv": "X25519", + "kid": "myX25519Key", + "x": "fGMcCrO_gWS7rva_PpXiS7D5-2OppjZQLlZmdRUSN0g" +}` + x25519PrivateKey = `GIu7AbclXA1FtVswPBUileBckbJu2B9UUhZPTebrox4` + ecExpected = `{ "kty": "EC", "d": "Vp3epfDd9viOo1w6Co7DpIP2lPnqwIB8HcOrI7Jt0II", "crv": "P-256", @@ -177,6 +243,7 @@ MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCBWnd6l8N32+I6jXDoK jsOkg/aU+erAgHwdw6sjsm3Qgg== -----END PRIVATE KEY-----` edExpected = `{ + "alg": "EdDSA", "kty": "OKP", "d": "tKqo1bnSif18g2hE0D7zPDNgSTKQKwBMEl2UvhJZ-bs", "crv": "Ed25519", diff --git a/jwkset.go b/jwkset.go new file mode 100644 index 0000000..bb4b542 --- /dev/null +++ b/jwkset.go @@ -0,0 +1,91 @@ +package jwkset + +import ( + "context" + "encoding/json" + "errors" + "fmt" +) + +// JWKSet is a set of JSON Web Keys. +type JWKSet struct { + Store Storage +} + +// NewMemory creates a new in-memory JWKSet. +func NewMemory() JWKSet { + return JWKSet{ + Store: NewMemoryStorage(), + } +} + +func (j JWKSet) JSON(ctx context.Context) (json.RawMessage, error) { + jwks, err := j.Marshal(ctx) + if err != nil { + return nil, fmt.Errorf("failed to marshal JWK Set: %w", err) + } + return json.Marshal(jwks) +} + +// JSONPublic creates the JSON representation of the public keys in JWKSet. +func (j JWKSet) JSONPublic(ctx context.Context) (json.RawMessage, error) { + return j.JSONWithOptions(ctx, JWKMarshalOptions{}, JWKValidateOptions{}) +} + +// JSONPrivate creates the JSON representation of the JWKSet public and private key material. +func (j JWKSet) JSONPrivate(ctx context.Context) (json.RawMessage, error) { + marshalOptions := JWKMarshalOptions{ + Private: true, + } + return j.JSONWithOptions(ctx, marshalOptions, JWKValidateOptions{}) +} + +// JSONWithOptions creates the JSON representation of the JWKSet with the given options. These options override whatever +// options are set on the individual JWKs. +func (j JWKSet) JSONWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (json.RawMessage, error) { + jwks, err := j.MarshalWithOptions(ctx, marshalOptions, validationOptions) + if err != nil { + return nil, fmt.Errorf("failed to marshal JWK Set with options: %w", err) + } + return json.Marshal(jwks) +} + +// Marshal transforms the JWK Set's current state into a Go type that can be marshaled into JSON. +func (j JWKSet) Marshal(ctx context.Context) (JWKSMarshal, error) { + keys, err := j.Store.SnapshotKeys(ctx) + if err != nil { + return JWKSMarshal{}, fmt.Errorf("failed to read snapshot of all keys from storage: %w", err) + } + jwks := JWKSMarshal{} + for _, key := range keys { + jwks.Keys = append(jwks.Keys, key.Marshal()) + } + return jwks, nil +} + +// MarshalWithOptions transforms the JWK Set's current state into a Go type that can be marshaled into JSON with the +// given options. These options override whatever options are set on the individual JWKs. +func (j JWKSet) MarshalWithOptions(ctx context.Context, marshalOptions JWKMarshalOptions, validationOptions JWKValidateOptions) (JWKSMarshal, error) { + jwks := JWKSMarshal{} + + keys, err := j.Store.SnapshotKeys(ctx) + if err != nil { + return JWKSMarshal{}, fmt.Errorf("failed to read snapshot of all keys from storage: %w", err) + } + + for _, key := range keys { + options := key.options + options.Marshal = marshalOptions + options.Validate = validationOptions + marshal, err := keyMarshal(key.Key(), options) + if err != nil { + if errors.Is(err, ErrOptions) { + continue + } + return JWKSMarshal{}, fmt.Errorf("failed to marshal key: %w", err) + } + jwks.Keys = append(jwks.Keys, marshal) + } + + return jwks, nil +} diff --git a/marshal.go b/marshal.go index 325fc42..e7dbef4 100644 --- a/marshal.go +++ b/marshal.go @@ -1,22 +1,35 @@ package jwkset import ( + "crypto/ecdh" "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" "encoding/base64" "errors" "fmt" "math/big" + "slices" "strings" ) var ( + // ErrGetX5U indicates there was an error getting the X5U remote resource. + ErrGetX5U = errors.New("failed to get X5U via given URI") + // ErrJWKValidation indicates that a JWK failed to validate. + ErrJWKValidation = errors.New("failed to validate JWK") // ErrKeyUnmarshalParameter indicates that a JWK's attributes are invalid and cannot be unmarshaled. ErrKeyUnmarshalParameter = errors.New("unable to unmarshal JWK due to invalid attributes") - // ErrUnsupportedKeyType indicates a key type is not supported. - ErrUnsupportedKeyType = errors.New("unsupported key type") + // ErrOptions indicates that the given options caused an error. + ErrOptions = errors.New("the given options caused an error") + // ErrUnsupportedKey indicates a key is not supported. + ErrUnsupportedKey = errors.New("unsupported key") + // ErrX509Mismatch indicates that the X.509 certificate does not match the key. + ErrX509Mismatch = errors.New("the X.509 certificate does not match Golang key type") ) // OtherPrimes is for RSA private keys that have more than 2 primes. @@ -31,29 +44,31 @@ type OtherPrimes struct { // https://www.rfc-editor.org/rfc/rfc7517 // https://www.rfc-editor.org/rfc/rfc7518 // https://www.rfc-editor.org/rfc/rfc8037 +// +// You can find the full list at https://www.iana.org/assignments/jose/jose.xhtml under "JSON Web Key Parameters". type JWKMarshal struct { - // TODO Check that ALG field is utilized fully. - ALG ALG `json:"alg,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.4 and https://www.rfc-editor.org/rfc/rfc7518#section-4.1 - CRV CRV `json:"crv,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.1 and https://www.rfc-editor.org/rfc/rfc8037.html#section-2 - D string `json:"d,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.1 and https://www.rfc-editor.org/rfc/rfc7518#section-6.2.2.1 and https://www.rfc-editor.org/rfc/rfc8037.html#section-2 - DP string `json:"dp,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.4 - DQ string `json:"dq,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.5 - E string `json:"e,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.1.2 - K string `json:"k,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.4.1 - // TODO Use KEYOPS field. - // KEYOPTS []string `json:"key_ops,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.3 - KID string `json:"kid,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.5 - KTY KTY `json:"kty,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.1 - N string `json:"n,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.1.1 - OTH []OtherPrimes `json:"oth,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7 - P string `json:"p,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.2 - Q string `json:"q,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.3 - QI string `json:"qi,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.6 - // TODO Use USE field. - // USE USE `json:"use,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.2 - X string `json:"x,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.2 and https://www.rfc-editor.org/rfc/rfc8037.html#section-2 - // TODO X.509 related fields. - Y string `json:"y,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.3 + KTY KTY `json:"kty,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.1 + USE USE `json:"use,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.2 + KEYOPS []KEYOPS `json:"key_ops,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.3 + ALG ALG `json:"alg,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.4 and https://www.rfc-editor.org/rfc/rfc7518#section-4.1 + KID string `json:"kid,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.5 + X5U string `json:"x5u,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.6 + X5C []string `json:"x5c,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.7 + X5T string `json:"x5t,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.8 + X5TS256 string `json:"x5t#S256,omitempty"` // https://www.rfc-editor.org/rfc/rfc7517#section-4.9 + CRV CRV `json:"crv,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.1 and https://www.rfc-editor.org/rfc/rfc8037.html#section-2 + X string `json:"x,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.2 and https://www.rfc-editor.org/rfc/rfc8037.html#section-2 + Y string `json:"y,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.2.1.3 + D string `json:"d,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.1 and https://www.rfc-editor.org/rfc/rfc7518#section-6.2.2.1 and https://www.rfc-editor.org/rfc/rfc8037.html#section-2 + N string `json:"n,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.1.1 + E string `json:"e,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.1.2 + P string `json:"p,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.2 + Q string `json:"q,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.3 + DP string `json:"dp,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.4 + DQ string `json:"dq,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.5 + QI string `json:"qi,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.6 + OTH []OtherPrimes `json:"oth,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.3.2.7 + K string `json:"k,omitempty"` // https://www.rfc-editor.org/rfc/rfc7518#section-6.4.1 } // JWKSMarshal is used to marshal or unmarshal a JSON Web Key Set. @@ -61,60 +76,68 @@ type JWKSMarshal struct { Keys []JWKMarshal `json:"keys"` } -// KeyMarshalOptions are used to specify options for marshaling a JSON Web Key. -type KeyMarshalOptions struct { - AsymmetricPrivate bool - Symmetric bool -} - -// KeyMarshal transforms a KeyWithMeta into a JWKMarshal, which is used to marshal/unmarshal a JSON Web Key. -func KeyMarshal[CustomKeyMeta any](meta KeyWithMeta[CustomKeyMeta], options KeyMarshalOptions) (JWKMarshal, error) { - var jwk JWKMarshal - switch key := meta.Key.(type) { +func keyMarshal(key any, options JWKOptions) (JWKMarshal, error) { + m := JWKMarshal{} + m.ALG = options.Metadata.ALG + switch key := key.(type) { + case *ecdh.PublicKey: + pub := key.Bytes() + m.CRV = CrvX25519 + m.X = base64.RawURLEncoding.EncodeToString(pub) + m.KTY = KtyOKP + case *ecdh.PrivateKey: + pub := key.PublicKey().Bytes() + m.CRV = CrvX25519 + m.X = base64.RawURLEncoding.EncodeToString(pub) + m.KTY = KtyOKP + if options.Marshal.Private { + priv := key.Bytes() + m.D = base64.RawURLEncoding.EncodeToString(priv) + } case *ecdsa.PrivateKey: pub := key.PublicKey - jwk.CRV = CRV(pub.Curve.Params().Name) - jwk.X = bigIntToBase64RawURL(pub.X) - jwk.Y = bigIntToBase64RawURL(pub.Y) - jwk.KTY = KtyEC - if options.AsymmetricPrivate { - jwk.D = bigIntToBase64RawURL(key.D) + m.CRV = CRV(pub.Curve.Params().Name) + m.X = bigIntToBase64RawURL(pub.X) + m.Y = bigIntToBase64RawURL(pub.Y) + m.KTY = KtyEC + if options.Marshal.Private { + m.D = bigIntToBase64RawURL(key.D) } case *ecdsa.PublicKey: - jwk.CRV = CRV(key.Curve.Params().Name) - jwk.X = bigIntToBase64RawURL(key.X) - jwk.Y = bigIntToBase64RawURL(key.Y) - jwk.KTY = KtyEC + m.CRV = CRV(key.Curve.Params().Name) + m.X = bigIntToBase64RawURL(key.X) + m.Y = bigIntToBase64RawURL(key.Y) + m.KTY = KtyEC case ed25519.PrivateKey: pub := key.Public().(ed25519.PublicKey) - jwk.ALG = AlgEdDSA - jwk.CRV = CrvEd25519 - jwk.X = base64.RawURLEncoding.EncodeToString(pub) - jwk.KTY = KtyOKP - if options.AsymmetricPrivate { - jwk.D = base64.RawURLEncoding.EncodeToString(key[:32]) + m.ALG = AlgEdDSA + m.CRV = CrvEd25519 + m.X = base64.RawURLEncoding.EncodeToString(pub) + m.KTY = KtyOKP + if options.Marshal.Private { + m.D = base64.RawURLEncoding.EncodeToString(key[:32]) } case ed25519.PublicKey: - jwk.ALG = AlgEdDSA - jwk.CRV = CrvEd25519 - jwk.X = base64.RawURLEncoding.EncodeToString(key) - jwk.KTY = KtyOKP + m.ALG = AlgEdDSA + m.CRV = CrvEd25519 + m.X = base64.RawURLEncoding.EncodeToString(key) + m.KTY = KtyOKP case *rsa.PrivateKey: pub := key.PublicKey - jwk.E = bigIntToBase64RawURL(big.NewInt(int64(pub.E))) - jwk.N = bigIntToBase64RawURL(pub.N) - jwk.KTY = KtyRSA - if options.AsymmetricPrivate { - jwk.D = bigIntToBase64RawURL(key.D) - jwk.P = bigIntToBase64RawURL(key.Primes[0]) - jwk.Q = bigIntToBase64RawURL(key.Primes[1]) - jwk.DP = bigIntToBase64RawURL(key.Precomputed.Dp) - jwk.DQ = bigIntToBase64RawURL(key.Precomputed.Dq) - jwk.QI = bigIntToBase64RawURL(key.Precomputed.Qinv) + m.E = bigIntToBase64RawURL(big.NewInt(int64(pub.E))) + m.N = bigIntToBase64RawURL(pub.N) + m.KTY = KtyRSA + if options.Marshal.Private { + m.D = bigIntToBase64RawURL(key.D) + m.P = bigIntToBase64RawURL(key.Primes[0]) + m.Q = bigIntToBase64RawURL(key.Primes[1]) + m.DP = bigIntToBase64RawURL(key.Precomputed.Dp) + m.DQ = bigIntToBase64RawURL(key.Precomputed.Dq) + m.QI = bigIntToBase64RawURL(key.Precomputed.Qinv) if len(key.Precomputed.CRTValues) > 0 { - jwk.OTH = make([]OtherPrimes, len(key.Precomputed.CRTValues)) + m.OTH = make([]OtherPrimes, len(key.Precomputed.CRTValues)) for i := 0; i < len(key.Precomputed.CRTValues); i++ { - jwk.OTH[i] = OtherPrimes{ + m.OTH[i] = OtherPrimes{ D: bigIntToBase64RawURL(key.Precomputed.CRTValues[i].Exp), T: bigIntToBase64RawURL(key.Precomputed.CRTValues[i].Coeff), R: bigIntToBase64RawURL(key.Primes[i+2]), @@ -123,54 +146,59 @@ func KeyMarshal[CustomKeyMeta any](meta KeyWithMeta[CustomKeyMeta], options KeyM } } case *rsa.PublicKey: - jwk.E = bigIntToBase64RawURL(big.NewInt(int64(key.E))) - jwk.N = bigIntToBase64RawURL(key.N) - jwk.KTY = KtyRSA + m.E = bigIntToBase64RawURL(big.NewInt(int64(key.E))) + m.N = bigIntToBase64RawURL(key.N) + m.KTY = KtyRSA case []byte: - if options.Symmetric { - jwk.KTY = KtyOct - jwk.K = base64.RawURLEncoding.EncodeToString(key) + if options.Marshal.Private { + m.KTY = KtyOct + m.K = base64.RawURLEncoding.EncodeToString(key) } else { - return JWKMarshal{}, fmt.Errorf("%w: incorrect options to marshal symmetric key (oct)", ErrUnsupportedKeyType) + return JWKMarshal{}, fmt.Errorf("%w: incorrect options to marshal symmetric key (oct)", ErrOptions) } default: - return JWKMarshal{}, fmt.Errorf("%w: %T", ErrUnsupportedKeyType, key) + return JWKMarshal{}, fmt.Errorf("%w: %T", ErrUnsupportedKey, key) } - if meta.ALG != "" { - jwk.ALG = meta.ALG + haveX5C := len(options.X509.X5C) > 0 + if haveX5C { + for i, cert := range options.X509.X5C { + m.X5C = append(m.X5C, base64.StdEncoding.EncodeToString(cert.Raw)) + if i == 0 { + h1 := sha1.Sum(cert.Raw) + m.X5T = base64.RawURLEncoding.EncodeToString(h1[:]) + h256 := sha256.Sum256(cert.Raw) + m.X5TS256 = base64.RawURLEncoding.EncodeToString(h256[:]) + } + } } - jwk.KID = meta.KeyID - return jwk, nil -} - -// KeyUnmarshalOptions are used to specify options for unmarshaling a JSON Web Key. -type KeyUnmarshalOptions struct { - AsymmetricPrivate bool - Symmetric bool + m.KID = options.Metadata.KID + m.KEYOPS = options.Metadata.KEYOPS + m.USE = options.Metadata.USE + m.X5U = options.X509.X5U + return m, nil } -// KeyUnmarshal transforms a JWKMarshal into a KeyWithMeta, which contains the correct Go type for the cryptographic -// key. -func KeyUnmarshal[CustomKeyMeta any](jwk JWKMarshal, options KeyUnmarshalOptions) (KeyWithMeta[CustomKeyMeta], error) { - var meta KeyWithMeta[CustomKeyMeta] - switch jwk.KTY { +func keyUnmarshal(marshal JWKMarshal, options JWKMarshalOptions, validateOptions JWKValidateOptions) (JWK, error) { + marshalCopy := JWKMarshal{} + var key any + switch marshal.KTY { case KtyEC: - if jwk.CRV == "" || jwk.X == "" || jwk.Y == "" { - return meta, fmt.Errorf(`%w: %s requires parameters "crv", "x", and "y"`, ErrKeyUnmarshalParameter, KtyEC) + if marshal.CRV == "" || marshal.X == "" || marshal.Y == "" { + return JWK{}, fmt.Errorf(`%w: %s requires parameters "crv", "x", and "y"`, ErrKeyUnmarshalParameter, KtyEC) } - x, err := base64urlTrailingPadding(jwk.X) + x, err := base64urlTrailingPadding(marshal.X) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "x": %w`, KtyEC, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "x": %w`, KtyEC, err) } - y, err := base64urlTrailingPadding(jwk.Y) + y, err := base64urlTrailingPadding(marshal.Y) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "y": %w`, KtyEC, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "y": %w`, KtyEC, err) } publicKey := &ecdsa.PublicKey{ X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y), } - switch jwk.CRV { + switch marshal.CRV { case CrvP256: publicKey.Curve = elliptic.P256() case CrvP384: @@ -178,111 +206,146 @@ func KeyUnmarshal[CustomKeyMeta any](jwk JWKMarshal, options KeyUnmarshalOptions case CrvP521: publicKey.Curve = elliptic.P521() default: - return meta, fmt.Errorf("%w: unsupported curve type %q", ErrKeyUnmarshalParameter, jwk.CRV) + return JWK{}, fmt.Errorf("%w: unsupported curve type %q", ErrKeyUnmarshalParameter, marshal.CRV) } - if options.AsymmetricPrivate && jwk.D != "" { - d, err := base64urlTrailingPadding(jwk.D) + marshalCopy.CRV = marshal.CRV + marshalCopy.X = marshal.X + marshalCopy.Y = marshal.Y + if options.Private && marshal.D != "" { + d, err := base64urlTrailingPadding(marshal.D) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyEC, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyEC, err) } privateKey := &ecdsa.PrivateKey{ PublicKey: *publicKey, D: new(big.Int).SetBytes(d), } - meta.Key = privateKey + key = privateKey + marshalCopy.D = marshal.D } else { - meta.Key = publicKey + key = publicKey } case KtyOKP: - if jwk.CRV != CrvEd25519 { - return meta, fmt.Errorf("%w: %s key type should have %q curve", ErrKeyUnmarshalParameter, KtyOKP, CrvEd25519) + if marshal.CRV == "" || marshal.X == "" { + return JWK{}, fmt.Errorf(`%w: %s requires parameters "crv" and "x"`, ErrKeyUnmarshalParameter, KtyOKP) } - if jwk.X == "" { - return meta, fmt.Errorf(`%w: %s requires parameter "x"`, ErrKeyUnmarshalParameter, KtyOKP) - } - public, err := base64urlTrailingPadding(jwk.X) + public, err := base64urlTrailingPadding(marshal.X) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "x": %w`, KtyOKP, err) - } - if len(public) != ed25519.PublicKeySize { - return meta, fmt.Errorf("%w: %s key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, ed25519.PublicKeySize) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "x": %w`, KtyOKP, err) } - if options.AsymmetricPrivate && jwk.D != "" { - private, err := base64urlTrailingPadding(jwk.D) + marshalCopy.CRV = marshal.CRV + marshalCopy.X = marshal.X + var private []byte + if options.Private && marshal.D != "" { + private, err = base64urlTrailingPadding(marshal.D) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyOKP, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyOKP, err) } - private = append(private, public...) - if len(private) != ed25519.PrivateKeySize { - return meta, fmt.Errorf("%w: %s key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, ed25519.PrivateKeySize) + } + switch marshal.CRV { + case CrvEd25519: + if len(public) != ed25519.PublicKeySize { + return JWK{}, fmt.Errorf("%w: %s key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, ed25519.PublicKeySize) } - meta.Key = ed25519.PrivateKey(private) - } else { - meta.Key = ed25519.PublicKey(public) + if options.Private && marshal.D != "" { + private = append(private, public...) + if len(private) != ed25519.PrivateKeySize { + return JWK{}, fmt.Errorf("%w: %s key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, ed25519.PrivateKeySize) + } + key = ed25519.PrivateKey(private) + marshalCopy.D = marshal.D + } else { + key = ed25519.PublicKey(public) + } + case CrvX25519: + const x25519PublicKeySize = 32 + if len(public) != x25519PublicKeySize { + return JWK{}, fmt.Errorf("%w: %s with curve %s public key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, CrvEd25519, x25519PublicKeySize) + } + if options.Private && marshal.D != "" { + const x25519PrivateKeySize = 32 + if len(private) != x25519PrivateKeySize { + return JWK{}, fmt.Errorf("%w: %s with curve %s private key should be %d bytes", ErrKeyUnmarshalParameter, KtyOKP, CrvEd25519, x25519PrivateKeySize) + } + key, err = ecdh.X25519().NewPrivateKey(private) + if err != nil { + return JWK{}, fmt.Errorf("failed to create X25519 private key: %w", err) + } + marshalCopy.D = marshal.D + } else { + key, err = ecdh.X25519().NewPublicKey(public) + if err != nil { + return JWK{}, fmt.Errorf("failed to create X25519 public key: %w", err) + } + } + default: + return JWK{}, fmt.Errorf("%w: unsupported curve type %q", ErrKeyUnmarshalParameter, marshal.CRV) } case KtyRSA: - if jwk.N == "" || jwk.E == "" { - return meta, fmt.Errorf(`%w: %s requires parameters "n" and "e"`, ErrKeyUnmarshalParameter, KtyRSA) + if marshal.N == "" || marshal.E == "" { + return JWK{}, fmt.Errorf(`%w: %s requires parameters "n" and "e"`, ErrKeyUnmarshalParameter, KtyRSA) } - n, err := base64urlTrailingPadding(jwk.N) + n, err := base64urlTrailingPadding(marshal.N) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "n": %w`, KtyRSA, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "n": %w`, KtyRSA, err) } - e, err := base64urlTrailingPadding(jwk.E) + e, err := base64urlTrailingPadding(marshal.E) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "e": %w`, KtyRSA, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "e": %w`, KtyRSA, err) } publicKey := rsa.PublicKey{ N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Uint64()), } - if options.AsymmetricPrivate && jwk.D != "" && jwk.P != "" && jwk.Q != "" && jwk.DP != "" && jwk.DQ != "" && jwk.QI != "" { // TODO Only "d" is required, but if one of the others is present, they all must be. - d, err := base64urlTrailingPadding(jwk.D) + marshalCopy.N = marshal.N + marshalCopy.E = marshal.E + if options.Private && marshal.D != "" && marshal.P != "" && marshal.Q != "" && marshal.DP != "" && marshal.DQ != "" && marshal.QI != "" { // TODO Only "d" is required, but if one of the others is present, they all must be. + d, err := base64urlTrailingPadding(marshal.D) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyRSA, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyRSA, err) } - p, err := base64urlTrailingPadding(jwk.P) + p, err := base64urlTrailingPadding(marshal.P) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "p": %w`, KtyRSA, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "p": %w`, KtyRSA, err) } - q, err := base64urlTrailingPadding(jwk.Q) + q, err := base64urlTrailingPadding(marshal.Q) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "q": %w`, KtyRSA, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "q": %w`, KtyRSA, err) } - dp, err := base64urlTrailingPadding(jwk.DP) + dp, err := base64urlTrailingPadding(marshal.DP) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "dp": %w`, KtyRSA, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "dp": %w`, KtyRSA, err) } - dq, err := base64urlTrailingPadding(jwk.DQ) + dq, err := base64urlTrailingPadding(marshal.DQ) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "dq": %w`, KtyRSA, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "dq": %w`, KtyRSA, err) } - qi, err := base64urlTrailingPadding(jwk.QI) + qi, err := base64urlTrailingPadding(marshal.QI) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "qi": %w`, KtyRSA, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "qi": %w`, KtyRSA, err) } var oth []rsa.CRTValue primes := []*big.Int{ new(big.Int).SetBytes(p), new(big.Int).SetBytes(q), } - if len(jwk.OTH) > 0 { - oth = make([]rsa.CRTValue, len(jwk.OTH)) - for i, otherPrimes := range jwk.OTH { + if len(marshal.OTH) > 0 { + oth = make([]rsa.CRTValue, len(marshal.OTH)) + for i, otherPrimes := range marshal.OTH { if otherPrimes.R == "" || otherPrimes.D == "" || otherPrimes.T == "" { - return meta, fmt.Errorf(`%w: %s requires parameters "r", "d", and "t" for each "oth"`, ErrKeyUnmarshalParameter, KtyRSA) + return JWK{}, fmt.Errorf(`%w: %s requires parameters "r", "d", and "t" for each "oth"`, ErrKeyUnmarshalParameter, KtyRSA) } othD, err := base64urlTrailingPadding(otherPrimes.D) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyRSA, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "d": %w`, KtyRSA, err) } othT, err := base64urlTrailingPadding(otherPrimes.T) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "t": %w`, KtyRSA, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "t": %w`, KtyRSA, err) } othR, err := base64urlTrailingPadding(otherPrimes.R) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "r": %w`, KtyRSA, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "r": %w`, KtyRSA, err) } primes = append(primes, new(big.Int).SetBytes(othR)) oth[i] = rsa.CRTValue{ @@ -305,31 +368,78 @@ func KeyUnmarshal[CustomKeyMeta any](jwk JWKMarshal, options KeyUnmarshalOptions } err = privateKey.Validate() if err != nil { - return meta, fmt.Errorf(`failed to validate %s key: %w`, KtyRSA, err) + return JWK{}, fmt.Errorf(`failed to validate %s key: %w`, KtyRSA, err) } - meta.Key = privateKey - } else if !options.AsymmetricPrivate { - meta.Key = &publicKey + key = privateKey + marshalCopy.D = marshal.D + marshalCopy.P = marshal.P + marshalCopy.Q = marshal.Q + marshalCopy.DP = marshal.DP + marshalCopy.DQ = marshal.DQ + marshalCopy.QI = marshal.QI + marshalCopy.OTH = slices.Clone(marshal.OTH) + } else { + key = &publicKey } case KtyOct: - if options.Symmetric { - if jwk.K == "" { - return meta, fmt.Errorf(`%w: %s requires parameter "k"`, ErrKeyUnmarshalParameter, KtyOct) + if options.Private { + if marshal.K == "" { + return JWK{}, fmt.Errorf(`%w: %s requires parameter "k"`, ErrKeyUnmarshalParameter, KtyOct) } - key, err := base64urlTrailingPadding(jwk.K) + k, err := base64urlTrailingPadding(marshal.K) if err != nil { - return meta, fmt.Errorf(`failed to decode %s key parameter "k": %w`, KtyOct, err) + return JWK{}, fmt.Errorf(`failed to decode %s key parameter "k": %w`, KtyOct, err) } - meta.Key = key + key = k + marshalCopy.K = marshal.K } else { - return meta, fmt.Errorf("%w: incorrect options to unmarshal symmetric key (%s)", ErrUnsupportedKeyType, KtyOct) + return JWK{}, fmt.Errorf("%w: incorrect options to unmarshal symmetric key (%s)", ErrOptions, KtyOct) } default: - return meta, fmt.Errorf("%w: %s", ErrUnsupportedKeyType, jwk.KTY) + return JWK{}, fmt.Errorf("%w: %s (kty)", ErrUnsupportedKey, marshal.KTY) + } + marshalCopy.KTY = marshal.KTY + x5c := make([]*x509.Certificate, len(marshal.X5C)) + for i, cert := range marshal.X5C { + raw, err := base64.StdEncoding.DecodeString(cert) + if err != nil { + return JWK{}, fmt.Errorf("failed to Base64 decode X.509 certificate: %w", err) + } + x5c[i], err = x509.ParseCertificate(raw) + if err != nil { + return JWK{}, fmt.Errorf("failed to parse X.509 certificate: %w", err) + } + } + jwkX509 := JWKX509Options{ + X5C: x5c, + X5U: marshal.X5U, + } + marshalCopy.X5C = slices.Clone(marshal.X5C) + marshalCopy.X5T = marshal.X5T + marshalCopy.X5TS256 = marshal.X5TS256 + marshalCopy.X5U = marshal.X5U + metadata := JWKMetadataOptions{ + ALG: marshal.ALG, + KID: marshal.KID, + KEYOPS: slices.Clone(marshal.KEYOPS), + USE: marshal.USE, + } + marshalCopy.ALG = marshal.ALG + marshalCopy.KID = marshal.KID + marshalCopy.KEYOPS = slices.Clone(marshal.KEYOPS) + marshalCopy.USE = marshal.USE + opts := JWKOptions{ + Metadata: metadata, + Marshal: options, + Validate: validateOptions, + X509: jwkX509, + } + j := JWK{ + key: key, + marshal: marshalCopy, + options: opts, } - meta.ALG = jwk.ALG - meta.KeyID = jwk.KID - return meta, nil + return j, nil } // base64urlTrailingPadding removes trailing padding before decoding a string from base64url. Some non-RFC compliant diff --git a/marshal_test.go b/marshal_test.go index e272910..9392034 100644 --- a/marshal_test.go +++ b/marshal_test.go @@ -2,6 +2,7 @@ package jwkset_test import ( "bytes" + "crypto/ecdh" "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" @@ -9,26 +10,29 @@ import ( "encoding/base64" "errors" "math/big" + "slices" "testing" "github.com/MicahParks/jwkset" ) const ( + ecdhX25519D = "iQCZajCYjcS3WacLOuX9OAwUqADFdwOMlv69Oyt4Erc" + ecdhX25519X = "dKnZoQtBYcCQ2oWeGvU52zjCnKB7XeU5xiD7NjRnVCo" ecdsaP256D = "GpanYiHB-TeCKFmfAwqzIJVhziUH6QX77obHwDPERGo" ecdsaP256X = "IZrURsAt0DcSytZRCBQ4SjCcbIhLLQvg53uSkRdETZ4" ecdsaP256Y = "Uy2iBhx7jMXB4n8fPASCOaNjnUPd8C1toVwytGeAEdU" ecdsaP384D = "P0mnrdElxUwAOcYeRlEz6uUNM6v_Bj4iBB4qxfEQ0xpKiAI5wM1lhzyoXibfWRHo" ecdsaP384X = "qL8wKJLZT5qowOGc8FMYqMWurcdVL15VxHqYV5JmJYj0EjBiPv14iwUrnhEEHVS9" ecdsaP384Y = "5qSWUmTjYNREUNCjDyAxu-ymHUGOtnEzO2z_pxtl5vd4W5Eb_9QcK9E9z3G3Xxjp" - ecdsaP521D = "AE4nfzwC69AYJhoJav6VH_rCFodPqcy5Li-6ISmJsLBZwvHX-2S0EYxsPuuk5shfxSFHJbXaD_t85doozgcsV_8t" + ecdsaP521D = "Tid_PALr0BgmGglq_pUf-sIWh0-pzLkuL7ohKYmwsFnC8df7ZLQRjGw-66TmyF_FIUcltdoP-3zl2ijOByxX_y0" ecdsaP521X = "ARxti_MdbyBVgT4N-08XzYBx5c8ZUPtZXshNHu_AoMwQqXq0WjZznL5b2175hv8nsUvRshjHpHaj_7SWQl5vH9f0" ecdsaP521Y = "AYx5MdFtiuPA1_IVS0A0z8MhLmQNJOxKd1hnhSRlod1sd7sz17WSXz-DggJwK5gj0qFp9_8dsVvI1Yn688myoImU" eddsaPrivate = "5hT6NTzNJyUCaG7mqtq2ru0EsA2z5SwnnkP0pBycP64" eddsaPublic = "VYk14QSFla7FKnL_okf6TqLIyV2X6DPaDi26UpAMVnM" hmacSecret = "myHMACSecret" invalidB64URL = "&" - myKeyID = "myKeyID" + myKeyID = "my-key-id" rsa2048D = "cNNmGwtIladiUlF9v4774vjflIMQvrr-AV-_tHjXK59PY2k4b2HvpKXAoOTn4FAR8fuEeYuMRA-cky5KpBvyXdTxCpPFjI-ZS7QFiTyKk5TmJh73g--ZvyAjjUmsJhL_A02zUD8N2cEP4dKmffSdhe4JO-HVuIHKQCF6TJ_IrrP7IkA5Kji2DZR9_xPiBEele_RkB74TykrClkbXZ-fASt-gdO3e058__j0Ou5LYnzxcnA0vkxarIdqszZ3rHxI2MtqaNobKGJ6R3i9CmjxRKlBw-cDOnFz_L1v7P2QL9szxuGSYCCbKE7d04zc-7GqissY_SRdKM4cJ66SJxin6AQ" rsa2048DP = "AV-vUYbJgrfbtLEc8i4N8k__BsFyiN3OjkqqqjgxJIYViOZPa7QMPwSbqhGTKJE8EzjkJw" rsa2048DQ = "s-ehKBdb1qBJ8b06TOt1u6VK2AqWR_nhXPLhdnqXbHcvWGNv54wI_C0VU8Wt3SA3Jm1h" @@ -48,13 +52,146 @@ const ( rsa2048OthT3 = "AssFXSpsj1ZFVjZ_tsJ2yePXdjdgQ-Wj59BcfKpzgJ6YuSEhf6kW4kbMZQULiSeNlckiYw" ) +func TestMarshalECDH(t *testing.T) { + checkMarshal := func(marshal jwkset.JWKMarshal, options jwkset.JWKOptions) { + if marshal.ALG != "" { + t.Fatal(`Marshaled key parameter "alg" should be empty when not set.`) + } + if marshal.CRV != jwkset.CrvX25519 { + t.Fatal(`Marshaled key parameter "crv" does not match original key.`) + } + if options.Marshal.Private { + if marshal.D != ecdhX25519D { + t.Fatal(`Marshaled key parameter "d" does not match original key.`) + } + } else { + if marshal.D != "" { + t.Fatalf("Asymmetric private key should be unsupported for given options.") + } + } + if marshal.KTY != jwkset.KtyOKP { + t.Fatal(`Marshaled key parameter "kty" does not match original key.`) + } + if marshal.X != ecdhX25519X { + t.Fatal(`Marshaled key parameter "x" does not match original key.`) + } + } + private := makeECDHX25519Private(t) + + options := jwkset.JWKOptions{} + jwk, err := jwkset.NewJWKFromKey(private, options) + if err != nil { + t.Fatalf("Failed to marshal key with correct options. %s", err) + } + checkMarshal(jwk.Marshal(), options) + + options.Marshal.Private = true + jwk, err = jwkset.NewJWKFromKey(private, options) + if err != nil { + t.Fatalf("Failed to marshal key with correct options. %s", err) + } + checkMarshal(jwk.Marshal(), options) + + options.Marshal.Private = false + jwk, err = jwkset.NewJWKFromKey(private.Public(), options) + if err != nil { + t.Fatalf("Failed to marshal key with correct options. %s", err) + } + checkMarshal(jwk.Marshal(), options) +} + +func TestUnmarshalECDH(t *testing.T) { + private := makeECDHX25519Private(t) + + marshal := jwkset.JWKMarshal{ + CRV: jwkset.CrvX25519, + D: ecdhX25519D, + KID: myKeyID, + KTY: jwkset.KtyOKP, + X: ecdhX25519X, + } + + marshalOptions := jwkset.JWKMarshalOptions{} + jwk, err := jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) + if err != nil { + t.Fatalf("Failed to unmarshal key with correct options. %s", err) + } + if !bytes.Equal(jwk.Key().(*ecdh.PublicKey).Bytes(), private.Public().(*ecdh.PublicKey).Bytes()) { + t.Fatalf("Unmarshaled key does not match original key.") + } + + marshalOptions.Private = true + jwk, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) + if err != nil { + t.Fatalf("Failed to unmarshal key with correct options. %s", err) + } + if !bytes.Equal(private.Bytes(), jwk.Key().(*ecdh.PrivateKey).Bytes()) { + t.Fatalf("Unmarshaled key does not match original key.") + } + if jwk.Marshal().KID != myKeyID { + t.Fatalf("Unmarshaled key ID does not match original key ID.") + } + + marshal.D = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) + if err == nil { + t.Fatalf(`Should get error when parameter "d" is invalid raw Base64URL. %s`, err) + } + + marshal.X = "" + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) + if !errors.Is(err, jwkset.ErrKeyUnmarshalParameter) { + t.Fatalf(`Should get ErrKeyUnmarshalParameter when parameter "x" is empty. %s`, err) + } + + marshal.X = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) + if err == nil { + t.Fatalf(`Should get error when parameter "x" is invalid raw Base64URL. %s`, err) + } + + marshal.CRV = "" + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) + if !errors.Is(err, jwkset.ErrKeyUnmarshalParameter) { + t.Fatalf(`Should get ErrKeyUnmarshalParameter when parameter "crv" is empty. %s`, err) + } + marshal.CRV = jwkset.CrvX25519 + + invalidSize := base64.RawURLEncoding.EncodeToString([]byte("invalidSize")) + marshal.X = invalidSize + marshal.D = ecdhX25519D + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) + if !errors.Is(err, jwkset.ErrKeyUnmarshalParameter) { + t.Fatalf(`Should get ErrKeyUnmarshalParameter when parameter "x" is invalid size. %s`, err) + } + marshal.X = ecdhX25519X + + marshal.D = invalidSize + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) + if !errors.Is(err, jwkset.ErrKeyUnmarshalParameter) { + t.Fatalf(`Should get ErrKeyUnmarshalParameter when parameter "d" is invalid size. %s`, err) + } +} + func TestMarshalECDSA(t *testing.T) { - checkMarshal := func(marshal jwkset.JWKMarshal, options jwkset.KeyMarshalOptions) { - // TODO Check ALG. + keyOps := []jwkset.KEYOPS{jwkset.KeyOpsSign, jwkset.KeyOpsVerify} + checkMarshal := func(marshal jwkset.JWKMarshal, options jwkset.JWKOptions) { + if marshal.ALG != jwkset.AlgES256 { + t.Fatal(`Marshaled parameter "alg" does not match original key.`) + } + if marshal.KID != myKeyID { + t.Fatal(`Marshaled parameter "kid" does not match original key.`) + } + if !slices.Equal(marshal.KEYOPS, keyOps) { + t.Fatal(`Marshaled parameter "key_ops" does not match original key.`) + } + if marshal.USE != jwkset.UseSig { + t.Fatal(`Marshaled parameter "use" does not match original key.`) + } if marshal.CRV != jwkset.CrvP256 { t.Fatal(`Marshaled parameter "crv" does not match original key.`) } - if options.AsymmetricPrivate { + if options.Marshal.Private { if marshal.D != ecdsaP256D { t.Fatal(`Marshaled parameter "d" does not match original key.`) } @@ -75,54 +212,39 @@ func TestMarshalECDSA(t *testing.T) { } private := makeECDSAP256(t) - meta := jwkset.KeyWithMeta[any]{ - Key: private, + metadata := jwkset.JWKMetadataOptions{ + ALG: jwkset.AlgES256, + KID: myKeyID, + KEYOPS: keyOps, + USE: jwkset.UseSig, } - - options := jwkset.KeyMarshalOptions{} - marshal, err := jwkset.KeyMarshal(meta, options) - if err != nil { - t.Fatalf("Failed to marshal key with correct options. %s", err) + options := jwkset.JWKOptions{ + Metadata: metadata, } - checkMarshal(marshal, options) + jwk := newJWK(t, private, options) - options.AsymmetricPrivate = true - marshal, err = jwkset.KeyMarshal(meta, options) - if err != nil { - t.Fatalf("Failed to marshal key with correct options. %s", err) - } - checkMarshal(marshal, options) + checkMarshal(jwk.Marshal(), options) - publicMeta := jwkset.KeyWithMeta[any]{ - Key: private.Public(), - } - options.AsymmetricPrivate = false - marshal, err = jwkset.KeyMarshal(publicMeta, options) - if err != nil { - t.Fatalf("Failed to marshal key with correct options. %s", err) - } + options.Marshal.Private = true + jwk = newJWK(t, private, options) + checkMarshal(jwk.Marshal(), options) - checkMarshal(marshal, options) + options.Marshal.Private = false + jwk = newJWK(t, private.Public(), options) + checkMarshal(jwk.Marshal(), options) } func TestUnmarshalECDSA(t *testing.T) { - checkUnmarshal := func(meta jwkset.KeyWithMeta[any], options jwkset.KeyUnmarshalOptions, original *ecdsa.PrivateKey) { + checkUnmarshal := func(jwk jwkset.JWK, options jwkset.JWKMarshalOptions, original *ecdsa.PrivateKey) { var public *ecdsa.PublicKey - var ok bool - if options.AsymmetricPrivate { - private, ok := meta.Key.(*ecdsa.PrivateKey) - if !ok { - t.Fatal("Unmarshaled key should be a private key.") - } + if options.Private { + private := jwk.Key().(*ecdsa.PrivateKey) if private.D.Cmp(original.D) != 0 { t.Fatal(`Unmarshaled key parameter "d" does not match original key.`) } public = private.Public().(*ecdsa.PublicKey) } else { - public, ok = meta.Key.(*ecdsa.PublicKey) - if !ok { - t.Fatal("Unmarshaled key should be a public key.") - } + public = jwk.Key().(*ecdsa.PublicKey) } if public.Curve != original.PublicKey.Curve { t.Fatal(`Unmarshaled key parameter "crv" does not match original key.`) @@ -135,7 +257,7 @@ func TestUnmarshalECDSA(t *testing.T) { } } - jwk := jwkset.JWKMarshal{ + marshal := jwkset.JWKMarshal{ CRV: jwkset.CrvP256, D: ecdsaP256D, KTY: jwkset.KtyEC, @@ -144,86 +266,74 @@ func TestUnmarshalECDSA(t *testing.T) { } key := makeECDSAP256(t) - options := jwkset.KeyUnmarshalOptions{} - meta, err := jwkset.KeyUnmarshal[any](jwk, options) - if err != nil { - t.Fatalf("Failed to unmarshal key with correct options. %s", err) - } - checkUnmarshal(meta, options, key) + marshalOptions := jwkset.JWKMarshalOptions{} + jwk := newJWKFromMarshal(t, marshal, marshalOptions) + checkUnmarshal(jwk, marshalOptions, key) - options.AsymmetricPrivate = true - meta, err = jwkset.KeyUnmarshal[any](jwk, options) - if err != nil { - t.Fatalf("Failed to unmarshal key with correct options. %s", err) - } - checkUnmarshal(meta, options, key) + marshalOptions.Private = true + jwk = newJWKFromMarshal(t, marshal, marshalOptions) + checkUnmarshal(jwk, marshalOptions, key) key = makeECDSAP384(t) - jwk.CRV = jwkset.CrvP384 - jwk.D = ecdsaP384D - jwk.X = ecdsaP384X - jwk.Y = ecdsaP384Y - meta, err = jwkset.KeyUnmarshal[any](jwk, options) - if err != nil { - t.Fatalf("Failed to unmarshal key with correct options. %s", err) - } - checkUnmarshal(meta, options, key) + marshal.CRV = jwkset.CrvP384 + marshal.D = ecdsaP384D + marshal.X = ecdsaP384X + marshal.Y = ecdsaP384Y + jwk = newJWKFromMarshal(t, marshal, marshalOptions) + checkUnmarshal(jwk, marshalOptions, key) key = makeECDSAP521(t) - jwk.CRV = jwkset.CrvP521 - jwk.D = ecdsaP521D - jwk.X = ecdsaP521X - jwk.Y = ecdsaP521Y - meta, err = jwkset.KeyUnmarshal[any](jwk, options) - if err != nil { - t.Fatalf("Failed to unmarshal key with correct options. %s", err) - } - checkUnmarshal(meta, options, key) - - jwk.CRV = "" - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.CRV = jwkset.CrvP521 + marshal.D = ecdsaP521D + marshal.X = ecdsaP521X + marshal.Y = ecdsaP521Y + jwk = newJWKFromMarshal(t, marshal, marshalOptions) + checkUnmarshal(jwk, marshalOptions, key) + + marshal.CRV = "" + _, err := jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if !errors.Is(err, jwkset.ErrKeyUnmarshalParameter) { t.Fatalf(`Should get ErrKeyUnmarshalParameter when parameter "crv" is empty. %s`, err) } - jwk.CRV = "invalid" - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.CRV = "invalid" + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if !errors.Is(err, jwkset.ErrKeyUnmarshalParameter) { t.Fatalf(`Should get ErrKeyUnmarshalParameter when parameter "crv" is invalid. %s`, err) } - jwk.CRV = jwkset.CrvP521 + marshal.CRV = jwkset.CrvP521 - jwk.D = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.D = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "d" is invalid raw Base64 URL. %s`, err) } - jwk.D = ecdsaP521D + marshal.D = ecdsaP521D - jwk.X = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.X = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "x" is invalid raw Base64 URL. %s`, err) } - jwk.X = ecdsaP521X + marshal.X = ecdsaP521X - jwk.Y = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.Y = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "y" is invalid raw Base64 URL. %s`, err) } - jwk.Y = ecdsaP521Y + marshal.Y = ecdsaP521Y } func TestMarshalEdDSA(t *testing.T) { - checkMarshal := func(marshal jwkset.JWKMarshal, options jwkset.KeyMarshalOptions) { + checkJWK := func(marshal jwkset.JWKMarshal, options jwkset.JWKOptions) { if marshal.ALG != jwkset.AlgEdDSA { t.Fatal(`Marshaled key parameter "alg" does not match original key.`) } if marshal.CRV != jwkset.CrvEd25519 { t.Fatal(`Marshaled key parameter "crv" does not match original key.`) } - if options.AsymmetricPrivate { + if options.Marshal.Private { if marshal.D != eddsaPrivate { t.Fatal(`Marshaled key parameter "d" does not match original key.`) } @@ -241,39 +351,33 @@ func TestMarshalEdDSA(t *testing.T) { } private := makeEdDSA(t) - meta := jwkset.KeyWithMeta[any]{ - Key: private, - } - - options := jwkset.KeyMarshalOptions{} - marshal, err := jwkset.KeyMarshal(meta, options) + options := jwkset.JWKOptions{} + jwk, err := jwkset.NewJWKFromKey(private, options) if err != nil { t.Fatalf("Failed to marshal key with correct options. %s", err) } - checkMarshal(marshal, options) + checkJWK(jwk.Marshal(), options) - options.AsymmetricPrivate = true - marshal, err = jwkset.KeyMarshal(meta, options) + options.Marshal.Private = true + jwk, err = jwkset.NewJWKFromKey(private, options) if err != nil { t.Fatalf("Failed to marshal key with correct options. %s", err) - } - checkMarshal(marshal, options) - publicMeta := jwkset.KeyWithMeta[any]{ - Key: private.Public(), } - options.AsymmetricPrivate = false - marshal, err = jwkset.KeyMarshal(publicMeta, options) + checkJWK(jwk.Marshal(), options) + + options.Marshal.Private = false + jwk, err = jwkset.NewJWKFromKey(private.Public(), options) if err != nil { t.Fatalf("Failed to marshal key with correct options. %s", err) } - checkMarshal(marshal, options) + checkJWK(jwk.Marshal(), options) } func TestUnmarshalEdDSA(t *testing.T) { private := makeEdDSA(t) - jwk := jwkset.JWKMarshal{ + marshal := jwkset.JWKMarshal{ ALG: jwkset.AlgEdDSA, CRV: jwkset.CrvEd25519, D: eddsaPrivate, @@ -282,129 +386,123 @@ func TestUnmarshalEdDSA(t *testing.T) { X: eddsaPublic, } - options := jwkset.KeyUnmarshalOptions{} - meta, err := jwkset.KeyUnmarshal[any](jwk, options) + marshalOptions := jwkset.JWKMarshalOptions{} + jwk, err := jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err != nil { t.Fatalf("Failed to unmarshal key with correct options. %s", err) } - switch meta.Key.(type) { - case ed25519.PublicKey: - // Do nothing. - default: - t.Fatal("Key type should be public key.") + if !bytes.Equal(jwk.Key().(ed25519.PublicKey), private.Public().(ed25519.PublicKey)) { + t.Fatalf("Unmarshaled key does not match original key.") } - options.AsymmetricPrivate = true - meta, err = jwkset.KeyUnmarshal[any](jwk, options) + marshalOptions.Private = true + jwk, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err != nil { t.Fatalf("Failed to unmarshal key with correct options. %s", err) } - if !bytes.Equal(private, meta.Key.(ed25519.PrivateKey)) { + if !bytes.Equal(private, jwk.Key().(ed25519.PrivateKey)) { t.Fatalf("Unmarshaled key does not match original key.") } - if meta.KeyID != myKeyID { + if jwk.Marshal().KID != myKeyID { t.Fatalf("Unmarshaled key ID does not match original key ID.") } - jwk.D = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.D = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "d" is invalid raw Base64URL. %s`, err) } - jwk.X = "" - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.X = "" + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if !errors.Is(err, jwkset.ErrKeyUnmarshalParameter) { t.Fatalf(`Should get ErrKeyUnmarshalParameter when parameter "x" is empty. %s`, err) } - jwk.X = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.X = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "x" is invalid raw Base64URL. %s`, err) } - jwk.CRV = "" - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.CRV = "" + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if !errors.Is(err, jwkset.ErrKeyUnmarshalParameter) { t.Fatalf(`Should get ErrKeyUnmarshalParameter when parameter "crv" is empty. %s`, err) } - jwk.CRV = jwkset.CrvEd25519 + marshal.CRV = jwkset.CrvEd25519 invalidSize := base64.RawURLEncoding.EncodeToString([]byte("invalidSize")) - jwk.X = invalidSize - jwk.D = eddsaPrivate - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.X = invalidSize + marshal.D = eddsaPrivate + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if !errors.Is(err, jwkset.ErrKeyUnmarshalParameter) { t.Fatalf(`Should get ErrKeyUnmarshalParameter when parameter "x" is invalid size. %s`, err) } - jwk.X = eddsaPublic + marshal.X = eddsaPublic - jwk.D = invalidSize - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.D = invalidSize + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if !errors.Is(err, jwkset.ErrKeyUnmarshalParameter) { t.Fatalf(`Should get ErrKeyUnmarshalParameter when parameter "d" is invalid size. %s`, err) } } func TestMarshalOct(t *testing.T) { - meta := jwkset.KeyWithMeta[any]{ - Key: []byte(hmacSecret), - } - - options := jwkset.KeyMarshalOptions{} - _, err := jwkset.KeyMarshal(meta, options) - if !errors.Is(err, jwkset.ErrUnsupportedKeyType) { + key := []byte(hmacSecret) + options := jwkset.JWKOptions{} + _, err := jwkset.NewJWKFromKey(key, options) + if !errors.Is(err, jwkset.ErrOptions) { t.Fatalf("Symmetric key should be unsupported for given options. %s", err) } - options.Symmetric = true - marshal, err := jwkset.KeyMarshal(meta, options) + options.Marshal.Private = true + jwk, err := jwkset.NewJWKFromKey(key, options) if err != nil { t.Fatalf("Failed to marshal key with correct options. %s", err) } - if marshal.K != base64.RawURLEncoding.EncodeToString(meta.Key.([]byte)) { + if jwk.Marshal().K != base64.RawURLEncoding.EncodeToString(jwk.Key().([]byte)) { t.Fatalf("Unmarshaled key does not match original key.") } - if marshal.KTY != jwkset.KtyOct { + if jwk.Marshal().KTY != jwkset.KtyOct { t.Fatalf("Key type does not match original key.") } } func TestUnmarshalOct(t *testing.T) { - jwk := jwkset.JWKMarshal{ + marshal := jwkset.JWKMarshal{ K: base64.RawURLEncoding.EncodeToString([]byte(hmacSecret)), KID: myKeyID, KTY: jwkset.KtyOct, } - options := jwkset.KeyUnmarshalOptions{} - meta, err := jwkset.KeyUnmarshal[any](jwk, options) - if !errors.Is(err, jwkset.ErrUnsupportedKeyType) { + options := jwkset.JWKMarshalOptions{} + _, err := jwkset.NewJWKFromMarshal(marshal, options, jwkset.JWKValidateOptions{}) + if !errors.Is(err, jwkset.ErrOptions) { t.Fatalf("Symmetric key should be unsupported for given options. %s", err) } - options.Symmetric = true - meta, err = jwkset.KeyUnmarshal[any](jwk, options) + options.Private = true + jwk, err := jwkset.NewJWKFromMarshal(marshal, options, jwkset.JWKValidateOptions{}) if err != nil { t.Fatalf("Failed to unmarshal key with correct options. %s", err) } - if !bytes.Equal([]byte(hmacSecret), meta.Key.([]byte)) { + if !bytes.Equal([]byte(hmacSecret), jwk.Key().([]byte)) { t.Fatalf("Unmarshaled key does not match original key.") } - if meta.KeyID != myKeyID { + if jwk.Marshal().KID != myKeyID { t.Fatalf("Unmarshaled key ID does not match original key ID.") } - jwk.K = "" - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.K = "" + _, err = jwkset.NewJWKFromMarshal(marshal, options, jwkset.JWKValidateOptions{}) if !errors.Is(err, jwkset.ErrKeyUnmarshalParameter) { t.Fatalf(`Should get ErrKeyUnmarshalParameter when parameter "k" is empty. %s`, err) } - jwk.K = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.K = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, options, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "k" is invalid raw Base64URL. %s`, err) } @@ -412,8 +510,7 @@ func TestUnmarshalOct(t *testing.T) { func TestMarshalRSA(t *testing.T) { private := makeRSA(t) - checkMarshal := func(marshal jwkset.JWKMarshal, options jwkset.KeyMarshalOptions) { - // TODO Check ALG. + checkMarshal := func(marshal jwkset.JWKMarshal, options jwkset.JWKOptions) { if marshal.E != rsa2048E { t.Fatal(`Marshal parameter "e" does not match original key.`) } @@ -423,7 +520,7 @@ func TestMarshalRSA(t *testing.T) { if marshal.N != rsa2048N { t.Fatal(`Marshal parameter "n" does not match original key.`) } - if options.AsymmetricPrivate { + if options.Marshal.Private { if marshal.D != rsa2048D { t.Fatal(`Marshal parameter "d" does not match original key.`) } @@ -497,41 +594,34 @@ func TestMarshalRSA(t *testing.T) { } } - meta := jwkset.KeyWithMeta[any]{ - Key: private, - } - - options := jwkset.KeyMarshalOptions{} - marshal, err := jwkset.KeyMarshal(meta, options) + options := jwkset.JWKOptions{} + jwk, err := jwkset.NewJWKFromKey(private, options) if err != nil { t.Fatalf("Failed to marshal key with correct options. %s", err) } - checkMarshal(marshal, options) + checkMarshal(jwk.Marshal(), options) - options.AsymmetricPrivate = true - marshal, err = jwkset.KeyMarshal(meta, options) + options.Marshal.Private = true + jwk, err = jwkset.NewJWKFromKey(private, options) if err != nil { t.Fatalf("Failed to marshal key with correct options. %s", err) } - checkMarshal(marshal, options) + checkMarshal(jwk.Marshal(), options) - metaPublic := jwkset.KeyWithMeta[any]{ - Key: private.Public(), - } - options.AsymmetricPrivate = false - marshal, err = jwkset.KeyMarshal(metaPublic, options) + options.Marshal.Private = false + jwk, err = jwkset.NewJWKFromKey(&jwk.Key().(*rsa.PrivateKey).PublicKey, options) if err != nil { t.Fatalf("Failed to marshal key with correct options. %s", err) } - checkMarshal(marshal, options) + checkMarshal(jwk.Marshal(), options) } func TestUnmarshalRSA(t *testing.T) { - checkUnmarshal := func(meta jwkset.KeyWithMeta[any], options jwkset.KeyUnmarshalOptions, original *rsa.PrivateKey) { + checkJWK := func(jwk jwkset.JWK, options jwkset.JWKMarshalOptions, original *rsa.PrivateKey) { var public *rsa.PublicKey var ok bool - if options.AsymmetricPrivate { - private, ok := meta.Key.(*rsa.PrivateKey) + if options.Private { + private, ok := jwk.Key().(*rsa.PrivateKey) if !ok { t.Fatal("Unmarshaled key should be a private key.") } @@ -566,7 +656,7 @@ func TestUnmarshalRSA(t *testing.T) { } public = private.Public().(*rsa.PublicKey) } else { - public, ok = meta.Key.(*rsa.PublicKey) + public, ok = jwk.Key().(*rsa.PublicKey) if !ok { t.Fatal("Unmarshaled key should be a public key.") } @@ -580,7 +670,7 @@ func TestUnmarshalRSA(t *testing.T) { } private := makeRSA(t) - jwk := jwkset.JWKMarshal{ + marshal := jwkset.JWKMarshal{ E: rsa2048E, D: rsa2048D, DP: rsa2048DP, @@ -609,134 +699,141 @@ func TestUnmarshalRSA(t *testing.T) { }, } - options := jwkset.KeyUnmarshalOptions{} - meta, err := jwkset.KeyUnmarshal[any](jwk, options) + marshalOptions := jwkset.JWKMarshalOptions{} + jwk, err := jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err != nil { t.Fatalf("Failed to unmarshal key with correct options. %s", err) } - checkUnmarshal(meta, options, private) + checkJWK(jwk, marshalOptions, private) - options.AsymmetricPrivate = true - meta, err = jwkset.KeyUnmarshal[any](jwk, options) + marshalOptions.Private = true + jwk, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err != nil { t.Fatalf("Failed to unmarshal key with correct options. %s", err) } - checkUnmarshal(meta, options, private) + checkJWK(jwk, marshalOptions, private) - jwk.N = "" - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.N = "" + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatal(`Should get error when parameter "n" is empty.`) } - jwk.N = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.N = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "n" is invalid raw Base64 URL. %s`, err) } - jwk.N = rsa2048N + marshal.N = rsa2048N - jwk.E = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.E = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "e" is invalid raw Base64 URL. %s`, err) } - jwk.E = rsa2048E + marshal.E = rsa2048E - jwk.D = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.D = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "d" is invalid raw Base64 URL. %s`, err) } - jwk.D = rsa2048D + marshal.D = rsa2048D - jwk.DP = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.DP = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "dp" is invalid raw Base64 URL. %s`, err) } - jwk.DP = rsa2048DP + marshal.DP = rsa2048DP - jwk.DQ = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.DQ = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "dq" is invalid raw Base64 URL. %s`, err) } - jwk.DQ = rsa2048DQ + marshal.DQ = rsa2048DQ - jwk.P = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.P = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "p" is invalid raw Base64 URL. %s`, err) } - jwk.P = rsa2048P + marshal.P = rsa2048P - jwk.Q = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.Q = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "q" is invalid raw Base64 URL. %s`, err) } - jwk.Q = rsa2048Q + marshal.Q = rsa2048Q - jwk.QI = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.QI = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "qi" is invalid raw Base64 URL. %s`, err) } - jwk.QI = rsa2048QI + marshal.QI = rsa2048QI - jwk.OTH[0].D = "" - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.OTH[0].D = "" + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if !errors.Is(err, jwkset.ErrKeyUnmarshalParameter) { t.Fatalf(`Should get error when parameter "oth" "d" is empty. %s`, err) } - jwk.OTH[0].D = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.OTH[0].D = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "oth" "d"" is invalid raw Base64 URL. %s`, err) } - jwk.OTH[0].D = rsa2048OthD1 + marshal.OTH[0].D = rsa2048OthD1 - jwk.OTH[0].R = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.OTH[0].R = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "oth" "r"" is invalid raw Base64 URL. %s`, err) } - jwk.OTH[0].R = rsa2048OthR1 + marshal.OTH[0].R = rsa2048OthR1 - jwk.OTH[0].T = invalidB64URL - _, err = jwkset.KeyUnmarshal[any](jwk, options) + marshal.OTH[0].T = invalidB64URL + _, err = jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) if err == nil { t.Fatalf(`Should get error when parameter "oth" "t"" is invalid raw Base64 URL. %s`, err) } - jwk.OTH[0].T = rsa2048OthT1 + marshal.OTH[0].T = rsa2048OthT1 } func TestMarshalUnsupported(t *testing.T) { - meta := jwkset.KeyWithMeta[any]{ - Key: "unsupported", - } - - options := jwkset.KeyMarshalOptions{} - _, err := jwkset.KeyMarshal(meta, options) - if !errors.Is(err, jwkset.ErrUnsupportedKeyType) { + _, err := jwkset.NewJWKFromMarshal(jwkset.JWKMarshal{}, jwkset.JWKMarshalOptions{}, jwkset.JWKValidateOptions{}) + if !errors.Is(err, jwkset.ErrUnsupportedKey) { t.Fatalf("Unsupported key type should be unsupported for given options. %s", err) } } func TestUnmarshalUnsupported(t *testing.T) { - jwk := jwkset.JWKMarshal{ + marshal := jwkset.JWKMarshal{ KTY: "unsupported", } - options := jwkset.KeyUnmarshalOptions{} - _, err := jwkset.KeyUnmarshal[any](jwk, options) - if !errors.Is(err, jwkset.ErrUnsupportedKeyType) { - t.Fatalf("Unsupported key type should return ErrUnsupportedKeyType. %s", err) + marshalOptions := jwkset.JWKMarshalOptions{} + _, err := jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) + if !errors.Is(err, jwkset.ErrUnsupportedKey) { + t.Fatalf("Unsupported key type should return ErrUnsupportedKey. %s", err) } } +func makeECDHX25519Private(t *testing.T) *ecdh.PrivateKey { + d, err := base64.RawURLEncoding.DecodeString(ecdhX25519D) + if err != nil { + t.Fatalf("Failed to decode private key. %s", err) + } + private, err := ecdh.X25519().NewPrivateKey(d) + if err != nil { + t.Fatalf("Failed to create private key. %s", err) + } + return private +} + func makeECDSAP256(t *testing.T) *ecdsa.PrivateKey { d, err := base64.RawURLEncoding.DecodeString(ecdsaP256D) if err != nil { @@ -920,3 +1017,18 @@ func makeRSA(t *testing.T) *rsa.PrivateKey { } return private } + +func newJWK(t *testing.T, key any, options jwkset.JWKOptions) jwkset.JWK { + jwk, err := jwkset.NewJWKFromKey(key, options) + if err != nil { + t.Fatalf("Failed to marshal key with correct options. %s", err) + } + return jwk +} +func newJWKFromMarshal(t *testing.T, marshal jwkset.JWKMarshal, marshalOptions jwkset.JWKMarshalOptions) jwkset.JWK { + jwk, err := jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) + if err != nil { + t.Fatalf("Failed to marshal key with correct options. %s", err) + } + return jwk +} diff --git a/storage.go b/storage.go index 416a201..7d63ac6 100644 --- a/storage.go +++ b/storage.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "sync" ) @@ -11,70 +12,70 @@ import ( var ErrKeyNotFound = errors.New("key not found") // Storage handles storage operations for a JWKSet. -type Storage[CustomKeyMeta any] interface { +type Storage interface { // DeleteKey deletes a key from the storage. It will return ok as true if the key was present for deletion. DeleteKey(ctx context.Context, keyID string) (ok bool, err error) // ReadKey reads a key from the storage. If the key is not present, it returns ErrKeyNotFound. Any pointers returned // should be considered read-only. - ReadKey(ctx context.Context, keyID string) (KeyWithMeta[CustomKeyMeta], error) + ReadKey(ctx context.Context, keyID string) (JWK, error) // SnapshotKeys reads a snapshot of all keys from storage. As with ReadKey, any pointers returned should be // considered read-only. - SnapshotKeys(ctx context.Context) ([]KeyWithMeta[CustomKeyMeta], error) + SnapshotKeys(ctx context.Context) ([]JWK, error) // WriteKey writes a key to the storage. If the key already exists, it will be overwritten. After writing a key, // any pointers written should be considered owned by the underlying storage. - WriteKey(ctx context.Context, meta KeyWithMeta[CustomKeyMeta]) error + WriteKey(ctx context.Context, jwk JWK) error } -var _ Storage[any] = &memoryJWKSet[any]{} +var _ Storage = &memoryJWKSet{} -type memoryJWKSet[CustomKeyMeta any] struct { - m map[string]KeyWithMeta[CustomKeyMeta] +type memoryJWKSet struct { + set []JWK mux sync.RWMutex } // NewMemoryStorage creates a new in-memory Storage implementation. -func NewMemoryStorage[CustomKeyMeta any]() Storage[CustomKeyMeta] { - return &memoryJWKSet[CustomKeyMeta]{ - m: make(map[string]KeyWithMeta[CustomKeyMeta]), - } +func NewMemoryStorage() Storage { + return &memoryJWKSet{} } -func (m *memoryJWKSet[CustomKeyMeta]) SnapshotKeys(ctx context.Context) ([]KeyWithMeta[CustomKeyMeta], error) { +func (m *memoryJWKSet) SnapshotKeys(_ context.Context) ([]JWK, error) { m.mux.RLock() defer m.mux.RUnlock() - cpy := make([]KeyWithMeta[CustomKeyMeta], len(m.m)) - i := 0 - for _, meta := range m.m { - cpy[i] = meta - i++ - } - return cpy, nil + return slices.Clone(m.set), nil } - -func (m *memoryJWKSet[CustomKeyMeta]) DeleteKey(ctx context.Context, keyID string) (ok bool, err error) { +func (m *memoryJWKSet) DeleteKey(_ context.Context, keyID string) (ok bool, err error) { m.mux.Lock() defer m.mux.Unlock() - _, ok = m.m[keyID] - delete(m.m, keyID) + for i, jwk := range m.set { + if jwk.Marshal().KID == keyID { + m.set = append(m.set[:i], m.set[i+1:]...) + return true, nil + } + } return ok, nil } - -func (m *memoryJWKSet[CustomKeyMeta]) ReadKey(ctx context.Context, keyID string) (KeyWithMeta[CustomKeyMeta], error) { +func (m *memoryJWKSet) ReadKey(_ context.Context, keyID string) (JWK, error) { m.mux.RLock() defer m.mux.RUnlock() - meta, ok := m.m[keyID] - if !ok { - return meta, fmt.Errorf("%s: %w", keyID, ErrKeyNotFound) + for _, jwk := range m.set { + if jwk.Marshal().KID == keyID { + return jwk, nil + } } - return meta, nil + return JWK{}, fmt.Errorf("%w: kid %q", ErrKeyNotFound, keyID) } - -func (m *memoryJWKSet[CustomKeyMeta]) WriteKey(ctx context.Context, meta KeyWithMeta[CustomKeyMeta]) error { +func (m *memoryJWKSet) WriteKey(_ context.Context, jwk JWK) error { m.mux.Lock() defer m.mux.Unlock() - m.m[meta.KeyID] = meta + for i, j := range m.set { + if j.Marshal().KID == jwk.Marshal().KID { + m.set[i] = jwk + return nil + } + } + m.set = append(m.set, jwk) return nil } diff --git a/storage_test.go b/storage_test.go index 6ea18e6..9f98630 100644 --- a/storage_test.go +++ b/storage_test.go @@ -21,18 +21,19 @@ var ( hmacKey2 = []byte("hamc key 2") ) -type storageTestParams[CustomKeyMeta any] struct { +type storageTestParams struct { ctx context.Context cancel context.CancelFunc - jwks jwkset.JWKSet[CustomKeyMeta] + jwks jwkset.JWKSet } func TestMemoryDeleteKey(t *testing.T) { - params := setupMemory[any]() + params := setupMemory() defer params.cancel() store := params.jwks.Store - err := store.WriteKey(params.ctx, jwkset.NewKey[any](hmacKey1, kidWritten)) + jwk := newStorageTestJWK(t, hmacKey1, kidWritten) + err := store.WriteKey(params.ctx, jwk) if err != nil { t.Fatalf("Failed to write key. %s", err) } @@ -55,11 +56,12 @@ func TestMemoryDeleteKey(t *testing.T) { } func TestMemoryReadKey(t *testing.T) { - params := setupMemory[any]() + params := setupMemory() defer params.cancel() store := params.jwks.Store - err := store.WriteKey(params.ctx, jwkset.NewKey[any](hmacKey1, kidWritten)) + jwk := newStorageTestJWK(t, hmacKey1, kidWritten) + err := store.WriteKey(params.ctx, jwk) if err != nil { t.Fatalf("Failed to write key. %s", err) } @@ -74,11 +76,12 @@ func TestMemoryReadKey(t *testing.T) { t.Fatalf("Failed to read written key. %s", err) } - if !bytes.Equal(key.Key.([]byte), hmacKey1) { + if !bytes.Equal(key.Key().([]byte), hmacKey1) { t.Fatalf("Read key does not match written key.") } - err = store.WriteKey(params.ctx, jwkset.NewKey[any](hmacKey2, kidWritten)) + jwk = newStorageTestJWK(t, hmacKey2, kidWritten) + err = store.WriteKey(params.ctx, jwk) if err != nil { t.Fatalf("Failed to overwrite key. %s", err) } @@ -88,7 +91,7 @@ func TestMemoryReadKey(t *testing.T) { t.Fatalf("Failed to read written key. %s", err) } - if !bytes.Equal(key.Key.([]byte), hmacKey2) { + if !bytes.Equal(key.Key().([]byte), hmacKey2) { t.Fatalf("Read key does not match written key.") } @@ -104,39 +107,41 @@ func TestMemoryReadKey(t *testing.T) { } func TestMemorySnapshotKeys(t *testing.T) { - params := setupMemory[any]() + params := setupMemory() defer params.cancel() store := params.jwks.Store - err := store.WriteKey(params.ctx, jwkset.NewKey[any](hmacKey1, kidWritten)) + jwk := newStorageTestJWK(t, hmacKey1, kidWritten) + err := store.WriteKey(params.ctx, jwk) if err != nil { t.Fatalf("Failed to write key 1. %s", err) } - err = store.WriteKey(params.ctx, jwkset.NewKey[any](hmacKey2, kidWritten2)) + jwk = newStorageTestJWK(t, hmacKey2, kidWritten2) + err = store.WriteKey(params.ctx, jwk) if err != nil { t.Fatalf("Failed to write key 2. %s", err) } - meta, err := store.SnapshotKeys(params.ctx) + keys, err := store.SnapshotKeys(params.ctx) if err != nil { t.Fatalf("Failed to snapshot keys. %s", err) } - if len(meta) != 2 { - t.Fatalf("Snapshot should have 2 keys. %d", len(meta)) + if len(keys) != 2 { + t.Fatalf("Snapshot should have 2 keys. %d", len(keys)) } kid1Found := false kid2Found := false - for _, m := range meta { - if !kid1Found && m.KeyID == kidWritten { + for _, jwk := range keys { + if !kid1Found && jwk.Marshal().KID == kidWritten { kid1Found = true - if !bytes.Equal(m.Key.([]byte), hmacKey1) { + if !bytes.Equal(jwk.Key().([]byte), hmacKey1) { t.Fatalf("Snapshot key does not match written key.") } - } else if !kid2Found && m.KeyID == kidWritten2 { + } else if !kid2Found && jwk.Marshal().KID == kidWritten2 { kid2Found = true - if !bytes.Equal(m.Key.([]byte), hmacKey2) { + if !bytes.Equal(jwk.Key().([]byte), hmacKey2) { t.Fatalf("Snapshot key does not match written key.") } } else { @@ -146,28 +151,48 @@ func TestMemorySnapshotKeys(t *testing.T) { } func TestMemoryWriteKey(t *testing.T) { - params := setupMemory[any]() + params := setupMemory() defer params.cancel() store := params.jwks.Store - err := store.WriteKey(params.ctx, jwkset.NewKey[any](hmacKey1, kidWritten)) + jwk := newStorageTestJWK(t, hmacKey1, kidWritten) + err := store.WriteKey(params.ctx, jwk) if err != nil { t.Fatalf("Failed to write key. %s", err) } - err = store.WriteKey(params.ctx, jwkset.NewKey[any](hmacKey2, kidWritten)) + jwk = newStorageTestJWK(t, hmacKey2, kidWritten) + err = store.WriteKey(params.ctx, jwk) if err != nil { t.Fatalf("Failed to overwrite key. %s", err) } } -func setupMemory[CustomKeyMeta any]() (params storageTestParams[CustomKeyMeta]) { - jwkSet := jwkset.NewMemory[CustomKeyMeta]() +func setupMemory() (params storageTestParams) { + jwkSet := jwkset.NewMemory() ctx, cancel := context.WithTimeout(context.Background(), time.Second) - params = storageTestParams[CustomKeyMeta]{ + params = storageTestParams{ ctx: ctx, cancel: cancel, jwks: jwkSet, } return params } + +func newStorageTestJWK(t *testing.T, key any, keyID string) jwkset.JWK { + marshal := jwkset.JWKMarshalOptions{ + Private: true, + } + metadata := jwkset.JWKMetadataOptions{ + KID: keyID, + } + options := jwkset.JWKOptions{ + Marshal: marshal, + Metadata: metadata, + } + jwk, err := jwkset.NewJWKFromKey(key, options) + if err != nil { + t.Fatalf("Failed to create JWK. %s", err) + } + return jwk +} diff --git a/website/.dockerignore b/website/.dockerignore new file mode 100644 index 0000000..9414382 --- /dev/null +++ b/website/.dockerignore @@ -0,0 +1 @@ +Dockerfile diff --git a/website/Dockerfile b/website/Dockerfile new file mode 100644 index 0000000..598382c --- /dev/null +++ b/website/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1 AS builder +WORKDIR /app +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "-s -w" -o jwksetcom -trimpath cmd/server/*.go + +FROM alpine +COPY --from=builder /app/jwksetcom /jwksetcom +ENV CONFIG_JSON='{}' +CMD ["/jwksetcom"] diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..249a340 --- /dev/null +++ b/website/README.md @@ -0,0 +1,15 @@ +# jwkset.com + +This is the open source project for the website https://jwkset.com. This website is a part of +the https://github.com/MicahParks/jwkset open source project. + +# Self-host + +This website can work with private cryptographic keys. Only work with private keys when using a self-hosted instance of +this website. + +Use the pre-built Docker container to self-host this website. + +``` +docker run --rm -p 8080:8080 micahparks/jwksetcom +``` diff --git a/website/cmd/server/main.go b/website/cmd/server/main.go new file mode 100644 index 0000000..d90dfa0 --- /dev/null +++ b/website/cmd/server/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "log" + "net/http" + "time" + + hh "github.com/MicahParks/httphandle" + hhconst "github.com/MicahParks/httphandle/constant" + "github.com/MicahParks/httphandle/middleware" + + jsc "github.com/MicahParks/jwkset/website" + "github.com/MicahParks/jwkset/website/handle/api" + "github.com/MicahParks/jwkset/website/handle/template" + "github.com/MicahParks/jwkset/website/server" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + setupArgs := hh.SetupArgs{ + Static: jsc.Static, + Templates: jsc.Templates, + } + conf, err := hh.Setup[jsc.Config](setupArgs) + if err != nil { + log.Fatalf(hhconst.LogFmt, "Failed to setup.", err) + } + l := conf.Logger + + srv := server.NewServer(conf.Conf, l) + + apiHandlers := []hh.API[server.Server]{ + &api.Inspect{}, + &api.NewGen{}, + &api.PemGen{}, + } + templateHandlers := []hh.Template[server.Server]{ + &template.Index{}, + &template.Generate{}, + &template.Inspect{}, + } + attachArgs := hh.AttachArgs[server.Server]{ + API: apiHandlers, + Files: conf.Files, + MiddlewareOpts: middleware.GlobalDefaults, + Template: templateHandlers, + Templater: conf.Templater, + } + + mux := http.NewServeMux() + err = hh.Attach(attachArgs, srv, mux) + if err != nil { + l.ErrorContext(ctx, "Failed to attach handlers.", + hhconst.LogErr, err, + ) + return + } + + l.InfoContext(ctx, "Starting server.", + "devClick", "http://localhost:8080", + ) + serveArgs := hh.ServeArgs{ + Logger: l.With("httphandle", true), + Port: 8080, + ShutdownFunc: srv.Shutdown, + ShutdownTimeout: 5 * time.Second, + } + hh.Serve(serveArgs, mux) +} diff --git a/website/config.go b/website/config.go new file mode 100644 index 0000000..1adf8b0 --- /dev/null +++ b/website/config.go @@ -0,0 +1,37 @@ +package jwksetcom + +import ( + "fmt" + + "github.com/MicahParks/jsontype" +) + +type Config struct { + DMode bool `json:"devMode"` + ReCAPTCHA ReCAPTCHA `json:"reCAPTCHA"` +} + +func (c Config) DefaultsAndValidate() (Config, error) { + if c.ReCAPTCHA.SiteKey != "" { + if c.ReCAPTCHA.Secret == "" { + return Config{}, fmt.Errorf(`%w: missing reCAPTCHA "secret" config`, jsontype.ErrDefaultsAndValidate) + } + if c.ReCAPTCHA.ScoreMin == 0 { + return Config{}, fmt.Errorf(`%w: missing reCAPTCHA "scoreMin" config`, jsontype.ErrDefaultsAndValidate) + } + if len(c.ReCAPTCHA.Hostname) == 0 { + return Config{}, fmt.Errorf(`%w: missing reCAPTCHA "hostname" config`, jsontype.ErrDefaultsAndValidate) + } + } + return c, nil +} +func (c Config) DevMode() bool { + return c.DMode +} + +type ReCAPTCHA struct { + Hostname []string `json:"hostname"` + ScoreMin float64 `json:"scoreMin"` + Secret string `json:"secret"` + SiteKey string `json:"siteKey"` +} diff --git a/website/constant.go b/website/constant.go new file mode 100644 index 0000000..824b72b --- /dev/null +++ b/website/constant.go @@ -0,0 +1,42 @@ +package jwksetcom + +import ( + hhconst "github.com/MicahParks/httphandle/constant" +) + +const ( + LinkGitHub = "https://github.com/MicahParks/jwkset/website" + PathAPIInspect = "/api/inspect" + PathAPINewGen = "/api/new-gen" + PathAPIPemGen = "/api/pem-gen" + PathGenerate = "/generate" + PathInspect = "/inspect" + TemplateWrapper = "wrapper.gohtml" +) + +type Link struct{} + +func (l Link) GitHub() string { + return LinkGitHub +} + +type Path struct{} + +func (p Path) APIInspect() string { + return PathAPIInspect +} +func (p Path) APINewGen() string { + return PathAPINewGen +} +func (p Path) APIPemGen() string { + return PathAPIPemGen +} +func (p Path) Generate() string { + return PathGenerate +} +func (p Path) Index() string { + return hhconst.PathIndex +} +func (p Path) Inspect() string { + return PathInspect +} diff --git a/website/css.sh b/website/css.sh new file mode 100644 index 0000000..6b6877b --- /dev/null +++ b/website/css.sh @@ -0,0 +1 @@ +npx tailwindcss --minify --watch --input ./input.css --output ./static/css/tailwind.min.css diff --git a/website/embed.go b/website/embed.go new file mode 100644 index 0000000..b51ae94 --- /dev/null +++ b/website/embed.go @@ -0,0 +1,11 @@ +package jwksetcom + +import ( + "embed" +) + +//go:embed static +var Static embed.FS + +//go:embed templates +var Templates embed.FS diff --git a/website/go.mod b/website/go.mod new file mode 100644 index 0000000..7a240a8 --- /dev/null +++ b/website/go.mod @@ -0,0 +1,20 @@ +module github.com/MicahParks/jwkset/website + +go 1.21.5 + +require ( + github.com/MicahParks/httphandle v0.5.6 + github.com/MicahParks/jsontype v0.6.1 + github.com/MicahParks/recaptcha v0.0.5 +) + +require ( + github.com/MicahParks/jwkset v0.3.2-0.20231205231409-8786292b6646 // indirect + github.com/MicahParks/templater v0.0.2 // indirect + github.com/google/uuid v1.4.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.0 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/website/go.sum b/website/go.sum new file mode 100644 index 0000000..528130d --- /dev/null +++ b/website/go.sum @@ -0,0 +1,66 @@ +github.com/MicahParks/httphandle v0.4.0 h1:QzI/w+tx385lTT7l88wldl72eloO2ag95NdIokJIPao= +github.com/MicahParks/httphandle v0.4.0/go.mod h1:qTy9tJWTKRknpvmeM07M9c18EKS1kB8X71FwiudlKps= +github.com/MicahParks/httphandle v0.4.1 h1:AnZwW62fxJKHkrxN+RD4kf2UfKt7RW/8+5t7GhAW1Xs= +github.com/MicahParks/httphandle v0.4.1/go.mod h1:b1/C8KRzVj9RumQosDxQJHoU0vwKoicQXfxe8p6B2Bg= +github.com/MicahParks/httphandle v0.5.0 h1:5QotoOMp+p5Sb/vwmw3fKWG2A819s/IZP3XdggjFt5c= +github.com/MicahParks/httphandle v0.5.0/go.mod h1:b1/C8KRzVj9RumQosDxQJHoU0vwKoicQXfxe8p6B2Bg= +github.com/MicahParks/httphandle v0.5.1 h1:89QOzKjjbEdD02lEVV5otFhzGADcB3T/Ovy9HA4lhsw= +github.com/MicahParks/httphandle v0.5.1/go.mod h1:b1/C8KRzVj9RumQosDxQJHoU0vwKoicQXfxe8p6B2Bg= +github.com/MicahParks/httphandle v0.5.2 h1:H/6lK1dYf2LI702kltVWcne0PSznVf5yS//SSMDd9Aw= +github.com/MicahParks/httphandle v0.5.2/go.mod h1:b1/C8KRzVj9RumQosDxQJHoU0vwKoicQXfxe8p6B2Bg= +github.com/MicahParks/httphandle v0.5.3 h1:4MVlIQ57CLrz8ihWc52jqOUKW6N97LC6rAFsmPIHF08= +github.com/MicahParks/httphandle v0.5.3/go.mod h1:b1/C8KRzVj9RumQosDxQJHoU0vwKoicQXfxe8p6B2Bg= +github.com/MicahParks/httphandle v0.5.5 h1:jP/KoIpMVBmoEVMy1/EAGNNPFHESxkhzQi0FMn2eOoc= +github.com/MicahParks/httphandle v0.5.5/go.mod h1:b1/C8KRzVj9RumQosDxQJHoU0vwKoicQXfxe8p6B2Bg= +github.com/MicahParks/httphandle v0.5.6 h1:C3wbRA45XfVcT+E1n9z52ZXqYjnLKeg+uIwsFozcc9U= +github.com/MicahParks/httphandle v0.5.6/go.mod h1:b1/C8KRzVj9RumQosDxQJHoU0vwKoicQXfxe8p6B2Bg= +github.com/MicahParks/jsontype v0.5.0 h1:O/7LAAbWEe3sPNvwaLINy6eEvgINQlJRNEShyUV5Jrc= +github.com/MicahParks/jsontype v0.5.0/go.mod h1:PVeg4g8eHt4QDlhe56X1sWzRuHiVlCg4m0vgkpEso/Y= +github.com/MicahParks/jsontype v0.6.1 h1:yFiDEOgSCDT+Es8k17PYZkvpqbZJ9GxJH2ioeVGvgt0= +github.com/MicahParks/jsontype v0.6.1/go.mod h1:PVeg4g8eHt4QDlhe56X1sWzRuHiVlCg4m0vgkpEso/Y= +github.com/MicahParks/jwkset v0.3.1 h1:DIVazR/elD8CLWPblrVo610TzovIDYMcvlM4X0UT0vQ= +github.com/MicahParks/jwkset v0.3.1/go.mod h1:Ob0sxSgMmQZFg4GO59PVBnfm+jtdQ1MJbfZDU90tEwM= +github.com/MicahParks/jwkset v0.3.2-0.20231128221226-5fa4338a62f8 h1:HmDMOXf4i5f19f90Ouqm65+ycv69sShuYF3LnK2JBNI= +github.com/MicahParks/jwkset v0.3.2-0.20231128221226-5fa4338a62f8/go.mod h1:U3f5Lro/pn/ehiMIt1YaIsceaop6OzkNIoGOaakeHv4= +github.com/MicahParks/jwkset v0.3.2-0.20231203010203-5ea58d236f30 h1:lUSq5nu+AcnXsG93DXBbRaZe8oIbbZkbbOL71l6Dp/0= +github.com/MicahParks/jwkset v0.3.2-0.20231203010203-5ea58d236f30/go.mod h1:gxRyAxC1mpqeV261WFLsJJsXlXd38dvAahDl5ZAa72s= +github.com/MicahParks/jwkset v0.3.2-0.20231203010649-90b400ca6ee4 h1:8UbJE/tTSSdh8Gv6lm09nmVd7sMPfVWSmaAJEEDvkEU= +github.com/MicahParks/jwkset v0.3.2-0.20231203010649-90b400ca6ee4/go.mod h1:gxRyAxC1mpqeV261WFLsJJsXlXd38dvAahDl5ZAa72s= +github.com/MicahParks/jwkset v0.3.2-0.20231204025437-e4b95ad72b24 h1:vcgKs99E9i9b5F94/5tBVfHJS52OY8rYXu6+V3EE6Gg= +github.com/MicahParks/jwkset v0.3.2-0.20231204025437-e4b95ad72b24/go.mod h1:gxRyAxC1mpqeV261WFLsJJsXlXd38dvAahDl5ZAa72s= +github.com/MicahParks/jwkset v0.3.2-0.20231205231409-8786292b6646 h1:5yeuRYACMehkr4hUZZEb0AT5adzbtEON1pogwmJ2RrQ= +github.com/MicahParks/jwkset v0.3.2-0.20231205231409-8786292b6646/go.mod h1:gxRyAxC1mpqeV261WFLsJJsXlXd38dvAahDl5ZAa72s= +github.com/MicahParks/recaptcha v0.0.5 h1:RvKq7E1BZJtz5ubSkBun20jXxIsMWt2oZ0ppTJOzX1A= +github.com/MicahParks/recaptcha v0.0.5/go.mod h1:aFv3iZDDs6Pbi6tRpUm8gofaTUnDxOQ27x5KsK0CZwE= +github.com/MicahParks/templater v0.0.2 h1:N2korNIqBlfJjK1uYq/OQxVStRyFkMsV4eNG0ZM4VK0= +github.com/MicahParks/templater v0.0.2/go.mod h1:N8bUCJg9gdP+hDAZAzfeYuvKZuuMH/MVOKqT3YcH+9g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw= +github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/website/handle/api/inspect.go b/website/handle/api/inspect.go new file mode 100644 index 0000000..c046f88 --- /dev/null +++ b/website/handle/api/inspect.go @@ -0,0 +1,135 @@ +package api + +import ( + "crypto" + "crypto/ecdh" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + + "github.com/MicahParks/httphandle/api" + hhconst "github.com/MicahParks/httphandle/constant" + jt "github.com/MicahParks/jsontype" + "github.com/MicahParks/jwkset" + + jsc "github.com/MicahParks/jwkset/website" + "github.com/MicahParks/jwkset/website/server" +) + +type InspectReq struct { + JWK string `json:"jwk"` +} + +func (i InspectReq) DefaultsAndValidate() (InspectReq, error) { + if i.JWK == "" { + return i, fmt.Errorf(`%w: "jwk" attribute requried`, jt.ErrDefaultsAndValidate) + } + return i, nil +} + +type InspectResp struct { + JWK string `json:"jwk"` + PKCS8 string `json:"pkcs8"` + PKIX string `json:"pkix"` +} + +type Inspect struct { + s server.Server +} + +func (i *Inspect) ApplyMiddleware(h http.Handler) http.Handler { + return h +} +func (i *Inspect) Authorize(w http.ResponseWriter, r *http.Request) (authorized bool, modified *http.Request) { + return authReCAPTCHA("inspect", i.s, w, r) +} +func (i *Inspect) ContentType() (request, response string) { + return hhconst.ContentTypeJSON, hhconst.ContentTypeJSON +} +func (i *Inspect) HTTPMethod() string { + return http.MethodPost +} +func (i *Inspect) Initialize(s server.Server) error { + i.s = s + return nil +} +func (i *Inspect) Respond(r *http.Request) (code int, body []byte, err error) { + reqData, l, ctx, code, body, err := api.ExtractJSON[InspectReq](r) + if err != nil { + return api.ErrorResponse(ctx, code, "Failed to JSON parse request body.") + } + + marshal := jwkset.JWKMarshal{} + err = json.Unmarshal([]byte(reqData.JWK), &marshal) + if err != nil { + return api.ErrorResponse(ctx, http.StatusUnprocessableEntity, "Failed to JSON parse JWK.") + } + + marshalOptions := jwkset.JWKMarshalOptions{ + Private: true, + } + jwk, err := jwkset.NewJWKFromMarshal(marshal, marshalOptions, jwkset.JWKValidateOptions{}) + if err != nil { + return api.ErrorResponse(ctx, http.StatusUnprocessableEntity, fmt.Sprintf("Failed to validate JWK: %s.", err)) + } + key := jwk.Key() + + b, err := json.MarshalIndent(jwk.Marshal(), "", " ") + if err != nil { + return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to JSON marshal JWK: %s.", err)) + } + resp := InspectResp{ + JWK: string(b), + } + + type publicKeyer interface { + Public() crypto.PublicKey + } + + var priv, pub []byte + var block *pem.Block + switch k := key.(type) { + case []byte: + case *ecdh.PrivateKey, ed25519.PrivateKey, *ecdsa.PrivateKey, *rsa.PrivateKey: + priv, err = x509.MarshalPKCS8PrivateKey(k) + if err != nil { + return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to PKCS #8 marshal private key: %s.", err)) + } + pub, err = x509.MarshalPKIXPublicKey(k.(publicKeyer).Public()) + if err != nil { + return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to PKIX marshal public key: %s.", err)) + } + case *ecdh.PublicKey, ed25519.PublicKey, *ecdsa.PublicKey, *rsa.PublicKey: + pub, err = x509.MarshalPKIXPublicKey(k) + if err != nil { + return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to PKIX marshal public key: %s.", err)) + } + default: + return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Unknown key cryptographic key type: %T.", k)) + } + + if priv != nil { + block = &pem.Block{ + Type: "PRIVATE KEY", + Bytes: priv, + } + resp.PKCS8 = string(pem.EncodeToMemory(block)) + } + block = &pem.Block{ + Type: "PUBLIC KEY", + Bytes: pub, + } + resp.PKIX = string(pem.EncodeToMemory(block)) + + l.InfoContext(ctx, "Inspected JWK.") + + return api.RespondJSON(ctx, http.StatusOK, resp) +} +func (i *Inspect) URLPattern() string { + return jsc.PathAPIInspect +} diff --git a/website/handle/api/new_gen.go b/website/handle/api/new_gen.go new file mode 100644 index 0000000..2fc836d --- /dev/null +++ b/website/handle/api/new_gen.go @@ -0,0 +1,254 @@ +package api + +import ( + "crypto/ecdh" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + + "github.com/MicahParks/httphandle/api" + hhconst "github.com/MicahParks/httphandle/constant" + jt "github.com/MicahParks/jsontype" + "github.com/MicahParks/jwkset" + + jsc "github.com/MicahParks/jwkset/website" + "github.com/MicahParks/jwkset/website/server" +) + +type NewGenRespData struct { + JWK string `json:"jwk"` + PKCS8 string `json:"pkcs8"` + PKIX string `json:"pkix"` +} + +const ( + KeyTypeRSA keyType = "RSA" + KeyTypeECDSA keyType = "ECDSA" + KeyTypeEd25519 keyType = "Ed25519" + KeyTypeX25519 keyType = "X25519" + KeyTypeSymmetric keyType = "Symmetric" +) + +type keyType string + +func (k keyType) valid() bool { + switch k { + case KeyTypeRSA, KeyTypeECDSA, KeyTypeEd25519, KeyTypeX25519, KeyTypeSymmetric: + return true + default: + return false + } +} + +type NewGenReqData struct { + ALG jwkset.ALG `json:"alg"` + KEYOPS []jwkset.KEYOPS `json:"keyops"` + KeyType keyType `json:"keyType"` + KID string `json:"kid"` + USE jwkset.USE `json:"use"` + + RSABits int `json:"rsaBits"` + ECCurve jwkset.CRV `json:"ecCurve"` +} + +func (n NewGenReqData) DefaultsAndValidate() (NewGenReqData, error) { + if !n.ALG.IANARegistered() { + return n, fmt.Errorf(`%w: "alg" attribute is not a known IANA registered value`, jt.ErrDefaultsAndValidate) + } + for _, o := range n.KEYOPS { + if !o.IANARegistered() { + return n, fmt.Errorf(`%w: "keyops" attribute is not a known IANA registered value`, jt.ErrDefaultsAndValidate) + } + } + if !n.KeyType.valid() { + return n, fmt.Errorf(`%w: unknown key type`, jt.ErrDefaultsAndValidate) + } + if !n.USE.IANARegistered() { + return n, fmt.Errorf(`%w: "use" attribute is not a known IANA registered value`, jt.ErrDefaultsAndValidate) + } + switch n.KeyType { + case KeyTypeRSA: + switch n.RSABits { + case 1024, 2048, 4096: + default: + return n, fmt.Errorf(`%w: "rsaBits" attribute must be 1024, 2048, or 4096`, jt.ErrDefaultsAndValidate) + } + case KeyTypeECDSA: + switch n.ECCurve { + case jwkset.CrvP256, jwkset.CrvP384, jwkset.CrvP521: + default: + return n, fmt.Errorf(`%w: "ecCurve" attribute must be "P-256", "P-384", or "P-521"`, jt.ErrDefaultsAndValidate) + } + } + return n, nil +} + +type NewGen struct { + s server.Server +} + +func (n *NewGen) ApplyMiddleware(h http.Handler) http.Handler { + return h +} +func (n *NewGen) Authorize(w http.ResponseWriter, r *http.Request) (authorized bool, modified *http.Request) { + return authReCAPTCHA("newGen", n.s, w, r) +} +func (n *NewGen) ContentType() (request, response string) { + return hhconst.ContentTypeJSON, hhconst.ContentTypeJSON +} +func (n *NewGen) HTTPMethod() string { + return http.MethodPost +} +func (n *NewGen) Initialize(s server.Server) error { + n.s = s + return nil +} +func (n *NewGen) Respond(r *http.Request) (code int, body []byte, err error) { + reqData, l, ctx, code, body, err := api.ExtractJSON[NewGenReqData](r) + if err != nil { + return api.ErrorResponse(ctx, code, "Failed to JSON parse request body.") + } + + var priv any + var pub any + switch reqData.KeyType { + case KeyTypeRSA: + rsaPriv, err := rsa.GenerateKey(rand.Reader, reqData.RSABits) + if err != nil { + l.ErrorContext(ctx, + "Failed to generate RSA private key.", + hhconst.LogErr, err, + ) + return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) + } + priv = rsaPriv + pub = rsaPriv.Public() + l.InfoContext(ctx, "Generated RSA private key.") + case KeyTypeECDSA: + var crv elliptic.Curve + switch reqData.ECCurve { + case jwkset.CrvP256: + crv = elliptic.P256() + case jwkset.CrvP384: + crv = elliptic.P384() + case jwkset.CrvP521: + crv = elliptic.P521() + default: + l.ErrorContext(ctx, "Failed to generate EC private key due to unhandled curve.") + return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) + } + ecPriv, err := ecdsa.GenerateKey(crv, rand.Reader) + if err != nil { + l.ErrorContext(ctx, + "Failed to generate EC private key.", + hhconst.LogErr, err, + ) + return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) + } + priv = ecPriv + pub = ecPriv.Public() + l.InfoContext(ctx, "Generated EC private key.") + case KeyTypeEd25519: + _, edPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + l.ErrorContext(ctx, "Failed to generate Ed25519 private key.") + return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) + } + priv = edPriv + pub = edPriv.Public() + l.InfoContext(ctx, "Generated Ed25519 private key.") + case KeyTypeX25519: + xPriv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + l.ErrorContext(ctx, "Failed to generate X25519 private key.") + return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) + } + priv = xPriv + pub = xPriv.Public() + l.InfoContext(ctx, "Generated X25519 private key.") + case KeyTypeSymmetric: + b := make([]byte, 64) + _, err := rand.Read(b) + if err != nil { + l.ErrorContext(ctx, "Failed to generate octet sequence.") + return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) + } + priv = b + l.InfoContext(ctx, "Generated octet sequence.") + default: + l.ErrorContext(ctx, "Failed to generate key due to unhandled key type.") + return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) + } + + marshalOptions := jwkset.JWKMarshalOptions{ + Private: true, + } + metadata := jwkset.JWKMetadataOptions{ + ALG: reqData.ALG, + KID: reqData.KID, + KEYOPS: reqData.KEYOPS, + USE: reqData.USE, + } + options := jwkset.JWKOptions{ + Marshal: marshalOptions, + Metadata: metadata, + } + jwk, err := jwkset.NewJWKFromKey(priv, options) + if err != nil { + l.ErrorContext(ctx, "Failed to create JWK from key.", + hhconst.LogErr, err, + ) + return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) + } + + j, err := json.MarshalIndent(jwk.Marshal(), "", " ") + if err != nil { + l.ErrorContext(ctx, "Failed to marshal JWK.", + hhconst.LogErr, err, + ) + return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) + } + + var pkcs8 string + var pkix string + if reqData.KeyType != KeyTypeSymmetric { + p, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + l.InfoContext(ctx, "Failed to marshal private key to PKCS8.") + return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) + } + block := &pem.Block{ + Type: "PRIVATE KEY", + Bytes: p, + } + pkcs8 = string(pem.EncodeToMemory(block)) + p, err = x509.MarshalPKIXPublicKey(pub) + if err != nil { + l.InfoContext(ctx, "Failed to marshal public key to PKIX.") + return api.ErrorResponse(ctx, http.StatusInternalServerError, hhconst.RespInternalServerError) + } + block = &pem.Block{ + Type: "PUBLIC KEY", + Bytes: p, + } + pkix = string(pem.EncodeToMemory(block)) + } + + respData := NewGenRespData{ + JWK: string(j), + PKCS8: pkcs8, + PKIX: pkix, + } + + return api.RespondJSON(ctx, http.StatusOK, respData) +} +func (n *NewGen) URLPattern() string { + return jsc.PathAPINewGen +} diff --git a/website/handle/api/pem_gen.go b/website/handle/api/pem_gen.go new file mode 100644 index 0000000..a3ed841 --- /dev/null +++ b/website/handle/api/pem_gen.go @@ -0,0 +1,139 @@ +package api + +import ( + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + + "github.com/MicahParks/httphandle/api" + hhconst "github.com/MicahParks/httphandle/constant" + jt "github.com/MicahParks/jsontype" + "github.com/MicahParks/jwkset" + + jsc "github.com/MicahParks/jwkset/website" + "github.com/MicahParks/jwkset/website/server" +) + +type PemGenRespData struct { + JWK string `json:"jwk"` +} + +type PemGenReqData struct { + ALG jwkset.ALG `json:"alg"` + KEYOPS []jwkset.KEYOPS `json:"keyops"` + KID string `json:"kid"` + PEM string `json:"pem"` + USE jwkset.USE `json:"use"` +} + +func (p PemGenReqData) DefaultsAndValidate() (PemGenReqData, error) { + if p.PEM == "" { + return p, fmt.Errorf(`%w: "pem" attribute requried`, jt.ErrDefaultsAndValidate) + } + if !p.ALG.IANARegistered() { + return p, fmt.Errorf(`%w: "alg" attribute invalid`, jt.ErrDefaultsAndValidate) + } + for _, o := range p.KEYOPS { + if !o.IANARegistered() { + return p, fmt.Errorf(`%w: "keyops" attribute invalid`, jt.ErrDefaultsAndValidate) + } + } + if !p.USE.IANARegistered() { + return p, fmt.Errorf(`%w: "use" attribute invalid`, jt.ErrDefaultsAndValidate) + } + return p, nil +} + +type PemGen struct { + s server.Server +} + +func (p *PemGen) ApplyMiddleware(h http.Handler) http.Handler { + return h +} +func (p *PemGen) Authorize(w http.ResponseWriter, r *http.Request) (authorized bool, modified *http.Request) { + return authReCAPTCHA("pemGen", p.s, w, r) +} +func (p *PemGen) ContentType() (request, response string) { + return hhconst.ContentTypeJSON, hhconst.ContentTypeJSON +} +func (p *PemGen) HTTPMethod() string { + return http.MethodPost +} +func (p *PemGen) Initialize(s server.Server) error { + p.s = s + return nil +} +func (p *PemGen) Respond(r *http.Request) (code int, body []byte, err error) { + reqData, l, ctx, code, body, err := api.ExtractJSON[PemGenReqData](r) + if err != nil { + return api.ErrorResponse(ctx, code, "Failed to JSON parse request body.") + } + + rawPEM := []byte(reqData.PEM) + block, _ := pem.Decode(rawPEM) + if block == nil { + return api.ErrorResponse(ctx, http.StatusBadRequest, fmt.Sprintf("Failed to decode PEM block.")) + } + + marshalOptions := jwkset.JWKMarshalOptions{ + Private: true, + } + metadata := jwkset.JWKMetadataOptions{ + ALG: reqData.ALG, + KID: reqData.KID, + KEYOPS: reqData.KEYOPS, + USE: reqData.USE, + } + + var jwk jwkset.JWK + switch block.Type { + case "CERTIFICATE": + certs, err := jwkset.LoadCertificates(rawPEM) + if err != nil { + return api.ErrorResponse(ctx, http.StatusBadRequest, fmt.Sprintf("Failed to load certificates: %s.", err)) + } + x509Options := jwkset.JWKX509Options{ + X5C: certs, + } + options := jwkset.JWKOptions{ + Marshal: marshalOptions, + Metadata: metadata, + X509: x509Options, + } + jwk, err = jwkset.NewJWKFromX5C(options) + if err != nil { + return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to create JWK from X5C: %s.", err)) + } + l.InfoContext(ctx, "Created JWK from certificate.") + default: + key, err := jwkset.LoadX509KeyInfer(block) + if err != nil { + return api.ErrorResponse(ctx, http.StatusBadRequest, fmt.Sprintf("Failed to load X509 key: %s.", err)) + } + options := jwkset.JWKOptions{ + Marshal: marshalOptions, + Metadata: metadata, + } + jwk, err = jwkset.NewJWKFromKey(key, options) + if err != nil { + return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to create JWK from key: %s.", err)) + } + l.InfoContext(ctx, "Created JWK from key.") + } + + j, err := json.MarshalIndent(jwk.Marshal(), "", " ") + if err != nil { + return api.ErrorResponse(ctx, http.StatusInternalServerError, fmt.Sprintf("Failed to marshal JSON: %s.", err)) + } + + respData := PemGenRespData{ + JWK: string(j), + } + + return api.RespondJSON(ctx, http.StatusOK, respData) +} +func (p *PemGen) URLPattern() string { + return jsc.PathAPIPemGen +} diff --git a/website/handle/api/util.go b/website/handle/api/util.go new file mode 100644 index 0000000..842dfc3 --- /dev/null +++ b/website/handle/api/util.go @@ -0,0 +1,42 @@ +package api + +import ( + "log/slog" + "net/http" + + "github.com/MicahParks/httphandle/api" + hhconst "github.com/MicahParks/httphandle/constant" + "github.com/MicahParks/httphandle/middleware/ctxkey" + "github.com/MicahParks/recaptcha" + + "github.com/MicahParks/jwkset/website/server" +) + +func authReCAPTCHA(action string, s server.Server, w http.ResponseWriter, r *http.Request) (bool, *http.Request) { + if s.Conf.ReCAPTCHA.SiteKey == "" { + return true, r + } + ctx := r.Context() + l := ctx.Value(ctxkey.Logger).(*slog.Logger) + token := r.Header.Get("g-recaptcha-response") + resp, err := s.Verifier.Verify(ctx, token, "") + if err != nil { + l.InfoContext(ctx, "Failed to verify reCAPTCHA response.", + hhconst.LogErr, err, + ) + return api.AuthorizeError(ctx, http.StatusUnauthorized, "reCAPTCHA verification failed.", w) + } + options := recaptcha.V3ResponseCheckOptions{ + Action: []string{action}, + Hostname: s.Conf.ReCAPTCHA.Hostname, + Score: s.Conf.ReCAPTCHA.ScoreMin, + } + err = resp.Check(options) + if err != nil { + l.InfoContext(ctx, "Failed reCAPTCHA response check.", + hhconst.LogErr, err, + ) + return false, r + } + return true, r +} diff --git a/website/handle/template/generate.go b/website/handle/template/generate.go new file mode 100644 index 0000000..46fd12b --- /dev/null +++ b/website/handle/template/generate.go @@ -0,0 +1,47 @@ +package template + +import ( + "net/http" + + hh "github.com/MicahParks/httphandle" + "github.com/MicahParks/httphandle/middleware" + + jsc "github.com/MicahParks/jwkset/website" + "github.com/MicahParks/jwkset/website/server" +) + +type GenerateData struct { + WrapperData *server.WrapperData +} + +type Generate struct { + s server.Server +} + +func (i *Generate) ApplyMiddleware(h http.Handler) http.Handler { + cache := middleware.CreateCacheControl(middleware.CacheDefaults) + return cache(middleware.EncodeGzip(h)) +} +func (i *Generate) Authorize(_ http.ResponseWriter, r *http.Request) (authorized bool, modified *http.Request, skipTemplate bool) { + return true, r, false +} +func (i *Generate) Initialize(s server.Server) error { + i.s = s + return nil +} +func (i *Generate) Respond(r *http.Request) (meta hh.TemplateRespMeta, templateData any, wrapperData hh.WrapperData) { + w := i.s.WrapperData(r) + w.Title = "Generate - JWK Set" + tData := GenerateData{} + tData.WrapperData = w + return meta, tData, w +} +func (i *Generate) TemplateName() string { + return "generate.gohtml" +} +func (i *Generate) URLPattern() string { + return jsc.PathGenerate +} +func (i *Generate) WrapperTemplateName() string { + return jsc.TemplateWrapper +} diff --git a/website/handle/template/index.go b/website/handle/template/index.go new file mode 100644 index 0000000..ca0023a --- /dev/null +++ b/website/handle/template/index.go @@ -0,0 +1,48 @@ +package template + +import ( + "net/http" + + hh "github.com/MicahParks/httphandle" + hhconst "github.com/MicahParks/httphandle/constant" + "github.com/MicahParks/httphandle/middleware" + + jsc "github.com/MicahParks/jwkset/website" + "github.com/MicahParks/jwkset/website/server" +) + +type IndexData struct { + WrapperData *server.WrapperData +} + +type Index struct { + s server.Server +} + +func (i *Index) ApplyMiddleware(h http.Handler) http.Handler { + cache := middleware.CreateCacheControl(middleware.CacheDefaults) + return cache(middleware.EncodeGzip(h)) +} +func (i *Index) Authorize(_ http.ResponseWriter, r *http.Request) (authorized bool, modified *http.Request, skipTemplate bool) { + return true, r, false +} +func (i *Index) Initialize(s server.Server) error { + i.s = s + return nil +} +func (i *Index) Respond(req *http.Request) (meta hh.TemplateRespMeta, templateData any, wrapperData hh.WrapperData) { + w := i.s.WrapperData(req) + w.Title = "Home - JWK Set" + tData := IndexData{} + tData.WrapperData = w + return meta, tData, w +} +func (i *Index) TemplateName() string { + return "index.gohtml" +} +func (i *Index) URLPattern() string { + return hhconst.PathIndex +} +func (i *Index) WrapperTemplateName() string { + return jsc.TemplateWrapper +} diff --git a/website/handle/template/inspect.go b/website/handle/template/inspect.go new file mode 100644 index 0000000..7b62592 --- /dev/null +++ b/website/handle/template/inspect.go @@ -0,0 +1,47 @@ +package template + +import ( + "net/http" + + hh "github.com/MicahParks/httphandle" + "github.com/MicahParks/httphandle/middleware" + + jsc "github.com/MicahParks/jwkset/website" + "github.com/MicahParks/jwkset/website/server" +) + +type InspectData struct { + WrapperData *server.WrapperData +} + +type Inspect struct { + s server.Server +} + +func (i *Inspect) ApplyMiddleware(h http.Handler) http.Handler { + cache := middleware.CreateCacheControl(middleware.CacheDefaults) + return cache(middleware.EncodeGzip(h)) +} +func (i *Inspect) Authorize(w http.ResponseWriter, r *http.Request) (authorized bool, modified *http.Request, skipTemplate bool) { + return true, r, false +} +func (i *Inspect) Initialize(s server.Server) error { + i.s = s + return nil +} +func (i *Inspect) Respond(r *http.Request) (meta hh.TemplateRespMeta, templateData any, wrapperData hh.WrapperData) { + w := i.s.WrapperData(r) + w.Title = "Inspect - JWK Set" + tData := InspectData{} + tData.WrapperData = w + return meta, tData, w +} +func (i *Inspect) TemplateName() string { + return "inspect.gohtml" +} +func (i *Inspect) URLPattern() string { + return jsc.PathInspect +} +func (i *Inspect) WrapperTemplateName() string { + return jsc.TemplateWrapper +} diff --git a/website/input.css b/website/input.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/website/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 0000000..1b209d8 --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,1078 @@ +{ + "name": "jwksetcom", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@tailwindcss/aspect-ratio": "^0.4.2", + "@tailwindcss/forms": "^0.5.7", + "@tailwindcss/typography": "^0.5.10", + "tailwindcss": "^3.3.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@tailwindcss/aspect-ratio": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz", + "integrity": "sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==", + "dev": true, + "peerDependencies": { + "tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz", + "integrity": "sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==", + "dev": true, + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.10.tgz", + "integrity": "sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==", + "dev": true, + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", + "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.11" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", + "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "7.1.6", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz", + "integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.19.1", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", + "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "dev": true, + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..cdbd6b5 --- /dev/null +++ b/website/package.json @@ -0,0 +1,8 @@ +{ + "devDependencies": { + "@tailwindcss/aspect-ratio": "^0.4.2", + "@tailwindcss/forms": "^0.5.7", + "@tailwindcss/typography": "^0.5.10", + "tailwindcss": "^3.3.5" + } +} diff --git a/website/static/apple-touch-icon.png b/website/static/apple-touch-icon.png new file mode 100644 index 0000000..404fada Binary files /dev/null and b/website/static/apple-touch-icon.png differ diff --git a/website/static/css/all.min.css b/website/static/css/all.min.css new file mode 100644 index 0000000..e80fd6c --- /dev/null +++ b/website/static/css/all.min.css @@ -0,0 +1,9 @@ +/*! + * Font Awesome Free 6.5.0 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2023 Fonticons, Inc. + */ +.fa{font-family:var(--fa-style-family,"Font Awesome 6 Free");font-weight:var(--fa-style,900)}.fa,.fa-brands,.fa-classic,.fa-regular,.fa-sharp,.fa-solid,.fab,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:var(--fa-display,inline-block);font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}.fa-classic,.fa-regular,.fa-solid,.far,.fas{font-family:"Font Awesome 6 Free"}.fa-brands,.fab{font-family:"Font Awesome 6 Brands"}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{left:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);padding:var(--fa-border-padding,.2em .25em .15em)}.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.fa-beat{-webkit-animation-name:fa-beat;animation-name:fa-beat;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{-webkit-animation-name:fa-bounce;animation-name:fa-bounce;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{-webkit-animation-name:fa-fade;animation-name:fa-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{-webkit-animation-name:fa-beat-fade;animation-name:fa-beat-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{-webkit-animation-name:fa-flip;animation-name:fa-flip;-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{-webkit-animation-name:fa-shake;animation-name:fa-shake;-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{-webkit-animation-delay:var(--fa-animation-delay,0s);animation-delay:var(--fa-animation-delay,0s);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-duration:var(--fa-animation-duration,2s);animation-duration:var(--fa-animation-duration,2s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,steps(8));animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{-webkit-animation-delay:-1ms;animation-delay:-1ms;-webkit-animation-duration:1ms;animation-duration:1ms;-webkit-animation-iteration-count:1;animation-iteration-count:1;-webkit-transition-delay:0s;transition-delay:0s;-webkit-transition-duration:0s;transition-duration:0s}}@-webkit-keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@-webkit-keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@-webkit-keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@-webkit-keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@-webkit-keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@-webkit-keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}.fa-rotate-by{-webkit-transform:rotate(var(--fa-rotate-angle,none));transform:rotate(var(--fa-rotate-angle,none))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%;z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)} + +.fa-0:before{content:"\30"}.fa-1:before{content:"\31"}.fa-2:before{content:"\32"}.fa-3:before{content:"\33"}.fa-4:before{content:"\34"}.fa-5:before{content:"\35"}.fa-6:before{content:"\36"}.fa-7:before{content:"\37"}.fa-8:before{content:"\38"}.fa-9:before{content:"\39"}.fa-fill-drip:before{content:"\f576"}.fa-arrows-to-circle:before{content:"\e4bd"}.fa-chevron-circle-right:before,.fa-circle-chevron-right:before{content:"\f138"}.fa-at:before{content:"\40"}.fa-trash-alt:before,.fa-trash-can:before{content:"\f2ed"}.fa-text-height:before{content:"\f034"}.fa-user-times:before,.fa-user-xmark:before{content:"\f235"}.fa-stethoscope:before{content:"\f0f1"}.fa-comment-alt:before,.fa-message:before{content:"\f27a"}.fa-info:before{content:"\f129"}.fa-compress-alt:before,.fa-down-left-and-up-right-to-center:before{content:"\f422"}.fa-explosion:before{content:"\e4e9"}.fa-file-alt:before,.fa-file-lines:before,.fa-file-text:before{content:"\f15c"}.fa-wave-square:before{content:"\f83e"}.fa-ring:before{content:"\f70b"}.fa-building-un:before{content:"\e4d9"}.fa-dice-three:before{content:"\f527"}.fa-calendar-alt:before,.fa-calendar-days:before{content:"\f073"}.fa-anchor-circle-check:before{content:"\e4aa"}.fa-building-circle-arrow-right:before{content:"\e4d1"}.fa-volleyball-ball:before,.fa-volleyball:before{content:"\f45f"}.fa-arrows-up-to-line:before{content:"\e4c2"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-circle-minus:before,.fa-minus-circle:before{content:"\f056"}.fa-door-open:before{content:"\f52b"}.fa-right-from-bracket:before,.fa-sign-out-alt:before{content:"\f2f5"}.fa-atom:before{content:"\f5d2"}.fa-soap:before{content:"\e06e"}.fa-heart-music-camera-bolt:before,.fa-icons:before{content:"\f86d"}.fa-microphone-alt-slash:before,.fa-microphone-lines-slash:before{content:"\f539"}.fa-bridge-circle-check:before{content:"\e4c9"}.fa-pump-medical:before{content:"\e06a"}.fa-fingerprint:before{content:"\f577"}.fa-hand-point-right:before{content:"\f0a4"}.fa-magnifying-glass-location:before,.fa-search-location:before{content:"\f689"}.fa-forward-step:before,.fa-step-forward:before{content:"\f051"}.fa-face-smile-beam:before,.fa-smile-beam:before{content:"\f5b8"}.fa-flag-checkered:before{content:"\f11e"}.fa-football-ball:before,.fa-football:before{content:"\f44e"}.fa-school-circle-exclamation:before{content:"\e56c"}.fa-crop:before{content:"\f125"}.fa-angle-double-down:before,.fa-angles-down:before{content:"\f103"}.fa-users-rectangle:before{content:"\e594"}.fa-people-roof:before{content:"\e537"}.fa-people-line:before{content:"\e534"}.fa-beer-mug-empty:before,.fa-beer:before{content:"\f0fc"}.fa-diagram-predecessor:before{content:"\e477"}.fa-arrow-up-long:before,.fa-long-arrow-up:before{content:"\f176"}.fa-burn:before,.fa-fire-flame-simple:before{content:"\f46a"}.fa-male:before,.fa-person:before{content:"\f183"}.fa-laptop:before{content:"\f109"}.fa-file-csv:before{content:"\f6dd"}.fa-menorah:before{content:"\f676"}.fa-truck-plane:before{content:"\e58f"}.fa-record-vinyl:before{content:"\f8d9"}.fa-face-grin-stars:before,.fa-grin-stars:before{content:"\f587"}.fa-bong:before{content:"\f55c"}.fa-pastafarianism:before,.fa-spaghetti-monster-flying:before{content:"\f67b"}.fa-arrow-down-up-across-line:before{content:"\e4af"}.fa-spoon:before,.fa-utensil-spoon:before{content:"\f2e5"}.fa-jar-wheat:before{content:"\e517"}.fa-envelopes-bulk:before,.fa-mail-bulk:before{content:"\f674"}.fa-file-circle-exclamation:before{content:"\e4eb"}.fa-circle-h:before,.fa-hospital-symbol:before{content:"\f47e"}.fa-pager:before{content:"\f815"}.fa-address-book:before,.fa-contact-book:before{content:"\f2b9"}.fa-strikethrough:before{content:"\f0cc"}.fa-k:before{content:"\4b"}.fa-landmark-flag:before{content:"\e51c"}.fa-pencil-alt:before,.fa-pencil:before{content:"\f303"}.fa-backward:before{content:"\f04a"}.fa-caret-right:before{content:"\f0da"}.fa-comments:before{content:"\f086"}.fa-file-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-code-pull-request:before{content:"\e13c"}.fa-clipboard-list:before{content:"\f46d"}.fa-truck-loading:before,.fa-truck-ramp-box:before{content:"\f4de"}.fa-user-check:before{content:"\f4fc"}.fa-vial-virus:before{content:"\e597"}.fa-sheet-plastic:before{content:"\e571"}.fa-blog:before{content:"\f781"}.fa-user-ninja:before{content:"\f504"}.fa-person-arrow-up-from-line:before{content:"\e539"}.fa-scroll-torah:before,.fa-torah:before{content:"\f6a0"}.fa-broom-ball:before,.fa-quidditch-broom-ball:before,.fa-quidditch:before{content:"\f458"}.fa-toggle-off:before{content:"\f204"}.fa-archive:before,.fa-box-archive:before{content:"\f187"}.fa-person-drowning:before{content:"\e545"}.fa-arrow-down-9-1:before,.fa-sort-numeric-desc:before,.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-face-grin-tongue-squint:before,.fa-grin-tongue-squint:before{content:"\f58a"}.fa-spray-can:before{content:"\f5bd"}.fa-truck-monster:before{content:"\f63b"}.fa-w:before{content:"\57"}.fa-earth-africa:before,.fa-globe-africa:before{content:"\f57c"}.fa-rainbow:before{content:"\f75b"}.fa-circle-notch:before{content:"\f1ce"}.fa-tablet-alt:before,.fa-tablet-screen-button:before{content:"\f3fa"}.fa-paw:before{content:"\f1b0"}.fa-cloud:before{content:"\f0c2"}.fa-trowel-bricks:before{content:"\e58a"}.fa-face-flushed:before,.fa-flushed:before{content:"\f579"}.fa-hospital-user:before{content:"\f80d"}.fa-tent-arrow-left-right:before{content:"\e57f"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-binoculars:before{content:"\f1e5"}.fa-microphone-slash:before{content:"\f131"}.fa-box-tissue:before{content:"\e05b"}.fa-motorcycle:before{content:"\f21c"}.fa-bell-concierge:before,.fa-concierge-bell:before{content:"\f562"}.fa-pen-ruler:before,.fa-pencil-ruler:before{content:"\f5ae"}.fa-people-arrows-left-right:before,.fa-people-arrows:before{content:"\e068"}.fa-mars-and-venus-burst:before{content:"\e523"}.fa-caret-square-right:before,.fa-square-caret-right:before{content:"\f152"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-sun-plant-wilt:before{content:"\e57a"}.fa-toilets-portable:before{content:"\e584"}.fa-hockey-puck:before{content:"\f453"}.fa-table:before{content:"\f0ce"}.fa-magnifying-glass-arrow-right:before{content:"\e521"}.fa-digital-tachograph:before,.fa-tachograph-digital:before{content:"\f566"}.fa-users-slash:before{content:"\e073"}.fa-clover:before{content:"\e139"}.fa-mail-reply:before,.fa-reply:before{content:"\f3e5"}.fa-star-and-crescent:before{content:"\f699"}.fa-house-fire:before{content:"\e50c"}.fa-minus-square:before,.fa-square-minus:before{content:"\f146"}.fa-helicopter:before{content:"\f533"}.fa-compass:before{content:"\f14e"}.fa-caret-square-down:before,.fa-square-caret-down:before{content:"\f150"}.fa-file-circle-question:before{content:"\e4ef"}.fa-laptop-code:before{content:"\f5fc"}.fa-swatchbook:before{content:"\f5c3"}.fa-prescription-bottle:before{content:"\f485"}.fa-bars:before,.fa-navicon:before{content:"\f0c9"}.fa-people-group:before{content:"\e533"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-heart-broken:before,.fa-heart-crack:before{content:"\f7a9"}.fa-external-link-square-alt:before,.fa-square-up-right:before{content:"\f360"}.fa-face-kiss-beam:before,.fa-kiss-beam:before{content:"\f597"}.fa-film:before{content:"\f008"}.fa-ruler-horizontal:before{content:"\f547"}.fa-people-robbery:before{content:"\e536"}.fa-lightbulb:before{content:"\f0eb"}.fa-caret-left:before{content:"\f0d9"}.fa-circle-exclamation:before,.fa-exclamation-circle:before{content:"\f06a"}.fa-school-circle-xmark:before{content:"\e56d"}.fa-arrow-right-from-bracket:before,.fa-sign-out:before{content:"\f08b"}.fa-chevron-circle-down:before,.fa-circle-chevron-down:before{content:"\f13a"}.fa-unlock-alt:before,.fa-unlock-keyhole:before{content:"\f13e"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-headphones-alt:before,.fa-headphones-simple:before{content:"\f58f"}.fa-sitemap:before{content:"\f0e8"}.fa-circle-dollar-to-slot:before,.fa-donate:before{content:"\f4b9"}.fa-memory:before{content:"\f538"}.fa-road-spikes:before{content:"\e568"}.fa-fire-burner:before{content:"\e4f1"}.fa-flag:before{content:"\f024"}.fa-hanukiah:before{content:"\f6e6"}.fa-feather:before{content:"\f52d"}.fa-volume-down:before,.fa-volume-low:before{content:"\f027"}.fa-comment-slash:before{content:"\f4b3"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-compress:before{content:"\f066"}.fa-wheat-alt:before,.fa-wheat-awn:before{content:"\e2cd"}.fa-ankh:before{content:"\f644"}.fa-hands-holding-child:before{content:"\e4fa"}.fa-asterisk:before{content:"\2a"}.fa-check-square:before,.fa-square-check:before{content:"\f14a"}.fa-peseta-sign:before{content:"\e221"}.fa-header:before,.fa-heading:before{content:"\f1dc"}.fa-ghost:before{content:"\f6e2"}.fa-list-squares:before,.fa-list:before{content:"\f03a"}.fa-phone-square-alt:before,.fa-square-phone-flip:before{content:"\f87b"}.fa-cart-plus:before{content:"\f217"}.fa-gamepad:before{content:"\f11b"}.fa-circle-dot:before,.fa-dot-circle:before{content:"\f192"}.fa-dizzy:before,.fa-face-dizzy:before{content:"\f567"}.fa-egg:before{content:"\f7fb"}.fa-house-medical-circle-xmark:before{content:"\e513"}.fa-campground:before{content:"\f6bb"}.fa-folder-plus:before{content:"\f65e"}.fa-futbol-ball:before,.fa-futbol:before,.fa-soccer-ball:before{content:"\f1e3"}.fa-paint-brush:before,.fa-paintbrush:before{content:"\f1fc"}.fa-lock:before{content:"\f023"}.fa-gas-pump:before{content:"\f52f"}.fa-hot-tub-person:before,.fa-hot-tub:before{content:"\f593"}.fa-map-location:before,.fa-map-marked:before{content:"\f59f"}.fa-house-flood-water:before{content:"\e50e"}.fa-tree:before{content:"\f1bb"}.fa-bridge-lock:before{content:"\e4cc"}.fa-sack-dollar:before{content:"\f81d"}.fa-edit:before,.fa-pen-to-square:before{content:"\f044"}.fa-car-side:before{content:"\f5e4"}.fa-share-alt:before,.fa-share-nodes:before{content:"\f1e0"}.fa-heart-circle-minus:before{content:"\e4ff"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-microscope:before{content:"\f610"}.fa-sink:before{content:"\e06d"}.fa-bag-shopping:before,.fa-shopping-bag:before{content:"\f290"}.fa-arrow-down-z-a:before,.fa-sort-alpha-desc:before,.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-mitten:before{content:"\f7b5"}.fa-person-rays:before{content:"\e54d"}.fa-users:before{content:"\f0c0"}.fa-eye-slash:before{content:"\f070"}.fa-flask-vial:before{content:"\e4f3"}.fa-hand-paper:before,.fa-hand:before{content:"\f256"}.fa-om:before{content:"\f679"}.fa-worm:before{content:"\e599"}.fa-house-circle-xmark:before{content:"\e50b"}.fa-plug:before{content:"\f1e6"}.fa-chevron-up:before{content:"\f077"}.fa-hand-spock:before{content:"\f259"}.fa-stopwatch:before{content:"\f2f2"}.fa-face-kiss:before,.fa-kiss:before{content:"\f596"}.fa-bridge-circle-xmark:before{content:"\e4cb"}.fa-face-grin-tongue:before,.fa-grin-tongue:before{content:"\f589"}.fa-chess-bishop:before{content:"\f43a"}.fa-face-grin-wink:before,.fa-grin-wink:before{content:"\f58c"}.fa-deaf:before,.fa-deafness:before,.fa-ear-deaf:before,.fa-hard-of-hearing:before{content:"\f2a4"}.fa-road-circle-check:before{content:"\e564"}.fa-dice-five:before{content:"\f523"}.fa-rss-square:before,.fa-square-rss:before{content:"\f143"}.fa-land-mine-on:before{content:"\e51b"}.fa-i-cursor:before{content:"\f246"}.fa-stamp:before{content:"\f5bf"}.fa-stairs:before{content:"\e289"}.fa-i:before{content:"\49"}.fa-hryvnia-sign:before,.fa-hryvnia:before{content:"\f6f2"}.fa-pills:before{content:"\f484"}.fa-face-grin-wide:before,.fa-grin-alt:before{content:"\f581"}.fa-tooth:before{content:"\f5c9"}.fa-v:before{content:"\56"}.fa-bangladeshi-taka-sign:before{content:"\e2e6"}.fa-bicycle:before{content:"\f206"}.fa-rod-asclepius:before,.fa-rod-snake:before,.fa-staff-aesculapius:before,.fa-staff-snake:before{content:"\e579"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-ambulance:before,.fa-truck-medical:before{content:"\f0f9"}.fa-wheat-awn-circle-exclamation:before{content:"\e598"}.fa-snowman:before{content:"\f7d0"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-road-barrier:before{content:"\e562"}.fa-school:before{content:"\f549"}.fa-igloo:before{content:"\f7ae"}.fa-joint:before{content:"\f595"}.fa-angle-right:before{content:"\f105"}.fa-horse:before{content:"\f6f0"}.fa-q:before{content:"\51"}.fa-g:before{content:"\47"}.fa-notes-medical:before{content:"\f481"}.fa-temperature-2:before,.fa-temperature-half:before,.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-dong-sign:before{content:"\e169"}.fa-capsules:before{content:"\f46b"}.fa-poo-bolt:before,.fa-poo-storm:before{content:"\f75a"}.fa-face-frown-open:before,.fa-frown-open:before{content:"\f57a"}.fa-hand-point-up:before{content:"\f0a6"}.fa-money-bill:before{content:"\f0d6"}.fa-bookmark:before{content:"\f02e"}.fa-align-justify:before{content:"\f039"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-helmet-un:before{content:"\e503"}.fa-bullseye:before{content:"\f140"}.fa-bacon:before{content:"\f7e5"}.fa-hand-point-down:before{content:"\f0a7"}.fa-arrow-up-from-bracket:before{content:"\e09a"}.fa-folder-blank:before,.fa-folder:before{content:"\f07b"}.fa-file-medical-alt:before,.fa-file-waveform:before{content:"\f478"}.fa-radiation:before{content:"\f7b9"}.fa-chart-simple:before{content:"\e473"}.fa-mars-stroke:before{content:"\f229"}.fa-vial:before{content:"\f492"}.fa-dashboard:before,.fa-gauge-med:before,.fa-gauge:before,.fa-tachometer-alt-average:before{content:"\f624"}.fa-magic-wand-sparkles:before,.fa-wand-magic-sparkles:before{content:"\e2ca"}.fa-e:before{content:"\45"}.fa-pen-alt:before,.fa-pen-clip:before{content:"\f305"}.fa-bridge-circle-exclamation:before{content:"\e4ca"}.fa-user:before{content:"\f007"}.fa-school-circle-check:before{content:"\e56b"}.fa-dumpster:before{content:"\f793"}.fa-shuttle-van:before,.fa-van-shuttle:before{content:"\f5b6"}.fa-building-user:before{content:"\e4da"}.fa-caret-square-left:before,.fa-square-caret-left:before{content:"\f191"}.fa-highlighter:before{content:"\f591"}.fa-key:before{content:"\f084"}.fa-bullhorn:before{content:"\f0a1"}.fa-globe:before{content:"\f0ac"}.fa-synagogue:before{content:"\f69b"}.fa-person-half-dress:before{content:"\e548"}.fa-road-bridge:before{content:"\e563"}.fa-location-arrow:before{content:"\f124"}.fa-c:before{content:"\43"}.fa-tablet-button:before{content:"\f10a"}.fa-building-lock:before{content:"\e4d6"}.fa-pizza-slice:before{content:"\f818"}.fa-money-bill-wave:before{content:"\f53a"}.fa-area-chart:before,.fa-chart-area:before{content:"\f1fe"}.fa-house-flag:before{content:"\e50d"}.fa-person-circle-minus:before{content:"\e540"}.fa-ban:before,.fa-cancel:before{content:"\f05e"}.fa-camera-rotate:before{content:"\e0d8"}.fa-air-freshener:before,.fa-spray-can-sparkles:before{content:"\f5d0"}.fa-star:before{content:"\f005"}.fa-repeat:before{content:"\f363"}.fa-cross:before{content:"\f654"}.fa-box:before{content:"\f466"}.fa-venus-mars:before{content:"\f228"}.fa-arrow-pointer:before,.fa-mouse-pointer:before{content:"\f245"}.fa-expand-arrows-alt:before,.fa-maximize:before{content:"\f31e"}.fa-charging-station:before{content:"\f5e7"}.fa-shapes:before,.fa-triangle-circle-square:before{content:"\f61f"}.fa-random:before,.fa-shuffle:before{content:"\f074"}.fa-person-running:before,.fa-running:before{content:"\f70c"}.fa-mobile-retro:before{content:"\e527"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-spider:before{content:"\f717"}.fa-hands-bound:before{content:"\e4f9"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-plane-circle-exclamation:before{content:"\e556"}.fa-x-ray:before{content:"\f497"}.fa-spell-check:before{content:"\f891"}.fa-slash:before{content:"\f715"}.fa-computer-mouse:before,.fa-mouse:before{content:"\f8cc"}.fa-arrow-right-to-bracket:before,.fa-sign-in:before{content:"\f090"}.fa-shop-slash:before,.fa-store-alt-slash:before{content:"\e070"}.fa-server:before{content:"\f233"}.fa-virus-covid-slash:before{content:"\e4a9"}.fa-shop-lock:before{content:"\e4a5"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-blender-phone:before{content:"\f6b6"}.fa-building-wheat:before{content:"\e4db"}.fa-person-breastfeeding:before{content:"\e53a"}.fa-right-to-bracket:before,.fa-sign-in-alt:before{content:"\f2f6"}.fa-venus:before{content:"\f221"}.fa-passport:before{content:"\f5ab"}.fa-heart-pulse:before,.fa-heartbeat:before{content:"\f21e"}.fa-people-carry-box:before,.fa-people-carry:before{content:"\f4ce"}.fa-temperature-high:before{content:"\f769"}.fa-microchip:before{content:"\f2db"}.fa-crown:before{content:"\f521"}.fa-weight-hanging:before{content:"\f5cd"}.fa-xmarks-lines:before{content:"\e59a"}.fa-file-prescription:before{content:"\f572"}.fa-weight-scale:before,.fa-weight:before{content:"\f496"}.fa-user-friends:before,.fa-user-group:before{content:"\f500"}.fa-arrow-up-a-z:before,.fa-sort-alpha-up:before{content:"\f15e"}.fa-chess-knight:before{content:"\f441"}.fa-face-laugh-squint:before,.fa-laugh-squint:before{content:"\f59b"}.fa-wheelchair:before{content:"\f193"}.fa-arrow-circle-up:before,.fa-circle-arrow-up:before{content:"\f0aa"}.fa-toggle-on:before{content:"\f205"}.fa-person-walking:before,.fa-walking:before{content:"\f554"}.fa-l:before{content:"\4c"}.fa-fire:before{content:"\f06d"}.fa-bed-pulse:before,.fa-procedures:before{content:"\f487"}.fa-shuttle-space:before,.fa-space-shuttle:before{content:"\f197"}.fa-face-laugh:before,.fa-laugh:before{content:"\f599"}.fa-folder-open:before{content:"\f07c"}.fa-heart-circle-plus:before{content:"\e500"}.fa-code-fork:before{content:"\e13b"}.fa-city:before{content:"\f64f"}.fa-microphone-alt:before,.fa-microphone-lines:before{content:"\f3c9"}.fa-pepper-hot:before{content:"\f816"}.fa-unlock:before{content:"\f09c"}.fa-colon-sign:before{content:"\e140"}.fa-headset:before{content:"\f590"}.fa-store-slash:before{content:"\e071"}.fa-road-circle-xmark:before{content:"\e566"}.fa-user-minus:before{content:"\f503"}.fa-mars-stroke-up:before,.fa-mars-stroke-v:before{content:"\f22a"}.fa-champagne-glasses:before,.fa-glass-cheers:before{content:"\f79f"}.fa-clipboard:before{content:"\f328"}.fa-house-circle-exclamation:before{content:"\e50a"}.fa-file-arrow-up:before,.fa-file-upload:before{content:"\f574"}.fa-wifi-3:before,.fa-wifi-strong:before,.fa-wifi:before{content:"\f1eb"}.fa-bath:before,.fa-bathtub:before{content:"\f2cd"}.fa-underline:before{content:"\f0cd"}.fa-user-edit:before,.fa-user-pen:before{content:"\f4ff"}.fa-signature:before{content:"\f5b7"}.fa-stroopwafel:before{content:"\f551"}.fa-bold:before{content:"\f032"}.fa-anchor-lock:before{content:"\e4ad"}.fa-building-ngo:before{content:"\e4d7"}.fa-manat-sign:before{content:"\e1d5"}.fa-not-equal:before{content:"\f53e"}.fa-border-style:before,.fa-border-top-left:before{content:"\f853"}.fa-map-location-dot:before,.fa-map-marked-alt:before{content:"\f5a0"}.fa-jedi:before{content:"\f669"}.fa-poll:before,.fa-square-poll-vertical:before{content:"\f681"}.fa-mug-hot:before{content:"\f7b6"}.fa-battery-car:before,.fa-car-battery:before{content:"\f5df"}.fa-gift:before{content:"\f06b"}.fa-dice-two:before{content:"\f528"}.fa-chess-queen:before{content:"\f445"}.fa-glasses:before{content:"\f530"}.fa-chess-board:before{content:"\f43c"}.fa-building-circle-check:before{content:"\e4d2"}.fa-person-chalkboard:before{content:"\e53d"}.fa-mars-stroke-h:before,.fa-mars-stroke-right:before{content:"\f22b"}.fa-hand-back-fist:before,.fa-hand-rock:before{content:"\f255"}.fa-caret-square-up:before,.fa-square-caret-up:before{content:"\f151"}.fa-cloud-showers-water:before{content:"\e4e4"}.fa-bar-chart:before,.fa-chart-bar:before{content:"\f080"}.fa-hands-bubbles:before,.fa-hands-wash:before{content:"\e05e"}.fa-less-than-equal:before{content:"\f537"}.fa-train:before{content:"\f238"}.fa-eye-low-vision:before,.fa-low-vision:before{content:"\f2a8"}.fa-crow:before{content:"\f520"}.fa-sailboat:before{content:"\e445"}.fa-window-restore:before{content:"\f2d2"}.fa-plus-square:before,.fa-square-plus:before{content:"\f0fe"}.fa-torii-gate:before{content:"\f6a1"}.fa-frog:before{content:"\f52e"}.fa-bucket:before{content:"\e4cf"}.fa-image:before{content:"\f03e"}.fa-microphone:before{content:"\f130"}.fa-cow:before{content:"\f6c8"}.fa-caret-up:before{content:"\f0d8"}.fa-screwdriver:before{content:"\f54a"}.fa-folder-closed:before{content:"\e185"}.fa-house-tsunami:before{content:"\e515"}.fa-square-nfi:before{content:"\e576"}.fa-arrow-up-from-ground-water:before{content:"\e4b5"}.fa-glass-martini-alt:before,.fa-martini-glass:before{content:"\f57b"}.fa-rotate-back:before,.fa-rotate-backward:before,.fa-rotate-left:before,.fa-undo-alt:before{content:"\f2ea"}.fa-columns:before,.fa-table-columns:before{content:"\f0db"}.fa-lemon:before{content:"\f094"}.fa-head-side-mask:before{content:"\e063"}.fa-handshake:before{content:"\f2b5"}.fa-gem:before{content:"\f3a5"}.fa-dolly-box:before,.fa-dolly:before{content:"\f472"}.fa-smoking:before{content:"\f48d"}.fa-compress-arrows-alt:before,.fa-minimize:before{content:"\f78c"}.fa-monument:before{content:"\f5a6"}.fa-snowplow:before{content:"\f7d2"}.fa-angle-double-right:before,.fa-angles-right:before{content:"\f101"}.fa-cannabis:before{content:"\f55f"}.fa-circle-play:before,.fa-play-circle:before{content:"\f144"}.fa-tablets:before{content:"\f490"}.fa-ethernet:before{content:"\f796"}.fa-eur:before,.fa-euro-sign:before,.fa-euro:before{content:"\f153"}.fa-chair:before{content:"\f6c0"}.fa-check-circle:before,.fa-circle-check:before{content:"\f058"}.fa-circle-stop:before,.fa-stop-circle:before{content:"\f28d"}.fa-compass-drafting:before,.fa-drafting-compass:before{content:"\f568"}.fa-plate-wheat:before{content:"\e55a"}.fa-icicles:before{content:"\f7ad"}.fa-person-shelter:before{content:"\e54f"}.fa-neuter:before{content:"\f22c"}.fa-id-badge:before{content:"\f2c1"}.fa-marker:before{content:"\f5a1"}.fa-face-laugh-beam:before,.fa-laugh-beam:before{content:"\f59a"}.fa-helicopter-symbol:before{content:"\e502"}.fa-universal-access:before{content:"\f29a"}.fa-chevron-circle-up:before,.fa-circle-chevron-up:before{content:"\f139"}.fa-lari-sign:before{content:"\e1c8"}.fa-volcano:before{content:"\f770"}.fa-person-walking-dashed-line-arrow-right:before{content:"\e553"}.fa-gbp:before,.fa-pound-sign:before,.fa-sterling-sign:before{content:"\f154"}.fa-viruses:before{content:"\e076"}.fa-square-person-confined:before{content:"\e577"}.fa-user-tie:before{content:"\f508"}.fa-arrow-down-long:before,.fa-long-arrow-down:before{content:"\f175"}.fa-tent-arrow-down-to-line:before{content:"\e57e"}.fa-certificate:before{content:"\f0a3"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-suitcase:before{content:"\f0f2"}.fa-person-skating:before,.fa-skating:before{content:"\f7c5"}.fa-filter-circle-dollar:before,.fa-funnel-dollar:before{content:"\f662"}.fa-camera-retro:before{content:"\f083"}.fa-arrow-circle-down:before,.fa-circle-arrow-down:before{content:"\f0ab"}.fa-arrow-right-to-file:before,.fa-file-import:before{content:"\f56f"}.fa-external-link-square:before,.fa-square-arrow-up-right:before{content:"\f14c"}.fa-box-open:before{content:"\f49e"}.fa-scroll:before{content:"\f70e"}.fa-spa:before{content:"\f5bb"}.fa-location-pin-lock:before{content:"\e51f"}.fa-pause:before{content:"\f04c"}.fa-hill-avalanche:before{content:"\e507"}.fa-temperature-0:before,.fa-temperature-empty:before,.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-bomb:before{content:"\f1e2"}.fa-registered:before{content:"\f25d"}.fa-address-card:before,.fa-contact-card:before,.fa-vcard:before{content:"\f2bb"}.fa-balance-scale-right:before,.fa-scale-unbalanced-flip:before{content:"\f516"}.fa-subscript:before{content:"\f12c"}.fa-diamond-turn-right:before,.fa-directions:before{content:"\f5eb"}.fa-burst:before{content:"\e4dc"}.fa-house-laptop:before,.fa-laptop-house:before{content:"\e066"}.fa-face-tired:before,.fa-tired:before{content:"\f5c8"}.fa-money-bills:before{content:"\e1f3"}.fa-smog:before{content:"\f75f"}.fa-crutch:before{content:"\f7f7"}.fa-cloud-arrow-up:before,.fa-cloud-upload-alt:before,.fa-cloud-upload:before{content:"\f0ee"}.fa-palette:before{content:"\f53f"}.fa-arrows-turn-right:before{content:"\e4c0"}.fa-vest:before{content:"\e085"}.fa-ferry:before{content:"\e4ea"}.fa-arrows-down-to-people:before{content:"\e4b9"}.fa-seedling:before,.fa-sprout:before{content:"\f4d8"}.fa-arrows-alt-h:before,.fa-left-right:before{content:"\f337"}.fa-boxes-packing:before{content:"\e4c7"}.fa-arrow-circle-left:before,.fa-circle-arrow-left:before{content:"\f0a8"}.fa-group-arrows-rotate:before{content:"\e4f6"}.fa-bowl-food:before{content:"\e4c6"}.fa-candy-cane:before{content:"\f786"}.fa-arrow-down-wide-short:before,.fa-sort-amount-asc:before,.fa-sort-amount-down:before{content:"\f160"}.fa-cloud-bolt:before,.fa-thunderstorm:before{content:"\f76c"}.fa-remove-format:before,.fa-text-slash:before{content:"\f87d"}.fa-face-smile-wink:before,.fa-smile-wink:before{content:"\f4da"}.fa-file-word:before{content:"\f1c2"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-arrows-h:before,.fa-arrows-left-right:before{content:"\f07e"}.fa-house-lock:before{content:"\e510"}.fa-cloud-arrow-down:before,.fa-cloud-download-alt:before,.fa-cloud-download:before{content:"\f0ed"}.fa-children:before{content:"\e4e1"}.fa-blackboard:before,.fa-chalkboard:before{content:"\f51b"}.fa-user-alt-slash:before,.fa-user-large-slash:before{content:"\f4fa"}.fa-envelope-open:before{content:"\f2b6"}.fa-handshake-alt-slash:before,.fa-handshake-simple-slash:before{content:"\e05f"}.fa-mattress-pillow:before{content:"\e525"}.fa-guarani-sign:before{content:"\e19a"}.fa-arrows-rotate:before,.fa-refresh:before,.fa-sync:before{content:"\f021"}.fa-fire-extinguisher:before{content:"\f134"}.fa-cruzeiro-sign:before{content:"\e152"}.fa-greater-than-equal:before{content:"\f532"}.fa-shield-alt:before,.fa-shield-halved:before{content:"\f3ed"}.fa-atlas:before,.fa-book-atlas:before{content:"\f558"}.fa-virus:before{content:"\e074"}.fa-envelope-circle-check:before{content:"\e4e8"}.fa-layer-group:before{content:"\f5fd"}.fa-arrows-to-dot:before{content:"\e4be"}.fa-archway:before{content:"\f557"}.fa-heart-circle-check:before{content:"\e4fd"}.fa-house-chimney-crack:before,.fa-house-damage:before{content:"\f6f1"}.fa-file-archive:before,.fa-file-zipper:before{content:"\f1c6"}.fa-square:before{content:"\f0c8"}.fa-glass-martini:before,.fa-martini-glass-empty:before{content:"\f000"}.fa-couch:before{content:"\f4b8"}.fa-cedi-sign:before{content:"\e0df"}.fa-italic:before{content:"\f033"}.fa-church:before{content:"\f51d"}.fa-comments-dollar:before{content:"\f653"}.fa-democrat:before{content:"\f747"}.fa-z:before{content:"\5a"}.fa-person-skiing:before,.fa-skiing:before{content:"\f7c9"}.fa-road-lock:before{content:"\e567"}.fa-a:before{content:"\41"}.fa-temperature-arrow-down:before,.fa-temperature-down:before{content:"\e03f"}.fa-feather-alt:before,.fa-feather-pointed:before{content:"\f56b"}.fa-p:before{content:"\50"}.fa-snowflake:before{content:"\f2dc"}.fa-newspaper:before{content:"\f1ea"}.fa-ad:before,.fa-rectangle-ad:before{content:"\f641"}.fa-arrow-circle-right:before,.fa-circle-arrow-right:before{content:"\f0a9"}.fa-filter-circle-xmark:before{content:"\e17b"}.fa-locust:before{content:"\e520"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-list-1-2:before,.fa-list-numeric:before,.fa-list-ol:before{content:"\f0cb"}.fa-person-dress-burst:before{content:"\e544"}.fa-money-check-alt:before,.fa-money-check-dollar:before{content:"\f53d"}.fa-vector-square:before{content:"\f5cb"}.fa-bread-slice:before{content:"\f7ec"}.fa-language:before{content:"\f1ab"}.fa-face-kiss-wink-heart:before,.fa-kiss-wink-heart:before{content:"\f598"}.fa-filter:before{content:"\f0b0"}.fa-question:before{content:"\3f"}.fa-file-signature:before{content:"\f573"}.fa-arrows-alt:before,.fa-up-down-left-right:before{content:"\f0b2"}.fa-house-chimney-user:before{content:"\e065"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-puzzle-piece:before{content:"\f12e"}.fa-money-check:before{content:"\f53c"}.fa-star-half-alt:before,.fa-star-half-stroke:before{content:"\f5c0"}.fa-code:before{content:"\f121"}.fa-glass-whiskey:before,.fa-whiskey-glass:before{content:"\f7a0"}.fa-building-circle-exclamation:before{content:"\e4d3"}.fa-magnifying-glass-chart:before{content:"\e522"}.fa-arrow-up-right-from-square:before,.fa-external-link:before{content:"\f08e"}.fa-cubes-stacked:before{content:"\e4e6"}.fa-krw:before,.fa-won-sign:before,.fa-won:before{content:"\f159"}.fa-virus-covid:before{content:"\e4a8"}.fa-austral-sign:before{content:"\e0a9"}.fa-f:before{content:"\46"}.fa-leaf:before{content:"\f06c"}.fa-road:before{content:"\f018"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-person-circle-plus:before{content:"\e541"}.fa-chart-pie:before,.fa-pie-chart:before{content:"\f200"}.fa-bolt-lightning:before{content:"\e0b7"}.fa-sack-xmark:before{content:"\e56a"}.fa-file-excel:before{content:"\f1c3"}.fa-file-contract:before{content:"\f56c"}.fa-fish-fins:before{content:"\e4f2"}.fa-building-flag:before{content:"\e4d5"}.fa-face-grin-beam:before,.fa-grin-beam:before{content:"\f582"}.fa-object-ungroup:before{content:"\f248"}.fa-poop:before{content:"\f619"}.fa-location-pin:before,.fa-map-marker:before{content:"\f041"}.fa-kaaba:before{content:"\f66b"}.fa-toilet-paper:before{content:"\f71e"}.fa-hard-hat:before,.fa-hat-hard:before,.fa-helmet-safety:before{content:"\f807"}.fa-eject:before{content:"\f052"}.fa-arrow-alt-circle-right:before,.fa-circle-right:before{content:"\f35a"}.fa-plane-circle-check:before{content:"\e555"}.fa-face-rolling-eyes:before,.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-object-group:before{content:"\f247"}.fa-chart-line:before,.fa-line-chart:before{content:"\f201"}.fa-mask-ventilator:before{content:"\e524"}.fa-arrow-right:before{content:"\f061"}.fa-map-signs:before,.fa-signs-post:before{content:"\f277"}.fa-cash-register:before{content:"\f788"}.fa-person-circle-question:before{content:"\e542"}.fa-h:before{content:"\48"}.fa-tarp:before{content:"\e57b"}.fa-screwdriver-wrench:before,.fa-tools:before{content:"\f7d9"}.fa-arrows-to-eye:before{content:"\e4bf"}.fa-plug-circle-bolt:before{content:"\e55b"}.fa-heart:before{content:"\f004"}.fa-mars-and-venus:before{content:"\f224"}.fa-home-user:before,.fa-house-user:before{content:"\e1b0"}.fa-dumpster-fire:before{content:"\f794"}.fa-house-crack:before{content:"\e3b1"}.fa-cocktail:before,.fa-martini-glass-citrus:before{content:"\f561"}.fa-face-surprise:before,.fa-surprise:before{content:"\f5c2"}.fa-bottle-water:before{content:"\e4c5"}.fa-circle-pause:before,.fa-pause-circle:before{content:"\f28b"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-apple-alt:before,.fa-apple-whole:before{content:"\f5d1"}.fa-kitchen-set:before{content:"\e51a"}.fa-r:before{content:"\52"}.fa-temperature-1:before,.fa-temperature-quarter:before,.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-cube:before{content:"\f1b2"}.fa-bitcoin-sign:before{content:"\e0b4"}.fa-shield-dog:before{content:"\e573"}.fa-solar-panel:before{content:"\f5ba"}.fa-lock-open:before{content:"\f3c1"}.fa-elevator:before{content:"\e16d"}.fa-money-bill-transfer:before{content:"\e528"}.fa-money-bill-trend-up:before{content:"\e529"}.fa-house-flood-water-circle-arrow-right:before{content:"\e50f"}.fa-poll-h:before,.fa-square-poll-horizontal:before{content:"\f682"}.fa-circle:before{content:"\f111"}.fa-backward-fast:before,.fa-fast-backward:before{content:"\f049"}.fa-recycle:before{content:"\f1b8"}.fa-user-astronaut:before{content:"\f4fb"}.fa-plane-slash:before{content:"\e069"}.fa-trademark:before{content:"\f25c"}.fa-basketball-ball:before,.fa-basketball:before{content:"\f434"}.fa-satellite-dish:before{content:"\f7c0"}.fa-arrow-alt-circle-up:before,.fa-circle-up:before{content:"\f35b"}.fa-mobile-alt:before,.fa-mobile-screen-button:before{content:"\f3cd"}.fa-volume-high:before,.fa-volume-up:before{content:"\f028"}.fa-users-rays:before{content:"\e593"}.fa-wallet:before{content:"\f555"}.fa-clipboard-check:before{content:"\f46c"}.fa-file-audio:before{content:"\f1c7"}.fa-burger:before,.fa-hamburger:before{content:"\f805"}.fa-wrench:before{content:"\f0ad"}.fa-bugs:before{content:"\e4d0"}.fa-rupee-sign:before,.fa-rupee:before{content:"\f156"}.fa-file-image:before{content:"\f1c5"}.fa-circle-question:before,.fa-question-circle:before{content:"\f059"}.fa-plane-departure:before{content:"\f5b0"}.fa-handshake-slash:before{content:"\e060"}.fa-book-bookmark:before{content:"\e0bb"}.fa-code-branch:before{content:"\f126"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-bridge:before{content:"\e4c8"}.fa-phone-alt:before,.fa-phone-flip:before{content:"\f879"}.fa-truck-front:before{content:"\e2b7"}.fa-cat:before{content:"\f6be"}.fa-anchor-circle-exclamation:before{content:"\e4ab"}.fa-truck-field:before{content:"\e58d"}.fa-route:before{content:"\f4d7"}.fa-clipboard-question:before{content:"\e4e3"}.fa-panorama:before{content:"\e209"}.fa-comment-medical:before{content:"\f7f5"}.fa-teeth-open:before{content:"\f62f"}.fa-file-circle-minus:before{content:"\e4ed"}.fa-tags:before{content:"\f02c"}.fa-wine-glass:before{content:"\f4e3"}.fa-fast-forward:before,.fa-forward-fast:before{content:"\f050"}.fa-face-meh-blank:before,.fa-meh-blank:before{content:"\f5a4"}.fa-parking:before,.fa-square-parking:before{content:"\f540"}.fa-house-signal:before{content:"\e012"}.fa-bars-progress:before,.fa-tasks-alt:before{content:"\f828"}.fa-faucet-drip:before{content:"\e006"}.fa-cart-flatbed:before,.fa-dolly-flatbed:before{content:"\f474"}.fa-ban-smoking:before,.fa-smoking-ban:before{content:"\f54d"}.fa-terminal:before{content:"\f120"}.fa-mobile-button:before{content:"\f10b"}.fa-house-medical-flag:before{content:"\e514"}.fa-basket-shopping:before,.fa-shopping-basket:before{content:"\f291"}.fa-tape:before{content:"\f4db"}.fa-bus-alt:before,.fa-bus-simple:before{content:"\f55e"}.fa-eye:before{content:"\f06e"}.fa-face-sad-cry:before,.fa-sad-cry:before{content:"\f5b3"}.fa-audio-description:before{content:"\f29e"}.fa-person-military-to-person:before{content:"\e54c"}.fa-file-shield:before{content:"\e4f0"}.fa-user-slash:before{content:"\f506"}.fa-pen:before{content:"\f304"}.fa-tower-observation:before{content:"\e586"}.fa-file-code:before{content:"\f1c9"}.fa-signal-5:before,.fa-signal-perfect:before,.fa-signal:before{content:"\f012"}.fa-bus:before{content:"\f207"}.fa-heart-circle-xmark:before{content:"\e501"}.fa-home-lg:before,.fa-house-chimney:before{content:"\e3af"}.fa-window-maximize:before{content:"\f2d0"}.fa-face-frown:before,.fa-frown:before{content:"\f119"}.fa-prescription:before{content:"\f5b1"}.fa-shop:before,.fa-store-alt:before{content:"\f54f"}.fa-floppy-disk:before,.fa-save:before{content:"\f0c7"}.fa-vihara:before{content:"\f6a7"}.fa-balance-scale-left:before,.fa-scale-unbalanced:before{content:"\f515"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-comment-dots:before,.fa-commenting:before{content:"\f4ad"}.fa-plant-wilt:before{content:"\e5aa"}.fa-diamond:before{content:"\f219"}.fa-face-grin-squint:before,.fa-grin-squint:before{content:"\f585"}.fa-hand-holding-dollar:before,.fa-hand-holding-usd:before{content:"\f4c0"}.fa-bacterium:before{content:"\e05a"}.fa-hand-pointer:before{content:"\f25a"}.fa-drum-steelpan:before{content:"\f56a"}.fa-hand-scissors:before{content:"\f257"}.fa-hands-praying:before,.fa-praying-hands:before{content:"\f684"}.fa-arrow-right-rotate:before,.fa-arrow-rotate-forward:before,.fa-arrow-rotate-right:before,.fa-redo:before{content:"\f01e"}.fa-biohazard:before{content:"\f780"}.fa-location-crosshairs:before,.fa-location:before{content:"\f601"}.fa-mars-double:before{content:"\f227"}.fa-child-dress:before{content:"\e59c"}.fa-users-between-lines:before{content:"\e591"}.fa-lungs-virus:before{content:"\e067"}.fa-face-grin-tears:before,.fa-grin-tears:before{content:"\f588"}.fa-phone:before{content:"\f095"}.fa-calendar-times:before,.fa-calendar-xmark:before{content:"\f273"}.fa-child-reaching:before{content:"\e59d"}.fa-head-side-virus:before{content:"\e064"}.fa-user-cog:before,.fa-user-gear:before{content:"\f4fe"}.fa-arrow-up-1-9:before,.fa-sort-numeric-up:before{content:"\f163"}.fa-door-closed:before{content:"\f52a"}.fa-shield-virus:before{content:"\e06c"}.fa-dice-six:before{content:"\f526"}.fa-mosquito-net:before{content:"\e52c"}.fa-bridge-water:before{content:"\e4ce"}.fa-person-booth:before{content:"\f756"}.fa-text-width:before{content:"\f035"}.fa-hat-wizard:before{content:"\f6e8"}.fa-pen-fancy:before{content:"\f5ac"}.fa-digging:before,.fa-person-digging:before{content:"\f85e"}.fa-trash:before{content:"\f1f8"}.fa-gauge-simple-med:before,.fa-gauge-simple:before,.fa-tachometer-average:before{content:"\f629"}.fa-book-medical:before{content:"\f7e6"}.fa-poo:before{content:"\f2fe"}.fa-quote-right-alt:before,.fa-quote-right:before{content:"\f10e"}.fa-shirt:before,.fa-t-shirt:before,.fa-tshirt:before{content:"\f553"}.fa-cubes:before{content:"\f1b3"}.fa-divide:before{content:"\f529"}.fa-tenge-sign:before,.fa-tenge:before{content:"\f7d7"}.fa-headphones:before{content:"\f025"}.fa-hands-holding:before{content:"\f4c2"}.fa-hands-clapping:before{content:"\e1a8"}.fa-republican:before{content:"\f75e"}.fa-arrow-left:before{content:"\f060"}.fa-person-circle-xmark:before{content:"\e543"}.fa-ruler:before{content:"\f545"}.fa-align-left:before{content:"\f036"}.fa-dice-d6:before{content:"\f6d1"}.fa-restroom:before{content:"\f7bd"}.fa-j:before{content:"\4a"}.fa-users-viewfinder:before{content:"\e595"}.fa-file-video:before{content:"\f1c8"}.fa-external-link-alt:before,.fa-up-right-from-square:before{content:"\f35d"}.fa-table-cells:before,.fa-th:before{content:"\f00a"}.fa-file-pdf:before{content:"\f1c1"}.fa-bible:before,.fa-book-bible:before{content:"\f647"}.fa-o:before{content:"\4f"}.fa-medkit:before,.fa-suitcase-medical:before{content:"\f0fa"}.fa-user-secret:before{content:"\f21b"}.fa-otter:before{content:"\f700"}.fa-female:before,.fa-person-dress:before{content:"\f182"}.fa-comment-dollar:before{content:"\f651"}.fa-briefcase-clock:before,.fa-business-time:before{content:"\f64a"}.fa-table-cells-large:before,.fa-th-large:before{content:"\f009"}.fa-book-tanakh:before,.fa-tanakh:before{content:"\f827"}.fa-phone-volume:before,.fa-volume-control-phone:before{content:"\f2a0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-clipboard-user:before{content:"\f7f3"}.fa-child:before{content:"\f1ae"}.fa-lira-sign:before{content:"\f195"}.fa-satellite:before{content:"\f7bf"}.fa-plane-lock:before{content:"\e558"}.fa-tag:before{content:"\f02b"}.fa-comment:before{content:"\f075"}.fa-birthday-cake:before,.fa-cake-candles:before,.fa-cake:before{content:"\f1fd"}.fa-envelope:before{content:"\f0e0"}.fa-angle-double-up:before,.fa-angles-up:before{content:"\f102"}.fa-paperclip:before{content:"\f0c6"}.fa-arrow-right-to-city:before{content:"\e4b3"}.fa-ribbon:before{content:"\f4d6"}.fa-lungs:before{content:"\f604"}.fa-arrow-up-9-1:before,.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-litecoin-sign:before{content:"\e1d3"}.fa-border-none:before{content:"\f850"}.fa-circle-nodes:before{content:"\e4e2"}.fa-parachute-box:before{content:"\f4cd"}.fa-indent:before{content:"\f03c"}.fa-truck-field-un:before{content:"\e58e"}.fa-hourglass-empty:before,.fa-hourglass:before{content:"\f254"}.fa-mountain:before{content:"\f6fc"}.fa-user-doctor:before,.fa-user-md:before{content:"\f0f0"}.fa-circle-info:before,.fa-info-circle:before{content:"\f05a"}.fa-cloud-meatball:before{content:"\f73b"}.fa-camera-alt:before,.fa-camera:before{content:"\f030"}.fa-square-virus:before{content:"\e578"}.fa-meteor:before{content:"\f753"}.fa-car-on:before{content:"\e4dd"}.fa-sleigh:before{content:"\f7cc"}.fa-arrow-down-1-9:before,.fa-sort-numeric-asc:before,.fa-sort-numeric-down:before{content:"\f162"}.fa-hand-holding-droplet:before,.fa-hand-holding-water:before{content:"\f4c1"}.fa-water:before{content:"\f773"}.fa-calendar-check:before{content:"\f274"}.fa-braille:before{content:"\f2a1"}.fa-prescription-bottle-alt:before,.fa-prescription-bottle-medical:before{content:"\f486"}.fa-landmark:before{content:"\f66f"}.fa-truck:before{content:"\f0d1"}.fa-crosshairs:before{content:"\f05b"}.fa-person-cane:before{content:"\e53c"}.fa-tent:before{content:"\e57d"}.fa-vest-patches:before{content:"\e086"}.fa-check-double:before{content:"\f560"}.fa-arrow-down-a-z:before,.fa-sort-alpha-asc:before,.fa-sort-alpha-down:before{content:"\f15d"}.fa-money-bill-wheat:before{content:"\e52a"}.fa-cookie:before{content:"\f563"}.fa-arrow-left-rotate:before,.fa-arrow-rotate-back:before,.fa-arrow-rotate-backward:before,.fa-arrow-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-hard-drive:before,.fa-hdd:before{content:"\f0a0"}.fa-face-grin-squint-tears:before,.fa-grin-squint-tears:before{content:"\f586"}.fa-dumbbell:before{content:"\f44b"}.fa-list-alt:before,.fa-rectangle-list:before{content:"\f022"}.fa-tarp-droplet:before{content:"\e57c"}.fa-house-medical-circle-check:before{content:"\e511"}.fa-person-skiing-nordic:before,.fa-skiing-nordic:before{content:"\f7ca"}.fa-calendar-plus:before{content:"\f271"}.fa-plane-arrival:before{content:"\f5af"}.fa-arrow-alt-circle-left:before,.fa-circle-left:before{content:"\f359"}.fa-subway:before,.fa-train-subway:before{content:"\f239"}.fa-chart-gantt:before{content:"\e0e4"}.fa-indian-rupee-sign:before,.fa-indian-rupee:before,.fa-inr:before{content:"\e1bc"}.fa-crop-alt:before,.fa-crop-simple:before{content:"\f565"}.fa-money-bill-1:before,.fa-money-bill-alt:before{content:"\f3d1"}.fa-left-long:before,.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-dna:before{content:"\f471"}.fa-virus-slash:before{content:"\e075"}.fa-minus:before,.fa-subtract:before{content:"\f068"}.fa-chess:before{content:"\f439"}.fa-arrow-left-long:before,.fa-long-arrow-left:before{content:"\f177"}.fa-plug-circle-check:before{content:"\e55c"}.fa-street-view:before{content:"\f21d"}.fa-franc-sign:before{content:"\e18f"}.fa-volume-off:before{content:"\f026"}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before,.fa-hands-american-sign-language-interpreting:before,.fa-hands-asl-interpreting:before{content:"\f2a3"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-droplet-slash:before,.fa-tint-slash:before{content:"\f5c7"}.fa-mosque:before{content:"\f678"}.fa-mosquito:before{content:"\e52b"}.fa-star-of-david:before{content:"\f69a"}.fa-person-military-rifle:before{content:"\e54b"}.fa-cart-shopping:before,.fa-shopping-cart:before{content:"\f07a"}.fa-vials:before{content:"\f493"}.fa-plug-circle-plus:before{content:"\e55f"}.fa-place-of-worship:before{content:"\f67f"}.fa-grip-vertical:before{content:"\f58e"}.fa-arrow-turn-up:before,.fa-level-up:before{content:"\f148"}.fa-u:before{content:"\55"}.fa-square-root-alt:before,.fa-square-root-variable:before{content:"\f698"}.fa-clock-four:before,.fa-clock:before{content:"\f017"}.fa-backward-step:before,.fa-step-backward:before{content:"\f048"}.fa-pallet:before{content:"\f482"}.fa-faucet:before{content:"\e005"}.fa-baseball-bat-ball:before{content:"\f432"}.fa-s:before{content:"\53"}.fa-timeline:before{content:"\e29c"}.fa-keyboard:before{content:"\f11c"}.fa-caret-down:before{content:"\f0d7"}.fa-clinic-medical:before,.fa-house-chimney-medical:before{content:"\f7f2"}.fa-temperature-3:before,.fa-temperature-three-quarters:before,.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-mobile-android-alt:before,.fa-mobile-screen:before{content:"\f3cf"}.fa-plane-up:before{content:"\e22d"}.fa-piggy-bank:before{content:"\f4d3"}.fa-battery-3:before,.fa-battery-half:before{content:"\f242"}.fa-mountain-city:before{content:"\e52e"}.fa-coins:before{content:"\f51e"}.fa-khanda:before{content:"\f66d"}.fa-sliders-h:before,.fa-sliders:before{content:"\f1de"}.fa-folder-tree:before{content:"\f802"}.fa-network-wired:before{content:"\f6ff"}.fa-map-pin:before{content:"\f276"}.fa-hamsa:before{content:"\f665"}.fa-cent-sign:before{content:"\e3f5"}.fa-flask:before{content:"\f0c3"}.fa-person-pregnant:before{content:"\e31e"}.fa-wand-sparkles:before{content:"\f72b"}.fa-ellipsis-v:before,.fa-ellipsis-vertical:before{content:"\f142"}.fa-ticket:before{content:"\f145"}.fa-power-off:before{content:"\f011"}.fa-long-arrow-alt-right:before,.fa-right-long:before{content:"\f30b"}.fa-flag-usa:before{content:"\f74d"}.fa-laptop-file:before{content:"\e51d"}.fa-teletype:before,.fa-tty:before{content:"\f1e4"}.fa-diagram-next:before{content:"\e476"}.fa-person-rifle:before{content:"\e54e"}.fa-house-medical-circle-exclamation:before{content:"\e512"}.fa-closed-captioning:before{content:"\f20a"}.fa-hiking:before,.fa-person-hiking:before{content:"\f6ec"}.fa-venus-double:before{content:"\f226"}.fa-images:before{content:"\f302"}.fa-calculator:before{content:"\f1ec"}.fa-people-pulling:before{content:"\e535"}.fa-n:before{content:"\4e"}.fa-cable-car:before,.fa-tram:before{content:"\f7da"}.fa-cloud-rain:before{content:"\f73d"}.fa-building-circle-xmark:before{content:"\e4d4"}.fa-ship:before{content:"\f21a"}.fa-arrows-down-to-line:before{content:"\e4b8"}.fa-download:before{content:"\f019"}.fa-face-grin:before,.fa-grin:before{content:"\f580"}.fa-backspace:before,.fa-delete-left:before{content:"\f55a"}.fa-eye-dropper-empty:before,.fa-eye-dropper:before,.fa-eyedropper:before{content:"\f1fb"}.fa-file-circle-check:before{content:"\e5a0"}.fa-forward:before{content:"\f04e"}.fa-mobile-android:before,.fa-mobile-phone:before,.fa-mobile:before{content:"\f3ce"}.fa-face-meh:before,.fa-meh:before{content:"\f11a"}.fa-align-center:before{content:"\f037"}.fa-book-dead:before,.fa-book-skull:before{content:"\f6b7"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-heart-circle-exclamation:before{content:"\e4fe"}.fa-home-alt:before,.fa-home-lg-alt:before,.fa-home:before,.fa-house:before{content:"\f015"}.fa-calendar-week:before{content:"\f784"}.fa-laptop-medical:before{content:"\f812"}.fa-b:before{content:"\42"}.fa-file-medical:before{content:"\f477"}.fa-dice-one:before{content:"\f525"}.fa-kiwi-bird:before{content:"\f535"}.fa-arrow-right-arrow-left:before,.fa-exchange:before{content:"\f0ec"}.fa-redo-alt:before,.fa-rotate-forward:before,.fa-rotate-right:before{content:"\f2f9"}.fa-cutlery:before,.fa-utensils:before{content:"\f2e7"}.fa-arrow-up-wide-short:before,.fa-sort-amount-up:before{content:"\f161"}.fa-mill-sign:before{content:"\e1ed"}.fa-bowl-rice:before{content:"\e2eb"}.fa-skull:before{content:"\f54c"}.fa-broadcast-tower:before,.fa-tower-broadcast:before{content:"\f519"}.fa-truck-pickup:before{content:"\f63c"}.fa-long-arrow-alt-up:before,.fa-up-long:before{content:"\f30c"}.fa-stop:before{content:"\f04d"}.fa-code-merge:before{content:"\f387"}.fa-upload:before{content:"\f093"}.fa-hurricane:before{content:"\f751"}.fa-mound:before{content:"\e52d"}.fa-toilet-portable:before{content:"\e583"}.fa-compact-disc:before{content:"\f51f"}.fa-file-arrow-down:before,.fa-file-download:before{content:"\f56d"}.fa-caravan:before{content:"\f8ff"}.fa-shield-cat:before{content:"\e572"}.fa-bolt:before,.fa-zap:before{content:"\f0e7"}.fa-glass-water:before{content:"\e4f4"}.fa-oil-well:before{content:"\e532"}.fa-vault:before{content:"\e2c5"}.fa-mars:before{content:"\f222"}.fa-toilet:before{content:"\f7d8"}.fa-plane-circle-xmark:before{content:"\e557"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen-sign:before,.fa-yen:before{content:"\f157"}.fa-rouble:before,.fa-rub:before,.fa-ruble-sign:before,.fa-ruble:before{content:"\f158"}.fa-sun:before{content:"\f185"}.fa-guitar:before{content:"\f7a6"}.fa-face-laugh-wink:before,.fa-laugh-wink:before{content:"\f59c"}.fa-horse-head:before{content:"\f7ab"}.fa-bore-hole:before{content:"\e4c3"}.fa-industry:before{content:"\f275"}.fa-arrow-alt-circle-down:before,.fa-circle-down:before{content:"\f358"}.fa-arrows-turn-to-dots:before{content:"\e4c1"}.fa-florin-sign:before{content:"\e184"}.fa-arrow-down-short-wide:before,.fa-sort-amount-desc:before,.fa-sort-amount-down-alt:before{content:"\f884"}.fa-less-than:before{content:"\3c"}.fa-angle-down:before{content:"\f107"}.fa-car-tunnel:before{content:"\e4de"}.fa-head-side-cough:before{content:"\e061"}.fa-grip-lines:before{content:"\f7a4"}.fa-thumbs-down:before{content:"\f165"}.fa-user-lock:before{content:"\f502"}.fa-arrow-right-long:before,.fa-long-arrow-right:before{content:"\f178"}.fa-anchor-circle-xmark:before{content:"\e4ac"}.fa-ellipsis-h:before,.fa-ellipsis:before{content:"\f141"}.fa-chess-pawn:before{content:"\f443"}.fa-first-aid:before,.fa-kit-medical:before{content:"\f479"}.fa-person-through-window:before{content:"\e5a9"}.fa-toolbox:before{content:"\f552"}.fa-hands-holding-circle:before{content:"\e4fb"}.fa-bug:before{content:"\f188"}.fa-credit-card-alt:before,.fa-credit-card:before{content:"\f09d"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-hand-holding-hand:before{content:"\e4f7"}.fa-book-open-reader:before,.fa-book-reader:before{content:"\f5da"}.fa-mountain-sun:before{content:"\e52f"}.fa-arrows-left-right-to-line:before{content:"\e4ba"}.fa-dice-d20:before{content:"\f6cf"}.fa-truck-droplet:before{content:"\e58c"}.fa-file-circle-xmark:before{content:"\e5a1"}.fa-temperature-arrow-up:before,.fa-temperature-up:before{content:"\e040"}.fa-medal:before{content:"\f5a2"}.fa-bed:before{content:"\f236"}.fa-h-square:before,.fa-square-h:before{content:"\f0fd"}.fa-podcast:before{content:"\f2ce"}.fa-temperature-4:before,.fa-temperature-full:before,.fa-thermometer-4:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-bell:before{content:"\f0f3"}.fa-superscript:before{content:"\f12b"}.fa-plug-circle-xmark:before{content:"\e560"}.fa-star-of-life:before{content:"\f621"}.fa-phone-slash:before{content:"\f3dd"}.fa-paint-roller:before{content:"\f5aa"}.fa-hands-helping:before,.fa-handshake-angle:before{content:"\f4c4"}.fa-location-dot:before,.fa-map-marker-alt:before{content:"\f3c5"}.fa-file:before{content:"\f15b"}.fa-greater-than:before{content:"\3e"}.fa-person-swimming:before,.fa-swimmer:before{content:"\f5c4"}.fa-arrow-down:before{content:"\f063"}.fa-droplet:before,.fa-tint:before{content:"\f043"}.fa-eraser:before{content:"\f12d"}.fa-earth-america:before,.fa-earth-americas:before,.fa-earth:before,.fa-globe-americas:before{content:"\f57d"}.fa-person-burst:before{content:"\e53b"}.fa-dove:before{content:"\f4ba"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-socks:before{content:"\f696"}.fa-inbox:before{content:"\f01c"}.fa-section:before{content:"\e447"}.fa-gauge-high:before,.fa-tachometer-alt-fast:before,.fa-tachometer-alt:before{content:"\f625"}.fa-envelope-open-text:before{content:"\f658"}.fa-hospital-alt:before,.fa-hospital-wide:before,.fa-hospital:before{content:"\f0f8"}.fa-wine-bottle:before{content:"\f72f"}.fa-chess-rook:before{content:"\f447"}.fa-bars-staggered:before,.fa-reorder:before,.fa-stream:before{content:"\f550"}.fa-dharmachakra:before{content:"\f655"}.fa-hotdog:before{content:"\f80f"}.fa-blind:before,.fa-person-walking-with-cane:before{content:"\f29d"}.fa-drum:before{content:"\f569"}.fa-ice-cream:before{content:"\f810"}.fa-heart-circle-bolt:before{content:"\e4fc"}.fa-fax:before{content:"\f1ac"}.fa-paragraph:before{content:"\f1dd"}.fa-check-to-slot:before,.fa-vote-yea:before{content:"\f772"}.fa-star-half:before{content:"\f089"}.fa-boxes-alt:before,.fa-boxes-stacked:before,.fa-boxes:before{content:"\f468"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-assistive-listening-systems:before,.fa-ear-listen:before{content:"\f2a2"}.fa-tree-city:before{content:"\e587"}.fa-play:before{content:"\f04b"}.fa-font:before{content:"\f031"}.fa-rupiah-sign:before{content:"\e23d"}.fa-magnifying-glass:before,.fa-search:before{content:"\f002"}.fa-ping-pong-paddle-ball:before,.fa-table-tennis-paddle-ball:before,.fa-table-tennis:before{content:"\f45d"}.fa-diagnoses:before,.fa-person-dots-from-line:before{content:"\f470"}.fa-trash-can-arrow-up:before,.fa-trash-restore-alt:before{content:"\f82a"}.fa-naira-sign:before{content:"\e1f6"}.fa-cart-arrow-down:before{content:"\f218"}.fa-walkie-talkie:before{content:"\f8ef"}.fa-file-edit:before,.fa-file-pen:before{content:"\f31c"}.fa-receipt:before{content:"\f543"}.fa-pen-square:before,.fa-pencil-square:before,.fa-square-pen:before{content:"\f14b"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-person-circle-exclamation:before{content:"\e53f"}.fa-chevron-down:before{content:"\f078"}.fa-battery-5:before,.fa-battery-full:before,.fa-battery:before{content:"\f240"}.fa-skull-crossbones:before{content:"\f714"}.fa-code-compare:before{content:"\e13a"}.fa-list-dots:before,.fa-list-ul:before{content:"\f0ca"}.fa-school-lock:before{content:"\e56f"}.fa-tower-cell:before{content:"\e585"}.fa-down-long:before,.fa-long-arrow-alt-down:before{content:"\f309"}.fa-ranking-star:before{content:"\e561"}.fa-chess-king:before{content:"\f43f"}.fa-person-harassing:before{content:"\e549"}.fa-brazilian-real-sign:before{content:"\e46c"}.fa-landmark-alt:before,.fa-landmark-dome:before{content:"\f752"}.fa-arrow-up:before{content:"\f062"}.fa-television:before,.fa-tv-alt:before,.fa-tv:before{content:"\f26c"}.fa-shrimp:before{content:"\e448"}.fa-list-check:before,.fa-tasks:before{content:"\f0ae"}.fa-jug-detergent:before{content:"\e519"}.fa-circle-user:before,.fa-user-circle:before{content:"\f2bd"}.fa-user-shield:before{content:"\f505"}.fa-wind:before{content:"\f72e"}.fa-car-burst:before,.fa-car-crash:before{content:"\f5e1"}.fa-y:before{content:"\59"}.fa-person-snowboarding:before,.fa-snowboarding:before{content:"\f7ce"}.fa-shipping-fast:before,.fa-truck-fast:before{content:"\f48b"}.fa-fish:before{content:"\f578"}.fa-user-graduate:before{content:"\f501"}.fa-adjust:before,.fa-circle-half-stroke:before{content:"\f042"}.fa-clapperboard:before{content:"\e131"}.fa-circle-radiation:before,.fa-radiation-alt:before{content:"\f7ba"}.fa-baseball-ball:before,.fa-baseball:before{content:"\f433"}.fa-jet-fighter-up:before{content:"\e518"}.fa-diagram-project:before,.fa-project-diagram:before{content:"\f542"}.fa-copy:before{content:"\f0c5"}.fa-volume-mute:before,.fa-volume-times:before,.fa-volume-xmark:before{content:"\f6a9"}.fa-hand-sparkles:before{content:"\e05d"}.fa-grip-horizontal:before,.fa-grip:before{content:"\f58d"}.fa-share-from-square:before,.fa-share-square:before{content:"\f14d"}.fa-child-combatant:before,.fa-child-rifle:before{content:"\e4e0"}.fa-gun:before{content:"\e19b"}.fa-phone-square:before,.fa-square-phone:before{content:"\f098"}.fa-add:before,.fa-plus:before{content:"\2b"}.fa-expand:before{content:"\f065"}.fa-computer:before{content:"\e4e5"}.fa-close:before,.fa-multiply:before,.fa-remove:before,.fa-times:before,.fa-xmark:before{content:"\f00d"}.fa-arrows-up-down-left-right:before,.fa-arrows:before{content:"\f047"}.fa-chalkboard-teacher:before,.fa-chalkboard-user:before{content:"\f51c"}.fa-peso-sign:before{content:"\e222"}.fa-building-shield:before{content:"\e4d8"}.fa-baby:before{content:"\f77c"}.fa-users-line:before{content:"\e592"}.fa-quote-left-alt:before,.fa-quote-left:before{content:"\f10d"}.fa-tractor:before{content:"\f722"}.fa-trash-arrow-up:before,.fa-trash-restore:before{content:"\f829"}.fa-arrow-down-up-lock:before{content:"\e4b0"}.fa-lines-leaning:before{content:"\e51e"}.fa-ruler-combined:before{content:"\f546"}.fa-copyright:before{content:"\f1f9"}.fa-equals:before{content:"\3d"}.fa-blender:before{content:"\f517"}.fa-teeth:before{content:"\f62e"}.fa-ils:before,.fa-shekel-sign:before,.fa-shekel:before,.fa-sheqel-sign:before,.fa-sheqel:before{content:"\f20b"}.fa-map:before{content:"\f279"}.fa-rocket:before{content:"\f135"}.fa-photo-film:before,.fa-photo-video:before{content:"\f87c"}.fa-folder-minus:before{content:"\f65d"}.fa-store:before{content:"\f54e"}.fa-arrow-trend-up:before{content:"\e098"}.fa-plug-circle-minus:before{content:"\e55e"}.fa-sign-hanging:before,.fa-sign:before{content:"\f4d9"}.fa-bezier-curve:before{content:"\f55b"}.fa-bell-slash:before{content:"\f1f6"}.fa-tablet-android:before,.fa-tablet:before{content:"\f3fb"}.fa-school-flag:before{content:"\e56e"}.fa-fill:before{content:"\f575"}.fa-angle-up:before{content:"\f106"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-holly-berry:before{content:"\f7aa"}.fa-chevron-left:before{content:"\f053"}.fa-bacteria:before{content:"\e059"}.fa-hand-lizard:before{content:"\f258"}.fa-notdef:before{content:"\e1fe"}.fa-disease:before{content:"\f7fa"}.fa-briefcase-medical:before{content:"\f469"}.fa-genderless:before{content:"\f22d"}.fa-chevron-right:before{content:"\f054"}.fa-retweet:before{content:"\f079"}.fa-car-alt:before,.fa-car-rear:before{content:"\f5de"}.fa-pump-soap:before{content:"\e06b"}.fa-video-slash:before{content:"\f4e2"}.fa-battery-2:before,.fa-battery-quarter:before{content:"\f243"}.fa-radio:before{content:"\f8d7"}.fa-baby-carriage:before,.fa-carriage-baby:before{content:"\f77d"}.fa-traffic-light:before{content:"\f637"}.fa-thermometer:before{content:"\f491"}.fa-vr-cardboard:before{content:"\f729"}.fa-hand-middle-finger:before{content:"\f806"}.fa-percent:before,.fa-percentage:before{content:"\25"}.fa-truck-moving:before{content:"\f4df"}.fa-glass-water-droplet:before{content:"\e4f5"}.fa-display:before{content:"\e163"}.fa-face-smile:before,.fa-smile:before{content:"\f118"}.fa-thumb-tack:before,.fa-thumbtack:before{content:"\f08d"}.fa-trophy:before{content:"\f091"}.fa-person-praying:before,.fa-pray:before{content:"\f683"}.fa-hammer:before{content:"\f6e3"}.fa-hand-peace:before{content:"\f25b"}.fa-rotate:before,.fa-sync-alt:before{content:"\f2f1"}.fa-spinner:before{content:"\f110"}.fa-robot:before{content:"\f544"}.fa-peace:before{content:"\f67c"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-warehouse:before{content:"\f494"}.fa-arrow-up-right-dots:before{content:"\e4b7"}.fa-splotch:before{content:"\f5bc"}.fa-face-grin-hearts:before,.fa-grin-hearts:before{content:"\f584"}.fa-dice-four:before{content:"\f524"}.fa-sim-card:before{content:"\f7c4"}.fa-transgender-alt:before,.fa-transgender:before{content:"\f225"}.fa-mercury:before{content:"\f223"}.fa-arrow-turn-down:before,.fa-level-down:before{content:"\f149"}.fa-person-falling-burst:before{content:"\e547"}.fa-award:before{content:"\f559"}.fa-ticket-alt:before,.fa-ticket-simple:before{content:"\f3ff"}.fa-building:before{content:"\f1ad"}.fa-angle-double-left:before,.fa-angles-left:before{content:"\f100"}.fa-qrcode:before{content:"\f029"}.fa-clock-rotate-left:before,.fa-history:before{content:"\f1da"}.fa-face-grin-beam-sweat:before,.fa-grin-beam-sweat:before{content:"\f583"}.fa-arrow-right-from-file:before,.fa-file-export:before{content:"\f56e"}.fa-shield-blank:before,.fa-shield:before{content:"\f132"}.fa-arrow-up-short-wide:before,.fa-sort-amount-up-alt:before{content:"\f885"}.fa-house-medical:before{content:"\e3b2"}.fa-golf-ball-tee:before,.fa-golf-ball:before{content:"\f450"}.fa-chevron-circle-left:before,.fa-circle-chevron-left:before{content:"\f137"}.fa-house-chimney-window:before{content:"\e00d"}.fa-pen-nib:before{content:"\f5ad"}.fa-tent-arrow-turn-left:before{content:"\e580"}.fa-tents:before{content:"\e582"}.fa-magic:before,.fa-wand-magic:before{content:"\f0d0"}.fa-dog:before{content:"\f6d3"}.fa-carrot:before{content:"\f787"}.fa-moon:before{content:"\f186"}.fa-wine-glass-alt:before,.fa-wine-glass-empty:before{content:"\f5ce"}.fa-cheese:before{content:"\f7ef"}.fa-yin-yang:before{content:"\f6ad"}.fa-music:before{content:"\f001"}.fa-code-commit:before{content:"\f386"}.fa-temperature-low:before{content:"\f76b"}.fa-biking:before,.fa-person-biking:before{content:"\f84a"}.fa-broom:before{content:"\f51a"}.fa-shield-heart:before{content:"\e574"}.fa-gopuram:before{content:"\f664"}.fa-earth-oceania:before,.fa-globe-oceania:before{content:"\e47b"}.fa-square-xmark:before,.fa-times-square:before,.fa-xmark-square:before{content:"\f2d3"}.fa-hashtag:before{content:"\23"}.fa-expand-alt:before,.fa-up-right-and-down-left-from-center:before{content:"\f424"}.fa-oil-can:before{content:"\f613"}.fa-t:before{content:"\54"}.fa-hippo:before{content:"\f6ed"}.fa-chart-column:before{content:"\e0e3"}.fa-infinity:before{content:"\f534"}.fa-vial-circle-check:before{content:"\e596"}.fa-person-arrow-down-to-line:before{content:"\e538"}.fa-voicemail:before{content:"\f897"}.fa-fan:before{content:"\f863"}.fa-person-walking-luggage:before{content:"\e554"}.fa-arrows-alt-v:before,.fa-up-down:before{content:"\f338"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-calendar:before{content:"\f133"}.fa-trailer:before{content:"\e041"}.fa-bahai:before,.fa-haykal:before{content:"\f666"}.fa-sd-card:before{content:"\f7c2"}.fa-dragon:before{content:"\f6d5"}.fa-shoe-prints:before{content:"\f54b"}.fa-circle-plus:before,.fa-plus-circle:before{content:"\f055"}.fa-face-grin-tongue-wink:before,.fa-grin-tongue-wink:before{content:"\f58b"}.fa-hand-holding:before{content:"\f4bd"}.fa-plug-circle-exclamation:before{content:"\e55d"}.fa-chain-broken:before,.fa-chain-slash:before,.fa-link-slash:before,.fa-unlink:before{content:"\f127"}.fa-clone:before{content:"\f24d"}.fa-person-walking-arrow-loop-left:before{content:"\e551"}.fa-arrow-up-z-a:before,.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-fire-alt:before,.fa-fire-flame-curved:before{content:"\f7e4"}.fa-tornado:before{content:"\f76f"}.fa-file-circle-plus:before{content:"\e494"}.fa-book-quran:before,.fa-quran:before{content:"\f687"}.fa-anchor:before{content:"\f13d"}.fa-border-all:before{content:"\f84c"}.fa-angry:before,.fa-face-angry:before{content:"\f556"}.fa-cookie-bite:before{content:"\f564"}.fa-arrow-trend-down:before{content:"\e097"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-draw-polygon:before{content:"\f5ee"}.fa-balance-scale:before,.fa-scale-balanced:before{content:"\f24e"}.fa-gauge-simple-high:before,.fa-tachometer-fast:before,.fa-tachometer:before{content:"\f62a"}.fa-shower:before{content:"\f2cc"}.fa-desktop-alt:before,.fa-desktop:before{content:"\f390"}.fa-m:before{content:"\4d"}.fa-table-list:before,.fa-th-list:before{content:"\f00b"}.fa-comment-sms:before,.fa-sms:before{content:"\f7cd"}.fa-book:before{content:"\f02d"}.fa-user-plus:before{content:"\f234"}.fa-check:before{content:"\f00c"}.fa-battery-4:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-house-circle-check:before{content:"\e509"}.fa-angle-left:before{content:"\f104"}.fa-diagram-successor:before{content:"\e47a"}.fa-truck-arrow-right:before{content:"\e58b"}.fa-arrows-split-up-and-left:before{content:"\e4bc"}.fa-fist-raised:before,.fa-hand-fist:before{content:"\f6de"}.fa-cloud-moon:before{content:"\f6c3"}.fa-briefcase:before{content:"\f0b1"}.fa-person-falling:before{content:"\e546"}.fa-image-portrait:before,.fa-portrait:before{content:"\f3e0"}.fa-user-tag:before{content:"\f507"}.fa-rug:before{content:"\e569"}.fa-earth-europe:before,.fa-globe-europe:before{content:"\f7a2"}.fa-cart-flatbed-suitcase:before,.fa-luggage-cart:before{content:"\f59d"}.fa-rectangle-times:before,.fa-rectangle-xmark:before,.fa-times-rectangle:before,.fa-window-close:before{content:"\f410"}.fa-baht-sign:before{content:"\e0ac"}.fa-book-open:before{content:"\f518"}.fa-book-journal-whills:before,.fa-journal-whills:before{content:"\f66a"}.fa-handcuffs:before{content:"\e4f8"}.fa-exclamation-triangle:before,.fa-triangle-exclamation:before,.fa-warning:before{content:"\f071"}.fa-database:before{content:"\f1c0"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-bottle-droplet:before{content:"\e4c4"}.fa-mask-face:before{content:"\e1d7"}.fa-hill-rockslide:before{content:"\e508"}.fa-exchange-alt:before,.fa-right-left:before{content:"\f362"}.fa-paper-plane:before{content:"\f1d8"}.fa-road-circle-exclamation:before{content:"\e565"}.fa-dungeon:before{content:"\f6d9"}.fa-align-right:before{content:"\f038"}.fa-money-bill-1-wave:before,.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-life-ring:before{content:"\f1cd"}.fa-hands:before,.fa-sign-language:before,.fa-signing:before{content:"\f2a7"}.fa-calendar-day:before{content:"\f783"}.fa-ladder-water:before,.fa-swimming-pool:before,.fa-water-ladder:before{content:"\f5c5"}.fa-arrows-up-down:before,.fa-arrows-v:before{content:"\f07d"}.fa-face-grimace:before,.fa-grimace:before{content:"\f57f"}.fa-wheelchair-alt:before,.fa-wheelchair-move:before{content:"\e2ce"}.fa-level-down-alt:before,.fa-turn-down:before{content:"\f3be"}.fa-person-walking-arrow-right:before{content:"\e552"}.fa-envelope-square:before,.fa-square-envelope:before{content:"\f199"}.fa-dice:before{content:"\f522"}.fa-bowling-ball:before{content:"\f436"}.fa-brain:before{content:"\f5dc"}.fa-band-aid:before,.fa-bandage:before{content:"\f462"}.fa-calendar-minus:before{content:"\f272"}.fa-circle-xmark:before,.fa-times-circle:before,.fa-xmark-circle:before{content:"\f057"}.fa-gifts:before{content:"\f79c"}.fa-hotel:before{content:"\f594"}.fa-earth-asia:before,.fa-globe-asia:before{content:"\f57e"}.fa-id-card-alt:before,.fa-id-card-clip:before{content:"\f47f"}.fa-magnifying-glass-plus:before,.fa-search-plus:before{content:"\f00e"}.fa-thumbs-up:before{content:"\f164"}.fa-user-clock:before{content:"\f4fd"}.fa-allergies:before,.fa-hand-dots:before{content:"\f461"}.fa-file-invoice:before{content:"\f570"}.fa-window-minimize:before{content:"\f2d1"}.fa-coffee:before,.fa-mug-saucer:before{content:"\f0f4"}.fa-brush:before{content:"\f55d"}.fa-mask:before{content:"\f6fa"}.fa-magnifying-glass-minus:before,.fa-search-minus:before{content:"\f010"}.fa-ruler-vertical:before{content:"\f548"}.fa-user-alt:before,.fa-user-large:before{content:"\f406"}.fa-train-tram:before{content:"\e5b4"}.fa-user-nurse:before{content:"\f82f"}.fa-syringe:before{content:"\f48e"}.fa-cloud-sun:before{content:"\f6c4"}.fa-stopwatch-20:before{content:"\e06f"}.fa-square-full:before{content:"\f45c"}.fa-magnet:before{content:"\f076"}.fa-jar:before{content:"\e516"}.fa-note-sticky:before,.fa-sticky-note:before{content:"\f249"}.fa-bug-slash:before{content:"\e490"}.fa-arrow-up-from-water-pump:before{content:"\e4b6"}.fa-bone:before{content:"\f5d7"}.fa-user-injured:before{content:"\f728"}.fa-face-sad-tear:before,.fa-sad-tear:before{content:"\f5b4"}.fa-plane:before{content:"\f072"}.fa-tent-arrows-down:before{content:"\e581"}.fa-exclamation:before{content:"\21"}.fa-arrows-spin:before{content:"\e4bb"}.fa-print:before{content:"\f02f"}.fa-try:before,.fa-turkish-lira-sign:before,.fa-turkish-lira:before{content:"\e2bb"}.fa-dollar-sign:before,.fa-dollar:before,.fa-usd:before{content:"\24"}.fa-x:before{content:"\58"}.fa-magnifying-glass-dollar:before,.fa-search-dollar:before{content:"\f688"}.fa-users-cog:before,.fa-users-gear:before{content:"\f509"}.fa-person-military-pointing:before{content:"\e54a"}.fa-bank:before,.fa-building-columns:before,.fa-institution:before,.fa-museum:before,.fa-university:before{content:"\f19c"}.fa-umbrella:before{content:"\f0e9"}.fa-trowel:before{content:"\e589"}.fa-d:before{content:"\44"}.fa-stapler:before{content:"\e5af"}.fa-masks-theater:before,.fa-theater-masks:before{content:"\f630"}.fa-kip-sign:before{content:"\e1c4"}.fa-hand-point-left:before{content:"\f0a5"}.fa-handshake-alt:before,.fa-handshake-simple:before{content:"\f4c6"}.fa-fighter-jet:before,.fa-jet-fighter:before{content:"\f0fb"}.fa-share-alt-square:before,.fa-square-share-nodes:before{content:"\f1e1"}.fa-barcode:before{content:"\f02a"}.fa-plus-minus:before{content:"\e43c"}.fa-video-camera:before,.fa-video:before{content:"\f03d"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-person-circle-check:before{content:"\e53e"}.fa-level-up-alt:before,.fa-turn-up:before{content:"\f3bf"} +.fa-sr-only,.fa-sr-only-focusable:not(:focus),.sr-only,.sr-only-focusable:not(:focus){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}:host,:root{--fa-style-family-brands:"Font Awesome 6 Brands";--fa-font-brands:normal 400 1em/1 "Font Awesome 6 Brands"}@font-face{font-family:"Font Awesome 6 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}.fa-brands,.fab{font-weight:400}.fa-monero:before{content:"\f3d0"}.fa-hooli:before{content:"\f427"}.fa-yelp:before{content:"\f1e9"}.fa-cc-visa:before{content:"\f1f0"}.fa-lastfm:before{content:"\f202"}.fa-shopware:before{content:"\f5b5"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-aws:before{content:"\f375"}.fa-redhat:before{content:"\f7bc"}.fa-yoast:before{content:"\f2b1"}.fa-cloudflare:before{content:"\e07d"}.fa-ups:before{content:"\f7e0"}.fa-pixiv:before{content:"\e640"}.fa-wpexplorer:before{content:"\f2de"}.fa-dyalog:before{content:"\f399"}.fa-bity:before{content:"\f37a"}.fa-stackpath:before{content:"\f842"}.fa-buysellads:before{content:"\f20d"}.fa-first-order:before{content:"\f2b0"}.fa-modx:before{content:"\f285"}.fa-guilded:before{content:"\e07e"}.fa-vnv:before{content:"\f40b"}.fa-js-square:before,.fa-square-js:before{content:"\f3b9"}.fa-microsoft:before{content:"\f3ca"}.fa-qq:before{content:"\f1d6"}.fa-orcid:before{content:"\f8d2"}.fa-java:before{content:"\f4e4"}.fa-invision:before{content:"\f7b0"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-centercode:before{content:"\f380"}.fa-glide-g:before{content:"\f2a6"}.fa-drupal:before{content:"\f1a9"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-unity:before{content:"\e049"}.fa-whmcs:before{content:"\f40d"}.fa-rocketchat:before{content:"\f3e8"}.fa-vk:before{content:"\f189"}.fa-untappd:before{content:"\f405"}.fa-mailchimp:before{content:"\f59e"}.fa-css3-alt:before{content:"\f38b"}.fa-reddit-square:before,.fa-square-reddit:before{content:"\f1a2"}.fa-vimeo-v:before{content:"\f27d"}.fa-contao:before{content:"\f26d"}.fa-square-font-awesome:before{content:"\e5ad"}.fa-deskpro:before{content:"\f38f"}.fa-brave:before{content:"\e63c"}.fa-sistrix:before{content:"\f3ee"}.fa-instagram-square:before,.fa-square-instagram:before{content:"\e055"}.fa-battle-net:before{content:"\f835"}.fa-the-red-yeti:before{content:"\f69d"}.fa-hacker-news-square:before,.fa-square-hacker-news:before{content:"\f3af"}.fa-edge:before{content:"\f282"}.fa-threads:before{content:"\e618"}.fa-napster:before{content:"\f3d2"}.fa-snapchat-square:before,.fa-square-snapchat:before{content:"\f2ad"}.fa-google-plus-g:before{content:"\f0d5"}.fa-artstation:before{content:"\f77a"}.fa-markdown:before{content:"\f60f"}.fa-sourcetree:before{content:"\f7d3"}.fa-google-plus:before{content:"\f2b3"}.fa-diaspora:before{content:"\f791"}.fa-foursquare:before{content:"\f180"}.fa-stack-overflow:before{content:"\f16c"}.fa-github-alt:before{content:"\f113"}.fa-phoenix-squadron:before{content:"\f511"}.fa-pagelines:before{content:"\f18c"}.fa-algolia:before{content:"\f36c"}.fa-red-river:before{content:"\f3e3"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-safari:before{content:"\f267"}.fa-google:before{content:"\f1a0"}.fa-font-awesome-alt:before,.fa-square-font-awesome-stroke:before{content:"\f35c"}.fa-atlassian:before{content:"\f77b"}.fa-linkedin-in:before{content:"\f0e1"}.fa-digital-ocean:before{content:"\f391"}.fa-nimblr:before{content:"\f5a8"}.fa-chromecast:before{content:"\f838"}.fa-evernote:before{content:"\f839"}.fa-hacker-news:before{content:"\f1d4"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-adversal:before{content:"\f36a"}.fa-creative-commons:before{content:"\f25e"}.fa-watchman-monitoring:before{content:"\e087"}.fa-fonticons:before{content:"\f280"}.fa-weixin:before{content:"\f1d7"}.fa-shirtsinbulk:before{content:"\f214"}.fa-codepen:before{content:"\f1cb"}.fa-git-alt:before{content:"\f841"}.fa-lyft:before{content:"\f3c3"}.fa-rev:before{content:"\f5b2"}.fa-windows:before{content:"\f17a"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-square-viadeo:before,.fa-viadeo-square:before{content:"\f2aa"}.fa-meetup:before{content:"\f2e0"}.fa-centos:before{content:"\f789"}.fa-adn:before{content:"\f170"}.fa-cloudsmith:before{content:"\f384"}.fa-opensuse:before{content:"\e62b"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-dribbble-square:before,.fa-square-dribbble:before{content:"\f397"}.fa-codiepie:before{content:"\f284"}.fa-node:before{content:"\f419"}.fa-mix:before{content:"\f3cb"}.fa-steam:before{content:"\f1b6"}.fa-cc-apple-pay:before{content:"\f416"}.fa-scribd:before{content:"\f28a"}.fa-debian:before{content:"\e60b"}.fa-openid:before{content:"\f19b"}.fa-instalod:before{content:"\e081"}.fa-expeditedssl:before{content:"\f23e"}.fa-sellcast:before{content:"\f2da"}.fa-square-twitter:before,.fa-twitter-square:before{content:"\f081"}.fa-r-project:before{content:"\f4f7"}.fa-delicious:before{content:"\f1a5"}.fa-freebsd:before{content:"\f3a4"}.fa-vuejs:before{content:"\f41f"}.fa-accusoft:before{content:"\f369"}.fa-ioxhost:before{content:"\f208"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-app-store:before{content:"\f36f"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-itunes-note:before{content:"\f3b5"}.fa-golang:before{content:"\e40f"}.fa-kickstarter:before{content:"\f3bb"}.fa-grav:before{content:"\f2d6"}.fa-weibo:before{content:"\f18a"}.fa-uncharted:before{content:"\e084"}.fa-firstdraft:before{content:"\f3a1"}.fa-square-youtube:before,.fa-youtube-square:before{content:"\f431"}.fa-wikipedia-w:before{content:"\f266"}.fa-rendact:before,.fa-wpressr:before{content:"\f3e4"}.fa-angellist:before{content:"\f209"}.fa-galactic-republic:before{content:"\f50c"}.fa-nfc-directional:before{content:"\e530"}.fa-skype:before{content:"\f17e"}.fa-joget:before{content:"\f3b7"}.fa-fedora:before{content:"\f798"}.fa-stripe-s:before{content:"\f42a"}.fa-meta:before{content:"\e49b"}.fa-laravel:before{content:"\f3bd"}.fa-hotjar:before{content:"\f3b1"}.fa-bluetooth-b:before{content:"\f294"}.fa-square-letterboxd:before{content:"\e62e"}.fa-sticker-mule:before{content:"\f3f7"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-hips:before{content:"\f452"}.fa-behance:before{content:"\f1b4"}.fa-reddit:before{content:"\f1a1"}.fa-discord:before{content:"\f392"}.fa-chrome:before{content:"\f268"}.fa-app-store-ios:before{content:"\f370"}.fa-cc-discover:before{content:"\f1f2"}.fa-wpbeginner:before{content:"\f297"}.fa-confluence:before{content:"\f78d"}.fa-mdb:before{content:"\f8ca"}.fa-dochub:before{content:"\f394"}.fa-accessible-icon:before{content:"\f368"}.fa-ebay:before{content:"\f4f4"}.fa-amazon:before{content:"\f270"}.fa-unsplash:before{content:"\e07c"}.fa-yarn:before{content:"\f7e3"}.fa-square-steam:before,.fa-steam-square:before{content:"\f1b7"}.fa-500px:before{content:"\f26e"}.fa-square-vimeo:before,.fa-vimeo-square:before{content:"\f194"}.fa-asymmetrik:before{content:"\f372"}.fa-font-awesome-flag:before,.fa-font-awesome-logo-full:before,.fa-font-awesome:before{content:"\f2b4"}.fa-gratipay:before{content:"\f184"}.fa-apple:before{content:"\f179"}.fa-hive:before{content:"\e07f"}.fa-gitkraken:before{content:"\f3a6"}.fa-keybase:before{content:"\f4f5"}.fa-apple-pay:before{content:"\f415"}.fa-padlet:before{content:"\e4a0"}.fa-amazon-pay:before{content:"\f42c"}.fa-github-square:before,.fa-square-github:before{content:"\f092"}.fa-stumbleupon:before{content:"\f1a4"}.fa-fedex:before{content:"\f797"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-shopify:before{content:"\e057"}.fa-neos:before{content:"\f612"}.fa-square-threads:before{content:"\e619"}.fa-hackerrank:before{content:"\f5f7"}.fa-researchgate:before{content:"\f4f8"}.fa-swift:before{content:"\f8e1"}.fa-angular:before{content:"\f420"}.fa-speakap:before{content:"\f3f3"}.fa-angrycreative:before{content:"\f36e"}.fa-y-combinator:before{content:"\f23b"}.fa-empire:before{content:"\f1d1"}.fa-envira:before{content:"\f299"}.fa-google-scholar:before{content:"\e63b"}.fa-gitlab-square:before,.fa-square-gitlab:before{content:"\e5ae"}.fa-studiovinari:before{content:"\f3f8"}.fa-pied-piper:before{content:"\f2ae"}.fa-wordpress:before{content:"\f19a"}.fa-product-hunt:before{content:"\f288"}.fa-firefox:before{content:"\f269"}.fa-linode:before{content:"\f2b8"}.fa-goodreads:before{content:"\f3a8"}.fa-odnoklassniki-square:before,.fa-square-odnoklassniki:before{content:"\f264"}.fa-jsfiddle:before{content:"\f1cc"}.fa-sith:before{content:"\f512"}.fa-themeisle:before{content:"\f2b2"}.fa-page4:before{content:"\f3d7"}.fa-hashnode:before{content:"\e499"}.fa-react:before{content:"\f41b"}.fa-cc-paypal:before{content:"\f1f4"}.fa-squarespace:before{content:"\f5be"}.fa-cc-stripe:before{content:"\f1f5"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-bitcoin:before{content:"\f379"}.fa-keycdn:before{content:"\f3ba"}.fa-opera:before{content:"\f26a"}.fa-itch-io:before{content:"\f83a"}.fa-umbraco:before{content:"\f8e8"}.fa-galactic-senate:before{content:"\f50d"}.fa-ubuntu:before{content:"\f7df"}.fa-draft2digital:before{content:"\f396"}.fa-stripe:before{content:"\f429"}.fa-houzz:before{content:"\f27c"}.fa-gg:before{content:"\f260"}.fa-dhl:before{content:"\f790"}.fa-pinterest-square:before,.fa-square-pinterest:before{content:"\f0d3"}.fa-xing:before{content:"\f168"}.fa-blackberry:before{content:"\f37b"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-playstation:before{content:"\f3df"}.fa-quinscape:before{content:"\f459"}.fa-less:before{content:"\f41d"}.fa-blogger-b:before{content:"\f37d"}.fa-opencart:before{content:"\f23d"}.fa-vine:before{content:"\f1ca"}.fa-paypal:before{content:"\f1ed"}.fa-gitlab:before{content:"\f296"}.fa-typo3:before{content:"\f42b"}.fa-reddit-alien:before{content:"\f281"}.fa-yahoo:before{content:"\f19e"}.fa-dailymotion:before{content:"\e052"}.fa-affiliatetheme:before{content:"\f36b"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-bootstrap:before{content:"\f836"}.fa-odnoklassniki:before{content:"\f263"}.fa-nfc-symbol:before{content:"\e531"}.fa-mintbit:before{content:"\e62f"}.fa-ethereum:before{content:"\f42e"}.fa-speaker-deck:before{content:"\f83c"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-patreon:before{content:"\f3d9"}.fa-avianex:before{content:"\f374"}.fa-ello:before{content:"\f5f1"}.fa-gofore:before{content:"\f3a7"}.fa-bimobject:before{content:"\f378"}.fa-brave-reverse:before{content:"\e63d"}.fa-facebook-f:before{content:"\f39e"}.fa-google-plus-square:before,.fa-square-google-plus:before{content:"\f0d4"}.fa-mandalorian:before{content:"\f50f"}.fa-first-order-alt:before{content:"\f50a"}.fa-osi:before{content:"\f41a"}.fa-google-wallet:before{content:"\f1ee"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-periscope:before{content:"\f3da"}.fa-fulcrum:before{content:"\f50b"}.fa-cloudscale:before{content:"\f383"}.fa-forumbee:before{content:"\f211"}.fa-mizuni:before{content:"\f3cc"}.fa-schlix:before{content:"\f3ea"}.fa-square-xing:before,.fa-xing-square:before{content:"\f169"}.fa-bandcamp:before{content:"\f2d5"}.fa-wpforms:before{content:"\f298"}.fa-cloudversify:before{content:"\f385"}.fa-usps:before{content:"\f7e1"}.fa-megaport:before{content:"\f5a3"}.fa-magento:before{content:"\f3c4"}.fa-spotify:before{content:"\f1bc"}.fa-optin-monster:before{content:"\f23c"}.fa-fly:before{content:"\f417"}.fa-aviato:before{content:"\f421"}.fa-itunes:before{content:"\f3b4"}.fa-cuttlefish:before{content:"\f38c"}.fa-blogger:before{content:"\f37c"}.fa-flickr:before{content:"\f16e"}.fa-viber:before{content:"\f409"}.fa-soundcloud:before{content:"\f1be"}.fa-digg:before{content:"\f1a6"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-letterboxd:before{content:"\e62d"}.fa-symfony:before{content:"\f83d"}.fa-maxcdn:before{content:"\f136"}.fa-etsy:before{content:"\f2d7"}.fa-facebook-messenger:before{content:"\f39f"}.fa-audible:before{content:"\f373"}.fa-think-peaks:before{content:"\f731"}.fa-bilibili:before{content:"\e3d9"}.fa-erlang:before{content:"\f39d"}.fa-x-twitter:before{content:"\e61b"}.fa-cotton-bureau:before{content:"\f89e"}.fa-dashcube:before{content:"\f210"}.fa-42-group:before,.fa-innosoft:before{content:"\e080"}.fa-stack-exchange:before{content:"\f18d"}.fa-elementor:before{content:"\f430"}.fa-pied-piper-square:before,.fa-square-pied-piper:before{content:"\e01e"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-palfed:before{content:"\f3d8"}.fa-superpowers:before{content:"\f2dd"}.fa-resolving:before{content:"\f3e7"}.fa-xbox:before{content:"\f412"}.fa-searchengin:before{content:"\f3eb"}.fa-tiktok:before{content:"\e07b"}.fa-facebook-square:before,.fa-square-facebook:before{content:"\f082"}.fa-renren:before{content:"\f18b"}.fa-linux:before{content:"\f17c"}.fa-glide:before{content:"\f2a5"}.fa-linkedin:before{content:"\f08c"}.fa-hubspot:before{content:"\f3b2"}.fa-deploydog:before{content:"\f38e"}.fa-twitch:before{content:"\f1e8"}.fa-ravelry:before{content:"\f2d9"}.fa-mixer:before{content:"\e056"}.fa-lastfm-square:before,.fa-square-lastfm:before{content:"\f203"}.fa-vimeo:before{content:"\f40a"}.fa-mendeley:before{content:"\f7b3"}.fa-uniregistry:before{content:"\f404"}.fa-figma:before{content:"\f799"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-dropbox:before{content:"\f16b"}.fa-instagram:before{content:"\f16d"}.fa-cmplid:before{content:"\e360"}.fa-upwork:before{content:"\e641"}.fa-facebook:before{content:"\f09a"}.fa-gripfire:before{content:"\f3ac"}.fa-jedi-order:before{content:"\f50e"}.fa-uikit:before{content:"\f403"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-phabricator:before{content:"\f3db"}.fa-ussunnah:before{content:"\f407"}.fa-earlybirds:before{content:"\f39a"}.fa-trade-federation:before{content:"\f513"}.fa-autoprefixer:before{content:"\f41c"}.fa-whatsapp:before{content:"\f232"}.fa-slideshare:before{content:"\f1e7"}.fa-google-play:before{content:"\f3ab"}.fa-viadeo:before{content:"\f2a9"}.fa-line:before{content:"\f3c0"}.fa-google-drive:before{content:"\f3aa"}.fa-servicestack:before{content:"\f3ec"}.fa-simplybuilt:before{content:"\f215"}.fa-bitbucket:before{content:"\f171"}.fa-imdb:before{content:"\f2d8"}.fa-deezer:before{content:"\e077"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-jira:before{content:"\f7b1"}.fa-docker:before{content:"\f395"}.fa-screenpal:before{content:"\e570"}.fa-bluetooth:before{content:"\f293"}.fa-gitter:before{content:"\f426"}.fa-d-and-d:before{content:"\f38d"}.fa-microblog:before{content:"\e01a"}.fa-cc-diners-club:before{content:"\f24c"}.fa-gg-circle:before{content:"\f261"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-yandex:before{content:"\f413"}.fa-readme:before{content:"\f4d5"}.fa-html5:before{content:"\f13b"}.fa-sellsy:before{content:"\f213"}.fa-sass:before{content:"\f41e"}.fa-wirsindhandwerk:before,.fa-wsh:before{content:"\e2d0"}.fa-buromobelexperte:before{content:"\f37f"}.fa-salesforce:before{content:"\f83b"}.fa-octopus-deploy:before{content:"\e082"}.fa-medapps:before{content:"\f3c6"}.fa-ns8:before{content:"\f3d5"}.fa-pinterest-p:before{content:"\f231"}.fa-apper:before{content:"\f371"}.fa-fort-awesome:before{content:"\f286"}.fa-waze:before{content:"\f83f"}.fa-cc-jcb:before{content:"\f24b"}.fa-snapchat-ghost:before,.fa-snapchat:before{content:"\f2ab"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-rust:before{content:"\e07a"}.fa-wix:before{content:"\f5cf"}.fa-behance-square:before,.fa-square-behance:before{content:"\f1b5"}.fa-supple:before{content:"\f3f9"}.fa-rebel:before{content:"\f1d0"}.fa-css3:before{content:"\f13c"}.fa-staylinked:before{content:"\f3f5"}.fa-kaggle:before{content:"\f5fa"}.fa-space-awesome:before{content:"\e5ac"}.fa-deviantart:before{content:"\f1bd"}.fa-cpanel:before{content:"\f388"}.fa-goodreads-g:before{content:"\f3a9"}.fa-git-square:before,.fa-square-git:before{content:"\f1d2"}.fa-square-tumblr:before,.fa-tumblr-square:before{content:"\f174"}.fa-trello:before{content:"\f181"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-get-pocket:before{content:"\f265"}.fa-perbyte:before{content:"\e083"}.fa-grunt:before{content:"\f3ad"}.fa-weebly:before{content:"\f5cc"}.fa-connectdevelop:before{content:"\f20e"}.fa-leanpub:before{content:"\f212"}.fa-black-tie:before{content:"\f27e"}.fa-themeco:before{content:"\f5c6"}.fa-python:before{content:"\f3e2"}.fa-android:before{content:"\f17b"}.fa-bots:before{content:"\e340"}.fa-free-code-camp:before{content:"\f2c5"}.fa-hornbill:before{content:"\f592"}.fa-js:before{content:"\f3b8"}.fa-ideal:before{content:"\e013"}.fa-git:before{content:"\f1d3"}.fa-dev:before{content:"\f6cc"}.fa-sketch:before{content:"\f7c6"}.fa-yandex-international:before{content:"\f414"}.fa-cc-amex:before{content:"\f1f3"}.fa-uber:before{content:"\f402"}.fa-github:before{content:"\f09b"}.fa-php:before{content:"\f457"}.fa-alipay:before{content:"\f642"}.fa-youtube:before{content:"\f167"}.fa-skyatlas:before{content:"\f216"}.fa-firefox-browser:before{content:"\e007"}.fa-replyd:before{content:"\f3e6"}.fa-suse:before{content:"\f7d6"}.fa-jenkins:before{content:"\f3b6"}.fa-twitter:before{content:"\f099"}.fa-rockrms:before{content:"\f3e9"}.fa-pinterest:before{content:"\f0d2"}.fa-buffer:before{content:"\f837"}.fa-npm:before{content:"\f3d4"}.fa-yammer:before{content:"\f840"}.fa-btc:before{content:"\f15a"}.fa-dribbble:before{content:"\f17d"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-internet-explorer:before{content:"\f26b"}.fa-stubber:before{content:"\e5c7"}.fa-telegram-plane:before,.fa-telegram:before{content:"\f2c6"}.fa-old-republic:before{content:"\f510"}.fa-odysee:before{content:"\e5c6"}.fa-square-whatsapp:before,.fa-whatsapp-square:before{content:"\f40c"}.fa-node-js:before{content:"\f3d3"}.fa-edge-legacy:before{content:"\e078"}.fa-slack-hash:before,.fa-slack:before{content:"\f198"}.fa-medrt:before{content:"\f3c8"}.fa-usb:before{content:"\f287"}.fa-tumblr:before{content:"\f173"}.fa-vaadin:before{content:"\f408"}.fa-quora:before{content:"\f2c4"}.fa-square-x-twitter:before{content:"\e61a"}.fa-reacteurope:before{content:"\f75d"}.fa-medium-m:before,.fa-medium:before{content:"\f23a"}.fa-amilia:before{content:"\f36d"}.fa-mixcloud:before{content:"\f289"}.fa-flipboard:before{content:"\f44d"}.fa-viacoin:before{content:"\f237"}.fa-critical-role:before{content:"\f6c9"}.fa-sitrox:before{content:"\e44a"}.fa-discourse:before{content:"\f393"}.fa-joomla:before{content:"\f1aa"}.fa-mastodon:before{content:"\f4f6"}.fa-airbnb:before{content:"\f834"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-buy-n-large:before{content:"\f8a6"}.fa-gulp:before{content:"\f3ae"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-strava:before{content:"\f428"}.fa-ember:before{content:"\f423"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-teamspeak:before{content:"\f4f9"}.fa-pushed:before{content:"\f3e1"}.fa-wordpress-simple:before{content:"\f411"}.fa-nutritionix:before{content:"\f3d6"}.fa-wodu:before{content:"\e088"}.fa-google-pay:before{content:"\e079"}.fa-intercom:before{content:"\f7af"}.fa-zhihu:before{content:"\f63f"}.fa-korvue:before{content:"\f42f"}.fa-pix:before{content:"\e43a"}.fa-steam-symbol:before{content:"\f3f6"}:host,:root{--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a} \ No newline at end of file diff --git a/website/static/css/tailwind.min.css b/website/static/css/tailwind.min.css new file mode 100644 index 0000000..a386466 --- /dev/null +++ b/website/static/css/tailwind.min.css @@ -0,0 +1 @@ +/*! tailwindcss v3.3.5 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji,Font Awesome\ 6 Pro;font-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid #0000;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid #0000;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:#0000;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=checkbox]:indeterminate,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:#0000;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:#0000;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.absolute{position:absolute}.relative{position:relative}.-inset-0{inset:0}.-inset-0\.5{inset:-.125rem}.col-span-1{grid-column:span 1/span 1}.col-span-2{grid-column:span 2/span 2}.m-4{margin:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.-mr-2{margin-right:-.5rem}.mb-2{margin-bottom:.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.mt-5{margin-top:1.25rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-16{height:4rem}.h-4{height:1rem}.h-6{height:1.5rem}.h-full{height:100%}.w-10{width:2.5rem}.w-4{width:1rem}.w-full{width:100%}.min-w-0{min-width:0}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-x-1{-moz-column-gap:.25rem;column-gap:.25rem}.gap-x-1\.5{-moz-column-gap:.375rem;column-gap:.375rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-6>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.5rem*var(--tw-space-x-reverse));margin-left:calc(1.5rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity))}.self-start{align-self:flex-start}.self-center{align-self:center}.justify-self-center{justify-self:center}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border-0{border-width:0}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.border-indigo-500{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity))}.border-transparent{border-color:#0000}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity))}.bg-indigo-400{--tw-bg-opacity:1;background-color:rgb(129 140 248/var(--tw-bg-opacity))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pl-3{padding-left:.75rem}.pr-10{padding-right:2.5rem}.pr-4{padding-right:1rem}.pt-1{padding-top:.25rem}.pt-1\.5{padding-top:.375rem}.pt-2{padding-top:.5rem}.text-center{text-align:center}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-5{line-height:1.25rem}.leading-6{line-height:1.5rem}.leading-7{line-height:1.75rem}.leading-8{line-height:2rem}.tracking-tight{letter-spacing:-.025em}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.opacity-25{opacity:.25}.shadow{--tw-shadow:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-1,.ring-2{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-inset{--tw-ring-inset:inset}.ring-gray-300{--tw-ring-opacity:1;--tw-ring-color:rgb(209 213 219/var(--tw-ring-opacity))}.ring-green-600\/20{--tw-ring-color:#16a34a33}.ring-indigo-600{--tw-ring-opacity:1;--tw-ring-color:rgb(79 70 229/var(--tw-ring-opacity))}.ring-offset-2{--tw-ring-offset-width:2px}.placeholder\:text-gray-400::-moz-placeholder{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.placeholder\:text-gray-400::placeholder{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity))}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.hover\:bg-indigo-500:hover{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity))}.hover\:text-gray-500:hover{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-inset:focus{--tw-ring-inset:inset}.focus\:ring-indigo-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity))}.focus\:ring-indigo-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(79 70 229/var(--tw-ring-opacity))}.focus-visible\:outline:focus-visible{outline-style:solid}.focus-visible\:outline-2:focus-visible{outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-indigo-600:focus-visible{outline-color:#4f46e5}@media (min-width:640px){.sm\:my-8{margin-top:2rem;margin-bottom:2rem}.sm\:ml-6{margin-left:1.5rem}.sm\:mt-16{margin-top:4rem}.sm\:mt-8{margin-top:2rem}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:flex-1{flex:1 1 0%}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:items-center{align-items:center}.sm\:space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.sm\:rounded-lg{border-radius:.5rem}.sm\:p-6{padding:1.5rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-16{padding-top:4rem;padding-bottom:4rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}.sm\:leading-6{line-height:1.5rem}}@media (min-width:768px){.md\:order-1{order:1}.md\:order-2{order:2}.md\:mt-0{margin-top:0}.md\:flex{display:flex}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}}@media (min-width:1024px){.lg\:mx-0{margin-left:0;margin-right:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}} \ No newline at end of file diff --git a/website/static/favicon-16x16.png b/website/static/favicon-16x16.png new file mode 100644 index 0000000..c4c80c8 Binary files /dev/null and b/website/static/favicon-16x16.png differ diff --git a/website/static/favicon-32x32.png b/website/static/favicon-32x32.png new file mode 100644 index 0000000..9bee895 Binary files /dev/null and b/website/static/favicon-32x32.png differ diff --git a/website/static/favicon.ico b/website/static/favicon.ico new file mode 100644 index 0000000..5579f15 Binary files /dev/null and b/website/static/favicon.ico differ diff --git a/website/static/js/generate.js b/website/static/js/generate.js new file mode 100644 index 0000000..1b2d26e --- /dev/null +++ b/website/static/js/generate.js @@ -0,0 +1,387 @@ +const keyTypeRSA = 'RSA'; +const keyTypeECDSA = 'ECDSA'; +const keyTypeEd25519 = 'Ed25519'; +const keyTypeX25519 = 'X25519'; +const keyTypeSymmetric = 'Symmetric'; + +$(function () { + let newGenButton = $('#new-gen-button'); + let newKeyType = $('input[name="new-key-type"]'); + let newKeyID = $('#new-key-id'); + let newKeyAlg = $('#new-key-alg'); + let newKeyAlgOptional = $('#new-key-alg-optional'); + let newUse = $('#new-key-use'); + let newKeyOpSign = $('#new-key-op-sign'); + let newKeyOpVerify = $('#new-key-op-verify'); + let newKeyOpEncrypt = $('#new-key-op-encrypt'); + let newKeyOpDecrypt = $('#new-key-op-decrypt'); + let newKeyOpWrapKey = $('#new-key-op-wrap-key'); + let newKeyOpUnwrapKey = $('#new-key-op-unwrap-key'); + let newKeyOpDeriveKey = $('#new-key-op-derive-key'); + let newKeyOpDeriveBits = $('#new-key-op-derive-bits'); + let newRSABitsChoice = $('input[name="new-rsa-bits"]'); + let newECDSACurveChoice = $('input[name="new-ecdsa-curve"]'); + let newRSABits = $('#new-rsa-bits'); + let newECDSACurve = $('#new-ecdsa-curve'); + let newGenCopyJWK = $('#new-gen-copy-jwk'); + let newGenCopyPKCS8 = $('#new-gen-copy-pkcs8'); + let newGenCopyPKIX = $('#new-gen-copy-pkix'); + let newGenResults = $('#new-gen-results'); + let newGenJWKResult = $('#new-gen-jwk-result'); + let newGenPKCS8Result = $('#new-gen-pkcs8-result'); + let newGenPKIXResult = $('#new-gen-pkix-result'); + let newGenPKCS8 = $('#new-gen-pkcs8'); + let newGenPKIX = $('#new-gen-pkix'); + let newResultButton = $('#new-result-button'); + let newResultText = $('#new-result-text'); + let newResultsList = $('#new-results-list'); + + let pemGenButton = $('#pem-gen-button'); + let pemInput = $('#pem-input'); + let pemKeyID = $('#pem-key-id'); + let pemKeyAlg = $('#pem-key-alg'); + let pemKeyUse = $('#pem-key-use'); + let pemKeyOpSign = $('#pem-key-op-sign'); + let pemKeyOpVerify = $('#pem-key-op-verify'); + let pemKeyOpEncrypt = $('#pem-key-op-encrypt'); + let pemKeyOpDecrypt = $('#pem-key-op-decrypt'); + let pemKeyOpWrapKey = $('#pem-key-op-wrap-key'); + let pemKeyOpUnwrapKey = $('#pem-key-op-unwrap-key'); + let pemKeyOpDeriveKey = $('#pem-key-op-derive-key'); + let pemKeyOpDeriveBits = $('#pem-key-op-derive-bits'); + let pemGenCopyJWK = $('#pem-gen-copy-jwk'); + let pemGenResults = $('#pem-gen-results'); + let pemGenJWKResult = $('#pem-gen-jwk-result'); + let pemResultButton = $('#pem-result-button'); + let pemResultText = $('#pem-result-text'); + let pemResultsList = $('#pem-results-list'); + pemKeyID.val(crypto.randomUUID()); + + pemGenCopyJWK.on('click', function () { + navigator.clipboard.writeText(pemGenJWKResult.text()); + }); + newGenCopyJWK.on('click', function () { + navigator.clipboard.writeText(newGenJWKResult.text()); + }); + newGenCopyPKCS8.on('click', function () { + navigator.clipboard.writeText(newGenPKCS8Result.text()); + }); + newGenCopyPKIX.on('click', function () { + navigator.clipboard.writeText(newGenPKIXResult.text()); + }); + + function pemGenCompete(jqXHR, status) { + switch (jqXHR.status) { + case 200: + pemResultText.text('The PEM is valid. The JWK results are below.'); + pemResultButton.removeClass('bg-red-600').addClass('bg-green-600'); + pemResultButton.contents().filter(function () { + return this.nodeType === 3; + }).first().replaceWith('Valid'); + pemResultButton.find('i').removeClass('fa-circle-xmark').addClass('fa-circle-check'); + unhide(pemGenResults); + unhide(pemResultsList); + pemGenJWKResult.text(jqXHR.responseJSON.data.jwk); + break; + default: + let message = jqXHR.responseJSON?.data?.message; + pemResultText.text(`The PEM generation failed. ${message}`); + pemResultButton.removeClass('bg-green-600').addClass('bg-red-600'); + pemResultButton.contents().filter(function () { + return this.nodeType === 3; + }).first().replaceWith('Invalid'); + unhide(pemGenResults); + hide(pemResultsList); + pemResultButton.find('i').removeClass('fa-circle-check').addClass('fa-circle-xmark'); + } + scroll(pemGenResults); + } + + pemGenButton.on('click', function () { + let keyOps = []; + if (pemKeyOpSign.is(':checked')) { + keyOps.push('sign'); + } + if (pemKeyOpVerify.is(':checked')) { + keyOps.push('verify'); + } + if (pemKeyOpEncrypt.is(':checked')) { + keyOps.push('encrypt'); + } + if (pemKeyOpDecrypt.is(':checked')) { + keyOps.push('decrypt'); + } + if (pemKeyOpWrapKey.is(':checked')) { + keyOps.push('wrapKey'); + } + if (pemKeyOpUnwrapKey.is(':checked')) { + keyOps.push('unwrapKey'); + } + if (pemKeyOpDeriveKey.is(':checked')) { + keyOps.push('deriveKey'); + } + if (pemKeyOpDeriveBits.is(':checked')) { + keyOps.push('deriveBits'); + } + let data = { + alg: pemKeyAlg.val(), + keyops: keyOps, + kid: pemKeyID.val(), + pem: pemInput.val(), + use: pemKeyUse.val(), + }; + postReCAPTCHA('pemGen', pemGenCompete, data, pathAPIPemGen, reCAPTCHASiteKey); + }); + + pemInput.on('input', function () { + let val = pemInput.val(); + if (val === '') { + pemGenButton.prop('disabled', true); + pemGenButton.removeClass('cursor-pointer').addClass('cursor-not-allowed'); + pemGenButton.removeClass('bg-indigo-600 hover:bg-indigo-500').addClass('bg-indigo-400'); + } else { + pemGenButton.prop('disabled', false); + pemGenButton.removeClass('cursor-not-allowed').addClass('cursor-pointer'); + pemGenButton.removeClass('bg-indigo-400').addClass('bg-indigo-600 hover:bg-indigo-500'); + } + }); + + function newGenCompete(jqXHR, status) { + switch (jqXHR.status) { + case 200: + newResultText.text('The results from the new key generation.'); + newResultButton.removeClass('bg-red-600').addClass('bg-green-600'); + newResultButton.contents().filter(function () { + return this.nodeType === 3; + }).first().replaceWith('Valid'); + newResultButton.find('i').removeClass('fa-circle-xmark').addClass('fa-circle-check'); + unhide(newGenResults); + unhide(newResultsList); + newGenJWKResult.text(jqXHR.responseJSON.data.jwk); + if (jqXHR.responseJSON.data.pkcs8) { + newGenPKCS8Result.text(jqXHR.responseJSON.data.pkcs8); + unhide(newGenPKCS8); + } else { + hide(newGenPKCS8); + } + if (jqXHR.responseJSON.data.pkix) { + newGenPKIXResult.text(jqXHR.responseJSON.data.pkix); + unhide(newGenPKIX); + } else { + hide(newGenPKIX); + } + break; + default: + let message = jqXHR.responseJSON?.data?.message; + newResultText.text(`Key generation failed. ${message}`); + newResultButton.removeClass('bg-green-600').addClass('bg-red-600'); + newResultButton.contents().filter(function () { + return this.nodeType === 3; + }).first().replaceWith('Invalid'); + unhide(newGenResults); + hide(newResultsList); + newResultButton.find('i').removeClass('fa-circle-check').addClass('fa-circle-xmark'); + } + scroll(newGenResults); + } + + newGenButton.on('click', function () { + let keyOps = []; + if (newKeyOpSign.is(':checked')) { + keyOps.push('sign'); + } + if (newKeyOpVerify.is(':checked')) { + keyOps.push('verify'); + } + if (newKeyOpEncrypt.is(':checked')) { + keyOps.push('encrypt'); + } + if (newKeyOpDecrypt.is(':checked')) { + keyOps.push('decrypt'); + } + if (newKeyOpWrapKey.is(':checked')) { + keyOps.push('wrapKey'); + } + if (newKeyOpUnwrapKey.is(':checked')) { + keyOps.push('unwrapKey'); + } + if (newKeyOpDeriveKey.is(':checked')) { + keyOps.push('deriveKey'); + } + if (newKeyOpDeriveBits.is(':checked')) { + keyOps.push('deriveBits'); + } + let data = { + alg: newKeyAlg.val(), + keyops: keyOps, + keyType: newKeyType.filter(':checked').val(), + kid: newKeyID.val(), + use: newUse.val(), + rsaBits: parseInt(newRSABitsChoice.filter(':checked').val()), + ecCurve: newECDSACurveChoice.filter(':checked').val(), + }; + postReCAPTCHA('newGen', newGenCompete, data, pathAPINewGen, reCAPTCHASiteKey); + }); + + let rsaAlgs = [ + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'RSA1_5', + 'RSA-OAEP', + 'RSA-OAEP-256', + 'RSA-OAEP-384', + 'RSA-OAEP-512', + ]; // RS1 Prohibited. + let ecdsaAlgs = [ + 'ES256', + 'ES384', + 'ES512', + 'ES256K', + ]; + let ed25519Algs = [ + 'EdDSA', + ]; + let x25519Algs = [ + 'ECDH-ES', + 'ECDH-ES+A128KW', + 'ECDH-ES+A192KW', + 'ECDH-ES+A256KW', + ]; + let symmetricAlgs = [ + 'HS256', + 'HS384', + 'HS512', + 'dir', + 'A128KW', + 'A192KW', + 'A256KW', + 'A128GCMKW', + 'A192GCMKW', + 'A256GCMKW', + 'PBES2-HS256+A128KW', + 'PBES2-HS384+A192KW', + 'PBES2-HS512+A256KW', + 'A128CBC-HS256', + 'A192CBC-HS384', + 'A256CBC-HS512', + 'A128GCM', + 'A192GCM', + 'A256GCM', + ]; + + const emptySelection = ''; + pemKeyAlg.append(emptySelection); + let pemAlgs = rsaAlgs.concat(ecdsaAlgs, ed25519Algs, x25519Algs); + for (let i = 0; i < pemAlgs.length; i++) { + let alg = pemAlgs[i]; + pemKeyAlg.append(``); + } + + function keyPanelChange(keyType) { + newKeyID.val(crypto.randomUUID()); + let algs; + switch (keyType) { + case keyTypeRSA: + hide(newECDSACurve); + unhide(newRSABits); + unhide(newKeyAlgOptional); + algs = rsaAlgs; + break; + case keyTypeECDSA: + unhide(newECDSACurve); + hide(newRSABits); + unhide(newKeyAlgOptional); + algs = ecdsaAlgs; + break; + case keyTypeEd25519: + hide(newECDSACurve); + hide(newRSABits); + hide(newKeyAlgOptional); + algs = ed25519Algs; + break; + case keyTypeX25519: + hide(newECDSACurve); + hide(newRSABits); + unhide(newKeyAlgOptional); + algs = x25519Algs; + break; + case keyTypeSymmetric: + hide(newECDSACurve); + hide(newRSABits); + unhide(newKeyAlgOptional); + algs = symmetricAlgs; + break; + default: + algs = []; + console.log('Unknown key type: ' + keyType); + break; + } + newKeyAlg.empty(); + if (keyType === keyTypeEd25519) { + newKeyAlg.prop('disabled', true); + newKeyAlg.append(''); + } else { + newKeyAlg.prop('disabled', false); + newKeyAlg.append(emptySelection); + for (let i = 0; i < algs.length; i++) { + let alg = algs[i]; + newKeyAlg.append(``); + } + } + let selectionSignature = ''; + let selectionEncryption = ''; + switch (keyType) { + case keyTypeECDSA: + newUse.empty(); + newUse.append(emptySelection); + newUse.append(selectionSignature); + break; + case keyTypeEd25519: + newUse.empty(); + newUse.append(emptySelection); + newUse.append(selectionSignature); + break; + case keyTypeX25519: + newUse.empty(); + newUse.append(emptySelection); + newUse.append(selectionEncryption); + break; + default: + newUse.empty(); + newUse.append(emptySelection); + newUse.append(selectionSignature); + newUse.append(selectionEncryption); + break; + } + } + + keyPanelChange(keyTypeRSA); + + newKeyType.on('change', function () { + let keyType = newKeyType.filter(':checked').val(); + keyPanelChange(keyType); + }); + + function radioChange(r) { + r.on('change', function () { + // Remove the active class from all options + let parent = r.parent(); + const selected = 'bg-indigo-600 text-white hover:bg-indigo-500'; + const unselected = 'ring-1 ring-inset ring-gray-300 bg-white text-gray-900 hover:bg-gray-50'; + parent.removeClass(selected); + parent.addClass(unselected); + + // Add the active class to the selected option + let t = $(this).parent(); + t.removeClass(unselected); + t.addClass(selected); + }); + } + + radioChange(newRSABitsChoice); + radioChange(newECDSACurveChoice); +}); diff --git a/website/static/js/inspect.js b/website/static/js/inspect.js new file mode 100644 index 0000000..1a5839e --- /dev/null +++ b/website/static/js/inspect.js @@ -0,0 +1,77 @@ +$(function () { + let jwkInspectButton = $('#jwk-inspect-button'); + let jwkInput = $('#jwk-input'); + let inspectResults = $('#inspect-results'); + let jwkResult = $('#jwk-result'); + let jwkResultText = $('#jwk-result-text'); + let pkcs8Result = $('#pkcs8-result'); + let pkcs8ResultText = $('#pkcs8-result-text'); + let pkixResult = $('#pkix-result'); + let pkixResultText = $('#pkix-result-text'); + let resultButton = $('#result-button'); + let resultText = $('#result-text'); + + jwkInput.on('input', function () { + let jwk = jwkInput.val(); + if (jwk) { + jwkInspectButton.prop('disabled', false); + jwkInspectButton.removeClass('cursor-not-allowed').addClass('cursor-pointer'); + jwkInspectButton.removeClass('bg-indigo-400').addClass('bg-indigo-600 hover:bg-indigo-500'); + } else { + jwkInspectButton.prop('disabled', true); + jwkInspectButton.removeClass('cursor-pointer').addClass('cursor-not-allowed'); + jwkInspectButton.removeClass('bg-indigo-600 hover:bg-indigo-500').addClass('bg-indigo-400'); + } + }); + + function complete(jqXHR, status) { + switch (jqXHR.status) { + case 200: + resultText.text('The JWK is valid. The parsing results are below.'); + resultButton.removeClass('bg-red-600').addClass('bg-green-600'); + resultButton.contents().filter(function () { + return this.nodeType === 3; + }).first().replaceWith('Valid'); + resultButton.find('i').removeClass('fa-circle-xmark').addClass('fa-circle-check'); + unhide(inspectResults); + unhide(jwkResult); + jwkResultText.text(jqXHR.responseJSON.data.jwk); + let pkcs8 = jqXHR.responseJSON.data.pkcs8; + if (pkcs8) { + pkcs8ResultText.text(pkcs8); + unhide(pkcs8Result); + } else { + hide(pkcs8Result); + } + let pkix = jqXHR.responseJSON.data.pkix; + if (pkix) { + pkixResultText.text(pkix); + unhide(pkixResult); + } else { + hide(pkixResult); + } + scroll(inspectResults); + break; + default: + let message = jqXHR.responseJSON?.data?.message; + resultText.text(`The JWK is invalid. ${message}`); + resultButton.removeClass('bg-green-600').addClass('bg-red-600'); + resultButton.contents().filter(function () { + return this.nodeType === 3; + }).first().replaceWith('Invalid'); + resultButton.find('i').removeClass('fa-circle-check').addClass('fa-circle-xmark'); + unhide(inspectResults); + hide(jwkResult); + hide(pkcs8Result); + hide(pkixResult); + } + scroll(inspectResults); + } + + jwkInspectButton.on('click', function () { + let data = { + jwk: jwkInput.val(), + }; + postReCAPTCHA('inspect', complete, data, pathAPIInspect, reCAPTCHASiteKey); + }); +}); diff --git a/website/static/js/wrapper.js b/website/static/js/wrapper.js new file mode 100644 index 0000000..747a448 --- /dev/null +++ b/website/static/js/wrapper.js @@ -0,0 +1,53 @@ +function hide(j) { + j.addClass('hidden'); +} + +function unhide(j) { + j.removeClass('hidden'); +} + +$(function () { + let mobileMenuButton = $('#mobile-menu-button'); + let mobileMenuIcon = $('#mobile-menu-icon'); + let mobileMenu = $('#mobile-menu'); + mobileMenuButton.on('click', function () { + mobileMenuIcon.toggleClass('fa-bars fa-x'); + mobileMenu.toggleClass('hidden'); + }); +}); + +// https://stackoverflow.com/a/6677069/14797322 +function scroll(e) { + $([document.documentElement, document.body]).animate({ + scrollTop: e.offset().top + }, 500); +} + +function postReCAPTCHA(action, complete, data, postURL, siteKey) { + if (reCAPTCHASiteKey === '') { + $.ajax(postURL, { + accepts: 'application/json', + contentType: 'application/json', + data: JSON.stringify(data), + dataType: 'json', + method: 'POST', + complete: complete, + }); + return + } + grecaptcha.ready(function () { + grecaptcha.execute(siteKey, {action: action}).then(function (token) { + $.ajax(postURL, { + accepts: 'application/json', + contentType: 'application/json', + data: JSON.stringify(data), + dataType: 'json', + headers: { + 'g-recaptcha-response': token, + }, + method: 'POST', + complete: complete, + }); + }); + }); +} \ No newline at end of file diff --git a/website/static/webfonts/fa-brands-400.ttf b/website/static/webfonts/fa-brands-400.ttf new file mode 100644 index 0000000..702efa6 Binary files /dev/null and b/website/static/webfonts/fa-brands-400.ttf differ diff --git a/website/static/webfonts/fa-brands-400.woff2 b/website/static/webfonts/fa-brands-400.woff2 new file mode 100644 index 0000000..1315a9c Binary files /dev/null and b/website/static/webfonts/fa-brands-400.woff2 differ diff --git a/website/static/webfonts/fa-solid-900.ttf b/website/static/webfonts/fa-solid-900.ttf new file mode 100644 index 0000000..1a80239 Binary files /dev/null and b/website/static/webfonts/fa-solid-900.ttf differ diff --git a/website/static/webfonts/fa-solid-900.woff2 b/website/static/webfonts/fa-solid-900.woff2 new file mode 100644 index 0000000..46ff23c Binary files /dev/null and b/website/static/webfonts/fa-solid-900.woff2 differ diff --git a/website/tailwind.config.js b/website/tailwind.config.js new file mode 100644 index 0000000..86e6882 --- /dev/null +++ b/website/tailwind.config.js @@ -0,0 +1,16 @@ +/** @type {import('tailwindcss').Config} */ +const defaultTheme = require('tailwindcss/defaultTheme'); +module.exports = { + content: ['./templates/*.gohtml'], + theme: { + fontFamily: { + 'sans': [...defaultTheme.fontFamily.sans, '"Font Awesome 6 Pro"'] + }, + extend: {}, + }, + plugins: [ + require('@tailwindcss/aspect-ratio'), + require('@tailwindcss/forms'), + require('@tailwindcss/typography'), + ], +}; diff --git a/website/templates/generate.gohtml b/website/templates/generate.gohtml new file mode 100644 index 0000000..648faed --- /dev/null +++ b/website/templates/generate.gohtml @@ -0,0 +1,646 @@ +{{- define "generate.gohtml.header" -}} + +{{- end -}} +{{- /*gotype: github.com/MicahParks/jwkset/website/handle/template.GenerateData*/ -}} + +{{- /*Header*/}} +
+
+
+ + Open source self-host instructions here + +

+ JWK Generator +

+

+ Use PEM encoded ASN.1 DER data for SEC 1, PKCS #1, PKCS #8, PKIX, or certificates to generate a JWK or + generate a new key. +

+
+
+
+{{- /*Header*/}} + +
+ {{- /*Use existing PEM encoded key or certificate*/}} +
+
+
+
+

+ Generate using PEM +

+

+ Generate a JWK given a PEM encoded key or certificate. +
+ Do not upload a private key unless this website is self-hosted. +

+
+
+ +
+
+
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + Key operations (optional) + + +
+ + + + + + + + +
+
+
+
+
+
+ + + {{- /*Use existing PEM encoded key or certificate*/}} + + {{- /*Generate a new key*/}} +
+
+
+
+

+ Generate a new key +

+

+ Generate a new key given and receive the JWK, PKIX public key, and PKCS #8 private key. +
+ Only trust the private key if you are self-hosting this website. +

+
+
+ +
+
+
+
+
+ Key type +
+
+ + Select a cryptographic key type + +
+ + + + + +
+
+
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+ Bits +
+
+ Choose an RSA key bit size +
+ + + + +
+
+
+ +
+
+
+ + Key operations (optional) + + +
+ + + + + + + + +
+
+
+
+
+
+ + + {{- /*Generate a new key*/}} +
diff --git a/website/templates/index.gohtml b/website/templates/index.gohtml new file mode 100644 index 0000000..61d70a0 --- /dev/null +++ b/website/templates/index.gohtml @@ -0,0 +1,124 @@ +{{- /*gotype: github.com/MicahParks/jwkset/website/handle/template.IndexData*/ -}} + +{{- /*Header*/}} +
+
+
+

+ JWK Set +

+

+ A JSON Web Key Set (JWK Set) is a JSON representation of a set of cryptographic keys and metadata. JWK Sets are + defined in + IANA, + RFC 7517, + RFC 8037, + and various other + RFCs. +

+
+
+
+{{- /*Header*/}} + +{{- /*Code*/}} +
+
+ Example JWK Set +
+
+
{
+  "keys": [
+    {
+      "kty": "EC",
+      "kid": "fd415283-5b58-4372-8f97-3c5b26910d85",
+      "crv": "P-256",
+      "x": "pYkxEyczvZkQ7UG1rIpl6fBAQQvXmpITYv99Uf3X7aE",
+      "y": "uQKi7IUrz3wwlcy1yW3HbZxiu5bQgRTfoVFDIFFHluE",
+      "d": "2bkgxUvO64UL-ouu4Eib02PA39nQ-HBmrN7jESp1gag"
+    },
+    {
+      "kty": "OKP",
+      "alg": "EdDSA",
+      "kid": "b86fe288-87e7-4926-891e-0e63736711ec",
+      "crv": "Ed25519",
+      "x": "JVuzaFQ-d6Q3AGgLerQNjRDaTwoF1jBGt3ScDhQ4Dso",
+      "d": "yO5_dyngoqDMqWvcm02kSvqq0uDbTelRAXKYlCBXRas"
+    },
+    {
+      "kty": "OKP",
+      "kid": "7f68a3cc-9970-49cb-8622-c686312f3ddc",
+      "crv": "X25519",
+      "x": "6WnrHvj1DP7NoSnk5qrID95jbTjC0zy-jexWR0Wnjm4",
+      "d": "V2cebWWmT9QX6IZ3qTBv2z9s7_u1T-8fUZDvF1fgv98"
+    }
+  ]
+}
+
+
+{{- /*Code*/}} + +{{- /*Tools*/}} +
+
+ Tools +
+
+ +
+

+ Generator +

+
+

+ Generate a JWK using an existing cryptographic key or create a new one. +

+
+
+
+ +
+
+ +
+

+ Inspector +

+
+

+ Inspect a JWK to validate it an extract cryptographic keys. +

+
+
+
+ +
+
+
+
+{{- /*Tools*/}} + +{{- /*Self-host*/}} +
+
+
+

+ Self-host this website +

+

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

+ +
+
+
+{{- /*Self-host*/}} diff --git a/website/templates/inspect.gohtml b/website/templates/inspect.gohtml new file mode 100644 index 0000000..88b5cfc --- /dev/null +++ b/website/templates/inspect.gohtml @@ -0,0 +1,160 @@ +{{- define "inspect.gohtml.header" -}} + +{{- end -}} +{{- /*gotype: github.com/MicahParks/jwkset/website/handle/template.InspectData*/ -}} + +{{- /*Header*/}} +
+
+
+ + Open source self-host instructions here + +

+ JWK Inspector +

+

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

+
+
+
+{{- /*Header*/}} + +
+ {{/*Inspector*/}} +
+
+
+
+

+ Inspect a JWK +

+

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

+
+
+ +
+
+ +
+
+ {{/*Inspector*/}} + + {{/*Results*/}} + + {{/*Results*/}} +
diff --git a/website/templates/wrapper.gohtml b/website/templates/wrapper.gohtml new file mode 100644 index 0000000..bdb2cad --- /dev/null +++ b/website/templates/wrapper.gohtml @@ -0,0 +1,112 @@ +{{- /*gotype: github.com/MicahParks/jwkset/website/server.WrapperData*/ -}} + + + + + + + {{- if .ReCAPTCHASiteKey}} + + {{- end}} + + {{.Title}} + + {{- /*Font Awesome*/}} + + {{- /*Font Awesome*/}} + {{- /*Favicon*/}} + + + + {{/* */}} + + + {{- /*Favicon*/}} + + {{- if .Result.HeaderAdd}} + {{.Result.HeaderAdd}} + {{- end}} + + + + +
+
+
+
+ {{.Result.InnerHTML}} +
+
+
+
+ + + diff --git a/x509.go b/x509.go new file mode 100644 index 0000000..b89a3a6 --- /dev/null +++ b/x509.go @@ -0,0 +1,125 @@ +package jwkset + +import ( + "crypto/ecdh" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" +) + +var ( + // ErrX509Infer is returned when the key type cannot be inferred from the PEM block type. + ErrX509Infer = errors.New("failed to infer X509 key type") +) + +// LoadCertificate loads an X509 certificate from a PEM block. +func LoadCertificate(pemBlock []byte) (*x509.Certificate, error) { + cert, err := x509.ParseCertificate(pemBlock) + if err != nil { + return nil, fmt.Errorf("failed to parse certificates: %w", err) + } + switch cert.PublicKey.(type) { + case *ecdsa.PublicKey, ed25519.PublicKey, *rsa.PublicKey: + default: + return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, cert.PublicKey) + } + return cert, nil +} + +// LoadCertificates loads X509 certificates from raw PEM data. It can be useful in loading X5U remote resources. +func LoadCertificates(rawPEM []byte) ([]*x509.Certificate, error) { + b := make([]byte, 0) + for { + block, rest := pem.Decode(rawPEM) + if block == nil { + break + } + rawPEM = rest + if block.Type == "CERTIFICATE" { + b = append(b, block.Bytes...) + } + } + certs, err := x509.ParseCertificates(b) + if err != nil { + return nil, fmt.Errorf("failed to parse certificates: %w", err) + } + for _, cert := range certs { + switch cert.PublicKey.(type) { + case *ecdsa.PublicKey, ed25519.PublicKey, *rsa.PublicKey: + default: + return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, cert.PublicKey) + } + } + return certs, nil +} + +// LoadX509KeyInfer loads an X509 key from a PEM block. +func LoadX509KeyInfer(pemBlock *pem.Block) (key any, err error) { + switch pemBlock.Type { + case "EC PRIVATE KEY": + key, err = loadECPrivate(pemBlock) + case "RSA PRIVATE KEY": + key, err = loadPKCS1Private(pemBlock) + case "RSA PUBLIC KEY": + key, err = loadPKCS1Public(pemBlock) + case "PRIVATE KEY": + key, err = loadPKCS8Private(pemBlock) + case "PUBLIC KEY": + key, err = loadPKIXPublic(pemBlock) + default: + return nil, ErrX509Infer + } + if err != nil { + return nil, fmt.Errorf("failed to load key from inferred format %q: %w", key, err) + } + return key, nil +} +func loadECPrivate(pemBlock *pem.Block) (priv *ecdsa.PrivateKey, err error) { + priv, err = x509.ParseECPrivateKey(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse EC private key: %w", err) + } + return priv, nil +} +func loadPKCS1Public(pemBlock *pem.Block) (pub *rsa.PublicKey, err error) { + pub, err = x509.ParsePKCS1PublicKey(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKCS1 public key: %w", err) + } + return pub, nil +} +func loadPKCS1Private(pemBlock *pem.Block) (priv *rsa.PrivateKey, err error) { + priv, err = x509.ParsePKCS1PrivateKey(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKCS1 private key: %w", err) + } + return priv, nil +} +func loadPKCS8Private(pemBlock *pem.Block) (priv any, err error) { + priv, err = x509.ParsePKCS8PrivateKey(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKCS8 private key: %w", err) + } + switch priv.(type) { + case *ecdh.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey, *rsa.PrivateKey: + default: + return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, priv) + } + return priv, nil +} +func loadPKIXPublic(pemBlock *pem.Block) (pub any, err error) { + pub, err = x509.ParsePKIXPublicKey(pemBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse PKIX public key: %w", err) + } + switch pub.(type) { + case *ecdh.PublicKey, *ecdsa.PublicKey, ed25519.PublicKey, *rsa.PublicKey: + default: + return nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, pub) + } + return pub, nil +} diff --git a/x509_gen.sh b/x509_gen.sh new file mode 100644 index 0000000..79cc315 --- /dev/null +++ b/x509_gen.sh @@ -0,0 +1,15 @@ +# OpenSSL 3.0.10 1 Aug 2023 (Library: OpenSSL 3.0.10 1 Aug 2023) +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" +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" +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" + +openssl pkey -in ec521.pem -pubout -out ec521pub.pem +openssl pkey -in ed25519.pem -pubout -out ed25519pub.pem +openssl pkey -in rsa4096.pem -pubout -out rsa4096pub.pem + +# For the "RSA PRIVATE KEY" (PKCS#1) and "EC PRIVATE KEY" (SEC1) formats, the PEM files are generated using the +# cmd/gen_pkcs1 and cmd/gen_ec Golang programs, respectively. + +openssl dsaparam -out dsaparam.pem 2048 +openssl gendsa -out dsa.pem dsaparam.pem +openssl dsa -in dsa.pem -pubout -out dsa_pub.pem diff --git a/x509_test.go b/x509_test.go new file mode 100644 index 0000000..e02de6c --- /dev/null +++ b/x509_test.go @@ -0,0 +1,534 @@ +package jwkset + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "encoding/pem" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +func TestNewJWKFromX5C(t *testing.T) { + testCases := []struct { + name string + raw []byte + keyType any + }{ + { + name: "EC", + raw: []byte(ec521Cert), + keyType: &ecdsa.PublicKey{}, + }, + { + name: "EdDSA", + raw: []byte(ed25519Cert), + keyType: ed25519.PublicKey{}, + }, + { + name: "RSA", + raw: []byte(rsa4096Cert), + keyType: &rsa.PublicKey{}, + }, + { + name: "Chain", + raw: []byte(ec521Cert + ed25519Cert + rsa4096Cert), + keyType: &ecdsa.PublicKey{}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + certs, err := LoadCertificates(testCase.raw) + if err != nil { + t.Fatal("Failed to load certificates:", err) + } + x509Options := JWKX509Options{ + X5C: certs, + } + options := JWKOptions{ + X509: x509Options, + } + jwk, err := NewJWKFromX5C(options) + if err != nil { + t.Fatal("Failed to create JWK from X5C:", err) + } + if reflect.TypeOf(jwk.Key()) != reflect.TypeOf(testCase.keyType) { + t.Fatal("Wrong key type:", reflect.TypeOf(jwk.Key())) + } + }) + } +} + +func TestDefaultGetX5U(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte(ec521Cert)) + if err != nil { + t.Fatal("Failed to write certificate:", err) + } + })) + defer server.Close() + + validateOptions := JWKValidateOptions{ + GetX5U: DefaultGetX5U, + SkipX5UScheme: true, + } + x509 := JWKX509Options{ + X5U: server.URL, + } + options := JWKOptions{ + Validate: validateOptions, + X509: x509, + } + jwk, err := NewJWKFromX5U(options) + if err != nil { + t.Fatal("Failed to create JWK from X5U:", err) + } + + _ = jwk.Key().(*ecdsa.PublicKey) +} + +func TestLoadCertificate(t *testing.T) { + b := loadPEM(t, ec521Cert) + cert, err := LoadCertificate(b.Bytes) + if err != nil { + t.Fatal("Failed to load certificate:", err) + } + _ = cert.PublicKey.(*ecdsa.PublicKey) + + b = loadPEM(t, ed25519Cert) + cert, err = LoadCertificate(b.Bytes) + if err != nil { + t.Fatal("Failed to load certificate:", err) + } + _ = cert.PublicKey.(ed25519.PublicKey) + + b = loadPEM(t, rsa4096Cert) + cert, err = LoadCertificate(b.Bytes) + if err != nil { + t.Fatal("Failed to load certificate:", err) + } + _ = cert.PublicKey.(*rsa.PublicKey) +} + +func TestLoadCertificates(t *testing.T) { + b := append(append([]byte(ec521Cert), []byte(ed25519Cert)...), []byte(rsa4096Cert)...) + certs, err := LoadCertificates(b) + if err != nil { + t.Fatal("Failed to load certificates:", err) + } + if len(certs) != 3 { + t.Fatal("Wrong number of certificates loaded:", len(certs)) + } + _ = certs[0].PublicKey.(*ecdsa.PublicKey) + _ = certs[1].PublicKey.(ed25519.PublicKey) + _ = certs[2].PublicKey.(*rsa.PublicKey) +} + +func TestLoadX509KeyInfer(t *testing.T) { + b := loadPEM(t, ec521Pub) + key, err := LoadX509KeyInfer(b) + if err != nil { + t.Fatal("Failed to load public EC 521 X509 key:", err) + } + _ = key.(*ecdsa.PublicKey) + + b = loadPEM(t, ed25519Pub) + key, err = LoadX509KeyInfer(b) + if err != nil { + t.Fatal("Failed to load public EdDSA X509 key:", err) + } + _ = key.(ed25519.PublicKey) + + b = loadPEM(t, rsa4096Pub) + key, err = LoadX509KeyInfer(b) + if err != nil { + t.Fatal("Failed to load public RSA 4096 X509 key:", err) + } + _ = key.(*rsa.PublicKey) + + b = loadPEM(t, ec521Priv) + key, err = LoadX509KeyInfer(b) + if err != nil { + t.Fatal("Failed to load private EC 521 X509 key:", err) + } + _ = key.(*ecdsa.PrivateKey) + + b = loadPEM(t, ed25519Priv) + key, err = LoadX509KeyInfer(b) + if err != nil { + t.Fatal("Failed to load private EdDSA X509 key:", err) + } + _ = key.(ed25519.PrivateKey) + + b = loadPEM(t, rsa4096Priv) + key, err = LoadX509KeyInfer(b) + if err != nil { + t.Fatal("Failed to load private RSA 4096 X509 key:", err) + } + _ = key.(*rsa.PrivateKey) + + b = loadPEM(t, rsa2048PKCS1Priv) + key, err = LoadX509KeyInfer(b) + if err != nil { + t.Fatal("Failed to load private RSA 2048 PKCS1 X509 key:", err) + } + _ = key.(*rsa.PrivateKey) + + b = loadPEM(t, rsa2048PKCS1Pub) + key, err = LoadX509KeyInfer(b) + if err != nil { + t.Fatal("Failed to load public RSA 2048 PKCS1 X509 key:", err) + } + _ = key.(*rsa.PublicKey) + + b = loadPEM(t, ec256SEC1Priv) + key, err = LoadX509KeyInfer(b) + if err != nil { + t.Fatal("Failed to load private EC P256 X509 key:", err) + } + _ = key.(*ecdsa.PrivateKey) + + b = &pem.Block{} + _, err = LoadX509KeyInfer(b) + if !errors.Is(err, ErrX509Infer) { + t.Fatal("Should have failed to infer X509 key type:", err) + } + + replaced := strings.ReplaceAll(rsa2048PKCS1Priv, "RSA PRIVATE KEY", "PRIVATE KEY") + b = loadPEM(t, replaced) + _, err = LoadX509KeyInfer(b) + if err == nil { + t.Fatal("Should have failed to infer X509 key type.") + } +} + +func TestLoadPKCS1Private(t *testing.T) { + b := loadPEM(t, rsa2048PKCS1Priv) + _, err := loadPKCS1Private(b) + if err != nil { + t.Fatal("Failed to load private PKCS1 key:", err) + } + + b = &pem.Block{} + _, err = loadPKCS1Private(b) + if err == nil { + t.Fatal("Should have failed to load private PKCS1 key.") + } +} + +func TestLoadPKCS1Public(t *testing.T) { + b := loadPEM(t, rsa2048PKCS1Pub) + _, err := loadPKCS1Public(b) + if err != nil { + t.Fatal("Failed to load public PKCS1 key:", err) + } + + b = &pem.Block{} + _, err = loadPKCS1Public(b) + if err == nil { + t.Fatal("Should have failed to load public PKCS1 key.") + } +} + +func TestLoadECPrivate(t *testing.T) { + b := loadPEM(t, ec256SEC1Priv) + _, err := loadECPrivate(b) + if err != nil { + t.Fatal("Failed to load private EC key:", err) + } + b = &pem.Block{} + _, err = loadECPrivate(b) + if err == nil { + t.Fatal("Should have failed to load private EC key.") + } +} + +func TestLoadPKCS8PrivateUnsupportedKey(t *testing.T) { + b := &pem.Block{} + _, err := loadPKCS8Private(b) + if err == nil { + t.Fatal("Should have failed to load empty PEM block.") + } + // The x509.ParsePKCS8PrivateKey function does not support loading private DSA keys. +} + +func TestLoadPKIXPublicUnsupportedKey(t *testing.T) { + b := &pem.Block{} + _, err := loadPKIXPublic(b) + if err == nil { + t.Fatal("Should have failed to load empty PEM block.") + } + b = loadPEM(t, dsa2048Pub) + _, err = loadPKIXPublic(b) + if !errors.Is(err, ErrUnsupportedKey) { + t.Fatal("Should have failed to load unsupported DSA public key.") + } +} + +func loadPEM(t *testing.T, rawPem string) *pem.Block { + rawPem = strings.TrimSpace(rawPem) + b, _ := pem.Decode([]byte(rawPem)) + if b == nil { + t.Fatal("Failed to decode PEM.") + } + return b +} + +// Certificates. +const ( + ec521Cert = ` +-----BEGIN CERTIFICATE----- +MIICuTCCAhqgAwIBAgIURHp0UtKTyrMNVuzjFxOPj09/fO8wCgYIKoZIzj0EAwIw +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCFZpcmdpbmlhMREwDwYDVQQHDAhSaWNo +bW9uZDEUMBIGA1UECgwLTWljYWggUGFya3MxDTALBgNVBAsMBFNlbGYxFDASBgNV +BAMMC2V4YW1wbGUuY29tMB4XDTIzMTExMjE3NTgxM1oXDTIzMTIxMjE3NTgxM1ow +bjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCFZpcmdpbmlhMREwDwYDVQQHDAhSaWNo +bW9uZDEUMBIGA1UECgwLTWljYWggUGFya3MxDTALBgNVBAsMBFNlbGYxFDASBgNV +BAMMC2V4YW1wbGUuY29tMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBtW2F+MPt +PcN+t5YtYcq8dluVBimcJ3cwTT/Hqrls0iHzpPVANAFRGqhvZnOb4rz7bh3bRqSm +zRNXT9lRJhg07gIA8n2j87Vg5r2FNwlRfD5eMNN3g+o62HUsB9sBfpMiGvLphgvy +g7Mtub7of4eBNphHTBvh3GU+S9TEHvTNP3Ja0aWjUzBRMB0GA1UdDgQWBBSRmKro +6jYkFz0suXUdjCeONWSZSDAfBgNVHSMEGDAWgBSRmKro6jYkFz0suXUdjCeONWSZ +SDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA4GMADCBiAJCARNYjIrrRbub +jF2D/I0Auw7sFQMvV3ImKp+L42kYpoFMXvnmKcuDt6n/OZCDAWpky/Uj/gLbvR2M +fsCNJ+9mbi+4AkIBB0L6Ue7Mxl5cNGprGKSy5c0mlXWezB3GhUKxNrOMUo3+Lt3G +slfqg3TSRlKC1YH863YkRGsE0XWwt9Myj2N6cVI= +-----END CERTIFICATE-----` + ed25519Cert = ` +-----BEGIN CERTIFICATE----- +MIIB8TCCAaOgAwIBAgIUV1qgafWZ5a/PVYZiwTZIyCfiF6gwBQYDK2VwMG4xCzAJ +BgNVBAYTAlVTMREwDwYDVQQIDAhWaXJnaW5pYTERMA8GA1UEBwwIUmljaG1vbmQx +FDASBgNVBAoMC01pY2FoIFBhcmtzMQ0wCwYDVQQLDARTZWxmMRQwEgYDVQQDDAtl +eGFtcGxlLmNvbTAeFw0yMzExMTIxNzU4MTNaFw0yMzEyMTIxNzU4MTNaMG4xCzAJ +BgNVBAYTAlVTMREwDwYDVQQIDAhWaXJnaW5pYTERMA8GA1UEBwwIUmljaG1vbmQx +FDASBgNVBAoMC01pY2FoIFBhcmtzMQ0wCwYDVQQLDARTZWxmMRQwEgYDVQQDDAtl +eGFtcGxlLmNvbTAqMAUGAytlcAMhAFddnU/P7hWUHzdljcXTsfKN5QffdYSikqUo +dt4PAu7oo1MwUTAdBgNVHQ4EFgQUoblrsByGUQ2+Ttthwnm/Vwe+yB8wHwYDVR0j +BBgwFoAUoblrsByGUQ2+Ttthwnm/Vwe+yB8wDwYDVR0TAQH/BAUwAwEB/zAFBgMr +ZXADQQB89PtKOOmgALNTe14oSxMEeFXxGgns7ZiTsuQ+nRtlvkkCJVJKDEJxBXnZ +RqPHwMhPvj2Jw4lYx85CSr47R7cM +-----END CERTIFICATE-----` + rsa4096Cert = ` +-----BEGIN CERTIFICATE----- +MIIFvTCCA6WgAwIBAgIUZNBtI415mo2zhbfEo3i9YeSocy8wDQYJKoZIhvcNAQEL +BQAwbjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCFZpcmdpbmlhMREwDwYDVQQHDAhS +aWNobW9uZDEUMBIGA1UECgwLTWljYWggUGFya3MxDTALBgNVBAsMBFNlbGYxFDAS +BgNVBAMMC2V4YW1wbGUuY29tMB4XDTIzMTExMjE3NTgxNFoXDTIzMTIxMjE3NTgx +NFowbjELMAkGA1UEBhMCVVMxETAPBgNVBAgMCFZpcmdpbmlhMREwDwYDVQQHDAhS +aWNobW9uZDEUMBIGA1UECgwLTWljYWggUGFya3MxDTALBgNVBAsMBFNlbGYxFDAS +BgNVBAMMC2V4YW1wbGUuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKC +AgEA29D7ytMzYhjhw8eweNA73Xid4qpb2uspSmleN5BjWvFeM+tk9RCdc7UQDhrQ +Sv1nrH5sc7NcK2gznmETm+cgaXQeAxacXiC7pXCcSLGhp3BJtBFmOpINyUxPjQrt +WzTUP2AC85DoxQVdM9/cRY7AMLF20YhsgG3B+VxvihbiMSVYwonqB31n2aZgKina +R65JSJQf4SjTgYCtXe128Ch5tsRMMSUTqKzl63w+85VHLoQ+vZBnx6Ht7pBm3tGs +ZFeeaTmZAF+0A9PMkDaFBw6mRyPqus87jjcWv6/VkcVDiZnNw6gEQfltDXFsk03B +Fvs9KU4rfjHA3GuDfpu4OZRkziO9aRvdk1xofTqQSrd6rTTFydzOhmvp5A0mt1Ap +KQQn1f+zTh9ybTxDtJroRCKP+gJYq+kGbMDlpCZXyHv6D68pL04LVctga+5CkZ09 +TzVijb85+LCUk5LNvFoh6NcFpz7z5Ru0EyMLDTazOvzQ/aNGl0IYMxs5Ske+cpiJ +XG0+dVe441TlRgwOeUKBg09vrWZoIaD8s7KiziRlrxfHSxJCgPMKCm0lMYQpaXAI +bDMVWr4a1O+WZy0z7R7e9QiFvSoy1ocL9m+5UwiOmS2Z/Oj9HZP+ZT+A/H0Z/hQw +nN7zpbsNms4LGgrneCOc7VaKYq+jeWIsoQ8Bq9aZwsTnH58CAwEAAaNTMFEwHQYD +VR0OBBYEFCXfwSpbQUoatjcGGpxcA0y63ZysMB8GA1UdIwQYMBaAFCXfwSpbQUoa +tjcGGpxcA0y63ZysMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIB +AGGGHDzxe+vSop9RUm5J1I8zmeVjxIC6FxEUSzXst+9DggjXRqaof4EhWKtW+Sdf +DgPGTvDZagDX1VKxuzolmMUgBRT9MrXrnNj3IOUkxptU1t1itIWNSOv0EuNfdMFo +kyDByLNXt8n3zewKZ0KEQkOHnl7sQSfnKxA4AuIaYUFOXAzn8LuL5ZCO3kl60Hla +M0DcRUaKJeJHFjxTXgBQr9M3dr55eQGw/jSWBk5jsfEQgKndQY27I43kMhFOmL5r +ldBB0LrTzuBToFG79oQpzvUkNQkbFevDE15RgtBV5xQTTYjxi3MBcFlL98Vtfj85 +SjICsN0KNjolABtryrlQ32PwPp5dIelaILzuvvLsQNFt62RtcntfniScdZ5PZIae +bx0IJyhvy2ylhmYVwfJwUKqIMqnbhYJinvZ/NYjD7tQmnUq2AzopB6YHXDs/CrfU +YckvHj/LGWRyTzb00CWQMvmRZmUsVEThSThY+aTlcRiXu6OATMYii/w/hv+7sVwr +NcMXVckh2/bpHjlZM03LJoabhDp+6c16U+NvOxoVsaPT7y4avoGZZ/IU30i3QpDf +qx5NKPcC4HDK28Daw6zBdO+fkodKFcgsL4jUqP+Q6QCWBH88PlmlXx80XoPQu++W +VhA/xoU82uODjoUbY6FzMW49ESHddZfuFg9fXHm1z31q +-----END CERTIFICATE-----` +) + +// PKCS#8 and PKIX formats. +const ( + ec521Priv = ` +-----BEGIN PRIVATE KEY----- +MIHuAgEAMBAGByqGSM49AgEGBSuBBAAjBIHWMIHTAgEBBEIBK1phZlyXggGSevAh +qqdocYbUK0AQBeD52ZB14sXshymnv/VkMop9UkZRIv11GrIDInxdfRBTXHS4lS18 +DvW6mOehgYkDgYYABAG1bYX4w+09w363li1hyrx2W5UGKZwndzBNP8equWzSIfOk +9UA0AVEaqG9mc5vivPtuHdtGpKbNE1dP2VEmGDTuAgDyfaPztWDmvYU3CVF8Pl4w +03eD6jrYdSwH2wF+kyIa8umGC/KDsy25vuh/h4E2mEdMG+HcZT5L1MQe9M0/clrR +pQ== +-----END PRIVATE KEY-----` + ec521Pub = ` +-----BEGIN PUBLIC KEY----- +MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBtW2F+MPtPcN+t5YtYcq8dluVBimc +J3cwTT/Hqrls0iHzpPVANAFRGqhvZnOb4rz7bh3bRqSmzRNXT9lRJhg07gIA8n2j +87Vg5r2FNwlRfD5eMNN3g+o62HUsB9sBfpMiGvLphgvyg7Mtub7of4eBNphHTBvh +3GU+S9TEHvTNP3Ja0aU= +-----END PUBLIC KEY-----` + ed25519Priv = ` +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIOC6YxHKyd+kPJo6N0lpdiGQLrre5P5W1GKDPwMN0Hxj +-----END PRIVATE KEY-----` + ed25519Pub = ` +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAV12dT8/uFZQfN2WNxdOx8o3lB991hKKSpSh23g8C7ug= +-----END PUBLIC KEY-----` + rsa4096Priv = ` +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDb0PvK0zNiGOHD +x7B40DvdeJ3iqlva6ylKaV43kGNa8V4z62T1EJ1ztRAOGtBK/Wesfmxzs1wraDOe +YROb5yBpdB4DFpxeILulcJxIsaGncEm0EWY6kg3JTE+NCu1bNNQ/YALzkOjFBV0z +39xFjsAwsXbRiGyAbcH5XG+KFuIxJVjCieoHfWfZpmAqKdpHrklIlB/hKNOBgK1d +7XbwKHm2xEwxJROorOXrfD7zlUcuhD69kGfHoe3ukGbe0axkV55pOZkAX7QD08yQ +NoUHDqZHI+q6zzuONxa/r9WRxUOJmc3DqARB+W0NcWyTTcEW+z0pTit+McDca4N+ +m7g5lGTOI71pG92TXGh9OpBKt3qtNMXJ3M6Ga+nkDSa3UCkpBCfV/7NOH3JtPEO0 +muhEIo/6Alir6QZswOWkJlfIe/oPrykvTgtVy2Br7kKRnT1PNWKNvzn4sJSTks28 +WiHo1wWnPvPlG7QTIwsNNrM6/ND9o0aXQhgzGzlKR75ymIlcbT51V7jjVOVGDA55 +QoGDT2+tZmghoPyzsqLOJGWvF8dLEkKA8woKbSUxhClpcAhsMxVavhrU75ZnLTPt +Ht71CIW9KjLWhwv2b7lTCI6ZLZn86P0dk/5lP4D8fRn+FDCc3vOluw2azgsaCud4 +I5ztVopir6N5YiyhDwGr1pnCxOcfnwIDAQABAoICAB4ZecEGNo0CNzflyiZg7TGg +aI43IajSdq73yqz1GoXDc1DMtOBRzB2h93bW+RqrpFycWyFkuARBmn/fbx30Ah4u +hkWJ/RNujANVbjEOEcKpv43mrAbtJPIhfusjSekpTL742K6dcyI3X9HQn4ruxyZj +xo9ejOzxGpSYsbVI+OQd5w+Mbv1jkKre+2AKpxcVqHdFwS/FtWCQTC0GbTjpcfEy +4/P+zbhVJI6gTsZv9HVMKoMumOdfJwN5xnxQXbjHvqtN9cN1V2MGx4Yf0QtsWBx5 +sJSv98m7hWPuIeJ6DotzAhf+k8as7t/eXi21gfExqehUCeSXz37fQfw+OnW3+i12 +/ddneTtCrLl2XmCkrsQCvx9DfKE0cxov/GQqaq3O/xXZ/f2z0T28dh5HTEcO7zOz +4SsCeY0GBPucxKOCOO8xj9xE2AkB9T0NlvWdqkZ19mU0tMZ2aP172sr1kl14KtRb +U32/jbi1zXIE3VjrrHHU82vO4ky1y7lDdctXT5YbKXIDaSYnpJhXTuu58DipP/Pq +75Ak8V0U5LYDTshlr7Rj1y2f55gP52NTC/KwfcdzhKSLPNEfvfei4NC/FZoxxQs3 +eDUEZbV1OBWtXWs+zojd6jcjbaUta21LOojZJ1d8GfElN4U5Hm/L0vLn3J3Rdp5u +p9fcFyBuZ4SeVzZWANpRAoIBAQDzz+QAd9UkrXPGcAtN4dm4qAJ0p2RsQ/KjtoT7 +K0S865Lu39KlB4U93qUwgohkMZcMhbz8AakplIH/B/uwvL5Iz+v+V2bwvTmD/gDK +1Hlah8Y1Vrla/i/3RzxWIxrgcM4RaTiYobB0s+t2EkwiUiaZ9YDBIRCdFu0rqOcj +TQHEb8aUSEqyMB8zAYnTcFyShvXRSAyXjMibl7pALt8ubvISp2GkhTwGUNjJ22Oi +9+yTcxvSMgbFxACXIbiIUINE7b2p7dlh9M1bSx13Zc2mT3jX9ARkrBtWGUN68zub +3zpAkydK0BW+5lYVv369TPoLpKGLHmpbQy9YmiduylshlmUdAoIBAQDmzgPXxUl9 +DSIgbsgj1k9rXgVoJ+ltd8S1MGf/GhHfmbEXcuYskcXFHfM/a4rtmgvsKDQHMLWu +ptatuRz8oql6AIya8rJDYY5rS2C1M3yPHrJ0Umln+W/2za4MLFxapSaYaFqcMMme +ls8SogXuXkbDyiJcu7JD7VShybKQtY2HrAR9oUaDP+gmCXuIg7K0rQFxFQo9QYhv +0yOjQ6hcIl9mxXit0h1vddSi0cRnqqjHgvqEfT+vLqyfweJvAhzi0ugJjqGdDcAw +SSfjzIWS/k0rBWq5ikxVrQXItAW+xhlSDi/XqOHeB7LyvReAOnNDAvgMjKHT/ele +Fi+96LemTibrAoIBAGmID4mAVPrGNTmsX8g7PPEnj8CMf/Q4yPrB0vegt+UKFpRc +vyF9itfH2jqQFZdAu7/I149A7Ma5qDcKbpAGclqz3NM/Y6hKT23pcNBafZiI8ms9 ++YcARSTEacJi+YwyZ4+zurKeMfGhuwZlTxz/8ANt92gg9r74IHpoZnuqJlyvgQXH +8MUF/UsnnE+v7/HghuAqToD+iAqI9y4225WOoise1i3PGbcmIV/mHU95/qWoCl/G +FZZei17fUq92Igug2BqIgDJdMtIURlHa99PHzGe1EH2+3So8TzAVvjRuwBkZWMWS +Igd6TcKmG6a2ffiyLtY3uRN9li3Es9LJtf5oyaUCggEAC+kZvarKrg9dcXsGDQNk +OdAySzu0ChgiKI+E7l80COvvfZxKUIZ9RDzVbrJoCvbmIpu4g5554bduYKyq2Ea0 +pD0fBGf91whTxymupeswRFp7LxGJqvnuUzguASbQ5USch0TrWCAUZ4C00utVjwWC +dVwbBdoRyvuWYHr+IgWcdiHkYW9PKjrECiJ3I4ZYVIaRCnrhemPFXK/yqNw29fo4 +Hh+WqLGtHzFfdb+JeSgPaaxSrT+hZ7Lq6ZuhycS8JOBpZQTdRjONdXBxBIprYjiJ +Vu0CouyGH+273K2dlki2ycs9oM1wSnrvOyOS8OUTSaP/lPY067Gwt1BBynUV9RkX +XQKCAQEAhOsJSbgkr4AiW31YyMjE5Tm5dbJ3OnxlBJzawaZ8qD2kiQRjSvIWJKSn +jG80QbkaK/JQXko/mSFQZHuBXRpJsygHTytsMbmWRxXZy5LVkdHelJdkX4UMlhKw +IydoeSfAe4vvQROfk6ol+v4iqTnba6JyHnSWaENcs5aRwNyKMRSYPlcMeHVvt4uM +30SKCfNL9e85AuBuitQDVvKkCv//RAOtJ/pCrmwasobBWalBs5NId8eZkeLihCZE +7J3VkHpbE11gnfOzIQfAU/e+ZkaOy5ChECPMP0f4KdHmLClA+2I0Mfu3dnSMMuTx +NlwPSUy/6PKkQnZiQ0mW4LZqG/maLg== +-----END PRIVATE KEY-----` + rsa4096Pub = ` +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA29D7ytMzYhjhw8eweNA7 +3Xid4qpb2uspSmleN5BjWvFeM+tk9RCdc7UQDhrQSv1nrH5sc7NcK2gznmETm+cg +aXQeAxacXiC7pXCcSLGhp3BJtBFmOpINyUxPjQrtWzTUP2AC85DoxQVdM9/cRY7A +MLF20YhsgG3B+VxvihbiMSVYwonqB31n2aZgKinaR65JSJQf4SjTgYCtXe128Ch5 +tsRMMSUTqKzl63w+85VHLoQ+vZBnx6Ht7pBm3tGsZFeeaTmZAF+0A9PMkDaFBw6m +RyPqus87jjcWv6/VkcVDiZnNw6gEQfltDXFsk03BFvs9KU4rfjHA3GuDfpu4OZRk +ziO9aRvdk1xofTqQSrd6rTTFydzOhmvp5A0mt1ApKQQn1f+zTh9ybTxDtJroRCKP ++gJYq+kGbMDlpCZXyHv6D68pL04LVctga+5CkZ09TzVijb85+LCUk5LNvFoh6NcF +pz7z5Ru0EyMLDTazOvzQ/aNGl0IYMxs5Ske+cpiJXG0+dVe441TlRgwOeUKBg09v +rWZoIaD8s7KiziRlrxfHSxJCgPMKCm0lMYQpaXAIbDMVWr4a1O+WZy0z7R7e9QiF +vSoy1ocL9m+5UwiOmS2Z/Oj9HZP+ZT+A/H0Z/hQwnN7zpbsNms4LGgrneCOc7VaK +Yq+jeWIsoQ8Bq9aZwsTnH58CAwEAAQ== +-----END PUBLIC KEY-----` +) + +// Other formats. +const ( + rsa2048PKCS1Priv = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEpgIBAAKCAQEA4v/3tBv7bKZgVyC8+Kjb82edPJmiEO2nJmTi/pAGK6bWEqOk +nsl9Qx5Ih1Z374mnIPWpeM/D4g/CC8E4NWWy6htGzZx8b5tcO08XJ7uGEWfG1Nyq +ACsQ18V6dPk3Wz8SvgqCxeZ5e+/wxHmPrhTRi1yKQBRfm/RqpaHgfFjM7ZTXG6MH +BUWUQD6I00o1hirs0oCka/Rlfy/OhikzvkiGDcS6VC+KFwP6wXx91TIwMLy+ncJ6 +hZJHHXbQN5oVkga1ZAtid4xeYvC9Ma5ytIfeRG61cUetc173vdxBtcHPXfrSDvjC +G8vFTrtIkY4rE6zx9qrTXrYniSgrBKsn+HoWcQIDAQABAoIBAQCJXdKc6I4GmswU +DZitdSndKueI44OicN5Eqqp+19MUGVrUXrjg6hdmRW4okBf2GbvMgzzyAfCM3XJU +wLFuBsP1TVpUVI0s0LxIm7zsa1tfLwiwiXRKs8T2fedz39gy3IFQBXZLogQEDxgJ +HXLoKmr/xZlX27xb2NWss7/wH6CrZ9GD0YShN6Xo4G1qZsDSf8MrJ6dKNYm4Fej8 +5ZsxtVvPi18lY6VO4bjJBq6VoPyJQYAacundyQ9Hifgg743+PTGBdcKP8SPb8X0u +yZEypAIVw3BXVJ3Shh8NN5iRfLaEvqMNhIzKiJxma7+J303icQJEurduSOM+to/7 +5u9kUTvRAoGBAP0yyDd4RT2jBHnOxKabOFBygtWJBvbHRSXt9s9P6fNxoWquKhxt +b1oesKAljffRsbrJeI8G3vzElofMmcKsohDwv83Qc7J1Ph7S+hr4COnt3gVlsxaH +CDL/VaPESXTYXF8N/U6Ewz1FsYVxzs20MMFcoro9D2FJVLz1SKOZH6eNAoGBAOWC ++Yv0lv92IGKXj0p/0PaBz3vmxpl49o+o9OukgRJMJwmlMJn/pTF4Eu6QYe60dKsm +f/jnahBsHe/f/OCV1W0iDO85o+8Fg7jXGUyqIvCVMehmLVItHZLBGoqzRIUJzC2P +RDyHLVuV9PiHZ0SgRLroqRKZVQSe0cDp8jk3Bk91AoGBAM1AyGunFMJFj1BLHMFO +nRUh7wu5XCrbGSQJRwWB685MdCTt8PdAg38T1+zK5M5bb+9SeWfAky1nE/wcER1u +IqcG8wWeENw/DM+iCduo7FjuWggYDFibuDrXIA51BXMyHZd02L45A6h9Ac6ClrnM +c6WcOdItw3UDJC1Vzb/JVo7VAoGBAM5Gly6YmBXl/1ldSmX01sSXCvobAifxtfiM +LASWB4OAeh2LIFFomPoLJ0jO75XxDmK86Yu1wXgdFBMBx2+6euXpEqL3tUUgObEp +cg2bZGfCT+bF3rna3peFgutiD5Vapu3Ts8qK29NSxaeRWtktCljKvxp+QRE0BOVT +3mZZ9Av5AoGBAIqukzaeOWXsnpJI1E4MpaRiAkFsHtzPwxMZJURRYyg3C0ZFiqkF +txxRdz/fj2HNEkEconBHVRwyr/f7vy2qmmo9Xd1fnvvSjOcuuZLL4WxXrhSYvK9e +cbf0IYk6FVqTwLdW1PFAR9PsMPnb9OKQ2MBKZIuamw5GEhL0KoNjVsUc +-----END RSA PRIVATE KEY-----` + rsa2048PKCS1Pub = ` +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEA4v/3tBv7bKZgVyC8+Kjb82edPJmiEO2nJmTi/pAGK6bWEqOknsl9 +Qx5Ih1Z374mnIPWpeM/D4g/CC8E4NWWy6htGzZx8b5tcO08XJ7uGEWfG1NyqACsQ +18V6dPk3Wz8SvgqCxeZ5e+/wxHmPrhTRi1yKQBRfm/RqpaHgfFjM7ZTXG6MHBUWU +QD6I00o1hirs0oCka/Rlfy/OhikzvkiGDcS6VC+KFwP6wXx91TIwMLy+ncJ6hZJH +HXbQN5oVkga1ZAtid4xeYvC9Ma5ytIfeRG61cUetc173vdxBtcHPXfrSDvjCG8vF +TrtIkY4rE6zx9qrTXrYniSgrBKsn+HoWcQIDAQAB +-----END RSA PUBLIC KEY-----` + ec256SEC1Priv = ` +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIPEHBaM5VfAK2Gss3HQcXg89UH/5+APhT+LeXv9QXJ5toAoGCCqGSM49 +AwEHoUQDQgAEpKijCjLFUcDsIjNAXkzQsk1/YnObl5dx1KR/CfDzKklOIDiCaU4H +O6SocyslNS/EH5UqyZgShM3WhoHcdvdBSg== +-----END EC PRIVATE KEY-----` +) + +// DSA keys (unsupported by any JWK RFC I know of). +const ( + dsa2048Priv = ` +-----BEGIN PRIVATE KEY----- +MIICXAIBADCCAjUGByqGSM44BAEwggIoAoIBAQCJi6h3cmIxHTrzsA3vrBM//AKv +/bM1K9FTJb/h+GTJpJ1Ccp4yURmxdS44/P1nhJBu0EcUCaP8UzBM9DLUtIVqO5Ag +KkxdgLsTspunGLykEkMcAN4Ij6ggcoSQ56SftQYZ1kgxJQPDT/KZk217Wwg1lLyJ +HobH6++HKfDB5z5NdCA3jau3ANNH9kptNGpnyai1FLJHBSHa8605fvRCvEtgL4jm +/7ZgIR8Xvm9D4CnIhtlYkWjy6dAykyjnh+AovJrEuB6DBEW5YU8PhEj2cYW7+GvV +KXFFsN90bocqf/6++dToB/bqUNW1j3Pg6+gDdqCkMD/k/4itZ5ObOBvw9yL3Ah0A +qtTg2DsMkJFSs3S9Q10LKJI561L9tj0mf7LinQKCAQA9BLRa4B1ozotJbiUNGRt9 +H0Ebvnh3IlL3F4JjNQUqChUvsOTHvRG/Ogck1k1fTTBGugg+oZiHG4CAFtNvz4LZ +DehPy4A8BW+nQEXoUxmlJTBjgc5J5lYBIgRQLv8FGCxdd3zo/4cgXjpchosk4N/J +rz33BvpovUoQE8t1ks3bddRD7MsN6muCvyJYED69rnCn79OEOjOKhmOQb3G/rTH8 +eO1acRjcTu1uMie/vHtltvd2TxGTNqzVBRvLIDWYD0UK/LFwig1TwWfZF3QLZ1HS +40//npFAd3C5Opf/ZPYOFlT3a/8GaZSSGzkugwLmSeCrI6DPQ6Z3r1sDkSY6ixXf +BB4CHAW4zQgr2nCGhaJmYvlaLmPfzpeN1LOOX0QCmy0= +-----END PRIVATE KEY-----` + dsa2048Pub = ` +-----BEGIN PUBLIC KEY----- +MIIDQjCCAjUGByqGSM44BAEwggIoAoIBAQCJi6h3cmIxHTrzsA3vrBM//AKv/bM1 +K9FTJb/h+GTJpJ1Ccp4yURmxdS44/P1nhJBu0EcUCaP8UzBM9DLUtIVqO5AgKkxd +gLsTspunGLykEkMcAN4Ij6ggcoSQ56SftQYZ1kgxJQPDT/KZk217Wwg1lLyJHobH +6++HKfDB5z5NdCA3jau3ANNH9kptNGpnyai1FLJHBSHa8605fvRCvEtgL4jm/7Zg +IR8Xvm9D4CnIhtlYkWjy6dAykyjnh+AovJrEuB6DBEW5YU8PhEj2cYW7+GvVKXFF +sN90bocqf/6++dToB/bqUNW1j3Pg6+gDdqCkMD/k/4itZ5ObOBvw9yL3Ah0AqtTg +2DsMkJFSs3S9Q10LKJI561L9tj0mf7LinQKCAQA9BLRa4B1ozotJbiUNGRt9H0Eb +vnh3IlL3F4JjNQUqChUvsOTHvRG/Ogck1k1fTTBGugg+oZiHG4CAFtNvz4LZDehP +y4A8BW+nQEXoUxmlJTBjgc5J5lYBIgRQLv8FGCxdd3zo/4cgXjpchosk4N/Jrz33 +BvpovUoQE8t1ks3bddRD7MsN6muCvyJYED69rnCn79OEOjOKhmOQb3G/rTH8eO1a +cRjcTu1uMie/vHtltvd2TxGTNqzVBRvLIDWYD0UK/LFwig1TwWfZF3QLZ1HS40// +npFAd3C5Opf/ZPYOFlT3a/8GaZSSGzkugwLmSeCrI6DPQ6Z3r1sDkSY6ixXfA4IB +BQACggEAMEBw8GdcPUuYJExRfYQLKNih789so8favDqcRI+ilfrRJz+hF4ZIKnTH +I7jPB5Lj20inAazVLl4omNxBdFzfKuzAdrYEGBHL5rjGNafo6VrLiU1y5zWFjJq7 +UAGZB1HbhnUXOaNlfVoSMMK+ErcazwUjPrzso/f9j5bmvkmT9vmuMieLEVaQ6dOJ +dScNUoz3aCEmxpOgPWFEYPtdN7QVg75CQ68PYjueNyyJR4nfovuIcGOmENV+FuDz +7GV23I8WCj1OBqERHrbXCYryMS7GOSKQiISOVKdi1kqyV2rBeFL9IWW1oUPyKC1P +8OvJojkV57e01tT6HN44BhWwhWRplg== +-----END PUBLIC KEY-----` +)