ProofedMesh/services/dummy-sink/internal/signer/client.go
CTO (Paperclip) b45ad34169
All checks were successful
validate / JSON Schema (ajv) (push) Successful in 19s
build-images / signing-service image (push) Successful in 22s
validate / Rego (opa fmt + opa test) (push) Successful in 13s
validate / DB (migrate up/down dry-run) (push) Successful in 26s
validate / Go (vet + test) (push) Successful in 1m13s
dummy-sink: Phase-1 mock sink (sink:dummy-sink) (DEV-611)
Trivial Senken-Adapter per §7 of architecture V1.1. Accepts
{decision_id, payload_hash, payload, approval_ref, transform_ref}
on POST /api/v1/sink/dummy/deliver; calls verification-service's
/api/v1/verify/chain and branches:

- valid=true → atomic .tmp+rename write of payload to
  /data/dummy-sink/<decision_id>.json, builds a
  basicstack.de/attestations/handover/v1 statement, signs via
  signing-service with the double identity flow (Pocket-ID bearer
  + projected SA token), submits DSSE envelope to the TLog and
  returns 201 {handover_ref, log_index}.
- valid=false & replay=true → 409.
- valid=false → 403 {reasons:[…]}.

Replay protection lives in verification-service per the parent plan;
dummy-sink keeps no local replay store. The payload_hash field is
verified against both the raw and canonical form so a verifier can
reproduce either hash.

Tests use httptest-backed fakes for verify / sign / tlog and cover
the DEV-611 acceptance criteria plus the bad-request shapes:
- happy path writes the file, calls signer with the handover
  statement, submits the envelope to the TLog
- 403 keeps the disk and never calls signer/tlog
- replay returns 409 without re-signing
- payload_hash mismatch rejects before verify
- malformed bodies return 400
- verify unavailable → 502, signing denied → 403

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 20:20:40 +00:00

143 lines
4.7 KiB
Go

// 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]) + "…"
}