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")