diff --git a/.golangci.yml b/.golangci.yml index 04be583..bbce5bf 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -7,6 +7,7 @@ run: build-tags: - insecure # internal/keystore/internal - http # cmd/quarry-client/http.go + - fixtures # internal/fixtures linters: enable: diff --git a/cmd/quarry-client/http.go b/cmd/quarry-client/http.go index 0a4b399..b02acd1 100644 --- a/cmd/quarry-client/http.go +++ b/cmd/quarry-client/http.go @@ -32,6 +32,7 @@ import ( "github.com/urfave/cli/v3" "go.amutable.dev/quarry/internal/third_party/funchelpers" + "go.amutable.dev/quarry/internal/tufclient" "go.amutable.dev/quarry/internal/tufext" "go.amutable.dev/quarry/internal/xsysupdate" ) @@ -220,6 +221,18 @@ func proxyTargetFile(rw http.ResponseWriter, req *http.Request) (Err error) { return nil } + // If we need a custom client for pulling data from the repository (such as + // there being an authentication scheme) then we cannot just provide + // sd-pull a redirect because it will not be able to pull from the source. + // So we have no choice but to stream the data here. + // + // TODO: Once we have support for UAPI.16 in systemd-sysupdate we will be + // able to provide custom headers to sd-pull and remove the need for this + // streaming ugliness. + if info.Repo.Client != nil { + return streamTargetFile(rw, req, info) + } + // Redirect to the first target URL. // TODO(uapi16): Once we get UAPI.16 support into systemd, we can serve a // manifest that lists every URL candidate as an alternative contents source @@ -252,6 +265,39 @@ func proxyTargetFile(rw http.ResponseWriter, req *http.Request) (Err error) { return errors.New("no fetch urls defined for target") } +// streamTargetFile streams the target file to the client. Because this is done +// in a lossy way (we cannot return an error after data has been sent in HTTP) +// some information is lost and we have to rely on the client to check +// Content-Length and Content-Digest. +func streamTargetFile(rw http.ResponseWriter, req *http.Request, info *tufclient.TargetInfo) error { + ctx := req.Context() + + rdr, err := info.Fetch(ctx) + if errors.Is(err, fs.ErrNotExist) { + http.NotFound(rw, req) + return nil + } + // TODO: We should have info.Fetch provide more error information so we can + // mirror it here properly. + if err != nil { + return fmt.Errorf("fetch target %s from repo %s: %w", info.Path, info.Repo.Name, err) + } + + rw.Header().Set("Content-Length", strconv.FormatInt(info.Length, 10)) + rw.Header().Set("Content-Type", "application/octet-stream") + if digestValue := hashesToContentDigest(info.Hashes); digestValue != "" { + rw.Header().Set("Content-Digest", digestValue) + rw.Header().Set("Repr-Digest", digestValue) + } + _, copyErr := io.Copy(rw, rdr) + if err := errors.Join(copyErr, rdr.Close()); err != nil { + slog.ErrorContext(ctx, "Aborting target response", + "repo", info.Repo.Name, "target", info.Path, "err", err.Error()) + panic(http.ErrAbortHandler) + } + return nil +} + // newSysupdateHandler returns a [http.Handler] that impements our sysupdate // SHA256SUM-based compatibility shim. func newSysupdateHandler() http.Handler { diff --git a/cmd/quarry-client/http_insecure_test.go b/cmd/quarry-client/http_insecure_test.go index e6c0ecf..874838f 100644 --- a/cmd/quarry-client/http_insecure_test.go +++ b/cmd/quarry-client/http_insecure_test.go @@ -19,7 +19,9 @@ import ( "net" "net/http" "net/http/httptest" + "net/http/httputil" "net/url" + "path/filepath" "slices" "strconv" "strings" @@ -29,6 +31,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" tufmetadata "github.com/theupdateframework/go-tuf/v2/metadata" + "snai.pe/go-varlink" "go.amutable.dev/quarry/cmd/internal/cliext" "go.amutable.dev/quarry/internal/ctxext" @@ -812,3 +815,51 @@ func TestHTTP_ReleaseLayouts(t *testing.T) { }) } } + +// startFakeIdentityAgent serves GetWorkloadIdentityToken with a fixed WIT and +// returns the agent's varlink URI. +func startFakeIdentityAgent(t *testing.T) string { + t.Helper() + uri := "unix:" + filepath.Join(t.TempDir(), "agent") //nolint:forbidigo // test code + listener, err := varlink.Listen(uri) + require.NoError(t, err, "listen on fake agent socket") + t.Cleanup(func() { _ = listener.Close() }) + + var mux varlink.ServeMux + mux.HandleFunc("com.amutable.IdentityAgent.GetWorkloadIdentityToken", func(w varlink.ReplyWriter, _ *varlink.Call) { + _ = w.WriteReply(map[string]any{"wit": "wit", "expires_in": 3600}) + }) + srv := &varlink.Server{Handler: &mux} + go func() { _ = srv.Serve(listener) }() + return uri +} + +// Targets of a repo with auth are served by quarry-client itself, fetched with +// the repo's signing client: a redirect would make sysupdate fetch them +// unsigned. The gate in front of the repo refuses any request without a WIT. +func TestHTTPProxyTarget_AuthRepoIsStreamed(t *testing.T) { + srv := testrepo.New(t) + files, _ := publishNightlyImage(t, srv) + + repoURL, err := url.Parse(srv.URL) + require.NoError(t, err) + proxy := httputil.NewSingleHostReverseProxy(repoURL) + gate := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.Header.Get("Workload-Identity-Token") == "" { + http.Error(rw, "unsigned", http.StatusUnauthorized) + return + } + proxy.ServeHTTP(rw, req) + })) + t.Cleanup(gate.Close) + + auth := fmt.Sprintf(`auth = { type = "wimse", wit_audience = "urn:amutable:quarry-worker", varlink = %q }`, startFakeIdentityAgent(t)) + block := srv.ConfigBlock("nightly", testrepo.ConfigWithExtraLines(auth)) + server := newServer(t, strings.ReplaceAll(block, srv.URL, gate.URL)) + + resp, body := serverGet(t, server, "/"+usrTarget) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, files[usrTarget], body) + assert.Empty(t, resp.Header.Values("Location")) + assert.Equal(t, sha256Digest(files[usrTarget]), resp.Header.Get("Content-Digest")) +} diff --git a/contrib/quarry-client/config.toml b/contrib/quarry-client/config.toml index 32d72d8..11500e5 100644 --- a/contrib/quarry-client/config.toml +++ b/contrib/quarry-client/config.toml @@ -77,3 +77,14 @@ cache_dir = "/var/lib/quarry-client/latest-metadata" ## overridden by setting the following two settings. #meta_root_url = "https://tufmetadata.example.com/tuf/%m" #data_root_url = "https://cloud-storage.example.com/repo/%m/foobar" + +## Requests for the repository's files can be signed as this machine, for +## servers that only serve a repository to the machine it belongs to. Each +## request then carries the machine's WIMSE workload identity token for +## "wit_audience", from the varlink service at "varlink" implementing +## com.amutable.IdentityAgent (default: amutablectl's, at +## unix:/run/amutable/com.amutable.IdentityAgent), and an RFC 9421 signature +## by a key quarry-client holds in memory and the token binds. The signature +## is for "wimse_audience" (default: the URL of the request). Redirects are +## not signed. "wimse" is the only supported type. +#auth = { type = "wimse", wit_audience = "urn:amutable:quarry-worker" } diff --git a/go.mod b/go.mod index d1879d2..0d13b84 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,15 @@ require ( cyphar.com/go-pathrs v0.2.6 github.com/BurntSushi/toml v1.6.0 github.com/coreos/go-systemd/v22 v22.7.0 + github.com/go-jose/go-jose/v4 v4.1.7 + github.com/go4org/hashtriemap v0.0.0-20260925222741-44e5305f85d9 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/umoci v0.6.0 github.com/schollz/progressbar/v3 v3.19.1 github.com/secure-systems-lab/go-securesystemslib v0.11.1 + github.com/shogo82148/go-sfv v0.3.6 github.com/stretchr/testify v1.12.1 github.com/theupdateframework/go-tuf/v2 v2.4.1 github.com/tiendc/go-deepcopy v1.7.2 diff --git a/go.sum b/go.sum index a81a5eb..00ad27d 100644 --- a/go.sum +++ b/go.sum @@ -21,11 +21,15 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-jose/go-jose/v4 v4.1.7 h1:Nac/98uT5LPkfbiFuYa8ktf62a2VxwQ4xbq1GK8pV5E= +github.com/go-jose/go-jose/v4 v4.1.7/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go4org/hashtriemap v0.0.0-20260925222741-44e5305f85d9 h1:J8S+Ms89nvHX1F9Ma2m3KhdTl41ZhbFKMyAJqvDA+iE= +github.com/go4org/hashtriemap v0.0.0-20260925222741-44e5305f85d9/go.mod h1:OGmRfY/9QEK2P5zCRtmqfbCF283xPkU2dvVA4MvbvpI= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -71,6 +75,8 @@ github.com/schollz/progressbar/v3 v3.19.1/go.mod h1:LFL7jqimKxfhero4K1eCkUr/6R39 github.com/secure-systems-lab/go-securesystemslib v0.11.1 h1:ayahDPjfSIqKegyt5YVGEvQ7SAi72GHXTy+b2YjUd5w= github.com/secure-systems-lab/go-securesystemslib v0.11.1/go.mod h1:UyOjhoZLi76ir63u1ptubEevVe6dlNPmNxwgVvKqFFY= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shogo82148/go-sfv v0.3.6 h1:8MpndGsf6vZ3dHeTYN8TnKjL5TstUYIpwS7urkehv2o= +github.com/shogo82148/go-sfv v0.3.6/go.mod h1:VYNXeh/Gj0TO5PR+K7ZNTRnxKe233q6kYU/qIL0s6s4= github.com/sigstore/protobuf-specs v0.5.0 h1:F8YTI65xOHw70NrvPwJ5PhAzsvTnuJMGLkA4FIkofAY= github.com/sigstore/protobuf-specs v0.5.0/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= github.com/sigstore/sigstore v1.10.4 h1:ytOmxMgLdcUed3w1SbbZOgcxqwMG61lh1TmZLN+WeZE= diff --git a/internal/fixtures/acp-sample-gen/acp-sample-gen.go b/internal/fixtures/acp-sample-gen/acp-sample-gen.go new file mode 100644 index 0000000..dacc1ae --- /dev/null +++ b/internal/fixtures/acp-sample-gen/acp-sample-gen.go @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +//go:build fixtures + +// acp-sample-gen regenerates the control plane's +// deploy/cloudflare/quarry-worker/test/fixtures/go-signed-read.json, which +// pins the Worker's verifier to what Transport puts on the wire: +// +// go run -tags fixtures ./internal/fixtures/acp-sample-gen \ +// /deploy/cloudflare/quarry-worker/test/fixtures/go-signed-read.json +// +// Transport gets its WIT from a fake identity agent, which mints one with a +// throwaway tenant key and binds the key Transport asked for. +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "time" + + jose "github.com/go-jose/go-jose/v4" + "snai.pe/go-varlink" + + "go.amutable.dev/quarry/internal/wimseagent" +) + +const ( + broker = "https://wit-broker.test" + tenant = "acme" + stamp = "test.example" + machine = "0123456789abcdef0123456789abcdef" + audience = "urn:amutable:quarry-worker" + kid = "tenant-key" + // publicURL is the Worker tests' PUBLIC_URL, so the request's default + // wimse-aud (its target URI) is one the Worker accepts. + publicURL = "https://customerdata.test" +) + +// captureTransport records the headers of the one request sent through it, +// so the signed request never has to reach a server at publicURL. +type captureTransport struct { + header http.Header +} + +func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + c.header = req.Header.Clone() + return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil +} + +var b64 = base64.RawURLEncoding.EncodeToString + +func es256JWT(key *ecdsa.PrivateKey, header, claims any) (string, error) { + h, err := json.Marshal(header) + if err != nil { + return "", err + } + c, err := json.Marshal(claims) + if err != nil { + return "", err + } + signing := b64(h) + "." + b64(c) + digest := sha256.Sum256([]byte(signing)) + r, s, err := ecdsa.Sign(rand.Reader, key, digest[:]) + if err != nil { + return "", err + } + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + return signing + "." + b64(sig), nil +} + +// p256JWK returns the JWK to bind into the WIT for jwk. Like the real agent, +// it only accepts P-256 public keys with "alg" ES256, which WIMSE requires. +func p256JWK(jwk jose.JSONWebKey) (jose.JSONWebKey, error) { + pub, ok := jwk.Key.(*ecdsa.PublicKey) + if !ok || pub.Curve != elliptic.P256() || !jwk.Valid() { + return jose.JSONWebKey{}, fmt.Errorf("not a P-256 public key: %T", jwk.Key) + } + if jwk.Algorithm != string(jose.ES256) { + return jose.JSONWebKey{}, fmt.Errorf("alg %q does not match a P-256 key", jwk.Algorithm) + } + return jose.JSONWebKey{Key: pub, Algorithm: string(jose.ES256)}, nil +} + +// startAgent serves GetWorkloadIdentityToken from a socket in dir, replying +// with a WIT signed by tenantKey and bound to the requested key. +func startAgent(dir string, tenantKey *ecdsa.PrivateKey, now time.Time) (string, error) { + uri := "unix:" + dir + "/agent" + listener, err := varlink.Listen(uri) + if err != nil { + return "", fmt.Errorf("listen on fake agent socket: %w", err) + } + var mux varlink.ServeMux + mux.HandleFunc("com.amutable.IdentityAgent.GetWorkloadIdentityToken", func(w varlink.ReplyWriter, c *varlink.Call) { + var p struct { + Audience string `json:"aud"` + PublicKey jose.JSONWebKey `json:"jwk_pub"` + } + if err := c.Unmarshal(&p); err != nil { + _ = w.WriteError(err) + return + } + pub, err := p256JWK(p.PublicKey) + if err != nil { + _ = w.WriteError(varlink.NewError("org.varlink.service.InvalidParameter", "parameter", "jwk_pub")) + return + } + wit, err := es256JWT(tenantKey, + map[string]string{"alg": "ES256", "typ": "wit+jwt", "kid": kid}, + map[string]any{ + "iss": broker + "/tenants/" + tenant, + "sub": "spiffe://" + tenant + "." + stamp + "/machine/" + machine, + "aud": []string{p.Audience}, + "iat": now.Unix(), + "exp": now.Add(time.Hour).Unix(), + "jti": "fixture", + "cnf": map[string]any{"jwk": pub}, + }) + if err != nil { + _ = w.WriteError(varlink.NewError("com.amutable.IdentityAgent.Failed", "error", err.Error())) + return + } + _ = w.WriteReply(map[string]any{"wit": wit, "expires_in": int64(time.Hour / time.Second)}) + }) + srv := &varlink.Server{Handler: &mux} + go func() { _ = srv.Serve(listener) }() + return uri, nil +} + +func run(out string) error { + tenantKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + now := time.Now() + + dir, err := os.MkdirTemp("", "quarry-worker-fixture-") //nolint:forbidigo // fresh private directory + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(dir) }() //nolint:forbidigo // our own temporary directory + agent, err := startAgent(dir, tenantKey, now) + if err != nil { + return err + } + + // The signing transport sends through http.DefaultTransport. + capture := &captureTransport{} + http.DefaultTransport = capture + cred, err := wimseagent.NewCred(wimseagent.ID{WitAudience: audience, Provider: agent}, nil) + if err != nil { + return err + } + client := &http.Client{Transport: &wimseagent.Transport{WitCred: cred}} + reqURL := publicURL + "/machine-repos/" + tenant + "/" + machine + "/1.root.json" + resp, err := client.Get(reqURL) //nolint:noctx // one-shot script + if err != nil { + return err + } + _ = resp.Body.Close() // empty body from captureTransport + header := capture.header + + jwk := jose.JSONWebKey{Key: &tenantKey.PublicKey, KeyID: kid, Algorithm: "ES256", Use: "sig"} + data, err := json.MarshalIndent(map[string]any{ + "now": now.Unix(), + "url": reqURL, + "jwks": map[string]any{"keys": []any{jwk}}, + "wit": header.Get("Workload-Identity-Token"), + "signatureInput": header.Get("Signature-Input"), + "signature": header.Get("Signature"), + }, "", " ") + if err != nil { + return err + } + return os.WriteFile(out, append(data, '\n'), 0o644) //nolint:forbidigo // path given by the user +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintf(os.Stderr, "usage: %s \n", os.Args[0]) + os.Exit(2) + } + if err := run(os.Args[1]); err != nil { + log.Fatal(err) + } +} diff --git a/internal/generics/slice.go b/internal/generics/slice.go new file mode 100644 index 0000000..8a337b2 --- /dev/null +++ b/internal/generics/slice.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package generics + +// TakeOne takes the first value out of a slice and returns the remaining slice. +// If the slice is empty or nil then (nil, s) is returned. +func TakeOne[S ~[]E, E any](s S) (*E, []E) { + if len(s) > 0 { + return Ptr(s[0]), s[1:] + } + return nil, s +} diff --git a/internal/httpsig/algorithm.go b/internal/httpsig/algorithm.go new file mode 100644 index 0000000..807c412 --- /dev/null +++ b/internal/httpsig/algorithm.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/asn1" + "fmt" + "io" + "math/big" +) + +// TODO: These algorithms actually match 1-to-1 to JWT signing algorithms. We +// should almost certainly come up with some unified representation of this +// stuff that can live in cryptoext so we don't need to have one version of +// this silly type-switching here and in wimseagent (and possibly in +// keystore). + +// algorithm is an RFC 9421 signature algorithm (section 3.3). +type algorithm struct { + // name is the RFC 9421 "alg" name + name string + // nilRand indicates whether this signature algorithm supports taking a nil + // [io.Reader] as the random source. (For most algorithms there is only one + // valid mode but ECDSA signatures can be made safe against bad randomness + // by RFC 6979 deterministic signatures.) + nilRand bool + // signerOpts is the canonical [crypto.SignerOpts] options used for this + // signature algorithm. + signerOpts crypto.SignerOpts + // matchOpts is used to allow [Base.SignRequest] to determine which + // algorithm matches the [crypto.Signer] and [crypto.SignerOpts] + // combination provided. + matchOpts func(crypto.SignerOpts) bool + // convertSig converts a signature encoded by [crypto.SignMessage] in the + // "Go format" to the RFC 9421 format. If nil, [algorithm.RecodeSignature] + // is a no-op. + convertSig func(sig []byte) ([]byte, error) +} + +// SignMessage signs a message using this [algorithm]. This is analogous to +// [crypto.SignMessage] except that the returned signature is in the RFC 9421 +// signature format rather than the Go-native one used by [crypto]. The +// provided [crypto.SignerOpts] must be compatible with this algorithm or an +// [ErrUnsupportedSignerOpts] error will be returned. +func (alg *algorithm) SignMessage(signer crypto.Signer, msg []byte, opts crypto.SignerOpts) (rfcSig []byte, err error) { + switch { + case opts == nil: + opts = alg.signerOpts + case !alg.matchOpts(opts): + return nil, fmt.Errorf("%w: %T %v for %T", ErrUnsupportedSignerOpts, opts, opts, signer.Public()) + } + var rnd io.Reader + if !alg.nilRand { + // Only use crypto/rand.Reader for algorithms that really need it. + rnd = rand.Reader + } + goSig, err := crypto.SignMessage(signer, rnd, msg, opts) + if err != nil { + return nil, err + } + rfcSig = goSig + if alg.convertSig != nil { + rfcSig, err = alg.convertSig(goSig) + } + return rfcSig, err +} + +func matchHash(h crypto.Hash) func(crypto.SignerOpts) bool { + return func(opts crypto.SignerOpts) bool { + got, ok := opts.(crypto.Hash) + return ok && got == h + } +} + +// convertEcdsaSig converts an ASN.1 DER ECDSA signature into the fixed-size +// r||s form RFC 9421 section 3.3.4 uses. +func convertEcdsaSig(size int) func([]byte) ([]byte, error) { + return func(sig []byte) ([]byte, error) { + var parsed struct{ R, S *big.Int } + rest, err := asn1.Unmarshal(sig, &parsed) + if err != nil { + return nil, fmt.Errorf("parse ecdsa signature: %w", err) + } + if len(rest) > 0 { + return nil, fmt.Errorf("parse ecdsa signature: trailing data") + } + if parsed.R.Sign() <= 0 || parsed.S.Sign() <= 0 || + parsed.R.BitLen() > size*8 || parsed.S.BitLen() > size*8 { + return nil, fmt.Errorf("ecdsa signature values out of range") + } + out := make([]byte, 2*size) + parsed.R.FillBytes(out[:size]) + parsed.S.FillBytes(out[size:]) + return out, nil + } +} + +var ( + algEd25519 = &algorithm{ + name: "ed25519", + nilRand: true, // rand is ignored + signerOpts: crypto.Hash(0), + matchOpts: func(opts crypto.SignerOpts) bool { + switch opts := opts.(type) { + case crypto.Hash: + return opts == 0 + case *ed25519.Options: + return opts.Hash == 0 && opts.Context == "" + } + return false + }, + convertSig: func(sig []byte) ([]byte, error) { + if len(sig) != ed25519.SignatureSize { + return nil, fmt.Errorf("ed25519 signature is %d bytes, expected %d", len(sig), ed25519.SignatureSize) + } + return sig, nil + }, + } + algECDSAP256SHA256 = &algorithm{ + name: "ecdsa-p256-sha256", + nilRand: true, // use RFC 6979 deterministic signatures + signerOpts: crypto.SHA256, + matchOpts: matchHash(crypto.SHA256), + convertSig: convertEcdsaSig(32), + } + algECDSAP384SHA384 = &algorithm{ + name: "ecdsa-p384-sha384", + nilRand: true, // use RFC 6979 deterministic signatures + signerOpts: crypto.SHA384, + matchOpts: matchHash(crypto.SHA384), + convertSig: convertEcdsaSig(48), + } + algRSAPSSSHA512 = &algorithm{ + name: "rsa-pss-sha512", + signerOpts: &rsa.PSSOptions{Hash: crypto.SHA512, SaltLength: rsa.PSSSaltLengthEqualsHash}, + matchOpts: func(opts crypto.SignerOpts) bool { + // RFC 9421 section 3.3.1 fixes the salt length at 64 bytes. + pss, ok := opts.(*rsa.PSSOptions) + return ok && pss.Hash == crypto.SHA512 && + (pss.SaltLength == rsa.PSSSaltLengthEqualsHash || pss.SaltLength == crypto.SHA512.Size()) + }, + } + algRSAv15SHA256 = &algorithm{ + name: "rsa-v1_5-sha256", + signerOpts: crypto.SHA256, + matchOpts: matchHash(crypto.SHA256), + } +) + +func curveName(c elliptic.Curve) string { + if c == nil || c.Params() == nil { + return "" + } + return c.Params().Name +} + +// chooseAlgorithm returns the algorithm for pub and opts, and the options to +// sign with. nil opts selects the key type's default algorithm. +func chooseAlgorithm(pub crypto.PublicKey, opts crypto.SignerOpts) (*algorithm, crypto.SignerOpts, error) { + var candidates []*algorithm + switch pub := pub.(type) { + case ed25519.PublicKey: + candidates = []*algorithm{algEd25519} + case *ecdsa.PublicKey: + switch pub.Curve { + case elliptic.P256(): + candidates = []*algorithm{algECDSAP256SHA256} + case elliptic.P384(): + candidates = []*algorithm{algECDSAP384SHA384} + default: + return nil, nil, fmt.Errorf("%w: ecdsa curve %s", ErrUnsupportedKey, curveName(pub.Curve)) + } + case *rsa.PublicKey: + candidates = []*algorithm{algRSAPSSSHA512, algRSAv15SHA256} + default: + return nil, nil, fmt.Errorf("%w: %T", ErrUnsupportedKey, pub) + } + for _, alg := range candidates { + if opts == nil { + opts = alg.signerOpts // just use the algorithm default + } + if alg.matchOpts(opts) { + return alg, opts, nil + } + } + return nil, nil, fmt.Errorf("%w: %T %v for %T", ErrUnsupportedSignerOpts, opts, opts, pub) +} diff --git a/internal/httpsig/base.go b/internal/httpsig/base.go new file mode 100644 index 0000000..60b2799 --- /dev/null +++ b/internal/httpsig/base.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "errors" + "fmt" + "slices" + "time" + + "github.com/shogo82148/go-sfv" + + "go.amutable.dev/quarry/internal/generics" +) + +// Signature parameters defined by the RFC, which cannot be specified using +// [WithExtraParam]. +var reservedParams = map[string]struct{}{ + "created": {}, + "expires": {}, + "nonce": {}, + "alg": {}, + "keyid": {}, + "tag": {}, +} + +// Base is a signature base template: the covered components and the +// parameters that are fixed for every signature made from it. A Base is +// immutable and safe for concurrent use. +// +// The order of the covered components and of the parameters is randomised +// for every signature, so that verifiers cannot depend on it. +type Base struct { + components map[string]coveredComponent // by identifier + expiry *time.Duration // nil means no expiry + noNonce bool + noAlg bool + tag string + extra map[string]any +} + +// coveredComponent is a component of a [Base], with whether it may be absent. +type coveredComponent struct { + Component + optional bool +} + +// BaseOption configures a [Base] in [NewBase]. +type BaseOption func(*Base) + +// WithComponents adds components that must be present in every message +// signed with the Base. A component with the same identifier as an earlier +// one replaces it. +func WithComponents(cs ...Component) BaseOption { + return withComponents(cs, false) +} + +// WithOptionalComponents adds components that are covered only when the +// message has them. A signature over a message without one leaves it out of +// the covered components entirely. A [Key] component is only present if the +// field exists and has that member. A component with the same identifier as +// an earlier one replaces it. +func WithOptionalComponents(cs ...Component) BaseOption { + return withComponents(cs, true) +} + +func withComponents(cs []Component, optional bool) BaseOption { + cs = slices.Clone(cs) + return func(b *Base) { + if b.components == nil { + b.components = make(map[string]coveredComponent) + } + for _, c := range cs { + b.components[c.String()] = coveredComponent{Component: c, optional: optional} + } + } +} + +// WithExpiry adds an "expires" parameter set to the signature's reference +// time (its "created" value) plus d. d must be positive. +func WithExpiry(d time.Duration) BaseOption { + return func(b *Base) { b.expiry = generics.Ptr(d) } +} + +// WithoutExpiry omits the "expires" parameter, which is also the default. +func WithoutExpiry() BaseOption { + return func(b *Base) { b.expiry = nil } +} + +// WithNonce explicitly includes the "nonce" parameter, which is also the +// default. The value will be 128 bits of fresh randomness or the value from +// [WithNonceValue], if specified. +func WithNonce() BaseOption { + return func(b *Base) { b.noNonce = false } +} + +// WithoutNonce omits the "nonce" parameter. +func WithoutNonce() BaseOption { + return func(b *Base) { b.noNonce = true } +} + +// WithAlg explicitly includes the "alg" parameter, which is also the default. +func WithAlg() BaseOption { + return func(b *Base) { b.noAlg = false } +} + +// WithoutAlg omits the "alg" parameter. This is not recommended, but some +// profiles (e.g., WIMSE) forbid the usage of "alg". +func WithoutAlg() BaseOption { + return func(b *Base) { b.noAlg = true } +} + +// WithTag sets the "tag" parameter to the given application-specific value. +func WithTag(tag string) BaseOption { + return func(b *Base) { b.tag = tag } +} + +// WithoutTag omits the "tag" parameter, which is also the default. +func WithoutTag() BaseOption { + return WithTag("") +} + +// WithExtraParam declares an application-defined signature parameter, as well +// as an optional default value. If no default value is provided, each +// signature must provide a value with [WithExtraParamValue] at signing time. +// This function panics if more than one argument is passed for defaultValue. +// name must be a valid sf-key and must not be a parameter defined by RFC 9421 +// ("created", "expires", "nonce", "alg", "keyid", "tag"). +func WithExtraParam(name string, defaultValue ...any) BaseOption { + v, rest := generics.TakeOne(defaultValue) + if len(rest) > 0 { + panic(fmt.Sprintf("WithExtraParam called with too many parameters: %q %v", name, defaultValue)) + } + var value any + if v != nil { + value = *v + } + return func(b *Base) { + if b.extra == nil { + b.extra = make(map[string]any) + } + b.extra[name] = value + } +} + +// NewBase returns a [Base] configured by opts. Options are applied in order, +// so later options override earlier ones. Every problem with the components +// and options is reported at once. +func NewBase(opts ...BaseOption) (*Base, error) { + var b Base + for _, opt := range opts { + opt(&b) + } + + var errs []error + for _, c := range b.components { + if err := c.validate(); err != nil { + errs = append(errs, err) + } + } + + if b.expiry != nil && *b.expiry <= 0 { + errs = append(errs, fmt.Errorf("%w: expiry %s is not positive", ErrInvalidParameter, *b.expiry)) + } + if b.tag != "" && !sfv.IsValidString(b.tag) { + errs = append(errs, fmt.Errorf("%w: tag %q is not a valid sf-string", ErrInvalidParameter, b.tag)) + } + for name := range b.extra { + switch { + case !validKey(name): + errs = append(errs, fmt.Errorf("%w: %q is not a valid parameter name", ErrInvalidParameter, name)) + case generics.MapContains(reservedParams, name): + errs = append(errs, fmt.Errorf("%w: %q is defined by RFC 9421", ErrInvalidParameter, name)) + } + } + + if err := errors.Join(errs...); err != nil { + return nil, err + } + return &b, nil +} diff --git a/internal/httpsig/component.go b/internal/httpsig/component.go new file mode 100644 index 0000000..4bce235 --- /dev/null +++ b/internal/httpsig/component.go @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "cmp" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/shogo82148/go-sfv" +) + +// Component is one covered component identifier: either a derived component +// ("@method", ...) or an HTTP field (lowercased name), plus its parameters. +// Components are comparable values; the zero Component is invalid. +type Component struct { + kind componentKind + req bool +} + +// componentKind is the type-specific part of a [Component]. Implementations +// must be comparable so that Component is. +type componentKind interface { + // name returns the component name, such as "@method" or "content-type". + name() string + // params returns the kind's own identifier parameters. + params() sfv.Parameters + validate() error + // resolve returns the component's value, or false if m doesn't have it. + resolve(m message) (value string, present bool, err error) +} + +// FromRequest returns c with the ";req" parameter, taking its value from the +// request a response answers. [Base.SignRequest] rejects it with +// [ErrResponseOnlyComponent]. +func (c Component) FromRequest() Component { + c.req = true + return c +} + +// item returns the component identifier as an sf-string item with its +// parameters. +func (c Component) item() sfv.Item { + if c.kind == nil { + return sfv.Item{Value: ""} + } + var params sfv.Parameters + if c.req { + params = append(params, sfv.Parameter{Key: "req", Value: true}) + } + params = append(params, c.kind.params()...) + return sfv.Item{Value: c.kind.name(), Parameters: params} +} + +// String returns the serialised component identifier as it appears in the +// signature base and Signature-Input, e.g. `"content-digest";key="sha-256"`. +func (c Component) String() string { + s, err := sfv.EncodeItem(c.item()) + if err != nil { + // Only an invalid component (rejected by NewBase) gets here. + return strconv.Quote(c.kind.name()) + ";" + } + return s +} + +func (c Component) validate() error { + err := errors.New("empty component") + if c.kind != nil { + err = c.kind.validate() + } + if err != nil { + return componentErr(c, fmt.Errorf("%w: %w", ErrInvalidComponent, err)) + } + return nil +} + +func (c Component) resolve(m message) (string, bool, error) { + if c.req { + var err error + m, err = m.related() + if err != nil { + return "", false, err + } + } + return c.kind.resolve(m) +} + +// derived is a derived component without parameters (RFC 9421 section 2.2). +type derived struct { + id string + value func(m message) (string, error) +} + +func (d *derived) name() string { return d.id } +func (d *derived) params() sfv.Parameters { return nil } +func (d *derived) validate() error { return nil } + +func (d *derived) resolve(m message) (string, bool, error) { + value, err := d.value(m) + return value, err == nil, err +} + +// fromRequest adapts a request-derived component's value function. +func fromRequest(value func(req *http.Request) (string, error)) func(message) (string, error) { + return func(m message) (string, error) { + req, err := m.request() + if err != nil { + return "", err + } + return value(req) + } +} + +var ( + derivedMethod = &derived{"@method", fromRequest(func(req *http.Request) (string, error) { + return cmp.Or(req.Method, http.MethodGet), nil + })} + derivedTargetURI = &derived{"@target-uri", fromRequest(RequestTargetURI)} + derivedAuthority = &derived{"@authority", fromRequest(RequestAuthority)} + derivedScheme = &derived{"@scheme", fromRequest(RequestScheme)} + derivedRequestTarget = &derived{"@request-target", fromRequest(func(req *http.Request) (string, error) { + // Matches the request-target net/http writes for CONNECT. + if req.Method == http.MethodConnect && req.URL.Path == "" { + if req.URL.Opaque != "" { + return req.URL.Opaque, nil + } + return cmp.Or(req.Host, req.URL.Host), nil + } + return req.URL.RequestURI(), nil + })} + derivedPath = &derived{"@path", fromRequest(func(req *http.Request) (string, error) { + return cmp.Or(req.URL.EscapedPath(), "/"), nil + })} + derivedQuery = &derived{"@query", fromRequest(func(req *http.Request) (string, error) { + return "?" + req.URL.RawQuery, nil + })} + derivedStatus = &derived{"@status", func(m message) (string, error) { + status, err := m.statusCode() + if err != nil { + return "", err + } + return strconv.Itoa(status), nil + }} +) + +// Method returns the "@method" derived component. +func Method() Component { return Component{kind: derivedMethod} } + +// TargetURI returns the "@target-uri" derived component. +func TargetURI() Component { return Component{kind: derivedTargetURI} } + +// Authority returns the "@authority" derived component. +func Authority() Component { return Component{kind: derivedAuthority} } + +// Scheme returns the "@scheme" derived component. +func Scheme() Component { return Component{kind: derivedScheme} } + +// RequestTarget returns the "@request-target" derived component. +func RequestTarget() Component { return Component{kind: derivedRequestTarget} } + +// Path returns the "@path" derived component. +func Path() Component { return Component{kind: derivedPath} } + +// Query returns the "@query" derived component. +func Query() Component { return Component{kind: derivedQuery} } + +// Status returns the "@status" derived component. It is valid in a [Base], +// but [Base.SignRequest] rejects it with [ErrResponseOnlyComponent]. +func Status() Component { return Component{kind: derivedStatus} } + +var defaultPorts = map[string]string{ + "http": "80", + "https": "443", +} + +// RequestScheme returns the request's "@scheme" value as defined by RFC 9421. +func RequestScheme(req *http.Request) (string, error) { + scheme := strings.ToLower(req.URL.Scheme) + if scheme == "" { + return "", fmt.Errorf("%w: request URL has no scheme", ErrMissingComponent) + } + return scheme, nil +} + +// RequestAuthority returns the request's "@authority" value as defined by RFC +// 9421 and normalised per RFC 9110 section 4.2.3. +func RequestAuthority(req *http.Request) (string, error) { + host := strings.ToLower(cmp.Or(req.Host, req.URL.Host)) + if host == "" { + return "", fmt.Errorf("%w: request has no host", ErrMissingComponent) + } + if port := (&url.URL{Host: host}).Port(); port == defaultPorts[strings.ToLower(req.URL.Scheme)] { + host = strings.TrimSuffix(host, ":"+port) + } + return strings.TrimSuffix(host, ":"), nil +} + +// RequestTargetURI returns the request's "@target-uri" value as defined by RFC +// 9421. +func RequestTargetURI(req *http.Request) (string, error) { + scheme, err := RequestScheme(req) + if err != nil { + return "", err + } + authority, err := RequestAuthority(req) + if err != nil { + return "", err + } + return scheme + "://" + authority + req.URL.RequestURI(), nil +} diff --git a/internal/httpsig/component_test.go b/internal/httpsig/component_test.go new file mode 100644 index 0000000..6a195f4 --- /dev/null +++ b/internal/httpsig/component_test.go @@ -0,0 +1,373 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestComponentString(t *testing.T) { + for _, test := range []struct { + component Component + want string + }{ + {Method(), `"@method"`}, + {TargetURI(), `"@target-uri"`}, + {Authority(), `"@authority"`}, + {Scheme(), `"@scheme"`}, + {RequestTarget(), `"@request-target"`}, + {Path(), `"@path"`}, + {Query(), `"@query"`}, + {Status(), `"@status"`}, + {Field("Content-Type"), `"content-type"`}, + {Field("example-dict", Key("a")), `"example-dict";key="a"`}, + {Field("example-dict", Structured()), `"example-dict";sf`}, + {Field("example-dict", StructuredAs(SFDictionary)), `"example-dict";sf`}, + {Field("example-header", ByteSequence()), `"example-header";bs`}, + {Field("expires", Trailer()), `"expires";tr`}, + {Field("signature", Key("sig1")).FromRequest(), `"signature";req;key="sig1"`}, + {Method().FromRequest(), `"@method";req`}, + {Field("x", ByteSequence(), Trailer()), `"x";bs;tr`}, + {Field("x", Trailer(), ByteSequence()), `"x";bs;tr`}, + } { + assert.Equal(t, test.want, test.component.String(), "String()") + } +} + +func TestComponentComparable(t *testing.T) { + // Using a Component as a map key panics if any kind is not comparable. + seen := map[Component]bool{} + for _, c := range []Component{ + Method(), TargetURI(), Authority(), Scheme(), RequestTarget(), Path(), Query(), Status(), + Field("x"), + Field("x", Key("a")), + Field("x", Structured()), + Field("x", StructuredAs(SFList)), + Field("x", ByteSequence()), + Field("x", Trailer()), + Method().FromRequest(), + } { + assert.False(t, seen[c], "%s is distinct", c) + seen[c] = true + } + assert.Equal(t, Field("content-digest", Key("sha-256")), Field("Content-Digest", Key("sha-256")), "same field") + assert.NotEqual(t, Field("content-digest"), Field("content-digest", Key("sha-256")), "different params") + assert.NotEqual(t, Method(), Method().FromRequest(), "req") + assert.NotEqual(t, Component{}, Method(), "zero component") + assert.Equal(t, Field("content-digest", Structured()), Field("content-digest", StructuredAs(SFDictionary)), + "Structured looks up the type when the field is built") +} + +func TestFieldEncodingLastWins(t *testing.T) { + assert.Equal(t, Field("x", ByteSequence()), Field("x", Key("a"), Structured(), ByteSequence()), "last encoding wins") + assert.Equal(t, Field("x", Key("a")), Field("x", ByteSequence(), Key("a")), "last encoding wins") +} + +// componentErrors returns the components of the *ComponentErrors in err. +func componentErrors(t *testing.T, err, target error) []Component { + t.Helper() + var joined interface{ Unwrap() []error } + errs := []error{err} + if errors.As(err, &joined) { + errs = joined.Unwrap() + } + var components []Component + for _, err := range errs { + var cerr *ComponentError + if errors.As(err, &cerr) && errors.Is(cerr, target) { + components = append(components, cerr.Component) + } + } + return components +} + +func TestNewBaseInvalidComponents(t *testing.T) { + bad := []Component{ + {}, + Field("@method"), + Field("bad name"), + Field("x", Key("A")), + Field("x-unknown", Structured()), + Field("x", StructuredAs(SFType(42))), + Field("bad name").FromRequest(), + } + _, err := NewBase(WithComponents(Method()), WithComponents(bad[:3]...), WithOptionalComponents(bad[3:]...)) + require.ErrorIs(t, err, ErrInvalidComponent, "NewBase with invalid components") + assert.ElementsMatch(t, bad, componentErrors(t, err, ErrInvalidComponent), "every invalid component is reported") + + // Field("") has the same identifier as the zero Component. + _, err = NewBase(WithComponents(Field(""))) + require.ErrorIs(t, err, ErrInvalidComponent, "NewBase with empty field name") +} + +func TestNewBaseValidComponents(t *testing.T) { + _, err := NewBase(WithComponents( + Method(), TargetURI(), Authority(), Scheme(), RequestTarget(), Path(), Query(), Status(), + Field("content-digest", Structured()), + Field("content-digest", Key("sha-256")), + Field("example-header", ByteSequence()), + Field("expires", Trailer()), + Field("signature", Key("sig1")).FromRequest(), + Method().FromRequest(), + )) + require.NoError(t, err, "NewBase") + + _, err = NewBase() + require.NoError(t, err, "NewBase without components") +} + +func TestWithComponentsOverride(t *testing.T) { + components := []Component{Method(), Path()} + opt := WithComponents(components...) + components[0] = Query() + + b, err := NewBase( + opt, + WithComponents(Field("content-type"), Method(), Field("x", StructuredAs(SFList))), + WithOptionalComponents(Field("Content-Type"), Field("a")), + WithComponents(Field("a"), Field("x", StructuredAs(SFDictionary))), + WithOptionalComponents(Method().FromRequest()), + ) + require.NoError(t, err, "NewBase") + assert.Equal(t, map[string]coveredComponent{ + `"@method"`: {Component: Method()}, + `"@path"`: {Component: Path()}, + `"content-type"`: {Component: Field("content-type"), optional: true}, + `"x";sf`: {Component: Field("x", StructuredAs(SFDictionary))}, + `"a"`: {Component: Field("a")}, + `"@method";req`: {Component: Method().FromRequest(), optional: true}, + }, b.components, "later components replace earlier ones with the same identifier, unaffected by later changes to the caller's slice") +} + +func resolveRequest(t *testing.T, req *http.Request, opts ...BaseOption) ([]resolvedComponent, error) { + t.Helper() + b, err := NewBase(opts...) + require.NoError(t, err, "NewBase") + return b.resolve(requestMessage{req}) +} + +func resolveOne(t *testing.T, req *http.Request, c Component) (string, error) { + t.Helper() + resolved, err := resolveRequest(t, req, WithComponents(c)) + if err != nil { + return "", err + } + require.Len(t, resolved, 1, "resolved components") + return resolved[0].value, nil +} + +func newRequest(t *testing.T, method, url string) *http.Request { + t.Helper() + req, err := http.NewRequest(method, url, nil) //nolint:noctx // test code + require.NoError(t, err, "NewRequest") + return req +} + +// RFC 9421 section 2.1 examples. +func TestResolveFields(t *testing.T) { + req := newRequest(t, http.MethodGet, "https://www.example.com/") + req.Header["X-Ows-Header"] = []string{" Leading and trailing whitespace. "} + req.Header["Cache-Control"] = []string{"max-age=60", " must-revalidate"} + req.Header["Example-Dict"] = []string{" a=1, b=2;x=1;y=2, c=(a b c)"} + req.Header["Example-Dict2"] = []string{" a=1, b=2;x=1;y=2, c=(a b c), d"} + req.Header["Example-Header"] = []string{"value, with, lots", "of, commas"} + req.Header["X-Empty-Header"] = []string{""} + req.Header["Example-List"] = []string{"a, b;x=1", " (c d)"} + req.Header["Example-Item"] = []string{` "x";a=?0 `} + req.Trailer = http.Header{"Expires": {"Wed, 9 Nov 2022 07:28:00 GMT"}} + + for _, test := range []struct { + component Component + want string + }{ + {Field("x-ows-header"), "Leading and trailing whitespace."}, + {Field("cache-control"), "max-age=60, must-revalidate"}, + {Field("example-dict"), "a=1, b=2;x=1;y=2, c=(a b c)"}, + {Field("x-empty-header"), ""}, + {Field("example-dict", StructuredAs(SFDictionary)), "a=1, b=2;x=1;y=2, c=(a b c)"}, + {Field("example-dict2", Key("a")), "1"}, + {Field("example-dict2", Key("d")), "?1"}, + {Field("example-dict2", Key("b")), "2;x=1;y=2"}, + {Field("example-dict2", Key("c")), "(a b c)"}, + {Field("example-header"), "value, with, lots, of, commas"}, + {Field("example-header", ByteSequence()), ":dmFsdWUsIHdpdGgsIGxvdHM=:, :b2YsIGNvbW1hcw==:"}, + {Field("example-list", StructuredAs(SFList)), "a, b;x=1, (c d)"}, + {Field("example-item", StructuredAs(SFItem)), `"x";a=?0`}, + {Field("expires", Trailer()), "Wed, 9 Nov 2022 07:28:00 GMT"}, + } { + got, err := resolveOne(t, req, test.component) + require.NoError(t, err, "resolve %s", test.component) + assert.Equal(t, test.want, got, "value of %s", test.component) + } +} + +func TestResolveStructuredInvalid(t *testing.T) { + req := newRequest(t, http.MethodGet, "https://example.com/") + req.Header.Set("Content-Digest", "not a dictionary!") + for _, c := range []Component{ + Field("content-digest", Structured()), + Field("content-digest", Key("sha-256")), + } { + _, err := resolveOne(t, req, c) + require.ErrorIs(t, err, ErrInvalidComponent, "resolve %s", c) + var cerr *ComponentError + require.ErrorAs(t, err, &cerr, "resolve %s", c) + assert.Equal(t, c, cerr.Component, "ComponentError.Component") + } +} + +// RFC 9421 section 2.2 examples. +func TestResolveDerived(t *testing.T) { + for _, test := range []struct { + method, url string + host string + component Component + want string + }{ + {"POST", "https://www.example.com/path?param=value", "", Method(), "POST"}, + {"", "https://www.example.com/", "", Method(), "GET"}, + {"POST", "https://www.example.com/path?param=value", "", TargetURI(), "https://www.example.com/path?param=value"}, + {"GET", "https://www.example.com/path#frag", "", TargetURI(), "https://www.example.com/path"}, + {"GET", "HTTPS://WWW.Example.COM:443/Path", "", TargetURI(), "https://www.example.com/Path"}, + {"POST", "https://www.example.com/path?param=value", "", Authority(), "www.example.com"}, + {"GET", "https://www.example.com:443/", "", Authority(), "www.example.com"}, + {"GET", "http://www.example.com:80/", "", Authority(), "www.example.com"}, + {"GET", "http://www.example.com:443/", "", Authority(), "www.example.com:443"}, + {"GET", "https://www.example.com:8443/", "", Authority(), "www.example.com:8443"}, + {"GET", "https://[::1]:443/", "", Authority(), "[::1]"}, + {"GET", "https://[::1]/", "", Authority(), "[::1]"}, + {"GET", "https://www.example.com/", "Other.Example.com:443", Authority(), "other.example.com"}, + {"POST", "http://www.example.com/path?param=value", "", Scheme(), "http"}, + {"POST", "https://www.example.com/path?param=value", "", RequestTarget(), "/path?param=value"}, + {"CONNECT", "https://www.example.com:80", "", RequestTarget(), "www.example.com:80"}, + {"GET", "https://www.example.com/path?param=value", "", Path(), "/path"}, + {"GET", "https://www.example.com", "", Path(), "/"}, + {"GET", "https://www.example.com/a%2Fb", "", Path(), "/a%2Fb"}, + {"GET", "https://www.example.com/path?param=value&foo=bar&baz=bat%2Dman", "", Query(), "?param=value&foo=bar&baz=bat%2Dman"}, + {"POST", "https://www.example.com/path?queryString", "", Query(), "?queryString"}, + {"GET", "https://www.example.com/path", "", Query(), "?"}, + {"GET", "https://www.example.com:/", "", Authority(), "www.example.com"}, + } { + req := newRequest(t, test.method, test.url) + req.Method = test.method + if test.host != "" { + req.Host = test.host + } + got, err := resolveOne(t, req, test.component) + require.NoError(t, err, "resolve %s for %s %s", test.component, test.method, test.url) + assert.Equal(t, test.want, got, "value of %s for %s %s", test.component, test.method, test.url) + } +} + +func TestResolveMissing(t *testing.T) { + req := newRequest(t, http.MethodGet, "https://example.com/") + req.Header.Set("Content-Digest", "sha-256=:AAAA:") + + for _, c := range []Component{ + Field("content-type"), + Field("content-digest", Key("sha-512")), + Field("expires", Trailer()), + } { + _, err := resolveOne(t, req, c) + require.ErrorIs(t, err, ErrMissingComponent, "resolve %s", c) + } + + resolved, err := resolveRequest(t, req, + WithComponents(Method()), + WithOptionalComponents( + Field("content-type"), + Field("content-digest", Key("sha-512")), + Field("content-digest", Key("sha-256")), + Field("expires", Trailer()), + ), + ) + require.NoError(t, err, "resolve optional components") + assert.ElementsMatch(t, []resolvedComponent{ + {component: Method(), value: http.MethodGet}, + {component: Field("content-digest", Key("sha-256")), value: ":AAAA:"}, + }, resolved, "absent optional components are dropped") +} + +func TestResolveResponseOnly(t *testing.T) { + req := newRequest(t, http.MethodGet, "https://example.com/") + req.Header.Set("Signature", "sig1=:AAAA:") + for _, c := range []Component{Status(), Field("signature", Key("sig1")).FromRequest(), Method().FromRequest()} { + _, err := resolveOne(t, req, c) + require.ErrorIs(t, err, ErrResponseOnlyComponent, "resolve %s", c) + } +} + +func TestResolveCRLF(t *testing.T) { + req := newRequest(t, http.MethodGet, "https://example.com/") + req.Header["X-Bad"] = []string{"a\r\nb"} + _, err := resolveOne(t, req, Field("x-bad")) + require.ErrorIs(t, err, ErrInvalidComponent, "value with CR/LF") + + req.Method = "GET\n" + _, err = resolveOne(t, req, Method()) + require.ErrorIs(t, err, ErrInvalidComponent, "method with LF") +} + +func TestResolveSpecialFields(t *testing.T) { + req := newRequest(t, http.MethodGet, "https://example.com/") + _, err := resolveOne(t, req, Field("host")) + require.ErrorIs(t, err, ErrInvalidComponent, "host") + require.ErrorContains(t, err, "@authority", "host error points to @authority") + + for _, name := range []string{"transfer-encoding", "trailer"} { + _, err := resolveOne(t, req, Field(name)) + require.ErrorIs(t, err, ErrInvalidComponent, "resolve %s", name) + } +} + +type testBody struct{} + +func (testBody) Read([]byte) (int, error) { return 0, nil } +func (testBody) Close() error { return nil } + +func TestResolveContentLength(t *testing.T) { + for _, test := range []struct { + name string + method string + body bool + contentLength int64 + chunked bool + want string // empty means missing + }{ + {"known length", "POST", true, 18, false, "18"}, + {"unknown length", "POST", true, -1, false, ""}, + {"body with zero length", "POST", true, 0, false, ""}, + {"chunked", "POST", true, 18, true, ""}, + {"no body POST", "POST", false, 0, false, "0"}, + {"no body PUT", "PUT", false, 0, false, "0"}, + {"no body GET", "GET", false, 0, false, ""}, + {"no body DELETE", "DELETE", false, 0, false, ""}, + } { + t.Run(test.name, func(t *testing.T) { + req := newRequest(t, test.method, "https://example.com/") + // Header Content-Length is ignored, as net/http ignores it. + req.Header.Set("Content-Length", "1234") + if test.body { + req.Body = testBody{} + } + req.ContentLength = test.contentLength + if test.chunked { + req.TransferEncoding = []string{"chunked"} + } + + got, err := resolveOne(t, req, Field("content-length")) + if test.want == "" { + require.ErrorIs(t, err, ErrMissingComponent, "content-length") + return + } + require.NoError(t, err, "content-length") + assert.Equal(t, test.want, got, "content-length") + }) + } +} diff --git a/internal/httpsig/doc.go b/internal/httpsig/doc.go new file mode 100644 index 0000000..9f881e8 --- /dev/null +++ b/internal/httpsig/doc.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +// Package httpsig implements the generation of [RFC 9421] HTTP Message +// Signatures using [crypto.Signer]. +// +// When signing [http.Request], users should first create a [Base] instance, +// which acts as an immutable "template" for signature. [Base.SignRequest] can +// then be used to used to sign individual [net/http.Request]s. [Transport] +// does the same for every request sent through an [net/http.Client]. +// +// TODO: Signing of [net/http.Response] is not yet implemented. +// TODO: Verification is not implemented. +// +// [RFC 9421]: https://www.rfc-editor.org/rfc/rfc9421 +package httpsig diff --git a/internal/httpsig/errors.go b/internal/httpsig/errors.go new file mode 100644 index 0000000..7aae987 --- /dev/null +++ b/internal/httpsig/errors.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "errors" + "fmt" +) + +// Errors returned by [NewBase] and [Base.SignRequest], for use with +// [errors.Is]. +var ( + // ErrInvalidComponent is a component that is malformed, can't be covered + // in this message, or whose value can't be parsed as it requires. + ErrInvalidComponent = errors.New("invalid component") + + // ErrResponseOnlyComponent is a response-only component ("@status" or + // ";req") in a request. + ErrResponseOnlyComponent = errors.New("component is only valid for responses") + + // ErrMissingComponent is a covered component the message doesn't have. + ErrMissingComponent = errors.New("covered component not present in message") + + // ErrInvalidParameter is a signature parameter name or value that is + // invalid, reserved, undeclared or can't be serialised. + ErrInvalidParameter = errors.New("invalid signature parameter") + + // ErrMissingParameter is a parameter declared with [WithExtraParam] that + // was given no value. + ErrMissingParameter = errors.New("declared signature parameter has no value") + + // ErrInvalidLabel is a [WithSigLabel] label that is not a valid sf-key. + ErrInvalidLabel = errors.New("invalid signature label") + + // ErrLabelExists is a [WithSigLabel] label already on the request. + ErrLabelExists = errors.New("signature label already in use") + + // ErrMalformedSignature is an existing Signature or Signature-Input field + // that can't be parsed. + ErrMalformedSignature = errors.New("malformed existing Signature or Signature-Input field") + + // ErrUnsupportedKey is a signer whose public key has no RFC 9421 + // algorithm. + ErrUnsupportedKey = errors.New("unsupported public key type") + + // ErrUnsupportedSignerOpts is a [crypto.SignerOpts] that selects no RFC + // 9421 algorithm for the signer's key. + ErrUnsupportedSignerOpts = errors.New("signer options do not match an RFC 9421 algorithm") + + // ErrNonceNotEnabled is a [WithNonceValue] for a [Base] made with + // [WithoutNonce]. + ErrNonceNotEnabled = errors.New("nonce value given for a base without nonce") +) + +// ComponentError wraps one of the package's sentinel errors with the +// offending component. +type ComponentError struct { + Component Component + Err error +} + +func (e *ComponentError) Error() string { + return fmt.Sprintf("component %s: %v", e.Component, e.Err) +} + +func (e *ComponentError) Unwrap() error { + return e.Err +} + +func componentErr(c Component, err error) error { + return &ComponentError{Component: c, Err: err} +} diff --git a/internal/httpsig/field.go b/internal/httpsig/field.go new file mode 100644 index 0000000..49c62fb --- /dev/null +++ b/internal/httpsig/field.go @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "encoding/base64" + "errors" + "fmt" + "strings" + + "github.com/shogo82148/go-sfv" +) + +// field is an HTTP field component (RFC 9421 section 2.1). +type field struct { + fieldName string + trailer bool + enc fieldEncoding +} + +// fieldEncoding turns a field's raw line values into its base value. +// Implementations must be comparable so that [Component] is. +type fieldEncoding interface { + params() sfv.Parameters + validate() error + // encode returns the value, or false if it is absent (a missing + // dictionary member). + encode(lines []string) (value string, present bool, err error) +} + +// Field returns an HTTP field component. name is case-insensitive and is +// stored lowercased. +func Field(name string, opts ...FieldOption) Component { + f := &field{fieldName: strings.ToLower(name), enc: rawEncoding{}} + for _, opt := range opts { + opt(f) + } + return Component{kind: *f} +} + +// FieldOption sets a parameter of a [Field] component. A field has exactly +// one value encoding ([Key], [Structured], [StructuredAs] or +// [ByteSequence]); the last one given wins. +type FieldOption func(*field) + +// Key adds ";key=member", covering one member of a dictionary field. +func Key(member string) FieldOption { + return func(f *field) { f.enc = dictMemberEncoding{key: member} } +} + +// Structured adds ";sf", strictly re-serialising the field as a structured +// field. Its type comes from a built-in table of fields whose type is defined +// by an RFC; other fields need [StructuredAs]. +func Structured() FieldOption { + return func(f *field) { f.enc = structuredEncoding{typ: sfTypes[f.fieldName]} } +} + +// StructuredAs adds ";sf", strictly re-serialising the field as a structured +// field of type t. +func StructuredAs(t SFType) FieldOption { + return func(f *field) { f.enc = structuredEncoding{typ: t} } +} + +// ByteSequence adds ";bs", wrapping each field line value as a byte sequence. +func ByteSequence() FieldOption { + return func(f *field) { f.enc = byteSeqEncoding{} } +} + +// Trailer adds ";tr", taking the value from the trailer fields. +func Trailer() FieldOption { + return func(f *field) { f.trailer = true } +} + +func (f field) name() string { return f.fieldName } + +func (f field) params() sfv.Parameters { + params := f.enc.params() + if f.trailer { + params = append(params, sfv.Parameter{Key: "tr", Value: true}) + } + return params +} + +func (f field) validate() error { + if !validToken(f.fieldName) { + return fmt.Errorf("%q is not a valid field name", f.fieldName) + } + return f.enc.validate() +} + +func (f field) resolve(m message) (string, bool, error) { + lines, ok, err := m.fieldValues(f.fieldName, f.trailer) + if err != nil || !ok { + return "", false, err + } + return f.enc.encode(lines) +} + +// rawEncoding is a field's plain value (RFC 9421 section 2.1). +type rawEncoding struct{} + +func (rawEncoding) params() sfv.Parameters { return nil } +func (rawEncoding) validate() error { return nil } + +func (rawEncoding) encode(lines []string) (string, bool, error) { + values := make([]string, len(lines)) + for i, v := range lines { + values[i] = strings.Trim(v, " \t") + } + return strings.Join(values, ", "), true, nil +} + +// byteSeqEncoding is ";bs" (RFC 9421 section 2.1.3). +type byteSeqEncoding struct{} + +func (byteSeqEncoding) params() sfv.Parameters { + return sfv.Parameters{{Key: "bs", Value: true}} +} + +func (byteSeqEncoding) validate() error { return nil } + +func (byteSeqEncoding) encode(lines []string) (string, bool, error) { + values := make([]string, len(lines)) + for i, v := range lines { + values[i] = ":" + base64.StdEncoding.EncodeToString([]byte(strings.Trim(v, " \t"))) + ":" + } + return strings.Join(values, ", "), true, nil +} + +// SFType is the top-level type of a structured field, needed to +// re-serialise it strictly for ;sf. +type SFType int + +// Structured field types (RFC 9651 section 3). +const ( + SFItem SFType = iota + 1 + SFList + SFDictionary +) + +// sfTypes holds the fields whose structured type is defined by an RFC, for +// [Structured]. +var sfTypes = map[string]SFType{ + "accept-ch": SFList, // RFC 8942 + "accept-signature": SFDictionary, // RFC 9421 + "cache-status": SFList, // RFC 9211 + "cdn-cache-control": SFDictionary, // RFC 9213 + "client-cert": SFItem, // RFC 9440 + "client-cert-chain": SFList, // RFC 9440 + "content-digest": SFDictionary, // RFC 9530 + "priority": SFDictionary, // RFC 9218 + "proxy-status": SFList, // RFC 9209 + "repr-digest": SFDictionary, // RFC 9530 + "signature": SFDictionary, // RFC 9421 + "signature-input": SFDictionary, // RFC 9421 + "want-content-digest": SFDictionary, // RFC 9530 + "want-repr-digest": SFDictionary, // RFC 9530 +} + +// structuredEncoding is ";sf" (RFC 9421 section 2.1.1). +type structuredEncoding struct { + typ SFType // zero if Structured found no type for the field +} + +func (structuredEncoding) params() sfv.Parameters { + return sfv.Parameters{{Key: "sf", Value: true}} +} + +func (e structuredEncoding) validate() error { + switch e.typ { + case SFItem, SFList, SFDictionary: + return nil + case 0: + return errors.New("structured type of field is not known, use StructuredAs") + default: + return fmt.Errorf("unknown structured field type %d", e.typ) + } +} + +func (e structuredEncoding) encode(lines []string) (string, bool, error) { + var ( + value string + err error + ) + switch e.typ { + case SFItem: + var item sfv.Item + if item, err = sfv.DecodeItem(lines); err == nil { + value, err = sfv.EncodeItem(item) + } + case SFList: + var list sfv.List + if list, err = sfv.DecodeList(lines); err == nil { + value, err = sfv.EncodeList(list) + } + case SFDictionary: + var dict sfv.Dictionary + if dict, err = sfv.DecodeDictionary(lines); err == nil { + value, err = sfv.EncodeDictionary(dict) + } + default: + err = fmt.Errorf("unknown structured field type %d", e.typ) + } + if err != nil { + return "", false, fmt.Errorf("%w: re-serialise structured field: %w", ErrInvalidComponent, err) + } + return value, true, nil +} + +// dictMemberEncoding is ";key" (RFC 9421 section 2.1.2). +type dictMemberEncoding struct { + key string +} + +func (e dictMemberEncoding) params() sfv.Parameters { + return sfv.Parameters{{Key: "key", Value: e.key}} +} + +func (e dictMemberEncoding) validate() error { + if !validKey(e.key) { + return fmt.Errorf("%q is not a valid dictionary key", e.key) + } + return nil +} + +func (e dictMemberEncoding) encode(lines []string) (string, bool, error) { + dict, err := sfv.DecodeDictionary(lines) + if err != nil { + return "", false, fmt.Errorf("%w: parse as dictionary: %w", ErrInvalidComponent, err) + } + member := dict.Get(e.key) + if member.Value == nil { + return "", false, nil + } + value, err := encodeMember(member) + if err != nil { + return "", false, fmt.Errorf("%w: serialise member %q: %w", ErrInvalidComponent, e.key, err) + } + return value, true, nil +} diff --git a/internal/httpsig/message.go b/internal/httpsig/message.go new file mode 100644 index 0000000..efdcb8f --- /dev/null +++ b/internal/httpsig/message.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "fmt" + "net/http" + "strconv" +) + +// message is a message whose components can be covered. +type message interface { + // request returns the request for request-derived components. + request() (*http.Request, error) + // statusCode returns the status for @status. + statusCode() (int, error) + // fieldValues returns the line values of a header (or trailer) field, or + // false if the message does not have it. + fieldValues(name string, trailer bool) ([]string, bool, error) + // related returns the message that ";req" refers to. + related() (message, error) +} + +// requestMessage is an outgoing request, as net/http will send it. +type requestMessage struct { + req *http.Request +} + +func (m requestMessage) request() (*http.Request, error) { + return m.req, nil +} + +func (requestMessage) statusCode() (int, error) { + return 0, ErrResponseOnlyComponent +} + +func (requestMessage) related() (message, error) { + return nil, ErrResponseOnlyComponent +} + +func (m requestMessage) fieldValues(name string, trailer bool) ([]string, bool, error) { + switch name { + case "host": + return nil, false, fmt.Errorf("%w: cover @authority instead of host", ErrInvalidComponent) + case "transfer-encoding", "trailer": + return nil, false, fmt.Errorf("%w: %s is generated by the transport", ErrInvalidComponent, name) + } + if trailer { + values := m.req.Trailer.Values(name) + return values, values != nil, nil + } + if name == "content-length" { + // net/http ignores Content-Length in Header and writes its own. + length, ok := m.contentLength() + if !ok { + return nil, false, nil + } + return []string{strconv.FormatInt(length, 10)}, true, nil + } + values := m.req.Header.Values(name) + return values, values != nil, nil +} + +// contentLength returns the Content-Length net/http will send, or false if it +// will not send one or its value cannot be known before sending. +func (m requestMessage) contentLength() (int64, bool) { + req := m.req + if len(req.TransferEncoding) > 0 { + return 0, false + } + if req.Body == nil || req.Body == http.NoBody { + // HTTP/1.1 and HTTP/2 both send a zero length for these methods. + switch req.Method { + case http.MethodPost, http.MethodPut, http.MethodPatch: + return 0, true + } + return 0, false + } + if req.ContentLength > 0 { + return req.ContentLength, true + } + return 0, false +} diff --git a/internal/httpsig/rfc_test.go b/internal/httpsig/rfc_test.go new file mode 100644 index 0000000..6e7b814 --- /dev/null +++ b/internal/httpsig/rfc_test.go @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/pem" + "math/big" + "net/http" + "strings" + "testing" + "time" + + "github.com/shogo82148/go-sfv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.amutable.dev/quarry/internal/cryptoext" +) + +// Test keys from RFC 9421 Appendix B.1. + +// test-key-rsa (B.1.1). +const rfcRSAKeyPEM = `-----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-----` + +// test-key-rsa-pss (B.1.2), from its JWK form: the PEM form uses the +// RSASSA-PSS OID, which crypto/x509 can't parse. +var rfcRSAPSSKeyJWK = map[string]string{ + "n": "r4tmm3r20Wd_PbqvP1s2-QEtvpuRaV8Yq40gjUR8y2Rjxa6dpG2GXHbPfvMs8ct-Lh1GH45x28Rw3Ry53mm-oAXjyQ86OnDkZ5N8lYbggD4O3w6M6pAvLkhk95AndTrifbIFPNU8PPMO7OyrFAHqgDsznjPFmTOtCEcN2Z1FpWgchwuYLPL-Wokqltd11nqqzi-bJ9cvSKADYdUAAN5WUtzdpiy6LbTgSxP7ociU4Tn0g5I6aDZJ7A8Lzo0KSyZYoA485mqcO0GVAdVw9lq4aOT9v6d-nb4bnNkQVklLQ3fVAvJm-xdDOp9LCNCN48V2pnDOkFV6-U9nV5oyc6XI2w", + "e": "AQAB", + "d": "lAfIqfpCYomVShfAKnwf2lD9I0wKjkHsCtZCif4kAlwQqqW6N-tIL3bdOR-VWf0Q1ZBIDtpO91UrG7pansyrPERbNrRJlPiYEyPTHkCT1nD-l2isuiyGLNBNnFoKfBgA4KAbPJZQatFIV9Cn34JSHnpN5-2ehreGBYHtkwHFtlmzeF3yu5bqRcqOhx8lkYmBzDAEUFyyXjknU5-WjAT9DzuG0MpOTkcU1EnjnIjyVBZLUB5Lxm8puyq8hH8B_E5LNC-1oc8j-tDy98UvRTTiYvZvs87cGCFxg0LijNhg7CE3g9piNqB6DzMgA9MHSOwcElVtfKdYfo4H3OHZXsSmEQ", + "p": "5V-6ISI5yEaCFXm-fk1EM2xwAWekePVCAyvr9QbTlFOCZwt9WwjUjhtKRusi5Uq-IYZ_tq2WRE4As4b_FHEMtp2AER43IcvmXPqKFBoUktVDS7dThIHrsnRi1U7dHqVdwiMEMe5jxKNgnsKLpnq-4NyhoS6OeWu1SFozG9J9xQk", + "q": "w-wIde17W5Y0Cphp3ZZ0uM8OUq1AkrV2IKauqYHaDxAT32EM4ci2MMER2nIUEo4g_42lW0zYouFFqONwv0-HyOsgPpdSqKRC5WLgn0VXabjaNcy6KhNPXeJ0AgtqdiDwPeJ2_L_eKwNWQ43RfdQBUquAwSd7SEmmQ8sViqB628M", +} + +// test-key-ecc-p256 (B.1.3). +const rfcECCP256KeyD = "UpuF81l-kOxbjf7T4mNSv0r5tN67Gim7rnf6EFpcYDs" + +// test-key-ed25519 (B.1.4). +const rfcEd25519KeyD = "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU" + +func b64url(t *testing.T, s string) []byte { + t.Helper() + b, err := base64.RawURLEncoding.DecodeString(s) + require.NoError(t, err, "decode base64url") + return b +} + +func b64std(t *testing.T, s string) []byte { + t.Helper() + b, err := base64.StdEncoding.DecodeString(s) + require.NoError(t, err, "decode base64") + return b +} + +func rfcRSAKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + block, _ := pem.Decode([]byte(rfcRSAKeyPEM)) + require.NotNil(t, block, "decode PEM") + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + require.NoError(t, err, "parse test-key-rsa") + return key +} + +func rfcRSAPSSKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + num := func(k string) *big.Int { return new(big.Int).SetBytes(b64url(t, rfcRSAPSSKeyJWK[k])) } + key := &rsa.PrivateKey{ + PublicKey: rsa.PublicKey{N: num("n"), E: int(num("e").Int64())}, + D: num("d"), + Primes: []*big.Int{num("p"), num("q")}, + } + require.NoError(t, key.Validate(), "validate test-key-rsa-pss") + key.Precompute() + return key +} + +func rfcECCP256Key(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := ecdsa.ParseRawPrivateKey(elliptic.P256(), b64url(t, rfcECCP256KeyD)) + require.NoError(t, err, "parse test-key-ecc-p256") + return key +} + +func rfcEd25519Key(t *testing.T) ed25519.PrivateKey { + t.Helper() + return ed25519.NewKeyFromSeed(b64url(t, rfcEd25519KeyD)) +} + +// rfcTestRequest returns the test-request message of RFC 9421 Appendix B.2. +func rfcTestRequest(t *testing.T) *http.Request { + t.Helper() + req, err := http.NewRequest(http.MethodPost, "https://example.com/foo?param=Value&Pet=dog", strings.NewReader(`{"hello": "world"}`)) //nolint:noctx // test code + require.NoError(t, err, "build test-request") + req.Header.Set("Date", "Tue, 20 Apr 2021 02:07:55 GMT") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Digest", "sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:") + return req +} + +var rfcCreated = time.Unix(1618884473, 0) + +// signatureFromHeader returns the signature bytes under label in h. +func signatureFromHeader(t *testing.T, h http.Header, label string) []byte { + t.Helper() + dict, err := sfv.DecodeDictionary(h.Values("Signature")) + require.NoError(t, err, "parse Signature") + sig, ok := dict.Get(label).Value.([]byte) + require.True(t, ok, "Signature has a byte sequence for %q", label) + return sig +} + +// verifyRFC verifies an RFC 9421 format signature over base. +func verifyRFC(t *testing.T, pub crypto.PublicKey, opts crypto.SignerOpts, base string, sig []byte) { + t.Helper() + if ecPub, ok := pub.(*ecdsa.PublicKey); ok { + size := (ecPub.Curve.Params().BitSize + 7) / 8 + require.Len(t, sig, 2*size, "ecdsa r||s signature length") + der, err := asn1.Marshal(struct{ R, S *big.Int }{ + new(big.Int).SetBytes(sig[:size]), + new(big.Int).SetBytes(sig[size:]), + }) + require.NoError(t, err, "convert r||s to ASN.1") + sig = der + } + verifier, err := cryptoext.NewVerifier(pub) + require.NoError(t, err, "make verifier") + ok, err := cryptoext.VerifyMessage(verifier, []byte(base), sig, opts) + require.NoError(t, err, "verify signature") + assert.True(t, ok, "signature verifies over base:\n%s", base) +} + +var rfcPSSOpts = &rsa.PSSOptions{Hash: crypto.SHA512, SaltLength: rsa.PSSSaltLengthEqualsHash} + +// B.2.1. SignRequest randomises the parameter order, so the base is built +// from the vector's exact parameter list. +func TestRFC9421_B21(t *testing.T) { + key := rfcRSAPSSKey(t) + input, base, err := signatureBase(nil, sfv.Parameters{ + {Key: "created", Value: int64(1618884473)}, + {Key: "keyid", Value: "test-key-rsa-pss"}, + {Key: "nonce", Value: "b3k2pp5k7z-50gnwp.yemd"}, + }) + require.NoError(t, err, "build signature base") + + const wantBase = `"@signature-params": ();created=1618884473;keyid="test-key-rsa-pss";nonce="b3k2pp5k7z-50gnwp.yemd"` + assert.Equal(t, wantBase, string(base), "signature base") + member, err := sfv.EncodeDictionary(sfv.Dictionary{{Key: "sig-b21", Item: input}}) + require.NoError(t, err, "encode Signature-Input") + assert.Equal(t, `sig-b21=();created=1618884473;keyid="test-key-rsa-pss";nonce="b3k2pp5k7z-50gnwp.yemd"`, member, "Signature-Input") + + rfcSig := b64std(t, "d2pmTvmbncD3xQm8E9ZV2828BjQWGgiwAaw5bAkgibUopemLJcWDy/lkbbHAve4cRAtx31Iq786U7it++wgGxbtRxf8Udx7zFZsckzXaJMkA7ChG52eSkFxykJeNqsrWH5S+oxNFlD4dzVuwe8DhTSja8xxbR/Z2cOGdCbzR72rgFWhzx2VjBqJzsPLMIQKhO4DGezXehhWwE56YCE+O6c0mKZsfxVrogUvA4HELjVKWmAvtl6UnCh8jYzuVG5WSb/QEVPnP5TmcAnLH1g+s++v6d4s8m0gCw1fV5/SITLq9mhho8K3+7EPYTU8IU1bLhdxO5Nyt8C8ssinQ98Xw9Q==") + verifyRFC(t, key.Public(), rfcPSSOpts, wantBase, rfcSig) +} + +func TestRFC9421_SignRequest(t *testing.T) { + for _, test := range []struct { + name string + components []Component + baseOpts []BaseOption + key crypto.Signer + keyID string + label string + verifyOpts crypto.SignerOpts + wantInput string + wantBase string + // rfcSig is the published signature, which must verify over + // wantBase. + rfcSig string + // exact means our signature must equal rfcSig byte for byte. + exact bool + }{ + { + name: "B.2.3", + components: []Component{ + Field("date"), Method(), Path(), Query(), Authority(), + Field("content-type"), Field("content-digest"), Field("content-length"), + }, + key: rfcRSAPSSKey(t), + keyID: "test-key-rsa-pss", + label: "sig-b23", + verifyOpts: rfcPSSOpts, + wantInput: `sig-b23=("date" "@method" "@path" "@query" "@authority" "content-type" "content-digest" "content-length");created=1618884473;keyid="test-key-rsa-pss"`, + wantBase: `"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"`, + rfcSig: "bbN8oArOxYoyylQQUU6QYwrTuaxLwjAC9fbY2F6SVWvh0yBiMIRGOnMYwZ/5MR6fb0Kh1rIRASVxFkeGt683+qRpRRU5p2voTp768ZrCUb38K0fUxN0O0iC59DzYx8DFll5GmydPxSmme9v6ULbMFkl+V5B1TP/yPViV7KsLNmvKiLJH1pFkh/aYA2HXXZzNBXmIkoQoLd7YfW91kE9o/CCoC1xMy7JA1ipwvKvfrs65ldmlu9bpG6A9BmzhuzF8Eim5f8ui9eH8LZH896+QIF61ka39VBrohr9iyMUJpvRX2Zbhl5ZJzSRxpJyoEZAFL2FUo5fTIztsDZKEgM4cUA==", + }, + { + name: "B.2.6", + components: []Component{ + Field("date"), Method(), Path(), Authority(), + Field("content-type"), Field("content-length"), + }, + key: rfcEd25519Key(t), + keyID: "test-key-ed25519", + label: "sig-b26", + verifyOpts: crypto.Hash(0), + wantInput: `sig-b26=("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"`, + wantBase: `"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"`, + rfcSig: "wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==", + exact: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + b, err := NewBase(append([]BaseOption{WithComponents(test.components...), WithoutAlg(), WithoutNonce()}, test.baseOpts...)...) + require.NoError(t, err, "NewBase") + ids := make([]string, len(test.components)) + for i, c := range test.components { + ids[i] = c.String() + } + + // SignRequest randomises the order, so check the vector with + // its own order. + req := rfcTestRequest(t) + resolved, err := b.resolve(requestMessage{req}) + require.NoError(t, err, "resolve") + input, base, err := signatureBase(orderAs(t, resolved, ids), sfv.Parameters{ + {Key: "created", Value: rfcCreated.Unix()}, + {Key: "keyid", Value: test.keyID}, + }) + require.NoError(t, err, "build signature base") + assert.Equal(t, test.wantBase, string(base), "signature base") + member, err := sfv.EncodeDictionary(sfv.Dictionary{{Key: test.label, Item: input}}) + require.NoError(t, err, "encode Signature-Input") + assert.Equal(t, test.wantInput, member, "Signature-Input") + + rfcSig := b64std(t, test.rfcSig) + verifyRFC(t, test.key.Public(), test.verifyOpts, test.wantBase, rfcSig) + if test.exact { + alg, opts, err := chooseAlgorithm(test.key.Public(), nil) + require.NoError(t, err, "chooseAlgorithm") + sig, err := alg.SignMessage(test.key, base, opts) + require.NoError(t, err, "SignMessage") + assert.Equal(t, rfcSig, sig, "signature matches RFC") + } + + label, err := b.SignRequest(test.key, req, + WithCreated(rfcCreated), WithKeyID(test.keyID), WithSigLabel(test.label)) + require.NoError(t, err, "SignRequest") + assert.Equal(t, test.label, label, "returned label") + got := signatureInput(t, req.Header, label) + assert.ElementsMatch(t, ids, coveredIDs(t, got), "covered components") + assert.ElementsMatch(t, []string{"created", "keyid"}, paramKeys(got.Parameters), "parameters") + assert.Equal(t, rfcCreated.Unix(), got.Parameters.Get("created"), "created") + assert.Equal(t, test.keyID, got.Parameters.Get("keyid"), "keyid") + verifyRFC(t, test.key.Public(), test.verifyOpts, rebuildBase(t, b, req, label), signatureFromHeader(t, req.Header, label)) + }) + } +} + +// RFC 9421 has no vector for the remaining algorithms, so check that each +// produces a valid signature in the RFC format over a known base. +func TestAlgorithms(t *testing.T) { + p384, err := ecdsa.GenerateKey(elliptic.P384(), nil) + require.NoError(t, err, "generate P-384 key") + + for _, test := range []struct { + name string + key crypto.Signer + signerOpts crypto.SignerOpts + verifyOpts crypto.SignerOpts + alg string + }{ + {"ed25519", rfcEd25519Key(t), nil, crypto.Hash(0), "ed25519"}, + {"ed25519-options", rfcEd25519Key(t), &ed25519.Options{}, crypto.Hash(0), "ed25519"}, + {"ecdsa-p256", rfcECCP256Key(t), nil, crypto.SHA256, "ecdsa-p256-sha256"}, + {"ecdsa-p384", p384, nil, crypto.SHA384, "ecdsa-p384-sha384"}, + {"rsa-pss-default", rfcRSAPSSKey(t), nil, rfcPSSOpts, "rsa-pss-sha512"}, + {"rsa-pss-salt-64", rfcRSAPSSKey(t), &rsa.PSSOptions{Hash: crypto.SHA512, SaltLength: 64}, rfcPSSOpts, "rsa-pss-sha512"}, + {"rsa-v1_5", rfcRSAKey(t), crypto.SHA256, crypto.SHA256, "rsa-v1_5-sha256"}, + } { + t.Run(test.name, func(t *testing.T) { + b, err := NewBase(WithComponents(Method(), TargetURI()), WithoutNonce()) + require.NoError(t, err, "NewBase") + + req, err := http.NewRequest(http.MethodGet, "https://repo.example.com/metadata/1.root.json", nil) //nolint:noctx // test code + require.NoError(t, err, "NewRequest") + opts := []SignOption{WithCreated(rfcCreated), WithKeyID("k")} + if test.signerOpts != nil { + opts = append(opts, WithSignerOpts(test.signerOpts)) + } + label, err := b.SignRequest(test.key, req, opts...) + require.NoError(t, err, "SignRequest") + + input := signatureInput(t, req.Header, label) + assert.ElementsMatch(t, []string{`"@method"`, `"@target-uri"`}, coveredIDs(t, input), "covered components") + assert.ElementsMatch(t, []string{"alg", "created", "keyid"}, paramKeys(input.Parameters), "parameters") + assert.Equal(t, test.alg, input.Parameters.Get("alg"), "alg") + base := rebuildBase(t, b, req, label) + assert.Contains(t, base, "\"@target-uri\": https://repo.example.com/metadata/1.root.json\n", "signature base") + sig := signatureFromHeader(t, req.Header, label) + verifyRFC(t, test.key.Public(), test.verifyOpts, base, sig) + + if test.alg == "rsa-v1_5-sha256" { + // PKCS #1 v1.5 is deterministic. + digest := crypto.SHA256.New() + digest.Write([]byte(base)) + want, err := rsa.SignPKCS1v15(nil, rfcRSAKey(t), crypto.SHA256, digest.Sum(nil)) + require.NoError(t, err, "SignPKCS1v15") + assert.Equal(t, want, sig, "rsa-v1_5-sha256 signature") + } + }) + } +} diff --git a/internal/httpsig/sfv.go b/internal/httpsig/sfv.go new file mode 100644 index 0000000..ee161e3 --- /dev/null +++ b/internal/httpsig/sfv.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "fmt" + "strings" + + "github.com/shogo82148/go-sfv" +) + +// encodeMember serialises a single list/dictionary member value (an item or +// an inner list, with its parameters). +func encodeMember(m sfv.Item) (string, error) { + return sfv.EncodeList(sfv.List{m}) +} + +// validKey reports whether s is a valid sf-key (RFC 9651 section 3.1.2). +// go-sfv doesn't export its key check, but its encoder applies it. +func validKey(s string) bool { + _, err := sfv.EncodeDictionary(sfv.Dictionary{{Key: s, Item: sfv.Item{Value: true}}}) + return err == nil +} + +// validToken reports whether s is a valid RFC 9110 token, the syntax of an +// HTTP field name. +func validToken(s string) bool { + if s == "" { + return false + } + for _, c := range []byte(s) { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + case strings.IndexByte("!#$%&'*+-.^_`|~", c) >= 0: + default: + return false + } + } + return true +} + +// paramValue converts a WithExtraParamValue value into an sf bare item. +func paramValue(v any) (sfv.Value, error) { + switch v := v.(type) { + case string: + if !sfv.IsValidString(v) { + return nil, fmt.Errorf("%q is not a valid sf-string", v) + } + return v, nil + case int64: + if v < sfv.MinInteger || v > sfv.MaxInteger { + return nil, fmt.Errorf("%d is out of range for an sf-integer", v) + } + return v, nil + case bool: + return v, nil + case []byte: + return v, nil + default: + return nil, fmt.Errorf("unsupported value type %T", v) + } +} diff --git a/internal/httpsig/sign.go b/internal/httpsig/sign.go new file mode 100644 index 0000000..4bb0b0a --- /dev/null +++ b/internal/httpsig/sign.go @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "crypto" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "maps" + "net/http" + "strconv" + "strings" + "time" + + "github.com/shogo82148/go-sfv" + + "go.amutable.dev/quarry/internal/generics" +) + +const ( + headerSignatureInput = "Signature-Input" + headerSignature = "Signature" +) + +type signConfig struct { + created *time.Time + keyID *string + label *string + signerOpts crypto.SignerOpts + nonce string + extra map[string]any +} + +// SignOption supplies per-signature values to [Base.SignRequest]. +type SignOption func(*signConfig) + +// WithCreated sets the reference time used for "created" (and "expires"). +// The default is [time.Now]. +func WithCreated(t time.Time) SignOption { + return func(c *signConfig) { c.created = &t } +} + +// WithKeyID adds a "keyid" parameter. +func WithKeyID(keyID string) SignOption { + return func(c *signConfig) { c.keyID = &keyID } +} + +// WithSigLabel sets the signature label (the dictionary key in +// Signature-Input and Signature). The label must be a valid sf-key and must +// not already be present on the request, and the default is an autogenerated +// "s". +func WithSigLabel(label string) SignOption { + return func(c *signConfig) { c.label = &label } +} + +// WithSignerOpts sets the [crypto.SignerOpts] passed to [crypto.SignMessage], +// which select the algorithm for RSA keys. The default is the first +// algorithm listed for the key type in the package documentation. +func WithSignerOpts(opts crypto.SignerOpts) SignOption { + return func(c *signConfig) { c.signerOpts = opts } +} + +// WithNonceValue uses nonce instead of a random one. It is an error to use +// this with a Base that was created with [WithoutNonce]. +func WithNonceValue(nonce string) SignOption { + return func(c *signConfig) { c.nonce = nonce } +} + +// WithExtraParamValue supplies the value of a parameter declared with +// [WithExtraParam]. value must be a string, int64, bool or []byte (serialised +// as an sf-string, sf-integer, sf-boolean or sf-binary respectively). It is +// an error to supply a value for a name the Base did not declare. +func WithExtraParamValue(name string, value any) SignOption { + return func(c *signConfig) { + if c.extra == nil { + c.extra = make(map[string]any) + } + c.extra[name] = value + } +} + +// SignRequest signs the [http.Request] using the provided [Base] and adds the +// signature to the set of request headers (modifying it in place), returning +// the label used to identify the signature (in case the request has multiple +// signatures). +// +// As the signature captures several aspects of the request, the request +// headers should not be modified by the caller after signing the request +// (aside from adding additional signatures to the same request). +// +// On err, [http.Request] will remain unmodified. +func (b *Base) SignRequest(signer crypto.Signer, req *http.Request, opts ...SignOption) (label string, err error) { + var cfg signConfig + for _, opt := range opts { + opt(&cfg) + } + if cfg.created == nil { + cfg.created = generics.Ptr(time.Now()) + } + + covered, err := b.resolve(requestMessage{req}) + if err != nil { + return "", err + } + alg, signerOpts, err := chooseAlgorithm(signer.Public(), cfg.signerOpts) + if err != nil { + return "", err + } + params, err := b.params(&cfg, alg) + if err != nil { + return "", err + } + input, base, err := signatureBase(covered, params) + if err != nil { + return "", err + } + label, err = allocateLabel(req.Header, cfg) + if err != nil { + return "", err + } + + sig, err := alg.SignMessage(signer, base, signerOpts) + if err != nil { + return "", fmt.Errorf("sign with %s: %w", alg.name, err) + } + + inputField, err := sfv.EncodeDictionary(sfv.Dictionary{{Key: label, Item: input}}) + if err != nil { + return "", fmt.Errorf("%w: serialise Signature-Input: %w", ErrInvalidParameter, err) + } + sigField, err := sfv.EncodeDictionary(sfv.Dictionary{{Key: label, Item: sfv.Item{Value: sig}}}) + if err != nil { + return "", fmt.Errorf("serialise Signature: %w", err) + } + req.Header.Add(headerSignatureInput, inputField) + req.Header.Add(headerSignature, sigField) + return label, nil +} + +// params returns the signature parameters for this signature in an arbitrary +// order. +func (b *Base) params(cfg *signConfig, alg *algorithm) (sfv.Parameters, error) { + params := map[string]sfv.Value{ + "created": cfg.created.Unix(), + } + if !b.noAlg { + params["alg"] = alg.name + } + if b.expiry != nil { + params["expires"] = cfg.created.Add(*b.expiry).Unix() + } + if !b.noNonce { + if cfg.nonce == "" { + var buf [16]byte + if _, err := io.ReadFull(rand.Reader, buf[:]); err != nil { + return nil, fmt.Errorf("generate nonce: %w", err) + } + cfg.nonce = base64.RawURLEncoding.EncodeToString(buf[:]) + } + if !sfv.IsValidString(cfg.nonce) { + return nil, fmt.Errorf("%w: nonce %q is not a valid sf-string", ErrInvalidParameter, cfg.nonce) + } + params["nonce"] = cfg.nonce + } else if cfg.nonce != "" { + return nil, ErrNonceNotEnabled + } + if cfg.keyID != nil { + if !sfv.IsValidString(*cfg.keyID) { + return nil, fmt.Errorf("%w: keyid %q is not a valid sf-string", ErrInvalidParameter, *cfg.keyID) + } + params["keyid"] = *cfg.keyID + } + if b.tag != "" { + params["tag"] = b.tag + } + + extra := maps.Clone(b.extra) + for name, value := range cfg.extra { + if _, ok := b.extra[name]; !ok { + return nil, fmt.Errorf("%w: %q is not declared by the base", ErrInvalidParameter, name) + } + extra[name] = value + } + for name, v := range extra { + if v == nil { + return nil, fmt.Errorf("%w: %q", ErrMissingParameter, name) + } + value, err := paramValue(v) + if err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidParameter, name, err) + } + params[name] = value + } + + out := make(sfv.Parameters, 0, len(params)) + for name, value := range params { + out = append(out, sfv.Parameter{Key: name, Value: value}) + } + return out, nil +} + +// resolvedComponent is a covered component with its signature base value. +type resolvedComponent struct { + component Component + value string +} + +// resolve returns the value of each of b's components in msg, leaving out +// absent optional components. +func (b *Base) resolve(msg message) ([]resolvedComponent, error) { + resolved := make([]resolvedComponent, 0, len(b.components)) + for _, c := range b.components { + value, ok, err := c.resolve(msg) + if err != nil { + return nil, componentErr(c.Component, err) + } + if !ok { + if c.optional { + continue + } + return nil, componentErr(c.Component, ErrMissingComponent) + } + if strings.ContainsAny(value, "\r\n") { + return nil, componentErr(c.Component, fmt.Errorf("%w: value contains CR or LF", ErrInvalidComponent)) + } + resolved = append(resolved, resolvedComponent{component: c.Component, value: value}) + } + return resolved, nil +} + +// signatureBase returns the Signature-Input member (the @signature-params +// value) and the signature base (RFC 9421 section 2.5) for the covered +// components and params. +func signatureBase(covered []resolvedComponent, params sfv.Parameters) (sfv.Item, []byte, error) { + ids := make(sfv.InnerList, 0, len(covered)) + for _, rc := range covered { + ids = append(ids, rc.component.item()) + } + input := sfv.Item{Value: ids, Parameters: params} + sigParams, err := encodeMember(input) + if err != nil { + return sfv.Item{}, nil, fmt.Errorf("%w: serialise @signature-params: %w", ErrInvalidParameter, err) + } + + var base strings.Builder + for _, rc := range covered { + base.WriteString(rc.component.String()) + base.WriteString(": ") + base.WriteString(rc.value) + base.WriteByte('\n') + } + base.WriteString(`"@signature-params": `) + base.WriteString(sigParams) + return input, []byte(base.String()), nil +} + +// allocateLabel returns the label for a new signature on a request with the +// given header. +func allocateLabel(h http.Header, cfg signConfig) (string, error) { + inputs, err := sfv.DecodeDictionary(h.Values(headerSignatureInput)) + if err != nil { + return "", fmt.Errorf("%w: Signature-Input: %w", ErrMalformedSignature, err) + } + sigs, err := sfv.DecodeDictionary(h.Values(headerSignature)) + if err != nil { + return "", fmt.Errorf("%w: Signature: %w", ErrMalformedSignature, err) + } + taken := func(label string) bool { + return inputs.Get(label).Value != nil || sigs.Get(label).Value != nil + } + + if label := cfg.label; label != nil { + if !validKey(*label) { + return "", fmt.Errorf("%w: %q is not a valid sf-key", ErrInvalidLabel, *label) + } + if taken(*label) { + return "", fmt.Errorf("%w: %q", ErrLabelExists, *label) + } + return *label, nil + } + for n := inputs.Len() + 1; ; n++ { + if label := "s" + strconv.Itoa(n); !taken(label) { + return label, nil + } + } +} diff --git a/internal/httpsig/sign_test.go b/internal/httpsig/sign_test.go new file mode 100644 index 0000000..daf2363 --- /dev/null +++ b/internal/httpsig/sign_test.go @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "io" + "net/http" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/shogo82148/go-sfv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustBase(t *testing.T, opts ...BaseOption) *Base { + t.Helper() + b, err := NewBase(opts...) + require.NoError(t, err, "NewBase") + return b +} + +// signatureInput returns the Signature-Input member under label in h. +func signatureInput(t *testing.T, h http.Header, label string) sfv.Item { + t.Helper() + dict, err := sfv.DecodeDictionary(h.Values("Signature-Input")) + require.NoError(t, err, "parse Signature-Input") + item := dict.Get(label) + require.NotNil(t, item.Value, "Signature-Input has %q", label) + return item +} + +func paramKeys(params sfv.Parameters) []string { + keys := make([]string, len(params)) + for i, p := range params { + keys[i] = p.Key + } + return keys +} + +func coveredIDs(t *testing.T, item sfv.Item) []string { + t.Helper() + list, ok := item.Value.(sfv.InnerList) + require.True(t, ok, "Signature-Input member is an inner list") + ids := make([]string, len(list)) + for i, c := range list { + id, err := sfv.EncodeItem(c) + require.NoError(t, err, "encode component identifier") + ids[i] = id + } + return ids +} + +// orderAs returns the resolved components in the order of ids. +func orderAs(t *testing.T, resolved []resolvedComponent, ids []string) []resolvedComponent { + t.Helper() + byID := make(map[string]resolvedComponent, len(resolved)) + for _, rc := range resolved { + byID[rc.component.String()] = rc + } + require.Len(t, ids, len(byID), "covered components") + ordered := make([]resolvedComponent, len(ids)) + for i, id := range ids { + rc, ok := byID[id] + require.True(t, ok, "%s is covered", id) + ordered[i] = rc + } + return ordered +} + +// rebuildBase returns the signature base of the signature under label on +// req, rebuilt as a verifier would: in the order given by its +// Signature-Input. +func rebuildBase(t *testing.T, b *Base, req *http.Request, label string) string { + t.Helper() + input := signatureInput(t, req.Header, label) + resolved, err := b.resolve(requestMessage{req}) + require.NoError(t, err, "resolve") + _, base, err := signatureBase(orderAs(t, resolved, coveredIDs(t, input)), input.Parameters) + require.NoError(t, err, "build signature base") + return string(base) +} + +// signGET signs a GET request with base and returns the Signature-Input +// member of the new signature. +func signGET(t *testing.T, base *Base, opts ...SignOption) sfv.Item { + t.Helper() + req := newRequest(t, http.MethodGet, "https://example.com/path?q=1") + label, err := base.SignRequest(rfcEd25519Key(t), req, opts...) + require.NoError(t, err, "SignRequest") + return signatureInput(t, req.Header, label) +} + +func TestSignDefaults(t *testing.T) { + created := time.Unix(1798761600, 0) + input := signGET(t, mustBase(t, WithComponents(Method())), WithCreated(created)) + assert.ElementsMatch(t, []string{"alg", "created", "nonce"}, paramKeys(input.Parameters), "default parameters") + assert.Equal(t, int64(1798761600), input.Parameters.Get("created"), "created") + assert.Equal(t, "ed25519", input.Parameters.Get("alg"), "alg") + nonce, ok := input.Parameters.Get("nonce").(string) + require.True(t, ok, "nonce is a string") + raw, err := base64.RawURLEncoding.DecodeString(nonce) + require.NoError(t, err, "nonce is base64url") + assert.Len(t, raw, 16, "nonce has 128 bits") +} + +func TestSignCreatedDefaultsToNow(t *testing.T) { + before := time.Now().Unix() + input := signGET(t, mustBase(t, WithComponents(Method()), WithExpiry(time.Minute))) + after := time.Now().Unix() + created, ok := input.Parameters.Get("created").(int64) + require.True(t, ok, "created is an integer") + assert.GreaterOrEqual(t, created, before, "created") + assert.LessOrEqual(t, created, after, "created") + assert.Equal(t, created+60, input.Parameters.Get("expires"), "expires") +} + +func TestSignParameters(t *testing.T) { + b := mustBase(t, WithComponents(Method()), + WithExtraParam("zz"), WithExtraParam("aa"), + WithTag("t"), WithExpiry(time.Minute)) + input := signGET(t, b, + WithCreated(time.Unix(1000, 0)), WithKeyID("k"), + WithExtraParamValue("aa", "1"), WithExtraParamValue("zz", "2")) + assert.ElementsMatch(t, []string{"alg", "created", "expires", "nonce", "keyid", "tag", "aa", "zz"}, + paramKeys(input.Parameters), "parameters") + assert.Equal(t, int64(1060), input.Parameters.Get("expires"), "expires") +} + +func TestSignRandomOrder(t *testing.T) { + b := mustBase(t, WithComponents(Method(), Path(), Query(), Authority(), Scheme()), + WithTag("t"), WithExpiry(time.Minute), WithExtraParam("x", "1")) + key := rfcEd25519Key(t) + components, params := map[string]bool{}, map[string]bool{} + for range 50 { + req := newRequest(t, http.MethodGet, "https://example.com/path?q=1") + label, err := b.SignRequest(key, req, WithKeyID("k")) + require.NoError(t, err, "SignRequest") + input := signatureInput(t, req.Header, label) + components[strings.Join(coveredIDs(t, input), " ")] = true + params[strings.Join(paramKeys(input.Parameters), " ")] = true + verifyRFC(t, key.Public(), crypto.Hash(0), rebuildBase(t, b, req, label), signatureFromHeader(t, req.Header, label)) + } + assert.Greater(t, len(components), 1, "component order varies") + assert.Greater(t, len(params), 1, "parameter order varies") +} + +func TestBaseOptionsLastWins(t *testing.T) { + for _, test := range []struct { + name string + opts []BaseOption + want []string + }{ + {"WithoutExpiry", []BaseOption{WithExpiry(time.Minute), WithoutExpiry()}, []string{"alg", "created", "nonce"}}, + {"WithExpiry", []BaseOption{WithoutExpiry(), WithExpiry(time.Minute)}, []string{"alg", "created", "expires", "nonce"}}, + {"WithoutNonce", []BaseOption{WithoutNonce()}, []string{"alg", "created"}}, + {"WithNonce", []BaseOption{WithoutNonce(), WithNonce()}, []string{"alg", "created", "nonce"}}, + {"WithoutAlg", []BaseOption{WithoutAlg()}, []string{"created", "nonce"}}, + {"WithAlg", []BaseOption{WithoutAlg(), WithAlg()}, []string{"alg", "created", "nonce"}}, + {"WithTag", []BaseOption{WithTag("a"), WithTag("b")}, []string{"alg", "created", "nonce", "tag"}}, + {"WithoutTag", []BaseOption{WithTag("a"), WithoutTag()}, []string{"alg", "created", "nonce"}}, + {"invalid expiry overridden", []BaseOption{WithExpiry(-time.Second), WithoutExpiry()}, []string{"alg", "created", "nonce"}}, + } { + t.Run(test.name, func(t *testing.T) { + input := signGET(t, mustBase(t, append([]BaseOption{WithComponents(Method())}, test.opts...)...)) + assert.ElementsMatch(t, test.want, paramKeys(input.Parameters), "parameters") + if slices.Contains(test.want, "tag") { + assert.Equal(t, "b", input.Parameters.Get("tag"), "tag") + } + }) + } +} + +func TestNewBaseInvalidOptions(t *testing.T) { + for _, test := range []struct { + name string + opts []BaseOption + }{ + {"zero expiry", []BaseOption{WithExpiry(0)}}, + {"negative expiry", []BaseOption{WithExpiry(-time.Minute)}}, + {"invalid tag", []BaseOption{WithTag("café")}}, + {"tag with control char", []BaseOption{WithTag("a\nb")}}, + {"reserved extra", []BaseOption{WithExtraParam("keyid")}}, + {"invalid extra", []BaseOption{WithExtraParam("Bad")}}, + {"empty extra", []BaseOption{WithExtraParam("")}}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := NewBase(append([]BaseOption{WithComponents(Method())}, test.opts...)...) + require.ErrorIs(t, err, ErrInvalidParameter, "NewBase") + }) + } +} + +func TestNewBaseReportsAllErrors(t *testing.T) { + _, err := NewBase(WithComponents(Field("bad name"), Method()), + WithExpiry(0), WithExtraParam("nonce")) + require.ErrorIs(t, err, ErrInvalidComponent, "NewBase") + require.ErrorIs(t, err, ErrInvalidParameter, "NewBase") +} + +func TestSignExtraParams(t *testing.T) { + b := mustBase(t, WithComponents(Method()), + WithExtraParam("s"), WithExtraParam("i"), WithExtraParam("b"), WithExtraParam("y")) + input := signGET(t, b, + WithExtraParamValue("s", "str"), + WithExtraParamValue("i", int64(-42)), + WithExtraParamValue("b", false), + WithExtraParamValue("y", []byte{1, 2, 3}), + WithExtraParamValue("s", "last wins")) + assert.ElementsMatch(t, []string{"alg", "created", "nonce", "b", "i", "s", "y"}, + paramKeys(input.Parameters), "parameters") + assert.Equal(t, "last wins", input.Parameters.Get("s"), "string value") + assert.Equal(t, int64(-42), input.Parameters.Get("i"), "integer value") + assert.Equal(t, false, input.Parameters.Get("b"), "boolean value") + assert.Equal(t, []byte{1, 2, 3}, input.Parameters.Get("y"), "byte sequence value") + + for _, test := range []struct { + name string + opts []SignOption + want error + }{ + {"missing", nil, ErrMissingParameter}, + {"undeclared", []SignOption{WithExtraParamValue("x", "1"), WithExtraParamValue("other", "2")}, ErrInvalidParameter}, + {"unsupported type", []SignOption{WithExtraParamValue("x", 1)}, ErrInvalidParameter}, + {"invalid string", []SignOption{WithExtraParamValue("x", "café")}, ErrInvalidParameter}, + {"integer out of range", []SignOption{WithExtraParamValue("x", int64(1e15))}, ErrInvalidParameter}, + } { + t.Run(test.name, func(t *testing.T) { + req := newRequest(t, http.MethodGet, "https://example.com/") + _, err := mustBase(t, WithComponents(Method()), WithExtraParam("x")). + SignRequest(rfcEd25519Key(t), req, test.opts...) + require.ErrorIs(t, err, test.want, "SignRequest") + assert.Empty(t, req.Header, "request is unchanged on error") + }) + } +} + +func TestSignExtraParamDefaults(t *testing.T) { + b := mustBase(t, WithComponents(Method()), WithExtraParam("x", "default")) + input := signGET(t, b) + assert.Equal(t, "default", input.Parameters.Get("x"), "default value") + + input = signGET(t, b, WithExtraParamValue("x", "override")) + assert.Equal(t, "override", input.Parameters.Get("x"), "WithExtraParamValue overrides the default") + + // Re-declaring a parameter replaces the earlier declaration. + input = signGET(t, mustBase(t, WithComponents(Method()), + WithExtraParam("x", "first"), WithExtraParam("x", int64(2)))) + assert.Equal(t, int64(2), input.Parameters.Get("x"), "last declaration wins") + + req := newRequest(t, http.MethodGet, "https://example.com/") + _, err := mustBase(t, WithComponents(Method()), WithExtraParam("x", "first"), WithExtraParam("x")). + SignRequest(rfcEd25519Key(t), req) + require.ErrorIs(t, err, ErrMissingParameter, "re-declaring without a default drops the default") + + assert.Panics(t, func() { WithExtraParam("x", "a", "b") }, "more than one default") +} + +func TestSignNonce(t *testing.T) { + input := signGET(t, mustBase(t, WithComponents(Method())), WithNonceValue("my-nonce")) + assert.Equal(t, "my-nonce", input.Parameters.Get("nonce"), "nonce") + + req := newRequest(t, http.MethodGet, "https://example.com/") + _, err := mustBase(t, WithComponents(Method()), WithoutNonce()). + SignRequest(rfcEd25519Key(t), req, WithNonceValue("my-nonce")) + require.ErrorIs(t, err, ErrNonceNotEnabled, "WithNonceValue with WithoutNonce") + + _, err = mustBase(t, WithComponents(Method())). + SignRequest(rfcEd25519Key(t), req, WithNonceValue("café")) + require.ErrorIs(t, err, ErrInvalidParameter, "invalid nonce") + + _, err = mustBase(t, WithComponents(Method())). + SignRequest(rfcEd25519Key(t), req, WithKeyID("café")) + require.ErrorIs(t, err, ErrInvalidParameter, "invalid keyid") + assert.Empty(t, req.Header, "request is unchanged on error") +} + +func TestSignLabels(t *testing.T) { + b := mustBase(t, WithComponents(Method())) + key := rfcEd25519Key(t) + req := newRequest(t, http.MethodGet, "https://example.com/") + + sign := func(opts ...SignOption) string { + t.Helper() + label, err := b.SignRequest(key, req, opts...) + require.NoError(t, err, "SignRequest") + signatureInput(t, req.Header, label) + signatureFromHeader(t, req.Header, label) + return label + } + assert.Equal(t, "s1", sign(), "first label") + assert.Equal(t, "custom", sign(WithSigLabel("custom")), "custom label") + assert.Equal(t, "s3", sign(), "label after a custom one") + assert.Equal(t, "s5", sign(WithSigLabel("s5")), "hand-picked s label") + assert.Equal(t, "s6", sign(), "label skips a hand-picked one") + assert.Equal(t, "s7", sign(), "next label") + assert.Len(t, req.Header.Values("Signature-Input"), 6, "one Signature-Input line per signature") + assert.Len(t, req.Header.Values("Signature"), 6, "one Signature line per signature") + + before := req.Header.Clone() + for _, test := range []struct { + label string + want error + }{ + {"custom", ErrLabelExists}, + {"s1", ErrLabelExists}, + {"", ErrInvalidLabel}, + {"Upper", ErrInvalidLabel}, + {"1abc", ErrInvalidLabel}, + } { + _, err := b.SignRequest(key, req, WithSigLabel(test.label)) + require.ErrorIs(t, err, test.want, "WithSigLabel(%q)", test.label) + } + assert.Equal(t, before, req.Header, "request is unchanged on error") + + // A label only present in Signature is also taken. + req = newRequest(t, http.MethodGet, "https://example.com/") + req.Header.Set("Signature", "s1=:AAAA:") + _, err := b.SignRequest(key, req, WithSigLabel("s1")) + require.ErrorIs(t, err, ErrLabelExists, "label in Signature only") + assert.Equal(t, "s2", sign(), "default label skips a label in Signature") +} + +func TestSignMalformedExisting(t *testing.T) { + b := mustBase(t, WithComponents(Method())) + for _, field := range []string{"Signature-Input", "Signature"} { + req := newRequest(t, http.MethodGet, "https://example.com/") + req.Header.Set(field, "not a dictionary!") + _, err := b.SignRequest(rfcEd25519Key(t), req) + require.ErrorIs(t, err, ErrMalformedSignature, "malformed %s", field) + assert.Len(t, req.Header, 1, "request is unchanged on error") + } +} + +func TestSignOptionalComponents(t *testing.T) { + b := mustBase(t, + WithComponents(Method()), + WithOptionalComponents(Field("content-type")), + WithComponents(Path()), + WithoutNonce(), WithoutAlg()) + + input := signGET(t, b) + assert.ElementsMatch(t, []string{`"@method"`, `"@path"`}, coveredIDs(t, input), "absent field is not covered") + + req := newRequest(t, http.MethodGet, "https://example.com/path?q=1") + req.Header.Set("Content-Type", "text/plain") + label, err := b.SignRequest(rfcEd25519Key(t), req, WithCreated(rfcCreated)) + require.NoError(t, err, "SignRequest") + input = signatureInput(t, req.Header, label) + assert.ElementsMatch(t, []string{`"@method"`, `"content-type"`, `"@path"`}, coveredIDs(t, input), "present field is covered") + + // Check that the base really omits the absent field. + req = newRequest(t, http.MethodGet, "https://example.com/path?q=1") + label, err = b.SignRequest(rfcEd25519Key(t), req, WithCreated(rfcCreated)) + require.NoError(t, err, "SignRequest") + base := rebuildBase(t, b, req, label) + assert.NotContains(t, base, "content-type", "signature base") + verifyRFC(t, rfcEd25519Key(t).Public(), crypto.Hash(0), base, signatureFromHeader(t, req.Header, label)) +} + +func TestSignRejectsComponents(t *testing.T) { + for _, test := range []struct { + component Component + want error + }{ + {Status(), ErrResponseOnlyComponent}, + {Field("content-type").FromRequest(), ErrResponseOnlyComponent}, + {Field("content-type"), ErrMissingComponent}, + {Field("host"), ErrInvalidComponent}, + } { + req := newRequest(t, http.MethodGet, "https://example.com/") + _, err := mustBase(t, WithComponents(test.component)).SignRequest(rfcEd25519Key(t), req) + require.ErrorIs(t, err, test.want, "sign covering %s", test.component) + var cerr *ComponentError + require.ErrorAs(t, err, &cerr, "sign covering %s", test.component) + assert.Equal(t, test.component, cerr.Component, "ComponentError.Component") + assert.Empty(t, req.Header, "request is unchanged on error") + } +} + +type opaqueKey struct{} + +type testSigner struct { + pub crypto.PublicKey +} + +func (s testSigner) Public() crypto.PublicKey { return s.pub } + +func (testSigner) Sign(io.Reader, []byte, crypto.SignerOpts) ([]byte, error) { + panic("must not be called") +} + +func TestSignUnsupported(t *testing.T) { + p521, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + require.NoError(t, err, "generate P-521 key") + p256 := rfcECCP256Key(t) + rsaKey := rfcRSAKey(t) + + for _, test := range []struct { + name string + signer crypto.Signer + opts crypto.SignerOpts + want error + }{ + {"opaque key", testSigner{opaqueKey{}}, nil, ErrUnsupportedKey}, + {"P-521", p521, nil, ErrUnsupportedKey}, + {"ed25519 with hash", rfcEd25519Key(t), crypto.SHA512, ErrUnsupportedSignerOpts}, + {"ed25519ph", rfcEd25519Key(t), &ed25519.Options{Hash: crypto.SHA512}, ErrUnsupportedSignerOpts}, + {"ed25519ctx", rfcEd25519Key(t), &ed25519.Options{Context: "x"}, ErrUnsupportedSignerOpts}, + {"P-256 with SHA-384", p256, crypto.SHA384, ErrUnsupportedSignerOpts}, + {"RSA PSS auto salt", rsaKey, &rsa.PSSOptions{Hash: crypto.SHA512, SaltLength: rsa.PSSSaltLengthAuto}, ErrUnsupportedSignerOpts}, + {"RSA PSS SHA-256", rsaKey, &rsa.PSSOptions{Hash: crypto.SHA256, SaltLength: rsa.PSSSaltLengthEqualsHash}, ErrUnsupportedSignerOpts}, + {"RSA v1.5 SHA-512", rsaKey, crypto.SHA512, ErrUnsupportedSignerOpts}, + } { + t.Run(test.name, func(t *testing.T) { + // The algorithm is checked even when alg is not emitted. + b := mustBase(t, WithComponents(Method()), WithoutAlg()) + req := newRequest(t, http.MethodGet, "https://example.com/") + var opts []SignOption + if test.opts != nil { + opts = append(opts, WithSignerOpts(test.opts)) + } + _, err := b.SignRequest(test.signer, req, opts...) + require.ErrorIs(t, err, test.want, "SignRequest") + assert.Empty(t, req.Header, "request is unchanged on error") + }) + } +} + +// The WIMSE workload-to-workload profile (draft-ietf-wimse-http-signature-08 +// section 3). +func TestWIMSEProfile(t *testing.T) { + witBase := mustBase(t, + WithComponents( + Method(), + Path(), + Query(), + Field("workload-identity-token"), + ), + WithOptionalComponents( + Field("content-type"), + Field("content-digest"), + Field("authorization"), + Field("txn-token"), + ), + WithExpiry(time.Minute), + WithoutAlg(), + WithTag("wimse-workload-to-workload"), + WithExtraParam("wimse-aud"), + ) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err, "generate key") + + req := newRequest(t, http.MethodGet, "https://repo.example.com/metadata/1.root.json?x=y") + req.Header.Set("Workload-Identity-Token", "wit") + label, err := witBase.SignRequest(key, req, + WithSigLabel("wimse"), WithExtraParamValue("wimse-aud", "quarry"), WithCreated(rfcCreated)) + require.NoError(t, err, "SignRequest") + assert.Equal(t, "wimse", label, "label") + + input := signatureInput(t, req.Header, label) + assert.ElementsMatch(t, []string{`"@method"`, `"@path"`, `"@query"`, `"workload-identity-token"`}, + coveredIDs(t, input), "covered components") + assert.ElementsMatch(t, []string{"created", "expires", "nonce", "tag", "wimse-aud"}, + paramKeys(input.Parameters), "parameters") + assert.Equal(t, "wimse-workload-to-workload", input.Parameters.Get("tag"), "tag") + assert.Equal(t, "quarry", input.Parameters.Get("wimse-aud"), "wimse-aud") + assert.Equal(t, int64(1618884473+60), input.Parameters.Get("expires"), "expires") +} + +func TestSignConcurrent(t *testing.T) { + b := mustBase(t, WithComponents(Method(), TargetURI(), Field("x-n")), WithExpiry(time.Minute)) + key := rfcEd25519Key(t) + + var wg sync.WaitGroup + for i := range 16 { + wg.Go(func() { + for j := range 20 { + req := newRequest(t, http.MethodGet, "https://example.com/") + req.Header.Set("X-N", strings.Repeat("x", i+j)) + if _, err := b.SignRequest(key, req); err != nil { + t.Errorf("SignRequest: %v", err) + } + } + }) + } + wg.Wait() +} diff --git a/internal/httpsig/transport.go b/internal/httpsig/transport.go new file mode 100644 index 0000000..18fdf90 --- /dev/null +++ b/internal/httpsig/transport.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "crypto" + "fmt" + "net/http" + "slices" + + "go.amutable.dev/quarry/internal/third_party/funchelpers" +) + +// Transport is an [http.RoundTripper] that signs requests with Base before +// passing them to Next. There are no restrictions for which Host this +// Transport will sign requests -- if you need such restrictions you will need +// to wrap the Transport in another [http.RoundTripper]. +// +// NOTE: At the moment, only the initial request is signed and all subsequent +// redirect requests have their signatures stripped. The former is mostly a +// matter of caution for the initial implementation, and the latter is +// essentially required by RFC 9421. +// +// Some caveats: +// +// - Fields that [http.Transport] adds while sending (e.g., Accept-Encoding +// and the default User-Agent) are never seen by [Base.SignRequest] as they +// are not available at RoundTrip time. This means that they cannot be +// covered by the signature, and attempting to do so will cause spurious +// errors. +// - The request body is never read, meaning that covering Content-Digest +// will require you to have already included it as a header before sending +// the request through this Transport. +type Transport struct { + // Base describes the signature added to each request. + Base *Base + // Signer signs each request. + Signer crypto.Signer + // Options are passed to [Base.SignRequest] for each request. + Options []SignOption + // Prepare is called on the request before signing, allowing for the custom + // setting of header fields or addition of new [SignOption]s based on the + // request. An error fails the request, so it is never sent unsigned. + Prepare func(req *http.Request) ([]SignOption, error) + // Next sends the request; nil means [http.DefaultTransport]. + Next http.RoundTripper +} + +func (t *Transport) next() http.RoundTripper { + if t.Next != nil { + return t.Next + } + return http.DefaultTransport +} + +// RoundTrip implements [http.RoundTripper]. +func (t *Transport) RoundTrip(req *http.Request) (_ *http.Response, Err error) { + req = req.Clone(req.Context()) + // Close the Body on error until we forward to the next transport, at which + // point we do not attempt to close the body. This is required for + // [http.RoundTripper] implementations. + var sent bool + defer funchelpers.OnError(&Err, func() error { + if sent || req.Body == nil { + return nil + } + return req.Body.Close() + }) + send := func(req *http.Request) (*http.Response, error) { + sent = true + return t.next().RoundTrip(req) + } + if req.Response != nil { + // This is a redirect, do not sign the request. + // + // TODO: Since we set wimse-aud to a unique value for the request, + // maybe it would be okay to sign later requests as long as we keep the + // *original* wimse-aud? Otherwise this restriction on redirects is a + // bit too extreme... + // TODO: Or maybe we should at least allow same-origin redirects by + // default? + req.Header.Del(headerSignature) + req.Header.Del(headerSignatureInput) + return send(req) + } + opts := t.Options + if t.Prepare != nil { + extra, err := t.Prepare(req) + if err != nil { + return nil, err + } + opts = append(slices.Clone(opts), extra...) + } + if _, err := t.Base.SignRequest(t.Signer, req, opts...); err != nil { + return nil, fmt.Errorf("sign request to %s: %w", req.URL.Host, err) + } + return send(req) +} diff --git a/internal/httpsig/transport_test.go b/internal/httpsig/transport_test.go new file mode 100644 index 0000000..c7ed20d --- /dev/null +++ b/internal/httpsig/transport_test.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package httpsig + +import ( + "errors" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recorder is a test server that records the headers of each request by +// path, and redirects /redirect/ to the "to" query parameter. +type recorder struct { + *httptest.Server + + mu sync.Mutex + seen map[string][]http.Header +} + +func newRecorder(t *testing.T) *recorder { + t.Helper() + r := &recorder{seen: map[string][]http.Header{}} + r.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + r.seen[req.URL.Path] = append(r.seen[req.URL.Path], req.Header.Clone()) + r.mu.Unlock() + if status, ok := redirectStatus(req.URL.Path); ok { + http.Redirect(w, req, req.URL.Query().Get("to"), status) + return + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(r.Close) + return r +} + +func redirectStatus(path string) (int, bool) { + const prefix = "/redirect/" + if len(path) <= len(prefix) || path[:len(prefix)] != prefix { + return 0, false + } + status, err := strconv.Atoi(path[len(prefix):]) + return status, err == nil +} + +func (r *recorder) headers(path string) []http.Header { + r.mu.Lock() + defer r.mu.Unlock() + return r.seen[path] +} + +const testTokenHeader = "Workload-Identity-Token" + +func newTestTransport(t *testing.T) *Transport { + t.Helper() + return &Transport{ + Base: mustBase(t, WithComponents(Method(), TargetURI(), Field(testTokenHeader))), + Signer: rfcEd25519Key(t), + Options: []SignOption{WithSigLabel("test")}, + Prepare: func(req *http.Request) ([]SignOption, error) { + req.Header.Set(testTokenHeader, "token") + return []SignOption{WithKeyID("prepared")}, nil + }, + } +} + +func TestTransportSigns(t *testing.T) { + srv := newRecorder(t) + client := &http.Client{Transport: newTestTransport(t)} + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL+"/file", nil) + require.NoError(t, err, "NewRequest") + req.Header.Set("X-Caller", "1") + before := req.Header.Clone() + + resp, err := client.Do(req) + require.NoError(t, err, "Do") + _ = resp.Body.Close() + assert.Equal(t, before, req.Header, "caller's request is unchanged") + + seen := srv.headers("/file") + require.Len(t, seen, 1, "requests to /file") + assert.Equal(t, "token", seen[0].Get(testTokenHeader), "Prepare-set field") + input := signatureInput(t, seen[0], "test") + assert.Equal(t, "prepared", input.Parameters.Get("keyid"), "keyid from Prepare options") + assert.ElementsMatch(t, []string{`"@method"`, `"@target-uri"`, `"workload-identity-token"`}, coveredIDs(t, input), "covered components") + signatureFromHeader(t, seen[0], "test") +} + +func TestTransportPrepareError(t *testing.T) { + srv := newRecorder(t) + tr := newTestTransport(t) + errPrepare := errors.New("no token") + tr.Prepare = func(*http.Request) ([]SignOption, error) { return nil, errPrepare } + client := &http.Client{Transport: tr} + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL+"/file", nil) + require.NoError(t, err, "NewRequest") + _, err = client.Do(req) //nolint:bodyclose // Do fails + require.ErrorIs(t, err, errPrepare, "Do") + assert.Empty(t, srv.headers("/file"), "request is never sent") +} + +func TestTransportSignError(t *testing.T) { + srv := newRecorder(t) + tr := newTestTransport(t) + tr.Prepare = nil // the covered token field is then missing + client := &http.Client{Transport: tr} + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL+"/file", nil) + require.NoError(t, err, "NewRequest") + _, err = client.Do(req) //nolint:bodyclose // Do fails + require.ErrorIs(t, err, ErrMissingComponent, "Do") + assert.Empty(t, srv.headers("/file"), "request is never sent unsigned") +} + +func TestTransportRedirects(t *testing.T) { + for _, status := range []int{ + http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, + http.StatusTemporaryRedirect, http.StatusPermanentRedirect, + } { + for _, crossHost := range []bool{false, true} { + for _, callerSigned := range []bool{false, true} { + name := strconv.Itoa(status) + if crossHost { + name += "/cross-host" + } + if callerSigned { + name += "/caller-signed" + } + t.Run(name, func(t *testing.T) { + first, second := newRecorder(t), newRecorder(t) + target := first.URL + "/final" + if crossHost { + target = second.URL + "/final" + } + client := &http.Client{Transport: newTestTransport(t)} + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, + first.URL+"/redirect/"+strconv.Itoa(status)+"?to="+target, nil) + require.NoError(t, err, "NewRequest") + if callerSigned { + // Without the Transport removing them, net/http + // would copy these to every hop. + _, err := mustBase(t, WithComponents(Method())).SignRequest(rfcEd25519Key(t), req) + require.NoError(t, err, "caller SignRequest") + } + + resp, err := client.Do(req) + require.NoError(t, err, "Do") + _ = resp.Body.Close() + assert.Equal(t, http.StatusNoContent, resp.StatusCode, "final status") + + hop := first.headers("/redirect/" + strconv.Itoa(status)) + require.Len(t, hop, 1, "first hop requests") + signatureInput(t, hop[0], "test") + + finalSrv := first + if crossHost { + finalSrv = second + } + final := finalSrv.headers("/final") + require.Len(t, final, 1, "redirected requests") + for _, h := range []string{"Signature", "Signature-Input", testTokenHeader} { + assert.Empty(t, final[0].Values(h), "redirect hop has no %s", h) + } + }) + } + } + } +} + +func TestTransportConcurrent(t *testing.T) { + srv := newRecorder(t) + client := &http.Client{Transport: newTestTransport(t)} + + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { + for range 10 { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL+"/file", nil) + if err != nil { + t.Errorf("NewRequest: %v", err) + return + } + resp, err := client.Do(req) + if err != nil { + t.Errorf("Do: %v", err) + return + } + _ = resp.Body.Close() + } + }) + } + wg.Wait() + assert.Len(t, srv.headers("/file"), 80, "requests") +} diff --git a/internal/httputils/verified_http.go b/internal/httputils/verified_http.go index ce27da3..3b2c7cd 100644 --- a/internal/httputils/verified_http.go +++ b/internal/httputils/verified_http.go @@ -27,14 +27,14 @@ const MaxErrorBytes = 128 // the server. const ErrorReadDeadline = 200 * time.Millisecond -// VerifiedHTTPGet is a wrapper around [http.Client.Do] but the returned +// VerifiedHTTPGet is a wrapper around client.Do but the returned // [io.ReadCloser] is wrapped in a [hardening.VerifiedReadCloser] so that // endless read and other attacks are protected against. You *must* check the // error return from [io.ReadCloser.Close] once you are done reading data and // before you use it for anything. // // TODO: Should we also return the response information...? -func VerifiedHTTPGet(ctx context.Context, url *url.URL, length int64, hashes tufmetadata.Hashes) (_ io.ReadCloser, _ *http.Response, Err error) { +func VerifiedHTTPGet(ctx context.Context, client *http.Client, url *url.URL, length int64, hashes tufmetadata.Hashes) (_ io.ReadCloser, _ *http.Response, Err error) { // Make sure the hashes are good before doing the request -- they get // re-converted later in [tufext.VerifiedReadCloser]. if _, err := tufext.HashesToDigest(hashes); err != nil { @@ -53,7 +53,6 @@ func VerifiedHTTPGet(ctx context.Context, url *url.URL, length int64, hashes tuf return nil, nil, fmt.Errorf("create http request: %w", err) } - client := http.DefaultClient res, err := client.Do(req) if err != nil { return nil, nil, fmt.Errorf("fetch %s: %w", url, err) diff --git a/internal/httputils/verified_http_test.go b/internal/httputils/verified_http_test.go index 3bbdadf..4074f6c 100644 --- a/internal/httputils/verified_http_test.go +++ b/internal/httputils/verified_http_test.go @@ -54,7 +54,7 @@ func TestVerifiedHTTPGet_HappyPath(t *testing.T) { u := staticServer(t, http.StatusOK, body) hashes := tufmetadata.Hashes{"sha256": sha256Of(body)} - rdr, res, err := httputils.VerifiedHTTPGet(t.Context(), u, int64(len(body)), hashes) + rdr, res, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, int64(len(body)), hashes) require.NoError(t, err) require.NotNil(t, res) require.Equal(t, http.StatusOK, res.StatusCode) @@ -73,7 +73,7 @@ func TestVerifiedHTTPGet_MultipleHashes_AllValid(t *testing.T) { "sha512": sha512Of(body), } - rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), u, int64(len(body)), hashes) + rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, int64(len(body)), hashes) require.NoError(t, err) // Layer order is an implementation detail of HashesToDigest; only the set @@ -108,7 +108,7 @@ func TestVerifiedHTTPGet_MultipleHashes_OneWrong(t *testing.T) { "sha512": make([]byte, sha512.Size), } - rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), u, int64(len(body)), hashes) + rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, int64(len(body)), hashes) require.NoError(t, err) _, err = io.ReadAll(rdr) err = errOrClose(err, rdr) @@ -122,7 +122,7 @@ func TestVerifiedHTTPGet_DigestMismatch(t *testing.T) { require.Len(t, wrong, len(body), "test setup: lengths must match to isolate digest mismatch") hashes := tufmetadata.Hashes{"sha256": sha256Of(wrong)} - rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), u, int64(len(body)), hashes) + rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, int64(len(body)), hashes) require.NoError(t, err) _, err = io.ReadAll(rdr) err = errOrClose(err, rdr) @@ -134,7 +134,7 @@ func TestVerifiedHTTPGet_ShortBody(t *testing.T) { u := staticServer(t, http.StatusOK, body) hashes := tufmetadata.Hashes{"sha256": sha256Of(body)} - rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), u, int64(len(body))+10, hashes) + rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, int64(len(body))+10, hashes) require.NoError(t, err) got, readErr := io.ReadAll(rdr) @@ -151,7 +151,7 @@ func TestVerifiedHTTPGet_LongBody(t *testing.T) { // (digest-mismatch takes precedence in verify()). hashes := tufmetadata.Hashes{"sha256": sha256Of(body[:declared])} - rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), u, declared, hashes) + rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, declared, hashes) require.NoError(t, err) got, readErr := io.ReadAll(rdr) assert.Equal(t, body[:declared], got, "read should be truncated to declared length") @@ -164,7 +164,7 @@ func TestVerifiedHTTPGet_ZeroLengthEmptyBody(t *testing.T) { u := staticServer(t, http.StatusOK, nil) hashes := tufmetadata.Hashes{"sha256": sha256Of(nil)} - rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), u, 0, hashes) + rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, hashes) require.NoError(t, err) got, err := io.ReadAll(rdr) require.NoError(t, err) @@ -179,7 +179,7 @@ func TestVerifiedHTTPGet_ZeroLengthNonEmptyBody(t *testing.T) { // isn't masked by digest-mismatch. hashes := tufmetadata.Hashes{"sha256": sha256Of(nil)} - rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), u, 0, hashes) + rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, hashes) require.NoError(t, err) _, err = io.ReadAll(rdr) err = errOrClose(err, rdr) @@ -193,7 +193,7 @@ func TestVerifiedHTTPGet_NegativeLength(t *testing.T) { // The wrapper does not validate length itself; VerifiedReadCloser surfaces // ErrInvalidExpectedSize on the first Read/Close. - rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), u, -1, hashes) + rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, -1, hashes) require.NoError(t, err) _, err = io.ReadAll(rdr) err = errOrClose(err, rdr) @@ -203,7 +203,7 @@ func TestVerifiedHTTPGet_NegativeLength(t *testing.T) { func TestVerifiedHTTPGet_EmptyHashes(t *testing.T) { u := staticServer(t, http.StatusOK, []byte("anything")) - rdr, res, err := httputils.VerifiedHTTPGet(t.Context(), u, 0, tufmetadata.Hashes{}) + rdr, res, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, tufmetadata.Hashes{}) require.Error(t, err) assert.ErrorIs(t, err, tufext.ErrNoSupportedHashTypes) //nolint:testifylint // assert is fine for error path checks assert.Nil(t, rdr) @@ -214,7 +214,7 @@ func TestVerifiedHTTPGet_OnlyUnsupportedHash(t *testing.T) { u := staticServer(t, http.StatusOK, []byte("anything")) hashes := tufmetadata.Hashes{"md5": []byte("does-not-matter")} - rdr, res, err := httputils.VerifiedHTTPGet(t.Context(), u, 0, hashes) + rdr, res, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, hashes) require.Error(t, err) assert.ErrorIs(t, err, tufext.ErrNoSupportedHashTypes) //nolint:testifylint // assert is fine for error path checks assert.Nil(t, rdr) @@ -229,7 +229,7 @@ func TestVerifiedHTTPGet_UnsupportedHashIgnoredAlongsideSupported(t *testing.T) "md5": []byte("unsupported, should be ignored"), } - rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), u, int64(len(body)), hashes) + rdr, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, int64(len(body)), hashes) require.NoError(t, err) got, err := io.ReadAll(rdr) require.NoError(t, err) @@ -241,7 +241,7 @@ func TestVerifiedHTTPGet_NotFound(t *testing.T) { u := staticServer(t, http.StatusNotFound, []byte("not here")) hashes := tufmetadata.Hashes{"sha256": sha256Of(nil)} - rdr, res, err := httputils.VerifiedHTTPGet(t.Context(), u, 0, hashes) + rdr, res, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, hashes) require.Error(t, err) assert.ErrorIs(t, err, fs.ErrNotExist) //nolint:testifylint // assert is fine for error path checks assert.Nil(t, rdr) @@ -253,7 +253,7 @@ func TestVerifiedHTTPGet_InternalServerError(t *testing.T) { u := staticServer(t, http.StatusInternalServerError, []byte("boom")) hashes := tufmetadata.Hashes{"sha256": sha256Of(nil)} - rdr, res, err := httputils.VerifiedHTTPGet(t.Context(), u, 0, hashes) + rdr, res, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, hashes) require.Error(t, err) assert.NotErrorIs(t, err, fs.ErrNotExist, "non-404 status must not masquerade as ENOENT") //nolint:testifylint // assert is fine for error path checks assert.Contains(t, err.Error(), "500") @@ -268,7 +268,7 @@ func TestVerifiedHTTPGet_ContextCancelledBeforeRequest(t *testing.T) { cancel() hashes := tufmetadata.Hashes{"sha256": sha256Of(nil)} - rdr, _, err := httputils.VerifiedHTTPGet(ctx, u, 0, hashes) + rdr, _, err := httputils.VerifiedHTTPGet(ctx, http.DefaultClient, u, 0, hashes) require.Error(t, err) assert.ErrorIs(t, err, context.Canceled) //nolint:testifylint // assert is fine for error path checks assert.Nil(t, rdr) @@ -279,7 +279,7 @@ func TestVerifiedHTTPGet_ResponseBodyEqualsReturnedReader(t *testing.T) { u := staticServer(t, http.StatusOK, body) hashes := tufmetadata.Hashes{"sha256": sha256Of(body)} - rdr, res, err := httputils.VerifiedHTTPGet(t.Context(), u, int64(len(body)), hashes) + rdr, res, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, int64(len(body)), hashes) require.NoError(t, err) assert.Same(t, rdr, res.Body, "res.Body must point at the verified reader, not the raw transport stream") require.NoError(t, rdr.Close()) @@ -290,7 +290,7 @@ func TestVerifiedHTTPGet_ErrorBodyCaptured(t *testing.T) { u := staticServer(t, http.StatusInternalServerError, body) hashes := tufmetadata.Hashes{"sha256": sha256Of(nil)} - _, _, err := httputils.VerifiedHTTPGet(t.Context(), u, 0, hashes) + _, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, hashes) require.Error(t, err) assert.Contains(t, err.Error(), "server response:", "error must label the captured response body so operators can spot it") @@ -305,7 +305,7 @@ func TestVerifiedHTTPGet_ErrorBodyTruncatedAtMaxBytes(t *testing.T) { u := staticServer(t, http.StatusBadGateway, body) hashes := tufmetadata.Hashes{"sha256": sha256Of(nil)} - _, _, err := httputils.VerifiedHTTPGet(t.Context(), u, 0, hashes) + _, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, hashes) require.Error(t, err) assert.Contains(t, err.Error(), "AAAA", "a sample of the captured prefix must appear so we know capture happened, not just that the tail was dropped") @@ -321,7 +321,7 @@ func TestVerifiedHTTPGet_ErrorBodyQuotedNoRawControlBytes(t *testing.T) { u := staticServer(t, http.StatusBadRequest, body) hashes := tufmetadata.Hashes{"sha256": sha256Of(nil)} - _, _, err := httputils.VerifiedHTTPGet(t.Context(), u, 0, hashes) + _, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, hashes) require.Error(t, err) msg := err.Error() assert.NotContains(t, msg, "\x1b", "raw escape byte must not leak into the error") @@ -334,7 +334,7 @@ func TestVerifiedHTTPGet_ErrorBodyEmpty(t *testing.T) { u := staticServer(t, http.StatusInternalServerError, nil) hashes := tufmetadata.Hashes{"sha256": sha256Of(nil)} - _, _, err := httputils.VerifiedHTTPGet(t.Context(), u, 0, hashes) + _, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, hashes) require.Error(t, err) assert.Contains(t, err.Error(), "server response empty", "an empty error body should be reported as such rather than as a read failure") @@ -369,7 +369,7 @@ func TestVerifiedHTTPGet_ErrorBodySlowRollReturnsWithBufferedPrefixOnly(t *testi require.NoError(t, err) hashes := tufmetadata.Hashes{"sha256": sha256Of(nil)} - _, _, err = httputils.VerifiedHTTPGet(t.Context(), u, 0, hashes) + _, _, err = httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, hashes) require.Error(t, err) assert.Contains(t, err.Error(), "EARLY-CHUNK", "the prefix buffered before the server parked must be captured") @@ -396,7 +396,7 @@ func TestVerifiedHTTPGet_ErrorBodyDeadlineFiresOnHangingServer(t *testing.T) { hashes := tufmetadata.Hashes{"sha256": sha256Of(nil)} start := time.Now() - _, _, err = httputils.VerifiedHTTPGet(t.Context(), u, 0, hashes) + _, _, err = httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, hashes) elapsed := time.Since(start) require.Error(t, err) @@ -415,7 +415,7 @@ func TestVerifiedHTTPGet_NotFoundIncludesBody(t *testing.T) { u := staticServer(t, http.StatusNotFound, body) hashes := tufmetadata.Hashes{"sha256": sha256Of(nil)} - _, _, err := httputils.VerifiedHTTPGet(t.Context(), u, 0, hashes) + _, _, err := httputils.VerifiedHTTPGet(t.Context(), http.DefaultClient, u, 0, hashes) require.Error(t, err) assert.ErrorIs(t, err, fs.ErrNotExist) //nolint:testifylint // assert is fine for error path checks assert.Contains(t, err.Error(), "artifact 'sha256:cafe' absent", diff --git a/internal/third_party/funchelpers/close_on_error.go b/internal/third_party/funchelpers/close_on_error.go index 4a36847..988d152 100644 --- a/internal/third_party/funchelpers/close_on_error.go +++ b/internal/third_party/funchelpers/close_on_error.go @@ -12,19 +12,36 @@ import ( "go.amutable.dev/quarry/internal/third_party/assert" ) -// CloseOnError closes the given [io.Closer] if there was an error. This is -// intended to be used to shorten defer statements. -func CloseOnError(Err *error, closer io.Closer) { +// OnError runs the given closure if there was an error. This is intended to be +// used to shorten defer statements. If an error is returned then the error +// will be joined with Err. +func OnError(Err *error, fn func() error) { assert.Assert(Err != nil, - "CloseOnError must be called with non-nil Err slot") // programmer error - if *Err != nil { - err := closer.Close() - if errors.Is(err, fs.ErrClosed) { - err = nil // no useful error to join - } - if err != nil { - *Err = errors.Join(*Err, - fmt.Errorf("encountered error while closing resource: %w", err)) + "OnError must be called with non-nil Err slot") // programmer error + if *Err != nil && fn != nil { + if err := fn(); err != nil { + *Err = errors.Join(*Err, fmt.Errorf("%w (deferred)", err)) } } } + +// CloseOnError closes the given [io.Closer] if there was an error. This is +// intended to be used to shorten defer statements. If an error is returned +// then the error will be joined with Err. [fs.ErrClosed] and [fs.ErrInvalid] +// errors are ignored as they are almost always returned when trying to close +// an already-closed or nil resource. +func CloseOnError(Err *error, closer io.Closer) { + OnError(Err, func() error { + if closer != nil { + err := closer.Close() + if errors.Is(err, fs.ErrClosed) || errors.Is(err, fs.ErrInvalid) { + err = nil // no useful error to join + } + if err != nil { + err = fmt.Errorf("error while closing resource: %w", err) + } + return err + } + return nil + }) +} diff --git a/internal/third_party/funchelpers/close_on_error_test.go b/internal/third_party/funchelpers/close_on_error_test.go index 46a057d..be2d899 100644 --- a/internal/third_party/funchelpers/close_on_error_test.go +++ b/internal/third_party/funchelpers/close_on_error_test.go @@ -48,7 +48,7 @@ func TestCloseOnError(t *testing.T) { assert.ErrorIs(t, err, mainErr, "main error should be kept") //nolint:testifylint // assert is fine for error path checks assert.ErrorIs(t, err, closeErr, "close error should be joined") //nolint:testifylint // assert is fine for error path checks assert.EqualError(t, err, - "main error\nencountered error while closing resource: close error", + "main error\nerror while closing resource: close error (deferred)", "main error should come first, then the wrapped close error") }) @@ -95,9 +95,70 @@ func TestCloseOnError(t *testing.T) { "main error should be unchanged if close returns a wrapped fs.ErrClosed") }) + t.Run("MainErr_ErrInvalid", func(t *testing.T) { + mainErr := errors.New("main error") + closer := &mockCloser{closeErr: fmt.Errorf("dummy wrapper: %w", fs.ErrInvalid)} + err := testFn(mainErr, closer) + require.Error(t, err, "main error should be returned") + assert.NotErrorIs(t, err, fs.ErrInvalid, "fs.ErrInvalid should be masked") //nolint:testifylint // assert is fine for error path checks + assert.EqualError(t, err, "main error", + "main error should be unchanged if close returns fs.ErrInvalid") + }) + + t.Run("MainErr_NilCloser", func(t *testing.T) { + mainErr := errors.New("main error") + err := testFn(mainErr, nil) + assert.EqualError(t, err, "main error", "nil closer is ignored") + }) + t.Run("NilErrSlot", func(t *testing.T) { assert.Panics(t, func() { CloseOnError(nil, &mockCloser{}) }, "CloseOnError with a nil Err slot is a programmer error") }) } + +func TestOnError(t *testing.T) { + testFn := func(mainErr error, fn func() error) (Err error) { + defer OnError(&Err, fn) + return mainErr + } + + t.Run("NoError", func(t *testing.T) { + called := false + err := testFn(nil, func() error { called = true; return nil }) + require.NoError(t, err, "no error returned") + assert.False(t, called, "fn should not be called on success") + }) + + t.Run("MainErr_NoFnErr", func(t *testing.T) { + mainErr := errors.New("main error") + called := false + err := testFn(mainErr, func() error { called = true; return nil }) + assert.True(t, called, "fn should be called on error") + assert.EqualError(t, err, "main error", "main error should be unchanged if fn succeeds") + }) + + t.Run("MainErr_FnErr", func(t *testing.T) { + mainErr := errors.New("main error") + fnErr := errors.New("fn error") + err := testFn(mainErr, func() error { return fnErr }) + require.Error(t, err, "an error should be returned") + assert.ErrorIs(t, err, mainErr, "main error should be kept") //nolint:testifylint // assert is fine for error path checks + assert.ErrorIs(t, err, fnErr, "fn error should be joined") //nolint:testifylint // assert is fine for error path checks + assert.EqualError(t, err, "main error\nfn error (deferred)", + "main error should come first, then the fn error") + }) + + t.Run("MainErr_NilFn", func(t *testing.T) { + mainErr := errors.New("main error") + err := testFn(mainErr, nil) + assert.EqualError(t, err, "main error", "nil fn is ignored") + }) + + t.Run("NilErrSlot", func(t *testing.T) { + assert.Panics(t, func() { + OnError(nil, func() error { return nil }) + }, "OnError with a nil Err slot is a programmer error") + }) +} diff --git a/internal/tufclient/client.go b/internal/tufclient/client.go index cdf68b4..bc1b6df 100644 --- a/internal/tufclient/client.go +++ b/internal/tufclient/client.go @@ -148,6 +148,9 @@ func RepoClient(ctx context.Context, cacheDir *pathrs.Root, repoLike tufext.Repo tufConfig.LocalTargetsDir = fmt.Sprintf("/tmp/quarry-targets-cache/%s", repo.Name) // At the moment our publishing flow doesn't use the . naming. tufConfig.PrefixTargetsWithHash = false + if err := tufConfig.SetDefaultFetcherHTTPClient(repo.HTTPClient()); err != nil { + return nil, fmt.Errorf("set tuf-client http client: %w", err) + } if err := tufConfig.EnsurePathsExist(); err != nil { return nil, fmt.Errorf("ensure tuf-client paths exist: %w", err) @@ -295,7 +298,7 @@ func (info *TargetInfo) Fetch(ctx context.Context) (io.ReadCloser, error) { if err != nil { return nil, fmt.Errorf("check target candidate urls: %w", err) } - rdr, _, err := httputils.VerifiedHTTPGet(ctx, url, info.Length, info.Hashes) + rdr, _, err := httputils.VerifiedHTTPGet(ctx, info.Repo.HTTPClient(), url, info.Length, info.Hashes) if err != nil { if errors.Is(err, fs.ErrNotExist) { slog.Info("Target file not available at URL, trying next candidate...", @@ -425,7 +428,7 @@ func (client *Client) trustedMetadataTargetsFetcher(repo *tufext.Repository, met return nil, err } - rdr, _, err := httputils.VerifiedHTTPGet(ctx, metaURL, metaRef.Length, metaRef.Hashes) + rdr, _, err := httputils.VerifiedHTTPGet(ctx, repo.HTTPClient(), metaURL, metaRef.Length, metaRef.Hashes) if err != nil { return nil, fmt.Errorf("get role %s (%s): %w", roleName, metaURL, err) } diff --git a/internal/tufclient/config/auth.go b/internal/tufclient/config/auth.go new file mode 100644 index 0000000..449122a --- /dev/null +++ b/internal/tufclient/config/auth.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package config + +import ( + "fmt" + "net/http" + + "go.amutable.dev/quarry/internal/serde" + "go.amutable.dev/quarry/internal/wimseagent" +) + +// repoAuth is how requests for a repository's files authenticate. +type repoAuth interface { + serde.Typed + + // httpClient returns the client that authenticates the repository's + // requests. + httpClient() (*http.Client, error) +} + +// repoAuths is the set of [repoAuth] implementations that can be used for auth +// in config files. +var repoAuths = serde.NewUnion[repoAuth]( + serde.UnionOptions{TagField: "type", Name: "auth"}, + tomlWimseAuth{}, +) + +// tomlRepoAuth is a wrapper around [repoAuth] that allows for a generic +// parsing of [repoAuth] implementations. +type tomlRepoAuth struct { + repoAuth +} + +func (t *tomlRepoAuth) UnmarshalTOML(data any) (err error) { + t.repoAuth, err = repoAuths.DecodeTOML(data) + return err +} + +// tomlWimseAuth signs each request as this machine with a WIT for +// WitAudience from the identity agent at Varlink, and a WIMSE signature for +// WimseAudience (see [wimseagent.Transport]). +// +// TODO: We probably need to support expandos for these options. +type tomlWimseAuth struct { + WitAudience string `toml:"wit_audience"` + WimseAudience string `toml:"wimse_audience"` + Varlink string `toml:"varlink"` +} + +func (tomlWimseAuth) Type() string { return "wimse" } + +func (t *tomlWimseAuth) Validate() error { + if t.WitAudience == "" { + return fmt.Errorf("%w %q", serde.ErrMissingField, "wit_audience") + } + return nil +} + +func (t tomlWimseAuth) httpClient() (*http.Client, error) { + cred, err := wimseagent.NewCred(wimseagent.ID{WitAudience: t.WitAudience, Provider: t.Varlink}, nil) + if err != nil { + return nil, fmt.Errorf("create WIT credential: %w", err) + } + return wimseagent.Client(cred, t.WimseAudience), nil +} diff --git a/internal/tufclient/config/config.go b/internal/tufclient/config/config.go index 85f9d71..3987161 100644 --- a/internal/tufclient/config/config.go +++ b/internal/tufclient/config/config.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "net/http" "net/url" "os" "path/filepath" @@ -179,6 +180,22 @@ type Repository struct { // DataRootURL is the base URL for the directory containing target data // files. If unset, the default is derived by [tufext.Repository.DataURL]. DataRootURL *tomlURL `toml:"data_root_url"` + + // Auth says how requests for the repository's files authenticate. Nil + // means no authentication. + Auth *tomlRepoAuth `toml:"auth"` + + // httpClient is shared by every config that authenticates as the same + // identity with the same WIMSE audience (see [wimseagent.Client]). + httpClient *http.Client +} + +// HTTPClient returns the client to fetch this repository's files with. +func (repo Repository) HTTPClient() *http.Client { + if repo.httpClient == nil { + return http.DefaultClient + } + return repo.httpClient } const defaultOrderIndex = 100 @@ -201,6 +218,7 @@ func (repo Repository) AsRepository() *tufext.Repository { extRepo := &tufext.Repository{ Name: repo.Name, RootTrust: repo.RootTrust.RootTrustSource, + Client: repo.httpClient, } if u := repo.MetaRootURL; u != nil { extRepo.MetaRootURL = generics.Ptr(u.URL) @@ -355,6 +373,14 @@ func (cfg *Config) expandAndValidate() error { ); err != nil { return fmt.Errorf("repository %s has invalid default url: %w", repo.Name, err) } + + if repo.Auth != nil { + client, err := repo.Auth.httpClient() + if err != nil { + return fmt.Errorf("repository %s has invalid auth: %w", repo.Name, err) + } + repo.httpClient = client + } } return nil } @@ -398,6 +424,9 @@ func (cfg *Config) merge(fragment *Config) error { } old.MetaRootURL = mergeURL(old.MetaRootURL, repo.MetaRootURL) old.DataRootURL = mergeURL(old.DataRootURL, repo.DataRootURL) + if repo.Auth != nil { + old.Auth = repo.Auth + } } return nil } diff --git a/internal/tufclient/config/config_test.go b/internal/tufclient/config/config_test.go index b235279..8ce6dec 100644 --- a/internal/tufclient/config/config_test.go +++ b/internal/tufclient/config/config_test.go @@ -5,6 +5,7 @@ package config import ( "fmt" + "net/http" "os" "regexp" "strings" @@ -15,6 +16,7 @@ import ( "go.amutable.dev/quarry/internal/expand" "go.amutable.dev/quarry/internal/tufext" + "go.amutable.dev/quarry/internal/wimseagent" ) const uuidPat = `[0-9a-f]{32}` @@ -1143,3 +1145,81 @@ func TestRootTrustSources_ExpandExhaustive(t *testing.T) { }) } } + +func TestParseConfig_Auth(t *testing.T) { + conf, err := Parse(strings.NewReader(` +config_version = 1 +[repo.signed] +root_trust = "insecure-tofu" +meta_root_url = "https://customerdata.example:8443/machine-id/abc" +auth = { type = "wimse", wit_audience = "urn:amutable:quarry-worker", wimse_audience = "urn:example:wimse", varlink = "unix:/run/test.sock" } + +[repo.plain] +root_trust = "insecure-tofu" +`)) + require.NoError(t, err) + + transport, ok := conf.Repos["signed"].HTTPClient().Transport.(*wimseagent.Transport) + require.True(t, ok, "a wimse repo signs its requests") + assert.Equal(t, wimseagent.ID{WitAudience: "urn:amutable:quarry-worker", Provider: "unix:/run/test.sock"}, transport.WitCred.ID(), "WIT identity") + assert.Equal(t, "urn:example:wimse", transport.WimseAudience, "WIMSE audience") + + assert.Same(t, http.DefaultClient, conf.Repos["plain"].HTTPClient()) +} + +func TestParseConfig_AuthShared(t *testing.T) { + conf, err := Parse(strings.NewReader(` +config_version = 1 +[repo.a] +root_trust = "insecure-tofu" +auth = { type = "wimse", wit_audience = "urn:amutable:quarry-worker", varlink = "unix:/run/shared.sock" } + +[repo.b] +root_trust = "insecure-tofu" +auth = { type = "wimse", wit_audience = "urn:amutable:quarry-worker", varlink = "unix:/run/shared.sock" } + +[repo.c] +root_trust = "insecure-tofu" +auth = { type = "wimse", wit_audience = "urn:amutable:quarry-worker", wimse_audience = "urn:example:c", varlink = "unix:/run/shared.sock" } +`)) + require.NoError(t, err) + + transport := func(name string) *wimseagent.Transport { + transport, ok := conf.Repos[name].HTTPClient().Transport.(*wimseagent.Transport) + require.True(t, ok, "repo %s signs its requests", name) + return transport + } + assert.Same(t, conf.Repos["a"].HTTPClient(), conf.Repos["b"].HTTPClient(), "same identity and WIMSE audience share a client") + assert.Empty(t, transport("a").WimseAudience, "wimse_audience is optional") + assert.NotSame(t, conf.Repos["a"].HTTPClient(), conf.Repos["c"].HTTPClient(), "another WIMSE audience gets its own client") + assert.Same(t, transport("a").WitCred, transport("c").WitCred, "but shares the WIT credential") +} + +func TestParseConfig_AuthInvalid(t *testing.T) { + for auth, wantErr := range map[string]string{ + `{ type = "basic" }`: `unsupported auth type "basic"`, + `{ type = "wimse" }`: `auth "wimse": missing required field "wit_audience"`, + `{ type = "wimse", wimse_audience = "x" }`: `auth "wimse": missing required field "wit_audience"`, + `{ type = "wimse", audience = "x" }`: `auth "wimse" has unsupported field "audience"`, + `{ type = "wimse", wit_audiance = "x" }`: `auth "wimse" has unsupported field "wit_audiance"`, + `"wimse"`: `auth "wimse" cannot be specified by name alone`, + } { + t.Run(auth, func(t *testing.T) { + _, err := Parse(strings.NewReader("config_version = 1\n[repo.r]\nroot_trust = \"insecure-tofu\"\nauth = " + auth + "\n")) + require.ErrorContains(t, err, wantErr) + }) + } +} + +func TestMerge_AuthAddedByDropIn(t *testing.T) { + cfg := mergeFragments(t, ` +config_version = 1 +[repo."example.com/m"] +root_trust = "insecure-tofu" +`, ` +config_version = 1 +[repo."example.com/m"] +auth = { type = "wimse", wit_audience = "urn:amutable:quarry-worker" } +`) + assert.Equal(t, &tomlRepoAuth{tomlWimseAuth{WitAudience: "urn:amutable:quarry-worker"}}, cfg.Repos["example.com/m"].Auth) +} diff --git a/internal/tufext/repo.go b/internal/tufext/repo.go index db05919..000edb1 100644 --- a/internal/tufext/repo.go +++ b/internal/tufext/repo.go @@ -5,6 +5,7 @@ package tufext import ( "fmt" + "net/http" "net/url" ) @@ -45,6 +46,19 @@ type Repository struct { // function. A nil function means all targets are allowed, to avoid nil // panics use [Repository.IsTargetPermitted]. IsTargetPermittedFn func(targetPath string) bool + + // Client is the HTTP client used to fetch this repository's files. Users + // should prefer [Repository.HTTPClient], which handles the default when + // this is nil. + Client *http.Client +} + +// HTTPClient returns the client to fetch this repository's files with. +func (repo *Repository) HTTPClient() *http.Client { + if repo.Client == nil { + return http.DefaultClient + } + return repo.Client } // IsTargetPermitted indicates whether the given target path is permitted to be diff --git a/internal/tufext/root_trust.go b/internal/tufext/root_trust.go index a01fa8b..e04107c 100644 --- a/internal/tufext/root_trust.go +++ b/internal/tufext/root_trust.go @@ -71,8 +71,7 @@ func (TofuRootTrust) FetchRoot(ctx context.Context, repo *Repository) (_ []byte, } req.Header.Set("Accept", "application/json") - client := http.DefaultClient - res, err := client.Do(req) + res, err := repo.HTTPClient().Do(req) if err != nil { return nil, fmt.Errorf("fetch %s: %w", rootURL, err) } diff --git a/internal/wimseagent/jwk.go b/internal/wimseagent/jwk.go new file mode 100644 index 0000000..d633e31 --- /dev/null +++ b/internal/wimseagent/jwk.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package wimseagent + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "fmt" + + "github.com/go-jose/go-jose/v4" +) + +// RFC 9864 deprecated the "EdDSA" algorithm identifier and instead recommended +// the usage of fully qualified names. +const algEd25519 jose.SignatureAlgorithm = "Ed25519" + +func curveName(c elliptic.Curve) string { + if c == nil || c.Params() == nil { + return "" + } + return c.Params().Name +} + +// defaultJwkAlgorithm returns the default [jose.SignatureAlgorithm] for the +// given [crypto.PublicKey] type, to be used when creating a public JWK. +func defaultJwkAlgorithm(pub crypto.PublicKey) (jose.SignatureAlgorithm, error) { + switch pub := pub.(type) { + case ed25519.PublicKey: + return algEd25519, nil + case *ecdsa.PublicKey: + switch pub.Curve { + case elliptic.P256(): + return jose.ES256, nil + case elliptic.P384(): + return jose.ES384, nil + default: + return "", fmt.Errorf("unsupported jwk: ecdsa curve %s", curveName(pub.Curve)) + } + case *rsa.PublicKey: + // PSS has better security properties and PKCS#v1.5 is disallowed by + // NIST. We use SHA-512 because that is the only form that httpsig + // supports. + // TODO: We should probably not have this kind of loose coupling + // between httpsig, wimseagent, and cryptoext... + return jose.PS512, nil + } + return "", fmt.Errorf("unsupported jwk: public key type %T", pub) +} + +// toJwkPublic takes a [crypto.PublicKey] and converts it to a +// [jose.JSONWebKey] with the default algorithm for the key type set in "alg". +func toJwkPublic(pub crypto.PublicKey) (jose.JSONWebKey, error) { + alg, err := defaultJwkAlgorithm(pub) + if err != nil { + return jose.JSONWebKey{}, fmt.Errorf("get default jwk.alg: %w", err) + } + return jose.JSONWebKey{ + Algorithm: string(alg), + Key: pub, + }, nil +} + +// jwkSignerOpts returns the [crypto.SignerOpts] that need to be used to match +// the public key description in [jose.JSONWebKey]. +// +// NOTE: Users should ensure that the JWK passed here actually matches the +// [crypto.Signer] key they plan to use, and that the WIT's jwk.cfg matches the +// public portion. +func jwkSignerOpts(pubJwk jose.JSONWebKey) (crypto.SignerOpts, error) { + pub := pubJwk.Public().Key + if pub == nil { + return nil, fmt.Errorf("jwk is missing key: %#v", pubJwk) + } + + // Prefer the declared signature algorithm. + alg := jose.SignatureAlgorithm(pubJwk.Algorithm) + if alg == "" { + var err error + alg, err = defaultJwkAlgorithm(pub) + if err != nil { + return nil, fmt.Errorf("get fallback jwk.alg: %w", err) + } + } + + switch alg { //nolint:exhaustive // we intentionally only support a subset + case jose.EdDSA: + // Technically EdDSA can also refer to Ed448 so make sure we don't + // return signing options for the wrong signature algorithm. + if _, ok := pub.(ed25519.PublicKey); !ok { + return nil, fmt.Errorf("invalid alg %q with non-ed25519 key type: %T", alg, pub) + } + fallthrough + case algEd25519: + return crypto.Hash(0), nil + case jose.ES256: + return crypto.SHA256, nil + case jose.ES384: + return crypto.SHA384, nil + case jose.RS256: + return crypto.SHA256, nil + case jose.PS512: + return &rsa.PSSOptions{ + Hash: crypto.SHA512, + SaltLength: rsa.PSSSaltLengthEqualsHash, // matches RFC 9421 + }, nil + } + // No need to support stuff that is not supported by httpsig (RFC 9421). + return nil, fmt.Errorf("unsupported alg %q for signing", alg) +} diff --git a/internal/wimseagent/jwk_test.go b/internal/wimseagent/jwk_test.go new file mode 100644 index 0000000..77f14c5 --- /dev/null +++ b/internal/wimseagent/jwk_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package wimseagent + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "testing" + + "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJwkSignerOpts(t *testing.T) { + p256, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err, "generate P-256 key") + p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err, "generate P-384 key") + edPub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err, "generate Ed25519 key") + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "generate RSA key") + pss := &rsa.PSSOptions{Hash: crypto.SHA512, SaltLength: rsa.PSSSaltLengthEqualsHash} + + for _, test := range []struct { + name string + alg jose.SignatureAlgorithm + key crypto.PublicKey + want crypto.SignerOpts + }{ + {"DefaultP256", "", &p256.PublicKey, crypto.SHA256}, + {"DefaultP384", "", &p384.PublicKey, crypto.SHA384}, + {"DefaultEd25519", "", edPub, crypto.Hash(0)}, + {"DefaultRSA", "", &rsaKey.PublicKey, pss}, + {"ES256", jose.ES256, &p256.PublicKey, crypto.SHA256}, + {"ES384", jose.ES384, &p384.PublicKey, crypto.SHA384}, + {"Ed25519", algEd25519, edPub, crypto.Hash(0)}, + {"EdDSA", jose.EdDSA, edPub, crypto.Hash(0)}, + {"RS256", jose.RS256, &rsaKey.PublicKey, crypto.SHA256}, + {"PS512", jose.PS512, &rsaKey.PublicKey, pss}, + {"PrivateKey", "", p256, crypto.SHA256}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := jwkSignerOpts(jose.JSONWebKey{Key: test.key, Algorithm: string(test.alg)}) + require.NoError(t, err, "jwkSignerOpts") + assert.Equal(t, test.want, got, "signer options") + }) + } +} + +func TestJwkSignerOptsErrors(t *testing.T) { + p224, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + require.NoError(t, err, "generate P-224 key") + p256, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err, "generate P-256 key") + p521, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + require.NoError(t, err, "generate P-521 key") + + for _, test := range []struct { + name string + jwk jose.JSONWebKey + }{ + {"DefaultP521", jose.JSONWebKey{Key: &p521.PublicKey}}, + {"ES512", jose.JSONWebKey{Key: &p521.PublicKey, Algorithm: string(jose.ES512)}}, + {"NoKey", jose.JSONWebKey{}}, + {"UnsupportedCurve", jose.JSONWebKey{Key: &p224.PublicKey}}, + {"EdDSAWithECDSAKey", jose.JSONWebKey{Key: &p256.PublicKey, Algorithm: string(jose.EdDSA)}}, + {"UnsupportedAlg", jose.JSONWebKey{Key: &p256.PublicKey, Algorithm: string(jose.HS256)}}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := jwkSignerOpts(test.jwk) + require.Error(t, err, "jwkSignerOpts") + }) + } +} + +func TestToJwkPublic(t *testing.T) { + p256, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err, "generate P-256 key") + jwk, err := toJwkPublic(p256.Public()) + require.NoError(t, err, "toJwkPublic") + assert.Equal(t, string(jose.ES256), jwk.Algorithm, "alg") + assert.True(t, jwk.IsPublic(), "public key") + + p224, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + require.NoError(t, err, "generate P-224 key") + _, err = toJwkPublic(p224.Public()) + require.Error(t, err, "toJwkPublic with an unsupported curve") + _, err = toJwkPublic("not a key") + require.Error(t, err, "toJwkPublic with an unsupported type") +} diff --git a/internal/wimseagent/transport.go b/internal/wimseagent/transport.go new file mode 100644 index 0000000..89ca3dd --- /dev/null +++ b/internal/wimseagent/transport.go @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +// Package wimseagent implements WIMSE workload-to-workload authentication +// using [httpsig], in accordance with WIMSE draft-08 profile of RFC 9421: +// . +// +// The signing key used is a special-purpose WIT that is signed by the local +// machine's identity provider (com.amutable.IdentityAgent) to allow for better +// audience segregation. +package wimseagent + +import ( + "fmt" + "net/http" + "sync" + "time" + + "github.com/go4org/hashtriemap" + + "go.amutable.dev/quarry/internal/httpsig" +) + +const ( + witHeader = "Workload-Identity-Token" + signatureLabel = "wimse" + signatureTag = "wimse-workload-to-workload" + audienceParam = "wimse-aud" + wimseExpiry = 2 * time.Minute +) + +// witBase is the request signature of draft-ietf-wimse-http-signature-08 +// section 3. +var witBase = func() *httpsig.Base { + base, err := httpsig.NewBase( + httpsig.WithComponents( + httpsig.Method(), + httpsig.Path(), + httpsig.Query(), + httpsig.Field(witHeader), + ), + httpsig.WithOptionalComponents( + httpsig.Field("Content-Type"), + httpsig.Field("Content-Digest"), + httpsig.Field("Authorization"), + httpsig.Field("Txn-Token"), + ), + httpsig.WithoutAlg(), // explicitly disallowed by the spec + httpsig.WithExpiry(wimseExpiry), + httpsig.WithNonce(), + httpsig.WithTag(signatureTag), + httpsig.WithExtraParam(audienceParam), + ) + if err != nil { + panic(err) + } + return base +}() + +// Transport is an [http.RoundTripper] that signs requests using WIMSE +// workload-to-workload HTTP Signed Requests. +// +// A request with a body must carry a Content-Digest, which Transport does +// not add. +type Transport struct { + // WitCred is the underlying credential used for this transport. It must be + // non-nil. + WitCred *WitCred + // WimseAudience is the WIMSE audience included in the [httpsig] signed + // requests. If empty, the WIMSE audience defaults to the initial request's + // URI (akin to @target-uri). + // + // TODO: We almost certainly should support expandos (or custom functions + // to generate WimseAudience) so users can configure custom wimse-aud + // values that are variable based on the request URL (and possibly the + // repository name). + WimseAudience string + + signerOnce sync.Once + signer *httpsig.Transport +} + +// RoundTrip implements [http.RoundTripper]. It fails the request if no WIT can +// be obtained, rather than sending it unsigned. +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + // NOTE: We are trusting [httpsig.Transport] to restrict signing of + // redirects and other nonsense based on its own policy (at the moment, it + // only permits the first request to be signed). + // TODO: Should also we have some restriction on the hosts we sign for? + return t.httpsigTransport().RoundTrip(req) +} + +// httpsigTransport returns the transport that signs requests using [WitCred]. +func (t *Transport) httpsigTransport() *httpsig.Transport { + t.signerOnce.Do(func() { + t.signer = &httpsig.Transport{ + Base: witBase, + Signer: t.WitCred.Signer(), + Options: []httpsig.SignOption{ + httpsig.WithSigLabel(signatureLabel), + }, + Prepare: func(req *http.Request) ([]httpsig.SignOption, error) { + opts := make([]httpsig.SignOption, 0, 2) + + // NOTE: This is currently static but in the future + signerOpts, err := t.WitCred.SignerOpts() + if err != nil { + return nil, fmt.Errorf("get WIT signing options: %w", err) + } + opts = append(opts, httpsig.WithSignerOpts(signerOpts)) + + audience := t.WimseAudience + if audience == "" { + var err error + audience, err = httpsig.RequestTargetURI(req) + if err != nil { + return nil, fmt.Errorf("compute default wimse-aud from request: %w", err) + } + } + opts = append(opts, httpsig.WithExtraParamValue(audienceParam, audience)) + + wit, err := t.WitCred.Credential(req.Context()) + if err != nil { + return nil, fmt.Errorf("get WIT for %s from the identity agent: %w", req.URL.Host, err) + } + req.Header.Set(witHeader, wit) + + return opts, nil + }, + } + }) + return t.signer +} + +type clientID struct { + id ID + wimseAudience string +} + +var clients = new(hashtriemap.HashTrieMap[clientID, *http.Client]) + +// Client returns a [http.Client] that can be used to sign requests using the +// given [WitCred]. These are cached, so if there is an existing client using +// the same [ID] and WIMSE audience it will be reused. +// +// TODO: Maybe we should instead take an [ID] and call [NewCred] implicitly +// with the default key? +func Client(wit *WitCred, wimseAudience string) *http.Client { + id := clientID{id: wit.ID(), wimseAudience: wimseAudience} + client, _ := clients.LoadOrStore(id, &http.Client{ + Transport: &Transport{ + WitCred: wit, + WimseAudience: wimseAudience, + }, + }) + return client +} diff --git a/internal/wimseagent/transport_test.go b/internal/wimseagent/transport_test.go new file mode 100644 index 0000000..7796b3d --- /dev/null +++ b/internal/wimseagent/transport_test.go @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package wimseagent + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" + "encoding/base64" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/shogo82148/go-sfv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// witComponents are the components every signed request covers. +var witComponents = []string{"@method", "@path", "@query", "workload-identity-token"} + +// verify checks a request the way the quarry-worker does, with the key the +// agent was asked to bind into the WIT. +func verify(t *testing.T, r *http.Request, wimseAudience string, boundKey jose.JSONWebKey, wantComponents []string) { + t.Helper() + input, ok := strings.CutPrefix(r.Header.Get("Signature-Input"), "wimse=") + require.True(t, ok, "Signature-Input %q", r.Header.Get("Signature-Input")) + inputs, err := sfv.DecodeDictionary([]string{r.Header.Get("Signature-Input")}) + require.NoError(t, err, "parse Signature-Input") + require.Len(t, inputs, 1, "signatures") + covered, ok := inputs[0].Item.Value.(sfv.InnerList) + require.True(t, ok, "covered components are an inner list") + + var base strings.Builder + ids := make([]string, 0, len(covered)) + for _, c := range covered { + id, ok := c.Value.(string) + require.True(t, ok, "component identifier is a string") + require.Empty(t, c.Parameters, "component %q has no parameters", id) + ids = append(ids, id) + var value string + switch id { + case "@method": + value = r.Method + case "@path": + value = r.URL.EscapedPath() + case "@query": + value = "?" + r.URL.RawQuery + default: + value = r.Header.Get(id) + } + fmt.Fprintf(&base, "%q: %s\n", id, value) + } + base.WriteString(`"@signature-params": ` + input) + assert.ElementsMatch(t, wantComponents, ids, "covered components") + + params := inputs[0].Item.Parameters + keys := make([]string, 0, len(params)) + for _, p := range params { + keys = append(keys, p.Key) + } + assert.ElementsMatch(t, []string{"created", "expires", "nonce", "tag", "wimse-aud"}, keys, "signature parameters") + created, _ := params.Get("created").(int64) + assert.InDelta(t, time.Now().Unix(), created, 5, "created") + assert.Equal(t, created+int64(wimseExpiry/time.Second), params.Get("expires"), "expires") + assert.Equal(t, "wimse-workload-to-workload", params.Get("tag"), "tag") + assert.Equal(t, wimseAudience, params.Get("wimse-aud"), "wimse-aud") + + sigB64, ok := strings.CutPrefix(r.Header.Get("Signature"), "wimse=:") + require.True(t, ok, "Signature %q", r.Header.Get("Signature")) + sig, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(sigB64, ":")) + require.NoError(t, err, "decode signature") + assert.True(t, verifySig(t, boundKey.Key, []byte(base.String()), sig), + "signature verifies with the key bound into the WIT") +} + +// verifySig checks an RFC 9421 signature (section 3.3) over msg. +func verifySig(t *testing.T, pub crypto.PublicKey, msg, sig []byte) bool { + t.Helper() + switch pub := pub.(type) { + case *ecdsa.PublicKey: + size := (pub.Curve.Params().BitSize + 7) / 8 + require.Len(t, sig, 2*size, "ECDSA signature is r||s") + hash := map[int]crypto.Hash{32: crypto.SHA256, 48: crypto.SHA384}[size] + h := hash.New() + h.Write(msg) + r, s := new(big.Int).SetBytes(sig[:size]), new(big.Int).SetBytes(sig[size:]) + return ecdsa.Verify(pub, h.Sum(nil), r, s) + case ed25519.PublicKey: + return ed25519.Verify(pub, msg, sig) + case *rsa.PublicKey: + h := crypto.SHA512.New() + h.Write(msg) + return rsa.VerifyPSS(pub, crypto.SHA512, h.Sum(nil), sig, + &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) == nil + } + t.Fatalf("unsupported bound key %T", pub) + return false +} + +// signedRequest sends one GET through transport and returns the request the +// server saw. +func signedRequest(t *testing.T, transport http.RoundTripper) *http.Request { + t.Helper() + var got *http.Request + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { got = r })) + defer srv.Close() + res, err := get(t, &http.Client{Transport: transport}, srv.URL+"/machine-id/abc/1.root.json?x=1") + require.NoError(t, err, "GET") + _ = res.Body.Close() + require.NotNil(t, got, "request reached the server") + return got +} + +func TestTransportSigns(t *testing.T) { + agent, calls := startFakeAgent(t, expiringIn(3600)) + cred := newCred(t, "urn:amutable:wit", agent) + + var got []*http.Request + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = append(got, r) + })) + defer srv.Close() + + client := &http.Client{Transport: &Transport{WitCred: cred, WimseAudience: "urn:amutable:quarry-worker"}} + for _, path := range []string{"/machine-id/abc/1.root.json?x=1", "/machine-id/abc/timestamp.json"} { + res, err := get(t, client, srv.URL+path) + require.NoError(t, err, "GET %s", path) + _ = res.Body.Close() + } + + require.Len(t, calls(), 1, "the WIT is reused across requests") + assert.Equal(t, "urn:amutable:wit", calls()[0].Audience, "the WIT is for the WIT audience") + require.Len(t, got, 2, "requests") + for _, r := range got { + assert.Equal(t, "wit-1", r.Header.Get("Workload-Identity-Token"), "WIT header") + verify(t, r, "urn:amutable:quarry-worker", calls()[0].JWK, witComponents) + } +} + +func TestTransportsShareCredential(t *testing.T) { + agent, calls := startFakeAgent(t, expiringIn(3600)) + cred := newCred(t, "urn:amutable:wit", agent) + + for _, wimseAudience := range []string{"urn:example:one", "urn:example:two"} { + got := signedRequest(t, &Transport{WitCred: cred, WimseAudience: wimseAudience}) + assert.Equal(t, "wit-1", got.Header.Get("Workload-Identity-Token"), "WIT header") + verify(t, got, wimseAudience, calls()[0].JWK, witComponents) + } + assert.Len(t, calls(), 1, "one WIT for both WIMSE audiences") +} + +func TestTransportDefaultWimseAudience(t *testing.T) { + agent, calls := startFakeAgent(t, expiringIn(3600)) + cred := newCred(t, "aud", agent) + + got := signedRequest(t, &Transport{WitCred: cred}) + verify(t, got, "http://"+got.Host+"/machine-id/abc/1.root.json?x=1", calls()[0].JWK, witComponents) +} + +func TestTransportFailsWhenAgentIsUnreachable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Error("request went out unsigned") + })) + defer srv.Close() + + cred := newCred(t, "aud", "unix:"+filepath.Join(t.TempDir(), "missing")) //nolint:forbidigo // test code + client := &http.Client{Transport: &Transport{WitCred: cred, WimseAudience: "aud"}} + _, err := get(t, client, srv.URL+"/x") //nolint:bodyclose // Do fails + require.ErrorContains(t, err, "identity agent", "GET without a WIT") +} + +func TestTransportCoversBodyFields(t *testing.T) { + agent, calls := startFakeAgent(t, expiringIn(3600)) + cred := newCred(t, "aud", agent) + + var got *http.Request + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = r + })) + defer srv.Close() + + client := &http.Client{Transport: &Transport{WitCred: cred, WimseAudience: "wimse"}} + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL+"/upload", strings.NewReader(`{"a":1}`)) + require.NoError(t, err, "NewRequest") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Digest", "sha-256=:AAAA:") + res, err := client.Do(req) + require.NoError(t, err, "POST") + _ = res.Body.Close() + + require.NotNil(t, got, "request reached the server") + verify(t, got, "wimse", calls()[0].JWK, append(slices.Clone(witComponents), "content-type", "content-digest")) +} + +func TestTransportRedirectDropsWIT(t *testing.T) { + agent, _ := startFakeAgent(t, expiringIn(3600)) + cred := newCred(t, "aud", agent) + + var mu sync.Mutex + seen := map[string]http.Header{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + seen[r.URL.Path] = r.Header.Clone() + mu.Unlock() + if r.URL.Path == "/from" { + http.Redirect(w, r, "/to", http.StatusFound) + } + })) + defer srv.Close() + + client := &http.Client{Transport: &Transport{WitCred: cred, WimseAudience: "wimse"}} + res, err := get(t, client, srv.URL+"/from") + require.NoError(t, err, "GET") + _ = res.Body.Close() + + mu.Lock() + defer mu.Unlock() + assert.NotEmpty(t, seen["/from"].Get("Workload-Identity-Token"), "first request is signed") + require.Contains(t, seen, "/to", "redirect followed") + for _, h := range []string{"Workload-Identity-Token", "Signature", "Signature-Input"} { + assert.Empty(t, seen["/to"].Values(h), "redirect hop has no %s", h) + } +} + +func TestClientIsSharedPerIdentityAndAudience(t *testing.T) { + provider := "unix:" + filepath.Join(t.TempDir(), "agent") //nolint:forbidigo // test code + cred := newCred(t, "aud", provider) + client := Client(cred, "wimse") + assert.Same(t, client, Client(cred, "wimse"), "same identity and WIMSE audience") + assert.NotSame(t, client, Client(cred, "other"), "other WIMSE audience") + assert.NotSame(t, client, Client(newCred(t, "other", provider), "wimse"), "other identity") +} + +func get(t *testing.T, client *http.Client, url string) (*http.Response, error) { + t.Helper() + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) + require.NoError(t, err, "NewRequest") + return client.Do(req) +} diff --git a/internal/wimseagent/witcred.go b/internal/wimseagent/witcred.go new file mode 100644 index 0000000..0fbd771 --- /dev/null +++ b/internal/wimseagent/witcred.go @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package wimseagent + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "fmt" + "sync" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go4org/hashtriemap" + + "go.amutable.dev/quarry/internal/varlink" +) + +// credCache is a thread-safe cache of all credentials keyed by the ID so that +// clients can independently refresh the same underlying WIT credential. +var credCache = new(hashtriemap.HashTrieMap[ID, *WitCred]) + +// ID represents a unique identity that is backed by a short-lived WIT provided +// by a com.amutable.IdentityAgent-compatible identity provider. +type ID struct { + // WitAudience is the audience that the WIT generated for this identity is + // signed for. + // TODO: Should we support requesting multiple audiences? It seems more + // prudent to treat each audience as a unique identity. + WitAudience string + + // Provider is the socket URI which is used to sign the WITs generated for + // this identity. Because it is possible for different providers to provide + // WITs with the same audience, this is considered part of the credential's + // unique "identity". Empty means [DefaultSocketURI]. + Provider string +} + +// WitCred is a longer-lived credential that can be used to mint new WITs as +// they expire. The [crypto.Signer] used to sign requests with this credential +// is stored within [WitCred], so use [WitCred.Signer] to access the signing +// handle. +type WitCred struct { + // ID is the credential identity used to refresh this wit. + id ID + // key and pubJwk are static once a credential is created, we do not do key + // rotation for WIT keys since we expect the service to not be particularly + // long-lived. + // TODO: Should we rotate these keys on each credential refresh...? + key crypto.Signer + pubJwk jose.JSONWebKey + // witMu protects the wit and expiry. + witMu sync.RWMutex + wit string + expiry time.Time +} + +// ID returns the [ID] for this [WitCred]. +func (w *WitCred) ID() ID { return w.id } + +// Signer returns the [crypto.Signer] for the key associated with the WIT. +func (w *WitCred) Signer() crypto.Signer { return w.key } + +// SignerOpts returns the signing options needed for using [crypto.SignMessage] +// with [WitCred.Signer] and this WIT. +func (w *WitCred) SignerOpts() (crypto.SignerOpts, error) { + // TODO: We should parse out the algorithm from the WIT's jwk.cfg.alg + // rather than just using the one we requested, as in principle we might've + // been given a different signing algorithm in the WIT. + return jwkSignerOpts(w.pubJwk) +} + +// NewCred tries to creates a new [WitCred] credential with the [crypto.Signer] +// acting as the private key for the credential. Note that since [WitCred]s are +// cached, if there is an existing credential with the same [ID] then the +// [crypto.Signer] is ignored and the existing [WitCred] is returned. If the +// provided [crypto.Signer] is nil, a new in-memory key will be generated. +func NewCred(id ID, key crypto.Signer) (*WitCred, error) { + if id.Provider == "" { + id.Provider = DefaultSocketURI + } + // Fast path -- if the cache is already filled then skip generating a key. + if wit, ok := credCache.Load(id); ok { + return wit, nil + } + // Generate a new key if we were not told to use a particular key. + if key == nil { + var err error + // TODO: Possibly make this less hardcoded? (And switch to Ed25519! :P) + key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate signing key: %w", err) + } + } + pubJwk, err := toJwkPublic(key.Public()) + if err != nil { + return nil, fmt.Errorf("generate public jwk for signing key: %w", err) + } + // Try to insert a candidate "blank" WIT credential into the cache, but + // re-use an existing one if it was present. + wit, _ := credCache.LoadOrStore(id, &WitCred{ + id: id, + key: key, + pubJwk: pubJwk, + }) + return wit, nil +} + +// refreshMargin is how long before expiry a WIT is replaced, so a request +// signed just before expiry still arrives with a valid one. +const refreshMargin = 5 * time.Minute + +const vlInterface = "com.amutable.IdentityAgent" + +// DefaultSocketURI is the varlink URI of amutablectl's identity agent. +const DefaultSocketURI = "unix:/run/amutable/" + vlInterface + +type witParams struct { + Audience string `json:"aud"` + PublicKey jose.JSONWebKey `json:"jwk_pub"` +} + +type witReply struct { + Token string `json:"wit"` + ExpiresIn int64 `json:"expires_in"` +} + +// Credential returns the WIT for this [WitCred], potentially refreshing it if +// the WIT has expired since its last usage. The WIT should be used immediately +// after this operation, as the expiry may be quite short. +// +// TODO: Maybe we should return the expiry too...? +func (w *WitCred) Credential(ctx context.Context) (string, error) { + // checkLockedWit checks that the wit is valid and has not expired. If the + // wit has expired, valid will be false and the caller must refresh the + // wit. checkLockedWit must be called with at least w.witMu.RLock held. + checkLockedWit := func(w *WitCred) (wit string, valid bool) { + return w.wit, w.wit != "" && time.Now().Add(refreshMargin).Before(w.expiry) + } + + w.witMu.RLock() + wit, ok := checkLockedWit(w) + w.witMu.RUnlock() + if ok { + return wit, nil + } + + // We need to refresh the wit. + w.witMu.Lock() + defer w.witMu.Unlock() + // Because we cannot upgrade from a read to a write lock atomically it is + // possible multiple racing goroutines hit the same path, so make sure to + // double-check the wit is still expired once we get exclusive access. + if wit, ok := checkLockedWit(w); ok { + return wit, nil + } + now := time.Now() + + uri := w.ID().Provider + reply, err := varlink.Call[witReply](ctx, uri, vlInterface+".GetWorkloadIdentityToken", witParams{ + Audience: w.ID().WitAudience, + PublicKey: w.pubJwk, + }) + if err != nil { + return "", err + } + + if reply == nil || reply.Token == "" { + return "", fmt.Errorf("bad provider %s: empty reply to GetWorkloadIdentityToken request", uri) + } + + w.wit = reply.Token + w.expiry = now.Add(time.Duration(reply.ExpiresIn) * time.Second) + return w.wit, nil +} diff --git a/internal/wimseagent/witcred_test.go b/internal/wimseagent/witcred_test.go new file mode 100644 index 0000000..2c19488 --- /dev/null +++ b/internal/wimseagent/witcred_test.go @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package wimseagent + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "maps" + "path/filepath" + "slices" + "strconv" + "sync" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "snai.pe/go-varlink" +) + +// agentCall is one GetWorkloadIdentityToken call seen by the fake agent. +type agentCall struct { + Audience string + JWK jose.JSONWebKey + // Members are the sorted member names of the JWK as sent. + Members []string +} + +// expiringIn returns a fake agent reply that names the call number and +// expires after the given number of seconds. +func expiringIn(seconds int64) func(n int) witReply { + return func(n int) witReply { + return witReply{Token: "wit-" + strconv.Itoa(n), ExpiresIn: seconds} + } +} + +// startFakeAgent serves GetWorkloadIdentityToken with reply, recording each +// call. Like the real agent, it rejects a JWK that is not a valid public key. +func startFakeAgent(t *testing.T, reply func(n int) witReply) (string, func() []agentCall) { + t.Helper() + uri := "unix:" + filepath.Join(t.TempDir(), vlInterface) //nolint:forbidigo // test code + listener, err := varlink.Listen(uri) + require.NoError(t, err, "listen on fake agent socket") + t.Cleanup(func() { _ = listener.Close() }) + + var ( + mu sync.Mutex + calls []agentCall + mux varlink.ServeMux + ) + mux.HandleFunc(vlInterface+".GetWorkloadIdentityToken", func(w varlink.ReplyWriter, c *varlink.Call) { + var p witParams + if err := c.Unmarshal(&p); err != nil { + _ = w.WriteError(err) + return + } + var raw struct { + JWK map[string]json.RawMessage `json:"jwk_pub"` + } + if !p.PublicKey.Valid() || !p.PublicKey.IsPublic() || json.Unmarshal(c.Parameters, &raw) != nil { + _ = w.WriteError(varlink.NewError("org.varlink.service.InvalidParameter", "parameter", "jwk_pub")) + return + } + mu.Lock() + calls = append(calls, agentCall{ + Audience: p.Audience, + JWK: p.PublicKey, + Members: slices.Sorted(maps.Keys(raw.JWK)), + }) + n := len(calls) + mu.Unlock() + _ = w.WriteReply(reply(n)) + }) + srv := &varlink.Server{Handler: &mux} + go func() { _ = srv.Serve(listener) }() + return uri, func() []agentCall { + mu.Lock() + defer mu.Unlock() + return slices.Clone(calls) + } +} + +// newCred returns a credential with a fresh P-256 key for the agent at +// provider. Each test uses its own provider, so the shared cache never hands a +// test another test's credential. +func newCred(t *testing.T, witAudience, provider string) *WitCred { + t.Helper() + cred, err := NewCred(ID{WitAudience: witAudience, Provider: provider}, nil) + require.NoError(t, err, "NewCred") + return cred +} + +func thumbprint(t *testing.T, jwk jose.JSONWebKey) []byte { + t.Helper() + tp, err := jwk.Thumbprint(crypto.SHA256) + require.NoError(t, err, "JWK thumbprint") + return tp +} + +func TestNewCredSharedPerID(t *testing.T) { + provider := "unix:" + filepath.Join(t.TempDir(), "agent") //nolint:forbidigo // test code + cred := newCred(t, "aud", provider) + assert.Same(t, cred, newCred(t, "aud", provider), "same identity") + assert.NotSame(t, cred, newCred(t, "other", provider), "other WIT audience") + assert.NotSame(t, cred, newCred(t, "aud", provider+"-other"), "other provider") +} + +func TestNewCredDefaultProvider(t *testing.T) { + audience := "urn:example:" + t.Name() + cred := newCred(t, audience, "") + assert.Equal(t, DefaultSocketURI, cred.ID().Provider, "empty provider is the default agent") + assert.Same(t, cred, newCred(t, audience, DefaultSocketURI), "same identity as the explicit default") +} + +func TestNewCredCachedIgnoresKey(t *testing.T) { + id := ID{WitAudience: "aud", Provider: "unix:" + filepath.Join(t.TempDir(), "agent")} //nolint:forbidigo // test code + cred, err := NewCred(id, nil) + require.NoError(t, err, "NewCred") + + _, ed, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err, "generate Ed25519 key") + again, err := NewCred(id, ed) + require.NoError(t, err, "NewCred with a key for a cached identity") + assert.Same(t, cred, again, "the cached credential is returned") + _, isECDSA := again.Signer().Public().(*ecdsa.PublicKey) + assert.True(t, isECDSA, "the cached credential keeps its key") +} + +func TestNewCredUnsupportedKey(t *testing.T) { + agent, calls := startFakeAgent(t, expiringIn(3600)) + p521, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + require.NoError(t, err, "generate P-521 key") + + _, err = NewCred(ID{WitAudience: "aud", Provider: agent}, p521) + require.Error(t, err, "NewCred with a key httpsig cannot sign with") + assert.Empty(t, calls(), "no WIT is minted") +} + +func TestCredentialCachesUntilNearExpiry(t *testing.T) { + agent, calls := startFakeAgent(t, expiringIn(3600)) + cred := newCred(t, "urn:amutable:wit", agent) + + for range 2 { + wit, err := cred.Credential(t.Context()) + require.NoError(t, err, "Credential") + assert.Equal(t, "wit-1", wit, "WIT") + } + + require.Len(t, calls(), 1, "the WIT is reused until it nears expiry") + call := calls()[0] + assert.Equal(t, "urn:amutable:wit", call.Audience, "WIT audience") + assert.Equal(t, []string{"alg", "crv", "kty", "x", "y"}, call.Members, "only public members are sent") + assert.Equal(t, string(jose.ES256), call.JWK.Algorithm, "alg of the default key") + pub, _ := cred.Signer().Public().(*ecdsa.PublicKey) + assert.True(t, pub.Equal(call.JWK.Key), "the agent binds the signing key") +} + +func TestCredentialRefreshesNearExpiry(t *testing.T) { + // Already inside refreshMargin, so every call asks for a new WIT. + agent, calls := startFakeAgent(t, expiringIn(int64(refreshMargin/time.Second)-1)) + cred := newCred(t, "aud", agent) + + for i := range 2 { + wit, err := cred.Credential(t.Context()) + require.NoError(t, err, "Credential") + assert.Equal(t, "wit-"+strconv.Itoa(i+1), wit, "a new WIT each time") + } + require.Len(t, calls(), 2, "agent calls") + assert.Equal(t, thumbprint(t, calls()[0].JWK), thumbprint(t, calls()[1].JWK), "the key outlives the WIT") +} + +func TestCredentialConcurrentRefresh(t *testing.T) { + agent, calls := startFakeAgent(t, expiringIn(3600)) + cred := newCred(t, "aud", agent) + + var ( + wg sync.WaitGroup + wits [16]string + errs [16]error + ) + for i := range wits { + wg.Go(func() { wits[i], errs[i] = cred.Credential(t.Context()) }) + } + wg.Wait() + for i := range wits { + require.NoError(t, errs[i], "Credential") + assert.Equal(t, "wit-1", wits[i], "WIT") + } + assert.Len(t, calls(), 1, "racing callers share one refresh") +} + +func TestCredentialErrors(t *testing.T) { + t.Run("Unreachable", func(t *testing.T) { + cred := newCred(t, "aud", "unix:"+filepath.Join(t.TempDir(), "missing")) //nolint:forbidigo // test code + _, err := cred.Credential(t.Context()) + require.Error(t, err, "Credential with no agent") + }) + t.Run("EmptyReply", func(t *testing.T) { + agent, _ := startFakeAgent(t, func(int) witReply { return witReply{ExpiresIn: 3600} }) + cred := newCred(t, "aud", agent) + _, err := cred.Credential(t.Context()) + require.ErrorContains(t, err, "empty reply", "Credential with an empty WIT") + }) +} + +func TestNewCredKeyTypes(t *testing.T) { + p384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + require.NoError(t, err, "generate P-384 key") + _, ed, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err, "generate Ed25519 key") + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err, "generate RSA key") + + for _, test := range []struct { + name string + key crypto.Signer + wantAlg jose.SignatureAlgorithm + }{ + {"Default", nil, jose.ES256}, + {"P384", p384, jose.ES384}, + {"Ed25519", ed, "Ed25519"}, + {"RSA", rsaKey, jose.PS512}, + } { + t.Run(test.name, func(t *testing.T) { + agent, calls := startFakeAgent(t, expiringIn(3600)) + cred, err := NewCred(ID{WitAudience: "aud", Provider: agent}, test.key) + require.NoError(t, err, "NewCred") + if test.key != nil { + pub, _ := test.key.Public().(interface{ Equal(crypto.PublicKey) bool }) + assert.True(t, pub.Equal(cred.Signer().Public()), "the given key is used") + } + + got := signedRequest(t, &Transport{WitCred: cred, WimseAudience: "wimse"}) + require.Len(t, calls(), 1, "agent calls") + assert.Equal(t, string(test.wantAlg), calls()[0].JWK.Algorithm, "JWK alg") + verify(t, got, "wimse", calls()[0].JWK, witComponents) + }) + } +}