From 6ba9445eac0f2ca83d2a29883d957aea82093f2e Mon Sep 17 00:00:00 2001 From: Joaquim Rocha Date: Mon, 12 Oct 2026 17:21:44 +0100 Subject: [PATCH 1/2] tufclient: sign repo requests as this machine when configured A server that gives each machine only its own repo has to know which machine is fetching. A repo can now set an auth table of type "wimse" with an audience. Requests to its metadata host then carry this machine's workload identity token, from amutablectl's identity agent, and an RFC 9421 signature by a key quarry-client keeps in memory. Requests to other hosts stay unsigned. Repos without the setting fetch as before. Signed-off-by: Joaquim Rocha --- cmd/quarry-client/http.go | 38 ++++ cmd/quarry-client/http_insecure_test.go | 51 +++++ contrib/quarry-client/config.toml | 11 ++ internal/httputils/verified_http.go | 5 +- internal/httputils/verified_http_test.go | 46 ++--- internal/identityagent/identityagent.go | 190 +++++++++++++++++++ internal/identityagent/identityagent_test.go | 181 ++++++++++++++++++ internal/tufclient/client.go | 7 +- internal/tufclient/config/config.go | 50 ++++- internal/tufclient/config/config_test.go | 51 +++++ 10 files changed, 600 insertions(+), 30 deletions(-) create mode 100644 internal/identityagent/identityagent.go create mode 100644 internal/identityagent/identityagent_test.go 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..10ca794 100644 --- a/internal/tufclient/client.go +++ b/internal/tufclient/client.go @@ -137,6 +137,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 +283,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 +353,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/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) +} From 5375f22b4a55426288771b97d99198d25d0fea3c Mon Sep 17 00:00:00 2001 From: Joaquim Rocha Date: Tue, 13 Oct 2026 10:47:42 +0100 Subject: [PATCH 2/2] tufclient: skip a repo only when its root.json is missing Any failure to fetch a repo's first root.json skipped the repo. A machine that could not authenticate then updated nothing and still reported success. Now only a missing root.json, which means the repo may not exist yet, skips the repo. Any other failure is an error, so one repo that fails this way stops the whole run. Signed-off-by: Joaquim Rocha --- internal/tufclient/client.go | 9 ++++++++- internal/tufclient/client_repos_test.go | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/internal/tufclient/client.go b/internal/tufclient/client.go index 10ca794..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 { 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.