// Package signer calls the signing-service's POST /api/v1/sign/dsse per // ADR-0005 using the double-identity flow: // // - Authorization: Bearer (OIDC client identity) // - X-Workload-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]) + "…" }