diff --git a/cmd/root.go b/cmd/root.go index 909203e4..2fe00e1c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 != "" { diff --git a/internal/container/telemetry_test.go b/internal/container/telemetry_test.go index f0bcdee4..51ff11f2 100644 --- a/internal/container/telemetry_test.go +++ b/internal/container/telemetry_test.go @@ -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) { diff --git a/internal/telemetry/client.go b/internal/telemetry/client.go index 8b1ad5aa..c5f71906 100644 --- a/internal/telemetry/client.go +++ b/internal/telemetry/client.go @@ -3,6 +3,8 @@ package telemetry import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -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 @@ -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 { diff --git a/internal/telemetry/events.go b/internal/telemetry/events.go index 20b1d6a3..291daa94 100644 --- a/internal/telemetry/events.go +++ b/internal/telemetry/events.go @@ -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, diff --git a/internal/telemetry/events_test.go b/internal/telemetry/events_test.go index 4749027c..f5d37307 100644 --- a/internal/telemetry/events_test.go +++ b/internal/telemetry/events_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "runtime" + "strings" "testing" "time" @@ -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) @@ -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) @@ -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) @@ -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)