services: add transparency-log Go service (ADR-0005 POST /api/v1/log/entries)
- 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:
parent
2e44147a71
commit
69b3f3012f
13 changed files with 1138 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
97
services/admin-api/internal/tlog/interop_test.go
Normal file
97
services/admin-api/internal/tlog/interop_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
85
services/transparency-log/README.md
Normal file
85
services/transparency-log/README.md
Normal 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).
|
||||||
77
services/transparency-log/cmd/transparency-log/main.go
Normal file
77
services/transparency-log/cmd/transparency-log/main.go
Normal 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) }
|
||||||
3
services/transparency-log/go.mod
Normal file
3
services/transparency-log/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module basicstack.de/proofedmesh/services/transparency-log
|
||||||
|
|
||||||
|
go 1.23
|
||||||
96
services/transparency-log/pkg/backend/hash.go
Normal file
96
services/transparency-log/pkg/backend/hash.go
Normal 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
|
||||||
|
}
|
||||||
119
services/transparency-log/pkg/backend/memory.go
Normal file
119
services/transparency-log/pkg/backend/memory.go
Normal 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")
|
||||||
203
services/transparency-log/pkg/backend/memory_test.go
Normal file
203
services/transparency-log/pkg/backend/memory_test.go
Normal 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):])
|
||||||
|
}
|
||||||
73
services/transparency-log/pkg/backend/merkle.go
Normal file
73
services/transparency-log/pkg/backend/merkle.go
Normal 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)
|
||||||
|
}
|
||||||
35
services/transparency-log/pkg/backend/trillian.go
Normal file
35
services/transparency-log/pkg/backend/trillian.go
Normal 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")
|
||||||
52
services/transparency-log/pkg/backend/types.go
Normal file
52
services/transparency-log/pkg/backend/types.go
Normal 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)
|
||||||
|
}
|
||||||
135
services/transparency-log/pkg/httpapi/server.go
Normal file
135
services/transparency-log/pkg/httpapi/server.go
Normal 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} }
|
||||||
156
services/transparency-log/pkg/httpapi/server_test.go
Normal file
156
services/transparency-log/pkg/httpapi/server_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue