From 2ebbfe50a91c51f6fbf4846838216058dd751cf9 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 17:44:35 -0700 Subject: [PATCH 1/4] refactor(proof/apikey): rename IssuedToken.Plaintext to Token "Plaintext" was misleading in a crypto context, and inside the package already collided with the parsed inner `secret` component used by formatToken/parseToken/hashSecret. Rename the field, the VerifyAPIToken parameter, and the parseToken parameter to Token/token. The field holds the full opaque token (ak__), so Token reads naturally and mirrors access/jwt.IssuedToken.JWT (PR #61) which named the field by what the value is. Tests outside proof/apikey/ that reference Plaintext on this struct will fail to compile after this commit and are fixed in the cascade commit that follows. Co-Authored-By: Claude Opus 4.7 (1M context) --- proof/apikey/doc.go | 4 ++-- proof/apikey/service.go | 14 +++++++------- proof/apikey/service_test.go | 30 +++++++++++++++--------------- proof/apikey/types.go | 4 ++-- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/proof/apikey/doc.go b/proof/apikey/doc.go index 629c9b7..bad0bd9 100644 --- a/proof/apikey/doc.go +++ b/proof/apikey/doc.go @@ -1,7 +1,7 @@ // Package apikey provides opaque API-token issuing and verification. // // Tokens are storage-backed, revocable credentials. The service stores only a -// SHA-256 hash of the token secret, returns the plaintext token only at issue -// time, and verifies successful tokens to principal-bearing token metadata for +// SHA-256 hash of the token secret, returns the opaque token only at issue +// time, and verifies presented tokens to principal-bearing token metadata for // access JWT exchange. package apikey diff --git a/proof/apikey/service.go b/proof/apikey/service.go index 3e07f35..38ed4e8 100644 --- a/proof/apikey/service.go +++ b/proof/apikey/service.go @@ -60,7 +60,7 @@ func (s *Service) IssueToken(ctx context.Context, req IssueRequest) (IssuedToken tokenID := rand.Text() secret := rand.Text() - plaintext := formatToken(tokenID, secret) + token := formatToken(tokenID, secret) if err := s.store.CreateToken(ctx, StoredToken{ ID: tokenID, @@ -74,18 +74,18 @@ func (s *Service) IssueToken(ctx context.Context, req IssueRequest) (IssuedToken return IssuedToken{ ID: tokenID, - Plaintext: plaintext, + Token: token, ExpiresAt: req.ExpiresAt, }, nil } -// VerifyAPIToken authenticates plaintext and returns its API-token metadata. -func (s *Service) VerifyAPIToken(ctx context.Context, plaintext string) (VerifiedToken, error) { +// VerifyAPIToken authenticates token and returns its API-token metadata. +func (s *Service) VerifyAPIToken(ctx context.Context, token string) (VerifiedToken, error) { if err := ctx.Err(); err != nil { return VerifiedToken{}, err } - tokenID, secret, ok := parseToken(plaintext) + tokenID, secret, ok := parseToken(token) if !ok { return VerifiedToken{}, unauthenticated("malformed token") } @@ -144,8 +144,8 @@ func formatToken(tokenID string, secret string) string { return tokenPartPrefix + tokenID + tokenSeparator + secret } -func parseToken(plaintext string) (string, string, bool) { - rest, ok := strings.CutPrefix(plaintext, tokenPartPrefix) +func parseToken(token string) (string, string, bool) { + rest, ok := strings.CutPrefix(token, tokenPartPrefix) if !ok { return "", "", false } diff --git a/proof/apikey/service_test.go b/proof/apikey/service_test.go index ef3bd45..2fe396f 100644 --- a/proof/apikey/service_test.go +++ b/proof/apikey/service_test.go @@ -36,8 +36,8 @@ func TestServiceIssueToken(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, issued.ID) assert.Equal(t, expiresAt, issued.ExpiresAt) - require.True(t, strings.HasPrefix(issued.Plaintext, "ak_"+issued.ID+"_")) - require.Len(t, strings.Split(issued.Plaintext, "_"), tokenParts) + require.True(t, strings.HasPrefix(issued.Token, "ak_"+issued.ID+"_")) + require.Len(t, strings.Split(issued.Token, "_"), tokenParts) stored, err := store.FindToken(context.Background(), issued.ID) require.NoError(t, err) @@ -87,7 +87,7 @@ func TestServiceVerifyAPIToken(t *testing.T) { service, store := newService(t, now) issued := issueToken(t, service, now.Add(time.Hour)) - verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext) + verified, err := service.VerifyAPIToken(context.Background(), issued.Token) require.NoError(t, err) assert.Equal(t, apikey.VerifiedToken{ @@ -110,18 +110,18 @@ func TestServiceVerifyAPITokenRejectsInvalidTokens(t *testing.T) { require.NoError(t, service.RevokeToken(context.Background(), revoked.ID)) tests := []struct { - name string - plaintext string + name string + token string }{ - {name: "malformed", plaintext: "not-a-token"}, - {name: "unknown ID", plaintext: "ak_missing_secret"}, - {name: "wrong secret", plaintext: replaceTokenSecret(t, issued.Plaintext, "wrong")}, - {name: "revoked", plaintext: revoked.Plaintext}, + {name: "malformed", token: "not-a-token"}, + {name: "unknown ID", token: "ak_missing_secret"}, + {name: "wrong secret", token: replaceTokenSecret(t, issued.Token, "wrong")}, + {name: "revoked", token: revoked.Token}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - verified, err := service.VerifyAPIToken(context.Background(), tt.plaintext) + verified, err := service.VerifyAPIToken(context.Background(), tt.token) require.ErrorIs(t, err, authkit.ErrUnauthenticated) assert.Empty(t, verified) @@ -138,7 +138,7 @@ func TestServiceVerifyAPITokenRejectsExpiredToken(t *testing.T) { issued := issueToken(t, service, now.Add(time.Hour)) current = now.Add(time.Hour) - verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext) + verified, err := service.VerifyAPIToken(context.Background(), issued.Token) require.ErrorIs(t, err, authkit.ErrUnauthenticated) assert.Empty(t, verified) @@ -156,7 +156,7 @@ func TestServiceVerifyAPITokenIgnoresLastUsedUpdateErrors(t *testing.T) { })) require.NoError(t, err) - verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext) + verified, err := service.VerifyAPIToken(context.Background(), issued.Token) require.NoError(t, err) assert.Equal(t, issued.ID, verified.ID) @@ -176,7 +176,7 @@ func TestServiceRevokeToken(t *testing.T) { require.NotNil(t, stored.RevokedAt) assert.Equal(t, now, *stored.RevokedAt) - verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext) + verified, err := service.VerifyAPIToken(context.Background(), issued.Token) require.ErrorIs(t, err, authkit.ErrUnauthenticated) assert.Empty(t, verified) } @@ -233,10 +233,10 @@ func issueTokenForPrincipal( return issued } -func replaceTokenSecret(t *testing.T, plaintext string, secret string) string { +func replaceTokenSecret(t *testing.T, token string, secret string) string { t.Helper() - parts := strings.Split(plaintext, "_") + parts := strings.Split(token, "_") require.Len(t, parts, tokenParts) return strings.Join([]string{parts[0], parts[1], secret}, "_") diff --git a/proof/apikey/types.go b/proof/apikey/types.go index 206e1ac..3bff01e 100644 --- a/proof/apikey/types.go +++ b/proof/apikey/types.go @@ -22,8 +22,8 @@ type IssuedToken struct { // ID is the stable lookup identifier embedded in the token. ID string - // Plaintext is the full token secret shown once to the caller. - Plaintext string + // Token is the full opaque token shown once to the caller (format: ak__). + Token string // ExpiresAt is the time after which the token must no longer authenticate. ExpiresAt time.Time From bfdf14722165563f3b95e9913d66d2e0eac0b79e Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 17:48:01 -0700 Subject: [PATCH 2/4] refactor(exchange,management,testkit): cascade Plaintext to Token rename Pick up the field-rename cascade that the apikey commit started. All three consumer-side names follow the source of truth: - exchange.APITokenRequest.Plaintext -> Token (deferred since PR #63) - management.IssuedAPIToken.Plaintext -> Token (deferred since PR #65) - exchange.APITokenVerifier.VerifyAPIToken parameter -> token - testkit/internal/authflow.Runtime.ExchangeAPIToken parameter -> token - testkit/internal/httpui.authRuntime.ExchangeAPIToken parameter -> token - management testTokenSecret constant -> testToken (the value is the full ak__ token, not just the inner secret) Comments and test fixtures aligned with the new vocabulary. Co-Authored-By: Claude Opus 4.7 (1M context) --- exchange/apitoken.go | 14 +++++++------- exchange/apitoken_test.go | 10 +++++----- internal/storetest/tokens.go | 2 +- management/apitoken.go | 6 +++--- management/apitoken_test.go | 4 ++-- management/helpers_test.go | 2 +- management/service_test.go | 2 +- testkit/internal/authflow/runtime.go | 10 +++++----- testkit/internal/httpui/server.go | 2 +- 9 files changed, 26 insertions(+), 26 deletions(-) diff --git a/exchange/apitoken.go b/exchange/apitoken.go index f40b31d..80a6f5a 100644 --- a/exchange/apitoken.go +++ b/exchange/apitoken.go @@ -10,11 +10,11 @@ import ( ) // APITokenVerifier verifies opaque API tokens and returns their authenticated metadata. -// Implementations must return authkit.ErrUnauthenticated (directly or wrapped) when plaintext is +// Implementations must return authkit.ErrUnauthenticated (directly or wrapped) when the token is // not a currently valid token; other errors are treated as internal failures. type APITokenVerifier interface { - // VerifyAPIToken verifies plaintext and returns its authenticated token metadata. - VerifyAPIToken(ctx context.Context, plaintext string) (apikey.VerifiedToken, error) + // VerifyAPIToken verifies token and returns its authenticated token metadata. + VerifyAPIToken(ctx context.Context, token string) (apikey.VerifiedToken, error) } // APITokenOptions configures an APITokenExchanger. @@ -31,8 +31,8 @@ type APITokenOptions struct { // APITokenRequest describes an API-token exchange request. type APITokenRequest struct { - // Plaintext is the opaque API token presented for exchange. - Plaintext string + // Token is the opaque API token presented for exchange. + Token string } // APITokenResult describes a completed API-token exchange. @@ -73,13 +73,13 @@ func NewAPITokenExchanger(opts APITokenOptions) (*APITokenExchanger, error) { }, nil } -// Exchange verifies req.Plaintext and issues an access JWT for the token principal. +// Exchange verifies req.Token and issues an access JWT for the token principal. func (e *APITokenExchanger) Exchange(ctx context.Context, req APITokenRequest) (APITokenResult, error) { if err := ctx.Err(); err != nil { return APITokenResult{}, err } - apiToken, err := e.apiTokens.VerifyAPIToken(ctx, req.Plaintext) + apiToken, err := e.apiTokens.VerifyAPIToken(ctx, req.Token) if err != nil { return APITokenResult{}, exchangeError("verify API token", err) } diff --git a/exchange/apitoken_test.go b/exchange/apitoken_test.go index 3b89db5..fead0d2 100644 --- a/exchange/apitoken_test.go +++ b/exchange/apitoken_test.go @@ -83,7 +83,7 @@ func TestAPITokenExchangerExchangesTokenForAccessJWT(t *testing.T) { }) result, err := exchanger.Exchange(ctx, exchange.APITokenRequest{ - Plaintext: apiToken.Plaintext, + Token: apiToken.Token, }) require.NoError(t, err) @@ -118,7 +118,7 @@ func TestAPITokenExchangerRejectsMissingPrincipal(t *testing.T) { }) result, err := exchanger.Exchange(context.Background(), exchange.APITokenRequest{ - Plaintext: "ak_token_secret", + Token: "ak_token_secret", }) require.ErrorIs(t, err, authkit.ErrUnauthenticated) @@ -137,7 +137,7 @@ func TestAPITokenExchangerRejectsInvalidAPIToken(t *testing.T) { }) result, err := exchanger.Exchange(context.Background(), exchange.APITokenRequest{ - Plaintext: "invalid", + Token: "invalid", }) require.ErrorIs(t, err, authkit.ErrUnauthenticated) @@ -202,7 +202,7 @@ func TestAPITokenExchangerWrapsInternalFailures(t *testing.T) { exchanger := newAPITokenExchanger(t, tt.setupOpts(t)) result, err := exchanger.Exchange(context.Background(), exchange.APITokenRequest{ - Plaintext: "ak_token_secret", + Token: "ak_token_secret", }) require.ErrorIs(t, err, authkit.ErrInternal) @@ -222,7 +222,7 @@ func TestAPITokenExchangerPassesThroughContextErrors(t *testing.T) { cancel() result, err := exchanger.Exchange(ctx, exchange.APITokenRequest{ - Plaintext: "ak_token_secret", + Token: "ak_token_secret", }) require.ErrorIs(t, err, context.Canceled) diff --git a/internal/storetest/tokens.go b/internal/storetest/tokens.go index b216e57..6f00134 100644 --- a/internal/storetest/tokens.go +++ b/internal/storetest/tokens.go @@ -161,7 +161,7 @@ func runTokenSuite(t *testing.T, newStore func(t *testing.T) Store) { }) require.NoError(t, err) - verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext) + verified, err := service.VerifyAPIToken(context.Background(), issued.Token) require.NoError(t, err) assert.Equal(t, apikey.VerifiedToken{ ID: issued.ID, diff --git a/management/apitoken.go b/management/apitoken.go index ca207f6..0624c24 100644 --- a/management/apitoken.go +++ b/management/apitoken.go @@ -38,8 +38,8 @@ type IssuedAPIToken struct { // PrincipalID identifies the principal the token authenticates as. PrincipalID string - // Plaintext is the full token secret shown once to the caller. - Plaintext string + // Token is the full opaque token shown once to the caller (format: ak__). + Token string // ExpiresAt is the time after which the token must no longer authenticate. ExpiresAt time.Time @@ -74,7 +74,7 @@ func (s *Service) IssueAPIToken(ctx context.Context, req IssueAPITokenRequest) ( return IssuedAPIToken{ ID: issued.ID, PrincipalID: principal.ID, - Plaintext: issued.Plaintext, + Token: issued.Token, ExpiresAt: issued.ExpiresAt, }, nil } diff --git a/management/apitoken_test.go b/management/apitoken_test.go index b6c1e0b..3b41c12 100644 --- a/management/apitoken_test.go +++ b/management/apitoken_test.go @@ -22,7 +22,7 @@ func TestServiceIssueAPITokenIssuesForExistingPrincipal(t *testing.T) { apiTokens := newFakeAPITokens() apiTokens.issued = apikey.IssuedToken{ ID: testTokenID, - Plaintext: testTokenSecret, + Token: testToken, ExpiresAt: expiresAt, } principal := authkit.Principal{ @@ -53,7 +53,7 @@ func TestServiceIssueAPITokenIssuesForExistingPrincipal(t *testing.T) { assert.Equal(t, management.IssuedAPIToken{ ID: testTokenID, PrincipalID: testPrincipalID, - Plaintext: testTokenSecret, + Token: testToken, ExpiresAt: expiresAt, }, issued) } diff --git a/management/helpers_test.go b/management/helpers_test.go index 6a4d35e..95c7337 100644 --- a/management/helpers_test.go +++ b/management/helpers_test.go @@ -15,7 +15,7 @@ const ( testPrincipalID = "principal_1" testTokenID = "token_1" testTokenName = "deploy token" - testTokenSecret = "ak_token_1_secret" + testToken = "ak_token_1_secret" testProvider = "oidc" testSubject = "user-123" testPrincipalName = "deploy service" diff --git a/management/service_test.go b/management/service_test.go index 4690743..55f082f 100644 --- a/management/service_test.go +++ b/management/service_test.go @@ -378,7 +378,7 @@ func TestServiceIssueAPITokenResolvesThroughMemoryStore(t *testing.T) { ExpiresAt: now.Add(time.Hour), }) require.NoError(t, err) - verified, err := tokenService.VerifyAPIToken(context.Background(), issued.Plaintext) + verified, err := tokenService.VerifyAPIToken(context.Background(), issued.Token) require.NoError(t, err) assert.Equal(t, principal.ID, issued.PrincipalID) assert.Equal(t, apikey.VerifiedToken{ diff --git a/testkit/internal/authflow/runtime.go b/testkit/internal/authflow/runtime.go index 8814990..05feab1 100644 --- a/testkit/internal/authflow/runtime.go +++ b/testkit/internal/authflow/runtime.go @@ -98,7 +98,7 @@ type Runtime struct { // Principal is the bootstrap principal that owns the startup API token. Principal authkit.Principal - // SeedAPIToken is the plaintext startup API token shown once on stdout. + // SeedAPIToken is the startup API token shown once on stdout. SeedAPIToken string // SeedAPITokenExpiresAt is when SeedAPIToken stops being accepted for exchange. @@ -250,7 +250,7 @@ func NewRuntime(ctx context.Context, store Store, opts ...Option) (*Runtime, err OIDCVerifier: oidcVerifier, Passkeys: passkeys, Principal: principal, - SeedAPIToken: seedToken.Plaintext, + SeedAPIToken: seedToken.Token, SeedAPITokenExpiresAt: seedToken.ExpiresAt, }, nil } @@ -278,17 +278,17 @@ func runtimeOptions(opts []Option) (options, error) { return cfg, nil } -// ExchangeAPIToken exchanges plaintext for an authkit access JWT. +// ExchangeAPIToken exchanges the opaque API token for an authkit access JWT. func (r *Runtime) ExchangeAPIToken( ctx context.Context, - plaintext string, + token string, ) (exchange.APITokenResult, error) { if r == nil || r.Exchanger == nil { return exchange.APITokenResult{}, errors.New("authflow: runtime exchanger is required") } return r.Exchanger.Exchange(ctx, exchange.APITokenRequest{ - Plaintext: plaintext, + Token: token, }) } diff --git a/testkit/internal/httpui/server.go b/testkit/internal/httpui/server.go index 608c13f..6cf71c3 100644 --- a/testkit/internal/httpui/server.go +++ b/testkit/internal/httpui/server.go @@ -33,7 +33,7 @@ type Server struct { } type authRuntime interface { - ExchangeAPIToken(ctx context.Context, plaintext string) (exchange.APITokenResult, error) + ExchangeAPIToken(ctx context.Context, token string) (exchange.APITokenResult, error) ExchangeOIDCToken(ctx context.Context, plaintext string) (exchange.IdentityResult, error) BeginPasskeyRegistration(ctx context.Context, principal authkit.Principal) (passkey.BeginRegistrationResult, error) FinishPasskeyRegistration( From 7fa1a3ead896d915596cd7e367355381284bb273 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 17:49:17 -0700 Subject: [PATCH 3/4] refactor(proof/apikey): godocs + inline security comments Add godocs to private items the audit flagged as undocumented: the options struct, defaultOptions, formatToken, parseToken, hashSecret, and unauthenticated. parseToken's godoc names the attack class its input-shape check defends against (separator-smuggling in the secret). Annotate the two security-critical branches in VerifyAPIToken: - The ErrTokenNotFound -> unauthenticated mapping is a deliberate existence-leak defense. - The subtle.ConstantTimeCompare branch defends against timing side-channel attacks on the stored secret hash. Co-Authored-By: Claude Opus 4.7 (1M context) --- proof/apikey/options.go | 2 ++ proof/apikey/service.go | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/proof/apikey/options.go b/proof/apikey/options.go index 2e83fec..7adbd3b 100644 --- a/proof/apikey/options.go +++ b/proof/apikey/options.go @@ -5,10 +5,12 @@ import "time" // Option configures a Service. type Option func(*options) +// options is the resolved configuration consumed by a Service. type options struct { clock func() time.Time } +// defaultOptions returns the baseline options applied before any caller-supplied Option. func defaultOptions() options { return options{ clock: time.Now, diff --git a/proof/apikey/service.go b/proof/apikey/service.go index 38ed4e8..285b315 100644 --- a/proof/apikey/service.go +++ b/proof/apikey/service.go @@ -91,6 +91,8 @@ func (s *Service) VerifyAPIToken(ctx context.Context, token string) (VerifiedTok } stored, err := s.store.FindToken(ctx, tokenID) + // Token-not-found collapses to the generic unauthenticated response so the API never + // leaks whether a specific token ID exists in storage. if errors.Is(err, ErrTokenNotFound) { return VerifiedToken{}, unauthenticated("token not found") } @@ -99,6 +101,8 @@ func (s *Service) VerifyAPIToken(ctx context.Context, token string) (VerifiedTok } secretHash := hashSecret(secret) + // Constant-time comparison defends against timing side-channel attacks that could + // otherwise reveal the stored secret hash byte by byte. if subtle.ConstantTimeCompare(secretHash[:], stored.SecretHash[:]) != 1 { return VerifiedToken{}, unauthenticated("token secret mismatch") } @@ -140,10 +144,13 @@ func (s *Service) RevokeToken(ctx context.Context, tokenID string) error { return nil } +// formatToken assembles the opaque token form ak__. func formatToken(tokenID string, secret string) string { return tokenPartPrefix + tokenID + tokenSeparator + secret } +// parseToken splits an opaque ak__ token into its parts, rejecting any +// shape that would let an attacker hide separator characters inside the secret. func parseToken(token string) (string, string, bool) { rest, ok := strings.CutPrefix(token, tokenPartPrefix) if !ok { @@ -158,10 +165,13 @@ func parseToken(token string) (string, string, bool) { return tokenID, secret, true } +// hashSecret returns the SHA-256 hash of secret used as the at-rest comparison value. func hashSecret(secret string) [sha256.Size]byte { return sha256.Sum256([]byte(secret)) } +// unauthenticated wraps reason as an authkit.ErrUnauthenticated so callers can detect +// auth failures with errors.Is without inspecting message strings. func unauthenticated(reason string) error { return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason) } From 30e59be21e3af02d0561e2568968ea2fb01bf663 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 17:50:20 -0700 Subject: [PATCH 4/4] chore(proof/apikey): link errors.Is in godoc per godoclint godoclint flagged the unauthenticated() godoc for naming errors.Is without the bracket syntax that links to the stdlib symbol. Co-Authored-By: Claude Opus 4.7 (1M context) --- proof/apikey/service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proof/apikey/service.go b/proof/apikey/service.go index 285b315..6d261a1 100644 --- a/proof/apikey/service.go +++ b/proof/apikey/service.go @@ -171,7 +171,7 @@ func hashSecret(secret string) [sha256.Size]byte { } // unauthenticated wraps reason as an authkit.ErrUnauthenticated so callers can detect -// auth failures with errors.Is without inspecting message strings. +// auth failures with [errors.Is] without inspecting message strings. func unauthenticated(reason string) error { return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason) }