enhancement: opaque Spec value type with comprehensive redaction (ai) #6

Open
opened 2026-06-01 08:22:17 +02:00 by heiko · 0 comments
Owner

enhancement: opaque Spec value type with comprehensive redaction (ai)

Summary

go.schlittermann.de/heiko/secret currently exposes a procedural
API: secret.Get(string) (string, error). Spec strings live as
plain string values until resolution. This makes redaction the
consumer's responsibility on every output path: fmt.Println,
slog, MarshalJSON, MarshalText, structured-error wrapping,
reflection-based debug dumpers — every one of these can leak the
spec (which is "safe-ish" for env:VAR but actively dangerous for
plain: and pass: schemes that carry the cleartext literal).

Proposed addition

An opaque Spec type with redaction-aware methods on every common
output interface:

type Spec struct{ /* opaque */ }

func ParseSpec(s string) (Spec, error)         // validates at parse time
func (s Spec) Scheme() string                  // "env", "file", "plain", ...
func (s Spec) String() string                  // "<scheme>:[redacted]" (Stringer)
func (s Spec) GoString() string                // same shape (GoStringer)
func (s Spec) Format(f fmt.State, verb rune)   // %v / %s / %q / %#v all redacted (Formatter)
func (s Spec) LogValue() slog.Value            // slog redaction (LogValuer)
func (s Spec) MarshalJSON() ([]byte, error)    // redacted JSON
func (s Spec) MarshalText() ([]byte, error)    // redacted TOML/YAML
func (s *Spec) UnmarshalText([]byte) error     // round-trips with the above

// Resolution stays explicit:
func Resolve(ctx context.Context, s Spec) (string, error)
// or as a method:
func (s Spec) Resolve(ctx context.Context) (string, error)

The current secret.Get(string) can stay as a thin wrapper for
backward compatibility (Get(s) = s2, _ := ParseSpec(s); Resolve(s2)).

Why this belongs upstream

Every consumer of heiko/secret that ships a service (especially
one with structured logging, JSON config introspection, or a
"dump effective config" verb) needs the same redaction guarantees.
The dmarc project re-implemented Spec in
internal/secret/spec.go (~310 LOC plus 600 LOC of tests) for
exactly this reason. Anyone else writing a long-running daemon
that uses heiko/secret will hit the same need and either
copy the dmarc shape or invent their own.

Putting it upstream:

  • removes the per-consumer redaction-correctness audit burden
    (one author got Format(%#v) right, the next forgets);
  • makes parse-time validation the default (today the upstream
    parses lazily inside Get, so a malformed spec fails at
    resolve-time, not config-load time);
  • gives pkg.go.dev a single canonical shape for the docs.

What dmarc does today

internal/secret/spec.go provides exactly this Spec type plus
parse-time scheme validation, quote-balance checks, and
fallback-chain length limits. It calls upstream
secret.Get(spec.getRaw()) for the actual resolution. The
wrapper is ~310 lines of dmarc-specific code that would
disappear if upstream shipped the type.

Out of scope for this issue

Resolution caching / dedup / audit-channel hooks are separate
concerns; filing those individually so each can be evaluated on
its own merits.

Compatibility

A Spec type addition is purely additive — secret.Get(string)
keeps working. Consumers that want the redaction-safe value type
opt in.

References

# enhancement: opaque `Spec` value type with comprehensive redaction (ai) ## Summary `go.schlittermann.de/heiko/secret` currently exposes a procedural API: `secret.Get(string) (string, error)`. Spec strings live as plain `string` values until resolution. This makes redaction the consumer's responsibility on every output path: `fmt.Println`, `slog`, `MarshalJSON`, `MarshalText`, structured-error wrapping, reflection-based debug dumpers — every one of these can leak the spec (which is "safe-ish" for `env:VAR` but actively dangerous for `plain:` and `pass:` schemes that carry the cleartext literal). ## Proposed addition An opaque `Spec` type with redaction-aware methods on every common output interface: ```go type Spec struct{ /* opaque */ } func ParseSpec(s string) (Spec, error) // validates at parse time func (s Spec) Scheme() string // "env", "file", "plain", ... func (s Spec) String() string // "<scheme>:[redacted]" (Stringer) func (s Spec) GoString() string // same shape (GoStringer) func (s Spec) Format(f fmt.State, verb rune) // %v / %s / %q / %#v all redacted (Formatter) func (s Spec) LogValue() slog.Value // slog redaction (LogValuer) func (s Spec) MarshalJSON() ([]byte, error) // redacted JSON func (s Spec) MarshalText() ([]byte, error) // redacted TOML/YAML func (s *Spec) UnmarshalText([]byte) error // round-trips with the above // Resolution stays explicit: func Resolve(ctx context.Context, s Spec) (string, error) // or as a method: func (s Spec) Resolve(ctx context.Context) (string, error) ``` The current `secret.Get(string)` can stay as a thin wrapper for backward compatibility (`Get(s) = s2, _ := ParseSpec(s); Resolve(s2)`). ## Why this belongs upstream Every consumer of `heiko/secret` that ships a service (especially one with structured logging, JSON config introspection, or a "dump effective config" verb) needs the same redaction guarantees. The dmarc project re-implemented `Spec` in `internal/secret/spec.go` (~310 LOC plus 600 LOC of tests) for exactly this reason. Anyone else writing a long-running daemon that uses `heiko/secret` will hit the same need and either copy the dmarc shape or invent their own. Putting it upstream: - removes the per-consumer redaction-correctness audit burden (one author got `Format(%#v)` right, the next forgets); - makes parse-time validation the default (today the upstream parses lazily inside `Get`, so a malformed spec fails at resolve-time, not config-load time); - gives `pkg.go.dev` a single canonical shape for the docs. ## What dmarc does today `internal/secret/spec.go` provides exactly this `Spec` type plus parse-time scheme validation, quote-balance checks, and fallback-chain length limits. It calls upstream `secret.Get(spec.getRaw())` for the actual resolution. The wrapper is ~310 lines of dmarc-specific code that would disappear if upstream shipped the type. ## Out of scope for this issue Resolution caching / dedup / audit-channel hooks are separate concerns; filing those individually so each can be evaluated on its own merits. ## Compatibility A `Spec` type addition is purely additive — `secret.Get(string)` keeps working. Consumers that want the redaction-safe value type opt in. ## References - dmarc's wrapper: https://git.schlittermann.de/heiko/dmarc/src/branch/dev/internal/secret/spec.go - The bug that prompted this filing (a dmarc-side mis-detection of zero-value `Spec` via redacted-string comparison) is being fixed in dmarc directly; the wrapper itself was correct.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
heiko/secret#6
No description provided.