ProofedMesh/services/transparency-log/pkg/backend/trillian.go
CTO 951f22f80e
Some checks failed
build-images / signing-service image (push) Failing after 14s
validate / JSON Schema (ajv) (push) Successful in 21s
validate / Rego (opa fmt + opa test) (push) Successful in 14s
validate / Go (vet + test) (push) Successful in 30s
transparency-log: real Trillian backend wiring + agent-trust deploy (DEV-604)
- services/transparency-log/pkg/backend/trillian.go: real gRPC client
  on github.com/google/trillian v1.6.1. Submit() does QueueLeaf with the
  canonical DSSE envelope as LeafValue and the raw content_hash bytes as
  LeafIdentityHash, then polls GetLatestSignedLogRoot +
  GetInclusionProofByHash until the leaf is sequenced. Idempotency uses
  Trillian's ALREADY_EXISTS signal on the inner QueuedLogLeaf.Status, so
  a repeated submit still returns 200 OK with the originally-assigned
  log_index + a valid proof against the current STH.
- Unconfigured backend (missing endpoint or LogID) returns the historical
  ErrTrillianUnavailable so the httpapi layer keeps mapping it to 503
  instead of silently 500. Existing stub-returns-503 test updated.
- cmd/transparency-log: pointer-receiver Trillian wiring; flag docs
  mention the production SRV name.
- services/transparency-log/Dockerfile: distroless multi-stage build
  used by deploy/trillian/40-transparency-log.yaml.

- deploy/trillian/: full K8s stack for the agent-trust namespace —
  MySQL 8.4 StatefulSet (schema init ConfigMap vendored from upstream
  storage.sql v1.6.1), Trillian LogServer + LogSigner Deployments on
  gcr.io/trillian-opensource-ci:v1.6.1, createtree Job that writes
  the TreeID to a ConfigMap (re-apply safe), transparency-log
  Deployment with --backend=trillian pulling TRILLIAN_LOG_ID from
  the ConfigMap.
- deploy/trillian/README.md: first-time apply + smoke-test + secret
  rotation + signer-key clarification (STH key is Trillian-internal;
  DSSE envelope signing stays with signing-service / OpenBao).

- services/admin-api: go.mod / go.sum pick up the new transitive deps
  (trillian + grpc) via the local replace on transparency-log.
- services/transparency-log/README.md: Backends table updated; next-
  steps list no longer carries Trillian-deployment as a follow-up.

Deployed to the live cluster from this heartbeat: MySQL + LogServer +
LogSigner running in agent-trust, tree id 8482455225499059043, service
smoke-tested end-to-end (201 on first POST, 200 on idempotent repeat,
409 on content_hash mismatch, tree_size=2 with correct audit path).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 18:39:44 +00:00

268 lines
8.8 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
}
// 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")