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)
+}