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
|
||||
|
||||
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