From 27217cfa2dea84a6fe33c17e2c9078248f58dd15 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 22 Jul 2026 16:07:22 +0300 Subject: [PATCH 01/11] feat(hashivault): enhance SignerVerifier to accept Vault options in addition to VAULT envs Signed-off-by: Dmitry Mordvinov --- .gitignore | 2 + pkg/signver/hashivault/auth_approle.go | 8 +++- pkg/signver/hashivault/auth_base.go | 9 +--- pkg/signver/hashivault/auth_factory.go | 61 +++++++++++++++++++++----- pkg/signver/hashivault/auth_jwt.go | 8 +++- pkg/signver/hashivault/client.go | 27 +++++++++--- pkg/signver/hashivault/signer.go | 18 +++++++- pkg/signver/key_loader.go | 4 +- pkg/signver/key_options.go | 8 ++++ pkg/signver/signerverifier.go | 2 +- 10 files changed, 114 insertions(+), 33 deletions(-) diff --git a/.gitignore b/.gitignore index 7fa4801..83d55dc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ /c/lib/welf/build .help /c/vendor/libuv.a +/.pi +/.pp diff --git a/pkg/signver/hashivault/auth_approle.go b/pkg/signver/hashivault/auth_approle.go index 116f32a..fe0e1fb 100644 --- a/pkg/signver/hashivault/auth_approle.go +++ b/pkg/signver/hashivault/auth_approle.go @@ -8,10 +8,14 @@ type appRoleAuthenticator struct { secretID string } -func newAppRoleAuthenticator(roleID, secretID string) *appRoleAuthenticator { +func newAppRoleAuthenticator(roleID, secretID, authPath string) *appRoleAuthenticator { + if authPath == "" { + authPath = "ar" + } + return &appRoleAuthenticator{ baseAuthenticator: baseAuthenticator{ - authPath: "ar", + authPath: authPath, }, roleID: roleID, secretID: secretID, diff --git a/pkg/signver/hashivault/auth_base.go b/pkg/signver/hashivault/auth_base.go index 7a91299..a755a2a 100644 --- a/pkg/signver/hashivault/auth_base.go +++ b/pkg/signver/hashivault/auth_base.go @@ -20,7 +20,7 @@ type baseAuthenticator struct { } func (b *baseAuthenticator) login(client *vault.Client, data map[string]interface{}) error { - resp, err := client.Logical().Write(fmt.Sprintf("/auth/%s/login", b.getAuthPath()), data) + resp, err := client.Logical().Write(fmt.Sprintf("/auth/%s/login", b.authPath), data) if err != nil { return fmt.Errorf("vault write: %w", err) } @@ -63,13 +63,6 @@ func (b *baseAuthenticator) isTokenValid() bool { return elapsed < b.tokenTTL-30*time.Second } -func (b *baseAuthenticator) getAuthPath() string { - if authPath := getVaultAuthPath(); authPath != "" { - return authPath - } - return b.authPath -} - func (b *baseAuthenticator) Login(client *vault.Client) error { panic("not implemented") } diff --git a/pkg/signver/hashivault/auth_factory.go b/pkg/signver/hashivault/auth_factory.go index 1f734ee..1503911 100644 --- a/pkg/signver/hashivault/auth_factory.go +++ b/pkg/signver/hashivault/auth_factory.go @@ -1,11 +1,43 @@ package hashivault -import "fmt" +import ( + "fmt" +) -func newAuthenticator(token string) (authenticator, error) { - if roleID, secretID := getVaultAuthRoleId(), getVaultAuthSecretId(); roleID != "" && secretID != "" { - return newAppRoleAuthenticator(roleID, secretID), nil - } else if audience := getActionsAudience(); audience != "" { +type authenticatorSettings struct { + token, roleID, secretID, authPath, audience, authRole, jwtToken string + fromEnv bool +} + +func newAuthSettings(opts VaultOpts) authenticatorSettings { + if !opts.hasAuthOpts() { + return authenticatorSettings{ + roleID: getVaultAuthRoleId(), + secretID: getVaultAuthSecretId(), + authPath: getVaultAuthPath(), + audience: getActionsAudience(), + authRole: getVaultAuthRole(), + jwtToken: getVaultAuthJwt(), + fromEnv: true, + } + } + + return authenticatorSettings{ + token: opts.Token, + roleID: opts.AuthRoleID, + secretID: opts.AuthSecretID, + authPath: opts.AuthPath, + authRole: opts.AuthRole, + jwtToken: opts.AuthJWT, + audience: opts.Audience, + fromEnv: false, + } +} + +func newAuthenticator(settings authenticatorSettings) (authenticator, error) { + if settings.roleID != "" && settings.secretID != "" { + return newAppRoleAuthenticator(settings.roleID, settings.secretID, settings.authPath), nil + } else if settings.audience != "" { requestURL := getActionsIDTokenRequestURL() requestToken := getActionsIDTokenRequestToken() if requestURL == "" { @@ -14,14 +46,21 @@ func newAuthenticator(token string) (authenticator, error) { if requestToken == "" { return nil, fmt.Errorf("WERF_ACTIONS_AUDIENCE is set but ACTIONS_ID_TOKEN_REQUEST_TOKEN is missing") } - provider := newActionsOidcJwtTokenProvider(requestURL, requestToken, audience) - return newJWTAuthenticator(provider, getVaultAuthRole()), nil - } else if jwtToken := getVaultAuthJwt(); jwtToken != "" { - provider := newStaticJwtTokenProvider(jwtToken) - return newJWTAuthenticator(provider, getVaultAuthRole()), nil + provider := newActionsOidcJwtTokenProvider(requestURL, requestToken, settings.audience) + return newJWTAuthenticator(provider, settings.authRole, settings.authPath), nil + } else if settings.jwtToken != "" { + provider := newStaticJwtTokenProvider(settings.jwtToken) + return newJWTAuthenticator(provider, settings.authRole, settings.authPath), nil + } + + if !settings.fromEnv { + if settings.token == "" { + return nil, fmt.Errorf("incomplete Vault auth options: provide a complete AppRole (role id + secret id), a JWT, an OIDC audience, or a token") + } + return newStaticAuthProvider(settings.token), nil } - token, err := getVaultToken(token) + token, err := getVaultToken(settings.token) if err != nil { return nil, err } diff --git a/pkg/signver/hashivault/auth_jwt.go b/pkg/signver/hashivault/auth_jwt.go index d740a64..fc4f8a2 100644 --- a/pkg/signver/hashivault/auth_jwt.go +++ b/pkg/signver/hashivault/auth_jwt.go @@ -12,10 +12,14 @@ type jwtAuthenticator struct { role string } -func newJWTAuthenticator(provider jwtTokenProvider, role string) *jwtAuthenticator { +func newJWTAuthenticator(provider jwtTokenProvider, role, authPath string) *jwtAuthenticator { + if authPath == "" { + authPath = "jwt" + } + return &jwtAuthenticator{ baseAuthenticator: baseAuthenticator{ - authPath: "jwt", + authPath: authPath, }, jwtProvider: provider, role: role, diff --git a/pkg/signver/hashivault/client.go b/pkg/signver/hashivault/client.go index 3fe32d0..f961ba9 100644 --- a/pkg/signver/hashivault/client.go +++ b/pkg/signver/hashivault/client.go @@ -44,12 +44,32 @@ type hashivaultClient struct { originalHashFunc crypto.Hash } +type VaultOpts struct { + Address string + Token string + TransitSecretEnginePath string + AuthJWT string + AuthPath string + AuthRole string + AuthRoleID string + AuthSecretID string + Audience string +} + +// hasAuthOpts reports whether the caller provided any explicit auth field. +// Transport-only options (Address, TransitSecretEnginePath) do NOT count, +// so env-based auth keeps working when only the address is set in code. +func (o VaultOpts) hasAuthOpts() bool { + return o.Token != "" || o.AuthRoleID != "" || o.AuthSecretID != "" || + o.AuthJWT != "" || o.AuthRole != "" || o.AuthPath != "" || o.Audience != "" +} + const ( // use a consistent key for cache lookups cacheKey = "signer" ) -func newHashivaultClient(address, token, transitSecretEnginePath, keyResourceID string, keyVersion uint64, originalHashFunc crypto.Hash) (*hashivaultClient, error) { +func newHashivaultClient(auth authenticator, address, transitSecretEnginePath, keyResourceID string, keyVersion uint64, originalHashFunc crypto.Hash) (*hashivaultClient, error) { if err := validReference(keyResourceID); err != nil { return nil, err } @@ -70,11 +90,6 @@ func newHashivaultClient(address, token, transitSecretEnginePath, keyResourceID return nil, fmt.Errorf("new vault client: %w", err) } - auth, err := newAuthenticator(token) - if err != nil { - return nil, err - } - hvClient := &hashivaultClient{ client: client, keyPath: keyPath, diff --git a/pkg/signver/hashivault/signer.go b/pkg/signver/hashivault/signer.go index 6d9576b..636db79 100644 --- a/pkg/signver/hashivault/signer.go +++ b/pkg/signver/hashivault/signer.go @@ -69,6 +69,15 @@ type SignerVerifier struct { // It also can verify signatures (via a remote vall to the Vault instance). The hashFunc will be // automatically set to crypto.Hash(0) if the key referred to by referenceStr is an ED25519 signing key. func LoadSignerVerifier(referenceStr string, hashFunc crypto.Hash, opts ...signature.RPCOption) (*SignerVerifier, error) { + return loadSignerVerifier(referenceStr, hashFunc, VaultOpts{}) +} + +// LoadSignerVerifierWithOpts does the same as LoadSignerVerifier but allows to specify the VaultOpts +func LoadSignerVerifierWithOpts(referenceStr string, hashFunc crypto.Hash, opts VaultOpts) (*SignerVerifier, error) { + return loadSignerVerifier(referenceStr, hashFunc, opts) +} + +func loadSignerVerifier(referenceStr string, hashFunc crypto.Hash, opts VaultOpts) (*SignerVerifier, error) { h := &SignerVerifier{} switch hashFunc { @@ -78,8 +87,15 @@ func LoadSignerVerifier(referenceStr string, hashFunc crypto.Hash, opts ...signa return nil, errors.New("hash function not supported by Hashivault") } + authSettings := newAuthSettings(opts) + var err error - h.client, err = newHashivaultClient("", "", "", referenceStr, 0, hashFunc) + auth, err := newAuthenticator(authSettings) + if err != nil { + return nil, err + } + + h.client, err = newHashivaultClient(auth, opts.Address, opts.TransitSecretEnginePath, referenceStr, 0, hashFunc) if err != nil { return nil, err } diff --git a/pkg/signver/key_loader.go b/pkg/signver/key_loader.go index 438a95d..3076f9c 100644 --- a/pkg/signver/key_loader.go +++ b/pkg/signver/key_loader.go @@ -29,7 +29,7 @@ const ( // signerVerifierFromKeyRef // Copied from https://github.com/sigstore/cosign/blob/c948138c19691142c1e506e712b7c1646e8ceb21/pkg/signature/keys.go#L103 // and modified after. -func signerVerifierFromKeyRef(ctx context.Context, keyRef string, passFunc cryptoutils.PassFunc) (signature.SignerVerifier, error) { +func signerVerifierFromKeyRef(ctx context.Context, keyRef string, passFunc cryptoutils.PassFunc, opts SignerVerifierOpts) (signature.SignerVerifier, error) { if keyRef == "" { return nil, errors.New("keyRef must not be empty string") } @@ -42,7 +42,7 @@ func signerVerifierFromKeyRef(ctx context.Context, keyRef string, passFunc crypt case strings.HasPrefix(keyRef, gitLabReferenceScheme): return nil, errors.New("gitlab keys are not supported") case strings.HasPrefix(keyRef, hashivault.ReferenceScheme): - return hashivault.LoadSignerVerifier(keyRef, crypto.SHA256) + return hashivault.LoadSignerVerifierWithOpts(keyRef, crypto.SHA256, opts.VaultOpts) default: sv, err := loadKey(keyRef, passFunc) if err != nil { diff --git a/pkg/signver/key_options.go b/pkg/signver/key_options.go index 77738ac..6899057 100644 --- a/pkg/signver/key_options.go +++ b/pkg/signver/key_options.go @@ -2,6 +2,8 @@ package signver import ( "github.com/sigstore/sigstore/pkg/cryptoutils" + + "github.com/deckhouse/delivery-kit-sdk/pkg/signver/hashivault" ) // KeyOpts @@ -12,4 +14,10 @@ type KeyOpts struct { KeyRef string // PassFunc PassFunc cryptoutils.PassFunc + // SignerVerifierOpts + SignerVerifierOpts SignerVerifierOpts +} + +type SignerVerifierOpts struct { + VaultOpts hashivault.VaultOpts } diff --git a/pkg/signver/signerverifier.go b/pkg/signver/signerverifier.go index f7ac390..a0c6f5e 100644 --- a/pkg/signver/signerverifier.go +++ b/pkg/signver/signerverifier.go @@ -35,7 +35,7 @@ func NewSignerVerifier(ctx context.Context, certRef, certChainRef string, ko Key return nil, errors.New("ko.KeyRef must not be empty string") } - k, err := signerVerifierFromKeyRef(ctx, ko.KeyRef, ko.PassFunc) + k, err := signerVerifierFromKeyRef(ctx, ko.KeyRef, ko.PassFunc, ko.SignerVerifierOpts) if err != nil { return nil, fmt.Errorf("reading key: %w", err) } From 2ab34e3a8f3b17068b72ca8d6cc6a5785f7b6c5c Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 22 Jul 2026 19:03:52 +0300 Subject: [PATCH 02/11] test(hashivault): cover Vault authenticator selection matrix Add table-driven tests for newAuthSettings/newAuthenticator across the env and opts sources: method priority (AppRole > OIDC > static JWT > token), transport-only opts preserving env auth, opts credentials ignoring env, incomplete opts returning an error without env/file token fallback, and auth-path resolution (opts over WERF_VAULT_AUTH_PATH, ar/jwt defaults). Env is isolated per test by unsetting Vault variables, since the getters chain WERF_* over VAULT_* via os.LookupEnv where an empty-but-present value would mask the fallback. Signed-off-by: Dmitry Mordvinov --- pkg/signver/hashivault/auth_factory_test.go | 339 ++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 pkg/signver/hashivault/auth_factory_test.go diff --git a/pkg/signver/hashivault/auth_factory_test.go b/pkg/signver/hashivault/auth_factory_test.go new file mode 100644 index 0000000..aa0b026 --- /dev/null +++ b/pkg/signver/hashivault/auth_factory_test.go @@ -0,0 +1,339 @@ +package hashivault + +import ( + "os" + "strings" + "testing" +) + +var vaultEnvKeys = []string{ + "WERF_VAULT_AUTH_ROLE_ID", "VAULT_ROLE_ID", + "WERF_VAULT_AUTH_SECRET_ID", "VAULT_SECRET_ID", + "WERF_VAULT_AUTH_ROLE", + "WERF_VAULT_AUTH_JWT", + "WERF_VAULT_AUTH_PATH", + "WERF_ACTIONS_AUDIENCE", + "ACTIONS_ID_TOKEN_REQUEST_URL", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "VAULT_TOKEN", +} + +// clearVaultEnv isolates a test from any Vault-related environment variables +// that may be present in the host or CI environment. Variables are unset (not +// set to an empty string) because getters chain WERF_* over VAULT_* via +// os.LookupEnv, where an empty-but-present value would mask the fallback. +func clearVaultEnv(t *testing.T) { + t.Helper() + // t.Setenv registers restoration of the original values on cleanup and + // guards against parallel tests; unset afterwards to get a truly empty env. + for _, key := range vaultEnvKeys { + t.Setenv(key, "") + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + } +} + +func assertAppRole(t *testing.T, auth authenticator, wantRole, wantSecret, wantPath string) { + t.Helper() + a, ok := auth.(*appRoleAuthenticator) + if !ok { + t.Fatalf("expected *appRoleAuthenticator, got %T", auth) + } + if a.roleID != wantRole { + t.Errorf("roleID = %q, want %q", a.roleID, wantRole) + } + if a.secretID != wantSecret { + t.Errorf("secretID = %q, want %q", a.secretID, wantSecret) + } + if a.authPath != wantPath { + t.Errorf("authPath = %q, want %q", a.authPath, wantPath) + } +} + +func assertOIDC(t *testing.T, auth authenticator, wantRole, wantPath string) { + t.Helper() + a, ok := auth.(*jwtAuthenticator) + if !ok { + t.Fatalf("expected *jwtAuthenticator, got %T", auth) + } + if _, ok := a.jwtProvider.(*actionsOidcJwtTokenProvider); !ok { + t.Fatalf("expected *actionsOidcJwtTokenProvider, got %T", a.jwtProvider) + } + if a.role != wantRole { + t.Errorf("role = %q, want %q", a.role, wantRole) + } + if a.authPath != wantPath { + t.Errorf("authPath = %q, want %q", a.authPath, wantPath) + } +} + +func assertStaticJWT(t *testing.T, auth authenticator, wantRole, wantPath string) { + t.Helper() + a, ok := auth.(*jwtAuthenticator) + if !ok { + t.Fatalf("expected *jwtAuthenticator, got %T", auth) + } + if _, ok := a.jwtProvider.(*staticJwtTokenProvider); !ok { + t.Fatalf("expected *staticJwtTokenProvider, got %T", a.jwtProvider) + } + if a.role != wantRole { + t.Errorf("role = %q, want %q", a.role, wantRole) + } + if a.authPath != wantPath { + t.Errorf("authPath = %q, want %q", a.authPath, wantPath) + } +} + +func assertStaticToken(t *testing.T, auth authenticator, wantToken string) { + t.Helper() + a, ok := auth.(*staticAuthenticator) + if !ok { + t.Fatalf("expected *staticAuthenticator, got %T", auth) + } + if a.tokenID != wantToken { + t.Errorf("tokenID = %q, want %q", a.tokenID, wantToken) + } +} + +// TestNewAuthenticatorEnv covers the env-driven auth source: when VaultOpts +// carries no auth field, the method is selected from environment variables, +// following the priority AppRole > GitHub Actions OIDC > static JWT > token. +func TestNewAuthenticatorEnv(t *testing.T) { + tests := []struct { + name string + env map[string]string + verify func(*testing.T, authenticator) + }{ + { + name: "approle from env wins over other methods", + env: map[string]string{ + "VAULT_ROLE_ID": "env-role", + "VAULT_SECRET_ID": "env-secret", + "WERF_ACTIONS_AUDIENCE": "aud", + "WERF_VAULT_AUTH_JWT": "jwt-token", + }, + verify: func(t *testing.T, a authenticator) { assertAppRole(t, a, "env-role", "env-secret", "ar") }, + }, + { + name: "oidc from env when approle absent", + env: map[string]string{ + "WERF_ACTIONS_AUDIENCE": "my-audience", + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://example.com/token", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token", + "WERF_VAULT_AUTH_ROLE": "ci-role", + "WERF_VAULT_AUTH_JWT": "jwt-token", + }, + verify: func(t *testing.T, a authenticator) { assertOIDC(t, a, "ci-role", "jwt") }, + }, + { + name: "static jwt from env when approle and oidc absent", + env: map[string]string{ + "WERF_VAULT_AUTH_JWT": "my-jwt", + "WERF_VAULT_AUTH_ROLE": "jwt-role", + }, + verify: func(t *testing.T, a authenticator) { assertStaticJWT(t, a, "jwt-role", "jwt") }, + }, + { + name: "token fallback from env when no auth method configured", + env: map[string]string{ + "VAULT_TOKEN": "env-static-token", + }, + verify: func(t *testing.T, a authenticator) { assertStaticToken(t, a, "env-static-token") }, + }, + { + name: "auth path from env applies to approle", + env: map[string]string{ + "VAULT_ROLE_ID": "env-role", + "VAULT_SECRET_ID": "env-secret", + "WERF_VAULT_AUTH_PATH": "custom-approle", + }, + verify: func(t *testing.T, a authenticator) { assertAppRole(t, a, "env-role", "env-secret", "custom-approle") }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearVaultEnv(t) + for k, v := range tt.env { + t.Setenv(k, v) + } + + auth, err := newAuthenticator(newAuthSettings(VaultOpts{})) + if err != nil { + t.Fatalf("newAuthenticator() unexpected error: %v", err) + } + tt.verify(t, auth) + }) + } +} + +// TestNewAuthenticatorEnvTransportOnly proves that transport-only options +// (Address, TransitSecretEnginePath) do not switch off the env auth source. +func TestNewAuthenticatorEnvTransportOnly(t *testing.T) { + transportOpts := []struct { + name string + opts VaultOpts + }{ + {"address only", VaultOpts{Address: "https://vault.example.com"}}, + {"transit path only", VaultOpts{TransitSecretEnginePath: "custom-transit"}}, + {"address and transit path", VaultOpts{Address: "https://vault.example.com", TransitSecretEnginePath: "custom-transit"}}, + } + + for _, tt := range transportOpts { + t.Run(tt.name, func(t *testing.T) { + clearVaultEnv(t) + t.Setenv("VAULT_ROLE_ID", "env-role") + t.Setenv("VAULT_SECRET_ID", "env-secret") + + settings := newAuthSettings(tt.opts) + if settings.fromEnv != true { + t.Fatalf("transport-only opts must keep env source, got fromEnv=%v", settings.fromEnv) + } + auth, err := newAuthenticator(settings) + if err != nil { + t.Fatalf("newAuthenticator() unexpected error: %v", err) + } + assertAppRole(t, auth, "env-role", "env-secret", "ar") + }) + } +} + +// TestNewAuthenticatorOpts covers the programmatic auth source: any non-empty +// auth field switches selection to VaultOpts and ignores env credentials. +func TestNewAuthenticatorOpts(t *testing.T) { + tests := []struct { + name string + opts VaultOpts + env map[string]string + verify func(*testing.T, authenticator) + }{ + { + name: "approle from opts ignores env credentials", + opts: VaultOpts{AuthRoleID: "opts-role", AuthSecretID: "opts-secret"}, + env: map[string]string{"VAULT_ROLE_ID": "env-role", "VAULT_SECRET_ID": "env-secret"}, + verify: func(t *testing.T, a authenticator) { + assertAppRole(t, a, "opts-role", "opts-secret", "ar") + }, + }, + { + name: "oidc from opts audience", + opts: VaultOpts{Audience: "opts-audience", AuthRole: "opts-role"}, + env: map[string]string{ + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://example.com/token", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token", + }, + verify: func(t *testing.T, a authenticator) { assertOIDC(t, a, "opts-role", "jwt") }, + }, + { + name: "static jwt from opts", + opts: VaultOpts{AuthJWT: "opts-jwt", AuthRole: "opts-role"}, + verify: func(t *testing.T, a authenticator) { assertStaticJWT(t, a, "opts-role", "jwt") }, + }, + { + name: "static token from opts", + opts: VaultOpts{Token: "opts-token"}, + verify: func(t *testing.T, a authenticator) { assertStaticToken(t, a, "opts-token") }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearVaultEnv(t) + for k, v := range tt.env { + t.Setenv(k, v) + } + + auth, err := newAuthenticator(newAuthSettings(tt.opts)) + if err != nil { + t.Fatalf("newAuthenticator() unexpected error: %v", err) + } + tt.verify(t, auth) + }) + } +} + +// TestNewAuthenticatorOptsIncomplete proves that incomplete auth options in +// opts-mode return an error and never fall back to VAULT_TOKEN / ~/.vault-token. +func TestNewAuthenticatorOptsIncomplete(t *testing.T) { + tests := []struct { + name string + opts VaultOpts + }{ + {"role id without secret id", VaultOpts{AuthRoleID: "opts-role"}}, + {"secret id without role id", VaultOpts{AuthSecretID: "opts-secret"}}, + {"auth path only", VaultOpts{AuthPath: "custom"}}, + {"auth role only", VaultOpts{AuthRole: "opts-role"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearVaultEnv(t) + // A host token that MUST NOT be picked up in opts-mode. + t.Setenv("VAULT_TOKEN", "host-token-must-not-be-used") + + _, err := newAuthenticator(newAuthSettings(tt.opts)) + if err == nil { + t.Fatal("expected error for incomplete opts, got nil") + } + if !strings.Contains(err.Error(), "incomplete Vault auth options") { + t.Errorf("error = %q, want it to contain %q", err.Error(), "incomplete Vault auth options") + } + }) + } +} + +// TestNewAuthenticatorAuthPath verifies auth-path resolution for AppRole and +// JWT: VaultOpts.AuthPath takes precedence over WERF_VAULT_AUTH_PATH, and the +// "ar"/"jwt" defaults apply when no path is set. +func TestNewAuthenticatorAuthPath(t *testing.T) { + t.Run("opts auth path wins over env for approle", func(t *testing.T) { + clearVaultEnv(t) + t.Setenv("WERF_VAULT_AUTH_PATH", "env-path") + + auth, err := newAuthenticator(newAuthSettings(VaultOpts{ + AuthRoleID: "opts-role", + AuthSecretID: "opts-secret", + AuthPath: "opts-path", + })) + if err != nil { + t.Fatalf("newAuthenticator() unexpected error: %v", err) + } + assertAppRole(t, auth, "opts-role", "opts-secret", "opts-path") + }) + + t.Run("env auth path applies to jwt when opts path absent", func(t *testing.T) { + clearVaultEnv(t) + t.Setenv("WERF_VAULT_AUTH_JWT", "env-jwt") + t.Setenv("WERF_VAULT_AUTH_PATH", "env-jwt-path") + + auth, err := newAuthenticator(newAuthSettings(VaultOpts{})) + if err != nil { + t.Fatalf("newAuthenticator() unexpected error: %v", err) + } + assertStaticJWT(t, auth, "", "env-jwt-path") + }) + + t.Run("default ar path when unset for approle", func(t *testing.T) { + clearVaultEnv(t) + + auth, err := newAuthenticator(newAuthSettings(VaultOpts{ + AuthRoleID: "opts-role", + AuthSecretID: "opts-secret", + })) + if err != nil { + t.Fatalf("newAuthenticator() unexpected error: %v", err) + } + assertAppRole(t, auth, "opts-role", "opts-secret", "ar") + }) + + t.Run("default jwt path when unset for jwt", func(t *testing.T) { + clearVaultEnv(t) + + auth, err := newAuthenticator(newAuthSettings(VaultOpts{AuthJWT: "opts-jwt"})) + if err != nil { + t.Fatalf("newAuthenticator() unexpected error: %v", err) + } + assertStaticJWT(t, auth, "", "jwt") + }) +} From 87c33a356baffceb6be019aedbc15a3f9e9f528c Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 22 Jul 2026 19:04:56 +0300 Subject: [PATCH 03/11] test(signver): verify VaultOpts passthrough via NewSignerVerifier Add Ginkgo specs proving KeyOpts.SignerVerifierOpts.VaultOpts is threaded to the Vault loader for hashivault:// refs (incomplete opts surface the "incomplete Vault auth options" error before any network call), that non-Vault refs take a different path, and that an empty KeyRef is rejected up front. Signed-off-by: Dmitry Mordvinov --- pkg/signver/keyopts_passthrough_test.go | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 pkg/signver/keyopts_passthrough_test.go diff --git a/pkg/signver/keyopts_passthrough_test.go b/pkg/signver/keyopts_passthrough_test.go new file mode 100644 index 0000000..d5bf6f8 --- /dev/null +++ b/pkg/signver/keyopts_passthrough_test.go @@ -0,0 +1,46 @@ +package signver_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/deckhouse/delivery-kit-sdk/pkg/signver" + "github.com/deckhouse/delivery-kit-sdk/pkg/signver/hashivault" +) + +var _ = Describe("KeyOpts SignerVerifierOpts passthrough", func() { + It("routes hashivault key refs through the Vault loader with the provided VaultOpts", func(ctx SpecContext) { + // Incomplete auth options force opts-mode, which fails deterministically + // with a specific error before any Vault client or network call. Reaching + // that error proves KeyOpts.SignerVerifierOpts.VaultOpts is threaded down + // to the Vault authenticator factory. + _, err := signver.NewSignerVerifier(ctx, "", "", signver.KeyOpts{ + KeyRef: hashivault.ReferenceScheme + "some-key", + SignerVerifierOpts: signver.SignerVerifierOpts{ + VaultOpts: hashivault.VaultOpts{AuthRoleID: "role-without-secret"}, + }, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("incomplete Vault auth options")) + }) + + It("does not route non-Vault key refs through the Vault loader", func(ctx SpecContext) { + _, err := signver.NewSignerVerifier(ctx, "", "", signver.KeyOpts{ + KeyRef: "pkcs11:some-token", + SignerVerifierOpts: signver.SignerVerifierOpts{ + VaultOpts: hashivault.VaultOpts{AuthRoleID: "role-without-secret"}, + }, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).NotTo(ContainSubstring("incomplete Vault auth options")) + Expect(err.Error()).To(ContainSubstring("pkcs11")) + }) + + It("rejects an empty key ref before touching any loader", func() { + _, err := signver.NewSignerVerifier(context.Background(), "", "", signver.KeyOpts{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("KeyRef must not be empty")) + }) +}) From 95a7aad2d53c8d58d3db25048fc89ffc68b44446 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 22 Jul 2026 19:05:37 +0300 Subject: [PATCH 04/11] docs(hashivault): document programmatic VaultOpts configuration Add a README section covering KeyOpts.SignerVerifierOpts.VaultOpts: the field-to-env mapping, the transport/auth separation, the all-or-nothing auth source selection, the auth-method priority and auth-path resolution, the incomplete-opts error (no env/file token fallback), and that OIDC still reads the GitHub-injected ACTIONS_ID_TOKEN_REQUEST_* variables. Signed-off-by: Dmitry Mordvinov --- pkg/signver/hashivault/README.md | 46 ++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/pkg/signver/hashivault/README.md b/pkg/signver/hashivault/README.md index cf7a588..702e905 100644 --- a/pkg/signver/hashivault/README.md +++ b/pkg/signver/hashivault/README.md @@ -42,6 +42,52 @@ This solves the 10-minute token expiry issue in GitHub Actions by obtaining a ne > available in GitHub Actions jobs with `id-token: write` permission. > See [GitHub OIDC documentation](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#oidc-token-permissions). +## Programmatic configuration (`VaultOpts`) + +Besides environment variables, the loader can be configured in code through +`KeyOpts.SignerVerifierOpts.VaultOpts` passed to `signver.NewSignerVerifier`. +`VaultOpts` is defined in this package: + +| Field | Env equivalent | Kind | +|---------------------------|------------------------------------------------------|-----------| +| `Address` | `VAULT_ADDR` | Transport | +| `TransitSecretEnginePath` | `TRANSIT_SECRET_ENGINE_PATH` | Transport | +| `Token` | `VAULT_TOKEN` | Auth | +| `AuthRoleID` | `WERF_VAULT_AUTH_ROLE_ID` (or `VAULT_ROLE_ID`) | Auth | +| `AuthSecretID` | `WERF_VAULT_AUTH_SECRET_ID` (or `VAULT_SECRET_ID`) | Auth | +| `AuthRole` | `WERF_VAULT_AUTH_ROLE` | Auth | +| `AuthJWT` | `WERF_VAULT_AUTH_JWT` | Auth | +| `AuthPath` | `WERF_VAULT_AUTH_PATH` | Auth | +| `Audience` | `WERF_ACTIONS_AUDIENCE` | Auth | + +### Transport vs. auth options + +Transport options (`Address`, `TransitSecretEnginePath`) are always resolved +independently, falling back to their environment variables and defaults when +left empty. They never affect how authentication credentials are sourced. + +The **authentication source** is selected all-or-nothing: if no auth field is +set, credentials are read from the environment (identical to the env-only +behavior above); if **any** auth field is set, credentials are taken **only** +from `VaultOpts` and environment auth variables are ignored. This lets you, for +example, set `Address` in code while still authenticating from CI environment +variables. + +The selected auth method follows the same priority as the env flow: AppRole +(both `AuthRoleID` and `AuthSecretID`) > GitHub Actions OIDC (`Audience`) > +static JWT (`AuthJWT`) > static token (`Token`). `AuthPath` takes precedence +over `WERF_VAULT_AUTH_PATH`, and the `ar` / `jwt` defaults still apply when +unset. + +> **Note:** in opts-mode, incomplete auth options (for example, `AuthRoleID` +> without `AuthSecretID`) return an `incomplete Vault auth options` error. The +> loader does **not** silently fall back to `VAULT_TOKEN` or `~/.vault-token`, +> so it never signs under an unexpected host identity. +> +> GitHub Actions OIDC via `Audience` still reads `ACTIONS_ID_TOKEN_REQUEST_URL` +> and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` from the environment, as those are +> injected by GitHub Actions. + ### Authentication flow All authenticators that obtain a Vault token via login (`jwtAuthenticator`, `appRoleAuthenticator`) cache the From 2aa49a87a8367a51dc602322bc1c805f5441db11 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Wed, 22 Jul 2026 23:27:17 +0300 Subject: [PATCH 05/11] feat: explicit auth method selection and error handling by VaultOpts Signed-off-by: Dmitry Mordvinov --- pkg/signver/hashivault/README.md | 100 ++++++++++----- pkg/signver/hashivault/auth_factory.go | 131 +++++++++++++------- pkg/signver/hashivault/auth_factory_test.go | 129 ++++++++++++++----- pkg/signver/hashivault/client.go | 81 +++++++++--- pkg/signver/hashivault/signer.go | 5 +- pkg/signver/keyopts_passthrough_test.go | 12 +- 6 files changed, 332 insertions(+), 126 deletions(-) diff --git a/pkg/signver/hashivault/README.md b/pkg/signver/hashivault/README.md index 702e905..07f630b 100644 --- a/pkg/signver/hashivault/README.md +++ b/pkg/signver/hashivault/README.md @@ -46,19 +46,37 @@ This solves the 10-minute token expiry issue in GitHub Actions by obtaining a ne Besides environment variables, the loader can be configured in code through `KeyOpts.SignerVerifierOpts.VaultOpts` passed to `signver.NewSignerVerifier`. -`VaultOpts` is defined in this package: - -| Field | Env equivalent | Kind | -|---------------------------|------------------------------------------------------|-----------| -| `Address` | `VAULT_ADDR` | Transport | -| `TransitSecretEnginePath` | `TRANSIT_SECRET_ENGINE_PATH` | Transport | -| `Token` | `VAULT_TOKEN` | Auth | -| `AuthRoleID` | `WERF_VAULT_AUTH_ROLE_ID` (or `VAULT_ROLE_ID`) | Auth | -| `AuthSecretID` | `WERF_VAULT_AUTH_SECRET_ID` (or `VAULT_SECRET_ID`) | Auth | -| `AuthRole` | `WERF_VAULT_AUTH_ROLE` | Auth | -| `AuthJWT` | `WERF_VAULT_AUTH_JWT` | Auth | -| `AuthPath` | `WERF_VAULT_AUTH_PATH` | Auth | -| `Audience` | `WERF_ACTIONS_AUDIENCE` | Auth | +`VaultOpts` is defined in this package. + +#### Top-level fields + +| Field | Env equivalent | Kind | +|---------------------------|------------------------------|-----------| +| `Address` | `VAULT_ADDR` | Transport | +| `TransitSecretEnginePath` | `TRANSIT_SECRET_ENGINE_PATH` | Transport | +| `Auth` | — | Auth | + +`Auth` is a `*VaultAuth` that selects **exactly one** authentication method by +setting exactly one of its variant fields. Setting zero or more than one is a +configuration error. + +| `VaultAuth` field | Type | Method | +|-------------------|----------------|---------------------| +| `AppRole` | `*AppRoleAuth` | Vault AppRole | +| `OIDC` | `*OIDCAuth` | GitHub Actions OIDC | +| `JWT` | `*JWTAuth` | Static JWT | +| `Token` | `*TokenAuth` | Static Vault token | + +Per-method fields: + +| Variant | Fields | Env equivalent | +|----------------|---------------------------------------|-------------------------------------------------------------------------------------------------| +| `AppRoleAuth` | `RoleID`, `SecretID`, `Path` | `WERF_VAULT_AUTH_ROLE_ID` (or `VAULT_ROLE_ID`), `WERF_VAULT_AUTH_SECRET_ID` (or `VAULT_SECRET_ID`), `WERF_VAULT_AUTH_PATH` | +| `OIDCAuth` | `Audience`, `Role`, `Path` | `WERF_ACTIONS_AUDIENCE`, `WERF_VAULT_AUTH_ROLE`, `WERF_VAULT_AUTH_PATH` | +| `JWTAuth` | `JWT`, `Role`, `Path` | `WERF_VAULT_AUTH_JWT`, `WERF_VAULT_AUTH_ROLE`, `WERF_VAULT_AUTH_PATH` | +| `TokenAuth` | `Token` | `VAULT_TOKEN` | + +`Path` defaults to `ar` for AppRole and `jwt` for JWT/OIDC when left empty. ### Transport vs. auth options @@ -66,25 +84,45 @@ Transport options (`Address`, `TransitSecretEnginePath`) are always resolved independently, falling back to their environment variables and defaults when left empty. They never affect how authentication credentials are sourced. -The **authentication source** is selected all-or-nothing: if no auth field is -set, credentials are read from the environment (identical to the env-only -behavior above); if **any** auth field is set, credentials are taken **only** -from `VaultOpts` and environment auth variables are ignored. This lets you, for -example, set `Address` in code while still authenticating from CI environment -variables. - -The selected auth method follows the same priority as the env flow: AppRole -(both `AuthRoleID` and `AuthSecretID`) > GitHub Actions OIDC (`Audience`) > -static JWT (`AuthJWT`) > static token (`Token`). `AuthPath` takes precedence -over `WERF_VAULT_AUTH_PATH`, and the `ar` / `jwt` defaults still apply when -unset. - -> **Note:** in opts-mode, incomplete auth options (for example, `AuthRoleID` -> without `AuthSecretID`) return an `incomplete Vault auth options` error. The -> loader does **not** silently fall back to `VAULT_TOKEN` or `~/.vault-token`, -> so it never signs under an unexpected host identity. +The **authentication source** is selected by `Auth`: + +- `Auth == nil` — env-mode: credentials are read from the environment, + identical to the env-only behavior above (including the priority AppRole > + GitHub Actions OIDC > static JWT > static token and the `VAULT_TOKEN` / + `~/.vault-token` fallback). +- `Auth != nil` — opts-mode: credentials are taken **only** from the selected + variant; environment auth variables are ignored. There is no priority chain — + exactly one method must be set explicitly. + +This lets you, for example, set `Address` in code while still authenticating +from CI environment variables (leave `Auth` nil). + +### Example + +```go +sv, err := signver.NewSignerVerifier(ctx, "", "", signver.KeyOpts{ + KeyRef: "hashivault://my-key", + SignerVerifierOpts: signver.SignerVerifierOpts{ + VaultOpts: hashivault.VaultOpts{ + Address: "https://vault.example.com", + Auth: &hashivault.VaultAuth{ + AppRole: &hashivault.AppRoleAuth{ + RoleID: "role", + SecretID: "secret", + Path: "approle", + }, + }, + }, + }, +}) +``` + +> **Note:** in opts-mode, incomplete auth options (for example, `AppRole` with +> `RoleID` but no `SecretID`), no method set, or multiple methods set return an +> error. The loader does **not** silently fall back to `VAULT_TOKEN` or +> `~/.vault-token`, so it never signs under an unexpected host identity. > -> GitHub Actions OIDC via `Audience` still reads `ACTIONS_ID_TOKEN_REQUEST_URL` +> GitHub Actions OIDC (`OIDCAuth`) still reads `ACTIONS_ID_TOKEN_REQUEST_URL` > and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` from the environment, as those are > injected by GitHub Actions. diff --git a/pkg/signver/hashivault/auth_factory.go b/pkg/signver/hashivault/auth_factory.go index 1503911..e2ff7a6 100644 --- a/pkg/signver/hashivault/auth_factory.go +++ b/pkg/signver/hashivault/auth_factory.go @@ -4,65 +4,110 @@ import ( "fmt" ) -type authenticatorSettings struct { - token, roleID, secretID, authPath, audience, authRole, jwtToken string - fromEnv bool +func newAuthenticator(opts VaultOpts) (authenticator, error) { + if opts.Auth == nil { + return newAuthenticatorFromEnv() + } + return newAuthenticatorFromOpts(opts.Auth) } -func newAuthSettings(opts VaultOpts) authenticatorSettings { - if !opts.hasAuthOpts() { - return authenticatorSettings{ - roleID: getVaultAuthRoleId(), - secretID: getVaultAuthSecretId(), - authPath: getVaultAuthPath(), - audience: getActionsAudience(), - authRole: getVaultAuthRole(), - jwtToken: getVaultAuthJwt(), - fromEnv: true, +func newAuthenticatorFromEnv() (authenticator, error) { + roleID := getVaultAuthRoleId() + secretID := getVaultAuthSecretId() + authPath := getVaultAuthPath() + audience := getActionsAudience() + authRole := getVaultAuthRole() + jwtToken := getVaultAuthJwt() + + if roleID != "" && secretID != "" { + return newAppRoleAuthenticator(roleID, secretID, authPath), nil + } else if audience != "" { + requestURL := getActionsIDTokenRequestURL() + requestToken := getActionsIDTokenRequestToken() + if requestURL == "" { + return nil, fmt.Errorf("WERF_ACTIONS_AUDIENCE is set but ACTIONS_ID_TOKEN_REQUEST_URL is missing") + } + if requestToken == "" { + return nil, fmt.Errorf("WERF_ACTIONS_AUDIENCE is set but ACTIONS_ID_TOKEN_REQUEST_TOKEN is missing") } + provider := newActionsOidcJwtTokenProvider(requestURL, requestToken, audience) + return newJWTAuthenticator(provider, authRole, authPath), nil + } else if jwtToken != "" { + provider := newStaticJwtTokenProvider(jwtToken) + return newJWTAuthenticator(provider, authRole, authPath), nil } - return authenticatorSettings{ - token: opts.Token, - roleID: opts.AuthRoleID, - secretID: opts.AuthSecretID, - authPath: opts.AuthPath, - authRole: opts.AuthRole, - jwtToken: opts.AuthJWT, - audience: opts.Audience, - fromEnv: false, + token, err := getVaultToken("") + if err != nil { + return nil, err } + return newStaticAuthProvider(token), nil } -func newAuthenticator(settings authenticatorSettings) (authenticator, error) { - if settings.roleID != "" && settings.secretID != "" { - return newAppRoleAuthenticator(settings.roleID, settings.secretID, settings.authPath), nil - } else if settings.audience != "" { +func newAuthenticatorFromOpts(auth *VaultAuth) (authenticator, error) { + if err := auth.validate(); err != nil { + return nil, err + } + + switch { + case auth.AppRole != nil: + ar := auth.AppRole + if ar.RoleID == "" || ar.SecretID == "" { + return nil, fmt.Errorf("incomplete Vault auth options: AppRole requires both RoleID and SecretID") + } + return newAppRoleAuthenticator(ar.RoleID, ar.SecretID, ar.Path), nil + case auth.OIDC != nil: + o := auth.OIDC + if o.Audience == "" { + return nil, fmt.Errorf("incomplete Vault auth options: OIDC requires Audience") + } requestURL := getActionsIDTokenRequestURL() requestToken := getActionsIDTokenRequestToken() if requestURL == "" { - return nil, fmt.Errorf("WERF_ACTIONS_AUDIENCE is set but ACTIONS_ID_TOKEN_REQUEST_URL is missing") + return nil, fmt.Errorf("OIDC audience is configured but ACTIONS_ID_TOKEN_REQUEST_URL is missing") } if requestToken == "" { - return nil, fmt.Errorf("WERF_ACTIONS_AUDIENCE is set but ACTIONS_ID_TOKEN_REQUEST_TOKEN is missing") + return nil, fmt.Errorf("OIDC audience is configured but ACTIONS_ID_TOKEN_REQUEST_TOKEN is missing") } - provider := newActionsOidcJwtTokenProvider(requestURL, requestToken, settings.audience) - return newJWTAuthenticator(provider, settings.authRole, settings.authPath), nil - } else if settings.jwtToken != "" { - provider := newStaticJwtTokenProvider(settings.jwtToken) - return newJWTAuthenticator(provider, settings.authRole, settings.authPath), nil - } - - if !settings.fromEnv { - if settings.token == "" { - return nil, fmt.Errorf("incomplete Vault auth options: provide a complete AppRole (role id + secret id), a JWT, an OIDC audience, or a token") + provider := newActionsOidcJwtTokenProvider(requestURL, requestToken, o.Audience) + return newJWTAuthenticator(provider, o.Role, o.Path), nil + case auth.JWT != nil: + j := auth.JWT + if j.JWT == "" { + return nil, fmt.Errorf("incomplete Vault auth options: JWT requires JWT") + } + provider := newStaticJwtTokenProvider(j.JWT) + return newJWTAuthenticator(provider, j.Role, j.Path), nil + case auth.Token != nil: + if auth.Token.Token == "" { + return nil, fmt.Errorf("incomplete Vault auth options: Token requires Token") } - return newStaticAuthProvider(settings.token), nil + return newStaticAuthProvider(auth.Token.Token), nil } - token, err := getVaultToken(settings.token) - if err != nil { - return nil, err + return nil, fmt.Errorf("incomplete Vault auth options: set exactly one of AppRole/OIDC/JWT/Token") +} + +// validate ensures exactly one auth method variant is set. +func (a *VaultAuth) validate() error { + n := 0 + if a.AppRole != nil { + n++ } - return newStaticAuthProvider(token), nil + if a.OIDC != nil { + n++ + } + if a.JWT != nil { + n++ + } + if a.Token != nil { + n++ + } + if n == 0 { + return fmt.Errorf("incomplete Vault auth options: set exactly one of AppRole/OIDC/JWT/Token") + } + if n > 1 { + return fmt.Errorf("ambiguous Vault auth options: multiple auth methods set, set exactly one of AppRole/OIDC/JWT/Token") + } + return nil } diff --git a/pkg/signver/hashivault/auth_factory_test.go b/pkg/signver/hashivault/auth_factory_test.go index aa0b026..324f802 100644 --- a/pkg/signver/hashivault/auth_factory_test.go +++ b/pkg/signver/hashivault/auth_factory_test.go @@ -159,7 +159,7 @@ func TestNewAuthenticatorEnv(t *testing.T) { t.Setenv(k, v) } - auth, err := newAuthenticator(newAuthSettings(VaultOpts{})) + auth, err := newAuthenticator(VaultOpts{}) if err != nil { t.Fatalf("newAuthenticator() unexpected error: %v", err) } @@ -169,7 +169,8 @@ func TestNewAuthenticatorEnv(t *testing.T) { } // TestNewAuthenticatorEnvTransportOnly proves that transport-only options -// (Address, TransitSecretEnginePath) do not switch off the env auth source. +// (Address, TransitSecretEnginePath) do not switch off the env auth source: +// with Auth == nil, credentials still come from the environment. func TestNewAuthenticatorEnvTransportOnly(t *testing.T) { transportOpts := []struct { name string @@ -186,11 +187,7 @@ func TestNewAuthenticatorEnvTransportOnly(t *testing.T) { t.Setenv("VAULT_ROLE_ID", "env-role") t.Setenv("VAULT_SECRET_ID", "env-secret") - settings := newAuthSettings(tt.opts) - if settings.fromEnv != true { - t.Fatalf("transport-only opts must keep env source, got fromEnv=%v", settings.fromEnv) - } - auth, err := newAuthenticator(settings) + auth, err := newAuthenticator(tt.opts) if err != nil { t.Fatalf("newAuthenticator() unexpected error: %v", err) } @@ -199,8 +196,8 @@ func TestNewAuthenticatorEnvTransportOnly(t *testing.T) { } } -// TestNewAuthenticatorOpts covers the programmatic auth source: any non-empty -// auth field switches selection to VaultOpts and ignores env credentials. +// TestNewAuthenticatorOpts covers the programmatic auth source: the auth method +// is selected explicitly via VaultOpts.Auth and env credentials are ignored. func TestNewAuthenticatorOpts(t *testing.T) { tests := []struct { name string @@ -210,7 +207,7 @@ func TestNewAuthenticatorOpts(t *testing.T) { }{ { name: "approle from opts ignores env credentials", - opts: VaultOpts{AuthRoleID: "opts-role", AuthSecretID: "opts-secret"}, + opts: VaultOpts{Auth: &VaultAuth{AppRole: &AppRoleAuth{RoleID: "opts-role", SecretID: "opts-secret"}}}, env: map[string]string{"VAULT_ROLE_ID": "env-role", "VAULT_SECRET_ID": "env-secret"}, verify: func(t *testing.T, a authenticator) { assertAppRole(t, a, "opts-role", "opts-secret", "ar") @@ -218,7 +215,7 @@ func TestNewAuthenticatorOpts(t *testing.T) { }, { name: "oidc from opts audience", - opts: VaultOpts{Audience: "opts-audience", AuthRole: "opts-role"}, + opts: VaultOpts{Auth: &VaultAuth{OIDC: &OIDCAuth{Audience: "opts-audience", Role: "opts-role"}}}, env: map[string]string{ "ACTIONS_ID_TOKEN_REQUEST_URL": "https://example.com/token", "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token", @@ -227,12 +224,12 @@ func TestNewAuthenticatorOpts(t *testing.T) { }, { name: "static jwt from opts", - opts: VaultOpts{AuthJWT: "opts-jwt", AuthRole: "opts-role"}, + opts: VaultOpts{Auth: &VaultAuth{JWT: &JWTAuth{JWT: "opts-jwt", Role: "opts-role"}}}, verify: func(t *testing.T, a authenticator) { assertStaticJWT(t, a, "opts-role", "jwt") }, }, { name: "static token from opts", - opts: VaultOpts{Token: "opts-token"}, + opts: VaultOpts{Auth: &VaultAuth{Token: &TokenAuth{Token: "opts-token"}}}, verify: func(t *testing.T, a authenticator) { assertStaticToken(t, a, "opts-token") }, }, } @@ -244,7 +241,7 @@ func TestNewAuthenticatorOpts(t *testing.T) { t.Setenv(k, v) } - auth, err := newAuthenticator(newAuthSettings(tt.opts)) + auth, err := newAuthenticator(tt.opts) if err != nil { t.Fatalf("newAuthenticator() unexpected error: %v", err) } @@ -260,10 +257,12 @@ func TestNewAuthenticatorOptsIncomplete(t *testing.T) { name string opts VaultOpts }{ - {"role id without secret id", VaultOpts{AuthRoleID: "opts-role"}}, - {"secret id without role id", VaultOpts{AuthSecretID: "opts-secret"}}, - {"auth path only", VaultOpts{AuthPath: "custom"}}, - {"auth role only", VaultOpts{AuthRole: "opts-role"}}, + {"approle role id without secret id", VaultOpts{Auth: &VaultAuth{AppRole: &AppRoleAuth{RoleID: "opts-role"}}}}, + {"approle secret id without role id", VaultOpts{Auth: &VaultAuth{AppRole: &AppRoleAuth{SecretID: "opts-secret"}}}}, + {"oidc without audience", VaultOpts{Auth: &VaultAuth{OIDC: &OIDCAuth{Role: "opts-role"}}}}, + {"jwt without token", VaultOpts{Auth: &VaultAuth{JWT: &JWTAuth{Role: "opts-role"}}}}, + {"token without value", VaultOpts{Auth: &VaultAuth{Token: &TokenAuth{}}}}, + {"empty auth with no method", VaultOpts{Auth: &VaultAuth{}}}, } for _, tt := range tests { @@ -272,7 +271,7 @@ func TestNewAuthenticatorOptsIncomplete(t *testing.T) { // A host token that MUST NOT be picked up in opts-mode. t.Setenv("VAULT_TOKEN", "host-token-must-not-be-used") - _, err := newAuthenticator(newAuthSettings(tt.opts)) + _, err := newAuthenticator(tt.opts) if err == nil { t.Fatal("expected error for incomplete opts, got nil") } @@ -283,19 +282,83 @@ func TestNewAuthenticatorOptsIncomplete(t *testing.T) { } } +// TestNewAuthenticatorOptsMultiple proves that setting more than one auth +// method in VaultOpts.Auth is an explicit configuration error, without any +// silent priority-based selection. +func TestNewAuthenticatorOptsMultiple(t *testing.T) { + tests := []struct { + name string + auth *VaultAuth + }{ + {"approle and token", &VaultAuth{AppRole: &AppRoleAuth{RoleID: "r", SecretID: "s"}, Token: &TokenAuth{Token: "t"}}}, + {"jwt and oidc", &VaultAuth{JWT: &JWTAuth{JWT: "j"}, OIDC: &OIDCAuth{Audience: "a"}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearVaultEnv(t) + t.Setenv("VAULT_TOKEN", "host-token-must-not-be-used") + + _, err := newAuthenticator(VaultOpts{Auth: tt.auth}) + if err == nil { + t.Fatal("expected error for multiple auth methods, got nil") + } + if !strings.Contains(err.Error(), "multiple auth methods set") { + t.Errorf("error = %q, want it to contain %q", err.Error(), "multiple auth methods set") + } + }) + } +} + +// TestNewAuthenticatorOptsOIDCMissingActionsEnv proves that opts-mode OIDC +// errors when the GitHub Actions request variables are missing, and that the +// message does not reference the WERF_ACTIONS_AUDIENCE env variable since the +// audience came from code. +func TestNewAuthenticatorOptsOIDCMissingActionsEnv(t *testing.T) { + tests := []struct { + name string + env map[string]string + }{ + {"missing request url", map[string]string{"ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token"}}, + {"missing request token", map[string]string{"ACTIONS_ID_TOKEN_REQUEST_URL": "https://example.com/token"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearVaultEnv(t) + for k, v := range tt.env { + t.Setenv(k, v) + } + + _, err := newAuthenticator(VaultOpts{Auth: &VaultAuth{OIDC: &OIDCAuth{Audience: "opts-audience"}}}) + if err == nil { + t.Fatal("expected error for missing Actions OIDC env, got nil") + } + if !strings.Contains(err.Error(), "OIDC audience is configured") { + t.Errorf("error = %q, want it to contain %q", err.Error(), "OIDC audience is configured") + } + if strings.Contains(err.Error(), "WERF_ACTIONS_AUDIENCE") { + t.Errorf("error = %q, must not reference WERF_ACTIONS_AUDIENCE in opts-mode", err.Error()) + } + }) + } +} + // TestNewAuthenticatorAuthPath verifies auth-path resolution for AppRole and -// JWT: VaultOpts.AuthPath takes precedence over WERF_VAULT_AUTH_PATH, and the -// "ar"/"jwt" defaults apply when no path is set. +// JWT: VaultOpts auth Path takes precedence over WERF_VAULT_AUTH_PATH in +// env-mode, and the "ar"/"jwt" defaults apply when no path is set. func TestNewAuthenticatorAuthPath(t *testing.T) { t.Run("opts auth path wins over env for approle", func(t *testing.T) { clearVaultEnv(t) t.Setenv("WERF_VAULT_AUTH_PATH", "env-path") - auth, err := newAuthenticator(newAuthSettings(VaultOpts{ - AuthRoleID: "opts-role", - AuthSecretID: "opts-secret", - AuthPath: "opts-path", - })) + auth, err := newAuthenticator(VaultOpts{ + Auth: &VaultAuth{AppRole: &AppRoleAuth{ + RoleID: "opts-role", + SecretID: "opts-secret", + Path: "opts-path", + }}, + }) if err != nil { t.Fatalf("newAuthenticator() unexpected error: %v", err) } @@ -307,7 +370,7 @@ func TestNewAuthenticatorAuthPath(t *testing.T) { t.Setenv("WERF_VAULT_AUTH_JWT", "env-jwt") t.Setenv("WERF_VAULT_AUTH_PATH", "env-jwt-path") - auth, err := newAuthenticator(newAuthSettings(VaultOpts{})) + auth, err := newAuthenticator(VaultOpts{}) if err != nil { t.Fatalf("newAuthenticator() unexpected error: %v", err) } @@ -317,10 +380,12 @@ func TestNewAuthenticatorAuthPath(t *testing.T) { t.Run("default ar path when unset for approle", func(t *testing.T) { clearVaultEnv(t) - auth, err := newAuthenticator(newAuthSettings(VaultOpts{ - AuthRoleID: "opts-role", - AuthSecretID: "opts-secret", - })) + auth, err := newAuthenticator(VaultOpts{ + Auth: &VaultAuth{AppRole: &AppRoleAuth{ + RoleID: "opts-role", + SecretID: "opts-secret", + }}, + }) if err != nil { t.Fatalf("newAuthenticator() unexpected error: %v", err) } @@ -330,7 +395,7 @@ func TestNewAuthenticatorAuthPath(t *testing.T) { t.Run("default jwt path when unset for jwt", func(t *testing.T) { clearVaultEnv(t) - auth, err := newAuthenticator(newAuthSettings(VaultOpts{AuthJWT: "opts-jwt"})) + auth, err := newAuthenticator(VaultOpts{Auth: &VaultAuth{JWT: &JWTAuth{JWT: "opts-jwt"}}}) if err != nil { t.Fatalf("newAuthenticator() unexpected error: %v", err) } diff --git a/pkg/signver/hashivault/client.go b/pkg/signver/hashivault/client.go index f961ba9..d293d66 100644 --- a/pkg/signver/hashivault/client.go +++ b/pkg/signver/hashivault/client.go @@ -44,24 +44,77 @@ type hashivaultClient struct { originalHashFunc crypto.Hash } +// VaultOpts configures the Vault SignerVerifier programmatically. +// +// Transport options (Address, TransitSecretEnginePath) are always resolved +// independently, falling back to environment variables and defaults when empty. +// +// Auth selects the authentication method: when nil, credentials are read from +// the environment (env-mode); when set, credentials are taken ONLY from Auth +// and the environment is never consulted for credentials (except the GitHub +// Actions OIDC request URL/token, which are always injected by the runner). type VaultOpts struct { - Address string - Token string + // Address is the Vault server address (env: VAULT_ADDR). + Address string + // TransitSecretEnginePath is the transit engine mount path + // (env: TRANSIT_SECRET_ENGINE_PATH, default "transit"). TransitSecretEnginePath string - AuthJWT string - AuthPath string - AuthRole string - AuthRoleID string - AuthSecretID string - Audience string + // Auth selects the programmatic auth method. When nil, auth is resolved + // from the environment. + Auth *VaultAuth +} + +// VaultAuth selects exactly one authentication method. Exactly one field must +// be non-nil; setting zero or more than one is a configuration error. +type VaultAuth struct { + // AppRole authenticates via Vault AppRole. + AppRole *AppRoleAuth + // OIDC authenticates via GitHub Actions OIDC. + OIDC *OIDCAuth + // JWT authenticates via a static JWT. + JWT *JWTAuth + // Token authenticates via a static Vault token. + Token *TokenAuth +} + +// AppRoleAuth authenticates via Vault AppRole. RoleID and SecretID are both +// required. +type AppRoleAuth struct { + // RoleID is the AppRole role id. + RoleID string + // SecretID is the AppRole secret id. + SecretID string + // Path is the AppRole auth mount path. Defaults to "ar" when empty. + Path string +} + +// OIDCAuth authenticates via GitHub Actions OIDC. Audience is required; the +// request URL and token are always read from the ACTIONS_ID_TOKEN_REQUEST_URL +// and ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variables injected by GitHub +// Actions. +type OIDCAuth struct { + // Audience is the OIDC token audience. + Audience string + // Role is the Vault JWT/OIDC role. + Role string + // Path is the JWT auth mount path. Defaults to "jwt" when empty. + Path string +} + +// JWTAuth authenticates via a static JWT. +type JWTAuth struct { + // JWT is the raw JWT to authenticate with. + JWT string + // Role is the Vault JWT role. + Role string + // Path is the JWT auth mount path. Defaults to "jwt" when empty. + Path string } -// hasAuthOpts reports whether the caller provided any explicit auth field. -// Transport-only options (Address, TransitSecretEnginePath) do NOT count, -// so env-based auth keeps working when only the address is set in code. -func (o VaultOpts) hasAuthOpts() bool { - return o.Token != "" || o.AuthRoleID != "" || o.AuthSecretID != "" || - o.AuthJWT != "" || o.AuthRole != "" || o.AuthPath != "" || o.Audience != "" +// TokenAuth authenticates via a static Vault token. +type TokenAuth struct { + // Token is the Vault token. + Token string } const ( diff --git a/pkg/signver/hashivault/signer.go b/pkg/signver/hashivault/signer.go index 636db79..f056b8f 100644 --- a/pkg/signver/hashivault/signer.go +++ b/pkg/signver/hashivault/signer.go @@ -87,10 +87,7 @@ func loadSignerVerifier(referenceStr string, hashFunc crypto.Hash, opts VaultOpt return nil, errors.New("hash function not supported by Hashivault") } - authSettings := newAuthSettings(opts) - - var err error - auth, err := newAuthenticator(authSettings) + auth, err := newAuthenticator(opts) if err != nil { return nil, err } diff --git a/pkg/signver/keyopts_passthrough_test.go b/pkg/signver/keyopts_passthrough_test.go index d5bf6f8..f0c841d 100644 --- a/pkg/signver/keyopts_passthrough_test.go +++ b/pkg/signver/keyopts_passthrough_test.go @@ -19,7 +19,11 @@ var _ = Describe("KeyOpts SignerVerifierOpts passthrough", func() { _, err := signver.NewSignerVerifier(ctx, "", "", signver.KeyOpts{ KeyRef: hashivault.ReferenceScheme + "some-key", SignerVerifierOpts: signver.SignerVerifierOpts{ - VaultOpts: hashivault.VaultOpts{AuthRoleID: "role-without-secret"}, + VaultOpts: hashivault.VaultOpts{ + Auth: &hashivault.VaultAuth{ + AppRole: &hashivault.AppRoleAuth{RoleID: "role-without-secret"}, + }, + }, }, }) Expect(err).To(HaveOccurred()) @@ -30,7 +34,11 @@ var _ = Describe("KeyOpts SignerVerifierOpts passthrough", func() { _, err := signver.NewSignerVerifier(ctx, "", "", signver.KeyOpts{ KeyRef: "pkcs11:some-token", SignerVerifierOpts: signver.SignerVerifierOpts{ - VaultOpts: hashivault.VaultOpts{AuthRoleID: "role-without-secret"}, + VaultOpts: hashivault.VaultOpts{ + Auth: &hashivault.VaultAuth{ + AppRole: &hashivault.AppRoleAuth{RoleID: "role-without-secret"}, + }, + }, }, }) Expect(err).To(HaveOccurred()) From 9608b8f09d45508b324944df37d0d4f5ffef2729 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 23 Jul 2026 12:54:08 +0300 Subject: [PATCH 06/11] fix(hashivault): install configured token on static auth login staticAuthenticator.Login was a no-op that never called client.SetToken, so in opts-mode a configured TokenAuth was silently dropped: the Vault SDK auto-loads VAULT_TOKEN at client construction, so requests were signed under the environment token (wrong identity) or, with VAULT_TOKEN unset, sent unauthenticated. This violated the opts-mode guarantee that credentials come only from the selected variant and never from the host environment. Login now calls client.SetToken(s.tokenID) on every invocation, which also fixes the pre-existing latent ~/.vault-token env-mode path. Add a Login-level regression test on a real *vault.Client covering both a conflicting VAULT_TOKEN and an unset VAULT_TOKEN; existing tests only asserted the constructed tokenID field and never exercised Login, so the bug was invisible to CI. Signed-off-by: Dmitry Mordvinov --- pkg/signver/hashivault/auth_factory_test.go | 50 +++++++++++++++++++++ pkg/signver/hashivault/auth_static.go | 3 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/pkg/signver/hashivault/auth_factory_test.go b/pkg/signver/hashivault/auth_factory_test.go index 324f802..91c79fb 100644 --- a/pkg/signver/hashivault/auth_factory_test.go +++ b/pkg/signver/hashivault/auth_factory_test.go @@ -4,6 +4,8 @@ import ( "os" "strings" "testing" + + vault "github.com/hashicorp/vault/api" ) var vaultEnvKeys = []string{ @@ -402,3 +404,51 @@ func TestNewAuthenticatorAuthPath(t *testing.T) { assertStaticJWT(t, auth, "", "jwt") }) } + +// TestStaticAuthenticatorLoginInstallsToken verifies that a static token +// authenticator installs its configured token onto the Vault client on every +// login, overriding any VAULT_TOKEN the SDK auto-loaded at client construction. +// This guards the opts-mode guarantee that credentials come only from the +// selected variant and never from the host environment. +func TestStaticAuthenticatorLoginInstallsToken(t *testing.T) { + t.Run("overrides conflicting VAULT_TOKEN", func(t *testing.T) { + clearVaultEnv(t) + t.Setenv("VAULT_TOKEN", "env-token") + + client, err := vault.NewClient(nil) + if err != nil { + t.Fatalf("vault.NewClient() unexpected error: %v", err) + } + if got := client.Token(); got != "env-token" { + t.Fatalf("precondition: client token = %q, want %q", got, "env-token") + } + + auth := newStaticAuthProvider("configured-token") + if err := auth.Login(client); err != nil { + t.Fatalf("Login() unexpected error: %v", err) + } + if got := client.Token(); got != "configured-token" { + t.Errorf("client token = %q, want %q", got, "configured-token") + } + }) + + t.Run("installs token when VAULT_TOKEN unset", func(t *testing.T) { + clearVaultEnv(t) + + client, err := vault.NewClient(nil) + if err != nil { + t.Fatalf("vault.NewClient() unexpected error: %v", err) + } + if got := client.Token(); got != "" { + t.Fatalf("precondition: client token = %q, want empty", got) + } + + auth := newStaticAuthProvider("configured-token") + if err := auth.Login(client); err != nil { + t.Fatalf("Login() unexpected error: %v", err) + } + if got := client.Token(); got != "configured-token" { + t.Errorf("client token = %q, want %q", got, "configured-token") + } + }) +} diff --git a/pkg/signver/hashivault/auth_static.go b/pkg/signver/hashivault/auth_static.go index d1efcf7..486e2a5 100644 --- a/pkg/signver/hashivault/auth_static.go +++ b/pkg/signver/hashivault/auth_static.go @@ -20,6 +20,7 @@ func newStaticAuthProvider(token string) *staticAuthenticator { } } -func (s *staticAuthenticator) Login(_ *vault.Client) error { +func (s *staticAuthenticator) Login(client *vault.Client) error { + client.SetToken(s.tokenID) return nil } From f090c34f8b00321929bc57c116e399fbb918d9da Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 23 Jul 2026 13:48:39 +0300 Subject: [PATCH 07/11] fix(hashivault): clear auto-loaded VAULT_TOKEN to prevent host token leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vault.NewClient auto-loads VAULT_TOKEN from the environment. For AppRole/JWT/OIDC in opts-mode, baseAuthenticator.login writes to /auth//login before SetToken, leaking the host token in the X-Vault-Token header of the login request — violating the documented opts-mode isolation guarantee. Clear the token right after client construction; each authenticator installs its own token before signing. Add a regression test asserting the login request carries no host token, and correct the README's JWT-source note for opts-mode. Signed-off-by: Dmitry Mordvinov --- pkg/signver/hashivault/README.md | 2 +- pkg/signver/hashivault/client.go | 6 ++ pkg/signver/hashivault/token_leak_test.go | 90 +++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 pkg/signver/hashivault/token_leak_test.go diff --git a/pkg/signver/hashivault/README.md b/pkg/signver/hashivault/README.md index 07f630b..62a9fa6 100644 --- a/pkg/signver/hashivault/README.md +++ b/pkg/signver/hashivault/README.md @@ -147,7 +147,7 @@ No extra HTTP requests between Vault operations, only the operation itself. sign() / verify() / public() → auth.Login() ├─ isTokenValid() = false → obtain new credentials │ ├─ AppRole: role_id + secret_id (no additional calls) - │ ├─ JWT (static): cached JWT from WERF_VAULT_AUTH_JWT (no additional calls) + │ ├─ JWT (static): cached JWT from WERF_VAULT_AUTH_JWT (env-mode) or JWTAuth.JWT (opts-mode) (no additional calls) │ └─ JWT (GitHub Actions): GET ACTIONS_ID_TOKEN_REQUEST_URL → fresh JWT ├─ POST /auth/jwt/login (or /auth/ar/login) → new Vault token │ cached as token_id with TTL from response diff --git a/pkg/signver/hashivault/client.go b/pkg/signver/hashivault/client.go index d293d66..071df80 100644 --- a/pkg/signver/hashivault/client.go +++ b/pkg/signver/hashivault/client.go @@ -143,6 +143,12 @@ func newHashivaultClient(auth authenticator, address, transitSecretEnginePath, k return nil, fmt.Errorf("new vault client: %w", err) } + // vault.NewClient auto-loads VAULT_TOKEN from the environment. Clear it so a + // host token never leaks in the X-Vault-Token header of AppRole/JWT/OIDC + // login requests; each authenticator installs its own token via SetToken + // before performing signing operations. + client.ClearToken() + hvClient := &hashivaultClient{ client: client, keyPath: keyPath, diff --git a/pkg/signver/hashivault/token_leak_test.go b/pkg/signver/hashivault/token_leak_test.go new file mode 100644 index 0000000..eb8909a --- /dev/null +++ b/pkg/signver/hashivault/token_leak_test.go @@ -0,0 +1,90 @@ +package hashivault + +import ( + "crypto" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// TestLoginDoesNotLeakHostToken proves that when the SDK auto-loads a host +// VAULT_TOKEN at client construction, that token never appears in the +// X-Vault-Token header of a login request. The leak path is that +// baseAuthenticator.login writes to /auth//login before SetToken, so an +// un-cleared host token would be attached to that first request. AppRole and +// JWT (static and GitHub Actions OIDC) all funnel through baseAuthenticator.login, +// so covering AppRole and static JWT exercises the shared leak path. +func TestLoginDoesNotLeakHostToken(t *testing.T) { + const hostToken = "host-token-must-not-leak" + const keyResourceID = "hashivault://transit-key" + + tests := []struct { + name string + wantLoginPath string + newAuth func() authenticator + }{ + { + name: "approle", + wantLoginPath: "/v1/auth/ar/login", + newAuth: func() authenticator { + return newAppRoleAuthenticator("role-id", "secret-id", "") + }, + }, + { + name: "static jwt", + wantLoginPath: "/v1/auth/jwt/login", + newAuth: func() authenticator { + return newJWTAuthenticator(newStaticJwtTokenProvider("static-jwt"), "jwt-role", "") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearVaultEnv(t) + t.Setenv("VAULT_TOKEN", hostToken) + + var gotHeader string + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotHeader = r.Header.Get("X-Vault-Token") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "auth": map[string]interface{}{ + "client_token": "new-vault-token", + "lease_duration": 3600, + }, + }) + })) + defer srv.Close() + + client, err := newHashivaultClient(tt.newAuth(), srv.URL, "transit", keyResourceID, 0, crypto.SHA256) + if err != nil { + t.Fatalf("newHashivaultClient() unexpected error: %v", err) + } + + if got := client.client.Token(); got != "" { + t.Fatalf("client token after construction = %q, want empty (host token must be cleared)", got) + } + + if err := client.auth.Login(client.client); err != nil { + t.Fatalf("Login() unexpected error: %v", err) + } + + if gotPath != tt.wantLoginPath { + t.Fatalf("login request path = %q, want %q", gotPath, tt.wantLoginPath) + } + if gotHeader == hostToken { + t.Errorf("login request leaked host token in X-Vault-Token header: %q", gotHeader) + } + if gotHeader != "" { + t.Errorf("login request X-Vault-Token = %q, want empty", gotHeader) + } + if got := client.client.Token(); got != "new-vault-token" { + t.Errorf("client token after login = %q, want %q", got, "new-vault-token") + } + }) + } +} From 33f8de1f40a7cdda2552a9be4eb33e7dd6a1561c Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 23 Jul 2026 14:10:02 +0300 Subject: [PATCH 08/11] refactor: add default case for VaultAuth validation Signed-off-by: Dmitry Mordvinov --- pkg/signver/hashivault/auth_factory.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/signver/hashivault/auth_factory.go b/pkg/signver/hashivault/auth_factory.go index e2ff7a6..ab74797 100644 --- a/pkg/signver/hashivault/auth_factory.go +++ b/pkg/signver/hashivault/auth_factory.go @@ -83,9 +83,10 @@ func newAuthenticatorFromOpts(auth *VaultAuth) (authenticator, error) { return nil, fmt.Errorf("incomplete Vault auth options: Token requires Token") } return newStaticAuthProvider(auth.Token.Token), nil + default: + return nil, fmt.Errorf("incomplete Vault auth options: set exactly one of AppRole/OIDC/JWT/Token") } - return nil, fmt.Errorf("incomplete Vault auth options: set exactly one of AppRole/OIDC/JWT/Token") } // validate ensures exactly one auth method variant is set. From ad0c19b06a26a7d68f6e1df69e25f0cb8ca48dc6 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 23 Jul 2026 15:04:26 +0300 Subject: [PATCH 09/11] refactor: public ValidReference function Signed-off-by: Dmitry Mordvinov --- pkg/signver/hashivault/client.go | 2 +- pkg/signver/hashivault/reference.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/signver/hashivault/client.go b/pkg/signver/hashivault/client.go index 071df80..bd73d8d 100644 --- a/pkg/signver/hashivault/client.go +++ b/pkg/signver/hashivault/client.go @@ -123,7 +123,7 @@ const ( ) func newHashivaultClient(auth authenticator, address, transitSecretEnginePath, keyResourceID string, keyVersion uint64, originalHashFunc crypto.Hash) (*hashivaultClient, error) { - if err := validReference(keyResourceID); err != nil { + if err := ValidReference(keyResourceID); err != nil { return nil, err } diff --git a/pkg/signver/hashivault/reference.go b/pkg/signver/hashivault/reference.go index 0b26648..4c54b85 100644 --- a/pkg/signver/hashivault/reference.go +++ b/pkg/signver/hashivault/reference.go @@ -17,8 +17,8 @@ const ( ReferenceScheme = "hashivault://" ) -// validReference returns a non-nil error if the reference string is invalid -func validReference(ref string) error { +// ValidReference returns a non-nil error if the reference string is invalid +func ValidReference(ref string) error { if !referenceRegex.MatchString(ref) { return errReference } From 480daa991d7ef29cb6ac717db4d22f8da53889a6 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Thu, 23 Jul 2026 18:02:37 +0300 Subject: [PATCH 10/11] fix: format auth factory Signed-off-by: Dmitry Mordvinov --- pkg/signver/hashivault/auth_factory.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/signver/hashivault/auth_factory.go b/pkg/signver/hashivault/auth_factory.go index ab74797..b9e40dd 100644 --- a/pkg/signver/hashivault/auth_factory.go +++ b/pkg/signver/hashivault/auth_factory.go @@ -86,7 +86,6 @@ func newAuthenticatorFromOpts(auth *VaultAuth) (authenticator, error) { default: return nil, fmt.Errorf("incomplete Vault auth options: set exactly one of AppRole/OIDC/JWT/Token") } - } // validate ensures exactly one auth method variant is set. From 687005b18abeb52e0e88da853b81952a151b6da5 Mon Sep 17 00:00:00 2001 From: Dmitry Mordvinov Date: Fri, 24 Jul 2026 21:25:50 +0300 Subject: [PATCH 11/11] feat(hashivault): rename unused RPCOption arg Signed-off-by: Dmitry Mordvinov --- pkg/signver/hashivault/signer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/signver/hashivault/signer.go b/pkg/signver/hashivault/signer.go index f056b8f..3342c46 100644 --- a/pkg/signver/hashivault/signer.go +++ b/pkg/signver/hashivault/signer.go @@ -68,7 +68,7 @@ type SignerVerifier struct { // // It also can verify signatures (via a remote vall to the Vault instance). The hashFunc will be // automatically set to crypto.Hash(0) if the key referred to by referenceStr is an ED25519 signing key. -func LoadSignerVerifier(referenceStr string, hashFunc crypto.Hash, opts ...signature.RPCOption) (*SignerVerifier, error) { +func LoadSignerVerifier(referenceStr string, hashFunc crypto.Hash, _ ...signature.RPCOption) (*SignerVerifier, error) { return loadSignerVerifier(referenceStr, hashFunc, VaultOpts{}) }