// Package tlog is the verification-service's read-side client for the // transparency-log. It performs GET /api/v1/log/entries/{content_hash} // and returns the stored DSSE envelope together with the inclusion // proof that lets the verifier recompute the Merkle tree root // independently. // // The read endpoint is the companion of // services/transparency-log/pkg/httpapi POST /api/v1/log/entries. The // production TLog is Trillian-backed; dev/CI uses an in-memory Merkle // tree. Both expose the inclusion proof fields defined by // transparency-log/pkg/backend.Receipt (tree_size, tree_root_hash, // signed_tree_head_ref, hashes[]). See docs/adr/0005-signing-service // -and-transparency-log-api.md. package tlog import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "basicstack.de/proofedmesh/services/verification-service/internal/dsse" ) // InclusionProof mirrors the server-side ADR-0005 inclusion proof. type InclusionProof struct { TreeSize int64 `json:"tree_size"` TreeRootHash string `json:"tree_root_hash"` SignedTreeHeadRef string `json:"signed_tree_head_ref"` Hashes []string `json:"hashes"` } // Entry is the shape returned by GET /api/v1/log/entries/{content_hash}. // Note the embedded InclusionProof: the verifier recomputes the Merkle // tree root from (leaf, log_index, tree_size, hashes) and matches it // against TreeRootHash. The TLog's own storage is never trusted on its // word; the proof MUST close independently. type Entry struct { ContentHash string `json:"content_hash"` Envelope dsse.Envelope `json:"envelope"` LogIndex int64 `json:"log_index"` LoggedAt time.Time `json:"logged_at"` InclusionProof InclusionProof `json:"inclusion_proof"` } // ErrNotFound is returned on a 404 from the TLog read endpoint. var ErrNotFound = errors.New("tlog: content_hash not in transparency-log") // ErrTLogUnavailable classifies connection errors and 5xx responses. var ErrTLogUnavailable = errors.New("tlog: transparency-log unavailable") // Fetcher pulls a TLog entry by content_hash. type Fetcher interface { Fetch(ctx context.Context, contentHash string) (Entry, error) } // Client is the HTTP implementation of Fetcher. type Client struct { BaseURL string HTTPClient *http.Client // AuthToken is sent as "Authorization: Bearer " when non-empty. // Phase-1 TLog read endpoints are expected to accept the same // Pocket-ID identity the signing-service uses. AuthToken string } // Fetch retrieves the DSSE envelope, inclusion proof, and log metadata // for contentHash. contentHash must be "sha256:<64 hex>". func (c Client) Fetch(ctx context.Context, contentHash string) (Entry, error) { if c.BaseURL == "" { return Entry{}, errors.New("tlog.Client: BaseURL is empty") } ref := strings.TrimSpace(contentHash) if ref == "" { return Entry{}, errors.New("tlog.Client: content_hash is empty") } hc := c.HTTPClient if hc == nil { hc = &http.Client{Timeout: 10 * time.Second} } endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/log/entries/", url.PathEscape(ref)) if err != nil { return Entry{}, fmt.Errorf("tlog: build url: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return Entry{}, err } req.Header.Set("Accept", "application/json") if c.AuthToken != "" { req.Header.Set("Authorization", "Bearer "+c.AuthToken) } resp, err := hc.Do(req) if err != nil { return Entry{}, fmt.Errorf("%w: %v", ErrTLogUnavailable, err) } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) switch { case resp.StatusCode == http.StatusOK: case resp.StatusCode == http.StatusNotFound: return Entry{}, ErrNotFound case resp.StatusCode >= 500: return Entry{}, fmt.Errorf("%w: status %d: %s", ErrTLogUnavailable, resp.StatusCode, truncate(raw, 256)) default: return Entry{}, fmt.Errorf("tlog: status %d: %s", resp.StatusCode, truncate(raw, 256)) } var out Entry if err := json.Unmarshal(raw, &out); err != nil { return Entry{}, fmt.Errorf("tlog: decode response: %w", err) } if out.ContentHash != ref { return Entry{}, fmt.Errorf("tlog returned content_hash %q != requested %q", out.ContentHash, ref) } return out, nil } func truncate(b []byte, n int) string { if len(b) <= n { return string(b) } return string(b[:n]) + "…" }