diff --git a/deploy/trillian/00-namespace.yaml b/deploy/trillian/00-namespace.yaml new file mode 100644 index 0000000..363c6d1 --- /dev/null +++ b/deploy/trillian/00-namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: agent-trust + labels: + name: agent-trust + app.kubernetes.io/part-of: proofedmesh diff --git a/deploy/trillian/10-mysql.yaml b/deploy/trillian/10-mysql.yaml new file mode 100644 index 0000000..5b142c5 --- /dev/null +++ b/deploy/trillian/10-mysql.yaml @@ -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 diff --git a/deploy/trillian/20-logserver.yaml b/deploy/trillian/20-logserver.yaml new file mode 100644 index 0000000..b12b2d7 --- /dev/null +++ b/deploy/trillian/20-logserver.yaml @@ -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" diff --git a/deploy/trillian/21-logsigner.yaml b/deploy/trillian/21-logsigner.yaml new file mode 100644 index 0000000..07caefa --- /dev/null +++ b/deploy/trillian/21-logsigner.yaml @@ -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" diff --git a/deploy/trillian/30-createtree-job.yaml b/deploy/trillian/30-createtree-job.yaml new file mode 100644 index 0000000..f763642 --- /dev/null +++ b/deploy/trillian/30-createtree-job.yaml @@ -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" diff --git a/deploy/trillian/40-transparency-log.yaml b/deploy/trillian/40-transparency-log.yaml new file mode 100644 index 0000000..07231a9 --- /dev/null +++ b/deploy/trillian/40-transparency-log.yaml @@ -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: +# — 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" diff --git a/deploy/trillian/README.md b/deploy/trillian/README.md new file mode 100644 index 0000000..a7a5953 --- /dev/null +++ b/deploy/trillian/README.md @@ -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\":\"\"}" + ' +``` + +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. diff --git a/deploy/trillian/storage.sql b/deploy/trillian/storage.sql new file mode 100644 index 0000000..76e984e --- /dev/null +++ b/deploy/trillian/storage.sql @@ -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) +); diff --git a/services/admin-api/go.mod b/services/admin-api/go.mod index 0329091..cf86185 100644 --- a/services/admin-api/go.mod +++ b/services/admin-api/go.mod @@ -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 +) diff --git a/services/admin-api/go.sum b/services/admin-api/go.sum new file mode 100644 index 0000000..d7a8b0c --- /dev/null +++ b/services/admin-api/go.sum @@ -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= diff --git a/services/transparency-log/Dockerfile b/services/transparency-log/Dockerfile new file mode 100644 index 0000000..ea0d971 --- /dev/null +++ b/services/transparency-log/Dockerfile @@ -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"] diff --git a/services/transparency-log/README.md b/services/transparency-log/README.md index 19c3a6d..17d5e58 100644 --- a/services/transparency-log/README.md +++ b/services/transparency-log/README.md @@ -25,10 +25,10 @@ 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. | +| Backend | Status | Wofür | +| ----------- | ----------------------------------------- | -------------------------------------------- | +| `memory` | implementiert | Dev + CI. Append-Log + RFC 6962 Merkle Tree. | +| `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:` 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). diff --git a/services/transparency-log/cmd/transparency-log/main.go b/services/transparency-log/cmd/transparency-log/main.go index af1a9ab..8a4814a 100644 --- a/services/transparency-log/cmd/transparency-log/main.go +++ b/services/transparency-log/cmd/transparency-log/main.go @@ -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) } diff --git a/services/transparency-log/go.mod b/services/transparency-log/go.mod index e4c5916..efc5104 100644 --- a/services/transparency-log/go.mod +++ b/services/transparency-log/go.mod @@ -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 +) diff --git a/services/transparency-log/go.sum b/services/transparency-log/go.sum new file mode 100644 index 0000000..d7a8b0c --- /dev/null +++ b/services/transparency-log/go.sum @@ -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= diff --git a/services/transparency-log/pkg/backend/trillian.go b/services/transparency-log/pkg/backend/trillian.go index 3685ebb..95cf7e6 100644 --- a/services/transparency-log/pkg/backend/trillian.go +++ b/services/transparency-log/pkg/backend/trillian.go @@ -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) { - return Receipt{}, false, 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 } -// 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") diff --git a/services/transparency-log/pkg/httpapi/server_test.go b/services/transparency-log/pkg/httpapi/server_test.go index 5b7689a..105159b 100644 --- a/services/transparency-log/pkg/httpapi/server_test.go +++ b/services/transparency-log/pkg/httpapi/server_test.go @@ -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) } }