diff --git a/cmd/quarry-client/http.go b/cmd/quarry-client/http.go index 291e889..9e94cbc 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,12 @@ func proxyTargetFile(rw http.ResponseWriter, req *http.Request) (Err error) { return nil } + // A redirect would send systemd-pull to the repo without this repo's + // request signatures, so targets of a repo with auth are proxied instead. + if info.Repo.Auth != 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 @@ -248,6 +255,37 @@ func proxyTargetFile(rw http.ResponseWriter, req *http.Request) (Err error) { return errors.New("no fetch urls defined for target") } +// streamTargetFile serves the target's contents, fetched with the repo's own +// HTTP client. The digest is only checked at EOF, after the whole body has been +// written, so a mismatch aborts the connection but cannot take back the bytes: +// the client must check the body against 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 + } + 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 ffa167b..6e4a253 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" @@ -813,3 +816,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{"token": "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", 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..a3edfb7 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 to the meta_root_url host 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 +## "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. Requests to +## other hosts (such as a separate data_root_url) are not signed. "wimse" is +## the only supported type. +#auth = { type = "wimse", audience = "urn:amutable:quarry-worker" } 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/identityagent/identityagent.go b/internal/identityagent/identityagent.go new file mode 100644 index 0000000..84df790 --- /dev/null +++ b/internal/identityagent/identityagent.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +// Package identityagent signs HTTP requests as this machine, for servers +// such as the Amutable quarry-worker that serve a repository only to the +// machine it belongs to. +// +// The client holds its own P-256 key. amutablectl's com.amutable.IdentityAgent +// varlink service, which holds the device key, attests quarry-client by its +// systemd unit and returns the machine's WIMSE workload identity token (WIT) +// bound to that key. The client then signs each request itself, per the WIMSE +// workload-to-workload profile of RFC 9421: +// . +package identityagent + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "go.amutable.dev/quarry/internal/varlink" +) + +const vlInterface = "com.amutable.IdentityAgent" + +// DefaultSocketURI is the varlink URI of amutablectl's identity agent. +const DefaultSocketURI = "unix:/run/amutable/" + vlInterface + +// 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 ( + witHeader = "workload-identity-token" + signatureLabel = "wimse" + signatureTag = "wimse-workload-to-workload" +) + +type witParams struct { + Audience string `json:"audience"` + PublicKey string `json:"public_key"` +} + +type witReply struct { + Token string `json:"token"` + ExpiresIn int64 `json:"expires_in"` +} + +// Transport is an [http.RoundTripper] that signs every request to Host as this +// machine, with a WIT for Audience. Requests to other hosts go out unsigned, +// so the WIT never reaches a server it was not minted for. +// +// Only bodiless requests are signed correctly: no body digest is covered. +type Transport struct { + // Audience is the WIT audience the server accepts. + Audience string + // Host is the URL host (with port, if any) whose requests are signed. + Host string + // SocketURI is the agent's varlink URI; empty means [DefaultSocketURI]. + SocketURI string + + mu sync.Mutex + key *ecdsa.PrivateKey + wit string + expiry time.Time +} + +// 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) { + if req.URL.Host != t.Host { + return http.DefaultTransport.RoundTrip(req) + } + + key, wit, err := t.credential(req) + if err != nil { + return nil, fmt.Errorf("get WIT for %s from the identity agent: %w", req.URL.Host, err) + } + input, sig, err := sign(key, wit, req.Method, req.URL.RequestURI(), time.Now()) + if err != nil { + return nil, fmt.Errorf("sign request to %s: %w", req.URL.Host, err) + } + + // A RoundTripper must not modify the caller's request. + req = req.Clone(req.Context()) + req.Header.Set("Workload-Identity-Token", wit) + req.Header.Set("Signature-Input", signatureLabel+"="+input) + req.Header.Set("Signature", signatureLabel+"=:"+base64.StdEncoding.EncodeToString(sig)+":") + return http.DefaultTransport.RoundTrip(req) +} + +var ( + clientsMu sync.Mutex + clients = map[[3]string]*http.Client{} +) + +// Client returns the process-wide client that signs requests to host with a +// WIT for audience from the agent at socketURI (empty means +// [DefaultSocketURI]), creating it on first use. Callers that rebuild their +// configuration often, such as "quarry-client http" on every request, then +// keep one signing key and reuse its WIT until it nears expiry. +func Client(audience, host, socketURI string) *http.Client { + clientsMu.Lock() + defer clientsMu.Unlock() + k := [3]string{audience, host, socketURI} + c, ok := clients[k] + if !ok { + c = &http.Client{Transport: &Transport{Audience: audience, Host: host, SocketURI: socketURI}} + clients[k] = c + } + return c +} + +// credential returns the signing key and a WIT bound to it, asking the agent +// for a new WIT when the cached one is close to expiry. The key lives as long +// as the Transport and never leaves the process. +func (t *Transport) credential(req *http.Request) (*ecdsa.PrivateKey, string, error) { + t.mu.Lock() + defer t.mu.Unlock() + + now := time.Now() + if t.wit != "" && now.Add(refreshMargin).Before(t.expiry) { + return t.key, t.wit, nil + } + if t.key == nil { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, "", fmt.Errorf("generate signing key: %w", err) + } + t.key = key + } + pub, err := t.key.PublicKey.Bytes() + if err != nil { + return nil, "", fmt.Errorf("encode signing key: %w", err) + } + + uri := t.SocketURI + if uri == "" { + uri = DefaultSocketURI + } + reply, err := varlink.Call[witReply](req.Context(), uri, vlInterface+".GetWorkloadIdentityToken", witParams{ + Audience: t.Audience, + PublicKey: base64.RawURLEncoding.EncodeToString(pub), + }) + if err != nil { + return nil, "", err + } + if reply == nil || reply.Token == "" { + return nil, "", fmt.Errorf("empty reply") + } + t.wit = reply.Token + t.expiry = now.Add(time.Duration(reply.ExpiresIn) * time.Second) + return t.key, t.wit, nil +} + +// sign returns the Signature-Input member and the raw ES256 signature (r||s) +// over the RFC 9421 signature base of a bodiless request: the WIT header, +// @method and @request-target, in that order. +func sign(key *ecdsa.PrivateKey, wit, method, requestTarget string, now time.Time) (string, []byte, error) { + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return "", nil, fmt.Errorf("generate nonce: %w", err) + } + input := fmt.Sprintf(`("%s" "@method" "@request-target");created=%d;nonce="%s";tag="%s"`, + witHeader, now.Unix(), hex.EncodeToString(nonce[:]), signatureTag) + base := strings.Join([]string{ + fmt.Sprintf(`"%s": %s`, witHeader, wit), + `"@method": ` + method, + `"@request-target": ` + requestTarget, + `"@signature-params": ` + input, + }, "\n") + + digest := sha256.Sum256([]byte(base)) + r, s, err := ecdsa.Sign(rand.Reader, key, digest[:]) + if err != nil { + return "", nil, fmt.Errorf("ecdsa sign: %w", err) + } + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + return input, sig, nil +} diff --git a/internal/identityagent/identityagent_test.go b/internal/identityagent/identityagent_test.go new file mode 100644 index 0000000..fb18c81 --- /dev/null +++ b/internal/identityagent/identityagent_test.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Amutable GmbH + +package identityagent + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "encoding/base64" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "snai.pe/go-varlink" +) + +// startFakeAgent serves GetWorkloadIdentityToken, recording the keys it was +// asked to bind, and replies with a WIT naming the call number. +func startFakeAgent(t *testing.T, expiresIn int64) (string, func() []witParams) { + 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 + var calls []witParams + var 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 + } + mu.Lock() + calls = append(calls, p) + n := len(calls) + mu.Unlock() + _ = w.WriteReply(witReply{Token: "wit-" + strconv.Itoa(n), ExpiresIn: expiresIn}) + }) + srv := &varlink.Server{Handler: &mux} + go func() { _ = srv.Serve(listener) }() + return uri, func() []witParams { + mu.Lock() + defer mu.Unlock() + return slices.Clone(calls) + } +} + +var inputRe = regexp.MustCompile(`^wimse=(\("workload-identity-token" "@method" "@request-target"\);created=(\d+);nonce="[0-9a-f]{32}";tag="wimse-workload-to-workload")$`) + +// 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, boundKey string) { + t.Helper() + m := inputRe.FindStringSubmatch(r.Header.Get("Signature-Input")) + require.NotNil(t, m, "Signature-Input %q", r.Header.Get("Signature-Input")) + created, err := strconv.ParseInt(m[2], 10, 64) + require.NoError(t, err) + assert.InDelta(t, time.Now().Unix(), created, 5) + + sigB64, ok := strings.CutPrefix(r.Header.Get("Signature"), "wimse=:") + require.True(t, ok) + sig, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(sigB64, ":")) + require.NoError(t, err) + require.Len(t, sig, 64) + + base := `"workload-identity-token": ` + r.Header.Get("Workload-Identity-Token") + "\n" + + `"@method": ` + r.Method + "\n" + + `"@request-target": ` + r.URL.RequestURI() + "\n" + + `"@signature-params": ` + m[1] + digest := sha256.Sum256([]byte(base)) + + raw, err := base64.RawURLEncoding.DecodeString(boundKey) + require.NoError(t, err) + pub, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), raw) + require.NoError(t, err) + assert.True(t, ecdsa.Verify(pub, digest[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])), + "signature verifies with the key bound into the WIT") +} + +func TestTransportSignsWithTheBoundKey(t *testing.T) { + agent, calls := startFakeAgent(t, 3600) + + var got []*http.Request + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = append(got, r) + })) + defer srv.Close() + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + client := &http.Client{Transport: &Transport{ + Audience: "urn:amutable:quarry-worker", + Host: srvURL.Host, + SocketURI: agent, + }} + 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) + _ = res.Body.Close() + } + + require.Len(t, calls(), 1, "the WIT is reused until it nears expiry") + assert.Equal(t, "urn:amutable:quarry-worker", calls()[0].Audience) + require.Len(t, got, 2) + for _, r := range got { + assert.Equal(t, "wit-1", r.Header.Get("Workload-Identity-Token")) + verify(t, r, calls()[0].PublicKey) + } + + // Same server reached under another host name: not the signed host. + res, err := get(t, client, "http://localhost:"+srvURL.Port()+"/targets/x") + require.NoError(t, err) + _ = res.Body.Close() + require.Len(t, got, 3) + assert.Empty(t, got[2].Header.Get("Workload-Identity-Token"), "WIT must not leave for another host") +} + +func TestTransportRefreshesNearExpiry(t *testing.T) { + // Already inside refreshMargin, so every request asks for a new WIT. + agent, calls := startFakeAgent(t, int64(refreshMargin/time.Second)-1) + + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer srv.Close() + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + client := &http.Client{Transport: &Transport{Audience: "aud", Host: srvURL.Host, SocketURI: agent}} + for range 2 { + res, err := get(t, client, srv.URL+"/x") + require.NoError(t, err) + _ = res.Body.Close() + } + require.Len(t, calls(), 2) + assert.Equal(t, calls()[0].PublicKey, calls()[1].PublicKey, "the key outlives the WIT") +} + +func TestTransportFailsWhenAgentIsUnreachable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Error("request went out unsigned") + })) + defer srv.Close() + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + + client := &http.Client{Transport: &Transport{ + Audience: "aud", + Host: srvURL.Host, + SocketURI: "unix:" + filepath.Join(t.TempDir(), "missing"), //nolint:forbidigo // test code + }} + _, err = get(t, client, srv.URL+"/x") + require.ErrorContains(t, err, "identity agent") +} + +func TestClientIsSharedPerAudienceAndHost(t *testing.T) { + a := Client("aud", "example.com", "") + assert.Same(t, a, Client("aud", "example.com", ""), "a rebuilt config reuses the key and WIT") + assert.NotSame(t, a, Client("other", "example.com", "")) + assert.NotSame(t, a, Client("aud", "example.org", "")) + assert.NotSame(t, a, Client("aud", "example.com", "unix:/run/other")) +} + +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) + return client.Do(req) +} diff --git a/internal/tufclient/client.go b/internal/tufclient/client.go index e4fd5e7..603b572 100644 --- a/internal/tufclient/client.go +++ b/internal/tufclient/client.go @@ -78,7 +78,14 @@ func RepoClient(ctx context.Context, cacheDir *pathrs.Root, repo *config.Reposit // Fallback to fetch from the trusted root source. rootData, err := repo.RootTrust.FetchRoot(ctx, repo) if err != nil { - return nil, skippableErr(fmt.Errorf("fetch trusted root.json: %w", err)) + err = fmt.Errorf("fetch trusted root.json: %w", err) + // Only a missing root.json means the repository may not exist + // yet. Any other failure (auth, network, server error) must not + // look like an empty repository list. + if errors.Is(err, fs.ErrNotExist) { + err = skippableErr(err) + } + return nil, err } rootFile, err = repoCacheDir.Create(".", unix.O_TMPFILE|unix.O_RDWR|unix.O_NOFOLLOW, 0o644) if err != nil { @@ -137,6 +144,9 @@ func RepoClient(ctx context.Context, cacheDir *pathrs.Root, repo *config.Reposit 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) @@ -280,7 +290,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...", @@ -350,7 +360,7 @@ func (client *Client) trustedMetadataTargetsFetcher(repo *config.Repository, met metaPath := fmt.Sprintf("%d.%s.json", metaRef.Version, roleName) metaURL := repo.MetaRootURL.JoinPath(metaPath) - 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/client_repos_test.go b/internal/tufclient/client_repos_test.go index 6aed330..bfbfc38 100644 --- a/internal/tufclient/client_repos_test.go +++ b/internal/tufclient/client_repos_test.go @@ -184,6 +184,25 @@ func TestNewClient_SkipsInvalidRemoteRoot(t *testing.T) { require.ErrorContains(t, err, "root_trust root.json is invalid JSON") } +// A remote root_trust fetch that fails for any reason but a 404 (here, the +// server refusing the client) is fatal for NewClient: only a missing +// repository may be skipped. +func TestNewClient_RemoteRootFetchErrorFatal(t *testing.T) { + valid, denied := testrepo.New(t), testrepo.NewBroken(t) + denied.Handle("/1.root.json", http.HandlerFunc(func(wtr http.ResponseWriter, _ *http.Request) { + wtr.WriteHeader(http.StatusUnauthorized) + })) + cfg := testrepo.Config(t, + valid.ConfigBlock("valid-repo"), + denied.ConfigBlock("denied-repo"), + ) + + _, err := tufclient.NewClient(t.Context(), cfg) + require.NotErrorIs(t, err, tufclient.ErrSkippableRepo) + require.ErrorContains(t, err, "bad repo denied-repo") + require.ErrorContains(t, err, "status code 401") +} + // A repository with a non-remote root_trust source that fails to load is a // fatal error for NewClient, never a skippable one -- local sources are meant // to always exist and be valid. diff --git a/internal/tufclient/config/config.go b/internal/tufclient/config/config.go index 2ba3109..69eae30 100644 --- a/internal/tufclient/config/config.go +++ b/internal/tufclient/config/config.go @@ -21,6 +21,7 @@ import ( "github.com/BurntSushi/toml" "go.amutable.dev/quarry/internal/expand" + "go.amutable.dev/quarry/internal/identityagent" "go.amutable.dev/quarry/internal/third_party/funchelpers" ) @@ -206,8 +207,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) } @@ -342,6 +342,43 @@ type Repository struct { // DataRootURL is the base URL for the directory containing target data // files. DataRootURL *tomlURL `toml:"data_root_url"` + + // Auth says how requests to the MetaRootURL host authenticate. Nil means + // no authentication. + Auth *RepoAuth `toml:"auth"` + + // httpClient is shared by every config that authenticates with the same + // audience and host (see [identityagent.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 +} + +// RepoAuth is a repository's auth table. The only type is "wimse": each +// request is signed as this machine with a WIT for Audience from the +// identity agent at Varlink (see [identityagent.Transport]). +type RepoAuth struct { + Type string `toml:"type"` + Audience string `toml:"audience"` + Varlink string `toml:"varlink"` +} + +func (auth *RepoAuth) httpClient(host string) (*http.Client, error) { + switch { + case auth == nil: + return http.DefaultClient, nil + case auth.Type != "wimse": + return nil, fmt.Errorf("unsupported auth type %q", auth.Type) + case auth.Audience == "": + return nil, errors.New(`auth type "wimse" needs an audience`) + } + return identityagent.Client(auth.Audience, host, auth.Varlink), nil } const defaultOrderIndex = 100 @@ -501,6 +538,12 @@ func (cfg *Config) expandAndValidate() error { if err := repo.DataRootURL.Expand(subExpander); err != nil { return fmt.Errorf("repository %s has invalid data_root_url value: %w", repo.Name, err) } + + client, err := repo.Auth.httpClient(repo.MetaRootURL.Host) + if err != nil { + return fmt.Errorf("repository %s has invalid auth value: %w", repo.Name, err) + } + repo.httpClient = client } return nil } @@ -544,6 +587,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 12099b5..0fa9155 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" @@ -12,6 +13,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "go.amutable.dev/quarry/internal/identityagent" ) const uuidPat = `[0-9a-f]{32}` @@ -1110,3 +1113,51 @@ func TestRootTrustSource_String(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", audience = "urn:amutable:quarry-worker", varlink = "unix:/run/test.sock" } + +[repo.plain] +root_trust = "insecure-tofu" +`)) + require.NoError(t, err) + + transport, ok := conf.Repos["signed"].HTTPClient().Transport.(*identityagent.Transport) + require.True(t, ok, "a wimse repo signs its requests") + assert.Equal(t, "urn:amutable:quarry-worker", transport.Audience) + assert.Equal(t, "customerdata.example:8443", transport.Host) + assert.Equal(t, "unix:/run/test.sock", transport.SocketURI) + + assert.Same(t, http.DefaultClient, conf.Repos["plain"].HTTPClient()) +} + +func TestParseConfig_AuthInvalid(t *testing.T) { + for auth, wantErr := range map[string]string{ + `{ type = "basic" }`: `unsupported auth type "basic"`, + `{ type = "wimse" }`: `auth type "wimse" needs an audience`, + `{ type = "wimse", audiance = "x" }`: "audiance", + } { + 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", audience = "urn:amutable:quarry-worker" } +`) + assert.Equal(t, &RepoAuth{Type: "wimse", Audience: "urn:amutable:quarry-worker"}, cfg.Repos["example.com/m"].Auth) +}