transparency-log: real Trillian backend wiring + agent-trust deploy (DEV-604)
- services/transparency-log/pkg/backend/trillian.go: real gRPC client on github.com/google/trillian v1.6.1. Submit() does QueueLeaf with the canonical DSSE envelope as LeafValue and the raw content_hash bytes as LeafIdentityHash, then polls GetLatestSignedLogRoot + GetInclusionProofByHash until the leaf is sequenced. Idempotency uses Trillian's ALREADY_EXISTS signal on the inner QueuedLogLeaf.Status, so a repeated submit still returns 200 OK with the originally-assigned log_index + a valid proof against the current STH. - Unconfigured backend (missing endpoint or LogID) returns the historical ErrTrillianUnavailable so the httpapi layer keeps mapping it to 503 instead of silently 500. Existing stub-returns-503 test updated. - cmd/transparency-log: pointer-receiver Trillian wiring; flag docs mention the production SRV name. - services/transparency-log/Dockerfile: distroless multi-stage build used by deploy/trillian/40-transparency-log.yaml. - deploy/trillian/: full K8s stack for the agent-trust namespace — MySQL 8.4 StatefulSet (schema init ConfigMap vendored from upstream storage.sql v1.6.1), Trillian LogServer + LogSigner Deployments on gcr.io/trillian-opensource-ci:v1.6.1, createtree Job that writes the TreeID to a ConfigMap (re-apply safe), transparency-log Deployment with --backend=trillian pulling TRILLIAN_LOG_ID from the ConfigMap. - deploy/trillian/README.md: first-time apply + smoke-test + secret rotation + signer-key clarification (STH key is Trillian-internal; DSSE envelope signing stays with signing-service / OpenBao). - services/admin-api: go.mod / go.sum pick up the new transitive deps (trillian + grpc) via the local replace on transparency-log. - services/transparency-log/README.md: Backends table updated; next- steps list no longer carries Trillian-deployment as a follow-up. Deployed to the live cluster from this heartbeat: MySQL + LogServer + LogSigner running in agent-trust, tree id 8482455225499059043, service smoke-tested end-to-end (201 on first POST, 200 on idempotent repeat, 409 on content_hash mismatch, tree_size=2 with correct audit path). Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b52936ce9d
commit
951f22f80e
17 changed files with 1145 additions and 32 deletions
7
deploy/trillian/00-namespace.yaml
Normal file
7
deploy/trillian/00-namespace.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: agent-trust
|
||||
labels:
|
||||
name: agent-trust
|
||||
app.kubernetes.io/part-of: proofedmesh
|
||||
220
deploy/trillian/10-mysql.yaml
Normal file
220
deploy/trillian/10-mysql.yaml
Normal 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
|
||||
71
deploy/trillian/20-logserver.yaml
Normal file
71
deploy/trillian/20-logserver.yaml
Normal 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"
|
||||
59
deploy/trillian/21-logsigner.yaml
Normal file
59
deploy/trillian/21-logsigner.yaml
Normal 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"
|
||||
115
deploy/trillian/30-createtree-job.yaml
Normal file
115
deploy/trillian/30-createtree-job.yaml
Normal 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"
|
||||
75
deploy/trillian/40-transparency-log.yaml
Normal file
75
deploy/trillian/40-transparency-log.yaml
Normal 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
102
deploy/trillian/README.md
Normal 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
137
deploy/trillian/storage.sql
Normal 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)
|
||||
);
|
||||
|
|
@ -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
20
services/admin-api/go.sum
Normal 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=
|
||||
19
services/transparency-log/Dockerfile
Normal file
19
services/transparency-log/Dockerfile
Normal 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"]
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
20
services/transparency-log/go.sum
Normal file
20
services/transparency-log/go.sum
Normal 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=
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
// ErrTrillianUnavailable signals that the prod backend is not wired yet.
|
||||
// Translated to HTTP 503 by the httpapi layer.
|
||||
canonical, err := canonicalJSON(env)
|
||||
if err != nil {
|
||||
return Receipt{}, false, err
|
||||
}
|
||||
identity, err := decodeSHA256HexPrefixed(contentHash)
|
||||
if err != nil {
|
||||
return Receipt{}, false, err
|
||||
}
|
||||
|
||||
if err := t.ensureClient(); err != nil {
|
||||
return Receipt{}, false, err
|
||||
}
|
||||
|
||||
// QueueLeaf.
|
||||
queueResp, err := t.client.QueueLeaf(ctx, &trillian.QueueLeafRequest{
|
||||
LogId: t.LogID,
|
||||
Leaf: &trillian.LogLeaf{
|
||||
LeafValue: canonical,
|
||||
LeafIdentityHash: identity,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return Receipt{}, false, fmt.Errorf("transparency-log: trillian QueueLeaf: %w", err)
|
||||
}
|
||||
|
||||
fresh := true
|
||||
if queueResp.GetQueuedLeaf() != nil && queueResp.GetQueuedLeaf().GetStatus() != nil {
|
||||
if codes.Code(queueResp.GetQueuedLeaf().GetStatus().GetCode()) == codes.AlreadyExists {
|
||||
fresh = false
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for the inclusion proof. The merkle leaf hash is the RFC 6962
|
||||
// leaf hash of the canonical envelope — same hashing we use in the
|
||||
// in-memory backend, which keeps the two backends interchangeable.
|
||||
merkleHash := leafHash(canonical)
|
||||
receipt, err := t.waitForInclusionProof(ctx, merkleHash, contentHash)
|
||||
if err != nil {
|
||||
return Receipt{}, false, err
|
||||
}
|
||||
return receipt, fresh, nil
|
||||
}
|
||||
|
||||
// waitForInclusionProof polls GetLatestSignedLogRoot + GetInclusionProofByHash
|
||||
// until the queued leaf has been sequenced into the tree. See PollInterval
|
||||
// and PollTimeout for tuning.
|
||||
func (t *Trillian) waitForInclusionProof(ctx context.Context, merkleHash []byte, contentHash string) (Receipt, error) {
|
||||
pollInterval := t.PollInterval
|
||||
if pollInterval <= 0 {
|
||||
pollInterval = 100 * time.Millisecond
|
||||
}
|
||||
pollTimeout := t.PollTimeout
|
||||
if pollTimeout <= 0 {
|
||||
pollTimeout = 10 * time.Second
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(pollTimeout)
|
||||
for {
|
||||
slr, treeSize, rootHash, err := t.latestSignedRoot(ctx)
|
||||
if err != nil {
|
||||
return Receipt{}, err
|
||||
}
|
||||
if treeSize > 0 {
|
||||
proof, err := t.client.GetInclusionProofByHash(ctx, &trillian.GetInclusionProofByHashRequest{
|
||||
LogId: t.LogID,
|
||||
LeafHash: merkleHash,
|
||||
TreeSize: int64(treeSize),
|
||||
})
|
||||
switch {
|
||||
case err == nil && len(proof.GetProof()) > 0:
|
||||
p := proof.GetProof()[0]
|
||||
hashes := make([]string, 0, len(p.GetHashes()))
|
||||
for _, h := range p.GetHashes() {
|
||||
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
|
||||
}
|
||||
return Receipt{
|
||||
LogIndex: p.GetLeafIndex(),
|
||||
ContentHash: contentHash,
|
||||
LoggedAt: t.clock(),
|
||||
InclusionProof: InclusionProof{
|
||||
TreeSize: int64(treeSize),
|
||||
TreeRootHash: "sha256:" + hex.EncodeToString(rootHash),
|
||||
SignedTreeHeadRef: signedTreeHeadRefTrillian(slr),
|
||||
Hashes: hashes,
|
||||
},
|
||||
}, nil
|
||||
case err != nil && status.Code(err) != codes.NotFound:
|
||||
return Receipt{}, fmt.Errorf("transparency-log: trillian GetInclusionProofByHash: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Not sequenced yet (NotFound or TreeSize==0). Sleep + retry up to the deadline.
|
||||
if time.Now().After(deadline) {
|
||||
return Receipt{}, fmt.Errorf("transparency-log: trillian inclusion proof wait exceeded %s", pollTimeout)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Receipt{}, ctx.Err()
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// latestSignedRoot pulls the current STH and decodes TreeSize + RootHash.
|
||||
func (t *Trillian) latestSignedRoot(ctx context.Context) (*trillian.SignedLogRoot, uint64, []byte, error) {
|
||||
resp, err := t.client.GetLatestSignedLogRoot(ctx, &trillian.GetLatestSignedLogRootRequest{
|
||||
LogId: t.LogID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, nil, fmt.Errorf("transparency-log: trillian GetLatestSignedLogRoot: %w", err)
|
||||
}
|
||||
slr := resp.GetSignedLogRoot()
|
||||
if slr == nil {
|
||||
return nil, 0, nil, errors.New("transparency-log: trillian GetLatestSignedLogRoot returned nil root")
|
||||
}
|
||||
var lr types.LogRootV1
|
||||
if err := lr.UnmarshalBinary(slr.GetLogRoot()); err != nil {
|
||||
return nil, 0, nil, fmt.Errorf("transparency-log: trillian LogRoot decode: %w", err)
|
||||
}
|
||||
return slr, lr.TreeSize, lr.RootHash, nil
|
||||
}
|
||||
|
||||
func (t *Trillian) ensureClient() error {
|
||||
t.connOnce.Do(func() {
|
||||
opts := t.DialOptions
|
||||
if len(opts) == 0 {
|
||||
opts = []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
}
|
||||
conn, err := grpc.NewClient(t.Endpoint, opts...)
|
||||
if err != nil {
|
||||
t.dialErr = fmt.Errorf("transparency-log: trillian dial %q: %w", t.Endpoint, err)
|
||||
return
|
||||
}
|
||||
t.conn = conn
|
||||
t.client = trillian.NewTrillianLogClient(conn)
|
||||
})
|
||||
return t.dialErr
|
||||
}
|
||||
|
||||
// Close releases the gRPC connection. Safe to call multiple times.
|
||||
func (t *Trillian) Close() error {
|
||||
if t.conn != nil {
|
||||
return t.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Trillian) clock() time.Time {
|
||||
if t.Clock != nil {
|
||||
return t.Clock()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
// signedTreeHeadRefTrillian returns a stable reference to the current STH.
|
||||
// We hash the serialised LogRoot bytes (which include tree size, root hash
|
||||
// and revision) so the ref is deterministic per head and does not depend
|
||||
// on the signature algorithm chosen for the Trees row.
|
||||
func signedTreeHeadRefTrillian(slr *trillian.SignedLogRoot) string {
|
||||
if slr == nil {
|
||||
return ""
|
||||
}
|
||||
h := sha256.Sum256(slr.GetLogRoot())
|
||||
return "sha256:" + hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// decodeSHA256HexPrefixed decodes "sha256:<64 hex>" into 32 raw bytes.
|
||||
// Used both for the Trillian LeafIdentityHash and defensive validation
|
||||
// at the public Submit entry point.
|
||||
func decodeSHA256HexPrefixed(s string) ([]byte, error) {
|
||||
hexPart := strings.TrimPrefix(s, "sha256:")
|
||||
if len(hexPart) != 64 {
|
||||
return nil, fmt.Errorf("transparency-log: expected sha256:<64 hex>, got %q", s)
|
||||
}
|
||||
b, err := hex.DecodeString(hexPart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transparency-log: content_hash hex decode: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// ErrTrillianUnavailable is kept for backward compatibility with existing
|
||||
// callers / tests. The httpapi layer still maps it to HTTP 503 for the
|
||||
// "trillian not configured" and "dial failed" cases that happen before a
|
||||
// request reaches the LogServer. Real server-side errors from Trillian
|
||||
// surface as regular wrapped errors and map to 500.
|
||||
var ErrTrillianUnavailable = errors.New("transparency-log: trillian backend not deployed")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue