ProofedMesh/services/dummy-sink/internal/signer/client.go

144 lines
4.7 KiB
Go
Raw Permalink Normal View History

// Package signer calls the signing-service's POST /api/v1/sign/dsse per
// ADR-0005 using the double-identity flow:
//
// - Authorization: Bearer <pocket-id-access-token> (OIDC client identity)
// - X-Workload-Token: <projected-sa-token> (K8s workload identity)
//
// The signing-service's structured policy evaluates BOTH identities
// against the Agent Registry and the OPA policy. See
// services/signing-service/internal/httpapi/sign.go.
package signer
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"basicstack.de/proofedmesh/services/dummy-sink/internal/auth"
"basicstack.de/proofedmesh/services/dummy-sink/internal/dsse"
)
// WorkloadTokenHeader is the HTTP header carrying the projected
// ServiceAccount token. Kept in sync with
// services/signing-service/internal/token.WorkloadTokenHeader.
const WorkloadTokenHeader = "X-Workload-Token"
// Statement is the in-toto v1 Statement handed to the signing-service.
type Statement map[string]any
// SignedResult pairs the returned DSSE envelope with its content_hash.
type SignedResult struct {
Envelope dsse.Envelope `json:"envelope"`
ContentHash string `json:"content_hash"`
SignedAt time.Time `json:"signed_at"`
}
// ErrSignerUnavailable is returned on connection errors or 5xx responses.
var ErrSignerUnavailable = errors.New("signer: signing-service unavailable")
// ErrSigningDenied is returned on 403 (OPA / registry mismatch). The
// sink surfaces this to the caller — it is NOT retryable with the same
// identities.
var ErrSigningDenied = errors.New("signer: signing-service denied the request")
// Client is the DSSE sign client.
type Client struct {
BaseURL string
HTTPClient *http.Client
PocketIDToken auth.TokenSource // Authorization: Bearer …
WorkloadToken auth.TokenSource // X-Workload-Token: …
}
type signRequest struct {
KeyRef string `json:"key_ref"`
Statement Statement `json:"statement"`
}
// Sign calls POST /api/v1/sign/dsse. The signing-service returns a DSSE
// envelope plus the stable content_hash of the envelope.
func (c Client) Sign(ctx context.Context, keyRef string, stmt Statement) (SignedResult, error) {
if c.BaseURL == "" {
return SignedResult{}, errors.New("signer.Client: BaseURL is empty")
}
if c.PocketIDToken == nil {
return SignedResult{}, errors.New("signer.Client: PocketIDToken is required")
}
if c.WorkloadToken == nil {
return SignedResult{}, errors.New("signer.Client: WorkloadToken is required")
}
hc := c.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 10 * time.Second}
}
body, err := json.Marshal(signRequest{KeyRef: keyRef, Statement: stmt})
if err != nil {
return SignedResult{}, fmt.Errorf("signer: marshal request: %w", err)
}
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/sign/dsse")
if err != nil {
return SignedResult{}, fmt.Errorf("signer: build url: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return SignedResult{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
pTok, err := c.PocketIDToken.Token(ctx)
if err != nil {
return SignedResult{}, fmt.Errorf("signer: pocket-id token: %w", err)
}
if pTok == "" {
return SignedResult{}, errors.New("signer: pocket-id token is empty")
}
req.Header.Set("Authorization", "Bearer "+pTok)
wTok, err := c.WorkloadToken.Token(ctx)
if err != nil {
return SignedResult{}, fmt.Errorf("signer: workload token: %w", err)
}
if wTok == "" {
return SignedResult{}, errors.New("signer: workload token is empty")
}
req.Header.Set(WorkloadTokenHeader, wTok)
resp, err := hc.Do(req)
if err != nil {
return SignedResult{}, fmt.Errorf("%w: %v", ErrSignerUnavailable, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
switch {
case resp.StatusCode == http.StatusOK:
// fall through
case resp.StatusCode == http.StatusForbidden:
return SignedResult{}, fmt.Errorf("%w: %s", ErrSigningDenied, truncate(raw, 256))
case resp.StatusCode >= 500:
return SignedResult{}, fmt.Errorf("%w: status %d: %s", ErrSignerUnavailable, resp.StatusCode, truncate(raw, 256))
default:
return SignedResult{}, fmt.Errorf("signer: status %d: %s", resp.StatusCode, truncate(raw, 256))
}
var out SignedResult
if err := json.Unmarshal(raw, &out); err != nil {
return SignedResult{}, fmt.Errorf("signer: decode response: %w", err)
}
if out.ContentHash == "" {
return SignedResult{}, errors.New("signer: response missing content_hash")
}
return out, nil
}
func truncate(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}