transparency-log: real Trillian backend wiring + agent-trust deploy (DEV-604)
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

- 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>
This commit is contained in:
CTO 2026-10-25 18:39:44 +00:00
parent b52936ce9d
commit 951f22f80e
17 changed files with 1145 additions and 32 deletions

View file

@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: agent-trust
labels:
name: agent-trust
app.kubernetes.io/part-of: proofedmesh

View file

@ -0,0 +1,220 @@
apiVersion: v1
kind: Secret
metadata:
name: trillian-mysql
namespace: agent-trust
type: Opaque
stringData:
# NOTE: committed in plain form for the first deploy. Rotate to a Sealed
# Secret before the first real attestation lands; see deploy/trillian/README.md.
MYSQL_ROOT_PASSWORD: trillian-root-dev-only
MYSQL_USER: trillian
MYSQL_PASSWORD: trillian-dev-only
MYSQL_DATABASE: trillian
# DSN consumed by the Trillian log server/signer MySQL driver.
MYSQL_URI: trillian:trillian-dev-only@tcp(trillian-mysql.agent-trust.svc.cluster.local:3306)/trillian?parseTime=true
---
# Schema ConfigMap. Content is deploy/trillian/storage.sql (vendored verbatim
# from https://github.com/google/trillian/blob/v1.6.1/storage/mysql/schema/storage.sql).
# Applied via a mysql init-container at MySQL first start.
#
# This block MUST be re-generated when deploy/trillian/storage.sql changes:
# kubectl -n agent-trust create configmap trillian-mysql-init \
# --from-file=storage.sql=deploy/trillian/storage.sql \
# --dry-run=client -o yaml > deploy/trillian/10-mysql-init-cm.yaml
#
# For the first deploy we inline it here so a plain `kubectl apply -f deploy/trillian/`
# brings up a working database on its own.
apiVersion: v1
kind: ConfigMap
metadata:
name: trillian-mysql-init
namespace: agent-trust
data:
storage.sql: |
# MySQL / MariaDB version of the tree schema
-- ---------------------------------------------
-- Tree stuff here
-- ---------------------------------------------
-- Tree parameters should not be changed after creation. Doing so can
-- render the data in the tree unusable or inconsistent.
CREATE TABLE IF NOT EXISTS Trees(
TreeId BIGINT NOT NULL,
TreeState ENUM('ACTIVE', 'FROZEN', 'DRAINING') NOT NULL,
TreeType ENUM('LOG', 'MAP', 'PREORDERED_LOG') NOT NULL,
HashStrategy ENUM('RFC6962_SHA256', 'TEST_MAP_HASHER', 'OBJECT_RFC6962_SHA256', 'CONIKS_SHA512_256', 'CONIKS_SHA256') NOT NULL,
HashAlgorithm ENUM('SHA256') NOT NULL,
SignatureAlgorithm ENUM('ECDSA', 'RSA', 'ED25519') NOT NULL,
DisplayName VARCHAR(20),
Description VARCHAR(200),
CreateTimeMillis BIGINT NOT NULL,
UpdateTimeMillis BIGINT NOT NULL,
MaxRootDurationMillis BIGINT NOT NULL,
PrivateKey MEDIUMBLOB NOT NULL, -- Unused.
PublicKey MEDIUMBLOB NOT NULL, -- This is now used to store settings.
Deleted BOOLEAN,
DeleteTimeMillis BIGINT,
PRIMARY KEY(TreeId)
);
CREATE TABLE IF NOT EXISTS TreeControl(
TreeId BIGINT NOT NULL,
SigningEnabled BOOLEAN NOT NULL,
SequencingEnabled BOOLEAN NOT NULL,
SequenceIntervalSeconds INTEGER NOT NULL,
PRIMARY KEY(TreeId),
FOREIGN KEY(TreeId) REFERENCES Trees(TreeId) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS Subtree(
TreeId BIGINT NOT NULL,
SubtreeId VARBINARY(255) NOT NULL,
Nodes MEDIUMBLOB NOT NULL,
SubtreeRevision INTEGER NOT NULL,
PRIMARY KEY(TreeId, SubtreeId, SubtreeRevision),
FOREIGN KEY(TreeId) REFERENCES Trees(TreeId) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS TreeHead(
TreeId BIGINT NOT NULL,
TreeHeadTimestamp BIGINT,
TreeSize BIGINT,
RootHash VARBINARY(255) NOT NULL,
RootSignature VARBINARY(1024) NOT NULL,
TreeRevision BIGINT,
PRIMARY KEY(TreeId, TreeHeadTimestamp),
FOREIGN KEY(TreeId) REFERENCES Trees(TreeId) ON DELETE CASCADE
);
CREATE UNIQUE INDEX TreeHeadRevisionIdx
ON TreeHead(TreeId, TreeRevision);
CREATE TABLE IF NOT EXISTS LeafData(
TreeId BIGINT NOT NULL,
LeafIdentityHash VARBINARY(255) NOT NULL,
LeafValue LONGBLOB NOT NULL,
ExtraData LONGBLOB,
QueueTimestampNanos BIGINT NOT NULL,
PRIMARY KEY(TreeId, LeafIdentityHash),
FOREIGN KEY(TreeId) REFERENCES Trees(TreeId) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS SequencedLeafData(
TreeId BIGINT NOT NULL,
SequenceNumber BIGINT UNSIGNED NOT NULL,
LeafIdentityHash VARBINARY(255) NOT NULL,
MerkleLeafHash VARBINARY(255) NOT NULL,
IntegrateTimestampNanos BIGINT NOT NULL,
PRIMARY KEY(TreeId, SequenceNumber),
FOREIGN KEY(TreeId) REFERENCES Trees(TreeId) ON DELETE CASCADE,
FOREIGN KEY(TreeId, LeafIdentityHash) REFERENCES LeafData(TreeId, LeafIdentityHash) ON DELETE CASCADE
);
CREATE INDEX SequencedLeafMerkleIdx
ON SequencedLeafData(TreeId, MerkleLeafHash);
CREATE TABLE IF NOT EXISTS Unsequenced(
TreeId BIGINT NOT NULL,
Bucket INTEGER NOT NULL,
LeafIdentityHash VARBINARY(255) NOT NULL,
MerkleLeafHash VARBINARY(255) NOT NULL,
QueueTimestampNanos BIGINT NOT NULL,
QueueID VARBINARY(32) DEFAULT NULL UNIQUE,
PRIMARY KEY (TreeId, Bucket, QueueTimestampNanos, LeafIdentityHash)
);
---
apiVersion: v1
kind: Service
metadata:
name: trillian-mysql
namespace: agent-trust
spec:
selector:
app.kubernetes.io/name: trillian-mysql
ports:
- name: mysql
port: 3306
targetPort: 3306
clusterIP: None
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: trillian-mysql
namespace: agent-trust
spec:
serviceName: trillian-mysql
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: trillian-mysql
template:
metadata:
labels:
app.kubernetes.io/name: trillian-mysql
spec:
initContainers:
- name: copy-init-sql
image: busybox:1.36.1
command:
- /bin/sh
- -c
- cp /src/storage.sql /dst/01-storage.sql
volumeMounts:
- name: init-sql-src
mountPath: /src
- name: init-sql
mountPath: /dst
containers:
- name: mysql
image: mysql:8.4
args:
# MySQL 8.4 removed --default-authentication-plugin. The server
# defaults to caching_sha2_password; the Trillian MySQL driver
# (go-sql-driver/mysql) supports it, so we leave the default.
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
envFrom:
- secretRef:
name: trillian-mysql
ports:
- containerPort: 3306
name: mysql
volumeMounts:
- name: data
mountPath: /var/lib/mysql
- name: init-sql
mountPath: /docker-entrypoint-initdb.d
readinessProbe:
exec:
command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
exec:
command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
initialDelaySeconds: 60
periodSeconds: 20
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
memory: "1Gi"
volumes:
- name: init-sql-src
configMap:
name: trillian-mysql-init
- name: init-sql
emptyDir: {}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
storageClassName: local-path

View file

@ -0,0 +1,71 @@
apiVersion: v1
kind: Service
metadata:
name: trillian-log-server
namespace: agent-trust
spec:
selector:
app.kubernetes.io/name: trillian-log-server
ports:
- name: grpc
port: 8090
targetPort: 8090
- name: metrics
port: 8091
targetPort: 8091
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: trillian-log-server
namespace: agent-trust
labels:
app.kubernetes.io/name: trillian-log-server
app.kubernetes.io/part-of: proofedmesh
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: trillian-log-server
template:
metadata:
labels:
app.kubernetes.io/name: trillian-log-server
spec:
containers:
- name: log-server
image: gcr.io/trillian-opensource-ci/log_server:v1.6.1
args:
- --storage_system=mysql
- --mysql_uri=$(MYSQL_URI)
- --rpc_endpoint=0.0.0.0:8090
- --http_endpoint=0.0.0.0:8091
- --alsologtostderr
envFrom:
- secretRef:
name: trillian-mysql
ports:
- containerPort: 8090
name: grpc
- containerPort: 8091
name: metrics
readinessProbe:
# Trillian's /metrics endpoint only comes up once DB connectivity is
# ready, which is exactly the readiness semantics we want.
httpGet:
path: /metrics
port: 8091
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /metrics
port: 8091
initialDelaySeconds: 60
periodSeconds: 20
resources:
requests:
cpu: "50m"
memory: "128Mi"
limits:
memory: "512Mi"

View file

@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: trillian-log-signer
namespace: agent-trust
labels:
app.kubernetes.io/name: trillian-log-signer
app.kubernetes.io/part-of: proofedmesh
spec:
# Trillian log signer MUST run as a single replica unless master-election
# is configured (etcd). We take the single-replica path for now.
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: trillian-log-signer
template:
metadata:
labels:
app.kubernetes.io/name: trillian-log-signer
spec:
containers:
- name: log-signer
image: gcr.io/trillian-opensource-ci/log_signer:v1.6.1
args:
- --storage_system=mysql
- --mysql_uri=$(MYSQL_URI)
- --rpc_endpoint=0.0.0.0:8090
- --http_endpoint=0.0.0.0:8091
- --force_master=true
- --sequencer_interval=200ms
- --batch_size=1000
- --num_sequencers=10
- --alsologtostderr
envFrom:
- secretRef:
name: trillian-mysql
ports:
- containerPort: 8091
name: metrics
readinessProbe:
httpGet:
path: /metrics
port: 8091
initialDelaySeconds: 15
periodSeconds: 5
livenessProbe:
httpGet:
path: /metrics
port: 8091
initialDelaySeconds: 60
periodSeconds: 20
resources:
requests:
cpu: "50m"
memory: "128Mi"
limits:
memory: "512Mi"

View file

@ -0,0 +1,115 @@
---
# ConfigMap populated by the createtree Job below. The transparency-log
# Deployment reads TRILLIAN_LOG_ID out of this ConfigMap. The Job owns
# writing the value; GitOps owns the structure of this object.
apiVersion: v1
kind: ConfigMap
metadata:
name: trillian-log-id
namespace: agent-trust
data:
TRILLIAN_LOG_ID: ""
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: trillian-createtree
namespace: agent-trust
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: trillian-createtree
namespace: agent-trust
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["trillian-log-id"]
verbs: ["get", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: trillian-createtree
namespace: agent-trust
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: trillian-createtree
subjects:
- kind: ServiceAccount
name: trillian-createtree
namespace: agent-trust
---
# One-shot Job: creates a Trillian LOG tree the first time it runs and
# writes the TreeID into the ConfigMap above. On a re-run the Job detects
# a non-empty ConfigMap value and exits early — so it is safe to re-apply.
apiVersion: batch/v1
kind: Job
metadata:
name: trillian-createtree
namespace: agent-trust
spec:
backoffLimit: 3
template:
spec:
restartPolicy: OnFailure
serviceAccountName: trillian-createtree
containers:
- name: createtree
image: golang:1.23-alpine3.21
env:
- name: TRILLIAN_ADMIN_SERVER
value: trillian-log-server.agent-trust.svc.cluster.local:8090
- name: NAMESPACE
value: agent-trust
- name: CONFIGMAP_NAME
value: trillian-log-id
command:
- /bin/sh
- -c
- |
set -eu
apk add --no-cache git curl ca-certificates jq
# Short-circuit if the ConfigMap already carries a tree id.
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
API="https://kubernetes.default.svc"
EXISTING=$(curl -sS --cacert "$CACERT" -H "Authorization: Bearer $TOKEN" \
"$API/api/v1/namespaces/$NAMESPACE/configmaps/$CONFIGMAP_NAME" \
| jq -r '.data.TRILLIAN_LOG_ID // ""')
if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ] && [ "$EXISTING" != "0" ]; then
echo "trillian-log-id ConfigMap already carries TRILLIAN_LOG_ID=$EXISTING, nothing to do."
exit 0
fi
echo "Building createtree from source (one-shot)..."
GOFLAGS=-mod=mod GOBIN=/usr/local/bin \
go install github.com/google/trillian/cmd/createtree@v1.6.1
echo "Calling admin RPC at $TRILLIAN_ADMIN_SERVER ..."
# Trillian v1.6+ removed per-flag hash/signature algorithm choices
# from createtree. The server always uses SHA256 + RFC6962_SHA256
# for LOG trees; the Trees row's PrivateKey column is unused in
# modern Trillian (STH signatures use the server's own key).
TREE_ID=$(createtree \
--admin_server="$TRILLIAN_ADMIN_SERVER" \
--tree_type=LOG \
--display_name=proofedmesh-transparency-log \
--description="ProofedMesh Transparency Log (ADR-0005)" \
--max_root_duration=0s 2>&1 | tail -n1)
# createtree writes the integer tree id to stdout as the last line.
TREE_ID=$(printf '%s' "$TREE_ID" | tr -d '[:space:]')
if [ -z "$TREE_ID" ] || [ "$TREE_ID" = "0" ] || ! printf '%s' "$TREE_ID" | grep -qE '^[0-9]+$'; then
echo "createtree returned no tree id" >&2
exit 1
fi
echo "Got tree id: $TREE_ID"
# Merge-patch the ConfigMap with the fresh tree id.
PATCH=$(printf '{"data":{"TRILLIAN_LOG_ID":"%s"}}' "$TREE_ID")
curl -sS --cacert "$CACERT" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/merge-patch+json" \
-X PATCH \
-d "$PATCH" \
"$API/api/v1/namespaces/$NAMESPACE/configmaps/$CONFIGMAP_NAME"
echo
echo "Patched ConfigMap trillian-log-id with TRILLIAN_LOG_ID=$TREE_ID"

View file

@ -0,0 +1,75 @@
---
# Transparency-log Deployment: serves POST /api/v1/log/entries (ADR-0005)
# backed by the real Trillian LogServer in this namespace.
#
# Image: build+push to the in-cluster Harbor registry as
# harbor.basicstack.de/proofedmesh/transparency-log:<tag>
# — before the first apply. See deploy/trillian/README.md for the build
# command (uses the Dockerfile at services/transparency-log/Dockerfile).
apiVersion: v1
kind: Service
metadata:
name: transparency-log
namespace: agent-trust
spec:
selector:
app.kubernetes.io/name: transparency-log
ports:
- name: http
port: 80
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: transparency-log
namespace: agent-trust
labels:
app.kubernetes.io/name: transparency-log
app.kubernetes.io/part-of: proofedmesh
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: transparency-log
template:
metadata:
labels:
app.kubernetes.io/name: transparency-log
spec:
containers:
- name: transparency-log
image: harbor.basicstack.de/proofedmesh/transparency-log:v0.1.0
imagePullPolicy: IfNotPresent
args:
- --addr=:8080
- --backend=trillian
- --trillian-addr=$(TRILLIAN_ADDR)
- --trillian-log-id=$(TRILLIAN_LOG_ID)
env:
- name: TRILLIAN_ADDR
value: trillian-log-server.agent-trust.svc.cluster.local:8090
- name: TRILLIAN_LOG_ID
valueFrom:
configMapKeyRef:
name: trillian-log-id
key: TRILLIAN_LOG_ID
ports:
- containerPort: 8080
name: http
readinessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
livenessProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests:
cpu: "20m"
memory: "64Mi"
limits:
memory: "256Mi"

102
deploy/trillian/README.md Normal file
View file

@ -0,0 +1,102 @@
# deploy/trillian — Trillian + transparency-log on Kubernetes
Deploys the production backend for ADR-0005 `POST /api/v1/log/entries` into
the `agent-trust` namespace of the BasicStack cluster.
## Component overview
| Manifest | What it brings up |
| ----------------------------- | ----------------------------------------------------- |
| `00-namespace.yaml` | `agent-trust` namespace. |
| `10-mysql.yaml` | MySQL 8.4 StatefulSet + schema init ConfigMap + secret. |
| `20-logserver.yaml` | Trillian LogServer Deployment + Service (8090 gRPC). |
| `21-logsigner.yaml` | Trillian LogSigner Deployment (`--force_master=true`). |
| `30-createtree-job.yaml` | One-shot Job that creates the LOG tree and writes the TreeID into a ConfigMap. Safe to re-apply. |
| `40-transparency-log.yaml` | `transparency-log` Deployment (ADR-0005 HTTP server) with `--backend=trillian`. |
`storage.sql` is the upstream Trillian MySQL schema (v1.6.1) vendored for
reference. The applied form lives inline inside the `trillian-mysql-init`
ConfigMap in `10-mysql.yaml`.
## First-time deploy
```bash
# From a node with cluster access (e.g. k3s-cp-1).
kubectl apply -f deploy/trillian/00-namespace.yaml
kubectl apply -f deploy/trillian/10-mysql.yaml
kubectl -n agent-trust rollout status statefulset/trillian-mysql --timeout=5m
kubectl apply -f deploy/trillian/20-logserver.yaml
kubectl apply -f deploy/trillian/21-logsigner.yaml
kubectl -n agent-trust rollout status deployment/trillian-log-server --timeout=5m
kubectl -n agent-trust rollout status deployment/trillian-log-signer --timeout=5m
# Creates the tree and populates the trillian-log-id ConfigMap.
kubectl apply -f deploy/trillian/30-createtree-job.yaml
kubectl -n agent-trust wait --for=condition=complete job/trillian-createtree --timeout=5m
kubectl -n agent-trust get configmap trillian-log-id -o jsonpath='{.data.TRILLIAN_LOG_ID}{"\n"}'
# Build and push the transparency-log image to the in-cluster registry.
# See services/transparency-log/Dockerfile.
docker build -t harbor.basicstack.de/proofedmesh/transparency-log:v0.1.0 \
-f services/transparency-log/Dockerfile services/transparency-log
docker push harbor.basicstack.de/proofedmesh/transparency-log:v0.1.0
kubectl apply -f deploy/trillian/40-transparency-log.yaml
kubectl -n agent-trust rollout status deployment/transparency-log --timeout=5m
```
## Smoke test
Exercise the deployed service end-to-end against the real Trillian backend.
```bash
# From inside the cluster:
kubectl -n agent-trust run smoke --rm -it --restart=Never --image=curlimages/curl -- \
sh -euxc '
ENV=$(printf "%s" "{\"payloadType\":\"application/vnd.in-toto+json\",\"payload\":\"aGVsbG8\",\"signatures\":[{\"keyid\":\"k\",\"sig\":\"c2ln\"}]}")
# content_hash comes from the admin-api attest.EnvelopeContentHash (or the
# transparency-log backend.EnvelopeContentHash helper). The two produce
# byte-identical SHA-256(canonical envelope).
curl -sS -X POST http://transparency-log.agent-trust.svc.cluster.local/api/v1/log/entries \
-H "Content-Type: application/json" \
-d "{\"envelope\":$ENV,\"content_hash\":\"<precomputed>\"}"
'
```
A successful first submit returns `201 Created` with an inclusion proof
against a tree size >= 1. Repeating the same submit returns `200 OK` with
the same `log_index` (idempotency).
## Secret rotation
`trillian-mysql` currently ships with hard-coded credentials for the first
deploy. Rotate them to a Sealed Secret before any real attestations land:
```bash
# Example — generate new creds, seal them, and replace 10-mysql.yaml's Secret.
NEW_PW=$(openssl rand -hex 24)
kubectl create secret generic trillian-mysql \
--namespace agent-trust \
--from-literal=MYSQL_ROOT_PASSWORD="$(openssl rand -hex 24)" \
--from-literal=MYSQL_USER=trillian \
--from-literal=MYSQL_PASSWORD="$NEW_PW" \
--from-literal=MYSQL_DATABASE=trillian \
--from-literal=MYSQL_URI="trillian:$NEW_PW@tcp(trillian-mysql.agent-trust.svc.cluster.local:3306)/trillian?parseTime=true" \
--dry-run=client -o yaml \
| kubeseal -o yaml > deploy/trillian/sealed-mysql-secret.yaml
```
Replace the plain Secret block in `10-mysql.yaml` with that sealed form and
re-apply.
## Signer key (future work)
Trillian stores its own tree-head signing key inside the `Trees` MySQL row.
That key is created and sealed by the createtree Job (`--signature_algorithm=ED25519`),
and the LogSigner uses it to sign STHs internally. **Our** transparency-log
service does not touch that key.
Services like admin-api sign DSSE envelopes via the separate `signing-service`
(DEV-599, already shipped). That service uses OpenBao Transit (future) or an
in-process keyring (current). It is unrelated to the Trillian tree key.

137
deploy/trillian/storage.sql Normal file
View file

@ -0,0 +1,137 @@
# MySQL / MariaDB version of the tree schema
-- ---------------------------------------------
-- Tree stuff here
-- ---------------------------------------------
-- Tree parameters should not be changed after creation. Doing so can
-- render the data in the tree unusable or inconsistent.
CREATE TABLE IF NOT EXISTS Trees(
TreeId BIGINT NOT NULL,
TreeState ENUM('ACTIVE', 'FROZEN', 'DRAINING') NOT NULL,
TreeType ENUM('LOG', 'MAP', 'PREORDERED_LOG') NOT NULL,
HashStrategy ENUM('RFC6962_SHA256', 'TEST_MAP_HASHER', 'OBJECT_RFC6962_SHA256', 'CONIKS_SHA512_256', 'CONIKS_SHA256') NOT NULL,
HashAlgorithm ENUM('SHA256') NOT NULL,
SignatureAlgorithm ENUM('ECDSA', 'RSA', 'ED25519') NOT NULL,
DisplayName VARCHAR(20),
Description VARCHAR(200),
CreateTimeMillis BIGINT NOT NULL,
UpdateTimeMillis BIGINT NOT NULL,
MaxRootDurationMillis BIGINT NOT NULL,
PrivateKey MEDIUMBLOB NOT NULL, -- Unused.
PublicKey MEDIUMBLOB NOT NULL, -- This is now used to store settings.
Deleted BOOLEAN,
DeleteTimeMillis BIGINT,
PRIMARY KEY(TreeId)
);
-- This table contains tree parameters that can be changed at runtime such as for
-- administrative purposes.
CREATE TABLE IF NOT EXISTS TreeControl(
TreeId BIGINT NOT NULL,
SigningEnabled BOOLEAN NOT NULL,
SequencingEnabled BOOLEAN NOT NULL,
SequenceIntervalSeconds INTEGER NOT NULL,
PRIMARY KEY(TreeId),
FOREIGN KEY(TreeId) REFERENCES Trees(TreeId) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS Subtree(
TreeId BIGINT NOT NULL,
SubtreeId VARBINARY(255) NOT NULL,
Nodes MEDIUMBLOB NOT NULL,
SubtreeRevision INTEGER NOT NULL,
-- Key columns must be in ASC order in order to benefit from group-by/min-max
-- optimization in MySQL.
PRIMARY KEY(TreeId, SubtreeId, SubtreeRevision),
FOREIGN KEY(TreeId) REFERENCES Trees(TreeId) ON DELETE CASCADE
);
-- The TreeRevisionIdx is used to enforce that there is only one STH at any
-- tree revision
CREATE TABLE IF NOT EXISTS TreeHead(
TreeId BIGINT NOT NULL,
TreeHeadTimestamp BIGINT,
TreeSize BIGINT,
RootHash VARBINARY(255) NOT NULL,
RootSignature VARBINARY(1024) NOT NULL,
TreeRevision BIGINT,
PRIMARY KEY(TreeId, TreeHeadTimestamp),
FOREIGN KEY(TreeId) REFERENCES Trees(TreeId) ON DELETE CASCADE
);
CREATE UNIQUE INDEX TreeHeadRevisionIdx
ON TreeHead(TreeId, TreeRevision);
-- ---------------------------------------------
-- Log specific stuff here
-- ---------------------------------------------
-- Creating index at same time as table allows some storage engines to better
-- optimize physical storage layout. Most engines allow multiple nulls in a
-- unique index but some may not.
-- A leaf that has not been sequenced has a row in this table. If duplicate leaves
-- are allowed they will all reference this row.
CREATE TABLE IF NOT EXISTS LeafData(
TreeId BIGINT NOT NULL,
-- This is a personality specific has of some subset of the leaf data.
-- It's only purpose is to allow Trillian to identify duplicate entries in
-- the context of the personality.
LeafIdentityHash VARBINARY(255) NOT NULL,
-- This is the data stored in the leaf for example in CT it contains a DER encoded
-- X.509 certificate but is application dependent
LeafValue LONGBLOB NOT NULL,
-- This is extra data that the application can associate with the leaf should it wish to.
-- This data is not included in signing and hashing.
ExtraData LONGBLOB,
-- The timestamp from when this leaf data was first queued for inclusion.
QueueTimestampNanos BIGINT NOT NULL,
PRIMARY KEY(TreeId, LeafIdentityHash),
FOREIGN KEY(TreeId) REFERENCES Trees(TreeId) ON DELETE CASCADE
);
-- When a leaf is sequenced a row is added to this table. If logs allow duplicates then
-- multiple rows will exist with different sequence numbers. The signed timestamp
-- will be communicated via the unsequenced table as this might need to be unique, depending
-- on the log parameters and we can't insert into this table until we have the sequence number
-- which is not available at the time we queue the entry. We need both hashes because the
-- LeafData table is keyed by the raw data hash.
CREATE TABLE IF NOT EXISTS SequencedLeafData(
TreeId BIGINT NOT NULL,
SequenceNumber BIGINT UNSIGNED NOT NULL,
-- This is a personality specific has of some subset of the leaf data.
-- It's only purpose is to allow Trillian to identify duplicate entries in
-- the context of the personality.
LeafIdentityHash VARBINARY(255) NOT NULL,
-- This is a MerkleLeafHash as defined by the treehasher that the log uses. For example for
-- CT this hash will include the leaf prefix byte as well as the leaf data.
MerkleLeafHash VARBINARY(255) NOT NULL,
IntegrateTimestampNanos BIGINT NOT NULL,
PRIMARY KEY(TreeId, SequenceNumber),
FOREIGN KEY(TreeId) REFERENCES Trees(TreeId) ON DELETE CASCADE,
FOREIGN KEY(TreeId, LeafIdentityHash) REFERENCES LeafData(TreeId, LeafIdentityHash) ON DELETE CASCADE
);
CREATE INDEX SequencedLeafMerkleIdx
ON SequencedLeafData(TreeId, MerkleLeafHash);
CREATE TABLE IF NOT EXISTS Unsequenced(
TreeId BIGINT NOT NULL,
-- The bucket field is to allow the use of time based ring bucketed schemes if desired. If
-- unused this should be set to zero for all entries.
Bucket INTEGER NOT NULL,
-- This is a personality specific hash of some subset of the leaf data.
-- It's only purpose is to allow Trillian to identify duplicate entries in
-- the context of the personality.
LeafIdentityHash VARBINARY(255) NOT NULL,
-- This is a MerkleLeafHash as defined by the treehasher that the log uses. For example for
-- CT this hash will include the leaf prefix byte as well as the leaf data.
MerkleLeafHash VARBINARY(255) NOT NULL,
QueueTimestampNanos BIGINT NOT NULL,
-- This is a SHA256 hash of the TreeID, LeafIdentityHash and QueueTimestampNanos. It is used
-- for batched deletes from the table when trillian_log_server and trillian_log_signer are
-- built with the batched_queue tag.
QueueID VARBINARY(32) DEFAULT NULL UNIQUE,
PRIMARY KEY (TreeId, Bucket, QueueTimestampNanos, LeafIdentityHash)
);

View file

@ -14,3 +14,13 @@ require (
basicstack.de/proofedmesh/services/signing-service v0.0.0
basicstack.de/proofedmesh/services/transparency-log v0.0.0
)
require (
github.com/google/trillian v1.6.1 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.17.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)

20
services/admin-api/go.sum Normal file
View file

@ -0,0 +1,20 @@
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/trillian v1.6.1 h1:jWU5BGz24GQ5IsHNr+qbmISLkt+73jLv8BOIPN8RtD4=
github.com/google/trillian v1.6.1/go.mod h1:TvwtNkJViJgWZ5VmAMXDwsTjzPBHaPjQO85Kt37JPmM=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=

View file

@ -0,0 +1,19 @@
# Multi-stage build for the transparency-log service.
# Build the static binary against the pinned Go toolchain used by CI
# (keeps the image reproducible from source).
FROM golang:1.23-alpine3.21 AS build
WORKDIR /src
RUN apk add --no-cache git ca-certificates
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \
-o /out/transparency-log ./cmd/transparency-log
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build /out/transparency-log /app/transparency-log
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app/transparency-log"]
CMD ["--addr=:8080", "--backend=memory"]

View file

@ -26,9 +26,9 @@ damit Dev- und CI-Runs ohne IdP laufen.
## Backends
| Backend | Status | Wofür |
| ----------- | --------------------- | -------------------------------------------- |
| ----------- | ----------------------------------------- | -------------------------------------------- |
| `memory` | implementiert | Dev + CI. Append-Log + RFC 6962 Merkle Tree. |
| `trillian` | Stub (503 bei Submit) | Prod. Wiring folgt im Trillian-Subtask. |
| `trillian` | implementiert (DEV-604); 503 wenn unkonfiguriert | Prod. gRPC-Client auf Trillian LogServer. Siehe [`deploy/trillian/`](../../deploy/trillian/). |
Der In-Memory-Backend ist bewusst klein gehalten: er speichert jeden
Envelope als Leaf-Hash (`SHA-256(0x00 || canonical envelope bytes)`),
@ -77,9 +77,16 @@ kann.
## Nächste Schritte
1. [DEV-xxx] Trillian-Deployment (Logserver + Signer-Key, Namespace
`agent-trust`), danach `backend.Trillian.Submit` echt verdrahten.
2. Pocket-ID-Middleware vor den Handler hängen (gleiche OIDC-Discovery wie
1. Pocket-ID-Middleware vor den Handler hängen (gleiche OIDC-Discovery wie
signing-service, Audience/`client_id`-Whitelist über OPA-Policy).
3. STH-Verteilung: periodisch signierten Tree Head veröffentlichen (eigenes
2. STH-Verteilung: periodisch signierten Tree Head veröffentlichen (eigenes
Deployment, nutzt denselben Signing-Service).
3. Image-Build-Pipeline (CI/CD): Push von `services/transparency-log/Dockerfile`
nach `harbor.basicstack.de/proofedmesh/transparency-log:<tag>` automatisieren
und `deploy/trillian/40-transparency-log.yaml` auf diese Tags anpassen.
## Prod-Deployment
Siehe [`deploy/trillian/README.md`](../../deploy/trillian/README.md) —
Trillian (MySQL + LogServer + LogSigner + Tree) läuft im Namespace
`agent-trust` auf dem BasicStack-Cluster (DEV-604).

View file

@ -22,8 +22,10 @@ import (
func main() {
addr := flag.String("addr", ":8080", "listen address")
// Default to memory for local `go run` and tests; prod manifests
// (deploy/trillian/40-transparency-log.yaml) always pass --backend=trillian.
backendKind := flag.String("backend", "memory", "backend: memory | trillian")
trillianAddr := flag.String("trillian-addr", "", "trillian LogServer address (prod)")
trillianAddr := flag.String("trillian-addr", "", "trillian LogServer address (prod), e.g. trillian-log-server.agent-trust.svc.cluster.local:8090")
trillianLogID := flag.Int64("trillian-log-id", 0, "trillian tree id (prod)")
flag.Parse()
@ -66,7 +68,7 @@ func buildBackend(kind, trillianAddr string, trillianLogID int64) (backend.Backe
case "memory", "":
return backend.NewMemory(), nil
case "trillian":
return backend.Trillian{Endpoint: trillianAddr, LogID: trillianLogID}, nil
return &backend.Trillian{Endpoint: trillianAddr, LogID: trillianLogID}, nil
default:
return nil, errUnknownBackend(kind)
}

View file

@ -1,3 +1,16 @@
module basicstack.de/proofedmesh/services/transparency-log
go 1.23
require (
github.com/google/trillian v1.6.1
google.golang.org/grpc v1.65.0
)
require (
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.17.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)

View file

@ -0,0 +1,20 @@
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/trillian v1.6.1 h1:jWU5BGz24GQ5IsHNr+qbmISLkt+73jLv8BOIPN8RtD4=
github.com/google/trillian v1.6.1/go.mod h1:TvwtNkJViJgWZ5VmAMXDwsTjzPBHaPjQO85Kt37JPmM=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=

View file

@ -2,34 +2,267 @@ 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 wrapper that will talk to a Trillian
// log server. It is intentionally a stub for now — ADR-0005 lists Trillian
// deployment + keys as a follow-up subtask. Once that lands, this struct
// gains a Trillian LogClient and translates Submit() into
// trillian.QueueLeaves + GetInclusionProofByHash (or the current API shape
// at the time of integration).
// 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.
//
// Keeping the stub here lets the httpapi + wiring layer treat the two
// backends uniformly and prevents the server from accidentally shipping
// without a prod backend hook.
// 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 will later hold the Trillian LogServer address, e.g.
// "trillian-log.agent-trust.svc.cluster.local:8090".
// Endpoint is the gRPC address of the Trillian LogServer, e.g.
// "trillian-log-server.agent-trust.svc.cluster.local:8090".
Endpoint string
// LogID will later hold the Trillian tree id this service writes to.
// 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 returns ErrTrillianUnavailable until the Trillian deployment ships.
// Config loading, auth plumbing and the actual LogClient wiring belong in
// the follow-up Trillian subtask.
func (t Trillian) Submit(_ context.Context, _ Envelope, _ string) (Receipt, bool, 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
}
// ErrTrillianUnavailable signals that the prod backend is not wired yet.
// Translated to HTTP 503 by the httpapi layer.
// 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")

View file

@ -138,8 +138,11 @@ func TestSubmit_UnknownRoute_404(t *testing.T) {
}
}
func TestSubmit_TrillianStub_Returns503(t *testing.T) {
srv := httptest.NewServer((&Server{Backend: backend.Trillian{}}).Handler())
func TestSubmit_TrillianUnconfigured_Returns503(t *testing.T) {
// An un-configured Trillian backend (no endpoint, no LogID) must map to
// 503 Service Unavailable, not 500. This keeps us from silently serving
// writes against a mis-deployed prod backend.
srv := httptest.NewServer((&Server{Backend: &backend.Trillian{}}).Handler())
defer srv.Close()
env := fixtureEnvelope()
ch, _ := backend.EnvelopeContentHash(env)
@ -151,6 +154,6 @@ func TestSubmit_TrillianStub_Returns503(t *testing.T) {
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("trillian stub want 503, got %d: %s", resp.StatusCode, raw)
t.Fatalf("trillian unconfigured want 503, got %d: %s", resp.StatusCode, raw)
}
}