diff --git a/authentication/authentication.go b/authentication/authentication.go index 3b1dc5d..84f4721 100644 --- a/authentication/authentication.go +++ b/authentication/authentication.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/agentuity/go-common/crypto" "github.com/xhit/go-str2duration/v2" ) @@ -82,7 +83,40 @@ func NewBearerToken(sharedSecret string, opts ...TokenOpt) (string, error) { return nonce + "." + tok2, nil } +// NewBearerTokenV2 generates a v2 bearer token using HKDF-derived key. +// The token format is "v2.bearer-token.." (non-expiring) +// or "v2.bearer-token..." (expiring). +func NewBearerTokenV2(sharedSecret string, opts ...TokenOpt) (string, error) { + derivedKey, err := crypto.DeriveKey([]byte(sharedSecret), crypto.ContextBearerToken) + if err != nil { + return "", fmt.Errorf("failed to derive key: %w", err) + } + // Generate the inner token using the derived key (same algorithm as v1) + innerToken, err := NewBearerToken(string(derivedKey), opts...) + if err != nil { + return "", err + } + return crypto.FormatV2Token(crypto.ContextBearerToken, innerToken), nil +} + func ValidateToken(sharedSecret string, auth string) error { + version, context, payload := crypto.DetectTokenVersion(auth) + if version == "v2" { + if context != crypto.ContextBearerToken { + return ErrInvalidToken + } + derivedKey, err := crypto.DeriveKey([]byte(sharedSecret), crypto.ContextBearerToken) + if err != nil { + return ErrInvalidToken + } + return validateTokenInner(string(derivedKey), payload) + } + // v1 legacy: use raw shared secret + return validateTokenInner(sharedSecret, auth) +} + +// validateTokenInner contains the core token validation logic shared by v1 and v2. +func validateTokenInner(key string, auth string) error { if len(auth) < 32 { return ErrInvalidToken } @@ -127,7 +161,7 @@ func ValidateToken(sharedSecret string, auth string) error { // see if we can hash the token with our shared secret to get the same value as the second token hash := sha256.New() - hash.Write([]byte(sharedSecret + "." + token)) + hash.Write([]byte(key + "." + token)) secret := hash.Sum(nil) // if the two values are not the same, return an error diff --git a/authentication/authentication_test.go b/authentication/authentication_test.go index 2edb7f7..091947b 100644 --- a/authentication/authentication_test.go +++ b/authentication/authentication_test.go @@ -152,3 +152,84 @@ func TestWithExpirationValidRanges(t *testing.T) { }) } } + +// V2 Bearer Token Tests + +func TestNewBearerTokenV2(t *testing.T) { + sharedSecret := "test-secret" + + token, err := NewBearerTokenV2(sharedSecret) + assert.NoError(t, err) + assert.NotEmpty(t, token) + + // v2 token should validate + err = ValidateToken(sharedSecret, token) + assert.NoError(t, err) +} + +func TestNewBearerTokenV2WithExpiration(t *testing.T) { + sharedSecret := "test-secret" + expiration := time.Now().Add(2 * time.Hour) + + token, err := NewBearerTokenV2(sharedSecret, WithExpiration(expiration)) + assert.NoError(t, err) + assert.NotEmpty(t, token) + + // v2 expiring token should validate + err = ValidateToken(sharedSecret, token) + assert.NoError(t, err) +} + +func TestV2TokenPrefix(t *testing.T) { + sharedSecret := "test-secret" + + token, err := NewBearerTokenV2(sharedSecret) + assert.NoError(t, err) + assert.True(t, strings.HasPrefix(token, "v2.bearer-token."), "v2 token should start with 'v2.bearer-token.' but got: %s", token) +} + +func TestV1TokenStillValidates(t *testing.T) { + sharedSecret := "test-secret" + + // Generate a v1 token + token, err := NewBearerToken(sharedSecret) + assert.NoError(t, err) + assert.NotEmpty(t, token) + + // v1 token should still validate through the updated ValidateToken + err = ValidateToken(sharedSecret, token) + assert.NoError(t, err) +} + +func TestV2TokenNotValidWithWrongSecret(t *testing.T) { + token, err := NewBearerTokenV2("correct-secret") + assert.NoError(t, err) + + err = ValidateToken("wrong-secret", token) + assert.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidToken) +} + +func TestCrossVersionIsolation(t *testing.T) { + sharedSecret := "test-secret" + + // Generate v1 and v2 tokens + v1Token, err := NewBearerToken(sharedSecret) + assert.NoError(t, err) + + v2Token, err := NewBearerTokenV2(sharedSecret) + assert.NoError(t, err) + + // Extract the inner payload of the v2 token (strip "v2.bearer-token." prefix) + v2Payload := strings.TrimPrefix(v2Token, "v2.bearer-token.") + assert.NotEqual(t, v2Token, v2Payload, "v2 token should have the prefix") + + // v2 payload should NOT validate as v1 (because it was hashed with derived key, not raw secret) + err = validateTokenInner(sharedSecret, v2Payload) + assert.Error(t, err, "v2 token payload should not validate as v1 with raw shared secret") + + // v1 token wrapped as v2 should NOT validate (because ValidateToken will use derived key) + fakeV2 := "v2.bearer-token." + v1Token + err = ValidateToken(sharedSecret, fakeV2) + assert.Error(t, err, "v1 token wrapped as v2 should not validate") +} diff --git a/crypto/derive.go b/crypto/derive.go new file mode 100644 index 0000000..608a9c2 --- /dev/null +++ b/crypto/derive.go @@ -0,0 +1,64 @@ +package crypto + +import ( + "crypto/sha256" + "fmt" + "io" + "strings" + + "golang.org/x/crypto/hkdf" +) + +const ( + // KeyDerivationVersion is the current version of key derivation. + KeyDerivationVersion = "v2" + + // keyDerivationSalt includes the version to ensure different versions produce different keys. + keyDerivationSalt = "agentuity-key-derivation-" + KeyDerivationVersion + + // Context constants for different key derivation purposes. + // Context strings MUST NOT contain '.' characters, as this would break + // DetectTokenVersion's parsing which uses SplitN(token, ".", 3) to separate + // the version prefix, context, and payload. + ContextBearerToken = "bearer-token" + ContextStickySession = "sticky-session" + ContextPostgresInternal = "postgres-internal" + ContextGravityJWT = "gravity-jwt" + ContextS3Webhook = "s3-webhook" +) + +// DeriveKey derives a purpose-specific 32-byte key from a master secret using HKDF-SHA256. +// The context parameter provides domain separation so the same master secret produces +// different keys for different purposes. +func DeriveKey(masterSecret []byte, context string) ([]byte, error) { + if len(masterSecret) == 0 { + return nil, fmt.Errorf("master secret cannot be empty") + } + if context == "" { + return nil, fmt.Errorf("context cannot be empty") + } + reader := hkdf.New(sha256.New, masterSecret, []byte(keyDerivationSalt), []byte(context)) + key := make([]byte, 32) + if _, err := io.ReadFull(reader, key); err != nil { + return nil, fmt.Errorf("failed to derive key for context %q: %w", context, err) + } + return key, nil +} + +// DetectTokenVersion inspects a token string and returns its version. +// v2 tokens have the format "v2..". +// All other tokens are assumed to be v1 (legacy format). +func DetectTokenVersion(token string) (version string, context string, payload string) { + if strings.HasPrefix(token, "v2.") { + parts := strings.SplitN(token, ".", 3) + if len(parts) == 3 { + return parts[0], parts[1], parts[2] + } + } + return "v1", "", token +} + +// FormatV2Token creates a v2 prefixed token string: "v2.." +func FormatV2Token(context string, payload string) string { + return fmt.Sprintf("v2.%s.%s", context, payload) +} diff --git a/crypto/derive_test.go b/crypto/derive_test.go new file mode 100644 index 0000000..7b9ca9d --- /dev/null +++ b/crypto/derive_test.go @@ -0,0 +1,122 @@ +package crypto + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeriveKeyDeterministic(t *testing.T) { + secret := []byte("my-master-secret") + key1, err := DeriveKey(secret, ContextBearerToken) + require.NoError(t, err) + + key2, err := DeriveKey(secret, ContextBearerToken) + require.NoError(t, err) + + assert.Equal(t, key1, key2, "DeriveKey should produce consistent output for same inputs") +} + +func TestDeriveKeyDomainSeparation(t *testing.T) { + secret := []byte("my-master-secret") + + key1, err := DeriveKey(secret, ContextBearerToken) + require.NoError(t, err) + + key2, err := DeriveKey(secret, ContextStickySession) + require.NoError(t, err) + + assert.NotEqual(t, key1, key2, "DeriveKey should produce different output for different contexts") +} + +func TestDeriveKeyDifferentSecrets(t *testing.T) { + key1, err := DeriveKey([]byte("secret-one"), ContextBearerToken) + require.NoError(t, err) + + key2, err := DeriveKey([]byte("secret-two"), ContextBearerToken) + require.NoError(t, err) + + assert.NotEqual(t, key1, key2, "DeriveKey should produce different output for different master secrets") +} + +func TestDeriveKeyEmptyMasterSecret(t *testing.T) { + key, err := DeriveKey([]byte{}, ContextBearerToken) + assert.Error(t, err) + assert.Nil(t, key) + assert.Contains(t, err.Error(), "master secret cannot be empty") +} + +func TestDeriveKeyNilMasterSecret(t *testing.T) { + key, err := DeriveKey(nil, ContextBearerToken) + assert.Error(t, err) + assert.Nil(t, key) + assert.Contains(t, err.Error(), "master secret cannot be empty") +} + +func TestDeriveKeyEmptyContext(t *testing.T) { + key, err := DeriveKey([]byte("secret"), "") + assert.Error(t, err) + assert.Nil(t, key) + assert.Contains(t, err.Error(), "context cannot be empty") +} + +func TestDeriveKeyOutputLength(t *testing.T) { + key, err := DeriveKey([]byte("my-master-secret"), ContextBearerToken) + require.NoError(t, err) + assert.Len(t, key, 32, "DeriveKey output should be exactly 32 bytes") +} + +func TestDetectTokenVersionV2(t *testing.T) { + version, context, payload := DetectTokenVersion("v2.bearer-token.somePayload") + assert.Equal(t, "v2", version) + assert.Equal(t, "bearer-token", context) + assert.Equal(t, "somePayload", payload) +} + +func TestDetectTokenVersionV1(t *testing.T) { + version, context, payload := DetectTokenVersion("oldStyleToken.hash") + assert.Equal(t, "v1", version) + assert.Equal(t, "", context) + assert.Equal(t, "oldStyleToken.hash", payload) +} + +func TestDetectTokenVersionMalformedV2(t *testing.T) { + // "v2.incomplete" has the v2 prefix but only 2 parts, not 3 + version, context, payload := DetectTokenVersion("v2.incomplete") + assert.Equal(t, "v1", version) + assert.Equal(t, "", context) + assert.Equal(t, "v2.incomplete", payload) +} + +func TestFormatV2Token(t *testing.T) { + result := FormatV2Token("bearer-token", "payload123") + assert.Equal(t, "v2.bearer-token.payload123", result) +} + +func TestDeriveKeyAllContexts(t *testing.T) { + secret := []byte("test-secret") + contexts := []string{ + ContextBearerToken, + ContextStickySession, + ContextPostgresInternal, + ContextGravityJWT, + ContextS3Webhook, + } + + keys := make(map[string][]byte) + for _, ctx := range contexts { + key, err := DeriveKey(secret, ctx) + require.NoError(t, err) + assert.Len(t, key, 32) + keys[ctx] = key + } + + // Verify all keys are unique + for i, ctx1 := range contexts { + for _, ctx2 := range contexts[i+1:] { + assert.NotEqual(t, keys[ctx1], keys[ctx2], + "keys for %q and %q should be different", ctx1, ctx2) + } + } +} diff --git a/go.mod b/go.mod index 2692b4b..7b8f434 100644 --- a/go.mod +++ b/go.mod @@ -32,9 +32,10 @@ require ( go.opentelemetry.io/otel/sdk/log v0.14.0 go.opentelemetry.io/otel/trace v1.40.0 go.uber.org/zap v1.27.0 - golang.org/x/net v0.46.0 - golang.org/x/sync v0.17.0 - golang.org/x/term v0.36.0 + golang.org/x/crypto v0.49.0 + golang.org/x/net v0.51.0 + golang.org/x/sync v0.20.0 + golang.org/x/term v0.41.0 google.golang.org/grpc v1.75.0 google.golang.org/protobuf v1.36.8 gopkg.in/yaml.v3 v3.0.1 @@ -103,10 +104,10 @@ require ( go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.30.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index b909d6f..0ffed57 100644 --- a/go.sum +++ b/go.sum @@ -216,23 +216,25 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -241,20 +243,20 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= 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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=