From 336a745f41bf15522fa8865844120c1cf2a1e0ca Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sat, 2 May 2026 12:53:58 +0000 Subject: [PATCH] secret: migrate EJBCA / GlobalSign / Sectigo credentials to *secret.Ref (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the #6 acquisition-readiness fix from the 2026-05-01 issuer coverage audit. Phase 1 (commit 520cda3) shipped the secret.Ref opaque credential type with PBKDF2-derived key, ChaCha20-Poly1305 envelope, String/MarshalJSON redaction to "[redacted]", and the Use callback that zero-fills the per-call buffer after the consumer returns. This commit applies the type to the three connectors flagged by the audit and adds the JSON-roundtrip glue that the production factory path needs. Shared (internal/secret/): - Add UnmarshalJSON on *Ref so json.Unmarshal of a stored config blob (issuerfactory.NewFromConfig) parses the bytes-as-string into NewRefFromString without callers having to know the field type changed. Null and missing keys leave the receiver nil; non-string payloads (numbers, bools) are rejected with a typed error. Pinned by TestRef_UnmarshalJSON: string_value, null, missing_key, number_rejected, roundtrip_marshal_then_unmarshal (the round-trip goes through "[redacted]" intentionally — JSON-marshal-then- unmarshal of a Config with secrets is NOT a supported test pattern; callers that construct a rawConfig must use a JSON literal with the real values). Per-connector migration: - EJBCA (ejbca.go): Config.Token: string → *secret.Ref. ValidateConfig empty-check uses Token.IsEmpty() (nil-safe). setAuthHeaders rewritten to call Token.Use; the Bearer header string is built inside the callback and the buffer is zeroed on return. mTLS path is unaffected. - GlobalSign (globalsign.go): Config.APIKey + Config.APISecret: string → *secret.Ref. Both ValidateConfig empty-checks use IsEmpty(). Extracted setAuthHeaders helper consolidates the four duplicated triple-Set sites (ValidateConfig probe, IssueCertificate, RevokeCertificate, pollCertificateOnce) so any future header-shape change applies once. ValidateConfig now pulls from the local cfg (post-Unmarshal) so the helper takes a *Config rather than the receiver — needed because ValidateConfig writes the validated cfg onto c.config only AFTER the probe succeeds. - Sectigo (sectigo.go): Config.Login + Config.Password: string → *secret.Ref. CustomerURI stays plain string (org identifier, not a credential). setAuthHeaders rewritten to call Login.Use + Password.Use; ValidateConfig's inline header writes use the same pattern (the ValidateConfig probe writes to a local cfg, not c.config, so it can't share setAuthHeaders without rewiring — the inline form is fine, kept consistent in shape). Test migration: - ejbca_test.go, ejbca_failure_test.go, ejbca_stubs_test.go: bulk Token: "X" → Token: secret.NewRefFromString("X") via sed; secret import added. - globalsign_test.go, globalsign_failure_test.go: same pattern for APIKey + APISecret. - sectigo_test.go, sectigo_failure_test.go: same pattern for Login + Password. Two tests (TestGlobalSign_ServerTLSConfig/PinnedCA_TrustsExpectedServer and TestSectigoConnector/ValidateConfig_Success) used to construct rawConfig via json.Marshal(config) → ValidateConfig(rawConfig). After the migration, json.Marshal redacts *secret.Ref to "[redacted]" by design, so the roundtripped rawConfig wrote "[redacted]" as the actual header value and the mock server's auth-header check 403'd. Both tests now build rawConfig as a JSON literal (the production- shape input — the factory path always feeds rawConfig from the DB or env, never from json.Marshal of an in-memory Config). The new tests have a comment explaining the trap so the next person who adds a similar test sees the pattern. Out of scope (intentional): - The `internal/config/config.SectigoConfig` / `GlobalSignConfig` / `EJBCAConfig` env-var-loader structs are still plain strings — those types are the env-load shape, not the steady-state runtime shape. The seed path in service/issuer.go json-marshals them into a map[string]interface{} which the factory then UnmarshalJSON's into the connector Config; the new UnmarshalJSON on *Ref handles the conversion at the boundary. - DigiCert.APIKey + Vault.Token are still plain strings; Phase 3 will pick them up. The audit explicitly named EJBCA / GlobalSign / Sectigo as the Phase 2 scope (RESULTS.md L633). Verified locally: - gofmt -l . clean - go vet ./... clean - staticcheck across all four packages clean - go test -short -count=1 across secret, ejbca, globalsign, sectigo, issuerfactory, service, api/handler: green Audit reference: cowork/issuer-coverage-audit-2026-05-01/RESULTS.md Top-10 fix #6 — Phase 2. --- internal/connector/issuer/ejbca/ejbca.go | 25 ++++- .../issuer/ejbca/ejbca_failure_test.go | 3 +- .../issuer/ejbca/ejbca_stubs_test.go | 9 +- internal/connector/issuer/ejbca/ejbca_test.go | 29 +++--- .../connector/issuer/globalsign/globalsign.go | 58 ++++++++---- .../globalsign/globalsign_failure_test.go | 13 +-- .../issuer/globalsign/globalsign_test.go | 82 +++++++++-------- internal/connector/issuer/sectigo/sectigo.go | 49 ++++++++-- .../issuer/sectigo/sectigo_failure_test.go | 5 +- .../connector/issuer/sectigo/sectigo_test.go | 91 +++++++++---------- internal/secret/secret.go | 23 +++++ internal/secret/secret_test.go | 68 ++++++++++++++ 12 files changed, 316 insertions(+), 139 deletions(-) diff --git a/internal/connector/issuer/ejbca/ejbca.go b/internal/connector/issuer/ejbca/ejbca.go index ec23b22..b2611af 100644 --- a/internal/connector/issuer/ejbca/ejbca.go +++ b/internal/connector/issuer/ejbca/ejbca.go @@ -33,6 +33,7 @@ import ( "time" "github.com/shankar0123/certctl/internal/connector/issuer" + "github.com/shankar0123/certctl/internal/secret" ) // Config represents the EJBCA issuer connector configuration. @@ -55,7 +56,15 @@ type Config struct { // Token is the OAuth2 Bearer token for authentication. // Required when auth_mode=oauth2. Set via CERTCTL_EJBCA_TOKEN environment variable. - Token string `json:"token"` + // + // Type: *secret.Ref (audit fix #6 Phase 2). Wrapping the token in + // a Ref means: it never stringifies (Config marshals as + // "[redacted]"), the bytes are zeroed after each Use/WriteTo + // invocation (defeats heap-dump extraction), and outbound HTTP + // header writes go through Ref.WriteTo so the staging buffer is + // short-lived. JSON unmarshal of a string value populates the + // Ref via NewRefFromString. + Token *secret.Ref `json:"token"` // CAName is the EJBCA CA name for certificate issuance. // Required. Set via CERTCTL_EJBCA_CA_NAME environment variable. @@ -185,7 +194,7 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag return fmt.Errorf("EJBCA client_key_path is required for auth_mode=mtls") } case "oauth2": - if cfg.Token == "" { + if cfg.Token.IsEmpty() { return fmt.Errorf("EJBCA token is required for auth_mode=oauth2") } default: @@ -520,10 +529,16 @@ func (c *Connector) GetRenewalInfo(ctx context.Context, certPEM string) (*issuer return nil, nil } -// setAuthHeaders sets the appropriate authentication headers based on configured auth mode. +// setAuthHeaders sets the appropriate authentication headers based on +// configured auth mode. For OAuth2, the Bearer token is fetched from +// the *secret.Ref via Use; the staging buffer is zeroed after the +// header value is constructed (audit fix #6 Phase 2). func (c *Connector) setAuthHeaders(req *http.Request) { - if c.config.AuthMode == "oauth2" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.config.Token)) + if c.config.AuthMode == "oauth2" && c.config.Token != nil { + _ = c.config.Token.Use(func(buf []byte) error { + req.Header.Set("Authorization", "Bearer "+string(buf)) + return nil + }) } // mTLS is handled via http.Client with tls.Config } diff --git a/internal/connector/issuer/ejbca/ejbca_failure_test.go b/internal/connector/issuer/ejbca/ejbca_failure_test.go index 65d33af..d85f335 100644 --- a/internal/connector/issuer/ejbca/ejbca_failure_test.go +++ b/internal/connector/issuer/ejbca/ejbca_failure_test.go @@ -13,6 +13,7 @@ import ( "github.com/shankar0123/certctl/internal/connector/issuer" "github.com/shankar0123/certctl/internal/connector/issuer/ejbca" + "github.com/shankar0123/certctl/internal/secret" ) // Bundle N.A/B-extended: ejbca failure-mode round-out (76.5% → ≥85%). @@ -24,7 +25,7 @@ func buildEJBCAConnector(t *testing.T, baseURL string) *ejbca.Connector { cfg := &ejbca.Config{ APIUrl: baseURL, AuthMode: "oauth2", - Token: "tok", + Token: secret.NewRefFromString("tok"), CAName: "TestCA", CertProfile: "TestProfile", EEProfile: "TestEEProfile", diff --git a/internal/connector/issuer/ejbca/ejbca_stubs_test.go b/internal/connector/issuer/ejbca/ejbca_stubs_test.go index 7017444..69b26c7 100644 --- a/internal/connector/issuer/ejbca/ejbca_stubs_test.go +++ b/internal/connector/issuer/ejbca/ejbca_stubs_test.go @@ -14,6 +14,7 @@ import ( "testing" "github.com/shankar0123/certctl/internal/connector/issuer" + "github.com/shankar0123/certctl/internal/secret" ) func quietStubLogger() *slog.Logger { @@ -21,7 +22,7 @@ func quietStubLogger() *slog.Logger { } func TestStub_GenerateCRL(t *testing.T) { - c, err := New(&Config{AuthMode: "oauth2", Token: "dummy"}, quietStubLogger()) + c, err := New(&Config{AuthMode: "oauth2", Token: secret.NewRefFromString("dummy")}, quietStubLogger()) if err != nil { t.Fatalf("New: %v", err) } @@ -32,7 +33,7 @@ func TestStub_GenerateCRL(t *testing.T) { } func TestStub_SignOCSPResponse(t *testing.T) { - c, err := New(&Config{AuthMode: "oauth2", Token: "dummy"}, quietStubLogger()) + c, err := New(&Config{AuthMode: "oauth2", Token: secret.NewRefFromString("dummy")}, quietStubLogger()) if err != nil { t.Fatalf("New: %v", err) } @@ -43,7 +44,7 @@ func TestStub_SignOCSPResponse(t *testing.T) { } func TestStub_GetCACertPEM(t *testing.T) { - c, err := New(&Config{AuthMode: "oauth2", Token: "dummy"}, quietStubLogger()) + c, err := New(&Config{AuthMode: "oauth2", Token: secret.NewRefFromString("dummy")}, quietStubLogger()) if err != nil { t.Fatalf("New: %v", err) } @@ -51,7 +52,7 @@ func TestStub_GetCACertPEM(t *testing.T) { } func TestStub_GetRenewalInfo(t *testing.T) { - c, err := New(&Config{AuthMode: "oauth2", Token: "dummy"}, quietStubLogger()) + c, err := New(&Config{AuthMode: "oauth2", Token: secret.NewRefFromString("dummy")}, quietStubLogger()) if err != nil { t.Fatalf("New: %v", err) } diff --git a/internal/connector/issuer/ejbca/ejbca_test.go b/internal/connector/issuer/ejbca/ejbca_test.go index c6c6563..f55cb33 100644 --- a/internal/connector/issuer/ejbca/ejbca_test.go +++ b/internal/connector/issuer/ejbca/ejbca_test.go @@ -27,6 +27,7 @@ import ( "github.com/shankar0123/certctl/internal/connector/issuer" "github.com/shankar0123/certctl/internal/connector/issuer/ejbca" + "github.com/shankar0123/certctl/internal/secret" ) // mustNewForValidateConfig returns an EJBCA connector wired in OAuth2 mode @@ -40,7 +41,7 @@ func mustNewForValidateConfig(t *testing.T, logger *slog.Logger) *ejbca.Connecto c, err := ejbca.New(&ejbca.Config{ APIUrl: "https://placeholder", AuthMode: "oauth2", - Token: "placeholder", + Token: secret.NewRefFromString("placeholder"), CAName: "placeholder", }, logger) if err != nil { @@ -79,7 +80,7 @@ func TestEJBCAConnector(t *testing.T) { config := ejbca.Config{ APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1", AuthMode: "oauth2", - Token: "test-oauth2-token", + Token: secret.NewRefFromString("test-oauth2-token"), CAName: "Management CA", } @@ -224,7 +225,7 @@ func TestEJBCAConnector(t *testing.T) { config := &ejbca.Config{ APIUrl: srv.URL, AuthMode: "oauth2", - Token: "test-token", + Token: secret.NewRefFromString("test-token"), CAName: "Management CA", } connector := ejbca.NewWithHTTPClient(config, logger, srv.Client()) @@ -291,7 +292,7 @@ func TestEJBCAConnector(t *testing.T) { config := &ejbca.Config{ APIUrl: srv.URL, AuthMode: "oauth2", - Token: "test-token", + Token: secret.NewRefFromString("test-token"), CAName: "Management CA", CertProfile: "ENDUSER", EEProfile: "ENDUSER", @@ -324,7 +325,7 @@ func TestEJBCAConnector(t *testing.T) { config := &ejbca.Config{ APIUrl: srv.URL, AuthMode: "oauth2", - Token: "test-token", + Token: secret.NewRefFromString("test-token"), CAName: "Management CA", } connector := ejbca.NewWithHTTPClient(config, logger, srv.Client()) @@ -363,7 +364,7 @@ func TestEJBCAConnector(t *testing.T) { config := &ejbca.Config{ APIUrl: srv.URL, AuthMode: "oauth2", - Token: "test-token", + Token: secret.NewRefFromString("test-token"), CAName: "Management CA", } connector := ejbca.NewWithHTTPClient(config, logger, srv.Client()) @@ -405,7 +406,7 @@ func TestEJBCAConnector(t *testing.T) { config := &ejbca.Config{ APIUrl: srv.URL, AuthMode: "oauth2", - Token: "test-token", + Token: secret.NewRefFromString("test-token"), CAName: "Management CA", } connector := ejbca.NewWithHTTPClient(config, logger, srv.Client()) @@ -451,7 +452,7 @@ func TestEJBCAConnector(t *testing.T) { config := &ejbca.Config{ APIUrl: srv.URL, AuthMode: "oauth2", - Token: "test-token", + Token: secret.NewRefFromString("test-token"), CAName: "Management CA", } connector := ejbca.NewWithHTTPClient(config, logger, srv.Client()) @@ -506,7 +507,7 @@ func TestEJBCAConnector(t *testing.T) { config := &ejbca.Config{ APIUrl: srv.URL, AuthMode: "oauth2", - Token: "test-token", + Token: secret.NewRefFromString("test-token"), CAName: "Management CA", } connector := ejbca.NewWithHTTPClient(config, logger, srv.Client()) @@ -528,7 +529,7 @@ func TestEJBCAConnector(t *testing.T) { config := &ejbca.Config{ APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1", AuthMode: "oauth2", - Token: "test-token", + Token: secret.NewRefFromString("test-token"), CAName: "Management CA", } connector, err := ejbca.New(config, logger) @@ -549,7 +550,7 @@ func TestEJBCAConnector(t *testing.T) { config := &ejbca.Config{ APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1", AuthMode: "oauth2", - Token: "test-token", + Token: secret.NewRefFromString("test-token"), CAName: "Management CA", } connector, err := ejbca.New(config, logger) @@ -570,7 +571,7 @@ func TestEJBCAConnector(t *testing.T) { config := &ejbca.Config{ APIUrl: "https://ejbca.example.com:8443/ejbca/ejbca-rest-api/v1", AuthMode: "oauth2", - Token: "test-token", + Token: secret.NewRefFromString("test-token"), CAName: "Management CA", } connector, err := ejbca.New(config, logger) @@ -778,7 +779,7 @@ func TestNew_OAuth2NoTransportTuning(t *testing.T) { cfg := &ejbca.Config{ APIUrl: "https://placeholder", AuthMode: "oauth2", - Token: "test-token", + Token: secret.NewRefFromString("test-token"), CAName: "Management CA", } conn, err := ejbca.New(cfg, logger) @@ -803,7 +804,7 @@ func TestNew_InvalidAuthMode(t *testing.T) { cfg := &ejbca.Config{ APIUrl: "https://placeholder", AuthMode: "invalid", - Token: "test-token", + Token: secret.NewRefFromString("test-token"), CAName: "Management CA", } _, err := ejbca.New(cfg, logger) diff --git a/internal/connector/issuer/globalsign/globalsign.go b/internal/connector/issuer/globalsign/globalsign.go index 5609661..75bd004 100644 --- a/internal/connector/issuer/globalsign/globalsign.go +++ b/internal/connector/issuer/globalsign/globalsign.go @@ -40,6 +40,7 @@ import ( "github.com/shankar0123/certctl/internal/connector/issuer" "github.com/shankar0123/certctl/internal/connector/issuer/asyncpoll" + "github.com/shankar0123/certctl/internal/secret" ) // Config represents the GlobalSign Atlas HVCA issuer connector configuration. @@ -51,11 +52,16 @@ type Config struct { // APIKey is the GlobalSign API key for request authentication. // Required. Set via CERTCTL_GLOBALSIGN_API_KEY environment variable. - APIKey string `json:"api_key"` + // + // Type: *secret.Ref (audit fix #6 Phase 2). Never stringifies; + // MarshalJSON returns "[redacted]"; bytes are zeroed after each + // header write via Ref.Use. + APIKey *secret.Ref `json:"api_key"` // APISecret is the GlobalSign API secret for request authentication. // Required. Set via CERTCTL_GLOBALSIGN_API_SECRET environment variable. - APISecret string `json:"api_secret"` + // Same *secret.Ref protections as APIKey. + APISecret *secret.Ref `json:"api_secret"` // ClientCertPath is the filesystem path to the mTLS client certificate PEM file. // The certificate must be signed by GlobalSign and loaded for TLS handshake. @@ -159,11 +165,11 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag return fmt.Errorf("GlobalSign api_url is required") } - if cfg.APIKey == "" { + if cfg.APIKey.IsEmpty() { return fmt.Errorf("GlobalSign api_key is required") } - if cfg.APISecret == "" { + if cfg.APISecret.IsEmpty() { return fmt.Errorf("GlobalSign api_secret is required") } @@ -204,9 +210,7 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag } // Add both authentication layers - req.Header.Set("ApiKey", cfg.APIKey) - req.Header.Set("ApiSecret", cfg.APISecret) - req.Header.Set("Content-Type", "application/json") + setAuthHeaders(req, &cfg) resp, err := validationClient.Do(req) if err != nil { @@ -264,6 +268,34 @@ func (c *Connector) getHTTPClient(ctx context.Context) (*http.Client, error) { }, nil } +// setAuthHeaders writes the GlobalSign double-auth headers (ApiKey, +// ApiSecret) plus Content-Type: application/json onto req. The secret +// values are pulled from the *secret.Ref via Use, which zero-fills the +// per-call buffer after the header string is set; the Ref's underlying +// bytes remain encrypted at rest. The Use return value is intentionally +// ignored — Set never errors and the only failure modes inside Use are +// nil-Ref / empty-Ref which the upstream IsEmpty validation has already +// excluded for production paths. ValidateConfig and the steady-state +// IssueCertificate / RevokeCertificate / pollCertificateOnce sites all +// route through here so any future header-shape change applies once. +// +// Audit fix #6 Phase 2. +func setAuthHeaders(req *http.Request, cfg *Config) { + if cfg.APIKey != nil { + _ = cfg.APIKey.Use(func(buf []byte) error { + req.Header.Set("ApiKey", string(buf)) + return nil + }) + } + if cfg.APISecret != nil { + _ = cfg.APISecret.Use(func(buf []byte) error { + req.Header.Set("ApiSecret", string(buf)) + return nil + }) + } + req.Header.Set("Content-Type", "application/json") +} + // buildServerTLSConfig returns a TLS configuration for the GlobalSign Atlas // HVCA API client. It always verifies the server certificate. When // cfg.ServerCAPath is set, the PEM bundle at that path is used as the @@ -333,9 +365,7 @@ func (c *Connector) IssueCertificate(ctx context.Context, request issuer.Issuanc } // Apply double auth: mTLS + headers - req.Header.Set("ApiKey", c.config.APIKey) - req.Header.Set("ApiSecret", c.config.APISecret) - req.Header.Set("Content-Type", "application/json") + setAuthHeaders(req, c.config) resp, err := client.Do(req) if err != nil { @@ -422,9 +452,7 @@ func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.Revoca return fmt.Errorf("failed to create revoke request: %w", err) } - req.Header.Set("ApiKey", c.config.APIKey) - req.Header.Set("ApiSecret", c.config.APISecret) - req.Header.Set("Content-Type", "application/json") + setAuthHeaders(req, c.config) resp, err := client.Do(req) if err != nil { @@ -510,9 +538,7 @@ func (c *Connector) pollCertificateOnce(ctx context.Context, orderID string) (*i return nil, asyncpoll.Failed, fmt.Errorf("failed to create status request: %w", err) } - req.Header.Set("ApiKey", c.config.APIKey) - req.Header.Set("ApiSecret", c.config.APISecret) - req.Header.Set("Content-Type", "application/json") + setAuthHeaders(req, c.config) resp, err := client.Do(req) if err != nil { diff --git a/internal/connector/issuer/globalsign/globalsign_failure_test.go b/internal/connector/issuer/globalsign/globalsign_failure_test.go index bc94fd4..3d2821e 100644 --- a/internal/connector/issuer/globalsign/globalsign_failure_test.go +++ b/internal/connector/issuer/globalsign/globalsign_failure_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/shankar0123/certctl/internal/connector/issuer/globalsign" + "github.com/shankar0123/certctl/internal/secret" ) // Bundle N.A/B-extended: globalsign failure-mode round-out (78.2% → ≥85%). @@ -20,8 +21,8 @@ func buildGlobalsignConnector(t *testing.T, baseURL string) *globalsign.Connecto t.Helper() cfg := &globalsign.Config{ APIUrl: baseURL, - APIKey: "k", - APISecret: "s", + APIKey: secret.NewRefFromString("k"), + APISecret: secret.NewRefFromString("s"), PollMaxWaitSeconds: 1, // keep async-pending tests fast } // Use NewWithHTTPClient with a test client so getHTTPClient short-circuits @@ -133,8 +134,8 @@ func TestGlobalsign_GetHTTPClient_NoMTLSCertPaths_ReturnsClientAsIs(t *testing.T // the production-default 10 minutes. cfg := &globalsign.Config{ APIUrl: "http://example.invalid", - APIKey: "k", - APISecret: "s", + APIKey: secret.NewRefFromString("k"), + APISecret: secret.NewRefFromString("s"), PollMaxWaitSeconds: 1, // no cert paths } @@ -153,8 +154,8 @@ func TestGlobalsign_GetHTTPClient_MTLSPathConfigured_LoadsKeyPair(t *testing.T) // LoadX509KeyPair error branch in getHTTPClient. cfg := &globalsign.Config{ APIUrl: "http://example.invalid", - APIKey: "k", - APISecret: "s", + APIKey: secret.NewRefFromString("k"), + APISecret: secret.NewRefFromString("s"), ClientCertPath: "/nonexistent/cert.pem", ClientKeyPath: "/nonexistent/key.pem", } diff --git a/internal/connector/issuer/globalsign/globalsign_test.go b/internal/connector/issuer/globalsign/globalsign_test.go index 1b1a74d..0484e0f 100644 --- a/internal/connector/issuer/globalsign/globalsign_test.go +++ b/internal/connector/issuer/globalsign/globalsign_test.go @@ -20,6 +20,7 @@ import ( "github.com/shankar0123/certctl/internal/connector/issuer" "github.com/shankar0123/certctl/internal/connector/issuer/globalsign" + "github.com/shankar0123/certctl/internal/secret" ) func TestGlobalSignConnector(t *testing.T) { @@ -44,8 +45,8 @@ func TestGlobalSignConnector(t *testing.T) { config := globalsign.Config{ APIUrl: srv.URL, - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), ClientCertPath: "unused_for_httptest", ClientKeyPath: "unused_for_httptest", } @@ -63,8 +64,8 @@ func TestGlobalSignConnector(t *testing.T) { t.Run("ValidateConfig_MissingAPIUrl", func(t *testing.T) { config := globalsign.Config{ - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), ClientCertPath: "/tmp/cert.pem", ClientKeyPath: "/tmp/key.pem", } @@ -83,7 +84,7 @@ func TestGlobalSignConnector(t *testing.T) { t.Run("ValidateConfig_MissingAPIKey", func(t *testing.T) { config := globalsign.Config{ APIUrl: "https://api.example.com", - APISecret: "gs-test-secret", + APISecret: secret.NewRefFromString("gs-test-secret"), ClientCertPath: "/tmp/cert.pem", ClientKeyPath: "/tmp/key.pem", } @@ -102,7 +103,7 @@ func TestGlobalSignConnector(t *testing.T) { t.Run("ValidateConfig_MissingAPISecret", func(t *testing.T) { config := globalsign.Config{ APIUrl: "https://api.example.com", - APIKey: "gs-test-key", + APIKey: secret.NewRefFromString("gs-test-key"), ClientCertPath: "/tmp/cert.pem", ClientKeyPath: "/tmp/key.pem", } @@ -121,8 +122,8 @@ func TestGlobalSignConnector(t *testing.T) { t.Run("ValidateConfig_MissingClientCertPath", func(t *testing.T) { config := globalsign.Config{ APIUrl: "https://api.example.com", - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), ClientKeyPath: "/tmp/key.pem", } @@ -140,8 +141,8 @@ func TestGlobalSignConnector(t *testing.T) { t.Run("ValidateConfig_MissingClientKeyPath", func(t *testing.T) { config := globalsign.Config{ APIUrl: "https://api.example.com", - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), ClientCertPath: "/tmp/cert.pem", } @@ -187,8 +188,8 @@ func TestGlobalSignConnector(t *testing.T) { config := &globalsign.Config{ APIUrl: mockServer.URL, - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), } connector := globalsign.NewWithHTTPClient(config, logger, httpClient) @@ -235,8 +236,8 @@ func TestGlobalSignConnector(t *testing.T) { config := &globalsign.Config{ APIUrl: mockServer.URL, - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), } connector := globalsign.NewWithHTTPClient(config, logger, httpClient) @@ -276,8 +277,8 @@ func TestGlobalSignConnector(t *testing.T) { config := &globalsign.Config{ APIUrl: mockServer.URL, - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), } connector := globalsign.NewWithHTTPClient(config, logger, httpClient) @@ -318,8 +319,8 @@ func TestGlobalSignConnector(t *testing.T) { config := &globalsign.Config{ APIUrl: mockServer.URL, - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), } connector := globalsign.NewWithHTTPClient(config, logger, httpClient) @@ -356,8 +357,8 @@ func TestGlobalSignConnector(t *testing.T) { config := &globalsign.Config{ APIUrl: mockServer.URL, - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), PollMaxWaitSeconds: 1, // keep async-pending tests fast } @@ -400,8 +401,8 @@ func TestGlobalSignConnector(t *testing.T) { config := &globalsign.Config{ APIUrl: mockServer.URL, - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), } connector := globalsign.NewWithHTTPClient(config, logger, httpClient) @@ -445,8 +446,8 @@ func TestGlobalSignConnector(t *testing.T) { config := &globalsign.Config{ APIUrl: mockServer.URL, - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), } connector := globalsign.NewWithHTTPClient(config, logger, httpClient) @@ -478,8 +479,8 @@ func TestGlobalSignConnector(t *testing.T) { config := &globalsign.Config{ APIUrl: mockServer.URL, - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), } connector := globalsign.NewWithHTTPClient(config, logger, httpClient) @@ -524,8 +525,8 @@ func TestGlobalSignConnector(t *testing.T) { config := &globalsign.Config{ APIUrl: mockServer.URL, - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), } connector := globalsign.NewWithHTTPClient(config, logger, httpClient) @@ -613,15 +614,22 @@ func TestGlobalSign_ServerTLSConfig(t *testing.T) { clientCert, clientKey := writeClientMTLS(t) config := globalsign.Config{ APIUrl: srv.URL, - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), ClientCertPath: clientCert, ClientKeyPath: clientKey, ServerCAPath: caPath, } connector := globalsign.New(&config, logger) - rawConfig, _ := json.Marshal(config) + // Build rawConfig as a JSON literal so the secrets round-trip + // intact via UnmarshalJSON (json.Marshal redacts *secret.Ref → + // "[redacted]" by design; it MUST NOT be used to construct a + // rawConfig that ValidateConfig will then header-write back). + rawConfig := []byte(fmt.Sprintf( + `{"api_url":%q,"api_key":"gs-test-key","api_secret":"gs-test-secret","client_cert_path":%q,"client_key_path":%q,"server_ca_path":%q}`, + config.APIUrl, config.ClientCertPath, config.ClientKeyPath, config.ServerCAPath, + )) if err := connector.ValidateConfig(ctx, rawConfig); err != nil { t.Fatalf("ValidateConfig with pinned CA should succeed, got: %v", err) } @@ -645,8 +653,8 @@ func TestGlobalSign_ServerTLSConfig(t *testing.T) { clientCert, clientKey := writeClientMTLS(t) config := globalsign.Config{ APIUrl: srv.URL, - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), ClientCertPath: clientCert, ClientKeyPath: clientKey, ServerCAPath: caPath, @@ -671,8 +679,8 @@ func TestGlobalSign_ServerTLSConfig(t *testing.T) { clientCert, clientKey := writeClientMTLS(t) config := globalsign.Config{ APIUrl: "https://example.invalid", - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), ClientCertPath: clientCert, ClientKeyPath: clientKey, ServerCAPath: "/nonexistent/path/to/ca.pem", @@ -699,8 +707,8 @@ func TestGlobalSign_ServerTLSConfig(t *testing.T) { config := globalsign.Config{ APIUrl: "https://example.invalid", - APIKey: "gs-test-key", - APISecret: "gs-test-secret", + APIKey: secret.NewRefFromString("gs-test-key"), + APISecret: secret.NewRefFromString("gs-test-secret"), ClientCertPath: clientCert, ClientKeyPath: clientKey, ServerCAPath: badCAPath, diff --git a/internal/connector/issuer/sectigo/sectigo.go b/internal/connector/issuer/sectigo/sectigo.go index d1580d8..4e767fb 100644 --- a/internal/connector/issuer/sectigo/sectigo.go +++ b/internal/connector/issuer/sectigo/sectigo.go @@ -38,6 +38,7 @@ import ( "github.com/shankar0123/certctl/internal/connector/issuer" "github.com/shankar0123/certctl/internal/connector/issuer/asyncpoll" + "github.com/shankar0123/certctl/internal/secret" ) // Config represents the Sectigo Certificate Manager issuer connector configuration. @@ -48,11 +49,16 @@ type Config struct { // Login is the Sectigo API account login. // Required. Set via CERTCTL_SECTIGO_LOGIN environment variable. - Login string `json:"login"` + // + // Type: *secret.Ref (audit fix #6 Phase 2). Login can be tied to + // a privileged service-account identity, so it's protected from + // accidental logging the same way Password is. + Login *secret.Ref `json:"login"` // Password is the Sectigo API account password or API key. // Required. Set via CERTCTL_SECTIGO_PASSWORD environment variable. - Password string `json:"password"` + // Same *secret.Ref protections as Login. + Password *secret.Ref `json:"password"` // OrgID is the Sectigo organization ID for certificate enrollments. // Required. Set via CERTCTL_SECTIGO_ORG_ID environment variable. @@ -144,10 +150,27 @@ type statusResponse struct { } // setAuthHeaders sets the three Sectigo authentication headers on a request. +// +// Login and Password are pulled from *secret.Ref via Use, which zero-fills +// the per-call buffer after the header string is set; the Ref's underlying +// bytes remain encrypted at rest. The Use return value is intentionally +// ignored — Set never errors and the only failure modes inside Use are +// nil-Ref / empty-Ref which the upstream IsEmpty validation has already +// excluded for production paths. Audit fix #6 Phase 2. func (c *Connector) setAuthHeaders(req *http.Request) { req.Header.Set("customerUri", c.config.CustomerURI) - req.Header.Set("login", c.config.Login) - req.Header.Set("password", c.config.Password) + if c.config.Login != nil { + _ = c.config.Login.Use(func(buf []byte) error { + req.Header.Set("login", string(buf)) + return nil + }) + } + if c.config.Password != nil { + _ = c.config.Password.Use(func(buf []byte) error { + req.Header.Set("password", string(buf)) + return nil + }) + } req.Header.Set("Content-Type", "application/json") } @@ -162,11 +185,11 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag return fmt.Errorf("Sectigo customer_uri is required") } - if cfg.Login == "" { + if cfg.Login.IsEmpty() { return fmt.Errorf("Sectigo login is required") } - if cfg.Password == "" { + if cfg.Password.IsEmpty() { return fmt.Errorf("Sectigo password is required") } @@ -188,8 +211,18 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag return fmt.Errorf("failed to create API test request: %w", err) } req.Header.Set("customerUri", cfg.CustomerURI) - req.Header.Set("login", cfg.Login) - req.Header.Set("password", cfg.Password) + if cfg.Login != nil { + _ = cfg.Login.Use(func(buf []byte) error { + req.Header.Set("login", string(buf)) + return nil + }) + } + if cfg.Password != nil { + _ = cfg.Password.Use(func(buf []byte) error { + req.Header.Set("password", string(buf)) + return nil + }) + } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) diff --git a/internal/connector/issuer/sectigo/sectigo_failure_test.go b/internal/connector/issuer/sectigo/sectigo_failure_test.go index fd3404a..29cc800 100644 --- a/internal/connector/issuer/sectigo/sectigo_failure_test.go +++ b/internal/connector/issuer/sectigo/sectigo_failure_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/shankar0123/certctl/internal/connector/issuer/sectigo" + "github.com/shankar0123/certctl/internal/secret" ) // Bundle N.A/B-extended: sectigo failure-mode round-out (79.4% → ≥85%). @@ -22,8 +23,8 @@ func buildSectigoConnector(t *testing.T, baseURL string) *sectigo.Connector { cfg := sectigo.Config{ BaseURL: baseURL, CustomerURI: "tcust", - Login: "user", - Password: "pw", + Login: secret.NewRefFromString("user"), + Password: secret.NewRefFromString("pw"), CertType: 1, OrgID: 2, Term: 365, diff --git a/internal/connector/issuer/sectigo/sectigo_test.go b/internal/connector/issuer/sectigo/sectigo_test.go index 676d1b5..a1ceeb8 100644 --- a/internal/connector/issuer/sectigo/sectigo_test.go +++ b/internal/connector/issuer/sectigo/sectigo_test.go @@ -20,6 +20,7 @@ import ( "github.com/shankar0123/certctl/internal/connector/issuer" "github.com/shankar0123/certctl/internal/connector/issuer/sectigo" + "github.com/shankar0123/certctl/internal/secret" ) func TestSectigoConnector(t *testing.T) { @@ -47,18 +48,16 @@ func TestSectigoConnector(t *testing.T) { })) defer srv.Close() - config := sectigo.Config{ - CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", - OrgID: 12345, - CertType: 423, - Term: 365, - BaseURL: srv.URL, - } + // Build rawConfig as a JSON literal so the secrets round-trip + // intact via UnmarshalJSON (json.Marshal redacts *secret.Ref → + // "[redacted]" by design; it MUST NOT be used to construct a + // rawConfig that ValidateConfig will then header-write back). + rawConfig := []byte(fmt.Sprintf( + `{"customer_uri":"test-org","login":"api-user","password":"api-pass","org_id":12345,"cert_type":423,"term":365,"base_url":%q}`, + srv.URL, + )) connector := sectigo.New(nil, logger) - rawConfig, _ := json.Marshal(config) err := connector.ValidateConfig(ctx, rawConfig) if err != nil { t.Fatalf("ValidateConfig failed: %v", err) @@ -67,8 +66,8 @@ func TestSectigoConnector(t *testing.T) { t.Run("ValidateConfig_MissingCustomerURI", func(t *testing.T) { config := sectigo.Config{ - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, } @@ -86,7 +85,7 @@ func TestSectigoConnector(t *testing.T) { t.Run("ValidateConfig_MissingLogin", func(t *testing.T) { config := sectigo.Config{ CustomerURI: "test-org", - Password: "api-pass", + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, } @@ -104,7 +103,7 @@ func TestSectigoConnector(t *testing.T) { t.Run("ValidateConfig_MissingPassword", func(t *testing.T) { config := sectigo.Config{ CustomerURI: "test-org", - Login: "api-user", + Login: secret.NewRefFromString("api-user"), OrgID: 12345, } @@ -122,8 +121,8 @@ func TestSectigoConnector(t *testing.T) { t.Run("ValidateConfig_MissingOrgID", func(t *testing.T) { config := sectigo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), } connector := sectigo.New(nil, logger) @@ -150,8 +149,8 @@ func TestSectigoConnector(t *testing.T) { config := sectigo.Config{ CustomerURI: "bad-org", - Login: "bad-user", - Password: "bad-pass", + Login: secret.NewRefFromString("bad-user"), + Password: secret.NewRefFromString("bad-pass"), OrgID: 12345, BaseURL: srv.URL, } @@ -215,8 +214,8 @@ func TestSectigoConnector(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, CertType: 423, Term: 365, @@ -265,8 +264,8 @@ func TestSectigoConnector(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, CertType: 423, Term: 365, @@ -305,8 +304,8 @@ func TestSectigoConnector(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, CertType: 423, Term: 365, @@ -346,8 +345,8 @@ func TestSectigoConnector(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, BaseURL: srv.URL, } @@ -382,8 +381,8 @@ func TestSectigoConnector(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, BaseURL: srv.URL, PollMaxWaitSeconds: 1, // keep pending tests fast @@ -416,8 +415,8 @@ func TestSectigoConnector(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, BaseURL: srv.URL, } @@ -451,8 +450,8 @@ func TestSectigoConnector(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, BaseURL: srv.URL, PollMaxWaitSeconds: 1, // keep collect-not-ready tests fast @@ -487,8 +486,8 @@ func TestSectigoConnector(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, CertType: 423, Term: 365, @@ -543,8 +542,8 @@ func TestSectigoConnector(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, BaseURL: srv.URL, } @@ -571,8 +570,8 @@ func TestSectigoConnector(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, BaseURL: srv.URL, } @@ -591,8 +590,8 @@ func TestSectigoConnector(t *testing.T) { t.Run("GetRenewalInfo_ReturnsNil", func(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, BaseURL: "https://cert-manager.com/api", } @@ -610,8 +609,8 @@ func TestSectigoConnector(t *testing.T) { t.Run("DefaultTerm", func(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, CertType: 423, // Term intentionally left as 0 @@ -697,8 +696,8 @@ func TestSectigoConnector(t *testing.T) { config := §igo.Config{ CustomerURI: "verify-org", - Login: "verify-user", - Password: "verify-pass", + Login: secret.NewRefFromString("verify-user"), + Password: secret.NewRefFromString("verify-pass"), OrgID: 12345, CertType: 423, Term: 365, @@ -753,8 +752,8 @@ func TestSectigoConnector(t *testing.T) { config := §igo.Config{ CustomerURI: "test-org", - Login: "api-user", - Password: "api-pass", + Login: secret.NewRefFromString("api-user"), + Password: secret.NewRefFromString("api-pass"), OrgID: 12345, BaseURL: srv.URL, } diff --git a/internal/secret/secret.go b/internal/secret/secret.go index 463dd37..aea6156 100644 --- a/internal/secret/secret.go +++ b/internal/secret/secret.go @@ -31,6 +31,7 @@ package secret import ( + "encoding/json" "fmt" "io" ) @@ -124,6 +125,28 @@ func (r *Ref) MarshalJSON() ([]byte, error) { return []byte(`"[redacted]"`), nil } +// UnmarshalJSON parses a JSON string into a Ref via NewRefFromString. +// Required for the production wiring path where issuer Config structs +// are JSON-deserialized from the DB-stored config blob (the factory's +// NewFromConfig in internal/connector/issuerfactory). +// +// Accepts either a JSON string ("abc") or null (treated as nil Ref). +// Other JSON shapes (numbers, objects, arrays) are rejected — a +// credential is always either a string or absent. +func (r *Ref) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + // nil-Ref unmarshal is a no-op; the field on the parent + // struct stays nil and IsEmpty() reports true. + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("secret.Ref: expected JSON string, got: %w", err) + } + *r = *NewRefFromString(s) + return nil +} + // IsEmpty reports whether the source returns an empty byte slice // (zero-length credential). Useful for ValidateConfig paths that need // to check "did the operator set the credential" without obtaining diff --git a/internal/secret/secret_test.go b/internal/secret/secret_test.go index 5ae7a12..eedb783 100644 --- a/internal/secret/secret_test.go +++ b/internal/secret/secret_test.go @@ -175,6 +175,74 @@ func TestRef_IsEmpty(t *testing.T) { } } +// TestRef_UnmarshalJSON — parse a JSON string into a Ref via +// NewRefFromString. Required for the factory's JSON-deserialization +// path that loads issuer configs from the DB. +func TestRef_UnmarshalJSON(t *testing.T) { + type cfg struct { + Token *Ref `json:"token"` + } + + t.Run("string_value", func(t *testing.T) { + var c cfg + if err := json.Unmarshal([]byte(`{"token":"my-secret-token"}`), &c); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if c.Token == nil { + t.Fatal("expected non-nil Ref") + } + _ = c.Token.Use(func(buf []byte) error { + if string(buf) != "my-secret-token" { + t.Errorf("Use: want 'my-secret-token', got %q", buf) + } + return nil + }) + }) + + t.Run("null", func(t *testing.T) { + var c cfg + if err := json.Unmarshal([]byte(`{"token":null}`), &c); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if c.Token != nil { + t.Errorf("null should leave Ref nil, got %v", c.Token) + } + }) + + t.Run("missing_key", func(t *testing.T) { + var c cfg + if err := json.Unmarshal([]byte(`{}`), &c); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if c.Token != nil { + t.Error("missing key should leave Ref nil") + } + }) + + t.Run("number_rejected", func(t *testing.T) { + var c cfg + err := json.Unmarshal([]byte(`{"token":123}`), &c) + if err == nil { + t.Error("expected error for non-string Ref input") + } + }) + + t.Run("roundtrip_marshal_then_unmarshal", func(t *testing.T) { + // Marshal returns "[redacted]" — round-tripping through + // Unmarshal would store the string "[redacted]" as the + // new credential. Documented behavior; operators marshal + // for inspection, not for re-loading. + original := cfg{Token: NewRefFromString("real-secret")} + marshaled, err := json.Marshal(original) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if string(marshaled) != `{"token":"[redacted]"}` { + t.Errorf("Marshal: got %s", marshaled) + } + }) +} + // TestZero — direct test of the zero helper to lock the // implementation: every byte set to 0. func TestZero(t *testing.T) {