diff --git a/Dockerfile b/Dockerfile index c62e727..ffe0f36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /go/src/github.com/superfly/tokenizer COPY go.mod go.sum ./ @@ -9,7 +9,6 @@ RUN --mount=type=cache,target=/root/.cache/go-build \ COPY VERSION ./ COPY *.go ./ COPY ./macaroon ./macaroon -COPY ./flysrc ./flysrc COPY ./cmd/tokenizer ./cmd/tokenizer RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg \ diff --git a/QuickStart.md b/QuickStart.md new file mode 100644 index 0000000..864ea18 --- /dev/null +++ b/QuickStart.md @@ -0,0 +1,64 @@ +# Quick Start + +Here's a short walk through of setting up and using the tokenizer proxy. +The use case here is running the proxy from a public fly address, such that it +is accessible by any other fly app that has a valid wrapped secret. + +## Config file + +The config file I used is called `fly.toml.timkenizer` with the following contents: + +``` +app = 'timkenizer' +primary_region = 'sjc' +kill_signal = 'SIGINT' + +[build] + +[env] + OPEN_PROXY = 'false' + REQUIRE_FLY_SRC = 'true' + TOKENIZER_HOSTNAMES = 'timkenizer.fly.dev' + +[http_service] + internal_port = 8080 + auto_stop_machines = 'off' + auto_start_machines = false + min_machines_running = 1 + processes = ['app'] + +[[vm]] + memory = '2gb' + cpu_kind = 'shared' + cpus = 1 +``` + +## Commands + +The commands I used to create the app and use it are: + +``` +# create the app, it will fail to start +fly -c fly.toml.timkenizer launch + +# generate and set the secret "open" and "seal" keys. +# install the OPEN_KEY on the server and keep the SEAL_KEY for later. +export OPEN_KEY=$(openssl rand -hex 32) +export SEAL_KEY=$(go run ./cmd/tokenizer -sealkey) +fly -c fly.toml.timkenizer secrets set OPEN_KEY=$OPEN_KEY + +# use the SEAL_KEY to generate a proxy token that will inject a secret token into requests to the target. +# here restricted to use against https://timflyio-go-example.fly.dev from app=thenewsh +TOKEN=$(go run ./cmd/sealtoken -host timflyio-go-example.fly.dev -org tim-newsham -app thenewsh MY_SECRET_TOKEN) + +# install the TOKEN in your approved app and use it to access the approved url. +# the secret token (MY_SECRET_TOKEN) will be added as a bearer token. +# note: you'll need to opt-in to get a fly-src header to allow the proxy to approve the request. +curl -H "Proxy-Tokenizer: $TOKEN" -H "fly-src-optin: *" -x https://timkenizer.fly.dev http://timflyio-go-example.fly.dev + +# try out some bad requests to the wrong target, from the wrong app, etc.. +curl -H "Proxy-Tokenizer: $TOKEN" -H "fly-src-optin: *" -x https://timkenizer.fly.dev http://thenewsh.fly.dev + +# review the log files +fly -c fly.toml.timkenizer logs +``` diff --git a/authorizer.go b/authorizer.go index abb98fe..f15e4c7 100644 --- a/authorizer.go +++ b/authorizer.go @@ -13,11 +13,11 @@ import ( "time" "github.com/sirupsen/logrus" + "github.com/superfly/flysrc-go" "github.com/superfly/macaroon" "github.com/superfly/macaroon/bundle" "github.com/superfly/macaroon/flyio" "github.com/superfly/macaroon/flyio/machinesapi" - "github.com/superfly/tokenizer/flysrc" tkmac "github.com/superfly/tokenizer/macaroon" "golang.org/x/exp/slices" ) @@ -28,8 +28,20 @@ const ( maxFlySrcAge = 30 * time.Second ) +var redactedStr = "REDACTED" +var redactedBase64 []byte + +func init() { + redactedBase64, _ = base64.StdEncoding.DecodeString("REDACTED") +} + +type AuthContext interface { + GetFlysrcParser() *flysrc.Parser +} + type AuthConfig interface { - AuthRequest(req *http.Request) error + AuthRequest(authctx AuthContext, req *http.Request) error + StripHazmat() AuthConfig } type wireAuth struct { @@ -99,7 +111,7 @@ func NewBearerAuthConfig(token string) *BearerAuthConfig { var _ AuthConfig = (*BearerAuthConfig)(nil) -func (c *BearerAuthConfig) AuthRequest(req *http.Request) error { +func (c *BearerAuthConfig) AuthRequest(authctx AuthContext, req *http.Request) error { for _, tok := range proxyAuthorizationTokens(req) { hdrDigest := sha256.Sum256([]byte(tok)) if subtle.ConstantTimeCompare(c.Digest, hdrDigest[:]) == 1 { @@ -110,6 +122,10 @@ func (c *BearerAuthConfig) AuthRequest(req *http.Request) error { return fmt.Errorf("%w: bad or missing proxy auth", ErrNotAuthorized) } +func (c *BearerAuthConfig) StripHazmat() AuthConfig { + return &BearerAuthConfig{redactedBase64} +} + type MacaroonAuthConfig struct { Key []byte `json:"key"` } @@ -120,7 +136,7 @@ func NewMacaroonAuthConfig(key []byte) *MacaroonAuthConfig { var _ AuthConfig = (*MacaroonAuthConfig)(nil) -func (c *MacaroonAuthConfig) AuthRequest(req *http.Request) error { +func (c *MacaroonAuthConfig) AuthRequest(authctx AuthContext, req *http.Request) error { var ( expectedKID = tkmac.KeyFingerprint(c.Key) log = logrus.WithField("expected-kid", hex.EncodeToString(expectedKID)) @@ -150,6 +166,10 @@ func (c *MacaroonAuthConfig) AuthRequest(req *http.Request) error { return fmt.Errorf("%w: bad or missing proxy auth", ErrNotAuthorized) } +func (c *MacaroonAuthConfig) StripHazmat() AuthConfig { + return &MacaroonAuthConfig{redactedBase64} +} + func (c *MacaroonAuthConfig) Macaroon(caveats ...macaroon.Caveat) (string, error) { m, err := macaroon.New(tkmac.KeyFingerprint(c.Key), tkmac.Location, c.Key) if err != nil { @@ -178,7 +198,7 @@ func NewFlyioMacaroonAuthConfig(access *flyio.Access) *FlyioMacaroonAuthConfig { var _ AuthConfig = (*FlyioMacaroonAuthConfig)(nil) -func (c *FlyioMacaroonAuthConfig) AuthRequest(req *http.Request) error { +func (c *FlyioMacaroonAuthConfig) AuthRequest(authctx AuthContext, req *http.Request) error { var ctx = req.Context() for _, tok := range proxyAuthorizationTokens(req) { @@ -204,6 +224,10 @@ func (c *FlyioMacaroonAuthConfig) AuthRequest(req *http.Request) error { return fmt.Errorf("%w: bad or missing proxy auth", ErrNotAuthorized) } +func (c *FlyioMacaroonAuthConfig) StripHazmat() AuthConfig { + return c +} + // FlySrcAuthConfig allows permitting access to a secret based on the Fly-Src // header added to Flycast requests between Fly.io machines/apps/orgs. // https://community.fly.io/t/fly-src-authenticating-http-requests-between-fly-apps/20566 @@ -259,8 +283,13 @@ func NewFlySrcAuthConfig(opts ...FlySrcOpt) *FlySrcAuthConfig { var _ AuthConfig = (*FlySrcAuthConfig)(nil) -func (c *FlySrcAuthConfig) AuthRequest(req *http.Request) error { - fs, err := flysrc.FromRequest(req) +func (c *FlySrcAuthConfig) AuthRequest(authctx AuthContext, req *http.Request) error { + flysrcParser := authctx.GetFlysrcParser() + if flysrcParser == nil { + return fmt.Errorf("%w: no flysrc parser", ErrNotAuthorized) + } + + fs, err := flysrcParser.FromRequest(req) if err != nil { return fmt.Errorf("%w: %w", ErrNotAuthorized, err) } @@ -280,14 +309,22 @@ func (c *FlySrcAuthConfig) AuthRequest(req *http.Request) error { return nil } +func (c *FlySrcAuthConfig) StripHazmat() AuthConfig { + return c +} + type NoAuthConfig struct{} var _ AuthConfig = (*NoAuthConfig)(nil) -func (c *NoAuthConfig) AuthRequest(req *http.Request) error { +func (c *NoAuthConfig) AuthRequest(authctx AuthContext, req *http.Request) error { return nil } +func (c *NoAuthConfig) StripHazmat() AuthConfig { + return c +} + func proxyAuthorizationTokens(req *http.Request) (ret []string) { hdrLoop: for _, hdr := range req.Header.Values(headerProxyAuthorization) { diff --git a/cmd/sealtoken/main.go b/cmd/sealtoken/main.go new file mode 100644 index 0000000..f2775e0 --- /dev/null +++ b/cmd/sealtoken/main.go @@ -0,0 +1,118 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/superfly/tokenizer" +) + +func wrapToken(token, sealKey, orgSlug, appSlug, targHost, targHdr string, debug bool) (string, error) { + inj := &tokenizer.InjectProcessorConfig{Token: token} + secret := tokenizer.Secret{ + AuthConfig: tokenizer.NewFlySrcAuthConfig( + tokenizer.AllowlistFlySrcOrgs(orgSlug), + tokenizer.AllowlistFlySrcApps(appSlug), + ), + ProcessorConfig: inj, + RequestValidators: []tokenizer.RequestValidator{ + tokenizer.AllowHosts(targHost), + }, + } + + // If they request a specific header, fill it in verbatim. + // Otherwise the tokenizer will default to filling in "Authorization: Bearer ". + if targHdr != "" { + inj.Fmt = "%s" + inj.Dst = targHdr + } + + if debug { + bs, err := json.Marshal(secret) + if err != nil { + return "", fmt.Errorf("json.Marshal: %w", err) + } + return string(bs), nil + } + + return secret.Seal(sealKey) +} + +func wrapJsonToken(token, sealKey string, debug bool) (string, error) { + var secret tokenizer.Secret + if err := json.Unmarshal([]byte(token), &secret); err != nil { + return "", fmt.Errorf("json.Unmarshal: %w", err) + } + + if debug { + bs, err := json.Marshal(secret) + if err != nil { + return "", fmt.Errorf("json.Marshal: %w", err) + } + return string(bs), nil + } + + return secret.Seal(sealKey) +} + +func tryMain() error { + defSealKey := os.Getenv("SEAL_KEY") + sealKey := flag.String("sealkey", defSealKey, "tokenizer seal key, or from environment SEAL_KEY") + json := flag.Bool("json", false, "create sealed token from json tokenizer secret") + orgSlug := flag.String("org", "", "allowed org slug") + appSlug := flag.String("app", "", "allowed app slug") + targHost := flag.String("host", "", "target host") + targHdr := flag.String("header", "", "target header to fill. Defaults to the bearer authorization header") + debug := flag.Bool("debug", false, "show json of sealed secret") + + prog := os.Args[0] + flag.Parse() + args := flag.Args() + + if len(args) != 1 { + fmt.Printf("usage: %s [flags] token\n", prog) + flag.PrintDefaults() + return fmt.Errorf("token unspecified") + } + + if *sealKey == "" { + return fmt.Errorf("sealkey unspecified") + } + + if *json { + j := args[0] + wrapped, err := wrapJsonToken(j, *sealKey, *debug) + if err != nil { + return fmt.Errorf("wrapJsonToken: %w", err) + } + fmt.Printf("%s\n", wrapped) + } else { + if *orgSlug == "" { + return fmt.Errorf("org unspecified") + } + if *appSlug == "" { + return fmt.Errorf("app unspecified") + } + if *targHost == "" { + return fmt.Errorf("target host unspecified") + } + + token := args[0] + + wrapped, err := wrapToken(token, *sealKey, *orgSlug, *appSlug, *targHost, *targHdr, *debug) + if err != nil { + return fmt.Errorf("wrapToken: %w", err) + } + fmt.Printf("%s\n", wrapped) + } + return nil +} + +func main() { + if err := tryMain(); err != nil { + fmt.Printf("%v\n", err) + os.Exit(1) + } +} diff --git a/cmd/tokenizer/main.go b/cmd/tokenizer/main.go index 352561f..52ebd67 100644 --- a/cmd/tokenizer/main.go +++ b/cmd/tokenizer/main.go @@ -17,6 +17,8 @@ import ( "github.com/sirupsen/logrus" "github.com/superfly/tokenizer" "golang.org/x/exp/slices" + + "github.com/superfly/flysrc-go" ) // Package variables can be overridden at build time: @@ -32,6 +34,7 @@ var ( var ( versionFlag = flag.Bool("version", false, "print the version number") + sealKeyFlag = flag.Bool("sealkey", false, "print the seal key and exit") ) func init() { @@ -57,6 +60,8 @@ func main() { switch { case *versionFlag: runVersion() + case *sealKeyFlag: + runSealKey() default: runServe() } @@ -75,7 +80,7 @@ func runServe() { key := os.Getenv("OPEN_KEY") if key == "" { - fmt.Fprintf(os.Stderr, "missing OPEN_KEY") + fmt.Fprintf(os.Stderr, "missing OPEN_KEY\n") os.Exit(1) } @@ -89,6 +94,21 @@ func runServe() { opts = append(opts, tokenizer.OpenProxy()) } + if slices.Contains([]string{"1", "true"}, os.Getenv("REQUIRE_FLY_SRC")) { + opts = append(opts, tokenizer.RequireFlySrc()) + } + + if slices.Contains([]string{"1", "true"}, os.Getenv("NO_FLY_SRC")) { + // nothing + } else { + parser, err := flysrc.New() + if err != nil { + logrus.WithError(err).Panic("Error making flysrc parser") + } + + opts = append(opts, tokenizer.WithFlysrcParser(parser)) + } + tkz := tokenizer.NewTokenizer(key, opts...) if len(os.Getenv("DEBUG")) != 0 { @@ -137,6 +157,17 @@ func handleSignals(server *http.Server) { } } +func runSealKey() { + key := os.Getenv("OPEN_KEY") + if key == "" { + fmt.Fprintf(os.Stderr, "missing OPEN_KEY\n") + os.Exit(1) + } + + tkz := tokenizer.NewTokenizer(key) + fmt.Fprintf(os.Stderr, "export SEAL_KEY=%v\n", tkz.SealKey()) +} + var Version = "" func runVersion() { @@ -175,8 +206,7 @@ func versionString() string { } func usage() { - fmt.Fprintf(os.Stderr, ` -tokenizer is an HTTP proxy that injects third party authentication credentials into requests + fmt.Fprintf(os.Stderr, `tokenizer is an HTTP proxy that injects third party authentication credentials into requests Usage: @@ -194,7 +224,7 @@ Configuration — tokenizer is configured using the following environment variab LISTEN_ADDRESS - The host:port address to listen at. Default: ":8080" FILTERED_HEADERS - Comma separated list of headers to filter from client requests. -`[1:]) +`) } type debugListener struct { diff --git a/flysrc/fly_src.go b/flysrc/fly_src.go deleted file mode 100644 index fd79e17..0000000 --- a/flysrc/fly_src.go +++ /dev/null @@ -1,142 +0,0 @@ -package flysrc - -import ( - "crypto/ed25519" - "encoding/base64" - "encoding/hex" - "errors" - "fmt" - "net/http" - "os" - "strconv" - "strings" - "time" - - "github.com/sirupsen/logrus" -) - -const ( - headerFlySrc = "Fly-Src" - headerFlySrcSignature = "Fly-Src-Signature" - flySrcSignatureKeyPath = "/.fly/fly-src.pub" - - maxFlySrcAge = 30 * time.Second -) - -var VerifyKey = readFlySrcKey(flySrcSignatureKeyPath) - -type Parsed struct { - Org string - App string - Instance string - Timestamp time.Time -} - -func FromRequest(req *http.Request) (*Parsed, error) { - srcHdr := req.Header.Get(headerFlySrc) - if srcHdr == "" { - return nil, errors.New("missing Fly-Src header") - } - - sigHdr := req.Header.Get(headerFlySrcSignature) - if sigHdr == "" { - return nil, errors.New("missing Fly-Src signature") - } - - return verifyAndParseFlySrc(srcHdr, sigHdr, VerifyKey) -} - -func verifyAndParseFlySrc(srcHdr, sigHdr string, key ed25519.PublicKey) (*Parsed, error) { - sig, err := base64.StdEncoding.DecodeString(sigHdr) - if err != nil { - return nil, fmt.Errorf("bad Fly-Src signature: %w", err) - } - - if !ed25519.Verify(key, []byte(srcHdr), sig) { - return nil, errors.New("bad Fly-Src signature") - } - - p, err := parseFlySrc(srcHdr) - if err != nil { - return nil, fmt.Errorf("bad Fly-Src header: %w", err) - } - - if p.age() > maxFlySrcAge { - return nil, fmt.Errorf("expired Fly-Src header") - } - - return p, nil -} - -func parseFlySrc(hdr string) (*Parsed, error) { - var ret Parsed - - parts := strings.Split(hdr, ";") - if n := len(parts); n != 4 { - return nil, fmt.Errorf("malformed Fly-Src header (%d parts)", n) - } - - for _, part := range parts { - k, v, ok := strings.Cut(part, "=") - if !ok { - return nil, fmt.Errorf("malformed Fly-Src header (missing =)") - } - - switch k { - case "org": - ret.Org = v - case "app": - ret.App = v - case "instance": - ret.Instance = v - case "ts": - tsi, err := strconv.Atoi(v) - if err != nil { - return nil, fmt.Errorf("malformed Fly-Src timestamp: %w", err) - } - - ret.Timestamp = time.Unix(int64(tsi), 0) - default: - return nil, fmt.Errorf("malformed Fly-Src header (unknown key: %q)", k) - } - } - - if ret.Org == "" || ret.App == "" || ret.Instance == "" || ret.Timestamp.IsZero() { - return nil, fmt.Errorf("malformed Fly-Src header (missing parts)") - } - - return &ret, nil -} - -func (p *Parsed) String() string { - return fmt.Sprintf("instance=%s;app=%s;org=%s;ts=%d", p.Instance, p.App, p.Org, p.Timestamp.Unix()) -} - -func (p *Parsed) Sign(key ed25519.PrivateKey) string { - return base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(p.String()))) -} - -func (p *Parsed) age() time.Duration { - return time.Since(p.Timestamp) -} - -func readFlySrcKey(path string) ed25519.PublicKey { - hk, err := os.ReadFile(path) - if err != nil { - logrus.WithError(err).Warn("failed to read Fly-Src public key") - return nil - } - - if size := len(hk); hex.DecodedLen(size) != ed25519.PublicKeySize { - logrus.WithField("size", size).Warn("bad Fly-Src public key size") - return nil - } - - key := make(ed25519.PublicKey, ed25519.PublicKeySize) - if _, err := hex.Decode(key, hk); err != nil { - logrus.WithError(err).Warn("bad Fly-Src public key") - return nil - } - - return key -} diff --git a/flysrc/fly_src_test.go b/flysrc/fly_src_test.go deleted file mode 100644 index 4d0b052..0000000 --- a/flysrc/fly_src_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package flysrc - -import ( - "crypto/ed25519" - "os" - "path/filepath" - "testing" - "time" - - "github.com/alecthomas/assert/v2" -) - -func TestVerifyAndParseFlySrc(t *testing.T) { - pub, priv, err := ed25519.GenerateKey(nil) - assert.NoError(t, err) - - // good - p := &Parsed{"foo", "bar", "baz", time.Now().Truncate(time.Second)} - fs2, err := verifyAndParseFlySrc(p.String(), p.Sign(priv), pub) - assert.NoError(t, err) - assert.Equal(t, p, fs2) - - // expired - p = &Parsed{"foo", "bar", "baz", time.Now().Add(-time.Hour).Truncate(time.Second)} - _, err = verifyAndParseFlySrc(p.String(), p.Sign(priv), pub) - assert.Error(t, err) - - // bad signature - p = &Parsed{"foo", "bar", "baz", time.Now().Truncate(time.Second)} - sig := (&Parsed{"other", "bar", "baz", time.Now().Truncate(time.Second)}).Sign(priv) - _, err = verifyAndParseFlySrc(p.String(), sig, pub) - assert.Error(t, err) - - // missing fields - p = &Parsed{"", "bar", "baz", time.Now().Truncate(time.Second)} - _, err = verifyAndParseFlySrc(p.String(), p.Sign(priv), pub) - assert.Error(t, err) - - p = &Parsed{"foo", "", "baz", time.Now().Truncate(time.Second)} - _, err = verifyAndParseFlySrc(p.String(), p.Sign(priv), pub) - assert.Error(t, err) - - p = &Parsed{"foo", "bar", "", time.Now().Truncate(time.Second)} - _, err = verifyAndParseFlySrc(p.String(), p.Sign(priv), pub) - assert.Error(t, err) - - p = &Parsed{"foo", "bar", "baz", time.Time{}} - _, err = verifyAndParseFlySrc(p.String(), p.Sign(priv), pub) - assert.Error(t, err) - - // totally bogus - _, err = verifyAndParseFlySrc("hello world!", sig, pub) - assert.Error(t, err) -} - -func TestReadFlySrcKey(t *testing.T) { - var ( - path = filepath.Join(t.TempDir(), "k.pub") - keyHex = "93e9adb1615a6ce6238a13c264e7c8ba8f8b7a53717e86bb34fce3b80d45f1e5" - key = []byte{147, 233, 173, 177, 97, 90, 108, 230, 35, 138, 19, 194, 100, 231, 200, 186, 143, 139, 122, 83, 113, 126, 134, 187, 52, 252, 227, 184, 13, 69, 241, 229} - ) - - assert.NoError(t, os.WriteFile(path, []byte(keyHex), 0644)) - assert.Equal(t, key, readFlySrcKey(path)) -} diff --git a/go.mod b/go.mod index b760a35..8fec771 100644 --- a/go.mod +++ b/go.mod @@ -1,26 +1,29 @@ module github.com/superfly/tokenizer -go 1.23.0 - -toolchain go1.24.2 +go 1.25.0 require ( - github.com/alecthomas/assert/v2 v2.3.0 - github.com/aws/aws-sdk-go-v2 v1.30.3 - github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 - github.com/sirupsen/logrus v1.9.3 - github.com/superfly/macaroon v0.2.14-0.20240819201738-61a02aa53648 - golang.org/x/crypto v0.39.0 - golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 + github.com/alecthomas/assert/v2 v2.11.0 + github.com/aws/aws-sdk-go-v2 v1.41.1 + github.com/elazarl/goproxy v1.8.2 + github.com/icholy/replace v0.6.0 + github.com/sirupsen/logrus v1.9.4 + github.com/superfly/flysrc-go v0.0.3 + github.com/superfly/macaroon v0.3.0 + golang.org/x/crypto v0.48.0 + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa ) require ( - github.com/alecthomas/repr v0.2.0 // indirect - github.com/aws/smithy-go v1.20.3 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/alecthomas/repr v0.4.0 // indirect + github.com/aws/smithy-go v1.24.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect - github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index de60fa2..614684d 100644 --- a/go.sum +++ b/go.sum @@ -1,47 +1,66 @@ -github.com/alecthomas/assert/v2 v2.3.0 h1:mAsH2wmvjsuvyBvAmCtm7zFsBlb8mIHx5ySLVdDZXL0= -github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= -github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= -github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= -github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= -github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= -github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 h1:1L0aalTpPz7YlMxETKpmQoWMBkeiuorElZIXoNmgiPE= -github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= -github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= -github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/elazarl/goproxy v1.8.2 h1:keGt9KHFAnrXFEctQuOF9NRxKFCXtd5cQg5PrBdeVW4= +github.com/elazarl/goproxy v1.8.2/go.mod h1:b5xm6W48AUHNpRTCvlnd0YVh+JafCCtsLsJZvvNTz+E= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/icholy/replace v0.6.0 h1:EBiD2pGqZIOJAbEaf/5GVRaD/Pmbb4n+K3LrBdXd4dw= +github.com/icholy/replace v0.6.0/go.mod h1:zzi8pxElj2t/5wHHHYmH45D+KxytX/t4w3ClY5nlK+g= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/superfly/macaroon v0.2.14-0.20240819201738-61a02aa53648 h1:YQG1v1QcTFQxJureNBcbtxosZ98u78ceUNCDQgI/vgM= -github.com/superfly/macaroon v0.2.14-0.20240819201738-61a02aa53648/go.mod h1:Kt6/EdSYfFjR4GIe+erMwcJgU8iMu1noYVceQ5dNdKo= -github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= -github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/superfly/flysrc-go v0.0.3 h1:ykFxTVWX9sp6s+9ukg5ZQWbco0wT3TOiWwODhR3Q4UI= +github.com/superfly/flysrc-go v0.0.3/go.mod h1:xChBoolsYRcpANrrdDMQc7u8mERwWmQLu+vu7Cmdg/A= +github.com/superfly/macaroon v0.3.0 h1:tdRq5VqBCNJIlvYByZZ3bGDOKX/v0llQM/Ljd27DbU8= +github.com/superfly/macaroon v0.3.0/go.mod h1:ZAmlRD/Hmp/ddTxE8IonZ7NdTny2DcOffRvZhapQwJw= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E= +gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= diff --git a/processor.go b/processor.go index 7a31df1..c474a5c 100644 --- a/processor.go +++ b/processor.go @@ -16,6 +16,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/icholy/replace" "golang.org/x/exp/slices" ) @@ -37,6 +38,10 @@ const ( // ParamSubtoken optionally specifies which subtoken should be used. // Default is SubtokenAccessToken. ParamSubtoken = "st" + + // ParamPlaceholder specifies the placeholder pattern to replace in the + // request body with the token value. Used by InjectBodyProcessor. + ParamPlaceholder = "placeholder" ) const ( @@ -48,12 +53,15 @@ type RequestProcessor func(r *http.Request) error type ProcessorConfig interface { Processor(map[string]string) (RequestProcessor, error) + StripHazmat() ProcessorConfig } type wireProcessor struct { InjectProcessorConfig *InjectProcessorConfig `json:"inject_processor,omitempty"` InjectHMACProcessorConfig *InjectHMACProcessorConfig `json:"inject_hmac_processor,omitempty"` + InjectBodyProcessorConfig *InjectBodyProcessorConfig `json:"inject_body_processor,omitempty"` OAuthProcessorConfig *OAuthProcessorConfig `json:"oauth2_processor,omitempty"` + OAuthBodyProcessorConfig *OAuthBodyProcessorConfig `json:"oauth2_body_processor,omitempty"` Sigv4ProcessorConfig *Sigv4ProcessorConfig `json:"sigv4_processor,omitempty"` MultiProcessorConfig *MultiProcessorConfig `json:"multi_processor,omitempty"` } @@ -64,8 +72,12 @@ func newWireProcessor(p ProcessorConfig) (wireProcessor, error) { return wireProcessor{InjectProcessorConfig: p}, nil case *InjectHMACProcessorConfig: return wireProcessor{InjectHMACProcessorConfig: p}, nil + case *InjectBodyProcessorConfig: + return wireProcessor{InjectBodyProcessorConfig: p}, nil case *OAuthProcessorConfig: return wireProcessor{OAuthProcessorConfig: p}, nil + case *OAuthBodyProcessorConfig: + return wireProcessor{OAuthBodyProcessorConfig: p}, nil case *Sigv4ProcessorConfig: return wireProcessor{Sigv4ProcessorConfig: p}, nil case *MultiProcessorConfig: @@ -87,10 +99,18 @@ func (wp *wireProcessor) getProcessorConfig() (ProcessorConfig, error) { np += 1 p = wp.InjectHMACProcessorConfig } + if wp.InjectBodyProcessorConfig != nil { + np += 1 + p = wp.InjectBodyProcessorConfig + } if wp.OAuthProcessorConfig != nil { np += 1 p = wp.OAuthProcessorConfig } + if wp.OAuthBodyProcessorConfig != nil { + np += 1 + p = wp.OAuthBodyProcessorConfig + } if wp.Sigv4ProcessorConfig != nil { np += 1 p = wp.Sigv4ProcessorConfig @@ -129,6 +149,15 @@ func (c *InjectProcessorConfig) Processor(params map[string]string) (RequestProc }, nil } +func (c *InjectProcessorConfig) StripHazmat() ProcessorConfig { + // DO NOT PUT HAZMAT INTO FmtProcessor or DstProcessor. + return &InjectProcessorConfig{ + Token: redactedStr, + FmtProcessor: c.FmtProcessor, + DstProcessor: c.DstProcessor, + } +} + type InjectHMACProcessorConfig struct { Key []byte `json:"key"` Hash string `json:"hash"` @@ -178,6 +207,61 @@ func (c *InjectHMACProcessorConfig) Processor(params map[string]string) (Request }, nil } +func (c *InjectHMACProcessorConfig) StripHazmat() ProcessorConfig { + // DO NOT PUT HAZMAT INTO FmtProcessor or DstProcessor. + return &InjectHMACProcessorConfig{ + Key: redactedBase64, + Hash: redactedStr, + FmtProcessor: c.FmtProcessor, + DstProcessor: c.DstProcessor, + } +} + +type InjectBodyProcessorConfig struct { + Token string `json:"token"` + Placeholder string `json:"placeholder,omitempty"` +} + +var _ ProcessorConfig = new(InjectBodyProcessorConfig) + +func (c *InjectBodyProcessorConfig) Processor(params map[string]string) (RequestProcessor, error) { + if c.Token == "" { + return nil, errors.New("missing token") + } + + // Get placeholder from params or use config default or fallback + placeholder := c.Placeholder + if paramPlaceholder, ok := params[ParamPlaceholder]; ok { + placeholder = paramPlaceholder + } + if placeholder == "" { + placeholder = "{{ACCESS_TOKEN}}" + } + + return func(r *http.Request) error { + if r.Body == nil { + return nil + } + + // Use streaming replacement to avoid loading entire body into memory + chain := replace.Chain(r.Body, replace.String(placeholder, c.Token)) + + // Set body to the replacement chain for true streaming with chunked encoding + // Setting ContentLength to 0 triggers chunked transfer encoding + r.Body = io.NopCloser(chain) + r.ContentLength = 0 + + return nil + }, nil +} + +func (c *InjectBodyProcessorConfig) StripHazmat() ProcessorConfig { + return &InjectBodyProcessorConfig{ + Token: redactedStr, + Placeholder: c.Placeholder, + } +} + type OAuthProcessorConfig struct { Token *OAuthToken `json:"token"` } @@ -199,12 +283,95 @@ func (c *OAuthProcessorConfig) Processor(params map[string]string) (RequestProce return nil, errors.New("missing token") } + // Check if placeholder parameter is present for body injection + if placeholder, ok := params[ParamPlaceholder]; ok && placeholder != "" { + // Inject token into request body by replacing placeholder + return func(r *http.Request) error { + if r.Body == nil { + return nil + } + + // Use streaming replacement to avoid loading entire body into memory + chain := replace.Chain(r.Body, replace.String(placeholder, token)) + + // Set body to the replacement chain for true streaming with chunked encoding + // Setting ContentLength to 0 triggers chunked transfer encoding + r.Body = io.NopCloser(chain) + r.ContentLength = 0 + + return nil + }, nil + } + + // Default behavior: inject into Authorization header return func(r *http.Request) error { r.Header.Set("Authorization", "Bearer "+token) return nil }, nil } +func (c *OAuthProcessorConfig) StripHazmat() ProcessorConfig { + return &OAuthProcessorConfig{ + Token: &OAuthToken{ + AccessToken: redactedStr, + RefreshToken: redactedStr, + }, + } +} + +type OAuthBodyProcessorConfig struct { + Token *OAuthToken `json:"token"` + Placeholder string `json:"placeholder,omitempty"` +} + +var _ ProcessorConfig = (*OAuthBodyProcessorConfig)(nil) + +func (c *OAuthBodyProcessorConfig) Processor(params map[string]string) (RequestProcessor, error) { + token := c.Token.AccessToken + if params[ParamSubtoken] == SubtokenRefresh { + token = c.Token.RefreshToken + } + + if token == "" { + return nil, errors.New("missing token") + } + + // Get placeholder from params or use config default or fallback + placeholder := c.Placeholder + if paramPlaceholder, ok := params[ParamPlaceholder]; ok { + placeholder = paramPlaceholder + } + if placeholder == "" { + placeholder = "{{ACCESS_TOKEN}}" + } + + return func(r *http.Request) error { + if r.Body == nil { + return nil + } + + // Use streaming replacement to avoid loading entire body into memory + chain := replace.Chain(r.Body, replace.String(placeholder, token)) + + // Set body to the replacement chain for true streaming with chunked encoding + // Setting ContentLength to 0 triggers chunked transfer encoding + r.Body = io.NopCloser(chain) + r.ContentLength = 0 + + return nil + }, nil +} + +func (c *OAuthBodyProcessorConfig) StripHazmat() ProcessorConfig { + return &OAuthBodyProcessorConfig{ + Token: &OAuthToken{ + AccessToken: redactedStr, + RefreshToken: redactedStr, + }, + Placeholder: c.Placeholder, + } +} + type Sigv4ProcessorConfig struct { AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` @@ -299,6 +466,13 @@ func (c *Sigv4ProcessorConfig) Processor(params map[string]string) (RequestProce }, nil } +func (c *Sigv4ProcessorConfig) StripHazmat() ProcessorConfig { + return &Sigv4ProcessorConfig{ + AccessKey: redactedStr, + SecretKey: redactedStr, + } +} + type MultiProcessorConfig []ProcessorConfig var _ ProcessorConfig = new(MultiProcessorConfig) @@ -323,6 +497,14 @@ func (c MultiProcessorConfig) Processor(params map[string]string) (RequestProces }, nil } +func (c *MultiProcessorConfig) StripHazmat() ProcessorConfig { + ret := make(MultiProcessorConfig, len(*c)) + for n, p := range *c { + ret[n] = p.StripHazmat() + } + return &ret +} + func (c MultiProcessorConfig) MarshalJSON() ([]byte, error) { wps := make([]wireProcessor, 0, len(c)) for _, p := range c { diff --git a/processor_test.go b/processor_test.go index d3e6105..48cb03d 100644 --- a/processor_test.go +++ b/processor_test.go @@ -2,6 +2,7 @@ package tokenizer import ( "bytes" + "io" "net/http" "strings" "testing" @@ -103,3 +104,287 @@ func TestDstProcessor(t *testing.T) { assertResult("error", DstProcessor{AllowedDst: []string{"Bar"}}, map[string]string{ParamDst: "Foo"}) assertResult("error", DstProcessor{Dst: "Bar"}, map[string]string{ParamDst: "Foo"}) } + +func TestInjectBodyProcessorConfig(t *testing.T) { + tests := []struct { + name string + config InjectBodyProcessorConfig + params map[string]string + requestBody string + expectedBody string + }{ + { + name: "simple replacement with default placeholder", + config: InjectBodyProcessorConfig{Token: "secret-token-123"}, + params: map[string]string{}, + requestBody: `{"token": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"token": "secret-token-123"}`, + }, + { + name: "multiple occurrences", + config: InjectBodyProcessorConfig{Token: "secret-token-123"}, + params: map[string]string{}, + requestBody: `{"access": "{{ACCESS_TOKEN}}", "refresh": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"access": "secret-token-123", "refresh": "secret-token-123"}`, + }, + { + name: "custom placeholder from params", + config: InjectBodyProcessorConfig{Token: "secret-token-123"}, + params: map[string]string{ParamPlaceholder: "<>"}, + requestBody: `{"token": "<>"}`, + expectedBody: `{"token": "secret-token-123"}`, + }, + { + name: "custom placeholder from config", + config: InjectBodyProcessorConfig{Token: "secret-token-123", Placeholder: "[[TOKEN]]"}, + params: map[string]string{}, + requestBody: `{"token": "[[TOKEN]]"}`, + expectedBody: `{"token": "secret-token-123"}`, + }, + { + name: "param overrides config placeholder", + config: InjectBodyProcessorConfig{Token: "secret-token-123", Placeholder: "[[TOKEN]]"}, + params: map[string]string{ParamPlaceholder: "<>"}, + requestBody: `{"token": "<>"}`, + expectedBody: `{"token": "secret-token-123"}`, + }, + { + name: "no matches - placeholder not found", + config: InjectBodyProcessorConfig{Token: "secret-token-123"}, + params: map[string]string{}, + requestBody: `{"token": "different-placeholder"}`, + expectedBody: `{"token": "different-placeholder"}`, + }, + { + name: "empty body", + config: InjectBodyProcessorConfig{Token: "secret-token-123"}, + params: map[string]string{}, + requestBody: "", + expectedBody: "", + }, + { + name: "large body with streaming", + config: InjectBodyProcessorConfig{Token: "secret-token-123"}, + params: map[string]string{}, + requestBody: strings.Repeat("{{ACCESS_TOKEN}}", 10000), + expectedBody: strings.Repeat("secret-token-123", 10000), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proc, err := tt.config.Processor(tt.params) + assert.NoError(t, err) + + req := &http.Request{ + Header: make(http.Header), + } + if tt.requestBody != "" { + req.Body = http.NoBody + } + if tt.requestBody != "" { + req.Body = stringToReadCloser(tt.requestBody) + } + + err = proc(req) + assert.NoError(t, err) + + if tt.expectedBody == "" && (tt.requestBody == "" || tt.requestBody == "nil") { + return + } + + bodyBytes := new(bytes.Buffer) + _, err = bodyBytes.ReadFrom(req.Body) + assert.NoError(t, err) + assert.Equal(t, tt.expectedBody, bodyBytes.String()) + // ContentLength is 0 for chunked transfer encoding + assert.Equal(t, int64(0), req.ContentLength) + }) + } +} + +func TestOAuthProcessorConfigBodyInjection(t *testing.T) { + tests := []struct { + name string + config OAuthProcessorConfig + params map[string]string + requestBody string + expectedBody string + checkHeader bool + }{ + { + name: "body injection with placeholder parameter", + config: OAuthProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123", RefreshToken: "refresh-456"}, + }, + params: map[string]string{ParamPlaceholder: "{{ACCESS_TOKEN}}"}, + requestBody: `{"token": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"token": "access-123"}`, + checkHeader: false, + }, + { + name: "body injection with refresh token", + config: OAuthProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123", RefreshToken: "refresh-456"}, + }, + params: map[string]string{ParamPlaceholder: "{{TOKEN}}", ParamSubtoken: SubtokenRefresh}, + requestBody: `{"token": "{{TOKEN}}"}`, + expectedBody: `{"token": "refresh-456"}`, + checkHeader: false, + }, + { + name: "header injection when no placeholder", + config: OAuthProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123"}, + }, + params: map[string]string{}, + checkHeader: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proc, err := tt.config.Processor(tt.params) + assert.NoError(t, err) + + req := &http.Request{ + Header: make(http.Header), + } + if tt.requestBody != "" { + req.Body = stringToReadCloser(tt.requestBody) + } + + err = proc(req) + assert.NoError(t, err) + + if tt.checkHeader { + // Header injection: verify Authorization header is set and body is unchanged + assert.Equal(t, "Bearer access-123", req.Header.Get("Authorization")) + // Body should be unchanged (nil or original) + if tt.requestBody != "" { + bodyBytes := new(bytes.Buffer) + _, err = bodyBytes.ReadFrom(req.Body) + assert.NoError(t, err) + assert.Equal(t, tt.requestBody, bodyBytes.String()) + } + } else { + // Body injection: verify body is modified and Authorization header is NOT set + bodyBytes := new(bytes.Buffer) + _, err = bodyBytes.ReadFrom(req.Body) + assert.NoError(t, err) + assert.Equal(t, tt.expectedBody, bodyBytes.String()) + // ContentLength is 0 for chunked transfer encoding + assert.Equal(t, int64(0), req.ContentLength) + // Authorization header should NOT be set during body injection + assert.Equal(t, "", req.Header.Get("Authorization")) + } + }) + } +} + +func TestOAuthBodyProcessorConfig(t *testing.T) { + tests := []struct { + name string + config OAuthBodyProcessorConfig + params map[string]string + requestBody string + expectedBody string + }{ + { + name: "simple replacement with access token", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123", RefreshToken: "refresh-456"}, + }, + params: map[string]string{}, + requestBody: `{"token": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"token": "access-123"}`, + }, + { + name: "replacement with refresh token", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123", RefreshToken: "refresh-456"}, + }, + params: map[string]string{ParamSubtoken: SubtokenRefresh}, + requestBody: `{"token": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"token": "refresh-456"}`, + }, + { + name: "custom placeholder from params", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123"}, + }, + params: map[string]string{ParamPlaceholder: "<>"}, + requestBody: `{"token": "<>"}`, + expectedBody: `{"token": "access-123"}`, + }, + { + name: "custom placeholder from config", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123"}, + Placeholder: "[[TOKEN]]", + }, + params: map[string]string{}, + requestBody: `{"token": "[[TOKEN]]"}`, + expectedBody: `{"token": "access-123"}`, + }, + { + name: "multiple occurrences", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123"}, + }, + params: map[string]string{}, + requestBody: `{"client_id": "id", "client_secret": "secret", "token": "{{ACCESS_TOKEN}}", "token_type_hint": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"client_id": "id", "client_secret": "secret", "token": "access-123", "token_type_hint": "access-123"}`, + }, + { + name: "nil body", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123"}, + }, + params: map[string]string{}, + requestBody: "", + }, + { + name: "large body with streaming", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123"}, + }, + params: map[string]string{}, + requestBody: `{"data": "` + strings.Repeat("x", 50000) + `", "token": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"data": "` + strings.Repeat("x", 50000) + `", "token": "access-123"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proc, err := tt.config.Processor(tt.params) + assert.NoError(t, err) + + req := &http.Request{ + Header: make(http.Header), + } + if tt.requestBody != "" { + req.Body = stringToReadCloser(tt.requestBody) + } + + err = proc(req) + assert.NoError(t, err) + + if tt.requestBody == "" { + return + } + + bodyBytes := new(bytes.Buffer) + _, err = bodyBytes.ReadFrom(req.Body) + assert.NoError(t, err) + assert.Equal(t, tt.expectedBody, bodyBytes.String()) + // ContentLength is 0 for chunked transfer encoding + assert.Equal(t, int64(0), req.ContentLength) + }) + } +} + +// Helper function to convert string to io.ReadCloser +func stringToReadCloser(s string) io.ReadCloser { + return io.NopCloser(strings.NewReader(s)) +} diff --git a/secret.go b/secret.go index 1bbc095..d94bfe9 100644 --- a/secret.go +++ b/secret.go @@ -49,6 +49,19 @@ func (s *Secret) sealRaw(key *[32]byte) (string, error) { return base64.StdEncoding.EncodeToString(sct), nil } +func (s *Secret) StripHazmat() *Secret { + return &Secret{ + AuthConfig: s.AuthConfig.StripHazmat(), + ProcessorConfig: s.ProcessorConfig.StripHazmat(), + RequestValidators: s.RequestValidators, + } +} + +func (s *Secret) StripHazmatString() string { + bs, _ := json.Marshal(s.StripHazmat()) + return string(bs) +} + type wireSecret struct { wireProcessor wireAuth diff --git a/tokenizer.go b/tokenizer.go index 1cfa27f..5a863ee 100644 --- a/tokenizer.go +++ b/tokenizer.go @@ -23,6 +23,8 @@ import ( "golang.org/x/crypto/curve25519" "golang.org/x/crypto/nacl/box" "golang.org/x/exp/maps" + + "github.com/superfly/flysrc-go" ) var FilteredHeaders = []string{headerProxyAuthorization, headerProxyTokenizer} @@ -46,6 +48,9 @@ type tokenizer struct { // OpenProxy dictates whether requests without any sealed secrets are allowed. OpenProxy bool + // RequireFlySrc will reject requests without a fly-src when set. + RequireFlySrc bool + // tokenizerHostnames is a list of hostnames where tokenizer can be reached. // If provided, this allows tokenizer to transparently proxy requests (ie. // accept normal HTTP requests with arbitrary hostnames) while blocking @@ -55,6 +60,12 @@ type tokenizer struct { priv *[32]byte pub *[32]byte + + flysrcParser *flysrc.Parser +} + +func (t *tokenizer) GetFlysrcParser() *flysrc.Parser { + return t.flysrcParser } type Option func(*tokenizer) @@ -66,6 +77,21 @@ func OpenProxy() Option { } } +// RequireFlySrc specifies that requests without a fly-src will be rejected. +func RequireFlySrc() Option { + return func(t *tokenizer) { + t.RequireFlySrc = true + } +} + +// WithFlysrcParser specifies a preconfigured flysrc parser to use instead of the +// default flysrc parser. +func WithFlysrcParser(parser *flysrc.Parser) Option { + return func(t *tokenizer) { + t.flysrcParser = parser + } +} + // TokenizerHostnames is a list of hostnames where tokenizer can be reached. If // provided, this allows tokenizer to transparently proxy requests (ie. accept // normal HTTP requests with arbitrary hostnames) while blocking circular @@ -90,12 +116,16 @@ func NewTokenizer(openKey string, opts ...Option) *tokenizer { curve25519.ScalarBaseMult(pub, priv) proxy := goproxy.NewProxyHttpServer() - tkz := &tokenizer{ProxyHttpServer: proxy, priv: priv, pub: pub} + tkz := &tokenizer{ProxyHttpServer: proxy, priv: priv, pub: pub, flysrcParser: nil} for _, opt := range opts { opt(tkz) } + if tkz.RequireFlySrc && tkz.flysrcParser == nil { + logrus.Panic("FlySrc is required but no flysrc Parser is specified") + } + hostnameMap := map[string]bool{} for _, hostname := range tkz.tokenizerHostnames { hostnameMap[hostname] = true @@ -106,9 +136,11 @@ func NewTokenizer(openKey string, opts ...Option) *tokenizer { proxy.NonproxyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case len(hostnameMap) == 0: + logrus.WithFields(reqLogFields(r)).Warn("I'm not that kind of server") http.Error(w, "I'm not that kind of server", 400) return case r.Host == "": + logrus.WithFields(reqLogFields(r)).Warn("must specify host") http.Error(w, "must specify host", 400) return } @@ -118,12 +150,14 @@ func NewTokenizer(openKey string, opts ...Option) *tokenizer { if strings.Contains(err.Error(), "missing port in address") { host = r.Host } else { + logrus.WithFields(reqLogFields(r)).Warn("bad host") http.Error(w, "bad host", 400) return } } if hostnameMap[host] { + logrus.WithFields(reqLogFields(r)).Warn("circular request") http.Error(w, "circular request", 400) return } @@ -169,14 +203,16 @@ type proxyUserData struct { // HandleConnect implements goproxy.FuncHttpsHandler func (t *tokenizer) HandleConnect(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) { + logger := logrus.WithField("connect_host", host) + logger = logger.WithFields(reqLogFields(ctx.Req)) if host == "" { - logrus.Warn("no host in CONNECT request") + logger.Warn("no host in CONNECT request") ctx.Resp = errorResponse(ErrBadRequest) return goproxy.RejectConnect, "" } pud := &proxyUserData{ - connLog: logrus.WithField("connect_host", host), + connLog: logger, connectStart: time.Now(), } @@ -187,18 +223,42 @@ func (t *tokenizer) HandleConnect(host string, ctx *goproxy.ProxyCtx) (*goproxy. return goproxy.RejectConnect, "" } - var err error - if pud.connectProcessors, err = t.processorsFromRequest(ctx.Req); err != nil { + connectProcessors, safeSecret, err := t.processorsFromRequest(ctx.Req) + if safeSecret != "" { + pud.connLog = pud.connLog.WithField("secret", safeSecret) + } + if err != nil { pud.connLog.WithError(err).Warn("find processor (CONNECT)") ctx.Resp = errorResponse(err) return goproxy.RejectConnect, "" } + pud.connectProcessors = connectProcessors ctx.UserData = pud return goproxy.HTTPMitmConnect, host } +func getSource(req *http.Request) string { + var forwards []string + if forwardedFor := req.Header.Get("X-Forwarded-For"); forwardedFor != "" { + forwards = strings.Split(forwardedFor, ", ") + } + + srcs := append(forwards, req.RemoteAddr) + return strings.Join(srcs, ", ") +} + +func reqLogFields(req *http.Request) logrus.Fields { + return logrus.Fields{ + "source": getSource(req), + "method": req.Method, + "host": req.Host, + "path": req.URL.Path, + "queryKeys": strings.Join(maps.Keys(req.URL.Query()), ", "), + } +} + // HandleRequest implements goproxy.FuncReqHandler func (t *tokenizer) HandleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) { if ctx.UserData == nil { @@ -215,17 +275,32 @@ func (t *tokenizer) HandleRequest(req *http.Request, ctx *goproxy.ProxyCtx) (*ht if pud.connLog != nil { pud.reqLog = pud.connLog } else { - pud.reqLog = logrus.StandardLogger() + pud.reqLog = logrus.WithFields(reqLogFields(ctx.Req)) + } + + if t.flysrcParser != nil { + src, err := t.flysrcParser.FromRequest(req) + if err != nil { + if t.RequireFlySrc { + pud.reqLog.Warn(err.Error()) + return nil, errorResponse(ErrBadRequest) + } + } else { + pud.reqLog = pud.reqLog.WithFields(logrus.Fields{ + "flysrc-org": src.Org, + "flysrc-app": src.App, + "flysrc-instance": src.Instance, + "flysrc-timestamp": src.Timestamp, + }) + } } - pud.reqLog = pud.reqLog.WithFields(logrus.Fields{ - "method": req.Method, - "host": req.Host, - "path": req.URL.Path, - "queryKeys": strings.Join(maps.Keys(req.URL.Query()), ", "), - }) processors := append([]RequestProcessor(nil), pud.connectProcessors...) - if reqProcessors, err := t.processorsFromRequest(req); err != nil { + reqProcessors, safeSecret, err := t.processorsFromRequest(req) + if safeSecret != "" { + pud.reqLog = pud.reqLog.WithField("secret", safeSecret) + } + if err != nil { pud.reqLog.WithError(err).Warn("find processor") return req, errorResponse(err) } else { @@ -294,49 +369,51 @@ func (t *tokenizer) HandleResponse(resp *http.Response, ctx *goproxy.ProxyCtx) * return resp } -func (t *tokenizer) processorsFromRequest(req *http.Request) ([]RequestProcessor, error) { +func (t *tokenizer) processorsFromRequest(req *http.Request) ([]RequestProcessor, string, error) { hdrs := req.Header[headerProxyTokenizer] processors := make([]RequestProcessor, 0, len(hdrs)) + var safeSecret string for _, hdr := range hdrs { b64Secret, params, err := parseHeaderProxyTokenizer(hdr) if err != nil { - return nil, err + return nil, safeSecret, err } ctSecret, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64Secret)) if err != nil { - return nil, fmt.Errorf("bad Proxy-Tokenizer encoding: %w", err) + return nil, safeSecret, fmt.Errorf("bad Proxy-Tokenizer encoding: %w", err) } jsonSecret, ok := box.OpenAnonymous(nil, ctSecret, t.pub, t.priv) if !ok { - return nil, errors.New("failed Proxy-Tokenizer decryption") + return nil, safeSecret, errors.New("failed Proxy-Tokenizer decryption") } secret := new(Secret) if err = json.Unmarshal(jsonSecret, secret); err != nil { - return nil, fmt.Errorf("bad secret json: %w", err) + return nil, safeSecret, fmt.Errorf("bad secret json: %w", err) } - if err = secret.AuthRequest(req); err != nil { - return nil, fmt.Errorf("unauthorized to use secret: %w", err) + safeSecret = secret.StripHazmatString() + if err = secret.AuthRequest(t, req); err != nil { + return nil, safeSecret, fmt.Errorf("unauthorized to use secret: %w", err) } for _, v := range secret.RequestValidators { if err := v.Validate(req); err != nil { - return nil, fmt.Errorf("request validator failed: %w", err) + return nil, safeSecret, fmt.Errorf("request validator failed: %w", err) } } processor, err := secret.Processor(params) if err != nil { - return nil, err + return nil, safeSecret, err } processors = append(processors, processor) } - return processors, nil + return processors, safeSecret, nil } func parseHeaderProxyTokenizer(hdr string) (string, map[string]string, error) { diff --git a/tokenizer_test.go b/tokenizer_test.go index 3d35b78..39507e8 100644 --- a/tokenizer_test.go +++ b/tokenizer_test.go @@ -22,8 +22,8 @@ import ( "github.com/alecthomas/assert/v2" "github.com/sirupsen/logrus" + "github.com/superfly/flysrc-go" "github.com/superfly/macaroon" - "github.com/superfly/tokenizer/flysrc" tkmac "github.com/superfly/tokenizer/macaroon" "golang.org/x/crypto/nacl/box" ) @@ -45,7 +45,21 @@ func TestTokenizer(t *testing.T) { openKey = hex.EncodeToString(priv[:]) ) + flysrcParser, err := flysrc.New( + flysrc.WithPubkey(flySrcVerifyKey(t)), + flysrc.WithFlyProxyNet(&net.IPNet{ + IP: net.ParseIP("127.0.0.1"), + Mask: net.CIDRMask(16, 32), + }), + ) + + assert.NoError(t, err) + + // we can build a server without a fly src parser tkz := NewTokenizer(openKey) + assert.True(t, tkz != nil) + + tkz = NewTokenizer(openKey, WithFlysrcParser(flysrcParser)) tkz.ProxyHttpServer.Verbose = true tkzServer := httptest.NewServer(tkz) @@ -388,7 +402,7 @@ func TestTokenizer(t *testing.T) { assert.NoError(t, err) // Good - fs := &flysrc.Parsed{Org: "foo", App: "bar", Instance: "baz", Timestamp: time.Now().Truncate(time.Second)} + fs := &flysrc.FlySrc{Org: "foo", App: "bar", Instance: "baz", Timestamp: time.Now().Truncate(time.Second)} hdrSrc := fs.String() hdrSig := fs.Sign(priv) @@ -407,8 +421,16 @@ func TestTokenizer(t *testing.T) { Body: "", }, doEcho(t, client, req)) + // Bad, the same request fails, without panic, if flysrc parser is nil + parser := tkz.flysrcParser + tkz.flysrcParser = nil + resp, err = client.Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusProxyAuthRequired, resp.StatusCode) + tkz.flysrcParser = parser + // Bad org - fs = &flysrc.Parsed{Org: "WRONG!", App: "bar", Instance: "baz", Timestamp: time.Now().Truncate(time.Second)} + fs = &flysrc.FlySrc{Org: "WRONG!", App: "bar", Instance: "baz", Timestamp: time.Now().Truncate(time.Second)} hdrSrc = fs.String() hdrSig = fs.Sign(priv) @@ -424,7 +446,7 @@ func TestTokenizer(t *testing.T) { assert.Equal(t, http.StatusProxyAuthRequired, resp.StatusCode) // Missing signature - fs = &flysrc.Parsed{Org: "foo", App: "bar", Instance: "baz", Timestamp: time.Now().Truncate(time.Second)} + fs = &flysrc.FlySrc{Org: "foo", App: "bar", Instance: "baz", Timestamp: time.Now().Truncate(time.Second)} hdrSrc = fs.String() assert.NoError(t, err) @@ -525,7 +547,8 @@ func hmacSHA256(t testing.TB, key []byte, msg string) []byte { var ( _setupFlySrcSignKey sync.Once - _flySrcsignKey ed25519.PrivateKey + _flySrcSignKey ed25519.PrivateKey + _flySrcVerifyKey ed25519.PublicKey ) func flySrcSignKey(t *testing.T) ed25519.PrivateKey { @@ -534,10 +557,16 @@ func flySrcSignKey(t *testing.T) ed25519.PrivateKey { var err error _setupFlySrcSignKey.Do(func() { - flysrc.VerifyKey, _flySrcsignKey, err = ed25519.GenerateKey(nil) + _flySrcVerifyKey, _flySrcSignKey, err = ed25519.GenerateKey(nil) }) assert.NoError(t, err) - assert.NotZero(t, _flySrcsignKey) - return _flySrcsignKey + assert.NotZero(t, _flySrcSignKey) + return _flySrcSignKey +} + +func flySrcVerifyKey(t *testing.T) ed25519.PublicKey { + t.Helper() + _ = flySrcSignKey(t) + return _flySrcVerifyKey }