Skip to content
Closed
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
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func Execute(ctx context.Context) error {

logger.Info("lstk %s starting", version.Version())

// Resolve auth token for telemetry: keyring first, then env var.
// Resolve auth token for telemetry correlation: keyring first, then env var.
resolvedToken := cfg.AuthToken
if tokenStorage, err := auth.NewTokenStorage(cfg.ForceFileKeyring, logger); err == nil {
if token, err := tokenStorage.GetAuthToken(); err == nil && token != "" {
Expand Down
2 changes: 1 addition & 1 deletion internal/container/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func TestStop_EmitsLifecycleStopEvent(t *testing.T) {
assert.Equal(t, "aws", payload["emulator"])

env := payload["environment"].(map[string]any)
assert.Equal(t, "ls-abc", env["auth_token_id"])
assert.Equal(t, telemetry.FingerprintToken("ls-abc"), env["auth_token_id"])
}

func TestEmitEmulatorLifecycleEvent_SendsStartErrorEvent(t *testing.T) {
Expand Down
26 changes: 19 additions & 7 deletions internal/telemetry/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package telemetry
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
Expand All @@ -22,10 +24,10 @@ func userAgent() string {
}

type Client struct {
enabled bool
sessionID string
machineID string
authToken string
enabled bool
sessionID string
machineID string
authTokenID string

httpClient *http.Client
endpoint string
Expand All @@ -36,10 +38,20 @@ type Client struct {
machineIDOnce sync.Once
}

// SetAuthToken stores the resolved auth token for inclusion in telemetry events.
// Call this once the token is known (e.g. after keyring resolution or interactive login).
// SetAuthToken stores a one-way fingerprint of the auth token for telemetry
// correlation. The raw token is not retained.
func (c *Client) SetAuthToken(token string) {
c.authToken = token
c.authTokenID = FingerprintToken(token)
}

// FingerprintToken returns a 16-character lowercase hex prefix of the token's
// SHA-256 digest. Empty input yields empty output.
func FingerprintToken(token string) string {
if token == "" {
return ""
}
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])[:16]
}

func New(endpoint string, disabled bool) *Client {
Expand Down
7 changes: 4 additions & 3 deletions internal/telemetry/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,16 @@ func ToMap(v any) map[string]any {
return m
}

// GetEnvironment returns the common environment payload for telemetry events,
// using the auth token set via SetAuthToken.
// GetEnvironment returns the common environment payload for telemetry events.
// AuthTokenID is a one-way fingerprint of the token registered via
// SetAuthToken, not the token itself.
func (c *Client) GetEnvironment(ctx context.Context) Environment {
c.machineIDOnce.Do(func() {
c.machineID = LoadOrCreateMachineID(ctx)
})
return Environment{
LstkVersion: version.Version(),
AuthTokenID: c.authToken,
AuthTokenID: c.authTokenID,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
MachineID: c.machineID,
Expand Down
27 changes: 25 additions & 2 deletions internal/telemetry/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -51,7 +52,7 @@ func TestGetEnvironment_PopulatesAllFields(t *testing.T) {
env := c.GetEnvironment(context.Background())

assert.Equal(t, version.Version(), env.LstkVersion)
assert.Equal(t, "ls-abc123", env.AuthTokenID)
assert.Equal(t, FingerprintToken("ls-abc123"), env.AuthTokenID)
assert.Equal(t, runtime.GOOS, env.OS)
assert.Equal(t, runtime.GOARCH, env.Arch)
assert.NotEmpty(t, env.MachineID)
Expand All @@ -63,6 +64,15 @@ func TestGetEnvironment_OmitsAuthTokenWhenEmpty(t *testing.T) {
assert.Empty(t, env.AuthTokenID)
}

func TestFingerprintToken_IsStableAndIrreversible(t *testing.T) {
assert.Empty(t, FingerprintToken(""))

fp := FingerprintToken("ls-secret-token")
assert.Len(t, fp, 16)
assert.Equal(t, fp, FingerprintToken("ls-secret-token"), "fingerprint must be deterministic")
assert.NotEqual(t, fp, FingerprintToken("ls-secret-token2"), "different inputs must yield different fingerprints")
}

func TestEmitCommand_SendsCorrectEventNameAndStructure(t *testing.T) {
tel, ch := captureEvents(t)

Expand All @@ -85,7 +95,7 @@ func TestEmitCommand_SendsCorrectEventNameAndStructure(t *testing.T) {
env, ok := payload["environment"].(map[string]any)
require.True(t, ok)
assert.Equal(t, version.Version(), env["lstk_version"])
assert.Equal(t, "ls-token", env["auth_token_id"])
assert.Equal(t, FingerprintToken("ls-token"), env["auth_token_id"])

params, ok := payload["parameters"].(map[string]any)
require.True(t, ok)
Expand All @@ -98,6 +108,19 @@ func TestEmitCommand_SendsCorrectEventNameAndStructure(t *testing.T) {
assert.InDelta(t, 0, result["exit_code"], 0)
}

func TestEmitCommand_NeverSerializesRawAuthToken(t *testing.T) {
tel, ch := captureEvents(t)

const rawToken = "ls-super-secret-do-not-leak"
tel.SetAuthToken(rawToken)
tel.EmitCommand(context.Background(), "status", nil, 0, 0, "")

got := drainEvent(t, tel, ch)
serialized, err := json.Marshal(got)
require.NoError(t, err)
assert.False(t, strings.Contains(string(serialized), rawToken), "raw auth token must not appear in telemetry payload")
}

func TestEmitCommand_IncludesErrorMsgOnFailure(t *testing.T) {
tel, ch := captureEvents(t)

Expand Down
Loading