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