services: add transparency-log Go service (ADR-0005 POST /api/v1/log/entries)
All checks were successful
validate / Rego (opa fmt + opa test) (push) Successful in 14s
validate / JSON Schema (ajv) (push) Successful in 20s
validate / Go (vet + test) (push) Successful in 20s

- services/transparency-log/pkg/backend: Backend interface with in-memory
  append-log (RFC 6962 Merkle tree) + Trillian wrapper (prod stub).
- Idempotenz-Pflicht: Wiederaufruf mit identischem content_hash liefert
  200 + gleichen log_index + aktuellen inclusion_proof.
- content_hash-Verifikation serverseitig; Mismatch → 409.
- pkg/httpapi: ADR-0005-konformer Handler (201/200/400/404/409/503).
- cmd/transparency-log: minimaler HTTP-Server, memory default,
  --backend trillian als Stub (503).
- Interop-Test in services/admin-api/internal/tlog/interop_test.go
  fährt die echte transparency-log pkg gegen die admin-api
  HTTPSubmitter — Happy-Path, Idempotenz und content_hash-Mismatch
  abgedeckt.
- go test: 10 Tests transparency-log + 2 Interop-Tests admin-api, go
  vet clean in beiden Modulen.

Folge-Subtask: Trillian-Deployment + Keys (siehe ADR-0005 Open Points).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO 2026-10-25 18:08:53 +00:00
parent 2e44147a71
commit 69b3f3012f
13 changed files with 1138 additions and 0 deletions

View file

@ -1,3 +1,10 @@
module basicstack.de/proofedmesh/services/admin-api module basicstack.de/proofedmesh/services/admin-api
go 1.23 go 1.23
// Interop test in internal/tlog exercises the real transparency-log HTTP
// handler via its public pkg API. The module is only available locally in
// this monorepo; keep the replace directive aligned with the sibling path.
replace basicstack.de/proofedmesh/services/transparency-log => ../transparency-log
require basicstack.de/proofedmesh/services/transparency-log v0.0.0

View file

@ -0,0 +1,97 @@
package tlog
// Interop test: the real admin-api HTTPSubmitter talks to the real
// transparency-log HTTP handler + in-memory backend from the sibling
// services/transparency-log module. This proves end-to-end that the
// ADR-0005 wire shape agreed on both sides (field names, status codes,
// idempotency semantics, content_hash algorithm) is actually compatible.
//
// The transparency-log module is pulled in via a replace directive in the
// top-level go.mod.
import (
"context"
"net/http/httptest"
"testing"
"basicstack.de/proofedmesh/services/admin-api/internal/attest"
tlbackend "basicstack.de/proofedmesh/services/transparency-log/pkg/backend"
tlhttp "basicstack.de/proofedmesh/services/transparency-log/pkg/httpapi"
)
func TestInterop_HTTPSubmitter_HappyPathAndIdempotency(t *testing.T) {
backendMem := tlbackend.NewMemory()
server := httptest.NewServer((&tlhttp.Server{Backend: backendMem}).Handler())
defer server.Close()
sub := HTTPSubmitter{BaseURL: server.URL, TokenSource: attest.StaticToken("dev")}
env := attest.Envelope{
PayloadType: "application/vnd.in-toto+json",
Payload: "aGVsbG8",
Signatures: []attest.EnvelopeSignature{{KeyID: "k", Sig: "c2ln"}},
}
ch, err := attest.EnvelopeContentHash(env)
if err != nil {
t.Fatalf("hash: %v", err)
}
r1, err := sub.Submit(context.Background(), env, ch)
if err != nil {
t.Fatalf("first submit: %v", err)
}
if r1.LogIndex != 0 {
t.Errorf("first log_index: want 0, got %d", r1.LogIndex)
}
if r1.ContentHash != ch {
t.Errorf("content_hash echoed wrong: %q vs %q", r1.ContentHash, ch)
}
if r1.InclusionProof.TreeSize != 1 {
t.Errorf("tree_size: %d", r1.InclusionProof.TreeSize)
}
// Idempotent repeat.
r2, err := sub.Submit(context.Background(), env, ch)
if err != nil {
t.Fatalf("second submit: %v", err)
}
if r2.LogIndex != r1.LogIndex {
t.Errorf("idempotent log_index drifted: %d -> %d", r1.LogIndex, r2.LogIndex)
}
if r2.InclusionProof.TreeRootHash != r1.InclusionProof.TreeRootHash {
t.Errorf("tree_root drifted without new entries")
}
// Second distinct envelope — tree grows.
envB := env
envB.Payload = "d29ybGQ"
chB, _ := attest.EnvelopeContentHash(envB)
rB, err := sub.Submit(context.Background(), envB, chB)
if err != nil {
t.Fatalf("submit B: %v", err)
}
if rB.LogIndex != 1 {
t.Errorf("second entry log_index: want 1, got %d", rB.LogIndex)
}
if rB.InclusionProof.TreeSize != 2 {
t.Errorf("tree_size after B: %d", rB.InclusionProof.TreeSize)
}
}
func TestInterop_HTTPSubmitter_ContentHashMismatchFails(t *testing.T) {
server := httptest.NewServer((&tlhttp.Server{Backend: tlbackend.NewMemory()}).Handler())
defer server.Close()
sub := HTTPSubmitter{BaseURL: server.URL, TokenSource: attest.StaticToken("dev")}
env := attest.Envelope{
PayloadType: "application/vnd.in-toto+json",
Payload: "aGVsbG8",
Signatures: []attest.EnvelopeSignature{{KeyID: "k", Sig: "c2ln"}},
}
// Wrong hash on purpose. Server must respond 409; the client surfaces
// that as an error.
_, err := sub.Submit(context.Background(), env, "sha256:00000000000000000000000000000000000000000000000000000000deadbeef")
if err == nil {
t.Fatalf("expected error on content_hash mismatch, got nil")
}
}

View file

@ -0,0 +1,85 @@
# transparency-log
Serverseitige Implementierung von **`POST /api/v1/log/entries`** laut
[`docs/adr/0005-signing-service-and-transparency-log-api.md`](../../docs/adr/0005-signing-service-and-transparency-log-api.md).
Die admin-api und spätere Agent-Dienste rufen diesen Service auf, um DSSE-
Envelopes in einen Append-Only-Log zu schreiben und einen Inclusion-Proof
zurückzubekommen. Der zurückgelieferte `content_hash` ist die stabile
Attestation-ID überall sonst im System.
## Vertrag
Siehe ADR-0005. Zusammengefasst:
- `POST /api/v1/log/entries` mit `{ envelope, content_hash }`.
- Server rechnet `content_hash` serverseitig nach — Mismatch → **409**.
- Idempotent: Wiederaufruf mit identischem `content_hash` liefert `200` +
dasselbe `log_index` + gültigen (aktuellen) `inclusion_proof`.
- Erstanlage antwortet mit `201`.
- Trillian-Backend nicht erreichbar / nicht deployed → **503**.
Authentisierung (Pocket-ID Bearer) wird außerhalb des Handlers über eine
Middleware eingehängt; der Core-Handler ist absichtlich auth-agnostisch,
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. |
Der In-Memory-Backend ist bewusst klein gehalten: er speichert jeden
Envelope als Leaf-Hash (`SHA-256(0x00 || canonical envelope bytes)`),
rechnet Baumwurzel und Audit-Path pro Submit frisch aus und liefert
deterministische `signed_tree_head_ref`-Werte aus `(tree_size, root)`.
Das reicht für die admin-api-Interop-Tests und den lokalen Durchstich der
Vertrauenskette.
## Starten (lokal)
```bash
cd services/transparency-log
go run ./cmd/transparency-log --addr :8080 --backend memory
```
Smoke-Test (gegen ADR-0005-Beispielpayload):
```bash
curl -sS -X POST http://127.0.0.1:8080/api/v1/log/entries \
-H 'Content-Type: application/json' \
-d '{"envelope":{"payloadType":"application/vnd.in-toto+json","payload":"aGVsbG8","signatures":[{"keyid":"k","sig":"c2ln"}]},"content_hash":"<siehe EnvelopeContentHash>"}'
```
Der korrekte `content_hash` ergibt sich aus `backend.EnvelopeContentHash`
(bit-identisch zu `admin-api/internal/attest.EnvelopeContentHash`).
## Tests
```bash
cd services/transparency-log
go vet ./...
go test ./...
```
Die wichtigen Testpakete:
- `internal/backend` — Append-Log, Idempotenz, content_hash-Mismatch,
RFC-6962-Audit-Path-Verifikation.
- `internal/httpapi` — HTTP-Statuscodes (201/200/400/404/409/503).
- `internal/interop` — admin-api `tlog.HTTPSubmitter` gegen diesen Server,
Happy-Path + Idempotenz + Content-Hash-Mismatch.
Die `go.mod` enthält eine `replace`-Direktive auf die lokale `admin-api`,
damit der Interop-Test den tatsächlichen Produktions-Client importieren
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
signing-service, Audience/`client_id`-Whitelist über OPA-Policy).
3. STH-Verteilung: periodisch signierten Tree Head veröffentlichen (eigenes
Deployment, nutzt denselben Signing-Service).

View file

@ -0,0 +1,77 @@
// Command transparency-log serves the ADR-0005 Transparency Log API.
//
// By default it runs with the in-memory backend (dev + CI). The Trillian
// backend is a stub until the Trillian deployment subtask lands; selecting
// it today returns 503 on every submit, which is the intended signal that
// production is not yet wired.
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"basicstack.de/proofedmesh/services/transparency-log/pkg/backend"
"basicstack.de/proofedmesh/services/transparency-log/pkg/httpapi"
)
func main() {
addr := flag.String("addr", ":8080", "listen address")
backendKind := flag.String("backend", "memory", "backend: memory | trillian")
trillianAddr := flag.String("trillian-addr", "", "trillian LogServer address (prod)")
trillianLogID := flag.Int64("trillian-log-id", 0, "trillian tree id (prod)")
flag.Parse()
be, err := buildBackend(*backendKind, *trillianAddr, *trillianLogID)
if err != nil {
log.Fatalf("backend init: %v", err)
}
srv := &httpapi.Server{Backend: be, LogError: log.Printf}
httpSrv := &http.Server{
Addr: *addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 5 * time.Second,
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
errc := make(chan error, 1)
go func() {
log.Printf("transparency-log: listening on %s (backend=%s)", *addr, *backendKind)
errc <- httpSrv.ListenAndServe()
}()
select {
case err := <-errc:
if err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
case <-ctx.Done():
log.Printf("transparency-log: shutdown requested")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
_ = httpSrv.Shutdown(shutdownCtx)
}
}
func buildBackend(kind, trillianAddr string, trillianLogID int64) (backend.Backend, error) {
switch kind {
case "memory", "":
return backend.NewMemory(), nil
case "trillian":
return backend.Trillian{Endpoint: trillianAddr, LogID: trillianLogID}, nil
default:
return nil, errUnknownBackend(kind)
}
}
type errUnknownBackend string
func (e errUnknownBackend) Error() string { return "unknown backend: " + string(e) }

View file

@ -0,0 +1,3 @@
module basicstack.de/proofedmesh/services/transparency-log
go 1.23

View file

@ -0,0 +1,96 @@
package backend
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
)
// EnvelopeContentHash returns the SHA-256 of the canonical JSON encoding
// of env, prefixed with "sha256:". The algorithm MUST match the one
// admin-api/internal/attest uses on the client side so that a client-sent
// content_hash is bit-identical to one we recompute here.
func EnvelopeContentHash(env Envelope) (string, error) {
b, err := canonicalJSON(env)
if err != nil {
return "", err
}
sum := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// canonicalJSON is the stable JSON encoding we use for hashing. It round-trips
// through generic unmarshal and then re-emits with sorted object keys. This
// is NOT a full RFC 8785 implementation; it is the subset that is sufficient
// for the admin-api <-> transparency-log boundary (envelopes contain strings,
// nested string maps, and arrays of those).
func canonicalJSON(v any) ([]byte, error) {
first, err := json.Marshal(v)
if err != nil {
return nil, err
}
var any_ any
if err := json.Unmarshal(first, &any_); err != nil {
return nil, err
}
var buf bytes.Buffer
if err := writeSorted(&buf, any_); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func writeSorted(buf *bytes.Buffer, v any) error {
switch x := v.(type) {
case nil:
buf.WriteString("null")
case bool:
if x {
buf.WriteString("true")
} else {
buf.WriteString("false")
}
case float64:
b, _ := json.Marshal(x)
buf.Write(b)
case string:
b, _ := json.Marshal(x)
buf.Write(b)
case []any:
buf.WriteByte('[')
for i, el := range x {
if i > 0 {
buf.WriteByte(',')
}
if err := writeSorted(buf, el); err != nil {
return err
}
}
buf.WriteByte(']')
case map[string]any:
keys := make([]string, 0, len(x))
for k := range x {
keys = append(keys, k)
}
sort.Strings(keys)
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
kb, _ := json.Marshal(k)
buf.Write(kb)
buf.WriteByte(':')
if err := writeSorted(buf, x[k]); err != nil {
return err
}
}
buf.WriteByte('}')
default:
return fmt.Errorf("writeSorted: unsupported type %T", v)
}
return nil
}

View file

@ -0,0 +1,119 @@
package backend
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"sync"
"time"
)
// Memory is an in-memory append-only Merkle log suitable for Dev + CI. It
// stores every leaf (envelope) in memory and recomputes inclusion proofs on
// each submit, which is fine at the entry counts expected in tests and
// development. Production uses Trillian via the trillian.go wrapper stub.
//
// Idempotency: a repeated submit with the same contentHash returns the
// originally assigned log_index with a fresh inclusion proof against the
// current tree size.
type Memory struct {
mu sync.Mutex
leaves [][]byte // ordered list of leaf hashes
indexByHash map[string]int64 // contentHash -> log_index (zero-based)
loggedAtByIndex map[int64]time.Time // original append time per index
now func() time.Time
}
// NewMemory returns an empty in-memory backend.
func NewMemory() *Memory {
return &Memory{
indexByHash: map[string]int64{},
loggedAtByIndex: map[int64]time.Time{},
now: func() time.Time { return time.Now().UTC() },
}
}
// WithClock replaces the time source. For tests.
func (m *Memory) WithClock(now func() time.Time) *Memory {
m.now = now
return m
}
// Submit appends (env, contentHash) if the hash is new, otherwise returns
// the existing entry. See Backend.Submit for the fresh semantics.
func (m *Memory) Submit(_ context.Context, env Envelope, contentHash string) (Receipt, bool, error) {
if contentHash == "" {
return Receipt{}, false, errors.New("transparency-log: empty content_hash")
}
// Recompute the hash to verify what the client sent matches the envelope.
recomputed, err := EnvelopeContentHash(env)
if err != nil {
return Receipt{}, false, err
}
if recomputed != contentHash {
return Receipt{}, false, ErrContentHashMismatch
}
// Encode the envelope the same way we hashed it, and use those bytes as
// the leaf material. This way the leaf hash is a cryptographic commitment
// to the exact envelope the client stored.
canonical, err := canonicalJSON(env)
if err != nil {
return Receipt{}, false, err
}
m.mu.Lock()
defer m.mu.Unlock()
fresh := false
idx, exists := m.indexByHash[contentHash]
if !exists {
idx = int64(len(m.leaves))
m.leaves = append(m.leaves, leafHash(canonical))
m.indexByHash[contentHash] = idx
m.loggedAtByIndex[idx] = m.now()
fresh = true
}
root := merkleTreeHash(m.leaves)
proof := auditPath(m.leaves, int(idx))
rootHex := sha256Hex(root)
headRef := signedTreeHeadRef(int64(len(m.leaves)), root)
hashes := make([]string, 0, len(proof))
for _, h := range proof {
hashes = append(hashes, sha256Hex(h))
}
return Receipt{
LogIndex: idx,
ContentHash: contentHash,
LoggedAt: m.loggedAtByIndex[idx],
InclusionProof: InclusionProof{
TreeSize: int64(len(m.leaves)),
TreeRootHash: rootHex,
SignedTreeHeadRef: headRef,
Hashes: hashes,
},
}, fresh, nil
}
// signedTreeHeadRef is a stand-in for the real STH reference. In the
// in-memory backend we deterministically derive it from (tree_size, root),
// so callers get a stable per-head identifier without needing a signer.
// The Trillian wrapper will return the actual signed head ref.
func signedTreeHeadRef(size int64, root []byte) string {
h := sha256.New()
// Little-endian uint64 encoding of size, then root bytes.
for i := 0; i < 8; i++ {
h.Write([]byte{byte(size >> (8 * i))})
}
h.Write(root)
return "sha256:" + hex.EncodeToString(h.Sum(nil))
}
// ErrContentHashMismatch is returned when the client-supplied content_hash
// does not match the SHA-256 of the serialised envelope. ADR-0005 says the
// transparency log MUST reject this with 409.
var ErrContentHashMismatch = errors.New("transparency-log: content_hash does not match envelope")

View file

@ -0,0 +1,203 @@
package backend
import (
"bytes"
"context"
"encoding/hex"
"errors"
"strings"
"testing"
"time"
)
func sampleEnvelope(payload, sig string) Envelope {
return Envelope{
PayloadType: "application/vnd.in-toto+json",
Payload: payload,
Signatures: []EnvelopeSignature{{KeyID: "k1", Sig: sig}},
}
}
func mustHash(t *testing.T, env Envelope) string {
t.Helper()
h, err := EnvelopeContentHash(env)
if err != nil {
t.Fatalf("hash: %v", err)
}
return h
}
func TestMemory_SubmitAndIdempotency(t *testing.T) {
m := NewMemory().WithClock(func() time.Time {
return time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
})
env := sampleEnvelope("aGVsbG8", "c2ln")
ch := mustHash(t, env)
r1, fresh, err := m.Submit(context.Background(), env, ch)
if err != nil {
t.Fatalf("first submit: %v", err)
}
if !fresh {
t.Errorf("first submit should be fresh")
}
if r1.LogIndex != 0 {
t.Errorf("first log_index: want 0, got %d", r1.LogIndex)
}
if r1.ContentHash != ch {
t.Errorf("content_hash echoed wrong: %q vs %q", r1.ContentHash, ch)
}
if r1.InclusionProof.TreeSize != 1 {
t.Errorf("tree_size: %d", r1.InclusionProof.TreeSize)
}
if len(r1.InclusionProof.Hashes) != 0 {
t.Errorf("single-leaf audit path should be empty, got %d entries", len(r1.InclusionProof.Hashes))
}
r2, fresh2, err := m.Submit(context.Background(), env, ch)
if err != nil {
t.Fatalf("second submit: %v", err)
}
if fresh2 {
t.Errorf("second submit should NOT be fresh (idempotent repeat)")
}
if r2.LogIndex != r1.LogIndex {
t.Errorf("idempotent log_index drifted: %d -> %d", r1.LogIndex, r2.LogIndex)
}
if !r2.LoggedAt.Equal(r1.LoggedAt) {
t.Errorf("logged_at should be stable across idempotent repeats")
}
if r2.InclusionProof.TreeRootHash != r1.InclusionProof.TreeRootHash {
t.Errorf("tree_root drifted without new entries: %q vs %q", r1.InclusionProof.TreeRootHash, r2.InclusionProof.TreeRootHash)
}
}
func TestMemory_ContentHashMismatch(t *testing.T) {
m := NewMemory()
env := sampleEnvelope("aGVsbG8", "c2ln")
wrong := "sha256:" + strings.Repeat("0", 64)
_, _, err := m.Submit(context.Background(), env, wrong)
if err == nil {
t.Fatalf("expected ErrContentHashMismatch, got nil")
}
if err != ErrContentHashMismatch {
t.Fatalf("wrong error: %v", err)
}
}
func TestMemory_MultipleEntriesGrowRoot(t *testing.T) {
m := NewMemory()
ctx := context.Background()
var lastRoot string
for i, payload := range []string{"a", "b", "c", "d", "e"} {
env := sampleEnvelope(payload, "s")
ch := mustHash(t, env)
r, fresh, err := m.Submit(ctx, env, ch)
if err != nil {
t.Fatalf("submit %d: %v", i, err)
}
if !fresh {
t.Errorf("submit %d should be fresh", i)
}
if r.LogIndex != int64(i) {
t.Errorf("submit %d log_index: want %d got %d", i, i, r.LogIndex)
}
if r.InclusionProof.TreeSize != int64(i+1) {
t.Errorf("submit %d tree_size: want %d got %d", i, i+1, r.InclusionProof.TreeSize)
}
if r.InclusionProof.TreeRootHash == lastRoot {
t.Errorf("tree_root did not change after new entry %d", i)
}
lastRoot = r.InclusionProof.TreeRootHash
}
}
// TestMemory_InclusionProofVerifiesAgainstRoot cross-checks that for a tree
// of several entries (including a non-power-of-two size), the audit path
// returned alongside every leaf verifies against the advertised tree root
// per RFC 6962 §2.1.1. If this breaks, our Merkle code is wrong.
func TestMemory_InclusionProofVerifiesAgainstRoot(t *testing.T) {
for _, n := range []int{1, 2, 3, 4, 5, 7, 8, 13} {
m := NewMemory()
ctx := context.Background()
envs := make([]Envelope, n)
hashes := make([]string, n)
for i := range envs {
envs[i] = sampleEnvelope(string(rune('A'+i)), "s")
hashes[i] = mustHash(t, envs[i])
if _, _, err := m.Submit(ctx, envs[i], hashes[i]); err != nil {
t.Fatalf("n=%d seed %d: %v", n, i, err)
}
}
for i := range envs {
r, _, err := m.Submit(ctx, envs[i], hashes[i])
if err != nil {
t.Fatalf("n=%d fetch leaf %d: %v", n, i, err)
}
canonical, err := canonicalJSON(envs[i])
if err != nil {
t.Fatalf("canonical: %v", err)
}
leaf := leafHash(canonical)
rootBytes, err := decodePrefixedHex(r.InclusionProof.TreeRootHash)
if err != nil {
t.Fatalf("decode root: %v", err)
}
var proof [][]byte
for _, h := range r.InclusionProof.Hashes {
b, err := decodePrefixedHex(h)
if err != nil {
t.Fatalf("decode sibling: %v", err)
}
proof = append(proof, b)
}
if err := verifyInclusionRFC6962(int64(i), int64(n), leaf, proof, rootBytes); err != nil {
t.Errorf("n=%d leaf %d: audit path does not verify: %v", n, i, err)
}
}
}
}
// verifyInclusionRFC6962 implements the RFC 6962 §2.1.1 audit path
// verification algorithm. Returns nil if proof proves leafHash is at
// leafIndex in a tree of size treeSize with the given root.
func verifyInclusionRFC6962(leafIndex, treeSize int64, leaf []byte, proof [][]byte, root []byte) error {
if leafIndex >= treeSize {
return errors.New("leaf index out of range")
}
fn, sn := leafIndex, treeSize-1
r := append([]byte(nil), leaf...)
for _, p := range proof {
if sn == 0 {
return errors.New("extra proof element")
}
if fn&1 == 1 || fn == sn {
r = nodeHash(p, r)
if fn&1 == 0 {
for fn != 0 && fn&1 == 0 {
fn >>= 1
sn >>= 1
}
}
} else {
r = nodeHash(r, p)
}
fn >>= 1
sn >>= 1
}
if sn != 0 {
return errors.New("incomplete proof")
}
if !bytes.Equal(r, root) {
return errors.New("root mismatch")
}
return nil
}
func decodePrefixedHex(s string) ([]byte, error) {
const prefix = "sha256:"
if len(s) <= len(prefix) || s[:len(prefix)] != prefix {
return nil, errors.New("hash missing sha256: prefix")
}
return hex.DecodeString(s[len(prefix):])
}

View file

@ -0,0 +1,73 @@
package backend
import (
"crypto/sha256"
"encoding/hex"
)
// RFC 6962 §2.1 Merkle tree hashing. We use the standard CT scheme:
// leaf hash = SHA-256(0x00 || data)
// node hash = SHA-256(0x01 || left || right)
// so that an inclusion proof from this server can later be audited by
// anything that speaks the certificate-transparency merkle format.
func leafHash(data []byte) []byte {
h := sha256.New()
h.Write([]byte{0x00})
h.Write(data)
return h.Sum(nil)
}
func nodeHash(left, right []byte) []byte {
h := sha256.New()
h.Write([]byte{0x01})
h.Write(left)
h.Write(right)
return h.Sum(nil)
}
// merkleTreeHash computes the Merkle Tree Hash (MTH) of the given list of
// leaf hashes. MTH([]) is SHA-256 of the empty string per RFC 6962, but we
// never call it on an empty slice in practice.
func merkleTreeHash(leaves [][]byte) []byte {
n := len(leaves)
switch n {
case 0:
h := sha256.Sum256(nil)
return h[:]
case 1:
return append([]byte(nil), leaves[0]...)
}
k := largestPow2LessThan(n)
return nodeHash(merkleTreeHash(leaves[:k]), merkleTreeHash(leaves[k:]))
}
// auditPath returns the RFC 6962 audit path for leaf index m within the
// given leaf-hash slice (tree size n = len(leaves)). The returned slice is
// the ordered list of sibling hashes from the leaf up to (but not including)
// the root. Returns an empty slice for a single-leaf tree.
func auditPath(leaves [][]byte, m int) [][]byte {
n := len(leaves)
if n <= 1 {
return nil
}
k := largestPow2LessThan(n)
if m < k {
return append(auditPath(leaves[:k], m), merkleTreeHash(leaves[k:]))
}
return append(auditPath(leaves[k:], m-k), merkleTreeHash(leaves[:k]))
}
// largestPow2LessThan returns the largest power of two strictly smaller than
// n (n must be >= 2).
func largestPow2LessThan(n int) int {
k := 1
for k < n {
k <<= 1
}
return k >> 1
}
func sha256Hex(b []byte) string {
return "sha256:" + hex.EncodeToString(b)
}

View file

@ -0,0 +1,35 @@
package backend
import (
"context"
"errors"
)
// 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).
//
// 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.
type Trillian struct {
// Endpoint will later hold the Trillian LogServer address, e.g.
// "trillian-log.agent-trust.svc.cluster.local:8090".
Endpoint string
// LogID will later hold the Trillian tree id this service writes to.
LogID int64
}
// 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
}
// ErrTrillianUnavailable signals that the prod backend is not wired yet.
// Translated to HTTP 503 by the httpapi layer.
var ErrTrillianUnavailable = errors.New("transparency-log: trillian backend not deployed")

View file

@ -0,0 +1,52 @@
// Package backend defines the storage/append-log interface for the
// transparency-log service and ships two implementations: an in-memory
// Merkle-tree append-log for dev + CI, and a Trillian wrapper (prod stub).
//
// The service contract (ADR-0005) is REST and envelope-shaped; this package
// has no HTTP concerns. The httpapi package translates HTTP requests into
// Backend.Submit calls.
package backend
import (
"context"
"time"
)
// Envelope mirrors the DSSE envelope shape from ADR-0005. The server never
// looks inside the payload; it only hashes the serialised envelope and
// stores it. The field order and tags match the admin-api attest.Envelope
// shape so the content_hash computation is bit-identical on both sides.
type Envelope struct {
PayloadType string `json:"payloadType"`
Payload string `json:"payload"`
Signatures []EnvelopeSignature `json:"signatures"`
}
type EnvelopeSignature struct {
KeyID string `json:"keyid"`
Sig string `json:"sig"`
}
// Receipt is what the Backend returns on a successful submit. It mirrors
// the response body of POST /api/v1/log/entries from ADR-0005.
type Receipt struct {
LogIndex int64 `json:"log_index"`
ContentHash string `json:"content_hash"`
LoggedAt time.Time `json:"logged_at"`
InclusionProof InclusionProof `json:"inclusion_proof"`
}
type InclusionProof struct {
TreeSize int64 `json:"tree_size"`
TreeRootHash string `json:"tree_root_hash"`
SignedTreeHeadRef string `json:"signed_tree_head_ref"`
Hashes []string `json:"hashes"`
}
// Backend is the append-log abstraction. Implementations must be safe for
// concurrent use and MUST be idempotent on contentHash: a repeated submit
// with the same contentHash returns the originally assigned log_index and
// a valid inclusion proof against the current tree size, and sets fresh=false.
type Backend interface {
Submit(ctx context.Context, env Envelope, contentHash string) (receipt Receipt, fresh bool, err error)
}

View file

@ -0,0 +1,135 @@
// Package httpapi implements the server side of the ADR-0005 Transparency
// Log HTTP contract: POST /api/v1/log/entries.
package httpapi
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"basicstack.de/proofedmesh/services/transparency-log/pkg/backend"
)
// Server is the HTTP handler. Mount it on /api/v1/log/entries via Handler().
// Authentication (Pocket-ID bearer tokens per ADR-0005) is intentionally
// NOT in this struct; it is wired by cmd/transparency-log/main.go through
// a middleware so that the dev/CI path can run without an IdP.
type Server struct {
Backend backend.Backend
MaxBody int64 // 0 → default 1 MiB
LogError func(string, ...any)
}
// Handler returns an http.Handler that serves the ADR-0005 routes. Only
// POST /api/v1/log/entries is implemented; everything else is 404.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("POST /api/v1/log/entries", http.HandlerFunc(s.handleSubmit))
return mux
}
type submitRequest struct {
Envelope backend.Envelope `json:"envelope"`
ContentHash string `json:"content_hash"`
}
type errorBody struct {
Error string `json:"error"`
}
func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
if ct := r.Header.Get("Content-Type"); ct != "" && ct != "application/json" && !isJSONWithCharset(ct) {
writeError(w, http.StatusBadRequest, "content-type must be application/json")
return
}
max := s.MaxBody
if max <= 0 {
max = 1 << 20
}
raw, err := io.ReadAll(io.LimitReader(r.Body, max+1))
if err != nil {
writeError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
if int64(len(raw)) > max {
writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("body exceeds %d bytes", max))
return
}
var req submitRequest
dec := json.NewDecoder(bytesReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "decode: "+err.Error())
return
}
if req.ContentHash == "" {
writeError(w, http.StatusBadRequest, "content_hash is required")
return
}
receipt, fresh, err := s.Backend.Submit(r.Context(), req.Envelope, req.ContentHash)
switch {
case errors.Is(err, backend.ErrContentHashMismatch):
writeError(w, http.StatusConflict, err.Error())
return
case errors.Is(err, backend.ErrTrillianUnavailable):
writeError(w, http.StatusServiceUnavailable, err.Error())
return
case err != nil:
s.logErr("transparency-log submit: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
status := http.StatusOK
if fresh {
status = http.StatusCreated
}
writeJSON(w, status, receipt)
}
func (s *Server) logErr(format string, args ...any) {
if s.LogError != nil {
s.LogError(format, args...)
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorBody{Error: msg})
}
func isJSONWithCharset(ct string) bool {
// Accept "application/json; charset=utf-8" and friends.
const prefix = "application/json"
if len(ct) < len(prefix) {
return false
}
if ct[:len(prefix)] != prefix {
return false
}
rest := ct[len(prefix):]
return len(rest) == 0 || rest[0] == ';' || rest[0] == ' '
}
// bytesReader avoids pulling in bytes just for a reader over a []byte.
type sliceReader struct {
b []byte
}
func (r *sliceReader) Read(p []byte) (int, error) {
if len(r.b) == 0 {
return 0, io.EOF
}
n := copy(p, r.b)
r.b = r.b[n:]
return n, nil
}
func bytesReader(b []byte) io.Reader { return &sliceReader{b: b} }

View file

@ -0,0 +1,156 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"basicstack.de/proofedmesh/services/transparency-log/pkg/backend"
)
func fixtureEnvelope() backend.Envelope {
return backend.Envelope{
PayloadType: "application/vnd.in-toto+json",
Payload: "aGVsbG8",
Signatures: []backend.EnvelopeSignature{{KeyID: "k", Sig: "c2ln"}},
}
}
func newServer(t *testing.T) *httptest.Server {
t.Helper()
s := &Server{Backend: backend.NewMemory()}
return httptest.NewServer(s.Handler())
}
func TestSubmit_HappyPath_201(t *testing.T) {
srv := newServer(t)
defer srv.Close()
env := fixtureEnvelope()
ch, err := backend.EnvelopeContentHash(env)
if err != nil {
t.Fatalf("hash: %v", err)
}
body, _ := json.Marshal(submitRequest{Envelope: env, ContentHash: ch})
resp, err := http.Post(srv.URL+"/api/v1/log/entries", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("want 201, got %d: %s", resp.StatusCode, raw)
}
var rct backend.Receipt
if err := json.NewDecoder(resp.Body).Decode(&rct); err != nil {
t.Fatalf("decode: %v", err)
}
if rct.ContentHash != ch {
t.Errorf("content_hash echoed wrong: %q vs %q", rct.ContentHash, ch)
}
if rct.LogIndex != 0 {
t.Errorf("log_index: %d", rct.LogIndex)
}
if rct.InclusionProof.TreeSize != 1 {
t.Errorf("tree_size: %d", rct.InclusionProof.TreeSize)
}
}
func TestSubmit_Idempotent_200_SameIndex(t *testing.T) {
srv := newServer(t)
defer srv.Close()
env := fixtureEnvelope()
ch, _ := backend.EnvelopeContentHash(env)
body, _ := json.Marshal(submitRequest{Envelope: env, ContentHash: ch})
// First call -> 201
resp1, err := http.Post(srv.URL+"/api/v1/log/entries", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatalf("post1: %v", err)
}
resp1.Body.Close()
if resp1.StatusCode != http.StatusCreated {
t.Fatalf("first status: %d", resp1.StatusCode)
}
// Second call -> 200
resp2, err := http.Post(srv.URL+"/api/v1/log/entries", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatalf("post2: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp2.Body)
t.Fatalf("idempotent repeat status: want 200, got %d, body=%s", resp2.StatusCode, raw)
}
var rct2 backend.Receipt
if err := json.NewDecoder(resp2.Body).Decode(&rct2); err != nil {
t.Fatalf("decode: %v", err)
}
if rct2.LogIndex != 0 {
t.Errorf("idempotent log_index: want 0, got %d", rct2.LogIndex)
}
}
func TestSubmit_ContentHashMismatch_409(t *testing.T) {
srv := newServer(t)
defer srv.Close()
env := fixtureEnvelope()
bogus := "sha256:" + strings.Repeat("0", 64)
body, _ := json.Marshal(submitRequest{Envelope: env, ContentHash: bogus})
resp, err := http.Post(srv.URL+"/api/v1/log/entries", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusConflict {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("want 409, got %d: %s", resp.StatusCode, raw)
}
}
func TestSubmit_RejectsMissingContentHash(t *testing.T) {
srv := newServer(t)
defer srv.Close()
body := `{"envelope":{"payloadType":"application/vnd.in-toto+json","payload":"aGVsbG8","signatures":[]}}`
resp, err := http.Post(srv.URL+"/api/v1/log/entries", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("want 400, got %d", resp.StatusCode)
}
}
func TestSubmit_UnknownRoute_404(t *testing.T) {
srv := newServer(t)
defer srv.Close()
resp, err := http.Post(srv.URL+"/api/v1/log/other", "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("want 404, got %d", resp.StatusCode)
}
}
func TestSubmit_TrillianStub_Returns503(t *testing.T) {
srv := httptest.NewServer((&Server{Backend: backend.Trillian{}}).Handler())
defer srv.Close()
env := fixtureEnvelope()
ch, _ := backend.EnvelopeContentHash(env)
body, _ := json.Marshal(submitRequest{Envelope: env, ContentHash: ch})
resp, err := http.Post(srv.URL+"/api/v1/log/entries", "application/json", strings.NewReader(string(body)))
if err != nil {
t.Fatalf("post: %v", err)
}
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)
}
}