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>
275 lines
9.1 KiB
Go
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")
|