// Package signer calls the signing-service's POST /api/v1/sign/dsse // endpoint per ADR-0005. The wire types are duplicated here (not imported // from the signing-service's internal package) so the approval-service // stays an independent module. package signer import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" ) // Statement is the in-toto v1 Statement handed to the signing-service. // Loosely typed via `map[string]any` so arbitrary predicate payloads can // flow through without an approval-service-local schema definition. type Statement map[string]any // EnvelopeSignature mirrors the DSSE spec. type EnvelopeSignature struct { KeyID string `json:"keyid"` Sig string `json:"sig"` } // Envelope is the DSSE envelope returned by the signing-service. type Envelope struct { PayloadType string `json:"payloadType"` Payload string `json:"payload"` Signatures []EnvelopeSignature `json:"signatures"` } // TokenSource supplies the Bearer token the signing-service expects. The // production wiring will plug in a Pocket-ID client-credentials source; // tests use StaticToken. type TokenSource interface { Token(ctx context.Context) (string, error) } // StaticToken is a trivial constant TokenSource. type StaticToken string // Token returns s. func (s StaticToken) Token(_ context.Context) (string, error) { return string(s), nil } // ErrSignerUnavailable is returned when the signing-service refuses the // connection, times out, or answers 5xx. The HTTP layer maps this to 502 // as DEV-608 requires. var ErrSignerUnavailable = errors.New("signer: signing-service unavailable") // Client is the DSSE sign client. type Client struct { BaseURL string HTTPClient *http.Client TokenSource TokenSource } type signRequest struct { KeyRef string `json:"key_ref"` Statement Statement `json:"statement"` } type signResponse struct { Envelope Envelope `json:"envelope"` ContentHash string `json:"content_hash"` SignedAt time.Time `json:"signed_at"` } // SignDSSE calls the signing-service and returns the DSSE envelope plus // its content hash. A connection-level or 5xx response from the signing- // service is reported as ErrSignerUnavailable. func (c Client) SignDSSE(ctx context.Context, keyRef string, stmt Statement) (Envelope, string, error) { if c.BaseURL == "" { return Envelope{}, "", errors.New("signer.Client: BaseURL is empty") } 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 Envelope{}, "", fmt.Errorf("marshal sign request: %w", err) } endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/sign/dsse") if err != nil { return Envelope{}, "", fmt.Errorf("join signing-service url: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return Envelope{}, "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") if c.TokenSource != nil { tok, err := c.TokenSource.Token(ctx) if err != nil { return Envelope{}, "", fmt.Errorf("token: %w", err) } if tok != "" { req.Header.Set("Authorization", "Bearer "+tok) } } resp, err := hc.Do(req) if err != nil { return Envelope{}, "", fmt.Errorf("%w: %v", ErrSignerUnavailable, err) } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode >= 500 { return Envelope{}, "", fmt.Errorf("%w: status %d: %s", ErrSignerUnavailable, resp.StatusCode, truncate(raw, 256)) } if resp.StatusCode != http.StatusOK { return Envelope{}, "", fmt.Errorf("signer: status %d: %s", resp.StatusCode, truncate(raw, 256)) } var out signResponse if err := json.Unmarshal(raw, &out); err != nil { return Envelope{}, "", fmt.Errorf("decode sign response: %w", err) } if out.ContentHash == "" { return Envelope{}, "", errors.New("signer: response missing content_hash") } return out.Envelope, out.ContentHash, nil } func truncate(b []byte, n int) string { if len(b) <= n { return string(b) } return string(b[:n]) + "…" }