Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ run:
build-tags:
- insecure # internal/keystore/internal
- http # cmd/quarry-client/http.go
- fixtures # internal/fixtures

linters:
enable:
Expand Down
38 changes: 38 additions & 0 deletions cmd/quarry-client/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -220,6 +221,12 @@ func proxyTargetFile(rw http.ResponseWriter, req *http.Request) (Err error) {
return nil
}

// A redirect would send systemd-pull to the repo without this repo's
// request signatures, so targets of a repo with auth are proxied instead.
if info.Repo.Client != nil {
return streamTargetFile(rw, req, info)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is quite unfortunate, though since only the method and URI are included in the signature I guess we can eventually pre-compute the signed headers and provide them to sd-pull once it switches to UAPI.16 (and a UAPI.16 extension is added to support it).

@davidstrauss Do we plan to extend the scope of things we would sign for WIMSE/WIT? If we start signing other headers then we would just need to accept that we are going to be proxying data a lot. (Though we could of course choose to proxy based n what headers are needed to be signed...)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might need more context, but I wasn't expecting we'd use WIT/WIMSE for repo auth at all. I thought we only wanted to rely on authZ mechanisms that (1) we can delegate to the delivery system itself (mainly for cost, DoS protection, and maybe some basic infrastructure disclosure control) or (2) we can achieve via sealing the data going through Quarry (for load-bearing secret delivery to specific machines).

In short, I assumed our entire model would work fine with publicly readable buckets.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can delegate to the delivery system itself (mainly for cost, DoS protection, and maybe some basic infrastructure disclosure control)

This is the "front door" access control we discussed in January, BTW.


// 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
Expand Down Expand Up @@ -252,6 +259,37 @@ func proxyTargetFile(rw http.ResponseWriter, req *http.Request) (Err error) {
return errors.New("no fetch urls defined for target")
}

// streamTargetFile serves the target's contents, fetched with the repo's own
// HTTP client. The digest is only checked at EOF, after the whole body has been
// written, so a mismatch aborts the connection but cannot take back the bytes:
// the client must check the body against Content-Digest.
func streamTargetFile(rw http.ResponseWriter, req *http.Request, info *tufclient.TargetInfo) error {
ctx := req.Context()

rdr, err := info.Fetch(ctx)
if errors.Is(err, fs.ErrNotExist) {
http.NotFound(rw, req)
return nil
}
if err != nil {
return fmt.Errorf("fetch target %s from repo %s: %w", info.Path, info.Repo.Name, err)
}

rw.Header().Set("Content-Length", strconv.FormatInt(info.Length, 10))
rw.Header().Set("Content-Type", "application/octet-stream")
if digestValue := hashesToContentDigest(info.Hashes); digestValue != "" {
rw.Header().Set("Content-Digest", digestValue)
rw.Header().Set("Repr-Digest", digestValue)
}
_, copyErr := io.Copy(rw, rdr)
if err := errors.Join(copyErr, rdr.Close()); err != nil {
slog.ErrorContext(ctx, "Aborting target response",
"repo", info.Repo.Name, "target", info.Path, "err", err.Error())
panic(http.ErrAbortHandler)
}
return nil
}

// newSysupdateHandler returns a [http.Handler] that impements our sysupdate
// SHA256SUM-based compatibility shim.
func newSysupdateHandler() http.Handler {
Expand Down
51 changes: 51 additions & 0 deletions cmd/quarry-client/http_insecure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import (
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"path/filepath"
"slices"
"strconv"
"strings"
Expand All @@ -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"
Expand Down Expand Up @@ -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"))
}
11 changes: 11 additions & 0 deletions contrib/quarry-client/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ require (
cyphar.com/go-pathrs v0.2.6
github.com/BurntSushi/toml v1.6.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/go-jose/go-jose/v4 v4.1.7
github.com/go4org/hashtriemap v0.0.0-20260925222741-44e5305f85d9
github.com/google/uuid v1.6.0
github.com/gorilla/handlers v1.5.2
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/umoci v0.6.0
github.com/schollz/progressbar/v3 v3.19.1
github.com/secure-systems-lab/go-securesystemslib v0.11.1
github.com/shogo82148/go-sfv v0.3.6
github.com/stretchr/testify v1.12.1
github.com/theupdateframework/go-tuf/v2 v2.4.1
github.com/tiendc/go-deepcopy v1.7.2
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -71,6 +75,8 @@ github.com/schollz/progressbar/v3 v3.19.1/go.mod h1:LFL7jqimKxfhero4K1eCkUr/6R39
github.com/secure-systems-lab/go-securesystemslib v0.11.1 h1:ayahDPjfSIqKegyt5YVGEvQ7SAi72GHXTy+b2YjUd5w=
github.com/secure-systems-lab/go-securesystemslib v0.11.1/go.mod h1:UyOjhoZLi76ir63u1ptubEevVe6dlNPmNxwgVvKqFFY=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shogo82148/go-sfv v0.3.6 h1:8MpndGsf6vZ3dHeTYN8TnKjL5TstUYIpwS7urkehv2o=
github.com/shogo82148/go-sfv v0.3.6/go.mod h1:VYNXeh/Gj0TO5PR+K7ZNTRnxKe233q6kYU/qIL0s6s4=
github.com/sigstore/protobuf-specs v0.5.0 h1:F8YTI65xOHw70NrvPwJ5PhAzsvTnuJMGLkA4FIkofAY=
github.com/sigstore/protobuf-specs v0.5.0/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc=
github.com/sigstore/sigstore v1.10.4 h1:ytOmxMgLdcUed3w1SbbZOgcxqwMG61lh1TmZLN+WeZE=
Expand Down
184 changes: 184 additions & 0 deletions internal/fixtures/acp-sample-gen/acp-sample-gen.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// 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 \
// <control-plane>/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"
"net/http/httptest"
"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"
)

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
}

var header http.Header
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
header = r.Header.Clone()
}))
defer srv.Close()
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 := srv.URL + "/machine-id/" + 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 the capture handler

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 <go-signed-read.json>\n", os.Args[0])
os.Exit(2)
}
if err := run(os.Args[1]); err != nil {
log.Fatal(err)
}
}
13 changes: 13 additions & 0 deletions internal/generics/slice.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading