diff --git a/services/transparency-log/pkg/backend/memory.go b/services/transparency-log/pkg/backend/memory.go index 74a226f..bb7add4 100644 --- a/services/transparency-log/pkg/backend/memory.go +++ b/services/transparency-log/pkg/backend/memory.go @@ -18,19 +18,21 @@ import ( // 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 + 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 + envelopesByIndex map[int64]Envelope // original envelope per index (for Get) + 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() }, + indexByHash: map[string]int64{}, + loggedAtByIndex: map[int64]time.Time{}, + envelopesByIndex: map[int64]Envelope{}, + now: func() time.Time { return time.Now().UTC() }, } } @@ -73,6 +75,7 @@ func (m *Memory) Submit(_ context.Context, env Envelope, contentHash string) (Re m.leaves = append(m.leaves, leafHash(canonical)) m.indexByHash[contentHash] = idx m.loggedAtByIndex[idx] = m.now() + m.envelopesByIndex[idx] = env fresh = true } @@ -99,6 +102,40 @@ func (m *Memory) Submit(_ context.Context, env Envelope, contentHash string) (Re }, fresh, nil } +// Get returns the stored envelope + a fresh inclusion proof against the +// current tree. Suitable for the ADR-0005 read endpoint. +func (m *Memory) Get(_ context.Context, contentHash string) (Entry, error) { + if contentHash == "" { + return Entry{}, errors.New("transparency-log: empty content_hash") + } + m.mu.Lock() + defer m.mu.Unlock() + + idx, ok := m.indexByHash[contentHash] + if !ok { + return Entry{}, ErrEntryNotFound + } + env := m.envelopesByIndex[idx] + root := merkleTreeHash(m.leaves) + proof := auditPath(m.leaves, int(idx)) + hashes := make([]string, 0, len(proof)) + for _, h := range proof { + hashes = append(hashes, sha256Hex(h)) + } + return Entry{ + ContentHash: contentHash, + Envelope: env, + LogIndex: idx, + LoggedAt: m.loggedAtByIndex[idx], + InclusionProof: InclusionProof{ + TreeSize: int64(len(m.leaves)), + TreeRootHash: sha256Hex(root), + SignedTreeHeadRef: signedTreeHeadRef(int64(len(m.leaves)), root), + Hashes: hashes, + }, + }, 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. diff --git a/services/transparency-log/pkg/backend/trillian.go b/services/transparency-log/pkg/backend/trillian.go index 95cf7e6..7ff15a4 100644 --- a/services/transparency-log/pkg/backend/trillian.go +++ b/services/transparency-log/pkg/backend/trillian.go @@ -58,6 +58,13 @@ type Trillian struct { dialErr error } +// Get is not yet wired against Trillian; the production read path is a +// future subtask. The handler short-circuits to 503 when the client is +// absent, so this stub only has to satisfy the interface. +func (t *Trillian) Get(_ context.Context, _ string) (Entry, error) { + return Entry{}, ErrTrillianUnavailable +} + // Submit queues the envelope and returns the inclusion proof against the // current signed tree head. See the Backend interface for the fresh // semantics. diff --git a/services/transparency-log/pkg/backend/types.go b/services/transparency-log/pkg/backend/types.go index c855f05..49d5332 100644 --- a/services/transparency-log/pkg/backend/types.go +++ b/services/transparency-log/pkg/backend/types.go @@ -47,6 +47,27 @@ type InclusionProof struct { // 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. +// +// Get returns the stored envelope + a fresh inclusion proof against the +// current tree size. Verifiers rely on this to independently recompute the +// Merkle tree root (ADR-0005 §Read side). type Backend interface { Submit(ctx context.Context, env Envelope, contentHash string) (receipt Receipt, fresh bool, err error) + Get(ctx context.Context, contentHash string) (Entry, error) } + +// Entry is the shape returned by GET /api/v1/log/entries/{content_hash}. +type Entry struct { + ContentHash string `json:"content_hash"` + Envelope Envelope `json:"envelope"` + LogIndex int64 `json:"log_index"` + LoggedAt time.Time `json:"logged_at"` + InclusionProof InclusionProof `json:"inclusion_proof"` +} + +// ErrEntryNotFound is returned by Get when contentHash is not in the log. +var ErrEntryNotFound = errorString("transparency-log: entry not found") + +type errorString string + +func (e errorString) Error() string { return string(e) } diff --git a/services/transparency-log/pkg/httpapi/server.go b/services/transparency-log/pkg/httpapi/server.go index 4733689..cd9e5ba 100644 --- a/services/transparency-log/pkg/httpapi/server.go +++ b/services/transparency-log/pkg/httpapi/server.go @@ -22,14 +22,44 @@ type Server struct { 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. +// Handler returns an http.Handler that serves the ADR-0005 routes plus a +// liveness check at GET /healthz. Everything else is 404. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.Handle("POST /api/v1/log/entries", http.HandlerFunc(s.handleSubmit)) + mux.Handle("GET /api/v1/log/entries/{hash}", http.HandlerFunc(s.handleGetEntry)) + mux.Handle("GET /healthz", http.HandlerFunc(s.handleHealth)) return mux } +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func (s *Server) handleGetEntry(w http.ResponseWriter, r *http.Request) { + hash := r.PathValue("hash") + if hash == "" { + writeError(w, http.StatusBadRequest, "content_hash is required") + return + } + entry, err := s.Backend.Get(r.Context(), hash) + switch { + case errors.Is(err, backend.ErrEntryNotFound): + writeError(w, http.StatusNotFound, "entry not found") + return + case errors.Is(err, backend.ErrTrillianUnavailable): + writeError(w, http.StatusServiceUnavailable, err.Error()) + return + case err != nil: + s.logErr("transparency-log get: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, http.StatusOK, entry) +} + type submitRequest struct { Envelope backend.Envelope `json:"envelope"` ContentHash string `json:"content_hash"`