Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

INFOPLAT-1559 Adds auth header token expiry functionality #963

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
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
59 changes: 55 additions & 4 deletions pkg/beholder/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
//
// <version>:<public_key_hex>:<signature_hex>
//
// where the byte value of <public_key_hex> is what's being signed
// and <signature_hex> is the signature of the public key.
// The version `2` is:
//
// <version>:<public_key_hex>:<timestamp>:<signature_hex>
//
// where the byte value of <public_key_hex> and <timestamp> 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 {
hendoxc marked this conversation as resolved.
Show resolved Hide resolved
if config == nil {
config = defaultAuthHeaderConfig()
}
if config.version == "" {
config.version = authHeaderVersion2
}
Comment on lines +49 to +51
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure how important it is, but with the current code we theoretically allow a caller to build v2 headers while passing in a different version, which will get set in the header key.

Copy link
Author

Choose a reason for hiding this comment

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

Yes, by design, it means this function can support upgrading to a new version

// 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
Copy link
Contributor

Choose a reason for hiding this comment

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

this sorta conflicts in spirit to the previous block where we set the timestamp to now if it's 0

should this be moved up?

Copy link
Author

@hendoxc hendoxc Dec 6, 2024

Choose a reason for hiding this comment

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

So you're saying if a negative value is passed in, we should auto correct to current time?

There are two things here:

  • need to handle a default value, e.g when config arg is nil
  • need to handle when config.timestamp is a negative value. should this be valid or not. as it is now, this would send a 0 timestamp to the auth server which would fail

If I auto-correct to current timestamp when passing in negative value, which then passes authentication, that to me is somewhat unexpected behaviour from user perspective. I would expect if pass an invalid negative timestamp, I would fail authentication one-way or another

Copy link
Contributor

Choose a reason for hiding this comment

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

gotcha, thanks for elaborating

there's definitely tradeoffs with either approach, though I don't feel strongly either way. happy to keep things as-is

Copy link
Author

Choose a reason for hiding this comment

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

I think let's keep as is then

}

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(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it intended that we don't use config.timestamp here? Seems counter-intuitive

Copy link
Author

Choose a reason for hiding this comment

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

not sure I understand

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah I think I mixed things up, nvm!

}
}
127 changes: 122 additions & 5 deletions pkg/beholder/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,138 @@ 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",
}

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")
Copy link
Contributor

@pkcll pkcll Dec 10, 2024

Choose a reason for hiding this comment

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

nit: Should it be just after or equal tonow ? or you can use mock clock github.com/jonboulle/clockwork


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
}
Loading