ProofedMesh/services/transparency-log/pkg/backend/memory.go
CTO (Paperclip) 47a7373ef2 transparency-log: add GET /api/v1/log/entries/{hash} + /healthz (DEV-613)
The verification-service and testagent-transform clients both call
`GET /api/v1/log/entries/{content_hash}` to read a stored envelope and
inclusion proof back out of the log, but the server previously only
implemented the POST (write) half of ADR-0005. Add:

- `Backend.Get` plus `backend.Entry` and `ErrEntryNotFound` so the
  memory backend can serve the read; Trillian stays stubbed and
  continues returning 503 (`ErrTrillianUnavailable`) until a later
  subtask wires the real read path.
- `GET /api/v1/log/entries/{hash}` on the HTTP server, routing 404 /
  503 / 500 with the same error envelope as the POST half.
- `GET /healthz` so the Phase-1 smoke-test harness can wait for the
  service to come up instead of racing against its bind.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 20:57:52 +00:00

156 lines
4.9 KiB
Go

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
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{},
envelopesByIndex: map[int64]Envelope{},
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()
m.envelopesByIndex[idx] = env
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
}
// 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.
// 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")