package config import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "strings" "sync" "time" ) // Secret key references. Values are never logged and never stored by the // harness; only their resolved bytes reach the outgoing HTTP headers. // // Formats: // // env:NAME environment variable (must be set and non-empty) // file:PATH file whose trimmed contents are the key // literal:VALUE inline key (last resort; still never logged) // mpk:REF keyproxy placeholder (ukrrs/mopac-keyproxy); resolved via // POST /v1/resolve. A `mpk:` prefix missing its own // `mpk-` prefix is auto-prefixed ("mpk:redmine" == // "mpk:mpk-redmine"). Needs [keyproxy] config (KeyResolver). // bw:REF bitwarden item (reserved; lands with the bw wrapper, phase 3) func CheckKeyRef(ref string) error { switch { case ref == "": return fmt.Errorf("empty key reference") case strings.HasPrefix(ref, "env:"): if len(ref) <= 4 { return fmt.Errorf("key ref %q: env: needs a variable name", redact(ref)) } case strings.HasPrefix(ref, "file:"): if len(ref) <= 5 { return fmt.Errorf("key ref %q: file: needs a path", redact(ref)) } case strings.HasPrefix(ref, "literal:"): if len(ref) <= 8 { return fmt.Errorf("key ref: literal: value is empty") } case strings.HasPrefix(ref, "mpk:"): if len(strings.TrimSpace(ref[4:])) == 0 { return fmt.Errorf("key ref %q: mpk: needs a placeholder name", redact(ref)) } case strings.HasPrefix(ref, "bw:"): return fmt.Errorf("key ref bw: not implemented yet (bitwarden wrapper lands in build phase 3)") default: return fmt.Errorf("key ref must use env:, file:, literal:, mpk:, or bw: prefixes (unrecognized ref redacted)") } return nil } // ResolveKeyRef resolves a local key reference (env:, file:, literal:) to its // value. mpk: refs need the keyproxy hop and a context; use KeyResolver. // Never log the result. func ResolveKeyRef(ref string) (string, error) { if err := CheckKeyRef(ref); err != nil { return "", err } switch { case strings.HasPrefix(ref, "env:"): v := os.Getenv(ref[4:]) if v == "" { return "", fmt.Errorf("environment variable %s is not set", ref[4:]) } return v, nil case strings.HasPrefix(ref, "file:"): data, err := os.ReadFile(ref[5:]) if err != nil { return "", fmt.Errorf("read key file: %w", err) } v := strings.TrimSpace(string(data)) if v == "" { return "", fmt.Errorf("key file %s is empty", ref[5:]) } return v, nil case strings.HasPrefix(ref, "literal:"): return ref[8:], nil case strings.HasPrefix(ref, "mpk:"): return "", fmt.Errorf("key ref mpk: needs the [keyproxy] resolver (KeyResolver), not the local resolver") } return "", fmt.Errorf("unreachable key ref branch") } // KeyResolver resolves key refs, mpk: included, against the configured // keyproxy hop ([keyproxy] in harness.toml). mpk resolutions are cached in // memory for a short TTL so the poll loop does not hammer the proxy; the // material itself never leaves memory. With no [keyproxy] config, env:/file:/ // literal: refs still resolve — the harness runs with or without keyproxy up. type KeyResolver struct { cfg KeyProxyConfig http *http.Client ttl time.Duration mu sync.Mutex cache map[string]mpkEntry } type mpkEntry struct { value string expires time.Time } // NewKeyResolver builds a resolver from the config's [keyproxy] section. func NewKeyResolver(cfg *Config) *KeyResolver { ttl := time.Duration(cfg.KeyProxy.CacheTTLSecs) * time.Second if ttl <= 0 { ttl = 60 * time.Second } return &KeyResolver{ cfg: cfg.KeyProxy, http: &http.Client{Timeout: 15 * time.Second}, ttl: ttl, cache: make(map[string]mpkEntry), } } // Resolve resolves any supported key ref. Never log the result. func (r *KeyResolver) Resolve(ctx context.Context, ref string) (string, error) { if strings.HasPrefix(ref, "mpk:") { return r.resolveMPK(ctx, ref[4:]) } return ResolveKeyRef(ref) } // resolveMPK POSTs {"ref":"mpk-"} to the keyproxy /v1/resolve hop with // the bearer token from [keyproxy] token_ref (itself a local ref, resolved // once and cached for the process lifetime). func (r *KeyResolver) resolveMPK(ctx context.Context, name string) (string, error) { name = strings.TrimSpace(name) if !strings.HasPrefix(name, "mpk-") { name = "mpk-" + name } if r.cfg.URL == "" { return "", fmt.Errorf("key ref mpk: no [keyproxy] url configured (fall back to env:/file: refs, or configure the hop)") } r.mu.Lock() if e, ok := r.cache[name]; ok && time.Now().Before(e.expires) { r.mu.Unlock() return e.value, nil } r.mu.Unlock() token, err := ResolveKeyRef(r.cfg.TokenRef) if err != nil { return "", fmt.Errorf("keyproxy token (%s): %w", redact(r.cfg.TokenRef), err) } body, err := json.Marshal(map[string]string{"ref": name}) if err != nil { return "", err } url := strings.TrimSuffix(r.cfg.URL, "/") + "/v1/resolve" req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body))) if err != nil { return "", fmt.Errorf("keyproxy request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := r.http.Do(req) if err != nil { return "", fmt.Errorf("keyproxy %s: %w", "resolve", redactErr(err)) } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return "", fmt.Errorf("keyproxy resolve: HTTP %d: %s", resp.StatusCode, truncateErrBody(string(raw))) } var out struct { Value string `json:"value"` } if err := json.Unmarshal(raw, &out); err != nil { return "", fmt.Errorf("keyproxy decode: %w", err) } if out.Value == "" { return "", fmt.Errorf("keyproxy resolve: empty value for %s", name) } r.mu.Lock() r.cache[name] = mpkEntry{value: out.Value, expires: time.Now().Add(r.ttl)} r.mu.Unlock() return out.Value, nil } // redactErr strips any URL query or userinfo from transport errors so a // misconfigured hop cannot echo the token back into logs. func redactErr(err error) error { msg := err.Error() if i := strings.Index(msg, "?"); i >= 0 { msg = msg[:i] + "?..." } return fmt.Errorf("%s", msg) } func truncateErrBody(s string) string { s = strings.TrimSpace(s) if len(s) > 200 { s = s[:200] + "..." } return s } // redact masks everything after a recognized prefix so malformed refs can // be reported without echoing a possibly-pasted secret. Refs without a // recognizable prefix are never echoed at all. mpk: refs are placeholders // by construction, but they are masked too for uniformity. func redact(ref string) string { if i := strings.IndexByte(ref, ':'); i >= 0 && len(ref) > i+1 { return ref[:i+1] + "****" } return "" }