diff --git a/pkg/beholder/auth.go b/pkg/beholder/auth.go index ae944ab0f..ef3ebc6ba 100644 --- a/pkg/beholder/auth.go +++ b/pkg/beholder/auth.go @@ -2,26 +2,77 @@ package beholder import ( "crypto/ed25519" + "encoding/binary" "fmt" + "time" ) // authHeaderKey is the name of the header that the node authenticator will use to send the auth token var authHeaderKey = "X-Beholder-Node-Auth-Token" // authHeaderVersion is the version of the auth header format -var authHeaderVersion = "1" +var authHeaderVersion1 = "1" +var authHeaderVersion2 = "2" + +// AuthHeaderConfig is a configuration struct for the BuildAuthHeadersV2 function +type AuthHeaderConfig struct { + timestamp int64 + version string +} // BuildAuthHeaders creates the auth header value to be included on requests. -// The current format for the header is: +// There are two formats for the header. Version `1` is: // // :: // // where the byte value of is what's being signed +// and is the signature of the public key. +// The version `2` is: +// +// ::: +// +// where the byte value of and are what's being signed func BuildAuthHeaders(privKey ed25519.PrivateKey) map[string]string { pubKey := privKey.Public().(ed25519.PublicKey) messageBytes := pubKey signature := ed25519.Sign(privKey, messageBytes) - headerValue := fmt.Sprintf("%s:%x:%x", authHeaderVersion, messageBytes, signature) - return map[string]string{authHeaderKey: headerValue} + return map[string]string{authHeaderKey: fmt.Sprintf("%s:%x:%x", authHeaderVersion1, messageBytes, signature)} +} + +// BuildAuthHeadersV2 creates the auth header value to be included on requests. +// See documentation on BuildAuthHeaders for more info. +func BuildAuthHeadersV2(privKey ed25519.PrivateKey, config *AuthHeaderConfig) map[string]string { + if config == nil { + config = defaultAuthHeaderConfig() + } + if config.version == "" { + config.version = authHeaderVersion2 + } + // If timestamp is not set, use the current time + if config.timestamp == 0 { + config.timestamp = time.Now().UnixMilli() + } + // If timestamp is negative, set it to 0. negative values cause overflow on conversion to uint64 + // 0 timestamps will be rejected by the server as being too old + if config.timestamp < 0 { + config.timestamp = 0 + } + + pubKey := privKey.Public().(ed25519.PublicKey) + + timestampUnixMsBytes := make([]byte, 8) + binary.BigEndian.PutUint64(timestampUnixMsBytes, uint64(config.timestamp)) + + messageBytes := append(pubKey, timestampUnixMsBytes...) + signature := ed25519.Sign(privKey, messageBytes) + + return map[string]string{authHeaderKey: fmt.Sprintf("%s:%x:%d:%x", config.version, pubKey, config.timestamp, signature)} +} + +func defaultAuthHeaderConfig() *AuthHeaderConfig { + return &AuthHeaderConfig{ + version: authHeaderVersion2, + timestamp: time.Now().UnixMilli(), + } } diff --git a/pkg/beholder/auth_test.go b/pkg/beholder/auth_test.go index fd0e2c86c..12bb5a138 100644 --- a/pkg/beholder/auth_test.go +++ b/pkg/beholder/auth_test.go @@ -2,17 +2,21 @@ package beholder import ( "crypto/ed25519" + "encoding/binary" "encoding/hex" + "strconv" + "strings" "testing" + "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestBuildAuthHeaders(t *testing.T) { - csaPrivKeyHex := "1ac84741fa51c633845fa65c06f37a700303619135630a01f2d22fb98eb1c54ecab39509e63cfaa81c70e2c907391f96803aacb00db5619a5ace5588b4b08159" - csaPrivKeyBytes, err := hex.DecodeString(csaPrivKeyHex) - assert.NoError(t, err) - csaPrivKey := ed25519.PrivateKey(csaPrivKeyBytes) +func TestBuildAuthHeadersV1(t *testing.T) { + csaPrivKey, err := generateTestCSAPrivateKey() + require.NoError(t, err) expectedHeaders := map[string]string{ "X-Beholder-Node-Auth-Token": "1:cab39509e63cfaa81c70e2c907391f96803aacb00db5619a5ace5588b4b08159:4403178e299e9acc5b48ae97de617d3975c5d431b794cfab1d23eda01c194119b2360f5f74cfb3e4f706237ab57a0ba88ffd3f8addbc1e5197b3d3e13a1fc409", @@ -20,3 +24,116 @@ func TestBuildAuthHeaders(t *testing.T) { assert.Equal(t, expectedHeaders, BuildAuthHeaders(csaPrivKey)) } + +func TestBuildAuthHeadersV2(t *testing.T) { + csaPrivKey, err := generateTestCSAPrivateKey() + require.NoError(t, err) + timestamp := time.Now().UnixMilli() + + authHeaderMap := BuildAuthHeadersV2(csaPrivKey, &AuthHeaderConfig{ + timestamp: timestamp, + }) + + authHeaderValue, ok := authHeaderMap[authHeaderKey] + require.True(t, ok, "auth header should be present") + + parts := strings.Split(authHeaderValue, ":") + assert.Len(t, parts, 4, "auth header v2 should have 4 parts") + // Check the parts + version, pubKeyHex, timestampStr, signatureHex := parts[0], parts[1], parts[2], parts[3] + assert.Equal(t, authHeaderVersion2, version, "BuildAuthHeadersV2 should should have version 2") + assert.Equal(t, hex.EncodeToString(csaPrivKey.Public().(ed25519.PublicKey)), pubKeyHex) + assert.Equal(t, strconv.FormatInt(timestamp, 10), timestampStr) + + // Decode the public key and signature + pubKeyBytes, err := hex.DecodeString(pubKeyHex) + require.NoError(t, err) + assert.Equal(t, csaPrivKey.Public().(ed25519.PublicKey), ed25519.PublicKey(pubKeyBytes)) + + // Parse the timestamp + timestampParsed, err := strconv.ParseInt(timestampStr, 10, 64) + require.NoError(t, err) + assert.Equal(t, timestamp, timestampParsed) + timestampBytes := make([]byte, 8) + binary.BigEndian.PutUint64(timestampBytes, uint64(timestampParsed)) + + // Reconstruct the message bytes + messageBytes := append(pubKeyBytes, timestampBytes...) + + // Verify the signature + signatureBytes, err := hex.DecodeString(signatureHex) + require.NoError(t, err) + assert.True(t, ed25519.Verify(pubKeyBytes, messageBytes, signatureBytes)) +} + +func TestBuildAuthHeadersV2WithDefaults(t *testing.T) { + csaPrivKey, err := generateTestCSAPrivateKey() + require.NoError(t, err) + + now := time.Now().UnixMilli() + + authHeaderMap := BuildAuthHeadersV2(csaPrivKey, nil) + authHeaderValue, ok := authHeaderMap[authHeaderKey] + require.True(t, ok, "auth header should be present") + + parts := strings.Split(authHeaderValue, ":") + assert.Len(t, parts, 4, "auth header v2 should have 4 parts") + // Check the parts + version, pubKeyHex, timestampStr, signatureHex := parts[0], parts[1], parts[2], parts[3] + assert.Equal(t, "2", version, "using WithAuthHeaderV2 should should have version 2") + assert.Equal(t, hex.EncodeToString(csaPrivKey.Public().(ed25519.PublicKey)), pubKeyHex) + + // Decode the public key and signature + pubKeyBytes, err := hex.DecodeString(pubKeyHex) + require.NoError(t, err) + assert.Equal(t, csaPrivKey.Public().(ed25519.PublicKey), ed25519.PublicKey(pubKeyBytes)) + + // Parse the timestamp + timestampParsed, err := strconv.ParseInt(timestampStr, 10, 64) + require.NoError(t, err) + + // Verify the timestamp is within the last 50ms + // This verifies that default configuration is to use the current time + assert.InDelta(t, now, timestampParsed, 50, "timestamp should be within the last 50ms") + + timestampBytes := make([]byte, 8) + binary.BigEndian.PutUint64(timestampBytes, uint64(timestampParsed)) + + // Reconstruct the message bytes + messageBytes := append(pubKeyBytes, timestampBytes...) + + // Verify the signature + signatureBytes, err := hex.DecodeString(signatureHex) + require.NoError(t, err) + assert.True(t, ed25519.Verify(pubKeyBytes, messageBytes, signatureBytes)) +} + +func TestBuildAuthHeadersV2WithNegativeTimestamp(t *testing.T) { + csaPrivKey, err := generateTestCSAPrivateKey() + require.NoError(t, err) + timestamp := int64(-111) + + authHeaderMap := BuildAuthHeadersV2(csaPrivKey, &AuthHeaderConfig{ + timestamp: timestamp, + }) + + authHeaderValue, ok := authHeaderMap[authHeaderKey] + require.True(t, ok, "auth header should be present") + + parts := strings.Split(authHeaderValue, ":") + assert.Len(t, parts, 4, "auth header v2 should have 4 parts") + // Check the the returned timestamp is 0 + _, _, timestampStr, _ := parts[0], parts[1], parts[2], parts[3] + timestampParsed, err := strconv.ParseInt(timestampStr, 10, 64) + require.NoError(t, err) + assert.Zero(t, timestampParsed) +} + +func generateTestCSAPrivateKey() (ed25519.PrivateKey, error) { + csaPrivKeyHex := "1ac84741fa51c633845fa65c06f37a700303619135630a01f2d22fb98eb1c54ecab39509e63cfaa81c70e2c907391f96803aacb00db5619a5ace5588b4b08159" + csaPrivKeyBytes, err := hex.DecodeString(csaPrivKeyHex) + if err != nil { + return nil, err + } + return ed25519.PrivateKey(csaPrivKeyBytes), nil +}