diff --git a/third_party/httpsig-go/.github/workflows/codeql.yml b/third_party/httpsig-go/.github/workflows/codeql.yml new file mode 100644 index 0000000000..41b7e1fb93 --- /dev/null +++ b/third_party/httpsig-go/.github/workflows/codeql.yml @@ -0,0 +1,84 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '42 20 * * 0' + +jobs: + analyze: + name: Analyze + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners + # Consider using larger runners for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + # required for all workflows + security-events: write + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + language: [ 'go' ] + # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] + # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/third_party/httpsig-go/.github/workflows/wf.yml b/third_party/httpsig-go/.github/workflows/wf.yml new file mode 100644 index 0000000000..189ed15758 --- /dev/null +++ b/third_party/httpsig-go/.github/workflows/wf.yml @@ -0,0 +1,14 @@ +on: [push, pull_request] +name: Test +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + - name: Checkout code + uses: actions/checkout@v3 + - name: Test + run: go test -v ./... diff --git a/third_party/httpsig-go/.semgrepignore b/third_party/httpsig-go/.semgrepignore new file mode 100644 index 0000000000..e3842490eb --- /dev/null +++ b/third_party/httpsig-go/.semgrepignore @@ -0,0 +1 @@ +testdata/*.key \ No newline at end of file diff --git a/third_party/httpsig-go/.sideignore b/third_party/httpsig-go/.sideignore new file mode 100644 index 0000000000..e5ed11ee18 --- /dev/null +++ b/third_party/httpsig-go/.sideignore @@ -0,0 +1,49 @@ +# To avoid giving your LLM irrelevant context, this .sideignore file can be used +# to exclude any code that isn't part of what is normally edited. The format is +# the same as .gitignore. Note that any patterns listed here are ignored *in +# addition* to to what is ignored via .sideignore. +# +# The following is a list of common vendored dependencies or known paths that +# should not be edited, in case they are not already in a .gitignore file. +# Adjust by: +# +# 1. Removing paths for languages/frameworks not relevant to you +# 2. Add any paths specific to your project that get auto-generated but not +# normally imported, such as mocks etc. + +# General vendored dependencies +vendor/ +third_party/ +extern/ +deps/ + +# Node +node_modules/ + +# Python virtual environments (less common names) +.venv/ +env/ + +# Go vendored dependencies +vendor/ + +# Ruby +.bundle/ +vendor/bundle/ + +# PHP +vendor/ + +# Java / Kotlin +.gradle/ + +# C / C++ +deps/ + +# Swift / Dart / Flutter +.dart_tool/ +Carthage/ + +# Elixir / Erlang +_build/ +deps/ \ No newline at end of file diff --git a/third_party/httpsig-go/LICENSE b/third_party/httpsig-go/LICENSE new file mode 100644 index 0000000000..95501867bd --- /dev/null +++ b/third_party/httpsig-go/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 remitly-oss + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/httpsig-go/README.md b/third_party/httpsig-go/README.md new file mode 100644 index 0000000000..2604f883e7 --- /dev/null +++ b/third_party/httpsig-go/README.md @@ -0,0 +1,65 @@ +# HTTP Message Signatures + +[![Go Reference](https://pkg.go.dev/badge/github.com/remitly-oss/httpsig-go.svg)](https://pkg.go.dev/github.com/remitly-oss/httpsig-go) +[![Go Report Card](https://goreportcard.com/badge/github.com/remitly-oss/httpsig-go)](https://goreportcard.com/report/github.com/remitly-oss/httpsig-go) + +An implementation of HTTP Message Signatures from [RFC 9421](https://datatracker.ietf.org/doc/rfc9421/). + +HTTP signatures are a mechanism for signing and verifying HTTP requests and responses. + +HTTP signatures can be (or will be able to) used for demonstrating proof-of-posession ([DPoP](https://www.rfc-editor.org/rfc/rfc9449.html)) for [OAuth](https://oauth.net/2/dpop/) bearer tokens. + +## Supported Features +The full specification is supported with the exception of the following. File a ticket or PR and support will be added +Planned but not currently supported features: +- JWS algorithms +- Header parameters including trailers + +## net/http integration +Create net/http clients that sign requests and/or verifies repsonses. +```go + params := httpsig.SigningOptions{ + PrivateKey: nil, // Fill in your private key + Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Fields: httpsig.DefaultRequiredFields, + Metadata: []httpsig.Metadata{httpsig.MetaKeyID}, + MetaKeyID: "key123", + } + + // Create the signature signer + signer, _ := httpsig.NewSigner(params) + + // Create a net/http Client that signs all requests + signingClient := httpsig.NewHTTPClient(nil, signer, nil) +``` + +Create net/http Handlers that verify incoming requests to the server. +```go + myhandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Lookup the results of verification + if veriftyResult, ok := httpsig.GetVerifyResult(r.Context()); ok { + keyid, _ := veriftyResult.KeyID() + fmt.Fprintf(w, "Hello, %s", keyid) + } else { + fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path)) + } + }) + + // Create a verifier + verifier, _ := httpsig.NewVerifier(nil, httpsig.DefaultVerifyProfile) + + mux := http.NewServeMux() + // Wrap the handler with the a signature verification handler. + mux.Handle("/", httpsig.NewHandler(myhandler, verifier)) +``` + +## Stability +The v1.1+ release is stable and production ready. + +Please file issues and bugs in the github projects issue tracker. + +## References + +- [RFC 9421](https://datatracker.ietf.org/doc/rfc9421/) +- [OAuth support](https://oauth.net/http-signatures/) +- [Interactive UI](https://httpsig.org/) diff --git a/third_party/httpsig-go/UPDATE_INSTRUCTIONS b/third_party/httpsig-go/UPDATE_INSTRUCTIONS new file mode 100644 index 0000000000..ec1acca1c0 --- /dev/null +++ b/third_party/httpsig-go/UPDATE_INSTRUCTIONS @@ -0,0 +1,42 @@ +These are instructions for pulling in the latest changes from the upstream version of this library. +The `UPSTREAM_COMMIT` file tracks the upstream version that we last synced with. + +_Notes:_ +- Update `/path/to/your/monorepo` below to your fleet repo location +- These instructions have not been fully tested. + +```bash +export FLEET_REPO=/path/to/your/monorepo +# Clone upstream +git clone https://github.com/remitly-oss/httpsig-go.git ~/httpsig-go-merge +cd ~/httpsig-go-merge + +# Check out the last upstream commit we vendored +git checkout $(cat "$FLEET_REPO"/third_party/httpsig-go/UPSTREAM_COMMIT) + +# Create a branch for our downstream changes +git checkout -b internal-changes + +# Copy current vendored version into this working repo +rsync -a --delete "$FLEET_REPO"/third_party/httpsig-go/ ./ --exclude .git +git add . +git commit -m "Apply downstream changes" + +# Fetch upstream updates and merge them +git fetch origin +git checkout main +git merge origin/main +git checkout internal-changes +git merge main # resolve conflicts + +# Copy merged result back into monorepo +rsync -a --delete ./ "$FLEET_REPO"/third_party/httpsig-go/ --exclude .git + +# Record the new upstream commit. Manually double check that it matches the upstream commit. +git rev-parse origin/main > "$FLEET_REPO"/third_party/httpsig-go/UPSTREAM_COMMIT + +# Commit to monorepo +cd "$FLEET_REPO" +git add third_party/httpsig-go +git commit -m "Update httpsig-go with latest upstream changes" +``` diff --git a/third_party/httpsig-go/UPSTREAM_COMMIT b/third_party/httpsig-go/UPSTREAM_COMMIT new file mode 100644 index 0000000000..f02329f8b1 --- /dev/null +++ b/third_party/httpsig-go/UPSTREAM_COMMIT @@ -0,0 +1 @@ +d68e2e99a37987076d8588fafe2aa34147abcc24 diff --git a/third_party/httpsig-go/accept.go b/third_party/httpsig-go/accept.go new file mode 100644 index 0000000000..eb9f6180ac --- /dev/null +++ b/third_party/httpsig-go/accept.go @@ -0,0 +1,68 @@ +package httpsig + +import ( + "fmt" + + sfv "github.com/dunglas/httpsfv" +) + +type AcceptSignature struct { + Profile SigningProfile + MetaNonce string // 'nonce' + MetaKeyID string // 'keyid' + MetaTag string // 'tag' - No default. A value must be provided if the parameter is in Metadata. +} + +func ParseAcceptSignature(acceptHeader string) (AcceptSignature, error) { + as := AcceptSignature{} + acceptDict, err := sfv.UnmarshalDictionary([]string{acceptHeader}) + if err != nil { + return as, newError(ErrInvalidAcceptSignature, "Unable to parse Accept-Signature value", err) + } + profiles := acceptDict.Names() + if len(profiles) == 0 { + return as, newError(ErrMissingAcceptSignature, "No Accept-Signature value") + } + + label := profiles[0] + profileItems, _ := acceptDict.Get(label) + profileList, isList := profileItems.(sfv.InnerList) + if !isList { + return as, newError(ErrInvalidAcceptSignature, "Unable to parse Accept-Signature value. Accept-Signature must be a dictionary.") + } + + fields := []string{} + for _, componentItem := range profileList.Items { + field, ok := componentItem.Value.(string) + if !ok { + return as, newError(ErrInvalidAcceptSignature, fmt.Sprintf("Invalid signature component '%v', Components must be strings", componentItem.Value)) + + } + fields = append(fields, field) + } + as.Profile = SigningProfile{ + Fields: Fields(fields...), + Label: label, + Metadata: []Metadata{}, + } + + md := metadataProviderFromParams{profileList.Params} + for _, meta := range profileList.Params.Names() { + as.Profile.Metadata = append(as.Profile.Metadata, Metadata(meta)) + switch Metadata(meta) { + case MetaNonce: + as.MetaNonce, _ = md.Nonce() + case MetaAlgorithm: + alg, _ := md.Alg() + as.Profile.Algorithm = Algorithm(alg) + case MetaKeyID: + as.MetaKeyID, _ = md.KeyID() + case MetaTag: + as.MetaTag, _ = md.Tag() + + } + } + + return as, nil + +} diff --git a/third_party/httpsig-go/accept_test.go b/third_party/httpsig-go/accept_test.go new file mode 100644 index 0000000000..30eb974bd0 --- /dev/null +++ b/third_party/httpsig-go/accept_test.go @@ -0,0 +1,64 @@ +package httpsig + +import ( + "testing" + + "github.com/remitly-oss/httpsig-go/sigtest" +) + +func TestAcceptParseSignature(t *testing.T) { + testcases := []struct { + Name string + Desc string + AcceptHeader string + Expected AcceptSignature + ExpectedErrCode ErrCode + }{ + { + Name: "FromSpecification", + Desc: "Accept header used in the RFC", + AcceptHeader: `sig1=("@method" "@target-uri" "@authority" "content-digest" "cache-control");keyid="test-key-rsa-pss";created;tag="app-123"`, + Expected: AcceptSignature{ + MetaKeyID: "test-key-rsa-pss", + MetaTag: "app-123", + Profile: SigningProfile{ + Fields: Fields("@method", "@target-uri", "@authority", "content-digest", "cache-control"), + Metadata: []Metadata{"keyid", "created", "tag"}, + Label: "sig1", + }, + }, + }, + { + Name: "InvalidAcceptSig", + AcceptHeader: `("@method" "@target-uri" "@authority" "content-digest" "cache-control");keyid="test-key-rsa-pss";created;tag="app-123"`, + + ExpectedErrCode: ErrInvalidAcceptSignature, + }, + { + Name: "NoAcceptSig", + AcceptHeader: "", + ExpectedErrCode: ErrMissingAcceptSignature, + }, + { + Name: "NotAList", + AcceptHeader: `sig1="@method"`, + ExpectedErrCode: ErrInvalidAcceptSignature, + }, + { + Name: "BadComponent", + AcceptHeader: `sig1=("@method" 1 "@authority" "content-digest" "cache-control");keyid="test-key-rsa-pss";created;tag="app-123"`, + ExpectedErrCode: ErrInvalidAcceptSignature, + }, + } + + for _, tc := range testcases { + t.Run(tc.Name, func(t *testing.T) { + actual, err := ParseAcceptSignature(tc.AcceptHeader) + if sigtest.Diff(t, tc.ExpectedErrCode, errCode(err), "Wrong error code") { + t.Logf("%+v\n", err) + return + } + sigtest.Diff(t, tc.Expected, actual, "Wrong signature options") + }) + } +} diff --git a/third_party/httpsig-go/base.go b/third_party/httpsig-go/base.go new file mode 100644 index 0000000000..f9e4780e83 --- /dev/null +++ b/third_party/httpsig-go/base.go @@ -0,0 +1,248 @@ +package httpsig + +import ( + "context" + "fmt" + "io" + "net/http" + "slices" + "strconv" + "strings" + + sfv "github.com/dunglas/httpsfv" +) + +// sigBaseInput is the required input to calculate the signature base +type sigBaseInput struct { + Components []componentID + MetadataParams []Metadata // metadata parameters to add to the signature and their values + MetadataValues MetadataProvider +} + +type httpMessage struct { + IsResponse bool + Req *http.Request + Resp *http.Response +} + +func (hrr httpMessage) Headers() http.Header { + if hrr.IsResponse { + return hrr.Resp.Header + } + return hrr.Req.Header +} + +func (hrr httpMessage) Body() io.ReadCloser { + if hrr.IsResponse { + return hrr.Resp.Body + } + return hrr.Req.Body +} + +func (hrr httpMessage) SetBody(body io.ReadCloser) { + if hrr.IsResponse { + hrr.Resp.Body = body + return + } + hrr.Req.Body = body +} + +func (hrr httpMessage) Context() context.Context { + if hrr.IsResponse { + return context.Background() + } + return hrr.Req.Context() +} + +func (hrr httpMessage) isDebug() bool { + if dbgval, ok := hrr.Context().Value(ctxKeyAddDebug).(bool); ok { + return dbgval + } + return false +} + +/* +calculateSignatureBase calculates the 'signature base' - the data used as the input to signing or verifying +The signature base is an ASCII string containing the canonicalized HTTP message components covered by the signature. +*/ +func calculateSignatureBase(msg httpMessage, bp sigBaseInput) (signatureBase, error) { + signatureParams := sfv.InnerList{ + Items: []sfv.Item{}, + Params: sfv.NewParams(), + } + componentNames := []string{} + var base strings.Builder + + // Add all the required components + for _, component := range bp.Components { + name, err := component.signatureName() + if err != nil { + return signatureBase{}, err + } + if slices.Contains(componentNames, name) { + return signatureBase{}, newError(ErrInvalidSignatureOptions, fmt.Sprintf("Repeated component name not allowed: '%s'", name)) + } + componentNames = append(componentNames, name) + signatureParams.Items = append(signatureParams.Items, component.Item) + + value, err := component.signatureValue(msg) + if err != nil { + return signatureBase{}, err + } + + base.WriteString(fmt.Sprintf("%s: %s\n", name, value)) + } + + // Add signature metadata parameters + for _, meta := range bp.MetadataParams { + switch meta { + case MetaCreated: + created, err := bp.MetadataValues.Created() + if err != nil { + return signatureBase{}, newError(ErrInvalidMetadata, fmt.Sprintf("Failed to get value for %s metadata parameter", meta), err) + } + signatureParams.Params.Add(string(MetaCreated), created) + case MetaExpires: + expires, err := bp.MetadataValues.Expires() + if err != nil { + return signatureBase{}, newError(ErrInvalidMetadata, fmt.Sprintf("Failed to get value for %s metadata parameter", meta), err) + } + signatureParams.Params.Add(string(MetaExpires), expires) + case MetaNonce: + nonce, err := bp.MetadataValues.Nonce() + if err != nil { + return signatureBase{}, newError(ErrInvalidMetadata, fmt.Sprintf("Failed to get value for %s metadata parameter", meta), err) + } + signatureParams.Params.Add(string(MetaNonce), nonce) + case MetaAlgorithm: + alg, err := bp.MetadataValues.Alg() + if err != nil { + return signatureBase{}, newError(ErrInvalidMetadata, fmt.Sprintf("Failed to get value for %s metadata parameter", meta), err) + } + signatureParams.Params.Add(string(MetaAlgorithm), alg) + case MetaKeyID: + keyID, err := bp.MetadataValues.KeyID() + if err != nil { + return signatureBase{}, newError(ErrInvalidMetadata, fmt.Sprintf("Failed to get value for %s metadata parameter", meta), err) + } + signatureParams.Params.Add(string(MetaKeyID), keyID) + case MetaTag: + tag, err := bp.MetadataValues.Tag() + if err != nil { + return signatureBase{}, newError(ErrInvalidMetadata, fmt.Sprintf("Failed to get value for %s metadata parameter", meta), err) + } + signatureParams.Params.Add(string(MetaTag), tag) + default: + return signatureBase{}, newError(ErrInvalidMetadata, fmt.Sprintf("Invalid metadata field '%s'", meta)) + } + } + + paramsOut, err := sfv.Marshal(signatureParams) + if err != nil { + return signatureBase{}, fmt.Errorf("Failed to marshal params: %w", err) + } + + base.WriteString(fmt.Sprintf("\"%s\": %s", sigparams, paramsOut)) + return signatureBase{ + base: []byte(base.String()), + signatureInput: paramsOut, + }, nil +} + +// componentID is the signature 'component identifier' as detailed in the specification. +type componentID struct { + Name string // canonical, lower case component name. The name is also the value of the Item. + Item sfv.Item // The sfv representation of the component identifier. This contains the name and parameters. +} + +// SignatureName is the components serialized name required by the signature. +func (cID componentID) signatureName() (string, error) { + signame, err := sfv.Marshal(cID.Item) + if err != nil { + return "", newError(ErrInvalidComponent, fmt.Sprintf("Unable to serialize component identifier '%s'", cID.Name), err) + } + return signame, nil +} + +// signatureValue is the components value required by the signature. +func (cID componentID) signatureValue(msg httpMessage) (string, error) { + val := "" + var err error + if strings.HasPrefix(cID.Name, "@") { + val, err = deriveComponentValue(msg, cID) + if err != nil { + return "", err + } + } else { + values := msg.Headers().Values(cID.Name) + if len(values) == 0 { + return "", newError(ErrInvalidComponent, fmt.Sprintf("Message is missing required component '%s'", cID.Name)) + } + // TODO Handle multi value + if len(values) > 1 { + return "", newError(ErrUnsupported, fmt.Sprintf("This library does yet support signatures for components/headers with multiple values: %s", cID.Name)) + } + val = msg.Headers().Get(cID.Name) + } + return val, nil +} +func deriveComponentValue(r httpMessage, component componentID) (string, error) { + if r.IsResponse { + return deriveComponentValueResponse(r.Resp, component) + } + return deriveComponentValueRequest(r.Req, component) +} + +func deriveComponentValueResponse(resp *http.Response, component componentID) (string, error) { + switch component.Name { + case "@status": + return strconv.Itoa(resp.StatusCode), nil + } + return "", nil +} + +func deriveComponentValueRequest(req *http.Request, component componentID) (string, error) { + switch component.Name { + case "@method": + return req.Method, nil + case "@target-uri": + return deriveTargetURI(req), nil + case "@authority": + return req.Host, nil + case "@scheme": + case "@request-target": + case "@path": + return req.URL.Path, nil + case "@query": + return fmt.Sprintf("?%s", req.URL.RawQuery), nil + case "@query-param": + paramKey, found := component.Item.Params.Get("name") + if !found { + return "", newError(ErrInvalidSignatureOptions, fmt.Sprintf("@query-param specified but missing 'name' parameter to indicate which parameter.")) + } + paramName, ok := paramKey.(string) + if !ok { + return "", newError(ErrInvalidSignatureOptions, fmt.Sprintf("@query-param specified but the 'name' parameter must be a string to indicate which parameter.")) + } + paramValue := req.URL.Query().Get(paramName) + // TODO support empty - is this still a string with a space in it? + if paramValue == "" { + return "", newError(ErrInvalidSignatureOptions, fmt.Sprintf("@query-param '%s' specified but that query param is not in the request", paramName)) + } + return paramValue, nil + default: + return "", newError(ErrInvalidSignatureOptions, fmt.Sprintf("Unsupported derived component identifier for a request '%s'", component.Name)) + } + return "", nil +} + +// deriveTargetURI resolves to an absolute form as required by RFC 9110 and referenced by the http signatures spec. +// The target URI excludes the reference's fragment component, if any, since fragment identifiers are reserved for client-side processing +func deriveTargetURI(req *http.Request) string { + scheme := "https" + if req.TLS == nil { + scheme = "http" + } + + return fmt.Sprintf("%s://%s%s%s", scheme, req.Host, req.URL.RawPath, req.URL.RawQuery) +} diff --git a/third_party/httpsig-go/digest.go b/third_party/httpsig-go/digest.go new file mode 100644 index 0000000000..63960ba18b --- /dev/null +++ b/third_party/httpsig-go/digest.go @@ -0,0 +1,123 @@ +package httpsig + +import ( + "bytes" + "crypto/sha256" + "crypto/sha512" + "fmt" + "io" + "net/http" + + sfv "github.com/dunglas/httpsfv" +) + +const ( + digestAlgoSHA256 = "sha-256" + digestAlgoSHA512 = "sha-512" +) + +var ( + emptySHA256 = sha256.Sum256([]byte{}) + emptySHA512 = sha512.Sum512([]byte{}) +) + +// digestBody reads the entire body to calculate the digest and returns a new io.ReaderCloser which can be set as the new request body. +type digestInfo struct { + Digest []byte + NewBody io.ReadCloser // NewBody is intended as the http.Request Body replacement. Calculating the digest requires reading the body. +} + +func digestBody(digAlgo Digest, body io.ReadCloser) (digestInfo, error) { + var digest []byte + // client GET requests have a nil body + // received/server GET requests have a body but its NoBody + if body == nil || body == http.NoBody { + switch digAlgo { + case DigestSHA256: + digest = emptySHA256[:] + case DigestSHA512: + digest = emptySHA512[:] + default: + return digestInfo{}, newError(ErrNoSigUnsupportedDigest, fmt.Sprintf("Unsupported digest algorithm '%s'", digAlgo)) + } + return digestInfo{ + Digest: digest, + NewBody: body, + }, nil + } + var buf bytes.Buffer + if _, err := buf.ReadFrom(body); err != nil { + return digestInfo{}, newError(ErrNoSigMessageBody, "Failed to read message body to calculate digest", err) + } + if err := body.Close(); err != nil { + return digestInfo{}, newError(ErrNoSigMessageBody, "Failed to close message body to calculate digest", err) + } + + switch digAlgo { + case DigestSHA256: + d := sha256.Sum256(buf.Bytes()) + digest = d[:] + case DigestSHA512: + d := sha512.Sum512(buf.Bytes()) + digest = d[:] + default: + return digestInfo{}, newError(ErrNoSigUnsupportedDigest, fmt.Sprintf("Unsupported digest algorithm '%s'", digAlgo)) + } + + return digestInfo{ + Digest: digest, + NewBody: io.NopCloser(bytes.NewReader(buf.Bytes())), + }, nil +} + +func createDigestHeader(algo Digest, digest []byte) (string, error) { + sfValue := sfv.NewItem(digest) + header := sfv.NewDictionary() + switch algo { + case DigestSHA256: + header.Add(digestAlgoSHA256, sfValue) + case DigestSHA512: + header.Add(digestAlgoSHA512, sfValue) + default: + return "", newError(ErrNoSigUnsupportedDigest, fmt.Sprintf("Unsupported digest algorithm '%s'", algo)) + } + value, err := sfv.Marshal(header) + if err != nil { + return "", newError(ErrInternal, "Failed to marshal digest", err) + } + return value, nil + +} + +// getSupportedDigestFromHeader returns the first supported digest from the supplied header. If no supported header is found a nil digest is returned. +func getSupportedDigestFromHeader(contentDigestHeader []string) (algo Digest, digest []byte, err error) { + digestDict, err := sfv.UnmarshalDictionary(contentDigestHeader) + if err != nil { + return "", nil, newError(ErrNoSigInvalidHeader, "Could not parse Content-Digest header", err) + } + + for _, algo := range digestDict.Names() { + switch Digest(algo) { + case DigestSHA256: + fallthrough + case DigestSHA512: + member, ok := digestDict.Get(algo) + if !ok { + continue + } + item, ok := member.(sfv.Item) + if !ok { + // If not a an Item it's not a valid header value. Skip + continue + } + if digest, ok := item.Value.([]byte); ok { + return Digest(algo), digest, nil + } + default: + // Unsupported + continue + } + } + + return "", nil, nil +} diff --git a/third_party/httpsig-go/digest_test.go b/third_party/httpsig-go/digest_test.go new file mode 100644 index 0000000000..f549aa59c7 --- /dev/null +++ b/third_party/httpsig-go/digest_test.go @@ -0,0 +1,127 @@ +package httpsig + +import ( + "encoding/base64" + "errors" + "io" + "testing" + + "github.com/remitly-oss/httpsig-go/sigtest" +) + +func TestDigestCreate(t *testing.T) { + testcases := []struct { + Name string + Algo Digest + Body io.ReadCloser + ExpectedDigest string // base64 encoded digest + ExpectedHeader string + ExpectedErrCode ErrCode + }{ + { + Name: "sha-256", + Algo: DigestSHA256, + Body: sigtest.MakeBody("hello world"), + ExpectedDigest: "uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=", + ExpectedHeader: "sha-256=:uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=:", + }, + { + Name: "sha-512", + Algo: DigestSHA512, + Body: sigtest.MakeBody("hello world"), + ExpectedDigest: "MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw==", + ExpectedHeader: "sha-512=:MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw==:", + }, + { + Name: "UnsupportedAlgorithm", + Algo: Digest("nope"), + Body: sigtest.MakeBody("hello world"), + ExpectedErrCode: ErrNoSigUnsupportedDigest, + }, + } + + for _, tc := range testcases { + t.Run(tc.Name, func(t *testing.T) { + actual, err := digestBody(tc.Algo, tc.Body) + if err != nil { + if tc.ExpectedErrCode != "" { + diffErrorCode(t, err, tc.ExpectedErrCode) + return + } + t.Fatal(err) + } + actualEncoded := base64.StdEncoding.EncodeToString(actual.Digest) + sigtest.Diff(t, tc.ExpectedDigest, actualEncoded, "Wrong digest") + + actualHeader, err := createDigestHeader(tc.Algo, actual.Digest) + if err != nil { + t.Fatal(err) + } + sigtest.Diff(t, tc.ExpectedHeader, actualHeader, "Wrong digest header") + }) + } +} + +func TestDigestParse(t *testing.T) { + testcases := []struct { + Name string + Header []string + ExcepctedAlgo Digest + ExpectedDigest string // base64 encoded digest + ExpectedErrCode ErrCode + }{ + { + Name: "sha-256", + Header: []string{"sha-256=:uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=:"}, + ExcepctedAlgo: DigestSHA256, + ExpectedDigest: "uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=", + }, + { + Name: "sha-512", + Header: []string{"sha-512=:MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw==:"}, + ExcepctedAlgo: DigestSHA512, + ExpectedDigest: "MJ7MSJwS1utMxA9QyQLytNDtd+5RGnx6m808qG1M2G+YndNbxf9JlnDaNCVbRbDP2DDoH2Bdz33FVC6TrpzXbw==", + }, + { + Name: "Empty", + Header: []string{}, + ExpectedDigest: "", + }, + { + Name: "BadHeader", + Header: []string{"bl===ah"}, + ExpectedErrCode: ErrNoSigInvalidHeader, + }, + { + Name: "Unsupported", + Header: []string{"md5=:blah:"}, + ExpectedDigest: "", + }, + } + + for _, tc := range testcases { + t.Run(tc.Name, func(t *testing.T) { + actualAlgo, actualDigest, err := getSupportedDigestFromHeader(tc.Header) + if err != nil { + if tc.ExpectedErrCode != "" { + diffErrorCode(t, err, tc.ExpectedErrCode) + return + } + t.Fatal(err) + } else if tc.ExpectedErrCode != "" { + t.Fatal("Expected an err") + } + digestEncoded := base64.StdEncoding.EncodeToString(actualDigest) + sigtest.Diff(t, tc.ExcepctedAlgo, actualAlgo, "Wrong digest algo") + sigtest.Diff(t, tc.ExpectedDigest, digestEncoded, "Wrong digest") + }) + } +} + +func diffErrorCode(t *testing.T, err error, code ErrCode) bool { + var sigerr *SignatureError + if errors.As(err, &sigerr) { + return sigtest.Diff(t, code, sigerr.Code, "Wrong error code") + } + return false +} diff --git a/third_party/httpsig-go/examples_test.go b/third_party/httpsig-go/examples_test.go new file mode 100644 index 0000000000..37b8e9e191 --- /dev/null +++ b/third_party/httpsig-go/examples_test.go @@ -0,0 +1,104 @@ +package httpsig_test + +import ( + "crypto" + "fmt" + "html" + "net/http" + "net/http/httptest" + + "github.com/remitly-oss/httpsig-go" + "github.com/remitly-oss/httpsig-go/keyman" + "github.com/remitly-oss/httpsig-go/keyutil" +) + +func ExampleSign() { + pkeyEncoded := `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgNTK6255ubaaj1i/c +ppuLouTgjAVyHGSxI0pYX8z1e2GhRANCAASkbVuWv1KXXs2H8b0ruFLyv2lKJWtT +BznPJ5sSI1Jn+srosJB/GbEZ3Kg6PcEi+jODF9fdpNEaHGbbGdaVhJi1 +-----END PRIVATE KEY-----` + + pkey, _ := keyutil.ReadPrivateKey([]byte(pkeyEncoded)) + req := httptest.NewRequest("GET", "https://example.com/data", nil) + + profile := httpsig.SigningProfile{ + Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Fields: httpsig.DefaultRequiredFields, + Metadata: []httpsig.Metadata{httpsig.MetaKeyID}, + } + skey := httpsig.SigningKey{ + Key: pkey, + MetaKeyID: "key123", + } + + signer, _ := httpsig.NewSigner(profile, skey) + signer.Sign(req) +} + +func ExampleVerify() { + pubkeyEncoded := `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIUctKvU5L/eEYxua5Zlz0HIQJRQq +MTQ7eYQXwqpTvTJkuTffGXKLilT75wY2YZWfybv9flu5d6bCfw+4UB9+cg== +-----END PUBLIC KEY-----` + + pubkey, _ := keyutil.ReadPublicKey([]byte(pubkeyEncoded)) + req := httptest.NewRequest("GET", "https://example.com/data", nil) + + kf := keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + "key123": { + KeyID: "key123", + Algo: httpsig.Algo_ECDSA_P256_SHA256, + PubKey: pubkey, + }, + }) + + httpsig.Verify(req, kf, httpsig.DefaultVerifyProfile) +} + +func ExampleNewHandler() { + myhandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Lookup the results of verification + if veriftyResult, ok := httpsig.GetVerifyResult(r.Context()); ok { + keyid, _ := veriftyResult.KeyID() + fmt.Fprintf(w, "Hello, %s", html.EscapeString(keyid)) + } else { + fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path)) + } + }) + + // Create a verifier + verifier, _ := httpsig.NewVerifier(nil, httpsig.DefaultVerifyProfile) + + mux := http.NewServeMux() + // Wrap the handler with the a signature verification handler. + mux.Handle("/", httpsig.NewHandler(myhandler, verifier)) +} + +func ExampleClient() { + profile := httpsig.SigningProfile{ + Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Fields: httpsig.DefaultRequiredFields, + Metadata: []httpsig.Metadata{httpsig.MetaKeyID}, + } + var privateKey crypto.PrivateKey // Get your private key + + sk := httpsig.SigningKey{ + Key: privateKey, + MetaKeyID: "key123", + } + // Create the signature signer + signer, _ := httpsig.NewSigner(profile, sk) + + // Create a net/http Client that signs all requests + signingClient := httpsig.NewHTTPClient(nil, signer, nil) + + // This call will be signed. + signingClient.Get("https://example.com") + + verifier, _ := httpsig.NewVerifier(nil, httpsig.DefaultVerifyProfile) + // Create a net/http Client that signs and verifies all requests + signVerifyClient := httpsig.NewHTTPClient(nil, signer, verifier) + + signVerifyClient.Get("https://example.com") +} diff --git a/third_party/httpsig-go/fz_test.go b/third_party/httpsig-go/fz_test.go new file mode 100644 index 0000000000..ed69fcb32f --- /dev/null +++ b/third_party/httpsig-go/fz_test.go @@ -0,0 +1,200 @@ +package httpsig + +import ( + "bufio" + "bytes" + "net/http" + "os" + "testing" + + "github.com/remitly-oss/httpsig-go/sigtest" +) + +// FuzzSigningOptions fuzzes the basic user input to SigningOptions +func FuzzSigningOptions1(f *testing.F) { + testcases := [][]string{ + {"", "", "", ""}, + {"", "0", "0", "\xde"}, + {"", "\n", "0", "0"}, + {"", "", "0", "@"}, + {"", "@query-param", "0", "0"}, + {string(Algo_ECDSA_P256_SHA256), "@query", "0", "0"}, + {"any", "@query", "0", "0"}, + {string(Algo_ED25519), "@query", "0", "0"}, + } + + for _, tc := range testcases { + f.Add(tc[0], tc[1], tc[2], tc[3]) + } + + reqtxt, err := os.ReadFile("testdata/rfc-test-request.txt") + if err != nil { + f.Fatal(err) + } + + f.Fuzz(func(t *testing.T, algo, label, keyID, tag string) { + t.Logf("Label: %s\n", label) + t.Logf("keyid: %s\n", keyID) + t.Logf("tag: %s\n", tag) + + fields := Fields(label, keyID, tag) + fields = append(fields, SignedField{ + Name: label, + Parameters: map[string]any{ + keyID: tag, + }, + }) + privKey := sigtest.ReadTestPrivateKey(t, "test-key-ed25519.key") + so := SigningProfile{ + Algorithm: Algo_ED25519, + Fields: Fields(label, keyID, tag), + Metadata: []Metadata{MetaKeyID, MetaTag}, + Label: label, + } + sk := SigningKey{ + Key: privKey, + MetaKeyID: keyID, + MetaTag: tag, + } + if so.validate(sk) != nil { + // Catching invalidate signing options is good. + return + } + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBuffer(reqtxt))) + if err != nil { + t.Fatal(err) + } + + err = Sign(req, so, sk) + if err != nil { + if _, ok := err.(*SignatureError); ok { + // Handled error + return + } + // Unhandled error + t.Error(err) + } + }) +} + +func FuzzSigningOptionsFields(f *testing.F) { + testcases := [][]string{ + {"", "", ""}, + {"0", "0", "\xde"}, + {"\n", "0", "0"}, + {"", "0", "@"}, + {"@query-param", "name", "0"}, + {"@query", "0", "0"}, + {"@method", "", ""}, + {"@status", "", ""}, + } + + for _, tc := range testcases { + f.Add(tc[0], tc[1], tc[2]) + } + + reqtxt, err := os.ReadFile("testdata/rfc-test-request.txt") + if err != nil { + f.Fatal(err) + } + + f.Fuzz(func(t *testing.T, field, tagName, tagValue string) { + t.Logf("field: %s\n", field) + t.Logf("tag: %s:%s\n", tagName, tagValue) + fields := []SignedField{} + if tagName == "" { + fields = append(fields, SignedField{ + Name: field, + }) + } else { + fields = append(fields, SignedField{ + Name: field, + Parameters: map[string]any{ + tagName: tagValue, + }, + }) + } + + so := SigningProfile{ + Algorithm: Algo_ED25519, + Fields: fields, + } + sk := SigningKey{ + Key: sigtest.ReadTestPrivateKey(t, "test-key-ed25519.key"), + } + if so.validate(sk) != nil { + // Catching invalidate signing options is good. + return + } + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBuffer(reqtxt))) + if err != nil { + t.Fatal(err) + } + + err = Sign(req, so, sk) + if err != nil { + if _, ok := err.(*SignatureError); ok { + // Handled error + return + } + // Unhandled error + t.Error(err) + } + }) +} + +func FuzzExtractSignatures(f *testing.F) { + testcases := []struct { + SignatureHeader string + SignatureInputHeader string + }{ + { + SignatureHeader: "", + SignatureInputHeader: "", + }, + { + SignatureHeader: "sig-b24=(\"@status\" \"content-type\" \"content-digest\" \"content-length\");created=1618884473;keyid=\"test-key-ecc-p256\"", + SignatureInputHeader: "sig-b24=:wNmSUAhwb5LxtOtOpNa6W5xj067m5hFrj0XQ4fvpaCLx0NKocgPquLgyahnzDnDAUy5eCdlYUEkLIj+32oiasw==:", + }, + } + + for _, tc := range testcases { + f.Add(tc.SignatureHeader, tc.SignatureInputHeader) + } + + reqtxt, err := os.ReadFile("testdata/rfc-test-request.txt") + if err != nil { + f.Fatal(err) + } + + f.Fuzz(func(t *testing.T, sigHeader, sigInputHeader string) { + t.Logf("signature header: %s\n", sigHeader) + t.Logf("signature input header: %s\n", sigInputHeader) + + req, err := http.ReadRequest(bufio.NewReader(bytes.NewBuffer(reqtxt))) + if err != nil { + t.Fatal(err) + } + + req.Header.Set("signature", sigHeader) + req.Header.Set("signature-input", sigInputHeader) + + sigSFV, err := parseSignaturesFromRequest(req.Header) + if err != nil { + return + } + for _, label := range sigSFV.Sigs.Names() { + _, err = unmarshalSignature(sigSFV, label) + if err != nil { + if _, ok := err.(*SignatureError); ok { + // Handled error + return + } + // Unhandled error + t.Error(err) + } + } + }) +} diff --git a/third_party/httpsig-go/go.mod b/third_party/httpsig-go/go.mod new file mode 100644 index 0000000000..ca025e3a94 --- /dev/null +++ b/third_party/httpsig-go/go.mod @@ -0,0 +1,8 @@ +module github.com/remitly-oss/httpsig-go + +go 1.21.4 + +require ( + github.com/dunglas/httpsfv v1.0.2 + github.com/google/go-cmp v0.7.0 +) diff --git a/third_party/httpsig-go/go.sum b/third_party/httpsig-go/go.sum new file mode 100644 index 0000000000..239a929214 --- /dev/null +++ b/third_party/httpsig-go/go.sum @@ -0,0 +1,4 @@ +github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= +github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/third_party/httpsig-go/http.go b/third_party/httpsig-go/http.go new file mode 100644 index 0000000000..54a60eb857 --- /dev/null +++ b/third_party/httpsig-go/http.go @@ -0,0 +1,102 @@ +package httpsig + +import ( + "context" + "net/http" +) + +type contextKey int + +const verifyKey contextKey = 0 + +// NewSigningHTTPClient creates an *http.Client that signs requests before sending. If hc is nil a new *http.Client is created. If signer is not nil all requests will be signed. If verifier is not nil all requests will be verified. +func NewHTTPClient(hc *http.Client, signer *Signer, verifier *Verifier) *http.Client { + if hc == nil { + hc = &http.Client{} + } + + hc.Transport = NewTransport(hc.Transport, signer, verifier) + return hc +} + +// NewTransport returns an http.RoundTripper implementation that signs requests and verifies responses if signer and verifier are not nil. If rt is nil http.DefaultTransport is used. +func NewTransport(rt http.RoundTripper, signer *Signer, verifier *Verifier) http.RoundTripper { + if rt == nil { + rt = http.DefaultTransport + } + + return &transport{ + sign: signer != nil, + signer: signer, + verify: verifier != nil, + verifier: verifier, + rt: rt, + } +} + +// VerifyHandler verifies the http signature of each request. If not verified it returns a 401 Unauthorized HTTP error. If verified it puts the verification result in the requests context. Use GetVerifyResult to read the context. +type VerifyHandler struct { + handler http.Handler + verifier *Verifier +} + +// NewHandler wraps an http.Handler with a an http.Handler that verifies each request. verifier cannot be nil. +func NewHandler(h http.Handler, verifier *Verifier) http.Handler { + if verifier == nil { + panic("verifier cannot be nil") + } + return &VerifyHandler{ + handler: h, + verifier: verifier, + } +} + +func (vh VerifyHandler) ServeHTTP(rw http.ResponseWriter, inReq *http.Request) { + vr, err := vh.verifier.Verify(inReq) + if err != nil { + // Failed to verify + rw.Write([]byte("Unauthorized")) + rw.WriteHeader(http.StatusUnauthorized) + return + } + + req := inReq.WithContext(context.WithValue(inReq.Context(), verifyKey, &vr)) + vh.handler.ServeHTTP(rw, req) +} + +// GetVerifyResult returns the results of a successful request signature verification. +func GetVerifyResult(ctx context.Context) (v VerifyResult, found bool) { + if vr, ok := ctx.Value(verifyKey).(*VerifyResult); ok && vr != nil { + return *vr, true + } + return VerifyResult{}, false +} + +// transport implements http.RoundTripper interface. It signs the request before calling the underlying RoundTripper +type transport struct { + sign bool + verify bool + signer *Signer + verifier *Verifier + rt http.RoundTripper +} + +func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.sign { + // Signing does not read or close the body + err := t.signer.Sign(req) + if err != nil { + return nil, err + } + } + + resp, err := t.rt.RoundTrip(req) + if err != nil { + return resp, err + } + if t.verify { + // Verifying does not read or close the response body + _, err = t.verifier.VerifyResponse(resp) + } + return resp, err +} diff --git a/third_party/httpsig-go/keyman/keyman.go b/third_party/httpsig-go/keyman/keyman.go new file mode 100644 index 0000000000..9161cc3a5c --- /dev/null +++ b/third_party/httpsig-go/keyman/keyman.go @@ -0,0 +1,34 @@ +// keyman provides key management functionality +package keyman + +import ( + "context" + "fmt" + "net/http" + + "github.com/remitly-oss/httpsig-go" +) + +// KeyFetchInMemory implements KeyFetcher for public keys stored in memory. +type KeyFetchInMemory struct { + pubkeys map[string]httpsig.KeySpec +} + +func NewKeyFetchInMemory(pubkeys map[string]httpsig.KeySpec) *KeyFetchInMemory { + if pubkeys == nil { + pubkeys = map[string]httpsig.KeySpec{} + } + return &KeyFetchInMemory{pubkeys} +} + +func (kf *KeyFetchInMemory) FetchByKeyID(ctx context.Context, rh http.Header, keyID string) (httpsig.KeySpecer, error) { + ks, found := kf.pubkeys[keyID] + if !found { + return nil, fmt.Errorf("Key for keyid '%s' not found", keyID) + } + return ks, nil +} + +func (kf *KeyFetchInMemory) Fetch(context.Context, http.Header, httpsig.MetadataProvider) (httpsig.KeySpecer, error) { + return nil, fmt.Errorf("Fetch without keyid not supported") +} diff --git a/third_party/httpsig-go/keyutil/jwk.go b/third_party/httpsig-go/keyutil/jwk.go new file mode 100644 index 0000000000..6a251912c7 --- /dev/null +++ b/third_party/httpsig-go/keyutil/jwk.go @@ -0,0 +1,284 @@ +package keyutil + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "os" +) + +const ( + KeyTypeEC = "EC" + KeyTypeOct = "oct" +) + +func ReadJWKFile(jwkFile string) (JWK, error) { + keyBytes, err := os.ReadFile(jwkFile) + if err != nil { + return JWK{}, fmt.Errorf("Failed to read jwk key file '%s': %w", jwkFile, err) + } + return ReadJWK(keyBytes) +} + +func ReadJWK(jwkBytes []byte) (JWK, error) { + base := jwk{} + err := json.Unmarshal(jwkBytes, &base) + if err != nil { + return JWK{}, fmt.Errorf("Failed to json parse JWK: %w", err) + } + jwk := JWK{ + KeyType: base.KeyType, + Algorithm: base.Algo, + KeyID: base.KeyID, + } + switch base.KeyType { + case KeyTypeEC: + jec := jwkEC{} + err := json.Unmarshal(jwkBytes, &jec) + if err != nil { + return JWK{}, fmt.Errorf("Failed to json parse JWK: %w", err) + } + jwk.jwtImpl = jec + case KeyTypeOct: + jsym := jwkSymmetric{} + err := json.Unmarshal(jwkBytes, &jsym) + if err != nil { + return JWK{}, fmt.Errorf("Failed to json parse JWK: %w", err) + } + jwk.jwtImpl = jsym + default: + return JWK{}, fmt.Errorf("Unsupported key type/kty - '%s'", base.KeyType) + } + + return jwk, nil +} + +// ReadJWKFromPEM converts a PEM encoded private key to JWK. 'kty' is set based on the passed in PrivateKey type. +func ReadJWKFromPEM(pkeyBytes []byte) (JWK, error) { + pkey, err := ReadPrivateKey(pkeyBytes) + if err != nil { + return JWK{}, err + } + return FromPrivateKey(pkey) +} + +// FromPrivateKey creates a JWK from a crypto.PrivateKey. 'kty' is set based on the passed in PrivateKey. +func FromPrivateKey(pkey crypto.PrivateKey) (JWK, error) { + switch key := pkey.(type) { + case *ecdsa.PrivateKey: + jec := jwkEC{ + jwk: jwk{ + KeyType: KeyTypeEC, + }, + Curve: key.Curve.Params().Name, + X: &octet{*key.X}, + Y: &octet{*key.Y}, + D: &octet{*key.D}, + } + + return JWK{ + KeyType: KeyTypeEC, + jwtImpl: jec, + }, nil + default: + return JWK{}, fmt.Errorf("Unsupported private key type '%T'", pkey) + } +} + +// JWK provides basic data and usage for a JWK. +type JWK struct { + // Common fields are duplicated as struct members for better usability. + KeyType string // 'kty' - "EC", "RSA", "oct" + Algorithm string // 'alg' + KeyID string // 'kid' + jwtImpl any // the specific implementation of JWK based on KeyType. +} + +func (ji *JWK) PublicKey() (crypto.PublicKey, error) { + switch ji.KeyType { + case ji.KeyType: // ECC + if jec, ok := ji.jwtImpl.(jwkEC); ok { + return jec.PublicKey() + } + } + + return nil, fmt.Errorf("Unsupported key type for PublicKey - '%s'", ji.KeyType) +} + +func (ji *JWK) PublicKeyJWK() (JWK, error) { + switch ji.KeyType { + case KeyTypeEC: + if jec, ok := ji.jwtImpl.(jwkEC); ok { + jec.jwk.Algo = ji.Algorithm + jec.jwk.KeyID = ji.KeyID + return jec.PublicKeyJWK() + } + } + + return JWK{}, fmt.Errorf("Unsupported key type for PublicKey'%s'", ji.KeyType) +} + +func (ji *JWK) PrivateKey() (crypto.PrivateKey, error) { + switch ji.KeyType { + case KeyTypeEC: + if jec, ok := ji.jwtImpl.(jwkEC); ok { + return jec.PrivateKey() + } + } + return nil, fmt.Errorf("Unsupported key type PrivateKey - '%s'", ji.KeyType) +} + +func (ji *JWK) SecretKey() ([]byte, error) { + switch ji.KeyType { + case KeyTypeOct: + if jsym, ok := ji.jwtImpl.(jwkSymmetric); ok { + return jsym.Key(), nil + } + + } + return nil, fmt.Errorf("Unsupported key type for Secret '%s'", ji.KeyType) +} + +// octet represents the data for base64 URL encoded data as specified by JWKs. +type octet struct { + big.Int +} + +func (ob octet) MarshalJSON() ([]byte, error) { + out := fmt.Sprintf("\"%s\"", base64.RawURLEncoding.EncodeToString(ob.Bytes())) + return []byte(out), nil +} + +func (ob *octet) UnmarshalJSON(data []byte) error { + // data is the json value and must be unmarshaled into a go string first + encoded := "" + err := json.Unmarshal(data, &encoded) + if err != nil { + return err + } + + rawBytes, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return fmt.Errorf("Failed to base64 decode: %w", err) + } + + x := new(big.Int) + x.SetBytes(rawBytes) + *ob = octet{*x} + + return nil +} + +type jwk struct { + KeyType string `json:"kty"` // kty algorithm family used with the key such as "RSA" or "EC". + Algo string `json:"alg,omitempty"` // alg identifies the algorithm intended for use with the key. + KeyID string `json:"kid,omitempty"` // Used to match a specific key +} + +type jwkEC struct { + jwk + Curve string `json:"crv"` // The curve used with the key e.g. P-256 + X *octet `json:"x"` // x coordinate of the curve. + Y *octet `json:"y"` // y coordinate of the curve. + D *octet `json:"d,omitempty"` // For private keys. +} + +func (ec *jwkEC) params() (crv elliptic.Curve, byteLen int, e error) { + switch ec.Curve { + case "P-256": + crv = elliptic.P256() + case "P-384": + crv = elliptic.P384() + case "P-521": + crv = elliptic.P521() + default: + return nil, 0, fmt.Errorf("Unsupported ECC curve '%s'", ec.Curve) + } + return crv, crv.Params().BitSize / 8, nil +} + +func (ec *jwkEC) PublicKey() (*ecdsa.PublicKey, error) { + crv, byteLen, err := ec.params() + if err != nil { + return nil, err + } + + if len(ec.X.Bytes()) != byteLen { + return nil, fmt.Errorf("X coordinate must be %d byte length for curve '%s'. Got '%d'", byteLen, ec.Curve, len(ec.X.Bytes())) + } + if len(ec.Y.Bytes()) != byteLen { + return nil, fmt.Errorf("Y coordinate must be %d byte length for curve '%s'. Got '%d'", byteLen, ec.Curve, len(ec.Y.Bytes())) + } + + return &ecdsa.PublicKey{ + Curve: crv, + X: &ec.X.Int, + Y: &ec.Y.Int, + }, nil +} + +func (ec *jwkEC) PublicKeyJWK() (JWK, error) { + return JWK{ + KeyType: ec.KeyType, + Algorithm: ec.Algo, + KeyID: ec.KeyID, + jwtImpl: jwkEC{ + jwk: ec.jwk, + Curve: ec.Curve, + X: ec.X, + Y: ec.Y, + }, + }, nil +} + +func (ec *jwkEC) PrivateKey() (*ecdsa.PrivateKey, error) { + if ec.D == nil { + return nil, fmt.Errorf("JWK does not contain a private key") + } + pubkey, err := ec.PublicKey() + if err != nil { + return nil, err + } + _, byteLen, err := ec.params() + if err != nil { + return nil, err + } + + if len(ec.D.Bytes()) != byteLen { + return nil, fmt.Errorf("D coordinate must be %d byte length for curve '%s'. Got '%d'", byteLen, ec.Curve, len(ec.D.Bytes())) + } + + return &ecdsa.PrivateKey{ + PublicKey: *pubkey, + D: &ec.D.Int, + }, nil +} + +type jwkSymmetric struct { + jwk + K *octet `json:"k" ` // Symmetric key +} + +func (js *jwkSymmetric) Key() []byte { + return js.K.Bytes() +} + +func (j JWK) MarshalJSON() ([]byte, error) { + // Set the Algo and KeyID in case the JWK fields have changed + switch jt := j.jwtImpl.(type) { + case jwkEC: + jt.jwk.Algo = j.Algorithm + jt.jwk.KeyID = j.KeyID + return json.Marshal(jt) + case jwkSymmetric: + jt.jwk.Algo = j.Algorithm + jt.jwk.KeyID = j.KeyID + return json.Marshal(jt) + } + + return json.Marshal(j.jwtImpl) +} diff --git a/third_party/httpsig-go/keyutil/jwk_test.go b/third_party/httpsig-go/keyutil/jwk_test.go new file mode 100644 index 0000000000..a01312115c --- /dev/null +++ b/third_party/httpsig-go/keyutil/jwk_test.go @@ -0,0 +1,142 @@ +package keyutil + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestParseJWK(t *testing.T) { + tests := []struct { + Name string + InputFile string // one of InputFile or Input is used + Input string + Expected JWK + ExpectedErrContains string + }{ + { + Name: "Valid EC JWK", + InputFile: "testdata/test-jwk-ec.json", + Expected: JWK{ + KeyType: "EC", + KeyID: "test-key-ecc-p256", + }, + }, + { + + Name: "Valid symmetric JWK", + InputFile: "testdata/test-jwk-symmetric.json", + Expected: JWK{ + + KeyType: "oct", + KeyID: "test-symmetric-key", + }, + }, + { + Name: "Invalid JSON", + Input: `{"kty": malformed`, + ExpectedErrContains: "parse", + }, + { + Name: "Empty input", + Input: "", + ExpectedErrContains: "parse", + }, + } + + for _, tc := range tests { + t.Run(tc.Name, func(t *testing.T) { + var actual JWK + var actualErr error + if tc.InputFile != "" { + actual, actualErr = ReadJWKFile(tc.InputFile) + } else { + actual, actualErr = ReadJWK([]byte(tc.Input)) + } + + if actualErr != nil { + if !strings.Contains(actualErr.Error(), tc.ExpectedErrContains) { + Diff(t, tc.ExpectedErrContains, actualErr.Error(), "Wrong error") + } + return + } + + Diff(t, tc.Expected, actual, "Wrong JWK", cmpopts.IgnoreUnexported(JWK{})) + }) + } +} + +func TestJWKMarshalRoundTrip(t *testing.T) { + tests := []struct { + name string + inputType string + expectedErrContains string + keyid string + algorithm string + }{ + { + name: "EC Key Round Trip", + inputType: "EC", + keyid: "mykey_123", + algorithm: "myalgo", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var pk crypto.PrivateKey + switch tc.inputType { + case "EC": + var err error + pk, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + } + + original, err := FromPrivateKey(pk) + original.KeyID = tc.keyid + original.Algorithm = tc.algorithm + if err != nil { + if tc.expectedErrContains != "" { + if !strings.Contains(err.Error(), tc.expectedErrContains) { + t.Errorf("Expected error containing %q, got: %v", tc.expectedErrContains, err) + } + return + } + t.Fatalf("Failed to generate create JWK from private key: %v", err) + } + + // Marshal JWK to JSON + jsonBytes, err := json.Marshal(original) + if err != nil { + t.Fatalf("Failed to marshal JWK: %v", err) + } + + // Unmarshal back to new JWK + roundTripped, err := ReadJWK(jsonBytes) + if err != nil { + t.Fatalf("Failed to unmarshal round-tripped JWK: %v", err) + } + + // Compare original and round-tripped JWKs + Diff(t, original, roundTripped, "Round-tripped JWK differs from original", cmpopts.IgnoreUnexported(JWK{})) + Diff(t, tc.keyid, roundTripped.KeyID, "Round-tripped JWK differs from original", cmpopts.IgnoreUnexported(JWK{})) + }) + } +} + +func Diff(t *testing.T, expected, actual any, msg string, opts ...cmp.Option) bool { + if diff := cmp.Diff(expected, actual, opts...); diff != "" { + t.Errorf("%s (-want +got):\n%s", msg, diff) + return true + } + return false +} diff --git a/third_party/httpsig-go/keyutil/keyutil.go b/third_party/httpsig-go/keyutil/keyutil.go new file mode 100644 index 0000000000..4018dbf8a8 --- /dev/null +++ b/third_party/httpsig-go/keyutil/keyutil.go @@ -0,0 +1,143 @@ +/* +Package keyutil provides some basic PEM and JWK key handling without dependencies. It is not meant as a replacement for a full key handling library. +*/ +package keyutil + +import ( + "crypto" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "fmt" + "os" +) + +var ( + oidPublicKeyRSAPSS = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 10} +) + +// MustReadPublicKeyFile reads a PEM encoded public key file or panics +func MustReadPublicKeyFile(pubkeyFile string) crypto.PublicKey { + pk, err := ReadPublicKeyFile(pubkeyFile) + if err != nil { + panic(err) + } + return pk +} + +// ReadPublicKeyFile reads a PEM encdoded public key file and parses into crypto.PublicKey +func ReadPublicKeyFile(pubkeyFile string) (crypto.PublicKey, error) { + keyBytes, err := os.ReadFile(pubkeyFile) + if err != nil { + return nil, fmt.Errorf("Failed to read public key file '%s': %w", pubkeyFile, err) + } + return ReadPublicKey(keyBytes) +} + +// ReadPublicKey decodes a PEM encoded public key and parses into crypto.PublicKey +func ReadPublicKey(encodedPubkey []byte) (crypto.PublicKey, error) { + block, _ := pem.Decode(encodedPubkey) + if block == nil { + return nil, fmt.Errorf("Failed to PEM decode public key") + } + var key crypto.PublicKey + var err error + + switch block.Type { + case "PUBLIC KEY": + key, err = x509.ParsePKIXPublicKey(block.Bytes) + case "RSA PUBLIC KEY": + key, err = x509.ParsePKCS1PublicKey(block.Bytes) + default: + return nil, fmt.Errorf("Unsupported pubkey format '%s'", block.Type) + } + if err != nil { + return nil, fmt.Errorf("Failed to parse public key with format '%s': %w", block.Type, err) + } + + return key, nil +} + +// MustReadPrivateKeyFile decodes a PEM encoded private key file and parses into a crypto.PrivateKey or panics. +func MustReadPrivateKeyFile(pkFile string) crypto.PrivateKey { + pk, err := ReadPrivateKeyFile(pkFile) + if err != nil { + panic(err) + } + return pk +} + +func MustReadPrivateKey(encodedPrivateKey []byte) crypto.PrivateKey { + pkey, err := ReadPrivateKey(encodedPrivateKey) + if err != nil { + panic(err) + } + return pkey +} + +// ReadPrivateKeyFile opens the given file and calls ReadPrivateKey to return a crypto.PrivateKey +func ReadPrivateKeyFile(pkFile string) (crypto.PrivateKey, error) { + keyBytes, err := os.ReadFile(pkFile) + if err != nil { + return nil, fmt.Errorf("Failed to read private key file '%s': %w", pkFile, err) + } + return ReadPrivateKey(keyBytes) +} + +// ReadPrivateKey decoded a PEM encoded private key and parses into a crypto.PrivateKey. +func ReadPrivateKey(encodedPrivateKey []byte) (crypto.PrivateKey, error) { + block, _ := pem.Decode(encodedPrivateKey) + + if block == nil { + return nil, fmt.Errorf("Failed to PEM decode private key") + } + + var key crypto.PrivateKey + var err error + + switch block.Type { + case "PRIVATE KEY": + key, err = x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + // Try to handle RSAPSS + psskey, psserr := parseRSAPSS(block) + if psserr == nil { + // success + key = psskey + err = psserr + } + } + case "EC PRIVATE KEY": + key, err = x509.ParseECPrivateKey(block.Bytes) + case "RSA PRIVATE KEY": + key, err = x509.ParsePKCS1PrivateKey(block.Bytes) + default: + return nil, fmt.Errorf("Unsupported private key format '%s'", block.Type) + } + if err != nil { + return nil, fmt.Errorf("Failed to parse private key with format '%s': %w", block.Type, err) + } + return key, nil +} + +func parseRSAPSS(block *pem.Block) (crypto.PrivateKey, error) { + // The rsa-pss key is PKCS8 encoded but the golang 1.19 parser doesn't recognize the algorithm and gives 'PKCS#8 wrapping contained private key with unknown algorithm: 1.2.840.113549.1.1.10 + + // Instead do the asn1 unmarshaling and check here. + pkcs8 := struct { + Version int + Algo pkix.AlgorithmIdentifier + PrivateKey []byte + }{} + + _, err := asn1.Unmarshal(block.Bytes, &pkcs8) + if err != nil { + return nil, fmt.Errorf("Failed to ans1 unmarshal private key: %w", err) + } + + if !pkcs8.Algo.Algorithm.Equal(oidPublicKeyRSAPSS) { + return nil, fmt.Errorf("PKCS#8 wrapping contained private key with unknown algorithm: %s", pkcs8.Algo.Algorithm) + } + return x509.ParsePKCS1PrivateKey(pkcs8.PrivateKey) +} diff --git a/third_party/httpsig-go/keyutil/testdata/test-jwk-ec.json b/third_party/httpsig-go/keyutil/testdata/test-jwk-ec.json new file mode 100644 index 0000000000..171db167f3 --- /dev/null +++ b/third_party/httpsig-go/keyutil/testdata/test-jwk-ec.json @@ -0,0 +1,8 @@ +{ + "kty": "EC", + "crv": "P-256", + "kid": "test-key-ecc-p256", + "x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", + "d": "870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE" +} \ No newline at end of file diff --git a/third_party/httpsig-go/keyutil/testdata/test-jwk-symmetric.json b/third_party/httpsig-go/keyutil/testdata/test-jwk-symmetric.json new file mode 100644 index 0000000000..d0bd22e37f --- /dev/null +++ b/third_party/httpsig-go/keyutil/testdata/test-jwk-symmetric.json @@ -0,0 +1,5 @@ +{ + "kty": "oct", + "kid": "test-symmetric-key", + "k": "QYPe3dGeFrKhEEK4CSr9_nES-ExXI9Nw" +} \ No newline at end of file diff --git a/third_party/httpsig-go/roundtrip_test.go b/third_party/httpsig-go/roundtrip_test.go new file mode 100644 index 0000000000..52c3ee4466 --- /dev/null +++ b/third_party/httpsig-go/roundtrip_test.go @@ -0,0 +1,217 @@ +package httpsig_test + +import ( + "crypto" + "testing" + + "github.com/remitly-oss/httpsig-go" + "github.com/remitly-oss/httpsig-go/keyman" + "github.com/remitly-oss/httpsig-go/keyutil" + "github.com/remitly-oss/httpsig-go/sigtest" +) + +// TestRoundTrip tests that the signing code can be verified by the verify code. +func TestRoundTrip(t *testing.T) { + + testcases := []struct { + Name string + PrivateKey crypto.PrivateKey + MetaKeyID string + Secret []byte + SignProfile httpsig.SigningProfile + RequestFile string + Keys httpsig.KeyFetcher + Profile httpsig.VerifyProfile + ExpectedErrCodeVerify httpsig.ErrCode + }{ + { + Name: "RSA-PSS", + PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-rsa-pss.key"), + MetaKeyID: "test-key-rsa", + SignProfile: httpsig.SigningProfile{ + Algorithm: httpsig.Algo_RSA_PSS_SHA512, + Fields: httpsig.DefaultRequiredFields, + Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, + Label: "tst-rsa-pss", + }, + RequestFile: "rfc-test-request.txt", + Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + "test-key-rsa": { + KeyID: "test-key-rsa", + Algo: httpsig.Algo_RSA_PSS_SHA512, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), + }, + }), + Profile: createVerifyProfile("tst-rsa-pss"), + }, + { + Name: "RSA-v15", + PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/key-rsa-v15.key"), + MetaKeyID: "test-key-rsa", + SignProfile: httpsig.SigningProfile{ + Algorithm: httpsig.Algo_RSA_v1_5_sha256, + Fields: httpsig.DefaultRequiredFields, + Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, + Label: "tst-rsa-pss", + }, + RequestFile: "rfc-test-request.txt", + Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + "test-key-rsa": { + KeyID: "test-key-rsa", + Algo: httpsig.Algo_RSA_v1_5_sha256, + PubKey: keyutil.MustReadPublicKeyFile("testdata/key-rsa-v15.pub"), + }, + }), + Profile: createVerifyProfile("tst-rsa-pss"), + }, + { + Name: "HMAC_SHA256", + Secret: sigtest.MustReadFile("testdata/test-shared-secret"), + MetaKeyID: "test-key-shared", + SignProfile: httpsig.SigningProfile{ + Algorithm: httpsig.Algo_HMAC_SHA256, + Fields: httpsig.DefaultRequiredFields, + Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, + }, + RequestFile: "rfc-test-request.txt", + Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + "test-key-shared": { + KeyID: "test-key-shared", + Algo: httpsig.Algo_HMAC_SHA256, + Secret: sigtest.MustReadFile("testdata/test-shared-secret"), + }, + }), + Profile: createVerifyProfile("sig1"), + }, + { + Name: "ECDSA-p265", + PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ecc-p256.key"), + MetaKeyID: "test-key-ecdsa", + SignProfile: httpsig.SigningProfile{ + Algorithm: httpsig.Algo_ECDSA_P256_SHA256, + Fields: httpsig.DefaultRequiredFields, + Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, + Label: "tst-ecdsa", + }, + RequestFile: "rfc-test-request.txt", + Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + "test-key-ecdsa": { + KeyID: "test-key-ecds", + Algo: httpsig.Algo_ECDSA_P256_SHA256, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ecc-p256.pub"), + }, + }), + Profile: createVerifyProfile("tst-ecdsa"), + }, + { + Name: "ECDSA-p384", + PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ecc-p384.key"), + MetaKeyID: "test-key-ecdsa", + SignProfile: httpsig.SigningProfile{ + Algorithm: httpsig.Algo_ECDSA_P384_SHA384, + Fields: httpsig.DefaultRequiredFields, + Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, + Label: "tst-ecdsa", + }, + RequestFile: "rfc-test-request.txt", + Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + "test-key-ecdsa": { + KeyID: "test-key-ecdsa", + Algo: httpsig.Algo_ECDSA_P384_SHA384, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ecc-p384.pub"), + }, + }), + Profile: createVerifyProfile("tst-ecdsa"), + }, + { + Name: "ED25519", + PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ed25519.key"), + MetaKeyID: "test-key-ed", + SignProfile: httpsig.SigningProfile{ + Algorithm: httpsig.Algo_ED25519, + Fields: httpsig.DefaultRequiredFields, + Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, + Label: "tst-ed", + }, + RequestFile: "rfc-test-request.txt", + Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + "test-key-ed": { + KeyID: "test-key-ed", + Algo: httpsig.Algo_ED25519, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ed25519.pub"), + }, + }), + Profile: createVerifyProfile("tst-ed"), + }, + { + Name: "BadDigest", + PrivateKey: keyutil.MustReadPrivateKeyFile("testdata/test-key-ed25519.key"), + MetaKeyID: "test-key-ed", + SignProfile: httpsig.SigningProfile{ + + Algorithm: httpsig.Algo_ED25519, + Fields: httpsig.DefaultRequiredFields, + Metadata: []httpsig.Metadata{httpsig.MetaCreated, httpsig.MetaKeyID}, + Label: "tst-content-digest", + }, + RequestFile: "request_bad_digest.txt", + Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + "test-key-ed": { + KeyID: "test-key-ed", + Algo: httpsig.Algo_ED25519, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-ed25519.pub"), + }, + }), + Profile: createVerifyProfile("tst-content-digest"), + ExpectedErrCodeVerify: httpsig.ErrNoSigWrongDigest, + }, + } + + for _, tc := range testcases { + t.Run(tc.Name, func(t *testing.T) { + var signer *httpsig.Signer + sk := httpsig.SigningKey{ + Key: tc.PrivateKey, + Secret: tc.Secret, + MetaKeyID: tc.MetaKeyID, + } + + signer, err := httpsig.NewSigner(tc.SignProfile, sk) + if err != nil { + t.Fatal(err) + } + + req := sigtest.ReadRequest(t, tc.RequestFile) + err = signer.Sign(req) + if err != nil { + t.Fatalf("%#v", err) + } + t.Log(req.Header.Get("Signature-Input")) + t.Log(req.Header.Get("Signature")) + ver, err := httpsig.NewVerifier(tc.Keys, tc.Profile) + if err != nil { + t.Fatal(err) + } + vf, err := ver.Verify(req) + if err != nil { + if tc.ExpectedErrCodeVerify != "" { + if sigerr, ok := err.(*httpsig.SignatureError); ok { + sigtest.Diff(t, tc.ExpectedErrCodeVerify, sigerr.Code, "Wrong err code") + } + } else { + t.Fatalf("%#v", err) + } + } else if tc.ExpectedErrCodeVerify != "" { + t.Fatal("Expected error") + } + t.Logf("%+v\n", vf) + }) + + } +} + +func createVerifyProfile(label string) httpsig.VerifyProfile { + vp := httpsig.DefaultVerifyProfile + vp.SignatureLabel = label + return vp +} diff --git a/third_party/httpsig-go/side.toml b/third_party/httpsig-go/side.toml new file mode 100644 index 0000000000..95ce9e536b --- /dev/null +++ b/third_party/httpsig-go/side.toml @@ -0,0 +1,5 @@ +max_iterations = 0 +max_planning_iterations = 0 + +[[test_commands]] + command = "go test" diff --git a/third_party/httpsig-go/sigerrors.go b/third_party/httpsig-go/sigerrors.go new file mode 100644 index 0000000000..fc7807f055 --- /dev/null +++ b/third_party/httpsig-go/sigerrors.go @@ -0,0 +1,89 @@ +package httpsig + +import ( + "errors" + "fmt" +) + +// ErrCode enumerates the reasons a signing or verification can fail +type ErrCode string + +const ( + // Error Codes + + // Errors related to not being able to extract a valid signature. + ErrNoSigInvalidHeader ErrCode = "nosig_invalid_header" + ErrNoSigUnsupportedDigest ErrCode = "nosig_unsupported_digest" + ErrNoSigWrongDigest ErrCode = "nosig_wrong_digest" + ErrNoSigMissingSignature ErrCode = "nosig_missing_signature" + ErrNoSigInvalidSignature ErrCode = "nosig_invalid_signature" + ErrNoSigMessageBody ErrCode = "nosig_message_body" // Could not read message body + + // Errors related to an individual signature. + ErrSigInvalidSignature ErrCode = "sig_invalid_signature" // The signature is unparseable or in the wrong format. + ErrSigKeyFetch ErrCode = "sig_key_fetch" // Failed to the key for a signature + ErrSigVerification ErrCode = "sig_failed_algo_verification" // The signature did not verify according to the algorithm. + ErrSigPublicKey ErrCode = "sig_public_key" // The public key for the signature is invalid or missing. + ErrSigSecretKey ErrCode = "sig_secret_key" // The secret key for the signature is invalid or missing. + ErrSigUnsupportedAlgorithm ErrCode = "sig_unsupported_algorithm" // unsupported or invalid algorithm. + ErrSigProfile ErrCode = "sig_failed_profile" // The signature was valid but failed the verify profile check + + // Signing + ErrInvalidSignatureOptions ErrCode = "invalid_signature_options" + ErrInvalidComponent ErrCode = "invalid_component" + ErrInvalidMetadata ErrCode = "invalid_metadata" + + // Accept Signature + ErrInvalidAcceptSignature ErrCode = "invalid_accept_signature" + ErrMissingAcceptSignature ErrCode = "missing_accept_signature" // The Accept-Signature field was present but had an empty value. + + // General + ErrInternal ErrCode = "internal_error" + ErrUnsupported ErrCode = "unsupported" // A particular feature of the spec is not supported +) + +type SignatureError struct { + Cause error // may be nil + Code ErrCode + Message string +} + +func (se *SignatureError) Error() string { + return se.Message +} + +func (se *SignatureError) Unwrap() error { + return se.Cause +} + +func (se *SignatureError) GoString() string { + cause := "" + if se.Cause != nil { + cause = fmt.Sprintf("Cause: %s\n", se.Cause) + } + return fmt.Sprintf("Code: %s\nMessage: %s\n%s", se.Code, se.Message, cause) +} + +func newError(code ErrCode, msg string, cause ...error) *SignatureError { + var rootErr error + if len(cause) > 0 { + rootErr = cause[0] + } + return &SignatureError{ + Cause: rootErr, + Code: code, + Message: msg, + } +} + +func errCode(err error) (ec ErrCode) { + if err == nil { + return "" + } + var se *SignatureError + if errors.As(err, &se) { + return se.Code + } + + return "" +} diff --git a/third_party/httpsig-go/sign.go b/third_party/httpsig-go/sign.go new file mode 100644 index 0000000000..025721cc9c --- /dev/null +++ b/third_party/httpsig-go/sign.go @@ -0,0 +1,332 @@ +package httpsig + +import ( + "crypto" + "crypto/rand" + "encoding/base64" + "fmt" + "net/http" + "strings" + "time" + "unicode" + + sfv "github.com/dunglas/httpsfv" +) + +type Algorithm string +type Digest string + +// Metadata are the named signature metadata parameters +type Metadata string + +type CreatedScheme int +type ExpiresScheme int +type NonceScheme int + +const ( + // Supported signing algorithms + Algo_RSA_PSS_SHA512 Algorithm = "rsa-pss-sha512" + Algo_RSA_v1_5_sha256 Algorithm = "rsa-v1_5-sha256" + Algo_HMAC_SHA256 Algorithm = "hmac-sha256" + Algo_ECDSA_P256_SHA256 Algorithm = "ecdsa-p256-sha256" + Algo_ECDSA_P384_SHA384 Algorithm = "ecdsa-p384-sha384" + Algo_ED25519 Algorithm = "ed25519" + + DigestSHA256 Digest = "sha-256" + DigestSHA512 Digest = "sha-512" + + // Signature metadata parameters + MetaCreated Metadata = "created" + MetaExpires Metadata = "expires" + MetaNonce Metadata = "nonce" + MetaAlgorithm Metadata = "alg" + MetaKeyID Metadata = "keyid" + MetaTag Metadata = "tag" + + // DefaultSignatureLabel is the label that will be used for a signature if not label is provided in the parameters. + // A request can contain multiple signatures therefore each signature is labeled. + DefaultSignatureLabel = "sig1" + + // Nonce schemes + NonceRandom32 = iota // 32 bit random nonce. Base64 encoded +) + +// SigningProfile is the set of fields, metadata, and the label to include in a signature. +type SigningProfile struct { + Algorithm Algorithm + Digest Digest // The http digest algorithm to apply. Defaults to sha-256. + Fields []SignedField // Fields and Derived components to sign. + Metadata []Metadata // Metadata parameters to add to the signature. + Label string // The signature label. Defaults to DefaultSignatureLabel. + ExpiresDuration time.Duration // Current time plus this duration. Default duration 5 minutes. Used only if included in Metadata. + Nonce NonceScheme // Scheme to use for generating the nonce if included in Metadata. +} + +// SignedField indicates which part of the request or response to use for signing. +// This is the 'message component' in the specification. +type SignedField struct { + Name string + Parameters map[string]any // Parameters are modifiers applied to the field that changes the way the signature is calculated. +} + +// Fields turns a list of fields into the full specification. Used when the signed fields/components do not need to specify any parameters +func Fields(fields ...string) []SignedField { + all := []SignedField{} + for _, field := range fields { + all = append(all, SignedField{ + Name: strings.ToLower(field), + Parameters: map[string]any{}, + }) + } + return all +} + +type SigningKey struct { + Key crypto.PrivateKey // private key for asymmetric algorithms + Secret []byte // Secret to use for symmetric algorithms + // Meta fields + MetaKeyID string // 'keyid' - Only used if 'keyid' is set in the SigningProfile. A value must be provided if the parameter is required in the SigningProfile. Metadata. + MetaTag string // 'tag'. Only used if 'tag' is set in the SigningProfile. A value must be provided if the parameter is required in the SigningProfile. +} + +type Signer struct { + profile SigningProfile + skey SigningKey +} + +func NewSigner(profile SigningProfile, skey SigningKey) (*Signer, error) { + err := profile.validate(skey) + if err != nil { + return nil, err + } + + opts := profile.withDefaults() + s := &Signer{ + profile: opts, + skey: skey, + } + return s, nil +} + +func Sign(req *http.Request, params SigningProfile, skey SigningKey) error { + s, err := NewSigner(params, skey) + if err != nil { + return err + } + return s.Sign(req) +} + +// Sign signs the request and adds the signature headers to the request. +// If the signature fields includes Content-Digest and Content-Digest is not already included in the request then Sign will read the request body to calculate the digest and set the header. The request body will be replaced with a new io.ReaderCloser. +func (s *Signer) Sign(req *http.Request) error { + // Add the content-digest if covered by the signature and not already present + if signedFields(s.profile.Fields).includes("content-digest") && req.Header.Get("Content-Digest") == "" { + di, err := digestBody(s.profile.Digest, req.Body) + if err != nil { + return err + } + req.Body = di.NewBody + digestValue, err := createDigestHeader(s.profile.Digest, di.Digest) + if err != nil { + return err + } + req.Header.Set("Content-Digest", digestValue) + } + + baseParams, err := s.baseParameters() + if err != nil { + return err + } + + return sign( + httpMessage{ + Req: req, + }, sigParameters{ + Base: baseParams, + Algo: s.profile.Algorithm, + PrivateKey: s.skey.Key, + Secret: s.skey.Secret, + Label: s.profile.Label, + }) +} + +func (s *Signer) SignResponse(resp *http.Response) error { + baseParams, err := s.baseParameters() + if err != nil { + return err + } + + return sign( + httpMessage{ + IsResponse: true, + Resp: resp, + }, sigParameters{ + Base: baseParams, + Algo: s.profile.Algorithm, + PrivateKey: s.skey.Key, + Secret: s.skey.Secret, + Label: s.profile.Label, + }) +} + +func (s *Signer) baseParameters() (sigBaseInput, error) { + bp := sigBaseInput{ + Components: componentsIDs(s.profile.Fields), + MetadataParams: s.profile.Metadata, + MetadataValues: s, + } + + return bp, nil +} + +func (s *Signer) Created() (int, error) { + return int(time.Now().Unix()), nil +} + +func (s *Signer) Expires() (int, error) { + return int(time.Now().Add(s.profile.ExpiresDuration).Unix()), nil +} + +func (s *Signer) Nonce() (string, error) { + switch s.profile.Nonce { + case NonceRandom32: + return nonceRandom32() + } + return "", fmt.Errorf("Invalid nonce scheme '%d'", s.profile.Nonce) +} + +func (s *Signer) Alg() (string, error) { + return string(s.profile.Algorithm), nil +} + +func (s *Signer) KeyID() (string, error) { + return s.skey.MetaKeyID, nil +} + +func (s *Signer) Tag() (string, error) { + return s.skey.MetaTag, nil +} + +func (so SigningProfile) validate(skey SigningKey) error { + if so.Algorithm == "" { + return fmt.Errorf("Missing required signing option 'Algorithm'") + } + if so.Algorithm.symmetric() && len(skey.Secret) == 0 { + return newError(ErrInvalidSignatureOptions, "Missing required 'Secret' value in SigningKey") + } + if !so.Algorithm.symmetric() && skey.Key == nil { + return newError(ErrInvalidSignatureOptions, "Missing required 'Key' value in SigningKey") + } + if !isSafeString(so.Label) { + return fmt.Errorf("Invalid label name '%s'", so.Label) + } + for _, sf := range so.Fields { + if !isSafeString(sf.Name) { + return fmt.Errorf("Invalid signing field name '%s'", sf.Name) + } + } + + for _, md := range so.Metadata { + switch md { + case MetaKeyID: + if skey.MetaKeyID == "" { + return fmt.Errorf("'keyid' metadata parameter was listed but missing MetaKeyID value'") + } + if !isSafeString(skey.MetaKeyID) { + return fmt.Errorf("'keyid' metadata parameter can only contain printable characters'") + } + case MetaTag: + if skey.MetaTag == "" { + return fmt.Errorf("'tag' metadata parameter was listed but missing MetaTag value'") + } + if !isSafeString(skey.MetaTag) { + return fmt.Errorf("'tag' metadata parameter can only contain printable characters'") + } + } + } + return nil +} + +func (sp SigningProfile) withDefaults() SigningProfile { + final := SigningProfile{ + Algorithm: sp.Algorithm, + Digest: sp.Digest, + Fields: sp.Fields, + Metadata: sp.Metadata, + Label: sp.Label, + ExpiresDuration: sp.ExpiresDuration, + Nonce: NonceRandom32, + } + // Defaults + if final.Label == "" { + final.Label = DefaultSignatureLabel + } + if final.ExpiresDuration == 0 { + final.ExpiresDuration = time.Minute * 5 + } + if final.Digest == "" { + final.Digest = DigestSHA256 + } + + return final +} + +func (sf SignedField) componentID() componentID { + item := sfv.NewItem(sf.Name) + for key, param := range sf.Parameters { + item.Params.Add(key, param) + } + return componentID{ + Name: strings.ToLower(sf.Name), + Item: item, + } +} + +type signedFields []SignedField + +func (sf signedFields) includes(field string) bool { + target := strings.ToLower(field) + for _, fld := range sf { + if fld.Name == target { + return true + } + } + return false +} + +func (a Algorithm) symmetric() bool { + switch a { + case Algo_HMAC_SHA256: + return true + } + return false +} +func componentsIDs(sfs []SignedField) []componentID { + cIDs := []componentID{} + for _, sf := range sfs { + cIDs = append(cIDs, sf.componentID()) + } + + return cIDs +} + +func nonceRandom32() (string, error) { + nonce := make([]byte, 32) + n, err := rand.Read(nonce) + if err != nil || n < 32 { + return "", fmt.Errorf("could not generate nonce") + } + return base64.StdEncoding.EncodeToString(nonce), nil +} + +func isSafeString(s string) bool { + for _, c := range s { + if !unicode.IsPrint(c) { + return false + } + if c > unicode.MaxASCII { + return false + } + } + return true +} diff --git a/third_party/httpsig-go/sign_test.go b/third_party/httpsig-go/sign_test.go new file mode 100644 index 0000000000..7cb3480a04 --- /dev/null +++ b/third_party/httpsig-go/sign_test.go @@ -0,0 +1,275 @@ +package httpsig + +import ( + "bufio" + "fmt" + "net/http" + "os" + "testing" + + "github.com/remitly-oss/httpsig-go/sigtest" +) + +// testcaseSigBase is a test case for signature bases +type testcaseSigBase struct { + Name string + Params sigBaseInput + IsResponse bool + SourceFile string // defaults to the specification request or response file + ExpectedFile string + ExpectedErr ErrCode +} + +func TestSignatureBase(t *testing.T) { + cases := []testcaseSigBase{ + { + Name: "RepeatedComponents", + Params: sigBaseInput{ + Components: makeComponents("one", "two", "one", "three"), + MetadataParams: []Metadata{}, + MetadataValues: emptyMeta, + }, + SourceFile: "request_repeated_components.txt", + ExpectedErr: ErrInvalidSignatureOptions, + }, + { + Name: "BadComponentName", + Params: sigBaseInput{ + Components: makeComponents("\xd3", "two", "one", "three"), + MetadataParams: []Metadata{}, + MetadataValues: emptyMeta, + }, + ExpectedErr: ErrInvalidComponent, + }, + { + Name: "NoMultiValueSuport", + Params: sigBaseInput{ + Components: makeComponents("one"), + MetadataParams: []Metadata{}, + MetadataValues: emptyMeta, + }, + ExpectedErr: ErrUnsupported, + SourceFile: "request_multivalue.txt", + }, + { + Name: "BadMeta-Created", + Params: sigBaseInput{ + Components: makeComponents(), + MetadataParams: []Metadata{ + MetaCreated, + }, + MetadataValues: errorMetadataProvider{}, + }, + ExpectedErr: ErrInvalidMetadata, + }, + { + Name: "BadMeta-Expires", + Params: sigBaseInput{ + Components: makeComponents(), + MetadataParams: []Metadata{ + MetaExpires, + }, + MetadataValues: errorMetadataProvider{}, + }, + ExpectedErr: ErrInvalidMetadata, + }, + { + Name: "BadMeta-Nonce", + Params: sigBaseInput{ + Components: makeComponents(), + MetadataParams: []Metadata{ + MetaNonce, + }, + MetadataValues: errorMetadataProvider{}, + }, + ExpectedErr: ErrInvalidMetadata, + }, + { + Name: "BadMeta-Algorithm", + Params: sigBaseInput{ + Components: makeComponents(), + MetadataParams: []Metadata{ + MetaAlgorithm, + }, + MetadataValues: errorMetadataProvider{}, + }, + ExpectedErr: ErrInvalidMetadata, + }, + { + Name: "BadMeta-KeyID", + Params: sigBaseInput{ + Components: makeComponents(), + MetadataParams: []Metadata{ + MetaKeyID, + }, + MetadataValues: errorMetadataProvider{}, + }, + ExpectedErr: ErrInvalidMetadata, + }, + { + Name: "BadMeta-Tag", + Params: sigBaseInput{ + Components: makeComponents(), + MetadataParams: []Metadata{ + MetaTag, + }, + MetadataValues: errorMetadataProvider{}, + }, + ExpectedErr: ErrInvalidMetadata, + }, + } + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + runTestSigBase(t, tc) + }) + } + +} + +func runTestSigBase(t *testing.T, tc testcaseSigBase) { + sourceFile := tc.SourceFile + hrr := httpMessage{ + IsResponse: tc.IsResponse, + } + if tc.IsResponse { + if sourceFile == "" { + sourceFile = "rfc-test-response.txt" + } + resptxt, err := os.Open(fmt.Sprintf("testdata/%s", sourceFile)) + if err != nil { + t.Fatal(err) + } + + resp, err := http.ReadResponse(bufio.NewReader(resptxt), nil) + if err != nil { + t.Fatal(err) + } + hrr.Resp = resp + } else { + if sourceFile == "" { + sourceFile = "rfc-test-request.txt" + } + // request + reqtxt, err := os.Open(fmt.Sprintf("testdata/%s", sourceFile)) + if err != nil { + t.Fatal(err) + } + + req, err := http.ReadRequest(bufio.NewReader(reqtxt)) + if err != nil { + t.Fatal(err) + } + hrr.Req = req + } + + actualBase, err := calculateSignatureBase(hrr, tc.Params) + if sigtest.Diff(t, tc.ExpectedErr, errCode(err), "Wrong error code") { + return + } else if tc.ExpectedErr != "" { + // If an error is expected and the err Diff check has passed then don't continue on to test the result + return + } + + t.Log(string(actualBase.base)) + expectedBase, err := os.ReadFile(fmt.Sprintf("testdata/%s", tc.ExpectedFile)) + if err != nil { + t.Fatal(err) + } + if sigtest.Diff(t, string(expectedBase), string(actualBase.base), "Signature base did not match") { + t.FailNow() + } +} + +type errorMetadataProvider struct{} + +func (fmp errorMetadataProvider) Created() (int, error) { + return 0, fmt.Errorf("No created value") +} + +func (fmp errorMetadataProvider) Expires() (int, error) { + return 0, fmt.Errorf("No expires value") +} + +func (fmp errorMetadataProvider) Nonce() (string, error) { + return "", fmt.Errorf("No nonce value") +} + +func (fmp errorMetadataProvider) Alg() (string, error) { + return "", fmt.Errorf("No alg value") +} + +func (fmp errorMetadataProvider) KeyID() (string, error) { + return "", fmt.Errorf("No keyid value") +} + +func (fmp errorMetadataProvider) Tag() (string, error) { + return "", fmt.Errorf("No tag value") +} + +var emptyMeta = fixedMetadataProvider{ + values: map[Metadata]any{}, +} + +type fixedMetadataProvider struct { + values map[Metadata]any +} + +func (fmp fixedMetadataProvider) Created() (int, error) { + if val, ok := fmp.values[MetaCreated]; ok { + return int(val.(int64)), nil + } + return 0, fmt.Errorf("No created value") +} + +func (fmp fixedMetadataProvider) Expires() (int, error) { + if val, ok := fmp.values[MetaExpires]; ok { + return int(val.(int64)), nil + } + return 0, fmt.Errorf("No expires value") +} + +func (fmp fixedMetadataProvider) Nonce() (string, error) { + if val, ok := fmp.values[MetaNonce]; ok { + return val.(string), nil + } + return "", fmt.Errorf("No nonce value") +} + +func (fmp fixedMetadataProvider) Alg() (string, error) { + if val, ok := fmp.values[MetaAlgorithm]; ok { + return val.(string), nil + } + return "", fmt.Errorf("No alg value") +} + +func (fmp fixedMetadataProvider) KeyID() (string, error) { + if val, ok := fmp.values[MetaKeyID]; ok { + return val.(string), nil + } + return "", fmt.Errorf("No keyid value") +} + +func (fmp fixedMetadataProvider) Tag() (string, error) { + if val, ok := fmp.values[MetaTag]; ok { + return val.(string), nil + } + return "", fmt.Errorf("No tag value") +} + +func makeComponents(ids ...string) []componentID { + cids := []componentID{} + for _, id := range ids { + cids = append(cids, SignedField{ + Name: id, + }.componentID()) + } + return cids +} +func makeComponentIDs(sfs ...SignedField) []componentID { + + cids := []componentID{} + for _, sf := range sfs { + cids = append(cids, sf.componentID()) + } + return cids +} diff --git a/third_party/httpsig-go/signatures.go b/third_party/httpsig-go/signatures.go new file mode 100644 index 0000000000..940f4f0eae --- /dev/null +++ b/third_party/httpsig-go/signatures.go @@ -0,0 +1,172 @@ +package httpsig + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/sha512" + "fmt" + "time" + + sfv "github.com/dunglas/httpsfv" +) + +// derived component names +type derived string + +const ( + sigparams derived = "@signature-params" + method derived = "@method" + path derived = "@path" + targetURI derived = "@target-uri" + authority derived = "@authority" +) + +// MetadataProvider allows customized functions for metadata parameter values. Not needed for default usage. +type MetadataProvider interface { + Created() (int, error) + Expires() (int, error) + Nonce() (string, error) + Alg() (string, error) + KeyID() (string, error) + Tag() (string, error) +} + +type signatureBase struct { + base []byte // The full signature base. Use this as input to signing and verification + signatureInput string // The signature-input line +} + +type sigParameters struct { + Base sigBaseInput + Algo Algorithm + Label string + PrivateKey crypto.PrivateKey + Secret []byte +} + +func sign(hrr httpMessage, sp sigParameters) error { + base, err := calculateSignatureBase(hrr, sp.Base) + if err != nil { + return err + } + + var sigBytes []byte + switch sp.Algo { + + case Algo_RSA_PSS_SHA512: + msgHash := sha512.Sum512(base.base) + opts := &rsa.PSSOptions{ + SaltLength: 64, + Hash: crypto.SHA512, + } + switch rsapk := sp.PrivateKey.(type) { + case *rsa.PrivateKey: + sigBytes, err = rsa.SignPSS(rand.Reader, rsapk, crypto.SHA512, msgHash[:], opts) + if err != nil { + return err + } + case crypto.Signer: + sigBytes, err = rsapk.Sign(rand.Reader, msgHash[:], crypto.SHA512) + if err != nil { + return err + } + default: + return fmt.Errorf("Invalid private key. Requires *rsa.PrivateKey or crypto.Signer: %T", sp.PrivateKey) + } + case Algo_RSA_v1_5_sha256: + msgHash := sha256.Sum256(base.base) + switch rsapk := sp.PrivateKey.(type) { + case *rsa.PrivateKey: + sigBytes, err = rsa.SignPKCS1v15(rand.Reader, rsapk, crypto.SHA256, msgHash[:]) + if err != nil { + return err + } + case crypto.Signer: + sigBytes, err = rsapk.Sign(rand.Reader, msgHash[:], crypto.SHA256) + if err != nil { + return err + } + default: + return fmt.Errorf("Invalid private key. Requires *rsa.PrivateKey or crypto.Signer: %T", sp.PrivateKey) + } + case Algo_ECDSA_P256_SHA256: + msgHash := sha256.Sum256(base.base) + switch eccpk := sp.PrivateKey.(type) { + case *ecdsa.PrivateKey: + r, s, err := ecdsa.Sign(rand.Reader, eccpk, msgHash[:]) + if err != nil { + return newError(ErrInternal, "Failed to sign with ecdsa private key", err) + } + // Concatenate r and s to make the signature as per the spec. r and s are *not* encoded in ASN1 format + sigBytes = make([]byte, 64) + r.FillBytes(sigBytes[0:32]) + s.FillBytes(sigBytes[32:64]) + case crypto.Signer: + sigBytes, err = eccpk.Sign(rand.Reader, msgHash[:], crypto.SHA256) + if err != nil { + return newError(ErrInternal, "Failed to sign with ecdsa custom signer", err) + } + default: + return fmt.Errorf("Invalid private key. Requires *ecdsa.PrivateKey or crypto.Signer: %T", sp.PrivateKey) + } + case Algo_ECDSA_P384_SHA384: + msgHash := sha512.Sum384(base.base) + switch eccpk := sp.PrivateKey.(type) { + case *ecdsa.PrivateKey: + r, s, err := ecdsa.Sign(rand.Reader, eccpk, msgHash[:]) + if err != nil { + return newError(ErrInternal, "Failed to sign with ecdsa private key", err) + } + // Concatenate r and s to make the signature as per the spec. r and s are *not* encoded in ASN1 format + sigBytes = make([]byte, 96) + r.FillBytes(sigBytes[0:48]) + s.FillBytes(sigBytes[48:96]) + case crypto.Signer: + sigBytes, err = eccpk.Sign(rand.Reader, msgHash[:], crypto.SHA384) + if err != nil { + return newError(ErrInternal, "Failed to sign with ecdsa custom signer", err) + } + default: + return fmt.Errorf("Invalid private key. Requires *ecdsa.PrivateKey or crypto.Signer: %T", sp.PrivateKey) + } + case Algo_ED25519: + switch edpk := sp.PrivateKey.(type) { + case ed25519.PrivateKey: + sigBytes = ed25519.Sign(edpk, base.base) + case crypto.Signer: + sigBytes, err = edpk.Sign(rand.Reader, base.base, crypto.Hash(0)) + if err != nil { + return newError(ErrInternal, "Failed to sign with ed25519 custom signer", err) + } + default: + return fmt.Errorf("Invalid private key. Requires ed25519.PrivateKey or crypto.Signer: %T", sp.PrivateKey) + } + case Algo_HMAC_SHA256: + if len(sp.Secret) == 0 { + return newError(ErrInvalidSignatureOptions, fmt.Sprintf("No secret provided for symmetric algorithm '%s'", Algo_HMAC_SHA256)) + } + msgHash := hmac.New(sha256.New, sp.Secret) + msgHash.Write(base.base) // write does not return an error per hash.Hash documentation + sigBytes = msgHash.Sum(nil) + default: + return newError(ErrInvalidSignatureOptions, fmt.Sprintf("Signing algorithm not supported: '%s'", sp.Algo)) + } + sigField := sfv.NewDictionary() + sigField.Add(sp.Label, sfv.NewItem(sigBytes)) + signature, err := sfv.Marshal(sigField) + if err != nil { + return newError(ErrInternal, fmt.Sprintf("Failed to marshal signature for label '%s'", sp.Label), err) + } + hrr.Headers().Set("Signature-Input", fmt.Sprintf("%s=%s", sp.Label, base.signatureInput)) + hrr.Headers().Set("Signature", signature) + return nil +} + +func timestamp(nowtime func() time.Time) int { + return int(nowtime().Unix()) +} diff --git a/third_party/httpsig-go/sigtest/helpers.go b/third_party/httpsig-go/sigtest/helpers.go new file mode 100644 index 0000000000..1beda295da --- /dev/null +++ b/third_party/httpsig-go/sigtest/helpers.go @@ -0,0 +1,85 @@ +package sigtest + +import ( + "bufio" + "bytes" + "crypto" + "encoding/base64" + "fmt" + "io" + "net/http" + "os" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/remitly-oss/httpsig-go/keyutil" +) + +func ReadRequest(t testing.TB, reqFile string) *http.Request { + reqtxt, err := os.Open(fmt.Sprintf("testdata/%s", reqFile)) + if err != nil { + t.Fatal(err) + } + + req, err := http.ReadRequest(bufio.NewReader(reqtxt)) + if err != nil { + t.Fatal(err) + } + return req +} + +func MustReadFile(file string) []byte { + data, err := os.ReadFile(file) + if err != nil { + panic(err) + } + return data +} + +func MakeBody(body string) io.ReadCloser { + return io.NopCloser(bytes.NewReader([]byte(body))) +} + +func ReadSharedSecret(t *testing.T, sharedSecretFile string) []byte { + secretBytes, err := os.ReadFile(fmt.Sprintf("testdata/%s", sharedSecretFile)) + if err != nil { + t.Fatal(err) + } + secret, err := base64.StdEncoding.DecodeString(string(secretBytes)) + if err != nil { + t.Fatal(err) + } + return secret +} + +func ReadTestPubkey(t *testing.T, pubkeyFile string) crypto.PublicKey { + keybytes, err := os.ReadFile(fmt.Sprintf("testdata/%s", pubkeyFile)) + if err != nil { + t.Fatal(err) + } + pubkey, err := keyutil.ReadPublicKey(keybytes) + if err != nil { + t.Fatal(err) + } + return pubkey +} + +func ReadTestPrivateKey(t testing.TB, pkFile string, hint ...string) crypto.PrivateKey { + keybytes, err := os.ReadFile(fmt.Sprintf("testdata/%s", pkFile)) + if err != nil { + t.Fatal(err) + } + pkey, err := keyutil.ReadPrivateKey(keybytes) + if err != nil { + t.Fatal(err) + } + return pkey +} + +func Diff(t *testing.T, expected, actual any, msg string, opts ...cmp.Option) bool { + if diff := cmp.Diff(expected, actual, opts...); diff != "" { + t.Errorf("%s (-want +got):\n%s", msg, diff) + return true + } + return false +} diff --git a/third_party/httpsig-go/spec_test.go b/third_party/httpsig-go/spec_test.go new file mode 100644 index 0000000000..788d4d6e72 --- /dev/null +++ b/third_party/httpsig-go/spec_test.go @@ -0,0 +1,385 @@ +package httpsig + +import ( + "bufio" + "context" + "fmt" + "net/http" + "os" + "testing" + + "github.com/remitly-oss/httpsig-go/sigtest" +) + +/* + Test cases from https://www.rfc-editor.org/rfc/rfc9421.pdf +*/ + +// TestSpecVerify ensures that requests from spec can be verified +func TestSpecVerify(t *testing.T) { + cases := []struct { + Name string + IsResponse bool + Key KeySpec + SignedRequestOrResonseFile string + Skip bool + }{ + { + Name: "b21", + Key: KeySpec{ + KeyID: "test-key-rsa-pss", + Algo: Algo_RSA_PSS_SHA512, + PubKey: sigtest.ReadTestPubkey(t, "test-key-rsa-pss.pub"), + }, + SignedRequestOrResonseFile: "b21_request_signed.txt", + }, + { + Name: "b22", + Key: KeySpec{ + KeyID: "test-key-rsa-pss", + Algo: Algo_RSA_PSS_SHA512, + PubKey: sigtest.ReadTestPubkey(t, "test-key-rsa-pss.pub"), + }, + SignedRequestOrResonseFile: "b22_request_signed.txt", + }, + { + Name: "b23", + Key: KeySpec{ + KeyID: "test-key-rsa-pss", + Algo: Algo_RSA_PSS_SHA512, + PubKey: sigtest.ReadTestPubkey(t, "test-key-rsa-pss.pub"), + }, + SignedRequestOrResonseFile: "b23_request_signed.txt", + }, + { + Name: "b24", + IsResponse: true, + Key: KeySpec{ + KeyID: "test-key-ecc-p256", + Algo: Algo_ECDSA_P256_SHA256, + PubKey: sigtest.ReadTestPubkey(t, "test-key-ecc-p256.pub"), + }, + SignedRequestOrResonseFile: "b24_response_signed.txt", + }, + { + Name: "b25", + Key: KeySpec{ + KeyID: "test-shared-secret", + Algo: Algo_HMAC_SHA256, + Secret: sigtest.ReadSharedSecret(t, "test-shared-secret"), + }, + SignedRequestOrResonseFile: "b25_request_signed.txt", + }, + { + Name: "b26", + Key: KeySpec{ + KeyID: "test-key-ed25519", + Algo: Algo_ED25519, + PubKey: sigtest.ReadTestPubkey(t, "test-key-ed25519.pub"), + }, + SignedRequestOrResonseFile: "b26_request_signed.txt", + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + if tc.Skip { + t.Skip(fmt.Sprintf("Skipping test %s", tc.Name)) + } + + hrrtxt, err := os.Open(fmt.Sprintf("testdata/%s", tc.SignedRequestOrResonseFile)) + if err != nil { + t.Fatal(err) + } + + ver, err := NewVerifier(&fixedKeyFetch{ + requiredKeyID: tc.Key.KeyID, + key: tc.Key, + }, VerifyProfile{ + SignatureLabel: fmt.Sprintf("sig-%s", tc.Name), + }) + + var verifyErr error + if tc.IsResponse { + resp, err := http.ReadResponse(bufio.NewReader(hrrtxt), nil) + if err != nil { + t.Fatal(err) + } + _, verifyErr = ver.VerifyResponse(resp) + } else { + req, err := http.ReadRequest(bufio.NewReader(hrrtxt)) + if err != nil { + t.Fatal(err) + } + _, verifyErr = ver.Verify(req) + } + + if verifyErr != nil { + t.Fatalf("%#v\n", verifyErr) + } + }) + } +} + +// TestSpecBase test recreation of the signature bases from the spec +func TestSpecBase(t *testing.T) { + cases := []testcaseSigBase{ + { + Name: "b21", + Params: sigBaseInput{ + Components: []componentID{}, + MetadataParams: []Metadata{ + MetaCreated, + MetaKeyID, + MetaNonce, + }, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(1618884473), + MetaKeyID: "test-key-rsa-pss", + MetaNonce: "b3k2pp5k7z-50gnwp.yemd", + }, + }, + }, + ExpectedFile: "b21_request_sigbase.txt", + }, + { + Name: "b22", + Params: sigBaseInput{ + Components: makeComponentIDs( + SignedField{ + Name: "@authority", + }, + SignedField{ + Name: "content-digest", + }, + SignedField{ + Name: "@query-param", + Parameters: map[string]any{ + "name": "Pet", + }, + }, + ), + MetadataParams: []Metadata{ + MetaCreated, + MetaKeyID, + MetaTag, + }, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(1618884473), + MetaKeyID: "test-key-rsa-pss", + MetaTag: "header-example", + }, + }, + }, + ExpectedFile: "b22_request_sigbase.txt", + }, + { + Name: "b23", + Params: sigBaseInput{ + Components: makeComponentIDs( + SignedField{ + Name: "date", + }, + SignedField{ + Name: "@method", + }, + SignedField{ + Name: "@path", + }, + SignedField{ + Name: "@query", + }, + SignedField{ + Name: "@authority", + }, + SignedField{ + Name: "content-type", + }, + SignedField{ + Name: "content-digest", + }, + SignedField{ + Name: "content-length", + }, + ), + MetadataParams: []Metadata{ + MetaCreated, + MetaKeyID, + }, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(1618884473), + MetaKeyID: "test-key-rsa-pss", + }, + }, + }, + ExpectedFile: "b23_request_sigbase.txt", + }, + { + Name: "b24", + IsResponse: true, + Params: sigBaseInput{ + Components: componentsIDs(Fields("@status", "content-type", "content-digest", "content-length")), + MetadataParams: []Metadata{ + MetaCreated, + MetaKeyID, + }, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(1618884473), + MetaKeyID: "test-key-ecc-p256", + }, + }, + }, + ExpectedFile: "b24_response_sigbase.txt", + }, + { + Name: "b25", + Params: sigBaseInput{ + Components: componentsIDs(Fields("date", "@authority", "content-type")), + MetadataParams: []Metadata{ + MetaCreated, + MetaKeyID, + }, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(1618884473), + MetaKeyID: "test-shared-secret", + }, + }, + }, + ExpectedFile: "b25_request_sigbase.txt", + }, + { + Name: "b26", + Params: sigBaseInput{ + Components: componentsIDs(Fields("date", "@method", "@path", "@authority", "content-type", "content-length")), + MetadataParams: []Metadata{ + MetaCreated, + MetaKeyID, + }, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(1618884473), + MetaKeyID: "test-key-ed25519", + }, + }, + }, + ExpectedFile: "b26_request_sigbase.txt", + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + runTestSigBase(t, tc) + }) + } +} + +type fixedKeyFetch struct { + requiredKeyID string // if not empty, Fetch will check if the input keyID matches + key KeySpec +} + +func (kf fixedKeyFetch) FetchByKeyID(ctx context.Context, rh http.Header, keyID string) (KeySpecer, error) { + if kf.requiredKeyID != "" && keyID != kf.requiredKeyID { + return nil, &KeyError{ + error: fmt.Errorf("Invalid key id. Wanted '%s' got '%s'", kf.requiredKeyID, keyID), + } + } + return kf.key, nil +} + +func (kf fixedKeyFetch) Fetch(ctx context.Context, rh http.Header, md MetadataProvider) (KeySpecer, error) { + return nil, fmt.Errorf("Fetch without a key id not supported.") +} + +// TestSpecRecreateSignature recreates the signature in the test cases. +// Algorithms that include randomness in the signing (each signature is unique) cannot be tested in this way. +func TestSpecRecreateSignature(t *testing.T) { + cases := []struct { + Name string + Params sigParameters + ExpectedFile string + }{ + { + Name: "b25", + Params: sigParameters{ + Base: sigBaseInput{ + Components: componentsIDs(Fields("date", "@authority", "content-type")), + MetadataParams: []Metadata{ + MetaCreated, + MetaKeyID, + }, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(1618884473), + MetaKeyID: "test-shared-secret", + }, + }, + }, + Algo: Algo_HMAC_SHA256, + Label: "sig-b25", + Secret: sigtest.ReadSharedSecret(t, "test-shared-secret"), + }, + + ExpectedFile: "b25_request_signed.txt", + }, + { + Name: "b26", + Params: sigParameters{ + Base: sigBaseInput{ + Components: componentsIDs(Fields("date", "@method", "@path", "@authority", "content-type", "content-length")), + MetadataParams: []Metadata{ + MetaCreated, + MetaKeyID, + }, + MetadataValues: fixedMetadataProvider{ + values: map[Metadata]any{ + MetaCreated: int64(1618884473), + MetaKeyID: "test-key-ed25519", + }, + }, + }, + + Algo: Algo_ED25519, + Label: "sig-b26", + PrivateKey: sigtest.ReadTestPrivateKey(t, "test-key-ed25519.key"), + }, + ExpectedFile: "b26_request_signed.txt", + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + reqtxt, err := os.Open("testdata/rfc-test-request.txt") + if err != nil { + t.Fatal(err) + } + + req, err := http.ReadRequest(bufio.NewReader(reqtxt)) + if err != nil { + t.Fatal(err) + } + + err = sign(httpMessage{ + Req: req, + }, tc.Params) + if err != nil { + t.Fatalf("%#v\n", err) + } + expectedtxt, err := os.Open(fmt.Sprintf("testdata/%s", tc.ExpectedFile)) + if err != nil { + t.Fatal(err) + } + + expected, err := http.ReadRequest(bufio.NewReader(expectedtxt)) + if err != nil { + t.Fatal(err) + } + sigtest.Diff(t, expected.Header, req.Header, "") + }) + } +} diff --git a/third_party/httpsig-go/testdata/b21_request_sigbase.txt b/third_party/httpsig-go/testdata/b21_request_sigbase.txt new file mode 100644 index 0000000000..4e60f3ed74 --- /dev/null +++ b/third_party/httpsig-go/testdata/b21_request_sigbase.txt @@ -0,0 +1 @@ +"@signature-params": ();created=1618884473;keyid="test-key-rsa-pss";nonce="b3k2pp5k7z-50gnwp.yemd" \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/b21_request_signed.txt b/third_party/httpsig-go/testdata/b21_request_signed.txt new file mode 100644 index 0000000000..ccc1bdb590 --- /dev/null +++ b/third_party/httpsig-go/testdata/b21_request_signed.txt @@ -0,0 +1,10 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +Content-Type: application/json +Content-Digest: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +Content-Length: 18 +Signature-Input: sig-b21=();created=1618884473;keyid="test-key-rsa-pss";nonce="b3k2pp5k7z-50gnwp.yemd" +Signature: sig-b21=:d2pmTvmbncD3xQm8E9ZV2828BjQWGgiwAaw5bAkgibUopemLJcWDy/lkbbHAve4cRAtx31Iq786U7it++wgGxbtRxf8Udx7zFZsckzXaJMkA7ChG52eSkFxykJeNqsrWH5S+oxNFlD4dzVuwe8DhTSja8xxbR/Z2cOGdCbzR72rgFWhzx2VjBqJzsPLMIQKhO4DGezXehhWwE56YCE+O6c0mKZsfxVrogUvA4HELjVKWmAvtl6UnCh8jYzuVG5WSb/QEVPnP5TmcAnLH1g+s++v6d4s8m0gCw1fV5/SITLq9mhho8K3+7EPYTU8IU1bLhdxO5Nyt8C8ssinQ98Xw9Q==: + +{"hello": "world"} diff --git a/third_party/httpsig-go/testdata/b22_request_sigbase.txt b/third_party/httpsig-go/testdata/b22_request_sigbase.txt new file mode 100644 index 0000000000..b4f1935d55 --- /dev/null +++ b/third_party/httpsig-go/testdata/b22_request_sigbase.txt @@ -0,0 +1,4 @@ +"@authority": example.com +"content-digest": sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +"@query-param";name="Pet": dog +"@signature-params": ("@authority" "content-digest" "@query-param";name="Pet");created=1618884473;keyid="test-key-rsa-pss";tag="header-example" \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/b22_request_signed.txt b/third_party/httpsig-go/testdata/b22_request_signed.txt new file mode 100644 index 0000000000..8ddd3c54b8 --- /dev/null +++ b/third_party/httpsig-go/testdata/b22_request_signed.txt @@ -0,0 +1,10 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +Content-Type: application/json +Content-Digest: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +Content-Length: 18 +Signature-Input: sig-b22=("@authority" "content-digest" "@query-param";name="Pet");created=1618884473;keyid="test-key-rsa-pss";tag="header-example" +Signature: sig-b22=:LjbtqUbfmvjj5C5kr1Ugj4PmLYvx9wVjZvD9GsTT4F7GrcQEdJzgI9qHxICagShLRiLMlAJjtq6N4CDfKtjvuJyE5qH7KT8UCMkSowOB4+ECxCmT8rtAmj/0PIXxi0A0nxKyB09RNrCQibbUjsLS/2YyFYXEu4TRJQzRw1rLEuEfY17SARYhpTlaqwZVtR8NV7+4UKkjqpcAoFqWFQh62s7Cl+H2fjBSpqfZUJcsIk4N6wiKYd4je2U/lankenQ99PZfB4jY3I5rSV2DSBVkSFsURIjYErOs0tFTQosMTAoxk//0RoKUqiYY8Bh0aaUEb0rQl3/XaVe4bXTugEjHSw==: + +{"hello": "world"} diff --git a/third_party/httpsig-go/testdata/b23_request_sigbase.txt b/third_party/httpsig-go/testdata/b23_request_sigbase.txt new file mode 100644 index 0000000000..fa2f73beca --- /dev/null +++ b/third_party/httpsig-go/testdata/b23_request_sigbase.txt @@ -0,0 +1,9 @@ +"date": Tue, 20 Apr 2021 02:07:55 GMT +"@method": POST +"@path": /foo +"@query": ?param=Value&Pet=dog +"@authority": example.com +"content-type": application/json +"content-digest": sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +"content-length": 18 +"@signature-params": ("date" "@method" "@path" "@query" "@authority" "content-type" "content-digest" "content-length");created=1618884473;keyid="test-key-rsa-pss" \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/b23_request_signed.txt b/third_party/httpsig-go/testdata/b23_request_signed.txt new file mode 100644 index 0000000000..ee53a21fdd --- /dev/null +++ b/third_party/httpsig-go/testdata/b23_request_signed.txt @@ -0,0 +1,10 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +Content-Type: application/json +Content-Digest: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +Content-Length: 18 +Signature-Input: sig-b23=("date" "@method" "@path" "@query" "@authority" "content-type" "content-digest" "content-length");created=1618884473;keyid="test-key-rsa-pss" +Signature: sig-b23=:bbN8oArOxYoyylQQUU6QYwrTuaxLwjAC9fbY2F6SVWvh0yBiMIRGOnMYwZ/5MR6fb0Kh1rIRASVxFkeGt683+qRpRRU5p2voTp768ZrCUb38K0fUxN0O0iC59DzYx8DFll5GmydPxSmme9v6ULbMFkl+V5B1TP/yPViV7KsLNmvKiLJH1pFkh/aYA2HXXZzNBXmIkoQoLd7YfW91kE9o/CCoC1xMy7JA1ipwvKvfrs65ldmlu9bpG6A9BmzhuzF8Eim5f8ui9eH8LZH896+QIF61ka39VBrohr9iyMUJpvRX2Zbhl5ZJzSRxpJyoEZAFL2FUo5fTIztsDZKEgM4cUA==: + +{"hello": "world"} diff --git a/third_party/httpsig-go/testdata/b24_response_sigbase.txt b/third_party/httpsig-go/testdata/b24_response_sigbase.txt new file mode 100644 index 0000000000..172719d614 --- /dev/null +++ b/third_party/httpsig-go/testdata/b24_response_sigbase.txt @@ -0,0 +1,5 @@ +"@status": 200 +"content-type": application/json +"content-digest": sha-512=:mEWXIS7MaLRuGgxOBdODa3xqM1XdEvxoYhvlCFJ41QJgJc4GTsPp29l5oGX69wWdXymyU0rjJuahq4l5aGgfLQ==: +"content-length": 23 +"@signature-params": ("@status" "content-type" "content-digest" "content-length");created=1618884473;keyid="test-key-ecc-p256" \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/b24_response_signed.txt b/third_party/httpsig-go/testdata/b24_response_signed.txt new file mode 100644 index 0000000000..ce04573499 --- /dev/null +++ b/third_party/httpsig-go/testdata/b24_response_signed.txt @@ -0,0 +1,9 @@ +HTTP/1.1 200 OK +Date: Tue, 20 Apr 2021 02:07:56 GMT +Content-Type: application/json +Content-Digest: sha-512=:mEWXIS7MaLRuGgxOBdODa3xqM1XdEvxoYhvlCFJ41QJgJc4GTsPp29l5oGX69wWdXymyU0rjJuahq4l5aGgfLQ==: +Content-Length: 23 +Signature-Input: sig-b24=("@status" "content-type" "content-digest" "content-length");created=1618884473;keyid="test-key-ecc-p256" +Signature: sig-b24=:wNmSUAhwb5LxtOtOpNa6W5xj067m5hFrj0XQ4fvpaCLx0NKocgPquLgyahnzDnDAUy5eCdlYUEkLIj+32oiasw==: + +{"message": "good dog"} diff --git a/third_party/httpsig-go/testdata/b25_request_sigbase.txt b/third_party/httpsig-go/testdata/b25_request_sigbase.txt new file mode 100644 index 0000000000..ed7f8eb88b --- /dev/null +++ b/third_party/httpsig-go/testdata/b25_request_sigbase.txt @@ -0,0 +1,4 @@ +"date": Tue, 20 Apr 2021 02:07:55 GMT +"@authority": example.com +"content-type": application/json +"@signature-params": ("date" "@authority" "content-type");created=1618884473;keyid="test-shared-secret" \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/b25_request_signed.txt b/third_party/httpsig-go/testdata/b25_request_signed.txt new file mode 100644 index 0000000000..680d75578e --- /dev/null +++ b/third_party/httpsig-go/testdata/b25_request_signed.txt @@ -0,0 +1,10 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +Content-Type: application/json +Content-Digest: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +Content-Length: 18 +Signature-Input: sig-b25=("date" "@authority" "content-type");created=1618884473;keyid="test-shared-secret" +Signature: sig-b25=:pxcQw6G3AjtMBQjwo8XzkZf/bws5LelbaMk5rGIGtE8=: + +{"hello": "world"} diff --git a/third_party/httpsig-go/testdata/b26_request_sigbase.txt b/third_party/httpsig-go/testdata/b26_request_sigbase.txt new file mode 100644 index 0000000000..7a727fc446 --- /dev/null +++ b/third_party/httpsig-go/testdata/b26_request_sigbase.txt @@ -0,0 +1,7 @@ +"date": Tue, 20 Apr 2021 02:07:55 GMT +"@method": POST +"@path": /foo +"@authority": example.com +"content-type": application/json +"content-length": 18 +"@signature-params": ("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519" \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/b26_request_signed.txt b/third_party/httpsig-go/testdata/b26_request_signed.txt new file mode 100644 index 0000000000..7ff0f2ae65 --- /dev/null +++ b/third_party/httpsig-go/testdata/b26_request_signed.txt @@ -0,0 +1,10 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +Content-Type: application/json +Content-Digest: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +Content-Length: 18 +Signature-Input: sig-b26=("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519" +Signature: sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==: + +{"hello": "world"} diff --git a/third_party/httpsig-go/testdata/fuzz/FuzzExtractSignatures/00e15d22123489fd b/third_party/httpsig-go/testdata/fuzz/FuzzExtractSignatures/00e15d22123489fd new file mode 100644 index 0000000000..b0e98fbe10 --- /dev/null +++ b/third_party/httpsig-go/testdata/fuzz/FuzzExtractSignatures/00e15d22123489fd @@ -0,0 +1,3 @@ +go test fuzz v1 +string("0") +string("0") diff --git a/third_party/httpsig-go/testdata/fuzz/FuzzExtractSignatures/85649d45641911d0 b/third_party/httpsig-go/testdata/fuzz/FuzzExtractSignatures/85649d45641911d0 new file mode 100644 index 0000000000..befd7df0d5 --- /dev/null +++ b/third_party/httpsig-go/testdata/fuzz/FuzzExtractSignatures/85649d45641911d0 @@ -0,0 +1,3 @@ +go test fuzz v1 +string("0") +string("") diff --git a/third_party/httpsig-go/testdata/fuzz/FuzzSign/01c151dfb31ce1b1 b/third_party/httpsig-go/testdata/fuzz/FuzzSign/01c151dfb31ce1b1 new file mode 100644 index 0000000000..1c15e48278 --- /dev/null +++ b/third_party/httpsig-go/testdata/fuzz/FuzzSign/01c151dfb31ce1b1 @@ -0,0 +1,4 @@ +go test fuzz v1 +string("0") +string("\x0f") +string("\x7f") diff --git a/third_party/httpsig-go/testdata/fuzz/FuzzSign/2da5aa149625a656 b/third_party/httpsig-go/testdata/fuzz/FuzzSign/2da5aa149625a656 new file mode 100644 index 0000000000..87dae56e3f --- /dev/null +++ b/third_party/httpsig-go/testdata/fuzz/FuzzSign/2da5aa149625a656 @@ -0,0 +1,4 @@ +go test fuzz v1 +string("0") +string("0") +string("\x7f") diff --git a/third_party/httpsig-go/testdata/fuzz/FuzzSign/58358ab8a686cc26 b/third_party/httpsig-go/testdata/fuzz/FuzzSign/58358ab8a686cc26 new file mode 100644 index 0000000000..504227963c --- /dev/null +++ b/third_party/httpsig-go/testdata/fuzz/FuzzSign/58358ab8a686cc26 @@ -0,0 +1,4 @@ +go test fuzz v1 +string("\n") +string("0") +string("0") diff --git a/third_party/httpsig-go/testdata/fuzz/FuzzSign/ecf6af09b9da5a62 b/third_party/httpsig-go/testdata/fuzz/FuzzSign/ecf6af09b9da5a62 new file mode 100644 index 0000000000..be810eb00a --- /dev/null +++ b/third_party/httpsig-go/testdata/fuzz/FuzzSign/ecf6af09b9da5a62 @@ -0,0 +1,4 @@ +go test fuzz v1 +string("") +string("0") +string("@") diff --git a/third_party/httpsig-go/testdata/fuzz/FuzzSign/ff08bb6e0efa59be b/third_party/httpsig-go/testdata/fuzz/FuzzSign/ff08bb6e0efa59be new file mode 100644 index 0000000000..cc15c00ba9 --- /dev/null +++ b/third_party/httpsig-go/testdata/fuzz/FuzzSign/ff08bb6e0efa59be @@ -0,0 +1,4 @@ +go test fuzz v1 +string("") +string("0") +string("\xde") diff --git a/third_party/httpsig-go/testdata/key-rsa-v15.key b/third_party/httpsig-go/testdata/key-rsa-v15.key new file mode 100644 index 0000000000..20ca7b388e --- /dev/null +++ b/third_party/httpsig-go/testdata/key-rsa-v15.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAK+BnK7LFtj6kjM1 +bL7HtCQQpCXj1mZtCTATz4HZS9QD5eGiu7RWdSW+dd0poGmfOE3GS2fofAt3oJip +mV8tNW9uB4NYTwq5fFb0MmL34BDQBQo6obCOAkx6UG0h8y91Y+Dp9lcKwN++O8mR +w6bHiVyfATJm3hgSHnmgiKyL0yJFAgMBAAECgYAj2Dvw8yeabyq27L1mBZGEICX2 +Wx8p0jEjMZia849qINWtjLf7cAEDEXAvGFZb3Bn6wHocIb5b9TXGmDTr3GbiIWhH +glwfz/aFths6xTquHcLZJ1TEGkZqRYcKvxSN3PBUYdpq40D5hPKKjerpsvN5YPk2 +b/P4V+jrCNr6futBoQJBAOS3NJuxur5vHRVdUJ3T1Me6Jv+Rs6mtK8DdJ02ARzvo +fBntb6BYEjEoSanqdFkeGHer5YyHLXqquGWA86Ghsy0CQQDEcW9pIxNZlaNbV8yq +2JaHFCalYoH8+dRbGKYqJW3izL/Kffp9jpmbTf8B7VzugHOO43lk12X+f1idxuD8 +Snp5AkBCkbxjMKi88tRROpbTSSuecmUVb9AOK9QXT4c3/IU/P5yXY09hKSEqY6KF +LTNuGN9gPY0TiOjI0lXXXWAMBGeFAkEAiQTWQO9GP+Yv2zaSe1g3JmDX0+Ox51Ia +3K+Et0EENH28CPF2Fr2wRrNQe3ekqnbOI4xmz/+uFKWeme5uX4tTgQJBAMJZY53Q +kIO53yMCvxth9HY6yAXMhbDTVyW7CW6yeyscM43HtQdlNtWPYwn4XuEzxyXHJBYb +l0097RDjZs8KCHw= +-----END PRIVATE KEY----- diff --git a/third_party/httpsig-go/testdata/key-rsa-v15.pub b/third_party/httpsig-go/testdata/key-rsa-v15.pub new file mode 100644 index 0000000000..c80080ea44 --- /dev/null +++ b/third_party/httpsig-go/testdata/key-rsa-v15.pub @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCvgZyuyxbY+pIzNWy+x7QkEKQl +49ZmbQkwE8+B2UvUA+Xhoru0VnUlvnXdKaBpnzhNxktn6HwLd6CYqZlfLTVvbgeD +WE8KuXxW9DJi9+AQ0AUKOqGwjgJMelBtIfMvdWPg6fZXCsDfvjvJkcOmx4lcnwEy +Zt4YEh55oIisi9MiRQIDAQAB +-----END PUBLIC KEY----- diff --git a/third_party/httpsig-go/testdata/request1.txt b/third_party/httpsig-go/testdata/request1.txt new file mode 100644 index 0000000000..d7db85f41a --- /dev/null +++ b/third_party/httpsig-go/testdata/request1.txt @@ -0,0 +1,8 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +Content-Type: application/json +Content-Digest: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +Content-Length: 18 + +{"hello": "world"} diff --git a/third_party/httpsig-go/testdata/request_bad_digest.txt b/third_party/httpsig-go/testdata/request_bad_digest.txt new file mode 100644 index 0000000000..bbf1d7bf57 --- /dev/null +++ b/third_party/httpsig-go/testdata/request_bad_digest.txt @@ -0,0 +1,8 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +Content-Type: application/json +Content-Digest: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +Content-Length: 18 + +{"hello": "bad digest world"} diff --git a/third_party/httpsig-go/testdata/request_multivalue.txt b/third_party/httpsig-go/testdata/request_multivalue.txt new file mode 100644 index 0000000000..2312380851 --- /dev/null +++ b/third_party/httpsig-go/testdata/request_multivalue.txt @@ -0,0 +1,9 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +One: multivalue1 +One: multivalue2 +Content-Type: application/json +Content-Length: 18 + +{"hello": "world"} diff --git a/third_party/httpsig-go/testdata/request_no_digest.txt b/third_party/httpsig-go/testdata/request_no_digest.txt new file mode 100644 index 0000000000..9150dfe018 --- /dev/null +++ b/third_party/httpsig-go/testdata/request_no_digest.txt @@ -0,0 +1,7 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +Content-Type: application/json +Content-Length: 18 + +{"hello": "world"} diff --git a/third_party/httpsig-go/testdata/request_repeated_components.txt b/third_party/httpsig-go/testdata/request_repeated_components.txt new file mode 100644 index 0000000000..6071d40d6a --- /dev/null +++ b/third_party/httpsig-go/testdata/request_repeated_components.txt @@ -0,0 +1,10 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +One: value1 +Two: value2 +Three: value2 +Content-Type: application/json +Content-Length: 18 + +{"hello": "world"} diff --git a/third_party/httpsig-go/testdata/rfc-test-request.txt b/third_party/httpsig-go/testdata/rfc-test-request.txt new file mode 100644 index 0000000000..d7db85f41a --- /dev/null +++ b/third_party/httpsig-go/testdata/rfc-test-request.txt @@ -0,0 +1,8 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +Content-Type: application/json +Content-Digest: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +Content-Length: 18 + +{"hello": "world"} diff --git a/third_party/httpsig-go/testdata/rfc-test-response.txt b/third_party/httpsig-go/testdata/rfc-test-response.txt new file mode 100644 index 0000000000..d118949ef2 --- /dev/null +++ b/third_party/httpsig-go/testdata/rfc-test-response.txt @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +Date: Tue, 20 Apr 2021 02:07:56 GMT +Content-Type: application/json +Content-Digest: sha-512=:mEWXIS7MaLRuGgxOBdODa3xqM1XdEvxoYhvlCFJ41QJgJc4GTsPp29l5oGX69wWdXymyU0rjJuahq4l5aGgfLQ==: +Content-Length: 23 + +{"message": "good dog"} diff --git a/third_party/httpsig-go/testdata/test-key-ecc-p256.key b/third_party/httpsig-go/testdata/test-key-ecc-p256.key new file mode 100644 index 0000000000..7a74374f1a --- /dev/null +++ b/third_party/httpsig-go/testdata/test-key-ecc-p256.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIFKbhfNZfpDsW43+0+JjUr9K+bTeuxopu653+hBaXGA7oAoGCCqGSM49 +AwEHoUQDQgAEqIVYZVLCrPZHGHjP17CTW0/+D9Lfw0EkjqF7xB4FivAxzic30tMM +4GF+hR6Dxh71Z50VGGdldkkDXZCnTNnoXQ== +-----END EC PRIVATE KEY----- \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/test-key-ecc-p256.pub b/third_party/httpsig-go/testdata/test-key-ecc-p256.pub new file mode 100644 index 0000000000..d72cdc86b7 --- /dev/null +++ b/third_party/httpsig-go/testdata/test-key-ecc-p256.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqIVYZVLCrPZHGHjP17CTW0/+D9Lf +w0EkjqF7xB4FivAxzic30tMM4GF+hR6Dxh71Z50VGGdldkkDXZCnTNnoXQ== +-----END PUBLIC KEY----- diff --git a/third_party/httpsig-go/testdata/test-key-ecc-p384.key b/third_party/httpsig-go/testdata/test-key-ecc-p384.key new file mode 100644 index 0000000000..0057681102 --- /dev/null +++ b/third_party/httpsig-go/testdata/test-key-ecc-p384.key @@ -0,0 +1,6 @@ +-----BEGIN EC PRIVATE KEY----- +MIGkAgEBBDBlRoIHTHCg2hgrwD5cQMl1aGP8ZZ/ulSbHCDHHIC4ENlQIdjLDhOBG +JO2ZMKJK33WgBwYFK4EEACKhZANiAAS46EhdhNcOPg3ZKMhlryAFfy6eKt3M3f+w +31ikDCbu10GLZjvCoevMvSY+TQIL9EQlAHRxcJ5ciXt1ukiH2zo1NpR7S8ozmvCV +t/IvcUiS9XS1nRiHKkM1e8NMNtg3avo= +-----END EC PRIVATE KEY----- diff --git a/third_party/httpsig-go/testdata/test-key-ecc-p384.pub b/third_party/httpsig-go/testdata/test-key-ecc-p384.pub new file mode 100644 index 0000000000..56ce078f98 --- /dev/null +++ b/third_party/httpsig-go/testdata/test-key-ecc-p384.pub @@ -0,0 +1,5 @@ +-----BEGIN PUBLIC KEY----- +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEuOhIXYTXDj4N2SjIZa8gBX8unirdzN3/ +sN9YpAwm7tdBi2Y7wqHrzL0mPk0CC/REJQB0cXCeXIl7dbpIh9s6NTaUe0vKM5rw +lbfyL3FIkvV0tZ0YhypDNXvDTDbYN2r6 +-----END PUBLIC KEY----- diff --git a/third_party/httpsig-go/testdata/test-key-ed25519.key b/third_party/httpsig-go/testdata/test-key-ed25519.key new file mode 100644 index 0000000000..06abe8cf0f --- /dev/null +++ b/third_party/httpsig-go/testdata/test-key-ed25519.key @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/test-key-ed25519.pub b/third_party/httpsig-go/testdata/test-key-ed25519.pub new file mode 100644 index 0000000000..03c11eaf0c --- /dev/null +++ b/third_party/httpsig-go/testdata/test-key-ed25519.pub @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAJrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs= +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/test-key-rsa-pss.key b/third_party/httpsig-go/testdata/test-key-rsa-pss.key new file mode 100644 index 0000000000..09b0898251 --- /dev/null +++ b/third_party/httpsig-go/testdata/test-key-rsa-pss.key @@ -0,0 +1,4 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADALBgkqhkiG9w0BAQoEggSqMIIEpgIBAAKCAQEAr4tmm3r20Wd/Pbqv P1s2+QEtvpuRaV8Yq40gjUR8y2Rjxa6dpG2GXHbPfvMs8ct+Lh1GH45x28Rw3Ry5 3mm+oAXjyQ86OnDkZ5N8lYbggD4O3w6M6pAvLkhk95AndTrifbIFPNU8PPMO7Oyr FAHqgDsznjPFmTOtCEcN2Z1FpWgchwuYLPL+Wokqltd11nqqzi+bJ9cvSKADYdUA AN5WUtzdpiy6LbTgSxP7ociU4Tn0g5I6aDZJ7A8Lzo0KSyZYoA485mqcO0GVAdVw 9lq4aOT9v6d+nb4bnNkQVklLQ3fVAvJm+xdDOp9LCNCN48V2pnDOkFV6+U9nV5oy c6XI2wIDAQABAoIBAQCUB8ip+kJiiZVKF8AqfB/aUP0jTAqOQewK1kKJ/iQCXBCq pbo360gvdt05H5VZ/RDVkEgO2k73VSsbulqezKs8RFs2tEmU+JgTI9MeQJPWcP6X aKy6LIYs0E2cWgp8GADgoBs8llBq0UhX0KffglIeek3n7Z6Gt4YFge2TAcW2WbN4 XfK7lupFyo6HHyWRiYHMMARQXLJeOSdTn5aMBP0PO4bQyk5ORxTUSeOciPJUFktQ HkvGbym7KryEfwH8Tks0L7WhzyP60PL3xS9FNOJi9m+zztwYIXGDQuKM2GDsITeD 2mI2oHoPMyAD0wdI7BwSVW18p1h+jgfc4dlexKYRAoGBAOVfuiEiOchGghV5vn5N RDNscAFnpHj1QgMr6/UG05RTgmcLfVsI1I4bSkbrIuVKviGGf7atlkROALOG/xRx DLadgBEeNyHL5lz6ihQaFJLVQ0u3U4SB67J0YtVO3R6lXcIjBDHuY8SjYJ7Ci6Z6 vuDcoaEujnlrtUhaMxvSfcUJAoGBAMPsCHXte1uWNAqYad2WdLjPDlKtQJK1diCm rqmB2g8QE99hDOHItjDBEdpyFBKOIP+NpVtM2KLhRajjcL9Ph8jrID6XUqikQuVi 4J9FV2m42jXMuioTT13idAILanYg8D3idvy/3isDVkON0X3UAVKrgMEne0hJpkPL FYqgetvDAoGBAKLQ6JZMbSe0pPIJkSamQhsehgL5Rs51iX4m1z7+sYFAJfhvN3Q/ OGIHDRp6HjMUcxHpHw7U+S1TETxePwKLnLKj6hw8jnX2/nZRgWHzgVcY+sPsReRx NJVf+Cfh6yOtznfX00p+JWOXdSY8glSSHJwRAMog+hFGW1AYdt7w80XBAoGBAImR NUugqapgaEA8TrFxkJmngXYaAqpA0iYRA7kv3S4QavPBUGtFJHBNULzitydkNtVZ 3w6hgce0h9YThTo/nKc+OZDZbgfN9s7cQ75x0PQCAO4fx2P91Q+mDzDUVTeG30mE t2m3S0dGe47JiJxifV9P3wNBNrZGSIF3mrORBVNDAoGBAI0QKn2Iv7Sgo4T/XjND dl2kZTXqGAk8dOhpUiw/HdM3OGWbhHj2NdCzBliOmPyQtAr770GITWvbAI+IRYyF S7Fnk6ZVVVHsxjtaHy1uJGFlaZzKR4AGNaUTOJMs6NadzCmGPAxNQQOCqoUjn4XR +rOjr9w349JooGXhOxbu8nOxX +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/test-key-rsa-pss.pub b/third_party/httpsig-go/testdata/test-key-rsa-pss.pub new file mode 100644 index 0000000000..97024bc00d --- /dev/null +++ b/third_party/httpsig-go/testdata/test-key-rsa-pss.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr4tmm3r20Wd/PbqvP1s2 +QEtvpuRaV8Yq40gjUR8y2Rjxa6dpG2GXHbPfvMs8ct+Lh1GH45x28Rw3Ry53mm+ oAXjyQ86OnDkZ5N8lYbggD4O3w6M6pAvLkhk95AndTrifbIFPNU8PPMO7OyrFAHq gDsznjPFmTOtCEcN2Z1FpWgchwuYLPL+Wokqltd11nqqzi+bJ9cvSKADYdUAAN5W Utzdpiy6LbTgSxP7ociU4Tn0g5I6aDZJ7A8Lzo0KSyZYoA485mqcO0GVAdVw9lq4 aOT9v6d+nb4bnNkQVklLQ3fVAvJm+xdDOp9LCNCN48V2pnDOkFV6+U9nV5oyc6XI +2wIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/test-key-rsa.key b/third_party/httpsig-go/testdata/test-key-rsa.key new file mode 100644 index 0000000000..05d0c16868 --- /dev/null +++ b/third_party/httpsig-go/testdata/test-key-rsa.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEqAIBAAKCAQEAhAKYdtoeoy8zcAcR874L8cnZxKzAGwd7v36APp7Pv6Q2jdsP +BRrwWEBnez6d0UDKDwGbc6nxfEXAy5mbhgajzrw3MOEt8uA5txSKobBpKDeBLOsd +JKFqMGmXCQvEG7YemcxDTRPxAleIAgYYRjTSd/QBwVW9OwNFhekro3RtlinV0a75 +jfZgkne/YiktSvLG34lw2zqXBDTC5NHROUqGTlML4PlNZS5Ri2U4aCNx2rUPRcKI +lE0PuKxI4T+HIaFpv8+rdV6eUgOrB2xeI1dSFFn/nnv5OoZJEIB+VmuKn3DCUcCZ +SFlQPSXSfBDiUGhwOw76WuSSsf1D4b/vLoJ10wIDAQABAoIBAG/JZuSWdoVHbi56 +vjgCgkjg3lkO1KrO3nrdm6nrgA9P9qaPjxuKoWaKO1cBQlE1pSWp/cKncYgD5WxE +CpAnRUXG2pG4zdkzCYzAh1i+c34L6oZoHsirK6oNcEnHveydfzJL5934egm6p8DW ++m1RQ70yUt4uRc0YSor+q1LGJvGQHReF0WmJBZHrhz5e63Pq7lE0gIwuBqL8SMaA +yRXtK+JGxZpImTq+NHvEWWCu09SCq0r838ceQI55SvzmTkwqtC+8AT2zFviMZkKR +Qo6SPsrqItxZWRty2izawTF0Bf5S2VAx7O+6t3wBsQ1sLptoSgX3QblELY5asI0J +YFz7LJECgYkAsqeUJmqXE3LP8tYoIjMIAKiTm9o6psPlc8CrLI9CH0UbuaA2JCOM +cCNq8SyYbTqgnWlB9ZfcAm/cFpA8tYci9m5vYK8HNxQr+8FS3Qo8N9RJ8d0U5Csw +DzMYfRghAfUGwmlWj5hp1pQzAuhwbOXFtxKHVsMPhz1IBtF9Y8jvgqgYHLbmyiu1 +mwJ5AL0pYF0G7x81prlARURwHo0Yf52kEw1dxpx+JXER7hQRWQki5/NsUEtv+8RT +qn2m6qte5DXLyn83b1qRscSdnCCwKtKWUug5q2ZbwVOCJCtmRwmnP131lWRYfj67 +B/xJ1ZA6X3GEf4sNReNAtaucPEelgR2nsN0gKQKBiGoqHWbK1qYvBxX2X3kbPDkv +9C+celgZd2PW7aGYLCHq7nPbmfDV0yHcWjOhXZ8jRMjmANVR/eLQ2EfsRLdW69bn +f3ZD7JS1fwGnO3exGmHO3HZG+6AvberKYVYNHahNFEw5TsAcQWDLRpkGybBcxqZo +81YCqlqidwfeO5YtlO7etx1xLyqa2NsCeG9A86UjG+aeNnXEIDk1PDK+EuiThIUa +/2IxKzJKWl1BKr2d4xAfR0ZnEYuRrbeDQYgTImOlfW6/GuYIxKYgEKCFHFqJATAG +IxHrq1PDOiSwXd2GmVVYyEmhZnbcp8CxaEMQoevxAta0ssMK3w6UsDtvUvYvF22m +qQKBiD5GwESzsFPy3Ga0MvZpn3D6EJQLgsnrtUPZx+z2Ep2x0xc5orneB5fGyF1P +WtP+fG5Q6Dpdz3LRfm+KwBCWFKQjg7uTxcjerhBWEYPmEMKYwTJF5PBG9/ddvHLQ +EQeNC8fHGg4UXU8mhHnSBt3EA10qQJfRDs15M38eG2cYwB1PZpDHScDnDA0= +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/test-key-rsa.pub b/third_party/httpsig-go/testdata/test-key-rsa.pub new file mode 100644 index 0000000000..6be6e4c8a5 --- /dev/null +++ b/third_party/httpsig-go/testdata/test-key-rsa.pub @@ -0,0 +1,8 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEAhAKYdtoeoy8zcAcR874L8cnZxKzAGwd7v36APp7Pv6Q2jdsPBRrw +WEBnez6d0UDKDwGbc6nxfEXAy5mbhgajzrw3MOEt8uA5txSKobBpKDeBLOsdJKFq +MGmXCQvEG7YemcxDTRPxAleIAgYYRjTSd/QBwVW9OwNFhekro3RtlinV0a75jfZg +kne/YiktSvLG34lw2zqXBDTC5NHROUqGTlML4PlNZS5Ri2U4aCNx2rUPRcKIlE0P +uKxI4T+HIaFpv8+rdV6eUgOrB2xeI1dSFFn/nnv5OoZJEIB+VmuKn3DCUcCZSFlQ +PSXSfBDiUGhwOw76WuSSsf1D4b/vLoJ10wIDAQAB +-----END RSA PUBLIC KEY----- diff --git a/third_party/httpsig-go/testdata/test-shared-secret b/third_party/httpsig-go/testdata/test-shared-secret new file mode 100644 index 0000000000..62530dacdf --- /dev/null +++ b/third_party/httpsig-go/testdata/test-shared-secret @@ -0,0 +1 @@ +uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ== \ No newline at end of file diff --git a/third_party/httpsig-go/testdata/verify_request1.txt b/third_party/httpsig-go/testdata/verify_request1.txt new file mode 100644 index 0000000000..ccc1bdb590 --- /dev/null +++ b/third_party/httpsig-go/testdata/verify_request1.txt @@ -0,0 +1,10 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +Content-Type: application/json +Content-Digest: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +Content-Length: 18 +Signature-Input: sig-b21=();created=1618884473;keyid="test-key-rsa-pss";nonce="b3k2pp5k7z-50gnwp.yemd" +Signature: sig-b21=:d2pmTvmbncD3xQm8E9ZV2828BjQWGgiwAaw5bAkgibUopemLJcWDy/lkbbHAve4cRAtx31Iq786U7it++wgGxbtRxf8Udx7zFZsckzXaJMkA7ChG52eSkFxykJeNqsrWH5S+oxNFlD4dzVuwe8DhTSja8xxbR/Z2cOGdCbzR72rgFWhzx2VjBqJzsPLMIQKhO4DGezXehhWwE56YCE+O6c0mKZsfxVrogUvA4HELjVKWmAvtl6UnCh8jYzuVG5WSb/QEVPnP5TmcAnLH1g+s++v6d4s8m0gCw1fV5/SITLq9mhho8K3+7EPYTU8IU1bLhdxO5Nyt8C8ssinQ98Xw9Q==: + +{"hello": "world"} diff --git a/third_party/httpsig-go/testdata/verify_request2.txt b/third_party/httpsig-go/testdata/verify_request2.txt new file mode 100644 index 0000000000..62586b55d0 --- /dev/null +++ b/third_party/httpsig-go/testdata/verify_request2.txt @@ -0,0 +1,10 @@ +POST /foo?param=Value&Pet=dog HTTP/1.1 +Host: example.com +Date: Tue, 20 Apr 2021 02:07:55 GMT +Content-Type: application/json +Content-Digest: sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==: +Content-Length: 18 +Signature-Input: sig-b21=();created=1618884473;keyid="test-key-rsa-pss";nonce="b3k2pp5k7z-50gnwp.yemd",bad-sig=("@authority");created=1618884473;keyid="test-key-rsa-pss" +Signature: sig-b21=:d2pmTvmbncD3xQm8E9ZV2828BjQWGgiwAaw5bAkgibUopemLJcWDy/lkbbHAve4cRAtx31Iq786U7it++wgGxbtRxf8Udx7zFZsckzXaJMkA7ChG52eSkFxykJeNqsrWH5S+oxNFlD4dzVuwe8DhTSja8xxbR/Z2cOGdCbzR72rgFWhzx2VjBqJzsPLMIQKhO4DGezXehhWwE56YCE+O6c0mKZsfxVrogUvA4HELjVKWmAvtl6UnCh8jYzuVG5WSb/QEVPnP5TmcAnLH1g+s++v6d4s8m0gCw1fV5/SITLq9mhho8K3+7EPYTU8IU1bLhdxO5Nyt8C8ssinQ98Xw9Q==:,bad-sig=:pxcQw6G3AjtMBQjwo8XzkZf/bws5LelbaMk5rGIGtE8=: + +{"hello": "world"} diff --git a/third_party/httpsig-go/verify.go b/third_party/httpsig-go/verify.go new file mode 100644 index 0000000000..a38ee14024 --- /dev/null +++ b/third_party/httpsig-go/verify.go @@ -0,0 +1,476 @@ +package httpsig + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/hmac" + "crypto/rsa" + "crypto/sha256" + "crypto/sha512" + "fmt" + "math/big" + "net/http" + "slices" + "time" + + sfv "github.com/dunglas/httpsfv" +) + +var ( + DefaultVerifyProfile = VerifyProfile{ + SignatureLabel: DefaultSignatureLabel, + AllowedAlgorithms: []Algorithm{Algo_ECDSA_P256_SHA256, Algo_ECDSA_P384_SHA384, Algo_ED25519, Algo_HMAC_SHA256}, + RequiredFields: DefaultRequiredFields, + RequiredMetadata: []Metadata{MetaCreated, MetaKeyID}, + DisallowedMetadata: []Metadata{MetaAlgorithm}, // The algorithm should be looked up from the keyid not an explicit setting. + CreatedValidDuration: time.Minute * 5, // Signatures must have been created within within the last 5 minutes + DateFieldSkew: time.Minute, // If the created parameter is present, the Date header cannot be more than a minute off. + } + + // DefaultRequiredFields covers the request body with 'content-digest' the method and full URI. + // As per the specification Date is not covered in favor of using the 'created' metadata parameter. + DefaultRequiredFields = Fields("content-digest", "@method", "@target-uri") + + ctxKeyAddDebug = struct{}{} +) + +// KeySpec is the per-key information needed to verify a signature. +type KeySpec struct { + KeyID string + Algo Algorithm + PubKey crypto.PublicKey + Secret []byte // shared secret for symmetric algorithms +} + +// KeySpec implements KeySpecer +func (ks KeySpec) KeySpec() (KeySpec, error) { + return ks, nil +} + +// KeySpecer should be implemented by your key/credential store +type KeySpecer interface { + KeySpec() (KeySpec, error) +} + +type KeyErrorReason string +type KeyError struct { + error + Reason KeyErrorReason + Message string +} + +type KeyFetcher interface { + // FetchByKeyID looks up a KeySpec from the 'keyid' metadata parameter on the signature. + FetchByKeyID(ctx context.Context, rh http.Header, keyID string) (KeySpecer, error) + // Fetch looks up a KeySpec when the keyid is not in the signature. + Fetch(ctx context.Context, rh http.Header, md MetadataProvider) (KeySpecer, error) +} + +// VerifyProfile sets the parameters for a fully valid request or response. +// A valid signature is a relatively easy accomplishment. Did the signature include all the important parts of the request? Did it use a strong enough algorithm? Was it signed 41 days ago? There are choices to make about what constitutes a valid signed request or response beyond just a verified signature. +type VerifyProfile struct { + SignatureLabel string // Which signature this profile applies to. '*' applies to all + RequiredFields []SignedField + RequiredMetadata []Metadata + DisallowedMetadata []Metadata + AllowedAlgorithms []Algorithm // Which algorithms are allowed, either from keyid meta or in the KeySpec + + // Timing enforcement options + DisableTimeEnforcement bool // If true do no time enforcement on any fields + DisableExpirationEnforcement bool // If expiration is present default to enforce + CreatedValidDuration time.Duration // Duration allowed for between time.Now and the created time + ExpiredSkew time.Duration // Maximum duration allowed between time.Now and the expired time + DateFieldSkew time.Duration // Maximum duration allowed between Date field and created +} + +type VerifyResult struct { + Verified bool + Label string + KeySpecer KeySpecer + DebugInfo VerifyDebugInfo // Present if the verifier debug flag is set and the signature was valid. + MetadataProvider +} + +type VerifyDebugInfo struct { + SignatureBase string // The signature base derived from the request. +} + +type Verifier struct { + keys KeyFetcher + profile VerifyProfile +} + +// Verify validates the signatures in a request and ensured the signature meets the required profile. +func Verify(req *http.Request, kf KeyFetcher, profile VerifyProfile) (VerifyResult, error) { + ver, err := NewVerifier(kf, profile) + if err != nil { + return VerifyResult{}, err + } + return ver.Verify(req) +} + +func VerifyResponse(resp *http.Response, kf KeyFetcher, profile VerifyProfile) (VerifyResult, error) { + ver, err := NewVerifier(kf, profile) + if err != nil { + return VerifyResult{}, err + } + return ver.VerifyResponse(resp) +} + +func NewVerifier(kf KeyFetcher, profile VerifyProfile) (*Verifier, error) { + if kf == nil { + return nil, newError(ErrSigKeyFetch, "KeyFetcher cannot be nil") + } + return &Verifier{ + keys: kf, + profile: profile, + }, nil +} + +// Verify verifies the signature(s) in an http request. Any invalid signature will return an error. +// A valid VerifyResult is returned even if error is also returned. +func (ver *Verifier) Verify(req *http.Request) (VerifyResult, error) { + return ver.verify(httpMessage{ + Req: req, + }) +} + +func (ver *Verifier) VerifyResponse(resp *http.Response) (VerifyResult, error) { + return ver.verify(httpMessage{ + IsResponse: true, + Resp: resp, + }) +} + +// verify verifies the request or response. +func (ver *Verifier) verify(hrr httpMessage) (VerifyResult, error) { + vr := VerifyResult{} + + /* calculate content digest if needed */ + if hrr.Headers().Get("Content-Digest") != "" { + digestAlgo, expectedDigest, err := getSupportedDigestFromHeader(hrr.Headers().Values("Content-Digest")) + if err != nil { + return vr, err + } + + di, err := digestBody(digestAlgo, hrr.Body()) + if err != nil { + return vr, err + } + hrr.SetBody(di.NewBody) + if !bytes.Equal(expectedDigest, di.Digest) { + return vr, newError(ErrNoSigWrongDigest, "Digest does not match") + } + } + + /* parse and extract the signature */ + sigsfv, err := parseSignaturesFromRequest(hrr.Headers()) + if err != nil { + return vr, err + } + + sig, err := unmarshalSignature(sigsfv, ver.profile.SignatureLabel) + if err != nil { + return vr, err + } + vr.Label = sig.Label + vr.MetadataProvider = sig.Input.MetadataValues + + /* verify and validate */ + base, err := calculateSignatureBase(hrr, sig.Input) + if err != nil { + return vr, err + } + + if hrr.isDebug() { + vr.DebugInfo = VerifyDebugInfo{ + SignatureBase: string(base.base), + } + } + keyspec, err := ver.verifySignature(hrr, sig, base) + vr.KeySpecer = keyspec + if err != nil { + return vr, err + } + + if err := ver.profile.validate(sig); err != nil { + return vr, err + } + + return VerifyResult{ + Verified: true, + Label: sig.Label, + KeySpecer: keyspec, + MetadataProvider: sig.Input.MetadataValues, + DebugInfo: vr.DebugInfo, + }, nil +} + +type extractedSignature struct { + Label string + Signature []byte + Input sigBaseInput +} + +// signaturesSFV is the structured field value representation of all the signatures of a request. +type signaturesSFV struct { + SigInputs *sfv.Dictionary + Sigs *sfv.Dictionary +} + +func parseSignaturesFromRequest(headers http.Header) (signaturesSFV, error) { + psd := signaturesSFV{} + /* Pull signature and signature-input header */ + sigHeader := headers.Get("signature") + if sigHeader == "" { + return psd, newError(ErrNoSigMissingSignature, "Missing signature header") + } + sigInputHeader := headers.Get("signature-input") + if sigInputHeader == "" { + return psd, newError(ErrNoSigMissingSignature, "Missing signature-input header") + } + + /* Parse headers into their appropriate HTTP structured field values */ + // signature-input must be a HTTP structured field value of type Dictionary. + var err error + psd.SigInputs, err = sfv.UnmarshalDictionary([]string{sigInputHeader}) + if err != nil { + return psd, newError(ErrNoSigInvalidSignature, "Invalid signature-input header. Not a valid Dictionary", err) + } + // signature must be a HTTP structured field value of type Dictionary. + psd.Sigs, err = sfv.UnmarshalDictionary([]string{sigHeader}) + if err != nil { + return psd, newError(ErrNoSigInvalidSignature, "Invalid signature header. Not a valid Dictionary", err) + } + return psd, nil +} + +// unmarshalSignature unmarshals a signature from hhtp structured field value (sfv) format. +func unmarshalSignature(sigs signaturesSFV, label string) (extractedSignature, error) { + sigInfo := extractedSignature{ + Label: label, + } + + sigMember, found := sigs.Sigs.Get(label) + if !found { + return sigInfo, newError(ErrNoSigMissingSignature, fmt.Sprintf("The signature for label '%s' not found", label)) + } + sigInputMember, found := sigs.SigInputs.Get(label) + if !found { + return sigInfo, newError(ErrNoSigMissingSignature, fmt.Sprintf("The signature-input for label '%s' not found", label)) + } + + // The signature must be of sfv type 'Item' + sigItem, isItem := sigMember.(sfv.Item) + if !isItem { + return sigInfo, newError(ErrSigInvalidSignature, fmt.Sprintf("The signature for label '%s' must be type Item. It was type %T", label, sigMember)) + } + // Signatures must be byte sequences. The sfv library uses []byte for byte sequences. + sigBytes, isByteSequence := sigItem.Value.([]byte) + if !isByteSequence { + return sigInfo, newError(ErrSigInvalidSignature, fmt.Sprintf("The signature for label '%s' was not a byte sequence. It was type %T", label, sigItem.Value)) + + } + sigInfo.Signature = sigBytes + + // The signature input must be of sfv type InnerList + sigInputList, isList := sigInputMember.(sfv.InnerList) + if !isList { + return sigInfo, newError(ErrSigInvalidSignature, fmt.Sprintf("The signature-input for label '%s' must be type InnerList. It was type '%T'.", label, sigInputMember)) + + } + cIDs := []componentID{} + for _, componentItem := range sigInputList.Items { + name, ok := componentItem.Value.(string) + if !ok { + return sigInfo, newError(ErrSigInvalidSignature, fmt.Sprintf("signature components must be string types")) + } + cIDs = append(cIDs, componentID{ + Name: name, + Item: componentItem, + }) + } + mds := []Metadata{} + for _, name := range sigInputList.Params.Names() { + mds = append(mds, Metadata(name)) + } + sigInfo.Input = sigBaseInput{ + Components: cIDs, + MetadataParams: mds, + MetadataValues: metadataProviderFromParams{sigInputList.Params}, + } + + return sigInfo, nil +} + +func (ver *Verifier) verifySignature(r httpMessage, sig extractedSignature, base signatureBase) (KeySpecer, error) { + var specer KeySpecer + var ks KeySpec + var err error + // Get keyspec + if slices.Contains(sig.Input.MetadataParams, MetaKeyID) { + keyid, err := sig.Input.MetadataValues.KeyID() + if err != nil { + return nil, newError(ErrSigKeyFetch, "Could not get keyid from signature metadata", err) + } + + specer, err = ver.keys.FetchByKeyID(r.Context(), r.Headers(), keyid) + if err != nil { + return nil, newError(ErrSigKeyFetch, fmt.Sprintf("Failed to fetch key for keyid '%s'", keyid), err) + } + ks, err = specer.KeySpec() + if err != nil { + return nil, newError(ErrSigKeyFetch, fmt.Sprintf("Failed to fetch key for keyid '%s'", keyid), err) + } + } else { + specer, err = ver.keys.Fetch(r.Context(), r.Headers(), sig.Input.MetadataValues) + if err != nil { + return specer, newError(ErrSigKeyFetch, fmt.Sprintf("Failed to fetch key for signature without a keyid and with label '%s'\n", sig.Label), err) + } + ks, err = specer.KeySpec() + if err != nil { + return specer, newError(ErrSigKeyFetch, fmt.Sprintf("Failed to fetch key for signature without a keyid and with label '%s'\n", sig.Label), err) + } + } + + switch ks.Algo { + case Algo_RSA_PSS_SHA512: + if rsapub, ok := ks.PubKey.(*rsa.PublicKey); ok { + opts := &rsa.PSSOptions{ + SaltLength: 64, + Hash: crypto.SHA512, + } + msgHash := sha512.Sum512(base.base) + err := rsa.VerifyPSS(rsapub, crypto.SHA512, msgHash[:], sig.Signature, opts) + if err != nil { + return specer, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) + } + return specer, nil + } else { + return specer, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires rsa.PublicKey but got type: %T", ks.PubKey)) + } + case Algo_RSA_v1_5_sha256: + if rsapub, ok := ks.PubKey.(*rsa.PublicKey); ok { + msgHash := sha256.Sum256(base.base) + err := rsa.VerifyPKCS1v15(rsapub, crypto.SHA256, msgHash[:], sig.Signature) + if err != nil { + return specer, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) + } + return specer, nil + } else { + return specer, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires rsa.PublicKey but got type: %T", ks.PubKey)) + } + case Algo_HMAC_SHA256: + if len(ks.Secret) == 0 { + return specer, newError(ErrInvalidSignatureOptions, fmt.Sprintf("No secret provided for symmetric algorithm '%s'", Algo_HMAC_SHA256)) + } + msgHash := hmac.New(sha256.New, ks.Secret) + msgHash.Write(base.base) // write does not return an error per hash.Hash documentation + calcualtedSignature := msgHash.Sum(nil) + if !hmac.Equal(calcualtedSignature, sig.Signature) { + return specer, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) + } + case Algo_ECDSA_P256_SHA256: + if epub, ok := ks.PubKey.(*ecdsa.PublicKey); ok { + if len(sig.Signature) != 64 { + return specer, newError(ErrSigInvalidSignature, fmt.Sprintf("Signature must be 64 bytes for algorithm '%s'", Algo_ECDSA_P256_SHA256)) + } + msgHash := sha256.Sum256(base.base) + // Concatenate r and s to form the signature as per the spec. r and s and *not* ANS1 encoded. + r := new(big.Int) + r.SetBytes(sig.Signature[0:32]) + s := new(big.Int) + s.SetBytes(sig.Signature[32:64]) + if !ecdsa.Verify(epub, msgHash[:], r, s) { + return specer, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) + } + } else { + return specer, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires *ecdsa.PublicKey but got type: %T", ks.PubKey)) + } + case Algo_ECDSA_P384_SHA384: + if epub, ok := ks.PubKey.(*ecdsa.PublicKey); ok { + if len(sig.Signature) != 96 { + return specer, newError(ErrSigInvalidSignature, fmt.Sprintf("Signature must be 96 bytes for algorithm '%s'", Algo_ECDSA_P256_SHA256)) + } + msgHash := sha512.Sum384(base.base) + // Concatenate r and s to form the signature as per the spec. r and s and *not* ANS1 encoded. + r := new(big.Int) + r.SetBytes(sig.Signature[0:48]) + s := new(big.Int) + s.SetBytes(sig.Signature[48:96]) + if !ecdsa.Verify(epub, msgHash[:], r, s) { + return specer, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) + } + } else { + return specer, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires *ecdsa.PublicKey but got type: %T", ks.PubKey)) + } + case Algo_ED25519: + if edpubkey, ok := ks.PubKey.(ed25519.PublicKey); ok { + if !ed25519.Verify(edpubkey, base.base, sig.Signature) { + return specer, newError(ErrSigVerification, fmt.Sprintf("Signature did not verify for algo '%s'", ks.Algo), err) + } + } else { + return specer, newError(ErrSigPublicKey, fmt.Sprintf("Invalid public key. Requires ed25519.PublicKey but got type: %T", ks.PubKey)) + } + default: + return specer, newError(ErrSigUnsupportedAlgorithm, fmt.Sprintf("Invalid verification algorithm '%s'", ks.Algo)) + } + return specer, nil +} + +func (vp VerifyProfile) validate(sig extractedSignature) error { + return nil +} + +type metadataProviderFromParams struct { + Params *sfv.Params +} + +func (mp metadataProviderFromParams) Created() (int, error) { + if val, ok := mp.Params.Get(string(MetaCreated)); ok { + return int(val.(int64)), nil + } + return 0, fmt.Errorf("No created value") +} + +func (mp metadataProviderFromParams) Expires() (int, error) { + if val, ok := mp.Params.Get(string(MetaExpires)); ok { + return int(val.(int64)), nil + } + return 0, fmt.Errorf("No expires value") +} + +func (mp metadataProviderFromParams) Nonce() (string, error) { + if val, ok := mp.Params.Get(string(MetaNonce)); ok { + return val.(string), nil + } + return "", fmt.Errorf("No nonce value") +} + +func (mp metadataProviderFromParams) Alg() (string, error) { + if val, ok := mp.Params.Get(string(MetaAlgorithm)); ok { + return val.(string), nil + } + return "", fmt.Errorf("No alg value") +} + +func (mp metadataProviderFromParams) KeyID() (string, error) { + if val, ok := mp.Params.Get(string(MetaKeyID)); ok { + return val.(string), nil + } + return "", fmt.Errorf("No keyid value") +} + +func (mp metadataProviderFromParams) Tag() (string, error) { + if val, ok := mp.Params.Get(string(MetaTag)); ok { + return val.(string), nil + } + return "", fmt.Errorf("No tag value") +} + +func SetAddDebugInfo(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxKeyAddDebug, true) +} diff --git a/third_party/httpsig-go/verify_test.go b/third_party/httpsig-go/verify_test.go new file mode 100644 index 0000000000..4c0dfe3489 --- /dev/null +++ b/third_party/httpsig-go/verify_test.go @@ -0,0 +1,233 @@ +package httpsig_test + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/remitly-oss/httpsig-go" + "github.com/remitly-oss/httpsig-go/keyman" + "github.com/remitly-oss/httpsig-go/keyutil" + "github.com/remitly-oss/httpsig-go/sigtest" +) + +func TestVerify(t *testing.T) { + testcases := []struct { + Name string + RequestFile string + Label string + AddDebugInfo bool + Keys httpsig.KeyFetcher + Expected httpsig.VerifyResult + }{ + { + Name: "OneValid", + Label: "sig-b21", + RequestFile: "verify_request1.txt", + Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + "test-key-rsa-pss": { + KeyID: "test-key-rsa-pss", + Algo: httpsig.Algo_RSA_PSS_SHA512, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), + }, + }), + Expected: httpsig.VerifyResult{ + Verified: true, + Label: "sig-b21", + MetadataProvider: &fixedMetadataProvider{map[httpsig.Metadata]any{ + httpsig.MetaKeyID: "test-key-rsa-pss", + httpsig.MetaCreated: int64(1618884473), + httpsig.MetaNonce: "b3k2pp5k7z-50gnwp.yemd", + }}, + KeySpecer: httpsig.KeySpec{ + KeyID: "test-key-rsa-pss", + Algo: httpsig.Algo_RSA_PSS_SHA512, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), + }, + }, + }, + { + Name: "OneValidDebug", + Label: "sig-b21", + RequestFile: "verify_request1.txt", + AddDebugInfo: true, + Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + "test-key-rsa-pss": { + KeyID: "test-key-rsa-pss", + Algo: httpsig.Algo_RSA_PSS_SHA512, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), + }, + }), + Expected: httpsig.VerifyResult{ + Verified: true, + Label: "sig-b21", + MetadataProvider: &fixedMetadataProvider{map[httpsig.Metadata]any{ + httpsig.MetaKeyID: "test-key-rsa-pss", + httpsig.MetaCreated: int64(1618884473), + httpsig.MetaNonce: "b3k2pp5k7z-50gnwp.yemd", + }}, + KeySpecer: httpsig.KeySpec{ + KeyID: "test-key-rsa-pss", + Algo: httpsig.Algo_RSA_PSS_SHA512, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), + }, + DebugInfo: httpsig.VerifyDebugInfo{ + SignatureBase: `"@signature-params": ();created=1618884473;keyid="test-key-rsa-pss";nonce="b3k2pp5k7z-50gnwp.yemd"`, + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.Name, func(t *testing.T) { + req := sigtest.ReadRequest(t, tc.RequestFile) + if tc.AddDebugInfo { + req = req.WithContext(httpsig.SetAddDebugInfo(req.Context())) + } + actual, err := httpsig.Verify(req, tc.Keys, httpsig.VerifyProfile{SignatureLabel: tc.Label}) + if err != nil { + t.Fatal(err) + } + + // VerifyResult is returned even when error is also returned. + // Because VerifryResult embed Metadataprovider we first need diff ignoring the MetadataProvider + sigtest.Diff(t, tc.Expected, actual, "Did not match", + cmp.FilterPath(func(p cmp.Path) bool { + return p.String() == "MetadataProvider" + }, cmp.Ignore())) + + // Then diff the metadata provider + sigtest.Diff(t, tc.Expected, actual, "Did not match", getCmdOpts()...) + }) + } +} + +func TestVerifyInvalid(t *testing.T) { + testcases := []struct { + Name string + RequestFile string + Label string + Keys httpsig.KeyFetcher + Expected httpsig.ErrCode + }{ + { + Name: "SignatureVerificationFailure", + RequestFile: "verify_request2.txt", + Label: "bad-sig", + Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{ + "test-key-rsa-pss": { + KeyID: "test-key-rsa-pss", + Algo: httpsig.Algo_RSA_PSS_SHA512, + PubKey: keyutil.MustReadPublicKeyFile("testdata/test-key-rsa-pss.pub"), + }, + }), + Expected: httpsig.ErrSigVerification, + }, + { + Name: "KeyFetchError", + RequestFile: "verify_request2.txt", + Label: "sig-b21", + Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{}), + Expected: httpsig.ErrSigKeyFetch, + }, + { + Name: "KeyFetchError2", + RequestFile: "verify_request2.txt", + Label: "bad-sig", + Keys: keyman.NewKeyFetchInMemory(map[string]httpsig.KeySpec{}), + Expected: httpsig.ErrSigKeyFetch, + }, + } + + for _, tc := range testcases { + t.Run(tc.Name, func(t *testing.T) { + _, err := httpsig.Verify(sigtest.ReadRequest(t, tc.RequestFile), tc.Keys, httpsig.VerifyProfile{SignatureLabel: tc.Label}) + + if err == nil { + t.Fatal("Expected err") + } + if sigerr, ok := err.(*httpsig.SignatureError); ok { + sigtest.Diff(t, tc.Expected, sigerr.Code, "Did not match") + } else { + sigtest.Diff(t, tc.Expected, sigerr, "Did not match") + } + }) + } +} + +type fixedMetadataProvider struct { + values map[httpsig.Metadata]any +} + +func (fmp fixedMetadataProvider) Created() (int, error) { + if val, ok := fmp.values[httpsig.MetaCreated]; ok { + return int(val.(int64)), nil + } + return 0, fmt.Errorf("No created value") +} + +func (fmp fixedMetadataProvider) Expires() (int, error) { + if val, ok := fmp.values[httpsig.MetaExpires]; ok { + return int(val.(int64)), nil + } + return 0, fmt.Errorf("No expires value") +} + +func (fmp fixedMetadataProvider) Nonce() (string, error) { + if val, ok := fmp.values[httpsig.MetaNonce]; ok { + return val.(string), nil + } + return "", fmt.Errorf("No nonce value") +} + +func (fmp fixedMetadataProvider) Alg() (string, error) { + if val, ok := fmp.values[httpsig.MetaAlgorithm]; ok { + return val.(string), nil + } + return "", fmt.Errorf("No alg value") +} + +func (fmp fixedMetadataProvider) KeyID() (string, error) { + if val, ok := fmp.values[httpsig.MetaKeyID]; ok { + return val.(string), nil + } + return "", fmt.Errorf("No keyid value") +} + +func (fmp fixedMetadataProvider) Tag() (string, error) { + if val, ok := fmp.values[httpsig.MetaTag]; ok { + return val.(string), nil + } + return "", fmt.Errorf("No tag value") +} + +func metaVal[E comparable](f1 func() (E, error)) any { + val, err := f1() + if err != nil { + return err.Error() + } + return val +} + +func getCmdOpts() []cmp.Option { + return []cmp.Option{ + // This gets used for *ANY* struct assignable to MetadataProvider including other structres + // that embed it! + cmp.Transformer("MetadataProvider", TransformMeta), + } +} + +func TransformMeta(md httpsig.MetadataProvider) map[string]any { + out := map[string]any{} + + if md == nil { + return out + } + out[string(httpsig.MetaCreated)] = metaVal(md.Created) + out[string(httpsig.MetaExpires)] = metaVal(md.Expires) + out[string(httpsig.MetaNonce)] = metaVal(md.Nonce) + out[string(httpsig.MetaAlgorithm)] = metaVal(md.Alg) + out[string(httpsig.MetaKeyID)] = metaVal(md.KeyID) + out[string(httpsig.MetaTag)] = metaVal(md.Tag) + return out +}