2026-10-25 18:08:53 +00:00
|
|
|
package backend
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
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
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
2026-10-25 18:08:53 +00:00
|
|
|
"errors"
|
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
|
|
|
"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"
|
2026-10-25 18:08:53 +00:00
|
|
|
)
|
|
|
|
|
|
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
|
|
|
// 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.
|
2026-10-25 18:08:53 +00:00
|
|
|
//
|
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
|
|
|
// 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.
|
2026-10-25 18:08:53 +00:00
|
|
|
type Trillian struct {
|
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
|
|
|
// Endpoint is the gRPC address of the Trillian LogServer, e.g.
|
|
|
|
|
// "trillian-log-server.agent-trust.svc.cluster.local:8090".
|
2026-10-25 18:08:53 +00:00
|
|
|
Endpoint string
|
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
|
|
|
// LogID is the integer tree id this service writes to.
|
2026-10-25 18:08:53 +00:00
|
|
|
LogID int64
|
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
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-25 20:57:52 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// 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[:])
|
2026-10-25 18:08:53 +00:00
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// 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
|
2026-10-25 18:08:53 +00:00
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// 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.
|
2026-10-25 18:08:53 +00:00
|
|
|
var ErrTrillianUnavailable = errors.New("transparency-log: trillian backend not deployed")
|