ProofedMesh/services/transparency-log/pkg/backend/trillian.go
CTO (Paperclip) 47a7373ef2 transparency-log: add GET /api/v1/log/entries/{hash} + /healthz (DEV-613)
The verification-service and testagent-transform clients both call
`GET /api/v1/log/entries/{content_hash}` to read a stored envelope and
inclusion proof back out of the log, but the server previously only
implemented the POST (write) half of ADR-0005. Add:

- `Backend.Get` plus `backend.Entry` and `ErrEntryNotFound` so the
  memory backend can serve the read; Trillian stays stubbed and
  continues returning 503 (`ErrTrillianUnavailable`) until a later
  subtask wires the real read path.
- `GET /api/v1/log/entries/{hash}` on the HTTP server, routing 404 /
  503 / 500 with the same error envelope as the POST half.
- `GET /healthz` so the Phase-1 smoke-test harness can wait for the
  service to come up instead of racing against its bind.

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

275 lines
9.1 KiB
Go

package backend
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/google/trillian"
"github.com/google/trillian/types"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
// Trillian is the production backend that talks to a Trillian LogServer
// via gRPC. ADR-0005 §Follow-ups spells out the wiring: DSSE envelope as
// LogLeaf.LeafValue, content_hash (32 raw bytes) as LeafIdentityHash,
// QueueLeaf → poll GetInclusionProofByHash → return the proof against the
// latest signed tree head.
//
// Idempotency: Trillian treats QueueLeaf with an already-known
// LeafIdentityHash as a no-op and signals it via
// QueuedLogLeaf.Status.Code == ALREADY_EXISTS. We translate that into
// fresh=false, so a repeated submit from the admin-api orchestrator still
// gets a 200 OK plus a valid current proof.
type Trillian struct {
// Endpoint is the gRPC address of the Trillian LogServer, e.g.
// "trillian-log-server.agent-trust.svc.cluster.local:8090".
Endpoint string
// LogID is the integer tree id this service writes to.
LogID int64
// PollInterval is the delay between inclusion-proof retries while the
// LogSigner integrates the queued leaf. Defaults to 100ms.
PollInterval time.Duration
// PollTimeout bounds the wait for the leaf to be sequenced. Defaults
// to 10s, well above the default sequencer interval (200ms) in the
// deploy/trillian LogSigner.
PollTimeout time.Duration
// DialOptions lets callers inject TLS or interceptors. If nil the
// connection is dialed with insecure.NewCredentials() — fine inside
// the agent-trust namespace behind cluster mTLS, not fine outside it.
DialOptions []grpc.DialOption
// Clock is injectable for tests. Defaults to time.Now().UTC().
Clock func() time.Time
connOnce sync.Once
conn *grpc.ClientConn
client trillian.TrillianLogClient
dialErr error
}
// Get is not yet wired against Trillian; the production read path is a
// future subtask. The handler short-circuits to 503 when the client is
// absent, so this stub only has to satisfy the interface.
func (t *Trillian) Get(_ context.Context, _ string) (Entry, error) {
return Entry{}, ErrTrillianUnavailable
}
// Submit queues the envelope and returns the inclusion proof against the
// current signed tree head. See the Backend interface for the fresh
// semantics.
func (t *Trillian) Submit(ctx context.Context, env Envelope, contentHash string) (Receipt, bool, error) {
if contentHash == "" {
return Receipt{}, false, errors.New("transparency-log: empty content_hash")
}
// Unconfigured backend — map to the historical "trillian unavailable"
// signal so the httpapi layer keeps returning 503 instead of 500 when
// the operator forgot to supply --trillian-log-id or --trillian-addr.
if t.LogID == 0 || t.Endpoint == "" {
return Receipt{}, false, ErrTrillianUnavailable
}
recomputed, err := EnvelopeContentHash(env)
if err != nil {
return Receipt{}, false, err
}
if recomputed != contentHash {
return Receipt{}, false, ErrContentHashMismatch
}
canonical, err := canonicalJSON(env)
if err != nil {
return Receipt{}, false, err
}
identity, err := decodeSHA256HexPrefixed(contentHash)
if err != nil {
return Receipt{}, false, err
}
if err := t.ensureClient(); err != nil {
return Receipt{}, false, err
}
// QueueLeaf.
queueResp, err := t.client.QueueLeaf(ctx, &trillian.QueueLeafRequest{
LogId: t.LogID,
Leaf: &trillian.LogLeaf{
LeafValue: canonical,
LeafIdentityHash: identity,
},
})
if err != nil {
return Receipt{}, false, fmt.Errorf("transparency-log: trillian QueueLeaf: %w", err)
}
fresh := true
if queueResp.GetQueuedLeaf() != nil && queueResp.GetQueuedLeaf().GetStatus() != nil {
if codes.Code(queueResp.GetQueuedLeaf().GetStatus().GetCode()) == codes.AlreadyExists {
fresh = false
}
}
// Poll for the inclusion proof. The merkle leaf hash is the RFC 6962
// leaf hash of the canonical envelope — same hashing we use in the
// in-memory backend, which keeps the two backends interchangeable.
merkleHash := leafHash(canonical)
receipt, err := t.waitForInclusionProof(ctx, merkleHash, contentHash)
if err != nil {
return Receipt{}, false, err
}
return receipt, fresh, nil
}
// waitForInclusionProof polls GetLatestSignedLogRoot + GetInclusionProofByHash
// until the queued leaf has been sequenced into the tree. See PollInterval
// and PollTimeout for tuning.
func (t *Trillian) waitForInclusionProof(ctx context.Context, merkleHash []byte, contentHash string) (Receipt, error) {
pollInterval := t.PollInterval
if pollInterval <= 0 {
pollInterval = 100 * time.Millisecond
}
pollTimeout := t.PollTimeout
if pollTimeout <= 0 {
pollTimeout = 10 * time.Second
}
deadline := time.Now().Add(pollTimeout)
for {
slr, treeSize, rootHash, err := t.latestSignedRoot(ctx)
if err != nil {
return Receipt{}, err
}
if treeSize > 0 {
proof, err := t.client.GetInclusionProofByHash(ctx, &trillian.GetInclusionProofByHashRequest{
LogId: t.LogID,
LeafHash: merkleHash,
TreeSize: int64(treeSize),
})
switch {
case err == nil && len(proof.GetProof()) > 0:
p := proof.GetProof()[0]
hashes := make([]string, 0, len(p.GetHashes()))
for _, h := range p.GetHashes() {
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
}
return Receipt{
LogIndex: p.GetLeafIndex(),
ContentHash: contentHash,
LoggedAt: t.clock(),
InclusionProof: InclusionProof{
TreeSize: int64(treeSize),
TreeRootHash: "sha256:" + hex.EncodeToString(rootHash),
SignedTreeHeadRef: signedTreeHeadRefTrillian(slr),
Hashes: hashes,
},
}, nil
case err != nil && status.Code(err) != codes.NotFound:
return Receipt{}, fmt.Errorf("transparency-log: trillian GetInclusionProofByHash: %w", err)
}
}
// Not sequenced yet (NotFound or TreeSize==0). Sleep + retry up to the deadline.
if time.Now().After(deadline) {
return Receipt{}, fmt.Errorf("transparency-log: trillian inclusion proof wait exceeded %s", pollTimeout)
}
select {
case <-ctx.Done():
return Receipt{}, ctx.Err()
case <-time.After(pollInterval):
}
}
}
// latestSignedRoot pulls the current STH and decodes TreeSize + RootHash.
func (t *Trillian) latestSignedRoot(ctx context.Context) (*trillian.SignedLogRoot, uint64, []byte, error) {
resp, err := t.client.GetLatestSignedLogRoot(ctx, &trillian.GetLatestSignedLogRootRequest{
LogId: t.LogID,
})
if err != nil {
return nil, 0, nil, fmt.Errorf("transparency-log: trillian GetLatestSignedLogRoot: %w", err)
}
slr := resp.GetSignedLogRoot()
if slr == nil {
return nil, 0, nil, errors.New("transparency-log: trillian GetLatestSignedLogRoot returned nil root")
}
var lr types.LogRootV1
if err := lr.UnmarshalBinary(slr.GetLogRoot()); err != nil {
return nil, 0, nil, fmt.Errorf("transparency-log: trillian LogRoot decode: %w", err)
}
return slr, lr.TreeSize, lr.RootHash, nil
}
func (t *Trillian) ensureClient() error {
t.connOnce.Do(func() {
opts := t.DialOptions
if len(opts) == 0 {
opts = []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
}
conn, err := grpc.NewClient(t.Endpoint, opts...)
if err != nil {
t.dialErr = fmt.Errorf("transparency-log: trillian dial %q: %w", t.Endpoint, err)
return
}
t.conn = conn
t.client = trillian.NewTrillianLogClient(conn)
})
return t.dialErr
}
// Close releases the gRPC connection. Safe to call multiple times.
func (t *Trillian) Close() error {
if t.conn != nil {
return t.conn.Close()
}
return nil
}
func (t *Trillian) clock() time.Time {
if t.Clock != nil {
return t.Clock()
}
return time.Now().UTC()
}
// signedTreeHeadRefTrillian returns a stable reference to the current STH.
// We hash the serialised LogRoot bytes (which include tree size, root hash
// and revision) so the ref is deterministic per head and does not depend
// on the signature algorithm chosen for the Trees row.
func signedTreeHeadRefTrillian(slr *trillian.SignedLogRoot) string {
if slr == nil {
return ""
}
h := sha256.Sum256(slr.GetLogRoot())
return "sha256:" + hex.EncodeToString(h[:])
}
// decodeSHA256HexPrefixed decodes "sha256:<64 hex>" into 32 raw bytes.
// Used both for the Trillian LeafIdentityHash and defensive validation
// at the public Submit entry point.
func decodeSHA256HexPrefixed(s string) ([]byte, error) {
hexPart := strings.TrimPrefix(s, "sha256:")
if len(hexPart) != 64 {
return nil, fmt.Errorf("transparency-log: expected sha256:<64 hex>, got %q", s)
}
b, err := hex.DecodeString(hexPart)
if err != nil {
return nil, fmt.Errorf("transparency-log: content_hash hex decode: %w", err)
}
return b, nil
}
// ErrTrillianUnavailable is kept for backward compatibility with existing
// callers / tests. The httpapi layer still maps it to HTTP 503 for the
// "trillian not configured" and "dial failed" cases that happen before a
// request reaches the LogServer. Real server-side errors from Trillian
// surface as regular wrapped errors and map to 500.
var ErrTrillianUnavailable = errors.New("transparency-log: trillian backend not deployed")