dummy-sink: Phase-1 mock sink (sink:dummy-sink) (DEV-611)
All checks were successful
validate / JSON Schema (ajv) (push) Successful in 19s
build-images / signing-service image (push) Successful in 22s
validate / Rego (opa fmt + opa test) (push) Successful in 13s
validate / DB (migrate up/down dry-run) (push) Successful in 26s
validate / Go (vet + test) (push) Successful in 1m13s

Trivial Senken-Adapter per §7 of architecture V1.1. Accepts
{decision_id, payload_hash, payload, approval_ref, transform_ref}
on POST /api/v1/sink/dummy/deliver; calls verification-service's
/api/v1/verify/chain and branches:

- valid=true → atomic .tmp+rename write of payload to
  /data/dummy-sink/<decision_id>.json, builds a
  basicstack.de/attestations/handover/v1 statement, signs via
  signing-service with the double identity flow (Pocket-ID bearer
  + projected SA token), submits DSSE envelope to the TLog and
  returns 201 {handover_ref, log_index}.
- valid=false & replay=true → 409.
- valid=false → 403 {reasons:[…]}.

Replay protection lives in verification-service per the parent plan;
dummy-sink keeps no local replay store. The payload_hash field is
verified against both the raw and canonical form so a verifier can
reproduce either hash.

Tests use httptest-backed fakes for verify / sign / tlog and cover
the DEV-611 acceptance criteria plus the bad-request shapes:
- happy path writes the file, calls signer with the handover
  statement, submits the envelope to the TLog
- 403 keeps the disk and never calls signer/tlog
- replay returns 409 without re-signing
- payload_hash mismatch rejects before verify
- malformed bodies return 400
- verify unavailable → 502, signing denied → 403

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

View file

@ -0,0 +1,4 @@
**/*_test.go.bak
**/.DS_Store
**/*.swp
**/*.swo

View file

@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1.7
# ---------- build ----------
FROM golang:1.23-alpine3.21 AS build
ENV CGO_ENABLED=0 \
GOOS=linux \
GOFLAGS="-trimpath"
WORKDIR /src
COPY services/dummy-sink/go.mod ./services/dummy-sink/
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
cd services/dummy-sink && go mod download
COPY services/dummy-sink/ ./services/dummy-sink/
WORKDIR /src/services/dummy-sink
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/dummy-sink ./cmd/dummy-sink
# ---------- runtime ----------
# Distroless static+nonroot. The projected ServiceAccount token lives on a
# tmpfs volume mounted by kubelet into /var/run/secrets/tokens/trust-api,
# and /data/dummy-sink is a PVC mount — the container needs no shell.
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/dummy-sink /dummy-sink
USER 65532:65532
EXPOSE 8083
ENTRYPOINT ["/dummy-sink"]

View file

@ -0,0 +1,151 @@
# dummy-sink
Phase-1 Mock-Senke (`sink:dummy-sink`) per [DEV-611](/DEV/issues/DEV-611),
parent [DEV-606](/DEV/issues/DEV-606).
Trivial sink adapter that implements the §7 Senken-Vertrag of the
architecture V1.1 at the minimum needed to close the Phase-1 trust chain:
it accepts a decision, calls the verification-service, persists the
payload to local storage, and emits a signed handover attestation.
## Endpoint
```
POST /api/v1/sink/dummy/deliver
Content-Type: application/json
{
"decision_id": "dec-123",
"payload_hash": "sha256:<64-hex>",
"payload": { ... },
"approval_ref": "sha256:<64-hex>",
"transform_ref": "sha256:<64-hex>"
}
```
`payload_hash` must match either `sha256(raw payload bytes)` or
`sha256(canonical(payload))` — a verifier can reproduce either form.
### Flow
1. Verify the submitted hash matches `payload`.
2. `POST {VERIFY_URL}/api/v1/verify/chain` with
`{decision_id, payload_hash, approval_ref, transform_ref, sink_target}`.
3. Branch on the verdict:
- `valid: true` →
1. Write `payload` atomically to
`{DATA_DIR}/{decision_id}.json` (`.tmp` + rename).
2. Build the handover statement (predicateType
`https://basicstack.de/attestations/handover/v1`) that binds
`decision_id`, `payload_hash`, `approval_ref`, `transform_ref`,
and `sink_record_ref` into the trust chain.
3. `POST {SIGNING_URL}/api/v1/sign/dsse` with the double identity
headers (Pocket-ID bearer + projected SA token).
4. `POST {TLOG_URL}/api/v1/log/entries` with the signed envelope
+ `content_hash`.
5. `201 Created { handover_ref, log_index, decision_id }`.
- `valid: false, replay: true` → `409 Conflict`.
- `valid: false` → `403 Forbidden { error: { reasons: [...] } }`.
Replay protection intentionally lives in the verification-service
(parent plan §Subtask 6); dummy-sink keeps no replay table. A second
delivery with the same `decision_id` is funneled through the
verification-service, which marks it as a replay and the sink translates
that to `409`.
## Non-scope
- Real sinks (DB-App, SAP, webhooks) — all in Phase 4+.
- No request authentication of the inbound caller; dummy-sink is reachable
only from in-cluster callers behind NetworkPolicy (Phase-1-minimum).
- No local replay store; the verification-service owns `decision_id`
consumption.
## Configuration
| Flag / env | Default | Purpose |
| ----------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------- |
| `-listen` | `:8083` | HTTP listen address. |
| `-data-dir` | `/data/dummy-sink` | Where delivered payloads are written as `<decision_id>.json`. |
| `-verify-url` | *(required)* | Base URL of verification-service. |
| `-signing-url` | *(required)* | Base URL of signing-service. |
| `-tlog-url` | *(required)* | Base URL of transparency-log. |
| `-sink-id` | `sink:dummy-sink` | Logical sink identifier (used in verify + handover attestation). |
| `-actor` | `agent-dummy-sink` | Agent registry id of the dummy-sink identity. |
| `-signing-key-ref` | `trust-api/agent-dummy-sink-2026-q4` | `key_ref` the handover attestation is signed with. |
| `-pocket-id-issuer` (env `POCKET_ID_ISSUER`) | *(required in prod)* | Pocket-ID OIDC issuer URL. |
| `-pocket-id-client-id` (env `POCKET_ID_CLIENT_ID`) | *(required in prod)* | OIDC client id for this agent. |
| `-pocket-id-client-secret` (env `POCKET_ID_CLIENT_SECRET`) | *(required in prod)* | OIDC client secret for this agent. |
| `-pocket-id-audience` (env `POCKET_ID_AUDIENCE`) | `trust-api` | OIDC token audience. |
| `-workload-token-path` (env `DUMMY_SINK_WORKLOAD_TOKEN_PATH`) | `/var/run/secrets/tokens/trust-api` | Projected SA token path. |
| `-dev-pocket-id-token` | *(unset)* | **Dev/CI only.** Static Pocket-ID access token. |
| `-dev-workload-token` | *(unset)* | **Dev/CI only.** Static projected SA token. |
## Handover attestation (`basicstack.de/attestations/handover/v1`)
```jsonc
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "handover/<decision_id>",
"digest": { "sha256": "<64-hex payload digest>" }
}
],
"predicateType": "https://basicstack.de/attestations/handover/v1",
"predicate": {
"decision_id": "<id>",
"payload_hash": "sha256:<64-hex>",
"approval_ref": "sha256:<64-hex>",
"transform_ref": "sha256:<64-hex>",
"sink_id": "sink:dummy-sink",
"sink_record_ref": "file://<DATA_DIR>/<decision_id>.json",
"materials": [
{ "name": "approval/<approval_ref>", "attestation_ref": "<approval_ref>" },
{ "name": "transform/<transform_ref>", "attestation_ref": "<transform_ref>" }
],
"actor": "agent-dummy-sink",
"issued_at": "<RFC3339 UTC>"
}
}
```
The subject digest is `sha256(payload)` so a verifier can detect any
tampering between what the agent handed the sink and what the sink
persisted. The two `materials[]` entries pin the attestation chain back
to the (approval, transform) that authorised this handover.
## Tests
```
cd services/dummy-sink
go vet ./...
go test ./...
```
The HTTP tests spin up the dummy-sink against httptest-backed fakes for
verify / sign / tlog and cover the DEV-611 acceptance criteria:
- **Happy path.** `valid=true` → `201` + file on disk + signer called with
the handover statement + TLog entry whose `content_hash` matches the
returned `handover_ref`.
- **Chain invalid.** `valid=false` → `403` with `error.reasons`, no file
written, signer and TLog never called.
- **Replay.** Second call with the same `decision_id` where the
verifier returns `replay: true` → `409`, signer not re-called.
- **Payload hash mismatch.** Client-sent `payload_hash` disagrees with
both the raw and canonical hash → `400`, verifier never called.
- **Bad request shapes.** Missing fields / malformed `sha256:` prefix →
`400`.
- **Verifier unavailable / signer denied.** → `502` / `403` with the
appropriate code so operators can distinguish transport failures
from policy denials.
## Known Phase-1 integration gap
The testagent-transform CLI shipped in [DEV-610](/DEV/issues/DEV-610) still
posts to `/api/v1/handover` with payload-in-body + `X-Attestation-Ref`
header, whereas this sink implements the DEV-611 contract
(`/api/v1/sink/dummy/deliver` with structured JSON). Aligning the agent
is parent-plan Subtask 7 (`make phase1-smoke`); DEV-611 scope is sink-side
only.

View file

@ -0,0 +1,171 @@
// Command dummy-sink is the Phase-1 Mock-Senke (sink:dummy-sink) per
// DEV-611. It receives a decision from the testagent-transform, calls
// the verification-service to vet the (approval, transform) chain, and
// — on valid=true — persists the payload to local storage and emits a
// signed handover_attestation into the transparency-log.
//
// See services/dummy-sink/README.md for the full endpoint contract and
// env configuration.
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"basicstack.de/proofedmesh/services/dummy-sink/internal/auth"
"basicstack.de/proofedmesh/services/dummy-sink/internal/httpapi"
"basicstack.de/proofedmesh/services/dummy-sink/internal/signer"
"basicstack.de/proofedmesh/services/dummy-sink/internal/sink"
"basicstack.de/proofedmesh/services/dummy-sink/internal/tlog"
"basicstack.de/proofedmesh/services/dummy-sink/internal/verify"
)
func main() {
listen := flag.String("listen", ":8083", "HTTP listen address")
dataDir := flag.String("data-dir", "/data/dummy-sink", "directory where delivered payloads are written as <decision_id>.json")
verifyURL := flag.String("verify-url", "", "base URL of verification-service (required)")
signingURL := flag.String("signing-url", "", "base URL of signing-service (required)")
tlogURL := flag.String("tlog-url", "", "base URL of transparency-log (required)")
sinkID := flag.String("sink-id", "sink:dummy-sink", "logical sink identifier used in verify + handover attestation")
actor := flag.String("actor", "agent-dummy-sink", "agent registry id of the dummy-sink identity")
keyRef := flag.String("signing-key-ref", "trust-api/agent-dummy-sink-2026-q4", "key_ref the handover attestation is signed with")
// Pocket-ID OIDC client_credentials for the Authorization: Bearer side
// of the signing-service double-identity check.
pocketIDIssuer := flag.String("pocket-id-issuer", os.Getenv("POCKET_ID_ISSUER"), "Pocket-ID OIDC issuer URL (env POCKET_ID_ISSUER)")
pocketIDClientID := flag.String("pocket-id-client-id", os.Getenv("POCKET_ID_CLIENT_ID"), "Pocket-ID client id (env POCKET_ID_CLIENT_ID)")
pocketIDClientSecret := flag.String("pocket-id-client-secret", os.Getenv("POCKET_ID_CLIENT_SECRET"), "Pocket-ID client secret (env POCKET_ID_CLIENT_SECRET)")
pocketIDAudience := flag.String("pocket-id-audience", envOr("POCKET_ID_AUDIENCE", "trust-api"), "Pocket-ID token audience (env POCKET_ID_AUDIENCE)")
// Projected ServiceAccount token for the X-Workload-Token side.
workloadTokenPath := flag.String("workload-token-path", envOr("DUMMY_SINK_WORKLOAD_TOKEN_PATH", "/var/run/secrets/tokens/trust-api"),
"path to the projected SA token file (env DUMMY_SINK_WORKLOAD_TOKEN_PATH)")
// Optional static tokens for dev/CI where Pocket-ID / kubelet are not
// wired up. Mutually exclusive with the real sources.
staticPocketIDToken := flag.String("dev-pocket-id-token", "", "static Pocket-ID access token for dev/CI only")
staticWorkloadToken := flag.String("dev-workload-token", "", "static projected SA token for dev/CI only")
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := requireNonEmpty("verify-url", *verifyURL); err != nil {
exit(logger, err)
}
if err := requireNonEmpty("signing-url", *signingURL); err != nil {
exit(logger, err)
}
if err := requireNonEmpty("tlog-url", *tlogURL); err != nil {
exit(logger, err)
}
pocketIDSource, err := buildPocketIDSource(*staticPocketIDToken, *pocketIDIssuer, *pocketIDClientID, *pocketIDClientSecret, *pocketIDAudience)
if err != nil {
exit(logger, err)
}
workloadSource, err := buildWorkloadSource(*staticWorkloadToken, *workloadTokenPath)
if err != nil {
exit(logger, err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := httpapi.Server{
Verifier: verify.Client{BaseURL: *verifyURL},
Store: sink.Store{DataDir: *dataDir},
Signer: signer.Client{
BaseURL: *signingURL,
PocketIDToken: pocketIDSource,
WorkloadToken: workloadSource,
},
TLog: tlog.Client{BaseURL: *tlogURL},
SinkID: *sinkID,
Actor: *actor,
SigningKeyRef: *keyRef,
Logger: logger,
Now: time.Now,
}
mux := http.NewServeMux()
srv.Mount(mux)
hsrv := &http.Server{
Addr: *listen,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
logger.Info("dummy-sink listening",
"addr", *listen,
"data_dir", *dataDir,
"sink_id", *sinkID,
"actor", *actor,
"signing_key_ref", *keyRef,
"verify_url", *verifyURL,
"signing_url", *signingURL,
"tlog_url", *tlogURL,
)
if err := hsrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("http serve", "err", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = hsrv.Shutdown(shutdownCtx)
}
func buildPocketIDSource(staticTok, issuer, clientID, clientSecret, audience string) (auth.TokenSource, error) {
if staticTok != "" {
return auth.StaticToken(staticTok), nil
}
if issuer == "" || clientID == "" || clientSecret == "" {
return nil, fmt.Errorf("either -dev-pocket-id-token or (-pocket-id-issuer + -pocket-id-client-id + -pocket-id-client-secret) is required")
}
return &auth.PocketIDTokenSource{
Issuer: issuer,
ClientID: clientID,
ClientSecret: clientSecret,
Audience: audience,
}, nil
}
func buildWorkloadSource(staticTok, path string) (auth.TokenSource, error) {
if staticTok != "" {
return auth.StaticToken(staticTok), nil
}
if path == "" {
return nil, fmt.Errorf("either -dev-workload-token or -workload-token-path is required")
}
return &auth.ProjectedSATokenSource{Path: path}, nil
}
func requireNonEmpty(name, value string) error {
if value == "" {
return fmt.Errorf("-%s is required", name)
}
return nil
}
func envOr(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}
func exit(logger *slog.Logger, err error) {
logger.Error("dummy-sink init", "err", err)
os.Exit(1)
}

View file

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

View file

@ -0,0 +1,165 @@
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// PocketIDTokenSource issues Bearer tokens using the Pocket-ID OIDC
// `client_credentials` grant and caches each token until shortly before
// its expiry. Safe for concurrent use.
//
// Shape matches services/testagent-transform/internal/auth so the
// signing-service's Pocket-ID validator sees the same token format
// admin-api, approval-service, and testagent-transform already send.
type PocketIDTokenSource struct {
Issuer string
ClientID string
ClientSecret string
Scope string
Audience string
Client *http.Client
RefreshLeeway time.Duration
Now func() time.Time
mu sync.Mutex
tokenEndpoint string
cachedToken string
cachedExpiry time.Time
}
type pocketIDTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type pocketIDDiscoveryDoc struct {
TokenEndpoint string `json:"token_endpoint"`
}
// Token returns a cached access token when still valid, or fetches a fresh
// one from Pocket-ID.
func (p *PocketIDTokenSource) Token(ctx context.Context) (string, error) {
if p == nil {
return "", errors.New("auth.PocketIDTokenSource: nil receiver")
}
if p.Issuer == "" {
return "", errors.New("auth.PocketIDTokenSource: Issuer is required")
}
if p.ClientID == "" || p.ClientSecret == "" {
return "", errors.New("auth.PocketIDTokenSource: ClientID and ClientSecret are required")
}
p.mu.Lock()
defer p.mu.Unlock()
leeway := p.RefreshLeeway
if leeway <= 0 {
leeway = 30 * time.Second
}
now := p.nowLocked()
if p.cachedToken != "" && now.Add(leeway).Before(p.cachedExpiry) {
return p.cachedToken, nil
}
if p.tokenEndpoint == "" {
if err := p.discoverLocked(ctx); err != nil {
return "", err
}
}
tok, expiry, err := p.fetchLocked(ctx, now)
if err != nil {
return "", err
}
p.cachedToken = tok
p.cachedExpiry = expiry
return tok, nil
}
func (p *PocketIDTokenSource) nowLocked() time.Time {
if p.Now != nil {
return p.Now()
}
return time.Now()
}
func (p *PocketIDTokenSource) httpClient() *http.Client {
if p.Client != nil {
return p.Client
}
return &http.Client{Timeout: 10 * time.Second}
}
func (p *PocketIDTokenSource) discoverLocked(ctx context.Context) error {
base := strings.TrimRight(p.Issuer, "/")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/.well-known/openid-configuration", nil)
if err != nil {
return fmt.Errorf("pocket id discovery: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := p.httpClient().Do(req)
if err != nil {
return fmt.Errorf("pocket id discovery: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("pocket id discovery: status %d: %s", resp.StatusCode, string(raw))
}
var doc pocketIDDiscoveryDoc
if err := json.Unmarshal(raw, &doc); err != nil {
return fmt.Errorf("pocket id discovery: decode: %w", err)
}
if doc.TokenEndpoint == "" {
return errors.New("pocket id discovery: token_endpoint missing from OIDC configuration")
}
p.tokenEndpoint = doc.TokenEndpoint
return nil
}
func (p *PocketIDTokenSource) fetchLocked(ctx context.Context, now time.Time) (string, time.Time, error) {
form := url.Values{}
form.Set("grant_type", "client_credentials")
if p.Scope != "" {
form.Set("scope", p.Scope)
}
if p.Audience != "" {
form.Set("audience", p.Audience)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", time.Time{}, fmt.Errorf("pocket id token: %w", err)
}
req.SetBasicAuth(p.ClientID, p.ClientSecret)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := p.httpClient().Do(req)
if err != nil {
return "", time.Time{}, fmt.Errorf("pocket id token: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", time.Time{}, fmt.Errorf("pocket id token: status %d: %s", resp.StatusCode, string(raw))
}
var out pocketIDTokenResponse
if err := json.Unmarshal(raw, &out); err != nil {
return "", time.Time{}, fmt.Errorf("pocket id token: decode: %w", err)
}
if out.AccessToken == "" {
return "", time.Time{}, errors.New("pocket id token: response missing access_token")
}
if out.ExpiresIn <= 0 {
return "", time.Time{}, errors.New("pocket id token: response has non-positive expires_in")
}
return out.AccessToken, now.Add(time.Duration(out.ExpiresIn) * time.Second), nil
}

View file

@ -0,0 +1,67 @@
package auth
import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
)
// ProjectedSATokenSource reads the kubelet-written projected
// ServiceAccount token from a mounted Volume (typically
// `/var/run/secrets/tokens/trust-api`) and re-reads it on each call so
// kubelet's periodic refresh is picked up without a service restart.
//
// A short in-process cache avoids a syscall on every signing-service call;
// kubelet refreshes projected tokens well before their `exp`, so a few
// seconds of staleness is fine. Set CacheTTL negative to disable.
type ProjectedSATokenSource struct {
Path string
CacheTTL time.Duration
Now func() time.Time
mu sync.Mutex
cachedToken string
cachedAt time.Time
}
// Token returns the current projected ServiceAccount token.
func (p *ProjectedSATokenSource) Token(_ context.Context) (string, error) {
if p == nil {
return "", fmt.Errorf("auth.ProjectedSATokenSource: nil receiver")
}
if p.Path == "" {
return "", fmt.Errorf("auth.ProjectedSATokenSource: Path is required")
}
p.mu.Lock()
defer p.mu.Unlock()
ttl := p.CacheTTL
if ttl == 0 {
ttl = 30 * time.Second
}
now := p.now()
if ttl > 0 && p.cachedToken != "" && now.Sub(p.cachedAt) < ttl {
return p.cachedToken, nil
}
raw, err := os.ReadFile(p.Path)
if err != nil {
return "", fmt.Errorf("read projected SA token %q: %w", p.Path, err)
}
tok := strings.TrimSpace(string(raw))
if tok == "" {
return "", fmt.Errorf("projected SA token at %q is empty", p.Path)
}
p.cachedToken = tok
p.cachedAt = now
return tok, nil
}
func (p *ProjectedSATokenSource) now() time.Time {
if p.Now != nil {
return p.Now()
}
return time.Now()
}

View file

@ -0,0 +1,33 @@
// Package auth exposes the two identity sources dummy-sink uses to call
// the signing-service:
//
// 1. Pocket-ID client_credentials access token (Authorization: Bearer …)
// 2. Projected ServiceAccount token (X-Workload-Token …) read from a
// kubelet-managed Volume (`projected: { serviceAccountToken: … }`).
//
// Both are modelled behind the same TokenSource interface so the
// orchestration code never cares about the identity's origin. The split
// mirrors services/testagent-transform/internal/auth and services/
// approval-service/internal/signer; both of those services already use
// the double-identity flow against signing-service.
package auth
import "context"
// TokenSource returns a bearer-style token. Implementations may cache
// internally; the caller is expected to retry on error.
type TokenSource interface {
Token(ctx context.Context) (string, error)
}
// StaticToken is a constant TokenSource. Primarily used in tests.
type StaticToken string
// Token returns s.
func (s StaticToken) Token(_ context.Context) (string, error) { return string(s), nil }
// TokenFunc adapts a plain function to TokenSource.
type TokenFunc func(ctx context.Context) (string, error)
// Token calls f.
func (f TokenFunc) Token(ctx context.Context) (string, error) { return f(ctx) }

View file

@ -0,0 +1,103 @@
// Package canonical implements the deterministic JSON serialisation
// (sorted object keys) dummy-sink uses when it computes payload / subject
// digests that MUST reproduce byte-for-byte on the verifier 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
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,22 @@
// Package dsse models the DSSE envelope shape the signing-service
// produces. dummy-sink does not verify envelopes locally; it simply ships
// the envelope the signing-service returned to the transparency-log.
//
// Shape mirrors services/testagent-transform/internal/dsse.Envelope so a
// future verifier can round-trip the handover envelope through either
// package byte-for-byte.
package dsse
// 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"`
}

View file

@ -0,0 +1,95 @@
// Package handover builds the in-toto v1 Statement for the Phase-1
// handover attestation (predicateType =
// https://basicstack.de/attestations/handover/v1). The attestation
// records that `sink:dummy-sink` persisted `payload` (identified by
// `payload_hash`) on behalf of the (approval_ref, transform_ref) chain,
// per §7.0 of the architecture.
package handover
import (
"errors"
"time"
"basicstack.de/proofedmesh/services/dummy-sink/internal/signer"
)
// PredicateType is the stable URL the signing-service, verification
// chain, and transparency-log key the handover statement off of.
const PredicateType = "https://basicstack.de/attestations/handover/v1"
// Input captures everything the sink knows about a successfully
// persisted delivery.
type Input struct {
DecisionID string
PayloadHash string // sha256:<hex>
ApprovalRef string
TransformRef string
SinkID string // e.g. "sink:dummy-sink"
SinkRecordRef string // storage locator (e.g. "file:///data/dummy-sink/<decision_id>.json")
Actor string // agent registry id of the dummy-sink identity
IssuedAt time.Time
}
// Build returns the in-toto Statement handed to the signing-service.
// The subject binds the attestation to the payload hash so a downstream
// verifier can detect any tampering between what the agent sent and what
// the sink persisted.
func Build(in Input) (signer.Statement, error) {
if in.DecisionID == "" || in.PayloadHash == "" ||
in.ApprovalRef == "" || in.TransformRef == "" ||
in.SinkID == "" || in.SinkRecordRef == "" || in.Actor == "" {
return nil, errors.New("handover.Build: Input missing required field")
}
hashHex, err := StripSha256Prefix(in.PayloadHash)
if err != nil {
return nil, err
}
issuedAt := in.IssuedAt
if issuedAt.IsZero() {
issuedAt = time.Now()
}
return signer.Statement{
"_type": "https://in-toto.io/Statement/v1",
"subject": []any{
map[string]any{
"name": "handover/" + in.DecisionID,
"digest": map[string]any{
"sha256": hashHex,
},
},
},
"predicateType": PredicateType,
"predicate": map[string]any{
"decision_id": in.DecisionID,
"payload_hash": in.PayloadHash,
"approval_ref": in.ApprovalRef,
"transform_ref": in.TransformRef,
"sink_id": in.SinkID,
"sink_record_ref": in.SinkRecordRef,
"materials": []any{
map[string]any{
"name": "approval/" + in.ApprovalRef,
"attestation_ref": in.ApprovalRef,
},
map[string]any{
"name": "transform/" + in.TransformRef,
"attestation_ref": in.TransformRef,
},
},
"actor": in.Actor,
"issued_at": issuedAt.UTC().Format(time.RFC3339),
},
}, nil
}
// StripSha256Prefix normalises a `sha256:<hex>` hash to the bare hex
// subject-digest form in-toto uses.
func StripSha256Prefix(hash string) (string, error) {
const prefix = "sha256:"
if len(hash) <= len(prefix) || hash[:len(prefix)] != prefix {
return "", errors.New("handover: payload_hash must be sha256:<hex>")
}
return hash[len(prefix):], nil
}

View file

@ -0,0 +1,69 @@
package handover
import (
"testing"
"time"
)
func TestBuildHappyPath(t *testing.T) {
in := Input{
DecisionID: "dec-1",
PayloadHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
ApprovalRef: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
TransformRef: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
SinkID: "sink:dummy-sink",
SinkRecordRef: "file:///data/dummy-sink/dec-1.json",
Actor: "agent-dummy-sink",
IssuedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}
stmt, err := Build(in)
if err != nil {
t.Fatalf("Build: %v", err)
}
if stmt["predicateType"] != PredicateType {
t.Fatalf("predicateType = %v, want %v", stmt["predicateType"], PredicateType)
}
pred := stmt["predicate"].(map[string]any)
if pred["decision_id"] != "dec-1" {
t.Fatalf("predicate.decision_id = %v", pred["decision_id"])
}
if pred["approval_ref"] != in.ApprovalRef {
t.Fatalf("predicate.approval_ref = %v", pred["approval_ref"])
}
if pred["issued_at"] != "2026-01-02T03:04:05Z" {
t.Fatalf("predicate.issued_at = %v", pred["issued_at"])
}
subject := stmt["subject"].([]any)[0].(map[string]any)
digest := subject["digest"].(map[string]any)
wantHex := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
if digest["sha256"] != wantHex {
t.Fatalf("digest.sha256 = %v, want %v", digest["sha256"], wantHex)
}
materials := pred["materials"].([]any)
if len(materials) != 2 {
t.Fatalf("materials len = %d, want 2", len(materials))
}
}
func TestBuildRequiresFields(t *testing.T) {
_, err := Build(Input{})
if err == nil {
t.Fatalf("Build empty: want err")
}
}
func TestStripSha256Prefix(t *testing.T) {
hex, err := StripSha256Prefix("sha256:abcd")
if err != nil {
t.Fatalf("StripSha256Prefix: %v", err)
}
if hex != "abcd" {
t.Fatalf("hex = %q, want abcd", hex)
}
if _, err := StripSha256Prefix("sha512:abcd"); err == nil {
t.Fatalf("sha512 prefix should error")
}
if _, err := StripSha256Prefix(""); err == nil {
t.Fatalf("empty should error")
}
}

View file

@ -0,0 +1,286 @@
// Package httpapi exposes dummy-sink's single endpoint:
// POST /api/v1/sink/dummy/deliver per the DEV-611 contract.
//
// Flow:
// 1. Parse the request body ({decision_id, payload_hash, payload,
// approval_ref, transform_ref}).
// 2. Call verification-service /api/v1/verify/chain.
// 3. On valid=true:
// a. Write payload atomically to <DataDir>/<decision_id>.json.
// b. Build the handover_attestation (schema
// basicstack.de/attestations/handover/v1).
// c. Sign it via signing-service.
// d. Submit the DSSE envelope to the transparency-log.
// e. Reply 201 Created {handover_ref, log_index}.
// 4. On valid=false:
// a. If verification-service flagged `replay: true`, reply 409.
// b. Otherwise reply 403 Forbidden {reasons: [...]}.
//
// Replay protection intentionally lives in the verification-service per
// the parent plan: dummy-sink keeps no replay table of its own.
package httpapi
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"basicstack.de/proofedmesh/services/dummy-sink/internal/canonical"
"basicstack.de/proofedmesh/services/dummy-sink/internal/dsse"
"basicstack.de/proofedmesh/services/dummy-sink/internal/handover"
"basicstack.de/proofedmesh/services/dummy-sink/internal/signer"
"basicstack.de/proofedmesh/services/dummy-sink/internal/sink"
"basicstack.de/proofedmesh/services/dummy-sink/internal/tlog"
"basicstack.de/proofedmesh/services/dummy-sink/internal/verify"
)
// Verifier calls the verification-service.
type Verifier interface {
Verify(ctx context.Context, req verify.Request) (verify.Result, error)
}
// Signer calls the signing-service.
type Signer interface {
Sign(ctx context.Context, keyRef string, stmt signer.Statement) (signer.SignedResult, error)
}
// TLog submits DSSE envelopes to the transparency-log.
type TLog interface {
Submit(ctx context.Context, env dsse.Envelope, contentHash string) (tlog.Receipt, error)
}
// Store writes payloads to disk atomically.
type Store interface {
Write(decisionID string, payload any) (sink.WriteResult, error)
}
// Server wires the delivery pipeline.
type Server struct {
Verifier Verifier
Store Store
Signer Signer
TLog TLog
SinkID string // e.g. "sink:dummy-sink"
Actor string // agent registry id of the dummy-sink identity
SigningKeyRef string // e.g. "trust-api/agent-dummy-sink-2026-q4"
Logger *slog.Logger
Now func() time.Time
}
// Mount registers the endpoints on mux.
func (s Server) Mount(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/sink/dummy/deliver", s.handleDeliver)
mux.HandleFunc("GET /healthz", s.handleHealth)
}
type deliverRequest struct {
DecisionID string `json:"decision_id"`
PayloadHash string `json:"payload_hash"`
Payload json.RawMessage `json:"payload"`
ApprovalRef string `json:"approval_ref"`
TransformRef string `json:"transform_ref"`
}
type deliverResponse struct {
HandoverRef string `json:"handover_ref"`
LogIndex int64 `json:"log_index"`
DecisionID string `json:"decision_id"`
}
type errResponse struct {
Error errBody `json:"error"`
}
type errBody struct {
Code string `json:"code"`
Message string `json:"message"`
Reasons []string `json:"reasons,omitempty"`
}
func (s Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
func (s Server) handleDeliver(w http.ResponseWriter, r *http.Request) {
logger := s.logger()
ctx := r.Context()
raw, err := io.ReadAll(io.LimitReader(r.Body, 10<<20))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid_body", "read body: "+err.Error(), nil)
return
}
defer r.Body.Close()
var req deliverRequest
if err := json.Unmarshal(raw, &req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid_body", "parse body: "+err.Error(), nil)
return
}
if err := validateRequest(req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid_body", err.Error(), nil)
return
}
payloadRaw := []byte(req.Payload)
rawDigest := sha256.Sum256(payloadRaw)
rawHex := "sha256:" + hex.EncodeToString(rawDigest[:])
var payloadAny any
if err := json.Unmarshal(payloadRaw, &payloadAny); err != nil {
writeErr(w, http.StatusBadRequest, "invalid_body", "payload must be valid JSON: "+err.Error(), nil)
return
}
canonicalHash, _, err := canonical.Hash(payloadAny)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid_body", "canonicalise payload: "+err.Error(), nil)
return
}
if req.PayloadHash != rawHex && req.PayloadHash != canonicalHash {
writeErr(w, http.StatusBadRequest, "payload_hash_mismatch",
fmt.Sprintf("payload_hash %q matches neither raw (%s) nor canonical (%s)",
req.PayloadHash, rawHex, canonicalHash), nil)
return
}
verdict, err := s.Verifier.Verify(ctx, verify.Request{
DecisionID: req.DecisionID,
PayloadHash: req.PayloadHash,
ApprovalRef: req.ApprovalRef,
TransformRef: req.TransformRef,
SinkTarget: s.SinkID,
})
if err != nil {
logger.Error("verify.chain failed", "decision_id", req.DecisionID, "err", err)
if errors.Is(err, verify.ErrVerifyUnavailable) {
writeErr(w, http.StatusBadGateway, "verify_unavailable", err.Error(), nil)
return
}
writeErr(w, http.StatusBadGateway, "verify_failed", err.Error(), nil)
return
}
if !verdict.Valid {
if verdict.Replay {
writeErr(w, http.StatusConflict, "replay", "decision_id already consumed", verdict.Reasons)
return
}
writeErr(w, http.StatusForbidden, "chain_invalid", "verification chain rejected delivery", verdict.Reasons)
return
}
write, err := s.Store.Write(req.DecisionID, payloadAny)
if err != nil {
logger.Error("store.Write failed", "decision_id", req.DecisionID, "err", err)
if errors.Is(err, sink.ErrInvalidDecisionID) {
writeErr(w, http.StatusBadRequest, "invalid_decision_id", err.Error(), nil)
return
}
writeErr(w, http.StatusInternalServerError, "sink_write_failed", err.Error(), nil)
return
}
stmt, err := handover.Build(handover.Input{
DecisionID: req.DecisionID,
PayloadHash: req.PayloadHash,
ApprovalRef: req.ApprovalRef,
TransformRef: req.TransformRef,
SinkID: s.SinkID,
SinkRecordRef: "file://" + write.Path,
Actor: s.Actor,
IssuedAt: s.now(),
})
if err != nil {
logger.Error("handover.Build failed", "decision_id", req.DecisionID, "err", err)
writeErr(w, http.StatusInternalServerError, "handover_build_failed", err.Error(), nil)
return
}
signed, err := s.Signer.Sign(ctx, s.SigningKeyRef, stmt)
if err != nil {
logger.Error("signer.Sign failed", "decision_id", req.DecisionID, "err", err)
if errors.Is(err, signer.ErrSigningDenied) {
writeErr(w, http.StatusForbidden, "signing_denied", err.Error(), nil)
return
}
if errors.Is(err, signer.ErrSignerUnavailable) {
writeErr(w, http.StatusBadGateway, "signer_unavailable", err.Error(), nil)
return
}
writeErr(w, http.StatusInternalServerError, "sign_failed", err.Error(), nil)
return
}
receipt, err := s.TLog.Submit(ctx, signed.Envelope, signed.ContentHash)
if err != nil {
logger.Error("tlog.Submit failed", "decision_id", req.DecisionID, "err", err)
writeErr(w, http.StatusBadGateway, "tlog_failed", err.Error(), nil)
return
}
writeJSON(w, http.StatusCreated, deliverResponse{
HandoverRef: signed.ContentHash,
LogIndex: receipt.LogIndex,
DecisionID: req.DecisionID,
})
}
func validateRequest(req deliverRequest) error {
if strings.TrimSpace(req.DecisionID) == "" {
return errors.New("decision_id is required")
}
if strings.TrimSpace(req.PayloadHash) == "" {
return errors.New("payload_hash is required")
}
if !strings.HasPrefix(req.PayloadHash, "sha256:") || len(req.PayloadHash) != 7+64 {
return errors.New("payload_hash must be sha256:<64-hex>")
}
if len(req.Payload) == 0 {
return errors.New("payload is required")
}
if strings.TrimSpace(req.ApprovalRef) == "" {
return errors.New("approval_ref is required")
}
if strings.TrimSpace(req.TransformRef) == "" {
return errors.New("transform_ref is required")
}
return nil
}
func writeErr(w http.ResponseWriter, status int, code, msg string, reasons []string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errResponse{Error: errBody{
Code: code, Message: msg, Reasons: reasons,
}})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func (s Server) logger() *slog.Logger {
if s.Logger != nil {
return s.Logger
}
return slog.Default()
}
func (s Server) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now()
}

View file

@ -0,0 +1,407 @@
package httpapi
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"basicstack.de/proofedmesh/services/dummy-sink/internal/dsse"
"basicstack.de/proofedmesh/services/dummy-sink/internal/signer"
"basicstack.de/proofedmesh/services/dummy-sink/internal/sink"
"basicstack.de/proofedmesh/services/dummy-sink/internal/tlog"
"basicstack.de/proofedmesh/services/dummy-sink/internal/verify"
)
type fakeVerifier struct {
calls int
results []verify.Result
lastReq verify.Request
err error
}
func (f *fakeVerifier) Verify(_ context.Context, req verify.Request) (verify.Result, error) {
f.lastReq = req
if f.err != nil {
return verify.Result{}, f.err
}
idx := f.calls
if idx >= len(f.results) {
idx = len(f.results) - 1
}
f.calls++
return f.results[idx], nil
}
type fakeSigner struct {
calls int
result signer.SignedResult
err error
lastStm signer.Statement
lastRef string
}
func (f *fakeSigner) Sign(_ context.Context, keyRef string, stmt signer.Statement) (signer.SignedResult, error) {
f.calls++
f.lastStm = stmt
f.lastRef = keyRef
if f.err != nil {
return signer.SignedResult{}, f.err
}
return f.result, nil
}
type fakeTLog struct {
calls int
result tlog.Receipt
err error
lastEnv dsse.Envelope
lastHsh string
}
func (f *fakeTLog) Submit(_ context.Context, env dsse.Envelope, contentHash string) (tlog.Receipt, error) {
f.calls++
f.lastEnv = env
f.lastHsh = contentHash
if f.err != nil {
return tlog.Receipt{}, f.err
}
r := f.result
r.ContentHash = contentHash
return r, nil
}
func makeServerAndDeps(t *testing.T, verifier Verifier, signr Signer, tl TLog) (Server, string) {
t.Helper()
dir := t.TempDir()
return Server{
Verifier: verifier,
Store: sink.Store{DataDir: dir},
Signer: signr,
TLog: tl,
SinkID: "sink:dummy-sink",
Actor: "agent-dummy-sink",
SigningKeyRef: "trust-api/agent-dummy-sink-2026-q4",
Now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) },
}, dir
}
func startTestServer(t *testing.T, srv Server) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
srv.Mount(mux)
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts
}
func deliverBody(t *testing.T, payload any, decisionID, approvalRef, transformRef string) ([]byte, string) {
t.Helper()
payloadRaw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
sum := sha256.Sum256(payloadRaw)
payloadHash := "sha256:" + hex.EncodeToString(sum[:])
body, err := json.Marshal(map[string]any{
"decision_id": decisionID,
"payload_hash": payloadHash,
"payload": json.RawMessage(payloadRaw),
"approval_ref": approvalRef,
"transform_ref": transformRef,
})
if err != nil {
t.Fatalf("marshal body: %v", err)
}
return body, payloadHash
}
func TestDeliverHappyPath(t *testing.T) {
verifier := &fakeVerifier{results: []verify.Result{{Valid: true, DecisionID: "dec-1"}}}
signr := &fakeSigner{result: signer.SignedResult{
Envelope: dsse.Envelope{
PayloadType: "application/vnd.in-toto+json",
Payload: "payload-b64",
Signatures: []dsse.EnvelopeSignature{{KeyID: "k1", Sig: "sig1"}},
},
ContentHash: "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
SignedAt: time.Now(),
}}
tl := &fakeTLog{result: tlog.Receipt{LogIndex: 123}}
srv, dataDir := makeServerAndDeps(t, verifier, signr, tl)
ts := startTestServer(t, srv)
payload := map[string]any{"workflow_id": "WF-0042", "amount": 42.5, "memo": "coffee"}
body, payloadHash := deliverBody(t, payload, "dec-1",
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
)
resp, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
rawResp, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", resp.StatusCode, rawResp)
}
var out deliverResponse
if err := json.Unmarshal(rawResp, &out); err != nil {
t.Fatalf("decode resp: %v", err)
}
if out.HandoverRef != signr.result.ContentHash {
t.Fatalf("handover_ref = %q, want %q", out.HandoverRef, signr.result.ContentHash)
}
if out.LogIndex != 123 {
t.Fatalf("log_index = %d, want 123", out.LogIndex)
}
if out.DecisionID != "dec-1" {
t.Fatalf("decision_id = %q, want dec-1", out.DecisionID)
}
// Payload file was written atomically.
content, err := os.ReadFile(filepath.Join(dataDir, "dec-1.json"))
if err != nil {
t.Fatalf("read written file: %v", err)
}
var got map[string]any
if err := json.Unmarshal(content, &got); err != nil {
t.Fatalf("decode written file: %v", err)
}
if got["workflow_id"] != "WF-0042" {
t.Fatalf("written payload workflow_id = %v", got["workflow_id"])
}
// Verifier was called with the right fields.
if verifier.lastReq.DecisionID != "dec-1" ||
verifier.lastReq.ApprovalRef == "" ||
verifier.lastReq.TransformRef == "" ||
verifier.lastReq.SinkTarget != "sink:dummy-sink" ||
verifier.lastReq.PayloadHash != payloadHash {
t.Fatalf("verify req = %+v", verifier.lastReq)
}
// Signer was called with the handover statement.
if signr.calls != 1 {
t.Fatalf("signer.calls = %d, want 1", signr.calls)
}
if signr.lastRef != "trust-api/agent-dummy-sink-2026-q4" {
t.Fatalf("key_ref = %q", signr.lastRef)
}
if signr.lastStm["predicateType"] != "https://basicstack.de/attestations/handover/v1" {
t.Fatalf("predicateType = %v", signr.lastStm["predicateType"])
}
// TLog was called with the signed envelope + content_hash.
if tl.calls != 1 {
t.Fatalf("tlog.calls = %d, want 1", tl.calls)
}
if tl.lastHsh != signr.result.ContentHash {
t.Fatalf("tlog content_hash = %q, want %q", tl.lastHsh, signr.result.ContentHash)
}
if tl.lastEnv.Payload != "payload-b64" {
t.Fatalf("tlog envelope payload = %q", tl.lastEnv.Payload)
}
}
func TestDeliverInvalidChainReturns403NoWriteNoSign(t *testing.T) {
verifier := &fakeVerifier{results: []verify.Result{{
Valid: false,
Reasons: []string{"approval_signature_invalid", "sink_not_allowed"},
}}}
signr := &fakeSigner{}
tl := &fakeTLog{}
srv, dataDir := makeServerAndDeps(t, verifier, signr, tl)
ts := startTestServer(t, srv)
body, _ := deliverBody(t, map[string]any{"x": 1}, "dec-bad",
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
)
resp, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body=%s", resp.StatusCode, raw)
}
var out errResponse
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.Error.Reasons) != 2 || out.Error.Reasons[0] != "approval_signature_invalid" {
t.Fatalf("reasons = %v", out.Error.Reasons)
}
// No file written.
if _, err := os.Stat(filepath.Join(dataDir, "dec-bad.json")); !os.IsNotExist(err) {
t.Fatalf("expected no file, stat err = %v", err)
}
if signr.calls != 0 {
t.Fatalf("signer must not be called; got %d", signr.calls)
}
if tl.calls != 0 {
t.Fatalf("tlog must not be called; got %d", tl.calls)
}
}
func TestDeliverReplayReturns409(t *testing.T) {
// First call succeeds; second call is marked as replay by the
// verification-service and must map to 409 with no fresh file or
// signing call.
envelope := dsse.Envelope{
PayloadType: "application/vnd.in-toto+json",
Payload: "p1",
Signatures: []dsse.EnvelopeSignature{{KeyID: "k1", Sig: "sig"}},
}
verifier := &fakeVerifier{results: []verify.Result{
{Valid: true, DecisionID: "dec-replay"},
{Valid: false, Replay: true, DecisionID: "dec-replay", Reasons: []string{"decision_id already consumed"}},
}}
signr := &fakeSigner{result: signer.SignedResult{
Envelope: envelope,
ContentHash: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
}}
tl := &fakeTLog{result: tlog.Receipt{LogIndex: 7}}
srv, dataDir := makeServerAndDeps(t, verifier, signr, tl)
ts := startTestServer(t, srv)
body, _ := deliverBody(t, map[string]any{"x": "once"}, "dec-replay",
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
)
resp1, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST1: %v", err)
}
resp1.Body.Close()
if resp1.StatusCode != http.StatusCreated {
t.Fatalf("first call status = %d, want 201", resp1.StatusCode)
}
if _, err := os.Stat(filepath.Join(dataDir, "dec-replay.json")); err != nil {
t.Fatalf("expected file after first call: %v", err)
}
callsAfterFirst := signr.calls
resp2, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST2: %v", err)
}
defer resp2.Body.Close()
rawResp, _ := io.ReadAll(resp2.Body)
if resp2.StatusCode != http.StatusConflict {
t.Fatalf("second call status = %d, want 409; body=%s", resp2.StatusCode, rawResp)
}
if signr.calls != callsAfterFirst {
t.Fatalf("signer must not be called on replay; calls = %d (was %d)", signr.calls, callsAfterFirst)
}
}
func TestDeliverPayloadHashMismatch(t *testing.T) {
verifier := &fakeVerifier{results: []verify.Result{{Valid: true}}}
signr := &fakeSigner{}
tl := &fakeTLog{}
srv, _ := makeServerAndDeps(t, verifier, signr, tl)
ts := startTestServer(t, srv)
payload, _ := json.Marshal(map[string]any{"x": 1})
bogus := "sha256:" + strings.Repeat("0", 64)
body, _ := json.Marshal(map[string]any{
"decision_id": "dec-bad-hash",
"payload_hash": bogus,
"payload": json.RawMessage(payload),
"approval_ref": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"transform_ref": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
})
resp, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
if verifier.calls != 0 {
t.Fatalf("verify must not be called on bad hash; calls=%d", verifier.calls)
}
}
func TestDeliverInvalidBody(t *testing.T) {
verifier := &fakeVerifier{results: []verify.Result{{Valid: true}}}
srv, _ := makeServerAndDeps(t, verifier, &fakeSigner{}, &fakeTLog{})
ts := startTestServer(t, srv)
for _, body := range []string{
`{}`,
`{"decision_id":"d","payload_hash":"nothex","payload":{"x":1},"approval_ref":"a","transform_ref":"t"}`,
`{"decision_id":"d","payload_hash":"sha256:` + strings.Repeat("0", 64) + `","approval_ref":"a","transform_ref":"t"}`,
} {
resp, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader([]byte(body)))
if err != nil {
t.Fatalf("POST: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for body %s", resp.StatusCode, body)
}
}
}
func TestDeliverVerifierUnavailableReturns502(t *testing.T) {
verifier := &fakeVerifier{err: verify.ErrVerifyUnavailable}
srv, _ := makeServerAndDeps(t, verifier, &fakeSigner{}, &fakeTLog{})
ts := startTestServer(t, srv)
body, _ := deliverBody(t, map[string]any{"x": 1}, "d1",
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
)
resp, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", resp.StatusCode)
}
}
func TestDeliverSigningDeniedPropagates403(t *testing.T) {
verifier := &fakeVerifier{results: []verify.Result{{Valid: true}}}
signr := &fakeSigner{err: errors.New("wrapped: " + signer.ErrSigningDenied.Error())}
// Ensure signer.err is the ErrSigningDenied sentinel so errors.Is matches.
signr.err = signer.ErrSigningDenied
srv, _ := makeServerAndDeps(t, verifier, signr, &fakeTLog{})
ts := startTestServer(t, srv)
body, _ := deliverBody(t, map[string]any{"x": 1}, "d1",
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
)
resp, err := http.Post(ts.URL+"/api/v1/sink/dummy/deliver", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
}

View file

@ -0,0 +1,143 @@
// Package signer calls the signing-service's POST /api/v1/sign/dsse per
// ADR-0005 using the double-identity flow:
//
// - Authorization: Bearer <pocket-id-access-token> (OIDC client identity)
// - X-Workload-Token: <projected-sa-token> (K8s workload identity)
//
// The signing-service's structured policy evaluates BOTH identities
// against the Agent Registry and the OPA policy. See
// services/signing-service/internal/httpapi/sign.go.
package signer
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"basicstack.de/proofedmesh/services/dummy-sink/internal/auth"
"basicstack.de/proofedmesh/services/dummy-sink/internal/dsse"
)
// WorkloadTokenHeader is the HTTP header carrying the projected
// ServiceAccount token. Kept in sync with
// services/signing-service/internal/token.WorkloadTokenHeader.
const WorkloadTokenHeader = "X-Workload-Token"
// Statement is the in-toto v1 Statement handed to the signing-service.
type Statement map[string]any
// SignedResult pairs the returned DSSE envelope with its content_hash.
type SignedResult struct {
Envelope dsse.Envelope `json:"envelope"`
ContentHash string `json:"content_hash"`
SignedAt time.Time `json:"signed_at"`
}
// ErrSignerUnavailable is returned on connection errors or 5xx responses.
var ErrSignerUnavailable = errors.New("signer: signing-service unavailable")
// ErrSigningDenied is returned on 403 (OPA / registry mismatch). The
// sink surfaces this to the caller — it is NOT retryable with the same
// identities.
var ErrSigningDenied = errors.New("signer: signing-service denied the request")
// Client is the DSSE sign client.
type Client struct {
BaseURL string
HTTPClient *http.Client
PocketIDToken auth.TokenSource // Authorization: Bearer …
WorkloadToken auth.TokenSource // X-Workload-Token: …
}
type signRequest struct {
KeyRef string `json:"key_ref"`
Statement Statement `json:"statement"`
}
// Sign calls POST /api/v1/sign/dsse. The signing-service returns a DSSE
// envelope plus the stable content_hash of the envelope.
func (c Client) Sign(ctx context.Context, keyRef string, stmt Statement) (SignedResult, error) {
if c.BaseURL == "" {
return SignedResult{}, errors.New("signer.Client: BaseURL is empty")
}
if c.PocketIDToken == nil {
return SignedResult{}, errors.New("signer.Client: PocketIDToken is required")
}
if c.WorkloadToken == nil {
return SignedResult{}, errors.New("signer.Client: WorkloadToken is required")
}
hc := c.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 10 * time.Second}
}
body, err := json.Marshal(signRequest{KeyRef: keyRef, Statement: stmt})
if err != nil {
return SignedResult{}, fmt.Errorf("signer: marshal request: %w", err)
}
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/sign/dsse")
if err != nil {
return SignedResult{}, fmt.Errorf("signer: build url: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return SignedResult{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
pTok, err := c.PocketIDToken.Token(ctx)
if err != nil {
return SignedResult{}, fmt.Errorf("signer: pocket-id token: %w", err)
}
if pTok == "" {
return SignedResult{}, errors.New("signer: pocket-id token is empty")
}
req.Header.Set("Authorization", "Bearer "+pTok)
wTok, err := c.WorkloadToken.Token(ctx)
if err != nil {
return SignedResult{}, fmt.Errorf("signer: workload token: %w", err)
}
if wTok == "" {
return SignedResult{}, errors.New("signer: workload token is empty")
}
req.Header.Set(WorkloadTokenHeader, wTok)
resp, err := hc.Do(req)
if err != nil {
return SignedResult{}, fmt.Errorf("%w: %v", ErrSignerUnavailable, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
switch {
case resp.StatusCode == http.StatusOK:
// fall through
case resp.StatusCode == http.StatusForbidden:
return SignedResult{}, fmt.Errorf("%w: %s", ErrSigningDenied, truncate(raw, 256))
case resp.StatusCode >= 500:
return SignedResult{}, fmt.Errorf("%w: status %d: %s", ErrSignerUnavailable, resp.StatusCode, truncate(raw, 256))
default:
return SignedResult{}, fmt.Errorf("signer: status %d: %s", resp.StatusCode, truncate(raw, 256))
}
var out SignedResult
if err := json.Unmarshal(raw, &out); err != nil {
return SignedResult{}, fmt.Errorf("signer: decode response: %w", err)
}
if out.ContentHash == "" {
return SignedResult{}, errors.New("signer: response missing content_hash")
}
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,85 @@
// Package sink persists verified payloads to local storage. It is the
// trivial "write a file and be done" tail of the Phase-1 trust chain:
// each delivery writes `<DataDir>/<decision_id>.json` atomically via
// `.tmp` + rename so a crash cannot leave a half-written file.
package sink
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
)
// decisionIDPattern is deliberately strict so decision_id can safely be
// pasted into a filesystem path. Approval- and verification-services
// generate decision_id values from UUIDs / sha256 digests, so this covers
// their output and nothing dangerous.
var decisionIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
// Store is a filesystem-backed payload store. All writes land in DataDir.
type Store struct {
DataDir string
}
// WriteResult is the record of an atomic payload write.
type WriteResult struct {
Path string
}
// ErrInvalidDecisionID is returned when decision_id would escape or
// otherwise be unsafe on the filesystem.
var ErrInvalidDecisionID = errors.New("sink: decision_id is not filesystem-safe")
// Write atomically writes payload to <DataDir>/<decisionID>.json.
// It writes to a sibling `<decisionID>.json.tmp` first and renames.
// Repeated calls with the same (decisionID) replace the file; replay
// protection is enforced by the verification-service per the Phase-1
// contract.
func (s Store) Write(decisionID string, payload any) (WriteResult, error) {
if s.DataDir == "" {
return WriteResult{}, errors.New("sink.Store: DataDir is required")
}
if !decisionIDPattern.MatchString(decisionID) {
return WriteResult{}, fmt.Errorf("%w: %q", ErrInvalidDecisionID, decisionID)
}
if err := os.MkdirAll(s.DataDir, 0o755); err != nil {
return WriteResult{}, fmt.Errorf("sink: ensure data dir: %w", err)
}
body, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return WriteResult{}, fmt.Errorf("sink: marshal payload: %w", err)
}
body = append(body, '\n')
final := filepath.Join(s.DataDir, decisionID+".json")
tmp := final + ".tmp"
// Open with O_TRUNC so a crashed previous .tmp cannot taint the write.
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
if err != nil {
return WriteResult{}, fmt.Errorf("sink: open tmp: %w", err)
}
if _, err := f.Write(body); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return WriteResult{}, fmt.Errorf("sink: write tmp: %w", err)
}
if err := f.Sync(); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return WriteResult{}, fmt.Errorf("sink: fsync tmp: %w", err)
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return WriteResult{}, fmt.Errorf("sink: close tmp: %w", err)
}
if err := os.Rename(tmp, final); err != nil {
_ = os.Remove(tmp)
return WriteResult{}, fmt.Errorf("sink: rename to final: %w", err)
}
return WriteResult{Path: final}, nil
}

View file

@ -0,0 +1,84 @@
package sink
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestStoreWriteCreatesAtomicFile(t *testing.T) {
dir := t.TempDir()
s := Store{DataDir: dir}
payload := map[string]any{"workflow_id": "WF-0042", "amount": 42.5}
res, err := s.Write("decision-abc", payload)
if err != nil {
t.Fatalf("Write: %v", err)
}
want := filepath.Join(dir, "decision-abc.json")
if res.Path != want {
t.Fatalf("Path = %q, want %q", res.Path, want)
}
raw, err := os.ReadFile(res.Path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
if got["workflow_id"] != "WF-0042" {
t.Fatalf("workflow_id = %v, want WF-0042", got["workflow_id"])
}
if got["amount"].(float64) != 42.5 {
t.Fatalf("amount = %v, want 42.5", got["amount"])
}
// No lingering .tmp.
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if filepath.Ext(e.Name()) == ".tmp" {
t.Fatalf("left stray tmp file %q", e.Name())
}
}
}
func TestStoreWriteRejectsPathTraversal(t *testing.T) {
s := Store{DataDir: t.TempDir()}
for _, bad := range []string{
"",
"../etc/passwd",
"./x",
"with/slash",
"with:colon-but-starts-with-/",
} {
_, err := s.Write(bad, map[string]any{"a": 1})
if err == nil {
t.Fatalf("Write(%q) = nil err, want rejection", bad)
}
}
}
func TestStoreWriteOverwritesExisting(t *testing.T) {
dir := t.TempDir()
s := Store{DataDir: dir}
if _, err := s.Write("dec1", map[string]any{"v": 1}); err != nil {
t.Fatalf("first Write: %v", err)
}
if _, err := s.Write("dec1", map[string]any{"v": 2}); err != nil {
t.Fatalf("second Write: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "dec1.json"))
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
var got map[string]any
_ = json.Unmarshal(raw, &got)
if got["v"].(float64) != 2 {
t.Fatalf("v = %v, want 2", got["v"])
}
}

View file

@ -0,0 +1,119 @@
// Package tlog submits attestation envelopes to the transparency-log per
// POST /api/v1/log/entries (ADR-0005). The TLog is idempotent on
// content_hash: re-submitting the same (envelope, content_hash) returns
// the original log_index.
package tlog
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"basicstack.de/proofedmesh/services/dummy-sink/internal/auth"
"basicstack.de/proofedmesh/services/dummy-sink/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"`
}
// Receipt is what the TLog returns on a successful submit.
type Receipt struct {
LogIndex int64 `json:"log_index"`
ContentHash string `json:"content_hash"`
LoggedAt time.Time `json:"logged_at"`
InclusionProof InclusionProof `json:"inclusion_proof"`
}
// ErrTLogUnavailable classifies connection errors and 5xx responses.
var ErrTLogUnavailable = errors.New("tlog: transparency-log unavailable")
// Client is the TLog submit client.
type Client struct {
BaseURL string
HTTPClient *http.Client
TokenSource auth.TokenSource
}
type submitRequest struct {
Envelope dsse.Envelope `json:"envelope"`
ContentHash string `json:"content_hash"`
}
// Submit posts env + its contentHash to the TLog. Returns the receipt on
// 200/201 (idempotent repeat / fresh insert) and ErrTLogUnavailable on
// transport/5xx. Other non-2xx responses are reported with the raw status.
func (c Client) Submit(ctx context.Context, env dsse.Envelope, contentHash string) (Receipt, error) {
if c.BaseURL == "" {
return Receipt{}, errors.New("tlog.Client: BaseURL is empty")
}
hc := c.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 10 * time.Second}
}
body, err := json.Marshal(submitRequest{Envelope: env, ContentHash: contentHash})
if err != nil {
return Receipt{}, fmt.Errorf("tlog: marshal submit: %w", err)
}
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/log/entries")
if err != nil {
return Receipt{}, fmt.Errorf("tlog: build url: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return Receipt{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if c.TokenSource != nil {
tok, err := c.TokenSource.Token(ctx)
if err != nil {
return Receipt{}, fmt.Errorf("tlog: token: %w", err)
}
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
}
resp, err := hc.Do(req)
if err != nil {
return Receipt{}, 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, resp.StatusCode == http.StatusCreated:
// ok
case resp.StatusCode >= 500:
return Receipt{}, fmt.Errorf("%w: status %d: %s", ErrTLogUnavailable, resp.StatusCode, truncate(raw, 256))
default:
return Receipt{}, fmt.Errorf("tlog: status %d: %s", resp.StatusCode, truncate(raw, 256))
}
var out Receipt
if err := json.Unmarshal(raw, &out); err != nil {
return Receipt{}, fmt.Errorf("tlog: decode receipt: %w", err)
}
if out.ContentHash != contentHash {
return out, fmt.Errorf("tlog returned content_hash %q != sent %q", out.ContentHash, contentHash)
}
if out.LogIndex < 0 {
return out, fmt.Errorf("tlog returned negative log_index %d", out.LogIndex)
}
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,111 @@
// Package verify calls the verification-service's POST
// /api/v1/verify/chain endpoint. Replay protection for `decision_id` lives
// in the verification-service — the sink does not keep its own replay
// table. See the parent plan subtask 6 for the exact chain checks.
package verify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"basicstack.de/proofedmesh/services/dummy-sink/internal/auth"
)
// ErrVerifyUnavailable classifies connection errors and 5xx responses.
var ErrVerifyUnavailable = errors.New("verify: verification-service unavailable")
// Request is the payload posted to the verification-service.
type Request struct {
DecisionID string `json:"decision_id"`
PayloadHash string `json:"payload_hash"`
ApprovalRef string `json:"approval_ref"`
TransformRef string `json:"transform_ref"`
SinkTarget string `json:"sink_target"`
}
// Result is the verification-service's structured verdict. The field set
// matches the Phase-1 plan: valid, decision_id, actors, reasons, plus a
// dedicated `replay` flag so the sink can map replays to 409.
type Result struct {
Valid bool `json:"valid"`
Replay bool `json:"replay"`
DecisionID string `json:"decision_id"`
Actors []string `json:"actors,omitempty"`
Reasons []string `json:"reasons,omitempty"`
ApprovalRef string `json:"approval_ref,omitempty"`
}
// Client calls POST /api/v1/verify/chain.
type Client struct {
BaseURL string
HTTPClient *http.Client
TokenSource auth.TokenSource
}
// Verify asks the verification-service whether the chain (approval ->
// transform) authorises writing payloadHash to sinkTarget.
func (c Client) Verify(ctx context.Context, req Request) (Result, error) {
if c.BaseURL == "" {
return Result{}, errors.New("verify.Client: BaseURL is empty")
}
hc := c.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 10 * time.Second}
}
body, err := json.Marshal(req)
if err != nil {
return Result{}, fmt.Errorf("verify: marshal: %w", err)
}
endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/verify/chain")
if err != nil {
return Result{}, fmt.Errorf("verify: build url: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return Result{}, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
if c.TokenSource != nil {
tok, err := c.TokenSource.Token(ctx)
if err != nil {
return Result{}, fmt.Errorf("verify: token: %w", err)
}
if tok != "" {
httpReq.Header.Set("Authorization", "Bearer "+tok)
}
}
resp, err := hc.Do(httpReq)
if err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrVerifyUnavailable, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
switch {
case resp.StatusCode == http.StatusOK:
// fall through
case resp.StatusCode >= 500:
return Result{}, fmt.Errorf("%w: status %d: %s", ErrVerifyUnavailable, resp.StatusCode, truncate(raw, 256))
default:
return Result{}, fmt.Errorf("verify: status %d: %s", resp.StatusCode, truncate(raw, 256))
}
var out Result
if err := json.Unmarshal(raw, &out); err != nil {
return Result{}, fmt.Errorf("verify: decode: %w", err)
}
return out, nil
}
func truncate(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}