verification-service: Phase-1 two-stage chain verifier (approval -> transform) (DEV-612)
Some checks failed
validate / Rego (opa fmt + opa test) (push) Successful in 16s
validate / JSON Schema (ajv) (push) Successful in 20s
build-images / signing-service image (push) Successful in 24s
validate / DB (migrate up/down dry-run) (push) Failing after 26s
validate / Go (vet + test) (push) Successful in 1m16s

Adds services/verification-service exposing POST /api/v1/verify/chain
for sink:dummy-sink. The handler runs the six hard-coded chain checks
described in DEV-612 and reserves decision_id in a new
`verification_decisions` table so a replayed sink delivery is refused
without recomputing signatures.

- DSSE signature verification against configurable approval + transform
  signer key_ids, loaded from VERIFICATION_SIGNER_KEYS (pem/hex/b64/b64u)
- RFC 6962 inclusion-proof verifier (shared leaf/node hashing with
  services/transparency-log/pkg/backend)
- Content binding: subject.digest.sha256 must match predicate.content_hash
  (approval) and request.payload_hash (transform)
- Hard-coded coverage of allowed_transformations / allowed_sinks /
  expires_at predicates (OPA deferred to Phase 2 per the subtask)
- Replay guard via new db/trust/migrations/0005_verification_decisions
  (in-memory store ships; Postgres skeleton mirrors sibling pattern)
- Dockerfile mirrors signing-service pattern (distroless static + nonroot)
- README documents endpoint, reason-code taxonomy, required predicate
  fields, and the Phase-2 non-scope

Tests: happy path + 7 negatives (bad signature, missing inclusion proof,
hash drift, sink not allowed, expired, replay, operation not allowed).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (Paperclip) 2026-10-25 20:35:35 +00:00
parent b45ad34169
commit 0ae29ae342
23 changed files with 2831 additions and 0 deletions

View file

@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_verification_decisions_approval;
DROP INDEX IF EXISTS idx_verification_decisions_sink_created;
DROP TABLE IF EXISTS verification_decisions;

View file

@ -0,0 +1,46 @@
-- Migration: 0005_verification_decisions
-- Replay-Schutz-Tabelle für die verification-service-Kette (DEV-612).
--
-- Jede erfolgreiche POST /api/v1/verify/chain-Antwort reserviert hier
-- genau eine Zeile. Ein zweiter Aufruf mit dem gleichen decision_id
-- löst keine Prüfung erneut aus, sondern liefert `valid=false,
-- replay=true` zurück (dummy-sink mappt das auf 409 Conflict).
--
-- Design-Entscheidungen:
-- * decision_id ist caller-chosen und opaque — TEXT PK statt UUID.
-- * approval_signer_key / transform_signer_key sind die DSSE-key_ids
-- (z. B. "trust-api/approval-signer-2026-q4"), nicht Agent-IDs.
-- Wer tatsächlich signiert hat, ist das Signaturartefakt, nicht der
-- Agent-Slug.
-- * payload_hash + sink_target werden mit protokolliert, damit sich
-- eine Replay-Abweisung nachträglich auditieren lässt
-- ("gleiche decision_id, aber Payload wurde verändert").
-- * NO FK auf approvals/attestations: diese Tabelle ist bewusst eine
-- Insel und darf nicht an die Lebenszyklen der Signatur-Services
-- gebunden werden.
CREATE TABLE IF NOT EXISTS verification_decisions (
decision_id TEXT PRIMARY KEY
CHECK (length(decision_id) BETWEEN 1 AND 256),
approval_ref TEXT NOT NULL
CHECK (approval_ref ~ '^sha256:[0-9a-f]{64}$'),
transform_ref TEXT NOT NULL
CHECK (transform_ref ~ '^sha256:[0-9a-f]{64}$'),
payload_hash TEXT NOT NULL
CHECK (payload_hash ~ '^sha256:[0-9a-f]{64}$'),
sink_target TEXT NOT NULL
CHECK (length(sink_target) BETWEEN 1 AND 256),
approval_signer_key TEXT NOT NULL
CHECK (length(approval_signer_key) BETWEEN 1 AND 256),
transform_signer_key TEXT NOT NULL
CHECK (length(transform_signer_key) BETWEEN 1 AND 256),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Audit-Pfad: "wann wurde zuletzt auf diese Senke geschrieben?"
CREATE INDEX idx_verification_decisions_sink_created
ON verification_decisions (sink_target, created_at);
-- Audit-Pfad: "welche Entscheidungen hängen an diesem approval_ref?"
CREATE INDEX idx_verification_decisions_approval
ON verification_decisions (approval_ref);

View file

@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1.7
# ---------- build ----------
FROM golang:1.23-alpine3.20 AS build
ENV CGO_ENABLED=0 \
GOOS=linux \
GOFLAGS="-trimpath"
WORKDIR /src
# Pre-cache module metadata before copying sources.
COPY services/verification-service/go.mod ./services/verification-service/
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
cd services/verification-service && go mod download
COPY services/verification-service/ ./services/verification-service/
WORKDIR /src/services/verification-service
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go vet ./... && \
go test ./... && \
go build -ldflags="-s -w" -o /out/verification-service ./cmd/verification-service
# ---------- runtime ----------
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/verification-service /verification-service
USER 65532:65532
EXPOSE 8084
ENTRYPOINT ["/verification-service"]
CMD ["-listen=:8084", "-backend=memory"]

View file

@ -0,0 +1,156 @@
# verification-service
Phase-1 Verifier for the ProofedMesh two-stage trust chain
(`approval → transform`) per
[DEV-612](/DEV/issues/DEV-612), parent
[DEV-606](/DEV/issues/DEV-606).
Validates an end-to-end chain for `sink:dummy-sink` and reserves a
`decision_id` exactly once so a replayed sink delivery can be refused
without re-running the signature math.
## Endpoint
```
POST /api/v1/verify/chain
Content-Type: application/json
{
"decision_id": "dec-123",
"approval_ref": "sha256:<64-hex>",
"transform_ref": "sha256:<64-hex>",
"payload_hash": "sha256:<64-hex>",
"sink_target": "sink:dummy-sink"
}
```
Response (always 200 unless the TLog is unreachable):
```
{
"valid": true | false,
"replay": true | false,
"decision_id": "dec-123",
"actors": ["trust-api/approval-signer-2026-q4", "trust-api/agent-testagent-transform-2026-q4"],
"approval_ref": "sha256:…",
"transform_ref": "sha256:…",
"reasons": ["<reason code>", …]
}
```
The sink (dummy-sink) maps `replay: true` to **409 Conflict** and the
rest of `valid: false` to **403 Forbidden**; the verification-service
itself only uses non-200 status codes for transport failures (502 when
the transparency-log is unavailable, 400 for malformed bodies).
The handler also accepts `target_sink` as a legacy alias for
`sink_target` so the DEV-612 scope wording stays wire-compatible.
### Chain rules (hard-coded, Phase 1)
In order; the first failure wins and populates `reasons`:
1. **DSSE signatures.**
- Approval signed by `trust-api/approval-signer-2026-q4`
(configurable via `APPROVAL_SIGNER_KEY_ID`).
- Transform signed by `trust-api/agent-testagent-transform-2026-q4`
(configurable via `TRANSFORM_SIGNER_KEY_ID`).
- Public keys loaded from `VERIFICATION_SIGNER_KEYS` as a comma- /
newline-separated spec:
`trust-api/approval-signer-2026-q4=pem:<PEM>,trust-api/agent-testagent-transform-2026-q4=hex:<64-hex>`.
Supported encodings: `pem`, `hex`, `b64`, `b64u`.
2. **Transparency-log inclusion proofs.** RFC 6962 §2.1.1; the proof
returned by the TLog must close against the `tree_root_hash` for
both envelopes.
3. **Content-binding hash.**
- `approval.subject.digest.sha256` matches
`strip("sha256:", approval.predicate.content_hash)`.
- `transform.subject.digest.sha256` matches
`strip("sha256:", request.payload_hash)`.
4. **`allowed_transformations`** on the approval predicate covers the
transform predicate's `operation`.
5. **`allowed_sinks`** on the approval predicate contains the chain
request's `sink_target`.
6. **Replay guard.** `decision_id` is reserved by the replay store
(`verification_decisions` table). A second call with the same id
returns `replay: true, valid: false`.
Approval predicates MUST additionally carry `expires_at` as an RFC 3339
timestamp. A verification call after that instant returns
`approval_expired` and does not reserve the decision.
### Reason codes
Stable strings; dashboards grep them.
- `bad_request`
- `approval_fetch_failed`, `transform_fetch_failed`
- `approval_signature_unknown_key`, `approval_signature_invalid`
- `transform_signature_unknown_key`, `transform_signature_invalid`
- `approval_inclusion_proof_invalid`, `transform_inclusion_proof_invalid`
- `approval_predicate_malformed`, `transform_predicate_malformed`
- `approval_hash_drift`, `transform_hash_drift`
- `operation_not_in_allowed_transformations`
- `sink_not_in_allowed_sinks`
- `approval_expired`
- `replay_decision_id`
### Expected predicate fields
The verifier is strict about the fields it reads. The approval
predicate (`basicstack.de/attestations/approval/v1`) MUST carry:
- `content_hash` — `sha256:<hex>` of the approved canonical payload.
- `allowed_transformations: ["<operation>", …]`
- `allowed_sinks: ["<sink_id>", …]`
- `expires_at` — RFC 3339 timestamp.
The transform predicate
(`basicstack.de/attestations/transform/v1`) MUST carry:
- `operation` — the OPA-vetted transformation name
(e.g. `expense.map_to_sink`).
The approval-service (DEV-608) currently emits `content_hash`,
`performed_by`, `performed_at`, `verification_digits` and
`approval_schema`. The three new fields (`allowed_transformations`,
`allowed_sinks`, `expires_at`) must be added to the approval predicate
upstream before the Phase-1 end-to-end smoke
([DEV-613](/DEV/issues/DEV-613)) can pass; this verifier returns
`approval_predicate_malformed` until they are present.
## Configuration
| Env var | Flag | Default | Required |
| -------------------------- | ----------------------- | --------------------------------------------------------- | -------- |
| `TLOG_URL` | `-tlog-url` | — | yes |
| `TLOG_READ_TOKEN` | `-tlog-token` | empty | no |
| `APPROVAL_SIGNER_KEY_ID` | `-approval-key-id` | `trust-api/approval-signer-2026-q4` | no |
| `TRANSFORM_SIGNER_KEY_ID` | `-transform-key-id` | `trust-api/agent-testagent-transform-2026-q4` | no |
| `VERIFICATION_SIGNER_KEYS` | `-signer-keys` | — | yes |
| `VERIFICATION_STORE_BACKEND` | `-backend` | `memory` | no |
The replay store is in-memory in Phase 1. The Postgres backend is a
stub mirroring the pattern used by the approval-service's Postgres
skeleton; the schema is authoritative in
`db/trust/migrations/0005_verification_decisions.{up,down}.sql`.
## Build + test
```
cd services/verification-service
go vet ./...
go test ./...
```
The CI pipeline (`.forgejo/workflows/validate.yml`) iterates every
`services/*/go.mod` so the module is picked up automatically.
## Non-scope (Phase 2)
- A second transform agent / N-stage chains.
- OPA for verification. The rules above are hard-coded here; moving
them to OPA is tracked under the Phase-2 chain-policy work.
- Live agent-registry lookups for transform signer keys. The
`registry.Keyring` interface is the swap-point when the HTTP-backed
registry lands.

View file

@ -0,0 +1,141 @@
// Command verification-service exposes POST /api/v1/verify/chain over
// HTTP and binds the chain verifier to its production dependencies:
//
// - TLog read client (transparency-log GET /api/v1/log/entries/{hash})
// - Keyring loaded from VERIFICATION_SIGNER_KEYS (or -signer-keys flag)
// - Replay store (in-memory in Phase 1; Postgres skeleton in place)
//
// See services/verification-service/README.md for the full env matrix.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"basicstack.de/proofedmesh/services/verification-service/internal/chain"
"basicstack.de/proofedmesh/services/verification-service/internal/httpapi"
"basicstack.de/proofedmesh/services/verification-service/internal/registry"
"basicstack.de/proofedmesh/services/verification-service/internal/store"
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
)
func main() {
listen := flag.String("listen", ":8084", "HTTP listen address")
tlogURL := flag.String("tlog-url", envOr("TLOG_URL", ""), "base URL of transparency-log (env TLOG_URL)")
tlogToken := flag.String("tlog-token", envOr("TLOG_READ_TOKEN", ""), "optional bearer token for TLog read (env TLOG_READ_TOKEN)")
approvalKeyID := flag.String("approval-key-id", envOr("APPROVAL_SIGNER_KEY_ID", "trust-api/approval-signer-2026-q4"),
"DSSE keyid of the approval signer (env APPROVAL_SIGNER_KEY_ID)")
transformKeyID := flag.String("transform-key-id", envOr("TRANSFORM_SIGNER_KEY_ID", "trust-api/agent-testagent-transform-2026-q4"),
"DSSE keyid of the transform signer (env TRANSFORM_SIGNER_KEY_ID)")
signerKeysSpec := flag.String("signer-keys", envOr("VERIFICATION_SIGNER_KEYS", ""),
"comma- or newline-separated keyid=<encoding>:<data> entries loaded into the keyring (env VERIFICATION_SIGNER_KEYS)")
backend := flag.String("backend", envOr("VERIFICATION_STORE_BACKEND", "memory"),
"replay store backend: memory | postgres (env VERIFICATION_STORE_BACKEND)")
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := requireNonEmpty("tlog-url", *tlogURL); err != nil {
exit(logger, err)
}
if *signerKeysSpec == "" {
exit(logger, errors.New("verification-service: VERIFICATION_SIGNER_KEYS (or -signer-keys) is required to load approval and transform signer public keys"))
}
keyring := registry.NewStatic()
if err := keyring.LoadFromSpec(*signerKeysSpec); err != nil {
exit(logger, fmt.Errorf("load signer keys: %w", err))
}
var replayStore store.Store
switch *backend {
case "memory":
replayStore = store.NewMemory()
case "postgres":
pg, err := store.NewPostgres()
if err != nil {
exit(logger, fmt.Errorf("postgres backend: %w", err))
}
replayStore = pg
default:
exit(logger, fmt.Errorf("unknown backend %q (want memory|postgres)", *backend))
}
verifier := &chain.Verifier{
TLog: tlog.Client{
BaseURL: *tlogURL,
AuthToken: *tlogToken,
},
Keyring: keyring,
Store: replayStore,
Approval: chain.ApprovalConfig{
ExpectedKeyID: *approvalKeyID,
TransformKeyID: *transformKeyID,
},
}
srv := &httpapi.Server{
Verifier: verifier,
Logger: logger,
}
mux := http.NewServeMux()
srv.Mount(mux)
hsrv := &http.Server{
Addr: *listen,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
logger.Info("verification-service listening",
"addr", *listen,
"tlog_url", *tlogURL,
"approval_key_id", *approvalKeyID,
"transform_key_id", *transformKeyID,
"backend", *backend,
)
if err := hsrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("http serve", "err", err)
stop()
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = hsrv.Shutdown(shutdownCtx)
logger.Info("verification-service stopped")
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func requireNonEmpty(name, value string) error {
if value == "" {
return fmt.Errorf("--%s (or its env equivalent) is required", name)
}
return nil
}
func exit(logger *slog.Logger, err error) {
logger.Error("startup failed", "err", err)
os.Exit(1)
}

View file

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

View file

@ -0,0 +1,106 @@
// Package canonical implements the deterministic JSON serialisation
// (sorted object keys) the verification-service uses when it computes
// payload / subject digests that MUST reproduce byte-for-byte on the
// signer side.
//
// MUST stay compatible with:
// - services/approval-service/internal/canonical/canonical.go
// - services/signing-service/internal/canonical/canonical.go
// - services/testagent-transform/internal/canonical/canonical.go
// - services/dummy-sink/internal/canonical/canonical.go
// - services/transparency-log/pkg/backend (canonicalJSON used for leaf hashing)
package canonical
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
)
// SHA256Hex returns the hex-encoded SHA-256 of b.
func SHA256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// Marshal returns the canonical JSON encoding of v (sorted object keys).
func Marshal(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
}
// Hash returns the sha256:<hex> content hash of v's canonical JSON along
// with the raw canonical bytes.
func Hash(v any) (string, []byte, error) {
raw, err := Marshal(v)
if err != nil {
return "", nil, err
}
return "sha256:" + SHA256Hex(raw), raw, 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("canonical.writeSorted: unsupported type %T", v)
}
return nil
}

View file

@ -0,0 +1,455 @@
// Package chain implements the Phase-1 two-stage (approval -> transform)
// verification chain described in DEV-612. Rules are hard-coded here;
// OPA for verification arrives in Phase 2.
//
// The six checks (see DEV-612 scope) are, in order:
//
// 1. Both DSSE signatures verify against their signer public keys
// (approval: `trust-api/approval-signer-2026-q4`; transform:
// `trust-api/agent-testagent-transform-2026-q4` via the Agent
// Registry).
// 2. TLog inclusion proofs for both refs exist and reproduce the
// signed tree root (RFC 6962 §2.1.1).
// 3. Content-binding: approval.subject.digest.sha256 ==
// strip("sha256:", approval.predicate.content_hash), and
// transform.subject.digest.sha256 == strip("sha256:", payload_hash).
// This closes the chain from the signed statement to the raw bytes
// the sink persists.
// 4. approval.predicate.allowed_transformations covers
// transform.predicate.operation.
// 5. approval.predicate.allowed_sinks contains sink_target.
// 6. Replay guard: Store.Reserve succeeds for decision_id.
//
// Any failure returns valid=false with a reason code in Verdict.Reasons;
// the HTTP layer maps those codes to a stable JSON response.
package chain
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
"basicstack.de/proofedmesh/services/verification-service/internal/registry"
"basicstack.de/proofedmesh/services/verification-service/internal/store"
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
)
// Request is the input to Verify.
type Request struct {
DecisionID string
ApprovalRef string
TransformRef string
PayloadHash string
SinkTarget string
}
// Verdict is the output of Verify.
type Verdict struct {
Valid bool `json:"valid"`
Replay bool `json:"replay"`
DecisionID string `json:"decision_id"`
Actors []string `json:"actors,omitempty"`
ApprovalRef string `json:"approval_ref,omitempty"`
TransformRef string `json:"transform_ref,omitempty"`
Reasons []string `json:"reasons,omitempty"`
}
// Reason codes. Stable strings; dashboards grep them. Keep in sync with
// the HTTP response contract and the dummy-sink's reaction (403 vs 409).
const (
ReasonBadRequest = "bad_request"
ReasonApprovalFetch = "approval_fetch_failed"
ReasonTransformFetch = "transform_fetch_failed"
ReasonApprovalSignatureUnknown = "approval_signature_unknown_key"
ReasonApprovalSignatureInvalid = "approval_signature_invalid"
ReasonTransformSignatureUnknown = "transform_signature_unknown_key"
ReasonTransformSignatureInvalid = "transform_signature_invalid"
ReasonApprovalInclusionInvalid = "approval_inclusion_proof_invalid"
ReasonTransformInclusionInvalid = "transform_inclusion_proof_invalid"
ReasonApprovalPredicateMalformed = "approval_predicate_malformed"
ReasonTransformPredicateMalformed = "transform_predicate_malformed"
ReasonApprovalHashDrift = "approval_hash_drift"
ReasonTransformHashDrift = "transform_hash_drift"
ReasonSinkNotAllowed = "sink_not_in_allowed_sinks"
ReasonTransformationNotAllowed = "operation_not_in_allowed_transformations"
ReasonApprovalExpired = "approval_expired"
ReasonReplay = "replay_decision_id"
)
// Verifier wires the chain-check dependencies.
type Verifier struct {
TLog tlog.Fetcher
Keyring registry.Keyring
Store store.Store
Now func() time.Time
Approval ApprovalConfig
}
// ApprovalConfig constrains which keys and schema the approval predicate
// is expected to have. The key_id prefix pins the DSSE keyid to the
// configured signer period; a mismatched keyid is rejected before any
// signature math runs.
type ApprovalConfig struct {
// ExpectedKeyID is the full DSSE keyid for the active approval signer
// (e.g. "trust-api/approval-signer-2026-q4"). The verifier accepts
// signatures whose keyid matches this value exactly.
ExpectedKeyID string
// TransformKeyID is the full DSSE keyid for the active transform
// signer (e.g. "trust-api/agent-testagent-transform-2026-q4"). Phase 1
// has one transform agent; Phase 2 will replace this with a lookup
// against the agent registry.
TransformKeyID string
}
// Verify runs the full chain check. It never returns a Go error for a
// chain rejection: the Verdict carries the reasons. A Go error is only
// returned for transport-level problems that prevent us from deciding
// (TLog down, store panics, etc.).
func (v *Verifier) Verify(ctx context.Context, req Request) (Verdict, error) {
verdict := Verdict{
DecisionID: req.DecisionID,
ApprovalRef: req.ApprovalRef,
TransformRef: req.TransformRef,
}
if err := validateRequest(req); err != nil {
verdict.Reasons = append(verdict.Reasons, ReasonBadRequest+": "+err.Error())
return verdict, nil
}
// 1a. Fetch approval + verify DSSE signature.
apEntry, err := v.TLog.Fetch(ctx, req.ApprovalRef)
if err != nil {
if errors.Is(err, tlog.ErrNotFound) {
verdict.Reasons = append(verdict.Reasons, ReasonApprovalFetch+": not_found")
return verdict, nil
}
return verdict, fmt.Errorf("approval fetch: %w", err)
}
trEntry, err := v.TLog.Fetch(ctx, req.TransformRef)
if err != nil {
if errors.Is(err, tlog.ErrNotFound) {
verdict.Reasons = append(verdict.Reasons, ReasonTransformFetch+": not_found")
return verdict, nil
}
return verdict, fmt.Errorf("transform fetch: %w", err)
}
// 1b. Signature verification for approval.
apKr, err := v.keyringFor(ctx, v.Approval.ExpectedKeyID)
if err != nil {
verdict.Reasons = append(verdict.Reasons, ReasonApprovalSignatureUnknown+": "+err.Error())
return verdict, nil
}
apPayload, apKey, err := dsse.Verify(apEntry.Envelope, apKr)
if err != nil {
switch {
case errors.Is(err, dsse.ErrUnknownKey):
verdict.Reasons = append(verdict.Reasons, ReasonApprovalSignatureUnknown)
case errors.Is(err, dsse.ErrSignatureInvalid):
verdict.Reasons = append(verdict.Reasons, ReasonApprovalSignatureInvalid)
default:
verdict.Reasons = append(verdict.Reasons, ReasonApprovalSignatureInvalid+": "+err.Error())
}
return verdict, nil
}
// 1c. Signature verification for transform.
trKr, err := v.keyringFor(ctx, v.Approval.TransformKeyID)
if err != nil {
verdict.Reasons = append(verdict.Reasons, ReasonTransformSignatureUnknown+": "+err.Error())
return verdict, nil
}
trPayload, trKey, err := dsse.Verify(trEntry.Envelope, trKr)
if err != nil {
switch {
case errors.Is(err, dsse.ErrUnknownKey):
verdict.Reasons = append(verdict.Reasons, ReasonTransformSignatureUnknown)
case errors.Is(err, dsse.ErrSignatureInvalid):
verdict.Reasons = append(verdict.Reasons, ReasonTransformSignatureInvalid)
default:
verdict.Reasons = append(verdict.Reasons, ReasonTransformSignatureInvalid+": "+err.Error())
}
return verdict, nil
}
// 2. Inclusion proofs.
if err := tlog.VerifyEntry(apEntry); err != nil {
verdict.Reasons = append(verdict.Reasons, ReasonApprovalInclusionInvalid+": "+err.Error())
return verdict, nil
}
if err := tlog.VerifyEntry(trEntry); err != nil {
verdict.Reasons = append(verdict.Reasons, ReasonTransformInclusionInvalid+": "+err.Error())
return verdict, nil
}
// 3. Parse predicates (needed for checks 3-5) and verify content binding.
ap, err := parseStatement(apPayload)
if err != nil {
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
return verdict, nil
}
tr, err := parseStatement(trPayload)
if err != nil {
verdict.Reasons = append(verdict.Reasons, ReasonTransformPredicateMalformed+": "+err.Error())
return verdict, nil
}
apContentHash, err := ap.ContentHash()
if err != nil {
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
return verdict, nil
}
if ap.SubjectDigest() != stripSha256Prefix(apContentHash) {
verdict.Reasons = append(verdict.Reasons, ReasonApprovalHashDrift+": subject.digest.sha256 != predicate.content_hash")
return verdict, nil
}
if tr.SubjectDigest() != stripSha256Prefix(req.PayloadHash) {
verdict.Reasons = append(verdict.Reasons, ReasonTransformHashDrift+": subject.digest.sha256 != request.payload_hash")
return verdict, nil
}
// 4. allowed_transformations covers operation.
op, err := tr.Operation()
if err != nil {
verdict.Reasons = append(verdict.Reasons, ReasonTransformPredicateMalformed+": "+err.Error())
return verdict, nil
}
allowedOps, err := ap.AllowedTransformations()
if err != nil {
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
return verdict, nil
}
if !containsString(allowedOps, op) {
verdict.Reasons = append(verdict.Reasons, fmt.Sprintf("%s: %q not in %v", ReasonTransformationNotAllowed, op, allowedOps))
return verdict, nil
}
// 5. allowed_sinks contains sink_target.
allowedSinks, err := ap.AllowedSinks()
if err != nil {
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
return verdict, nil
}
if !containsString(allowedSinks, req.SinkTarget) {
verdict.Reasons = append(verdict.Reasons, fmt.Sprintf("%s: %q not in %v", ReasonSinkNotAllowed, req.SinkTarget, allowedSinks))
return verdict, nil
}
// Expiry check belongs with the approval predicate.
expiresAt, err := ap.ExpiresAt()
if err != nil {
verdict.Reasons = append(verdict.Reasons, ReasonApprovalPredicateMalformed+": "+err.Error())
return verdict, nil
}
if !v.now().Before(expiresAt) {
verdict.Reasons = append(verdict.Reasons, fmt.Sprintf("%s: expires_at=%s now=%s", ReasonApprovalExpired, expiresAt.Format(time.RFC3339), v.now().Format(time.RFC3339)))
return verdict, nil
}
// 6. Replay guard. Reserve atomically; a repeated decision_id lands
// here only after passing checks 1-5 on the first call, so we can
// safely treat it as replay without recomputing anything.
decision := store.Decision{
DecisionID: req.DecisionID,
ApprovalRef: req.ApprovalRef,
TransformRef: req.TransformRef,
PayloadHash: req.PayloadHash,
SinkTarget: req.SinkTarget,
ApprovalKey: apKey,
TransformKey: trKey,
CreatedAt: v.now().UTC(),
}
if err := v.Store.Reserve(ctx, decision); err != nil {
if errors.Is(err, store.ErrReplay) {
verdict.Replay = true
verdict.Reasons = append(verdict.Reasons, ReasonReplay)
return verdict, nil
}
return verdict, fmt.Errorf("reserve decision: %w", err)
}
verdict.Valid = true
verdict.Actors = []string{apKey, trKey}
return verdict, nil
}
func validateRequest(req Request) error {
if strings.TrimSpace(req.DecisionID) == "" {
return errors.New("decision_id is required")
}
if !isSha256Ref(req.ApprovalRef) {
return errors.New("approval_ref must be sha256:<64 hex>")
}
if !isSha256Ref(req.TransformRef) {
return errors.New("transform_ref must be sha256:<64 hex>")
}
if !isSha256Ref(req.PayloadHash) {
return errors.New("payload_hash must be sha256:<64 hex>")
}
if strings.TrimSpace(req.SinkTarget) == "" {
return errors.New("sink_target is required")
}
return nil
}
func isSha256Ref(s string) bool {
if !strings.HasPrefix(s, "sha256:") {
return false
}
hex := s[len("sha256:"):]
if len(hex) != 64 {
return false
}
for i := 0; i < len(hex); i++ {
c := hex[i]
if !('0' <= c && c <= '9' || 'a' <= c && c <= 'f' || 'A' <= c && c <= 'F') {
return false
}
}
return true
}
func stripSha256Prefix(s string) string {
return strings.TrimPrefix(s, "sha256:")
}
func containsString(xs []string, want string) bool {
for _, x := range xs {
if x == want {
return true
}
}
return false
}
// keyringFor returns a dsse.Keyring with a single mapping (expectedKeyID
// -> public key fetched from the registry). Restricting the keyring to
// the configured signer id makes DSSE verification refuse a signature
// that happens to be valid against some *other* key the registry knows
// — a defensive measure against key confusion in a shared keyring.
func (v *Verifier) keyringFor(ctx context.Context, expectedKeyID string) (dsse.Keyring, error) {
if expectedKeyID == "" {
return nil, errors.New("expected key_id is unset in verifier config")
}
pub, err := v.Keyring.Lookup(ctx, expectedKeyID)
if err != nil {
return nil, err
}
return dsse.Keyring{expectedKeyID: pub}, nil
}
func (v *Verifier) now() time.Time {
if v.Now != nil {
return v.Now()
}
return time.Now()
}
// Statement is the parsed in-toto v1 Statement with helpers for the
// specific predicate fields the chain check needs. Loosely typed so the
// verifier is forward-compatible with additional predicate fields.
type Statement struct {
Raw map[string]any
}
func parseStatement(payload []byte) (Statement, error) {
var m map[string]any
dec := json.NewDecoder(strings.NewReader(string(payload)))
dec.UseNumber()
if err := dec.Decode(&m); err != nil {
return Statement{}, fmt.Errorf("decode statement: %w", err)
}
if _, ok := m["predicate"].(map[string]any); !ok {
return Statement{}, errors.New("statement has no predicate object")
}
return Statement{Raw: m}, nil
}
// SubjectDigest returns the sha256 digest hex (no prefix) of the first
// subject on the statement. The approval and transform statements both
// have a single subject per Phase-1 convention.
func (s Statement) SubjectDigest() string {
subs, ok := s.Raw["subject"].([]any)
if !ok || len(subs) == 0 {
return ""
}
first, ok := subs[0].(map[string]any)
if !ok {
return ""
}
digest, ok := first["digest"].(map[string]any)
if !ok {
return ""
}
sha, _ := digest["sha256"].(string)
return sha
}
func (s Statement) predicate() map[string]any {
m, _ := s.Raw["predicate"].(map[string]any)
return m
}
// ContentHash returns predicate.content_hash as "sha256:<hex>". Required
// on approval statements; the chain verifier uses it to cross-check the
// subject digest and so detect a mismatched preview-vs-approval row.
func (s Statement) ContentHash() (string, error) {
v, ok := s.predicate()["content_hash"].(string)
if !ok || v == "" {
return "", errors.New("predicate.content_hash is missing")
}
return v, nil
}
// Operation returns predicate.operation (transform statements).
func (s Statement) Operation() (string, error) {
v, ok := s.predicate()["operation"].(string)
if !ok || v == "" {
return "", errors.New("predicate.operation is missing")
}
return v, nil
}
// AllowedTransformations returns predicate.allowed_transformations
// (approval statements). The predicate MUST carry this field; a missing
// or empty list is a malformed approval, not an implicit allow-all.
func (s Statement) AllowedTransformations() ([]string, error) {
return s.stringArray("allowed_transformations")
}
// AllowedSinks returns predicate.allowed_sinks (approval statements).
// Same discipline as AllowedTransformations.
func (s Statement) AllowedSinks() ([]string, error) {
return s.stringArray("allowed_sinks")
}
// ExpiresAt returns predicate.expires_at (approval statements) as a
// time.Time. The predicate MUST carry an RFC 3339 timestamp; the chain
// verifier refuses to approve a sink write past that instant.
func (s Statement) ExpiresAt() (time.Time, error) {
v, ok := s.predicate()["expires_at"].(string)
if !ok || v == "" {
return time.Time{}, errors.New("predicate.expires_at is missing")
}
return time.Parse(time.RFC3339, v)
}
func (s Statement) stringArray(key string) ([]string, error) {
raw, ok := s.predicate()[key].([]any)
if !ok {
return nil, fmt.Errorf("predicate.%s is missing or not an array", key)
}
out := make([]string, 0, len(raw))
for i, v := range raw {
str, ok := v.(string)
if !ok {
return nil, fmt.Errorf("predicate.%s[%d] is not a string", key, i)
}
out = append(out, str)
}
return out, nil
}

View file

@ -0,0 +1,415 @@
package chain
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"strings"
"testing"
"time"
"basicstack.de/proofedmesh/services/verification-service/internal/canonical"
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
"basicstack.de/proofedmesh/services/verification-service/internal/registry"
"basicstack.de/proofedmesh/services/verification-service/internal/store"
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
)
const (
approvalKeyID = "trust-api/approval-signer-2026-q4"
transformKeyID = "trust-api/agent-testagent-transform-2026-q4"
)
// fixedClock returns a deterministic Now function.
func fixedClock(ts time.Time) func() time.Time { return func() time.Time { return ts } }
// fakeFetcher is a map-backed tlog.Fetcher.
type fakeFetcher struct {
entries map[string]tlog.Entry
}
func (f *fakeFetcher) Fetch(_ context.Context, ref string) (tlog.Entry, error) {
e, ok := f.entries[ref]
if !ok {
return tlog.Entry{}, tlog.ErrNotFound
}
return e, nil
}
// fixture bundles everything a happy-path test needs. Negative-path
// tests derive from fixture and mutate exactly one input.
type fixture struct {
t *testing.T
now time.Time
payloadHash string
decisionID string
sinkTarget string
approvalEntry tlog.Entry
transformEntry tlog.Entry
tree *memTree
keyring *registry.Static
storev store.Store
}
func newFixture(t *testing.T) *fixture {
t.Helper()
now := time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC)
payloadHash := "sha256:" + hex.EncodeToString(sha(seedBytes("payload-xyz")))
keyring := registry.NewStatic()
// Approval signer.
apPub, apPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen approval key: %v", err)
}
keyring.Register(approvalKeyID, apPub)
// Transform signer.
trPub, trPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen transform key: %v", err)
}
keyring.Register(transformKeyID, trPub)
// Approval statement. Content_hash is self-asserted — in the real
// pipeline the orchestrator computes it from the preview's canonical
// content; here we just mint a stable value and reuse it as the
// subject digest to satisfy the content-binding check.
approvalContent := "sha256:" + hex.EncodeToString(sha(seedBytes("approval-xyz")))
apStmt := map[string]any{
"_type": "https://in-toto.io/Statement/v1",
"subject": []any{map[string]any{
"name": "approval/pv-xyz",
"digest": map[string]any{"sha256": stripSha256Prefix(approvalContent)},
}},
"predicateType": "https://basicstack.de/attestations/approval/v1",
"predicate": map[string]any{
"approval_schema": "basicstack.de/attestations/approval/v1",
"preview_id": "pv-xyz",
"session_id": "sess-xyz",
"content_hash": approvalContent,
"allowed_transformations": []any{"expense.map_to_sink"},
"allowed_sinks": []any{"sink:dummy-sink"},
"expires_at": now.Add(10 * time.Minute).Format(time.RFC3339),
"performed_by": map[string]any{
"iss": "https://pocket.local",
"sub": "user-xyz",
"session_id": "sess-xyz",
},
"performed_at": now.Format(time.RFC3339),
"verification_digits": "1234",
},
}
apBytes, err := canonical.Marshal(apStmt)
if err != nil {
t.Fatalf("canonical approval: %v", err)
}
apEnv := dsse.Sign("application/vnd.in-toto+json", apBytes, approvalKeyID, apPriv)
// Transform statement. Subject digest == payload_hash (hex only).
trStmt := map[string]any{
"_type": "https://in-toto.io/Statement/v1",
"subject": []any{map[string]any{
"name": "transform/wf-xyz/handover",
"digest": map[string]any{"sha256": stripSha256Prefix(payloadHash)},
}},
"predicateType": "https://basicstack.de/attestations/transform/v1",
"predicate": map[string]any{
"workflow_id": "wf-xyz",
"actor": "agent-testagent-transform",
"operation": "expense.map_to_sink",
"sink_target": "sink:dummy-sink",
"approval_ref": "(filled below)",
"transform_version": "v0.1.0",
"issued_at": now.Format(time.RFC3339),
},
}
trBytes, err := canonical.Marshal(trStmt)
if err != nil {
t.Fatalf("canonical transform: %v", err)
}
trEnv := dsse.Sign("application/vnd.in-toto+json", trBytes, transformKeyID, trPriv)
// Build a two-leaf Merkle tree out of the two envelopes.
tree := &memTree{}
apEntry := tree.add(apEnv)
trEntry := tree.add(trEnv)
tree.refreshProofs(&apEntry, &trEntry)
return &fixture{
t: t,
now: now,
payloadHash: payloadHash,
decisionID: "dec-xyz-1",
sinkTarget: "sink:dummy-sink",
approvalEntry: apEntry,
transformEntry: trEntry,
tree: tree,
keyring: keyring,
storev: store.NewMemory(),
}
}
func (f *fixture) verifier() *Verifier {
fetcher := &fakeFetcher{entries: map[string]tlog.Entry{
f.approvalEntry.ContentHash: f.approvalEntry,
f.transformEntry.ContentHash: f.transformEntry,
}}
return &Verifier{
TLog: fetcher,
Keyring: f.keyring,
Store: f.storev,
Now: fixedClock(f.now),
Approval: ApprovalConfig{
ExpectedKeyID: approvalKeyID,
TransformKeyID: transformKeyID,
},
}
}
func (f *fixture) request() Request {
return Request{
DecisionID: f.decisionID,
ApprovalRef: f.approvalEntry.ContentHash,
TransformRef: f.transformEntry.ContentHash,
PayloadHash: f.payloadHash,
SinkTarget: f.sinkTarget,
}
}
func TestVerify_HappyPath(t *testing.T) {
f := newFixture(t)
v := f.verifier()
verdict, err := v.Verify(context.Background(), f.request())
if err != nil {
t.Fatalf("verify: %v", err)
}
if !verdict.Valid {
t.Fatalf("verdict not valid: %+v", verdict)
}
if verdict.Replay {
t.Fatalf("verdict replay flag unexpectedly set")
}
if len(verdict.Actors) != 2 {
t.Fatalf("actors: want 2, got %v", verdict.Actors)
}
if verdict.Actors[0] != approvalKeyID || verdict.Actors[1] != transformKeyID {
t.Fatalf("actors: %v", verdict.Actors)
}
}
func TestVerify_Replay(t *testing.T) {
f := newFixture(t)
v := f.verifier()
// First call reserves the decision_id.
verdict, err := v.Verify(context.Background(), f.request())
if err != nil || !verdict.Valid {
t.Fatalf("first call: want valid=true, got valid=%v err=%v reasons=%v", verdict.Valid, err, verdict.Reasons)
}
// Second call with the SAME decision_id MUST land as replay=true,
// valid=false — the store refuses the insert so the chain does not
// re-authorise the sink write.
v2 := f.verifier()
v2.Store = f.storev // keep the same store so the first row is still there
verdict2, err := v2.Verify(context.Background(), f.request())
if err != nil {
t.Fatalf("second call err: %v", err)
}
if verdict2.Valid {
t.Fatalf("second call: want valid=false, got %+v", verdict2)
}
if !verdict2.Replay {
t.Fatalf("second call: want replay=true, got %+v", verdict2)
}
if !containsReason(verdict2.Reasons, ReasonReplay) {
t.Fatalf("replay reason missing: %v", verdict2.Reasons)
}
}
func TestVerify_BadSignature(t *testing.T) {
f := newFixture(t)
// Flip a bit in the transform signature so dsse.Verify rejects it.
env := f.transformEntry.Envelope
raw, _ := base64.RawURLEncoding.DecodeString(env.Signatures[0].Sig)
raw[0] ^= 0xff
env.Signatures[0].Sig = base64.RawURLEncoding.EncodeToString(raw)
f.transformEntry.Envelope = env
v := f.verifier()
verdict, err := v.Verify(context.Background(), f.request())
if err != nil {
t.Fatalf("verify: %v", err)
}
if verdict.Valid {
t.Fatalf("verdict unexpectedly valid")
}
if !containsReason(verdict.Reasons, ReasonTransformSignatureInvalid) {
t.Fatalf("want %s, got %v", ReasonTransformSignatureInvalid, verdict.Reasons)
}
}
func TestVerify_InclusionProofMissing(t *testing.T) {
f := newFixture(t)
// Drop the audit path on the approval entry — the proof should fail
// to recompute the tree root.
f.approvalEntry.InclusionProof.Hashes = nil
v := f.verifier()
verdict, err := v.Verify(context.Background(), f.request())
if err != nil {
t.Fatalf("verify: %v", err)
}
if verdict.Valid {
t.Fatalf("verdict unexpectedly valid")
}
if !hasPrefix(verdict.Reasons, ReasonApprovalInclusionInvalid) {
t.Fatalf("want %s*, got %v", ReasonApprovalInclusionInvalid, verdict.Reasons)
}
}
func TestVerify_HashDrift(t *testing.T) {
f := newFixture(t)
// Flip the payload_hash the caller claims; transform.subject.digest
// no longer matches.
driftHash := "sha256:" + hex.EncodeToString(sha(seedBytes("wrong-payload")))
req := f.request()
req.PayloadHash = driftHash
v := f.verifier()
verdict, err := v.Verify(context.Background(), req)
if err != nil {
t.Fatalf("verify: %v", err)
}
if verdict.Valid {
t.Fatalf("verdict unexpectedly valid")
}
if !hasPrefix(verdict.Reasons, ReasonTransformHashDrift) {
t.Fatalf("want %s*, got %v", ReasonTransformHashDrift, verdict.Reasons)
}
}
func TestVerify_SinkNotAllowed(t *testing.T) {
f := newFixture(t)
req := f.request()
req.SinkTarget = "sink:unknown"
v := f.verifier()
verdict, err := v.Verify(context.Background(), req)
if err != nil {
t.Fatalf("verify: %v", err)
}
if verdict.Valid {
t.Fatalf("verdict unexpectedly valid")
}
if !hasPrefix(verdict.Reasons, ReasonSinkNotAllowed) {
t.Fatalf("want %s*, got %v", ReasonSinkNotAllowed, verdict.Reasons)
}
}
func TestVerify_Expired(t *testing.T) {
f := newFixture(t)
// Push the clock past the approval's expires_at.
v := f.verifier()
v.Now = fixedClock(f.now.Add(30 * time.Minute))
verdict, err := v.Verify(context.Background(), f.request())
if err != nil {
t.Fatalf("verify: %v", err)
}
if verdict.Valid {
t.Fatalf("verdict unexpectedly valid")
}
if !hasPrefix(verdict.Reasons, ReasonApprovalExpired) {
t.Fatalf("want %s*, got %v", ReasonApprovalExpired, verdict.Reasons)
}
}
func TestVerify_OperationNotAllowed(t *testing.T) {
// Rebuild the fixture with a transform that uses an operation the
// approval does not list — this is a stricter negative than SinkNotAllowed
// since it exercises predicate.allowed_transformations.
f := newFixtureCustom(t, func(trPredicate map[string]any) {
trPredicate["operation"] = "expense.unauthorised"
})
v := f.verifier()
verdict, err := v.Verify(context.Background(), f.request())
if err != nil {
t.Fatalf("verify: %v", err)
}
if verdict.Valid {
t.Fatalf("verdict unexpectedly valid")
}
if !hasPrefix(verdict.Reasons, ReasonTransformationNotAllowed) {
t.Fatalf("want %s*, got %v", ReasonTransformationNotAllowed, verdict.Reasons)
}
}
// ----- helpers -----
// newFixtureCustom builds a fixture with a transform-predicate mutation
// applied before signing. Used by tests that need to exercise fields
// outside the default fixture (e.g. operation not in allowed_transformations).
func newFixtureCustom(t *testing.T, mutateTransform func(map[string]any)) *fixture {
t.Helper()
f := newFixture(t)
// Pull the transform payload out, mutate, re-sign, re-tree.
_, trPriv, _ := ed25519.GenerateKey(rand.Reader)
// Re-register with the registry so the signing matches.
trPub := trPriv.Public().(ed25519.PublicKey)
f.keyring.Register(transformKeyID, trPub)
trPredicate := map[string]any{
"workflow_id": "wf-xyz",
"actor": "agent-testagent-transform",
"operation": "expense.map_to_sink",
"sink_target": "sink:dummy-sink",
"approval_ref": "(filled below)",
"transform_version": "v0.1.0",
"issued_at": f.now.Format(time.RFC3339),
}
mutateTransform(trPredicate)
trStmt := map[string]any{
"_type": "https://in-toto.io/Statement/v1",
"subject": []any{map[string]any{
"name": "transform/wf-xyz/handover",
"digest": map[string]any{"sha256": stripSha256Prefix(f.payloadHash)},
}},
"predicateType": "https://basicstack.de/attestations/transform/v1",
"predicate": trPredicate,
}
trBytes, err := canonical.Marshal(trStmt)
if err != nil {
t.Fatalf("re-canonicalise transform: %v", err)
}
trEnv := dsse.Sign("application/vnd.in-toto+json", trBytes, transformKeyID, trPriv)
// Rebuild the tree so proofs stay consistent.
tree := &memTree{}
apEntry := tree.add(f.approvalEntry.Envelope)
trEntry := tree.add(trEnv)
tree.refreshProofs(&apEntry, &trEntry)
f.approvalEntry = apEntry
f.transformEntry = trEntry
f.tree = tree
return f
}
func containsReason(reasons []string, want string) bool {
for _, r := range reasons {
if r == want {
return true
}
}
return false
}
func hasPrefix(reasons []string, prefix string) bool {
for _, r := range reasons {
if r == prefix || len(r) > len(prefix) && strings.HasPrefix(r, prefix+":") {
return true
}
}
return false
}

View file

@ -0,0 +1,102 @@
package chain
import (
"crypto/sha256"
"encoding/hex"
"basicstack.de/proofedmesh/services/verification-service/internal/canonical"
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
)
// memTree is a test-only Merkle tree mirroring the transparency-log's
// in-memory backend. It lives in the chain tests (not under internal/tlog)
// so the proof verifier under test does not share code with the proof
// producer.
type memTree struct {
leaves [][]byte
}
func (t *memTree) add(env dsse.Envelope) tlog.Entry {
leafBytes, _ := canonical.Marshal(env)
t.leaves = append(t.leaves, leafHash(leafBytes))
idx := int64(len(t.leaves) - 1)
hash := "sha256:" + canonical.SHA256Hex(leafBytes)
return tlog.Entry{
ContentHash: hash,
Envelope: env,
LogIndex: idx,
InclusionProof: tlog.InclusionProof{},
}
}
func (t *memTree) refreshProofs(entries ...*tlog.Entry) {
root := merkleTreeHashT(t.leaves)
rootStr := "sha256:" + hex.EncodeToString(root)
for _, e := range entries {
path := auditPathT(t.leaves, int(e.LogIndex))
hashes := make([]string, 0, len(path))
for _, h := range path {
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
}
e.InclusionProof = tlog.InclusionProof{
TreeSize: int64(len(t.leaves)),
TreeRootHash: rootStr,
Hashes: hashes,
}
}
}
func leafHash(data []byte) []byte {
h := sha256.New()
h.Write([]byte{0x00})
h.Write(data)
return h.Sum(nil)
}
func nodeHashT(left, right []byte) []byte {
h := sha256.New()
h.Write([]byte{0x01})
h.Write(left)
h.Write(right)
return h.Sum(nil)
}
func merkleTreeHashT(leaves [][]byte) []byte {
switch len(leaves) {
case 0:
s := sha256.Sum256(nil)
return s[:]
case 1:
return append([]byte(nil), leaves[0]...)
}
k := largestPow2LessThanT(len(leaves))
return nodeHashT(merkleTreeHashT(leaves[:k]), merkleTreeHashT(leaves[k:]))
}
func auditPathT(leaves [][]byte, m int) [][]byte {
n := len(leaves)
if n <= 1 {
return nil
}
k := largestPow2LessThanT(n)
if m < k {
return append(auditPathT(leaves[:k], m), merkleTreeHashT(leaves[k:]))
}
return append(auditPathT(leaves[k:], m-k), merkleTreeHashT(leaves[:k]))
}
func largestPow2LessThanT(n int) int {
k := 1
for k < n {
k <<= 1
}
return k >> 1
}
func seedBytes(s string) []byte { return []byte(s) }
func sha(b []byte) []byte {
s := sha256.Sum256(b)
return s[:]
}

View file

@ -0,0 +1,111 @@
// Package dsse models the DSSE envelope shape the signing-service produces
// and verifies an envelope's Ed25519 signature using the DSSEv1 Pre-
// Authentication Encoding (PAE) from the DSSE spec.
//
// The envelope shape mirrors services/testagent-transform/internal/dsse so
// an entry fetched from the transparency-log round-trips through either
// package byte-for-byte.
package dsse
import (
"crypto/ed25519"
"encoding/base64"
"errors"
"fmt"
)
// EnvelopeSignature mirrors the DSSE spec.
type EnvelopeSignature struct {
KeyID string `json:"keyid"`
Sig string `json:"sig"`
}
// Envelope is the DSSE envelope the signing-service returns. Payload is
// base64url (no padding) of the canonical in-toto Statement bytes.
type Envelope struct {
PayloadType string `json:"payloadType"`
Payload string `json:"payload"`
Signatures []EnvelopeSignature `json:"signatures"`
}
// Keyring maps keyid (as embedded in the envelope signature) to the
// Ed25519 public key that signed it.
type Keyring map[string]ed25519.PublicKey
// ErrNoSignatures is returned when the envelope has no signatures to try.
var ErrNoSignatures = errors.New("dsse: envelope has no signatures")
// ErrUnknownKey is returned when none of the envelope's signatures has a
// keyid that is present in the verifier's keyring.
var ErrUnknownKey = errors.New("dsse: no signature keyid is in the keyring")
// ErrSignatureInvalid is returned when at least one keyring-matching
// signature was tried and none verified.
var ErrSignatureInvalid = errors.New("dsse: no signature verified against its keyring entry")
// Verify checks that at least one signature on env matches a public key in
// kr. The payload is base64url-decoded, the DSSEv1 PAE is reconstructed,
// and ed25519.Verify is applied. On success the decoded payload bytes and
// the matched keyid are returned so the caller can parse the Statement
// and record the signing actor.
func Verify(env Envelope, kr Keyring) ([]byte, string, error) {
if len(env.Signatures) == 0 {
return nil, "", ErrNoSignatures
}
payload, err := base64.RawURLEncoding.DecodeString(env.Payload)
if err != nil {
return nil, "", fmt.Errorf("dsse: decode payload: %w", err)
}
pae := pae(env.PayloadType, payload)
tried := false
for _, sig := range env.Signatures {
pub, ok := kr[sig.KeyID]
if !ok {
continue
}
tried = true
raw, err := base64.RawURLEncoding.DecodeString(sig.Sig)
if err != nil {
continue
}
if ed25519.Verify(pub, pae, raw) {
return payload, sig.KeyID, nil
}
}
if !tried {
return nil, "", ErrUnknownKey
}
return nil, "", ErrSignatureInvalid
}
// pae reconstructs the DSSEv1 Pre-Authentication Encoding:
//
// "DSSEv1 " || len(payloadType) || " " || payloadType || " " || len(payload) || " " || payload
//
// (ASCII decimal lengths, bytes.) See
// https://github.com/secure-systems-lab/dsse/blob/master/protocol.md.
func pae(payloadType string, payload []byte) []byte {
prefix := fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(payload))
out := make([]byte, 0, len(prefix)+len(payload))
out = append(out, prefix...)
out = append(out, payload...)
return out
}
// Sign is a test helper that builds an Envelope by signing stmtBytes with
// priv. Production signing lives in signing-service; the verifier only
// exposes Sign so the chain tests can craft envelopes that are supposed
// to verify (and tamper variants that are supposed to fail) without a
// dependency on the signing-service module.
func Sign(payloadType string, payloadBytes []byte, keyID string, priv ed25519.PrivateKey) Envelope {
paeBytes := pae(payloadType, payloadBytes)
sig := ed25519.Sign(priv, paeBytes)
return Envelope{
PayloadType: payloadType,
Payload: base64.RawURLEncoding.EncodeToString(payloadBytes),
Signatures: []EnvelopeSignature{
{KeyID: keyID, Sig: base64.RawURLEncoding.EncodeToString(sig)},
},
}
}

View file

@ -0,0 +1,58 @@
package dsse
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"errors"
"testing"
)
func TestVerify_HappyPath(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate: %v", err)
}
env := Sign("application/vnd.in-toto+json", []byte(`{"_type":"x"}`), "k1", priv)
payload, kid, err := Verify(env, Keyring{"k1": pub})
if err != nil {
t.Fatalf("verify: %v", err)
}
if kid != "k1" {
t.Fatalf("keyid: want k1, got %q", kid)
}
if string(payload) != `{"_type":"x"}` {
t.Fatalf("payload: want %q, got %q", `{"_type":"x"}`, payload)
}
}
func TestVerify_UnknownKey(t *testing.T) {
_, priv, _ := ed25519.GenerateKey(rand.Reader)
otherPub, _, _ := ed25519.GenerateKey(rand.Reader)
env := Sign("application/vnd.in-toto+json", []byte("x"), "k1", priv)
_, _, err := Verify(env, Keyring{"k2": otherPub})
if !errors.Is(err, ErrUnknownKey) {
t.Fatalf("want ErrUnknownKey, got %v", err)
}
}
func TestVerify_InvalidSignature(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
env := Sign("application/vnd.in-toto+json", []byte("x"), "k1", priv)
// Flip one bit of the signature.
raw, _ := base64.RawURLEncoding.DecodeString(env.Signatures[0].Sig)
raw[0] ^= 0xff
env.Signatures[0].Sig = base64.RawURLEncoding.EncodeToString(raw)
_, _, err := Verify(env, Keyring{"k1": pub})
if !errors.Is(err, ErrSignatureInvalid) {
t.Fatalf("want ErrSignatureInvalid, got %v", err)
}
}
func TestVerify_EmptySignatures(t *testing.T) {
env := Envelope{PayloadType: "application/vnd.in-toto+json", Payload: ""}
_, _, err := Verify(env, Keyring{})
if !errors.Is(err, ErrNoSignatures) {
t.Fatalf("want ErrNoSignatures, got %v", err)
}
}

View file

@ -0,0 +1,153 @@
// Package httpapi exposes the verification-service's single endpoint:
// POST /api/v1/verify/chain, per DEV-612.
//
// Request:
//
// {
// "decision_id": "<caller-chosen opaque id>",
// "approval_ref": "sha256:<64-hex>",
// "transform_ref": "sha256:<64-hex>",
// "payload_hash": "sha256:<64-hex>",
// "sink_target": "sink:dummy-sink"
// }
//
// Response:
//
// {
// "valid": true|false,
// "replay": true|false,
// "decision_id": "<echoed>",
// "actors": ["<approval key_id>", "<transform key_id>"],
// "approval_ref": "sha256:…",
// "transform_ref": "sha256:…",
// "reasons": ["<reason code>", …]
// }
//
// Status codes:
// - 200 OK, valid=true — chain passed, decision_id reserved.
// - 200 OK, valid=false — chain failed; `reasons` lists the
// specific guards that fired. The sink maps `replay: true` to 409
// and the rest to 403; verification-service itself never signals
// "forbidden" via a status code because a 403 would occlude the
// structured reasons the sink needs.
// - 400 Bad Request — malformed body.
// - 502 Bad Gateway — TLog unavailable (transport failure).
// - 500 Internal Server Error — unexpected internal error.
package httpapi
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"basicstack.de/proofedmesh/services/verification-service/internal/chain"
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
)
// Server wires the chain verifier to the HTTP transport.
type Server struct {
Verifier *chain.Verifier
Logger *slog.Logger
MaxBody int64 // 0 → default 1 MiB
}
// Mount registers the endpoints on mux.
func (s *Server) Mount(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/verify/chain", s.handleVerifyChain)
mux.HandleFunc("GET /healthz", s.handleHealth)
}
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"}`))
}
type verifyRequest struct {
DecisionID string `json:"decision_id"`
ApprovalRef string `json:"approval_ref"`
TransformRef string `json:"transform_ref"`
PayloadHash string `json:"payload_hash"`
// SinkTarget is the canonical field. Historical siblings (dummy-sink's
// verify.Client) send `sink_target`; the DEV-612 scope description
// used `target_sink`; we accept either on the wire for compatibility
// and prefer `sink_target` when both are present.
SinkTarget string `json:"sink_target,omitempty"`
TargetSink string `json:"target_sink,omitempty"`
}
func (s *Server) handleVerifyChain(w http.ResponseWriter, r *http.Request) {
logger := s.logger()
max := s.MaxBody
if max <= 0 {
max = 1 << 20
}
ct := r.Header.Get("Content-Type")
if ct != "" && !strings.HasPrefix(ct, "application/json") {
writeErr(w, http.StatusBadRequest, "content-type must be application/json")
return
}
raw, err := io.ReadAll(io.LimitReader(r.Body, max+1))
if err != nil {
writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
if int64(len(raw)) > max {
writeErr(w, http.StatusRequestEntityTooLarge, "body too large")
return
}
var req verifyRequest
if err := json.Unmarshal(raw, &req); err != nil {
writeErr(w, http.StatusBadRequest, "decode: "+err.Error())
return
}
sink := req.SinkTarget
if sink == "" {
sink = req.TargetSink
}
verdict, err := s.Verifier.Verify(r.Context(), chain.Request{
DecisionID: req.DecisionID,
ApprovalRef: req.ApprovalRef,
TransformRef: req.TransformRef,
PayloadHash: req.PayloadHash,
SinkTarget: sink,
})
if err != nil {
if errors.Is(err, tlog.ErrTLogUnavailable) {
logger.Warn("tlog unavailable", "err", err)
writeErr(w, http.StatusBadGateway, "transparency-log unavailable")
return
}
logger.Error("chain verify failed", "err", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, verdict)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errBody struct {
Error string `json:"error"`
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errBody{Error: msg})
}
func (s *Server) logger() *slog.Logger {
if s.Logger != nil {
return s.Logger
}
return slog.Default()
}

View file

@ -0,0 +1,141 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"basicstack.de/proofedmesh/services/verification-service/internal/chain"
"basicstack.de/proofedmesh/services/verification-service/internal/tlog"
)
type stubVerifier struct {
ret chain.Verdict
err error
}
func (s *stubVerifier) Verify(_ context.Context, _ chain.Request) (chain.Verdict, error) {
return s.ret, s.err
}
// Rebuild a Server with the stub by replacing the *chain.Verifier's
// dependencies. The handler only needs to call Verifier.Verify, so we
// shim through a tiny indirection: build a Verifier whose TLog.Fetcher
// is a stub that returns ErrTLogUnavailable to force the 502 path, or
// wire the stubVerifier via a type-asserting handler clone.
//
// Simpler: pass a verifier whose dependencies make the Verify call land
// on the branch we want. Since Verifier is a concrete struct, we expose
// a tiny shim type here to avoid pulling the whole chain package into
// the test as a hard dependency.
type fakeFetcher struct{ err error }
func (f fakeFetcher) Fetch(_ context.Context, _ string) (tlog.Entry, error) {
return tlog.Entry{}, f.err
}
func newServerWithFetcher(f fakeFetcher) *Server {
return &Server{
Verifier: &chain.Verifier{
TLog: f,
// Keyring + Store intentionally nil: the request hits
// Fetch first and bails out before touching them.
Approval: chain.ApprovalConfig{
ExpectedKeyID: "k1",
TransformKeyID: "k2",
},
},
}
}
func TestHandleVerifyChain_BadRequestOnMalformedJSON(t *testing.T) {
s := &Server{Verifier: &chain.Verifier{}}
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
defer ts.Close()
resp, err := http.Post(ts.URL+"/api/v1/verify/chain", "application/json", strings.NewReader("not-json"))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status: want 400, got %d", resp.StatusCode)
}
}
func TestHandleVerifyChain_SinkTargetAliases(t *testing.T) {
// Even when the request names `target_sink` instead of `sink_target`,
// the request must reach the verifier. We prove it by observing the
// verifier produce a verdict reasons list that references the sink
// field — the simplest signal is to force a TLog-not-found verdict
// (not an error) by setting ErrNotFound.
s := newServerWithFetcher(fakeFetcher{err: tlog.ErrNotFound})
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
defer ts.Close()
for _, body := range []string{
`{"decision_id":"d","approval_ref":"sha256:` + strings.Repeat("a", 64) + `","transform_ref":"sha256:` + strings.Repeat("b", 64) + `","payload_hash":"sha256:` + strings.Repeat("c", 64) + `","sink_target":"sink:dummy-sink"}`,
`{"decision_id":"d","approval_ref":"sha256:` + strings.Repeat("a", 64) + `","transform_ref":"sha256:` + strings.Repeat("b", 64) + `","payload_hash":"sha256:` + strings.Repeat("c", 64) + `","target_sink":"sink:dummy-sink"}`,
} {
resp, err := http.Post(ts.URL+"/api/v1/verify/chain", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("post: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status: want 200, got %d", resp.StatusCode)
}
var out chain.Verdict
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
resp.Body.Close()
if out.Valid {
t.Fatalf("verdict unexpectedly valid: %+v", out)
}
if len(out.Reasons) == 0 || !strings.HasPrefix(out.Reasons[0], chain.ReasonApprovalFetch) {
t.Fatalf("reasons: %v", out.Reasons)
}
}
}
func TestHandleVerifyChain_BadGatewayOnTLogUnavailable(t *testing.T) {
s := newServerWithFetcher(fakeFetcher{err: tlog.ErrTLogUnavailable})
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
defer ts.Close()
body := `{"decision_id":"d","approval_ref":"sha256:` + strings.Repeat("a", 64) + `","transform_ref":"sha256:` + strings.Repeat("b", 64) + `","payload_hash":"sha256:` + strings.Repeat("c", 64) + `","sink_target":"sink:dummy-sink"}`
resp, err := http.Post(ts.URL+"/api/v1/verify/chain", "application/json", bytes.NewReader([]byte(body)))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("status: want 502, got %d", resp.StatusCode)
}
}
func TestHandleHealth(t *testing.T) {
s := &Server{}
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
defer ts.Close()
resp, err := http.Get(ts.URL + "/healthz")
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status: want 200, got %d", resp.StatusCode)
}
}

View file

@ -0,0 +1,212 @@
// Package registry resolves DSSE keyids to Ed25519 public keys so the
// chain verifier can check approval and transform signatures without
// trusting the storage layer that produced them.
//
// Phase 1 ships a Static keyring loaded from the environment / config.
// A later subtask swaps in a real Agent-Registry HTTP client that pulls
// the active public key for an agent + period (`trust-api/agent-<slug>
// -<yyyy>-q<n>`). The Keyring interface is the swap point.
package registry
import (
"context"
"crypto/ed25519"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"strings"
"sync"
)
// Keyring is the lookup surface the chain verifier depends on. Lookup
// is per-request so a future HTTP-backed Registry can refresh keys
// without a restart.
type Keyring interface {
Lookup(ctx context.Context, keyID string) (ed25519.PublicKey, error)
}
// ErrUnknownKey means the keyring has no public key registered for the
// requested keyid. The HTTP layer maps this to a chain-rejection with
// reason `signature_unknown_key`.
var ErrUnknownKey = errors.New("registry: unknown keyid")
// Static is an in-memory Keyring backed by a mutex-protected map. Suitable
// for Phase 1 where the two signer keys (approval, transform) are known
// at startup. The map is cloned on every Register call so a running
// lookup never races a mutation.
type Static struct {
mu sync.RWMutex
keys map[string]ed25519.PublicKey
}
// NewStatic returns an empty Static keyring.
func NewStatic() *Static {
return &Static{keys: map[string]ed25519.PublicKey{}}
}
// Register adds (or overwrites) keyID -> pub.
func (s *Static) Register(keyID string, pub ed25519.PublicKey) {
s.mu.Lock()
defer s.mu.Unlock()
s.keys[keyID] = pub
}
// Lookup returns the registered key or ErrUnknownKey.
func (s *Static) Lookup(_ context.Context, keyID string) (ed25519.PublicKey, error) {
s.mu.RLock()
defer s.mu.RUnlock()
pub, ok := s.keys[keyID]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownKey, keyID)
}
return pub, nil
}
// Snapshot returns a copy of the keyring as a `dsse.Keyring`-shaped map so
// callers can hand it to a verifier that wants the full map at once.
// This avoids repeated map lookups during a single chain verification.
func (s *Static) Snapshot() map[string]ed25519.PublicKey {
s.mu.RLock()
defer s.mu.RUnlock()
out := make(map[string]ed25519.PublicKey, len(s.keys))
for k, v := range s.keys {
out[k] = v
}
return out
}
// LoadFromSpec populates s from a `;`-separated spec of entries of the
// form "<keyid>=<encoding>:<data>". Supported encodings:
//
// pem — PKIX-wrapped Ed25519 public key (PEM-encoded)
// hex — raw 32-byte Ed25519 public key, hex
// b64 — raw 32-byte Ed25519 public key, standard base64
// b64u — raw 32-byte Ed25519 public key, base64url (no padding)
//
// Entries may also be separated by a comma, provided the entry's data
// does not itself contain a comma (safe for pem/hex/b64/b64u). Newlines
// inside a PEM payload are preserved. Blank entries are ignored. Keys
// with duplicate ids overwrite earlier entries.
func (s *Static) LoadFromSpec(spec string) error {
for _, raw := range splitEntries(spec) {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
eq := strings.IndexByte(entry, '=')
if eq <= 0 || eq == len(entry)-1 {
return fmt.Errorf("registry: malformed key spec entry %q (want keyid=<encoding>:<data>)", entry)
}
keyID := strings.TrimSpace(entry[:eq])
enc, data, ok := strings.Cut(entry[eq+1:], ":")
if !ok {
return fmt.Errorf("registry: malformed key spec entry %q (missing <encoding>: prefix)", entry)
}
pub, err := decodePublicKey(enc, data)
if err != nil {
return fmt.Errorf("registry: decode %q: %w", keyID, err)
}
s.Register(keyID, pub)
}
return nil
}
// splitEntries splits a spec on `;` or `,`. Newlines are NOT separators
// so a PEM block (which contains newlines internally) survives
// round-tripping through this function.
func splitEntries(s string) []string {
return strings.FieldsFunc(s, func(r rune) bool { return r == ';' || r == ',' })
}
func decodePublicKey(encoding, data string) (ed25519.PublicKey, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "pem":
return decodePEM(data)
case "hex":
return decodeFixedRaw(data, decodeHex)
case "b64":
return decodeFixedRaw(data, decodeB64)
case "b64u":
return decodeFixedRaw(data, decodeB64URL)
default:
return nil, fmt.Errorf("unsupported encoding %q (want pem|hex|b64|b64u)", encoding)
}
}
type rawDecoder func(string) ([]byte, error)
func decodeFixedRaw(data string, dec rawDecoder) (ed25519.PublicKey, error) {
raw, err := dec(data)
if err != nil {
return nil, err
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("expected %d-byte Ed25519 public key, got %d", ed25519.PublicKeySize, len(raw))
}
return ed25519.PublicKey(raw), nil
}
func decodePEM(data string) (ed25519.PublicKey, error) {
// PEM headers may have been shell-escaped as "\n"; accept either.
normalised := strings.ReplaceAll(data, `\n`, "\n")
block, _ := pem.Decode([]byte(normalised))
if block == nil {
return nil, errors.New("not a PEM block")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse PKIX: %w", err)
}
ed, ok := pub.(ed25519.PublicKey)
if !ok {
return nil, fmt.Errorf("not an Ed25519 public key (got %T)", pub)
}
return ed, nil
}
func decodeHex(data string) ([]byte, error) {
return hexDecode(strings.TrimSpace(data))
}
func decodeB64(data string) ([]byte, error) {
return base64.StdEncoding.DecodeString(strings.TrimSpace(data))
}
func decodeB64URL(data string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(strings.TrimSpace(data))
}
// hexDecode isolated so decodeFixedRaw can share the shape of its callers.
func hexDecode(s string) ([]byte, error) {
n := len(s)
if n%2 != 0 {
return nil, errors.New("odd-length hex string")
}
out := make([]byte, n/2)
for i := 0; i < n; i += 2 {
hi, err := hexNibble(s[i])
if err != nil {
return nil, err
}
lo, err := hexNibble(s[i+1])
if err != nil {
return nil, err
}
out[i/2] = hi<<4 | lo
}
return out, nil
}
func hexNibble(c byte) (byte, error) {
switch {
case '0' <= c && c <= '9':
return c - '0', nil
case 'a' <= c && c <= 'f':
return c - 'a' + 10, nil
case 'A' <= c && c <= 'F':
return c - 'A' + 10, nil
}
return 0, fmt.Errorf("invalid hex char %q", c)
}

View file

@ -0,0 +1,94 @@
package registry
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"errors"
"testing"
)
func TestStatic_RegisterAndLookup(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen: %v", err)
}
s := NewStatic()
s.Register("k1", pub)
got, err := s.Lookup(context.Background(), "k1")
if err != nil {
t.Fatalf("lookup: %v", err)
}
if !pub.Equal(got) {
t.Fatalf("key mismatch")
}
if _, err := s.Lookup(context.Background(), "unknown"); !errors.Is(err, ErrUnknownKey) {
t.Fatalf("want ErrUnknownKey, got %v", err)
}
}
func TestLoadFromSpec_Hex(t *testing.T) {
pub, _, _ := ed25519.GenerateKey(rand.Reader)
spec := "trust-api/approval-signer-2026-q4=hex:" + hex.EncodeToString(pub)
s := NewStatic()
if err := s.LoadFromSpec(spec); err != nil {
t.Fatalf("load hex: %v", err)
}
got, err := s.Lookup(context.Background(), "trust-api/approval-signer-2026-q4")
if err != nil || !got.Equal(pub) {
t.Fatalf("lookup after hex load: %v / equal=%v", err, got.Equal(pub))
}
}
func TestLoadFromSpec_B64AndB64URL(t *testing.T) {
pub, _, _ := ed25519.GenerateKey(rand.Reader)
spec := "k1=b64:" + base64.StdEncoding.EncodeToString(pub) +
",k2=b64u:" + base64.RawURLEncoding.EncodeToString(pub)
s := NewStatic()
if err := s.LoadFromSpec(spec); err != nil {
t.Fatalf("load b64: %v", err)
}
for _, id := range []string{"k1", "k2"} {
got, err := s.Lookup(context.Background(), id)
if err != nil || !got.Equal(pub) {
t.Fatalf("lookup %s: %v", id, err)
}
}
}
func TestLoadFromSpec_PEM(t *testing.T) {
pub, _, _ := ed25519.GenerateKey(rand.Reader)
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
t.Fatalf("marshal pkix: %v", err)
}
pemBlock := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})
spec := "pem-key=pem:" + string(pemBlock)
s := NewStatic()
if err := s.LoadFromSpec(spec); err != nil {
t.Fatalf("load pem: %v", err)
}
got, err := s.Lookup(context.Background(), "pem-key")
if err != nil || !got.Equal(pub) {
t.Fatalf("pem lookup: %v", err)
}
}
func TestLoadFromSpec_RejectsBadFormat(t *testing.T) {
s := NewStatic()
for _, bad := range []string{
"missing-equals",
"no-prefix=rawbytes",
"k1=hex:xxyyzz",
"k1=hex:" + hex.EncodeToString(make([]byte, 10)), // wrong length
"k1=rsa:deadbeef", // unsupported
} {
if err := s.LoadFromSpec(bad); err == nil {
t.Fatalf("expected error for %q", bad)
}
}
}

View file

@ -0,0 +1,37 @@
package store
import (
"context"
"sync"
)
// Memory is the Phase-1 in-process Store. Thread-safe via a single mutex.
type Memory struct {
mu sync.Mutex
decisions map[string]Decision
}
// NewMemory returns an empty Memory store.
func NewMemory() *Memory {
return &Memory{decisions: map[string]Decision{}}
}
// Reserve inserts d.DecisionID → d. On duplicate it returns ErrReplay
// and leaves the stored row untouched.
func (m *Memory) Reserve(_ context.Context, d Decision) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.decisions[d.DecisionID]; exists {
return ErrReplay
}
m.decisions[d.DecisionID] = d
return nil
}
// Get returns the stored decision, if any.
func (m *Memory) Get(_ context.Context, decisionID string) (Decision, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
d, ok := m.decisions[decisionID]
return d, ok, nil
}

View file

@ -0,0 +1,31 @@
package store
import (
"context"
"errors"
"testing"
"time"
)
func TestMemory_Reserve_Idempotent(t *testing.T) {
m := NewMemory()
d := Decision{
DecisionID: "dec-1",
ApprovalRef: "sha256:aaaa",
TransformRef: "sha256:bbbb",
PayloadHash: "sha256:cccc",
SinkTarget: "sink:dummy-sink",
CreatedAt: time.Now(),
}
if err := m.Reserve(context.Background(), d); err != nil {
t.Fatalf("first reserve: %v", err)
}
err := m.Reserve(context.Background(), d)
if !errors.Is(err, ErrReplay) {
t.Fatalf("second reserve: want ErrReplay, got %v", err)
}
got, ok, _ := m.Get(context.Background(), "dec-1")
if !ok || got.ApprovalRef != d.ApprovalRef {
t.Fatalf("get: ok=%v, got=%+v", ok, got)
}
}

View file

@ -0,0 +1,26 @@
package store
import (
"context"
"errors"
)
// Postgres is the production Store. Skeleton mirroring the pattern used
// by services/approval-service/internal/store/postgres.go: every method
// returns errPGUnimplemented until the pgxpool wiring lands. The schema
// is authoritative in db/trust/migrations/0005_verification_decisions.*.sql.
type Postgres struct {
// pool *pgxpool.Pool // kept out of go.mod in the skeleton
}
var errPGUnimplemented = errors.New("postgres store is a skeleton; use NewMemory for Phase 1")
// NewPostgres returns a zero-value Postgres. Real wiring is Phase 2.
func NewPostgres( /* conn string, logger */ ) (*Postgres, error) {
return &Postgres{}, nil
}
func (*Postgres) Reserve(_ context.Context, _ Decision) error { return errPGUnimplemented }
func (*Postgres) Get(_ context.Context, _ string) (Decision, bool, error) {
return Decision{}, false, errPGUnimplemented
}

View file

@ -0,0 +1,44 @@
// Package store persists the verification-service's replay-guard
// `verification_decisions` row. The verifier reserves a decision_id
// exactly once per successful chain check; the second attempt sees
// ErrReplay and short-circuits to a 409-shaped verdict in the HTTP
// layer.
//
// Phase 1 ships an in-memory impl; the Postgres schema is in
// db/trust/migrations/0005_verification_decisions.*.sql and wired via a
// skeleton Postgres type so the HTTP + chain layers stay unchanged when
// the real pgxpool wiring lands.
package store
import (
"context"
"errors"
"time"
)
// ErrReplay is returned by Reserve when decision_id has already been
// stored by a prior successful chain check.
var ErrReplay = errors.New("store: decision_id already used")
// Decision is the single row inserted on a successful chain verdict.
// Actors are the (approval_signer_key, transform_signer_key) pair; they
// are useful for post-hoc audit (who, together, authorised this sink
// write) and are returned to the client in the verify response.
type Decision struct {
DecisionID string
ApprovalRef string
TransformRef string
PayloadHash string
SinkTarget string
ApprovalKey string
TransformKey string
CreatedAt time.Time
}
// Store is the persistence contract. Reserve MUST be atomic: a second
// Reserve of the same decision_id, even if interleaved with the first,
// MUST return ErrReplay and NOT overwrite the stored row.
type Store interface {
Reserve(ctx context.Context, d Decision) error
Get(ctx context.Context, decisionID string) (Decision, bool, error)
}

View file

@ -0,0 +1,128 @@
// Package tlog is the verification-service's read-side client for the
// transparency-log. It performs GET /api/v1/log/entries/{content_hash}
// and returns the stored DSSE envelope together with the inclusion
// proof that lets the verifier recompute the Merkle tree root
// independently.
//
// The read endpoint is the companion of
// services/transparency-log/pkg/httpapi POST /api/v1/log/entries. The
// production TLog is Trillian-backed; dev/CI uses an in-memory Merkle
// tree. Both expose the inclusion proof fields defined by
// transparency-log/pkg/backend.Receipt (tree_size, tree_root_hash,
// signed_tree_head_ref, hashes[]). See docs/adr/0005-signing-service
// -and-transparency-log-api.md.
package tlog
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
)
// InclusionProof mirrors the server-side ADR-0005 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"`
}
// Entry is the shape returned by GET /api/v1/log/entries/{content_hash}.
// Note the embedded InclusionProof: the verifier recomputes the Merkle
// tree root from (leaf, log_index, tree_size, hashes) and matches it
// against TreeRootHash. The TLog's own storage is never trusted on its
// word; the proof MUST close independently.
type Entry struct {
ContentHash string `json:"content_hash"`
Envelope dsse.Envelope `json:"envelope"`
LogIndex int64 `json:"log_index"`
LoggedAt time.Time `json:"logged_at"`
InclusionProof InclusionProof `json:"inclusion_proof"`
}
// ErrNotFound is returned on a 404 from the TLog read endpoint.
var ErrNotFound = errors.New("tlog: content_hash not in transparency-log")
// ErrTLogUnavailable classifies connection errors and 5xx responses.
var ErrTLogUnavailable = errors.New("tlog: transparency-log unavailable")
// Fetcher pulls a TLog entry by content_hash.
type Fetcher interface {
Fetch(ctx context.Context, contentHash string) (Entry, error)
}
// Client is the HTTP implementation of Fetcher.
type Client struct {
BaseURL string
HTTPClient *http.Client
// AuthToken is sent as "Authorization: Bearer <token>" when non-empty.
// Phase-1 TLog read endpoints are expected to accept the same
// Pocket-ID identity the signing-service uses.
AuthToken string
}
// Fetch retrieves the DSSE envelope, inclusion proof, and log metadata
// for contentHash. contentHash must be "sha256:<64 hex>".
func (c Client) Fetch(ctx context.Context, contentHash string) (Entry, error) {
if c.BaseURL == "" {
return Entry{}, errors.New("tlog.Client: BaseURL is empty")
}
ref := strings.TrimSpace(contentHash)
if ref == "" {
return Entry{}, errors.New("tlog.Client: content_hash is empty")
}
hc := c.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 10 * time.Second}
}
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/log/entries/", url.PathEscape(ref))
if err != nil {
return Entry{}, fmt.Errorf("tlog: build url: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return Entry{}, err
}
req.Header.Set("Accept", "application/json")
if c.AuthToken != "" {
req.Header.Set("Authorization", "Bearer "+c.AuthToken)
}
resp, err := hc.Do(req)
if err != nil {
return Entry{}, fmt.Errorf("%w: %v", ErrTLogUnavailable, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
switch {
case resp.StatusCode == http.StatusOK:
case resp.StatusCode == http.StatusNotFound:
return Entry{}, ErrNotFound
case resp.StatusCode >= 500:
return Entry{}, fmt.Errorf("%w: status %d: %s", ErrTLogUnavailable, resp.StatusCode, truncate(raw, 256))
default:
return Entry{}, fmt.Errorf("tlog: status %d: %s", resp.StatusCode, truncate(raw, 256))
}
var out Entry
if err := json.Unmarshal(raw, &out); err != nil {
return Entry{}, fmt.Errorf("tlog: decode response: %w", err)
}
if out.ContentHash != ref {
return Entry{}, fmt.Errorf("tlog returned content_hash %q != requested %q", out.ContentHash, ref)
}
return out, nil
}
func truncate(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}

View file

@ -0,0 +1,166 @@
package tlog
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"basicstack.de/proofedmesh/services/verification-service/internal/canonical"
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
)
// RFC 6962 §2.1 Merkle tree hashing. Must stay byte-identical with
// services/transparency-log/pkg/backend/merkle.go so a proof produced
// there verifies here.
//
// leaf hash = SHA-256(0x00 || data)
// node hash = SHA-256(0x01 || left || right)
// ErrInclusionProofInvalid is returned when the inclusion proof fails to
// reproduce the signed tree root.
var ErrInclusionProofInvalid = errors.New("tlog: inclusion proof does not match tree_root_hash")
// ErrInclusionProofMalformed is returned when the proof fields are
// internally inconsistent (negative sizes, bad hex, log_index out of
// range for the stated tree_size, etc.) and we cannot even attempt to
// recompute the root.
var ErrInclusionProofMalformed = errors.New("tlog: inclusion proof is malformed")
// VerifyEntry checks that:
//
// 1. The DSSE envelope's canonical hash equals the Entry.ContentHash
// (so no bit of the envelope moved between what the signer stored
// and what the TLog hands back).
// 2. The inclusion proof closes against the signed tree root using the
// RFC 6962 leaf-hashing scheme shared with the transparency-log
// backend.
func VerifyEntry(e Entry) error {
// (1) Envelope -> content hash equality.
gotHash, err := envelopeContentHash(e.Envelope)
if err != nil {
return fmt.Errorf("%w: hash envelope: %v", ErrInclusionProofMalformed, err)
}
if gotHash != e.ContentHash {
return fmt.Errorf("%w: envelope hash %q != entry.content_hash %q",
ErrInclusionProofMalformed, gotHash, e.ContentHash)
}
// (2) Reconstruct the leaf hash from the envelope, then walk the
// audit path up to the stated tree root.
leafBytes, err := canonical.Marshal(e.Envelope)
if err != nil {
return fmt.Errorf("%w: canonicalise envelope: %v", ErrInclusionProofMalformed, err)
}
leaf := leafHash(leafBytes)
root, err := decodeHashField(e.InclusionProof.TreeRootHash)
if err != nil {
return fmt.Errorf("%w: tree_root_hash: %v", ErrInclusionProofMalformed, err)
}
computed, err := rootFromAuditPath(leaf, e.LogIndex, e.InclusionProof.TreeSize, e.InclusionProof.Hashes)
if err != nil {
return err
}
if !equalBytes(computed, root) {
return ErrInclusionProofInvalid
}
return nil
}
// envelopeContentHash recomputes the sha256:<hex> hash of env's canonical
// JSON. Mirrors transparency-log/pkg/backend.EnvelopeContentHash.
func envelopeContentHash(env dsse.Envelope) (string, error) {
b, err := canonical.Marshal(env)
if err != nil {
return "", err
}
return "sha256:" + canonical.SHA256Hex(b), nil
}
// rootFromAuditPath implements the RFC 6962 §2.1.1 inclusion proof
// verifier. index is the zero-based leaf position; size is the total
// number of leaves at the time the proof was issued; hashes is the
// audit path from the leaf towards the root. The computed root is
// returned so the caller can compare it against the signed root.
func rootFromAuditPath(leaf []byte, index, size int64, hashes []string) ([]byte, error) {
if size <= 0 {
return nil, fmt.Errorf("%w: tree_size %d must be positive", ErrInclusionProofMalformed, size)
}
if index < 0 || index >= size {
return nil, fmt.Errorf("%w: log_index %d out of range for tree_size %d",
ErrInclusionProofMalformed, index, size)
}
raw := make([][]byte, 0, len(hashes))
for i, h := range hashes {
b, err := decodeHashField(h)
if err != nil {
return nil, fmt.Errorf("%w: hashes[%d]: %v", ErrInclusionProofMalformed, i, err)
}
raw = append(raw, b)
}
// RFC 6962 iterative verifier.
fn, sn := index, size-1
h := append([]byte(nil), leaf...)
for _, p := range raw {
if sn == 0 {
// We ran out of layers but still have proof bytes — malformed.
return nil, fmt.Errorf("%w: audit path longer than tree depth", ErrInclusionProofMalformed)
}
if fn%2 == 1 || fn == sn {
h = nodeHash(p, h)
for fn%2 == 0 {
fn >>= 1
sn >>= 1
}
} else {
h = nodeHash(h, p)
}
fn >>= 1
sn >>= 1
}
if sn != 0 {
return nil, fmt.Errorf("%w: audit path shorter than tree depth", ErrInclusionProofMalformed)
}
return h, nil
}
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)
}
// decodeHashField decodes a hash that may be prefixed with "sha256:" or
// bare hex. The transparency-log backend ships prefixed form; be lenient.
func decodeHashField(s string) ([]byte, error) {
s = strings.TrimPrefix(s, "sha256:")
if len(s) != 64 {
return nil, fmt.Errorf("expected 64 hex chars, got %d", len(s))
}
return hex.DecodeString(s)
}
func equalBytes(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View file

@ -0,0 +1,168 @@
package tlog
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"errors"
"testing"
"basicstack.de/proofedmesh/services/verification-service/internal/canonical"
"basicstack.de/proofedmesh/services/verification-service/internal/dsse"
)
// Local re-implementation of the transparency-log's in-memory Merkle
// backend. Kept here (rather than importing it) so this module stays
// free of cross-module dependencies — the whole point of the proof
// verifier is that it closes against bytes, not against the server
// that produced them.
type tree struct {
leaves [][]byte
}
func (t *tree) addEnvelope(env dsse.Envelope) (Entry, string) {
leafBytes, _ := canonical.Marshal(env)
t.leaves = append(t.leaves, leafHash(leafBytes))
idx := int64(len(t.leaves) - 1)
root := merkleTreeHash(t.leaves)
path := auditPath(t.leaves, int(idx))
hashes := make([]string, 0, len(path))
for _, h := range path {
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
}
hash := "sha256:" + canonical.SHA256Hex(leafBytes)
return Entry{
ContentHash: hash,
Envelope: env,
LogIndex: idx,
InclusionProof: InclusionProof{
TreeSize: int64(len(t.leaves)),
TreeRootHash: "sha256:" + hex.EncodeToString(root),
Hashes: hashes,
},
}, hash
}
func merkleTreeHash(leaves [][]byte) []byte {
switch len(leaves) {
case 0:
h := leafHash(nil)
return h
case 1:
return append([]byte(nil), leaves[0]...)
}
k := largestPow2LessThan(len(leaves))
return nodeHash(merkleTreeHash(leaves[:k]), merkleTreeHash(leaves[k:]))
}
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]))
}
func largestPow2LessThan(n int) int {
k := 1
for k < n {
k <<= 1
}
return k >> 1
}
func newEnvelope(t *testing.T, s string) dsse.Envelope {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen key: %v", err)
}
return dsse.Sign("application/vnd.in-toto+json", []byte(s), "k1", priv)
}
func TestVerifyEntry_SingleLeafTree(t *testing.T) {
var tr tree
entry, _ := tr.addEnvelope(newEnvelope(t, `{"i":0}`))
if err := VerifyEntry(entry); err != nil {
t.Fatalf("single-leaf: %v", err)
}
}
func TestVerifyEntry_MultiLeafTree(t *testing.T) {
var tr tree
// Append five envelopes; verify every leaf's inclusion proof.
entries := make([]Entry, 0, 5)
for i := 0; i < 5; i++ {
e, _ := tr.addEnvelope(newEnvelope(t, "payload-"+string(rune('a'+i))))
entries = append(entries, e)
}
// Re-fetch the up-to-date proofs for every leaf against the final tree.
for i := range entries {
leaf := tr.leaves[i]
path := auditPath(tr.leaves, i)
hashes := make([]string, 0, len(path))
for _, h := range path {
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
}
entries[i].InclusionProof = InclusionProof{
TreeSize: int64(len(tr.leaves)),
TreeRootHash: "sha256:" + hex.EncodeToString(merkleTreeHash(tr.leaves)),
Hashes: hashes,
}
_ = leaf
if err := VerifyEntry(entries[i]); err != nil {
t.Fatalf("leaf %d: %v", i, err)
}
}
}
func TestVerifyEntry_TamperedRoot(t *testing.T) {
var tr tree
entry, _ := tr.addEnvelope(newEnvelope(t, `{"i":0}`))
tr.addEnvelope(newEnvelope(t, `{"i":1}`))
// Re-fetch leaf 0's proof against the 2-leaf tree.
path := auditPath(tr.leaves, 0)
hashes := make([]string, 0, len(path))
for _, h := range path {
hashes = append(hashes, "sha256:"+hex.EncodeToString(h))
}
entry.InclusionProof = InclusionProof{
TreeSize: 2,
TreeRootHash: "sha256:" + hex.EncodeToString(merkleTreeHash(tr.leaves)),
Hashes: hashes,
}
// Flip a bit in the stated root — proof must fail.
rootBytes, _ := hex.DecodeString(entry.InclusionProof.TreeRootHash[len("sha256:"):])
rootBytes[0] ^= 0xff
entry.InclusionProof.TreeRootHash = "sha256:" + hex.EncodeToString(rootBytes)
err := VerifyEntry(entry)
if !errors.Is(err, ErrInclusionProofInvalid) {
t.Fatalf("want ErrInclusionProofInvalid, got %v", err)
}
}
func TestVerifyEntry_ContentHashMismatch(t *testing.T) {
var tr tree
entry, _ := tr.addEnvelope(newEnvelope(t, `{"i":0}`))
// Flip a bit in the envelope payload after the proof was captured.
entry.Envelope.Payload = "aaaa"
err := VerifyEntry(entry)
if !errors.Is(err, ErrInclusionProofMalformed) {
t.Fatalf("want ErrInclusionProofMalformed, got %v", err)
}
}
func TestVerifyEntry_IndexOutOfRange(t *testing.T) {
var tr tree
entry, _ := tr.addEnvelope(newEnvelope(t, `{"i":0}`))
entry.LogIndex = 7
entry.InclusionProof.TreeSize = 7
err := VerifyEntry(entry)
if !errors.Is(err, ErrInclusionProofMalformed) {
t.Fatalf("want ErrInclusionProofMalformed, got %v", err)
}
}