diff --git a/services/dummy-sink/.dockerignore b/services/dummy-sink/.dockerignore new file mode 100644 index 0000000..05b8f29 --- /dev/null +++ b/services/dummy-sink/.dockerignore @@ -0,0 +1,4 @@ +**/*_test.go.bak +**/.DS_Store +**/*.swp +**/*.swo diff --git a/services/dummy-sink/Dockerfile b/services/dummy-sink/Dockerfile new file mode 100644 index 0000000..a21daf3 --- /dev/null +++ b/services/dummy-sink/Dockerfile @@ -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"] diff --git a/services/dummy-sink/README.md b/services/dummy-sink/README.md new file mode 100644 index 0000000..6a4c787 --- /dev/null +++ b/services/dummy-sink/README.md @@ -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 `.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/", + "digest": { "sha256": "<64-hex payload digest>" } + } + ], + "predicateType": "https://basicstack.de/attestations/handover/v1", + "predicate": { + "decision_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:///.json", + "materials": [ + { "name": "approval/", "attestation_ref": "" }, + { "name": "transform/", "attestation_ref": "" } + ], + "actor": "agent-dummy-sink", + "issued_at": "" + } +} +``` + +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. diff --git a/services/dummy-sink/cmd/dummy-sink/main.go b/services/dummy-sink/cmd/dummy-sink/main.go new file mode 100644 index 0000000..a7ba9e6 --- /dev/null +++ b/services/dummy-sink/cmd/dummy-sink/main.go @@ -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 .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) +} diff --git a/services/dummy-sink/go.mod b/services/dummy-sink/go.mod new file mode 100644 index 0000000..539e856 --- /dev/null +++ b/services/dummy-sink/go.mod @@ -0,0 +1,3 @@ +module basicstack.de/proofedmesh/services/dummy-sink + +go 1.23 diff --git a/services/dummy-sink/internal/auth/pocket_id.go b/services/dummy-sink/internal/auth/pocket_id.go new file mode 100644 index 0000000..db59e37 --- /dev/null +++ b/services/dummy-sink/internal/auth/pocket_id.go @@ -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 +} diff --git a/services/dummy-sink/internal/auth/sa_token.go b/services/dummy-sink/internal/auth/sa_token.go new file mode 100644 index 0000000..4ef00c4 --- /dev/null +++ b/services/dummy-sink/internal/auth/sa_token.go @@ -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() +} diff --git a/services/dummy-sink/internal/auth/token_source.go b/services/dummy-sink/internal/auth/token_source.go new file mode 100644 index 0000000..df55a71 --- /dev/null +++ b/services/dummy-sink/internal/auth/token_source.go @@ -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) } diff --git a/services/dummy-sink/internal/canonical/canonical.go b/services/dummy-sink/internal/canonical/canonical.go new file mode 100644 index 0000000..0fe6f7c --- /dev/null +++ b/services/dummy-sink/internal/canonical/canonical.go @@ -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: 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 +} diff --git a/services/dummy-sink/internal/dsse/dsse.go b/services/dummy-sink/internal/dsse/dsse.go new file mode 100644 index 0000000..ad29121 --- /dev/null +++ b/services/dummy-sink/internal/dsse/dsse.go @@ -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"` +} diff --git a/services/dummy-sink/internal/handover/builder.go b/services/dummy-sink/internal/handover/builder.go new file mode 100644 index 0000000..19db5a1 --- /dev/null +++ b/services/dummy-sink/internal/handover/builder.go @@ -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: + ApprovalRef string + TransformRef string + + SinkID string // e.g. "sink:dummy-sink" + SinkRecordRef string // storage locator (e.g. "file:///data/dummy-sink/.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:` 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:") + } + return hash[len(prefix):], nil +} diff --git a/services/dummy-sink/internal/handover/builder_test.go b/services/dummy-sink/internal/handover/builder_test.go new file mode 100644 index 0000000..b0fca52 --- /dev/null +++ b/services/dummy-sink/internal/handover/builder_test.go @@ -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") + } +} diff --git a/services/dummy-sink/internal/httpapi/server.go b/services/dummy-sink/internal/httpapi/server.go new file mode 100644 index 0000000..7aeed20 --- /dev/null +++ b/services/dummy-sink/internal/httpapi/server.go @@ -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 /.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() +} diff --git a/services/dummy-sink/internal/httpapi/server_test.go b/services/dummy-sink/internal/httpapi/server_test.go new file mode 100644 index 0000000..783e8da --- /dev/null +++ b/services/dummy-sink/internal/httpapi/server_test.go @@ -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) + } +} diff --git a/services/dummy-sink/internal/signer/client.go b/services/dummy-sink/internal/signer/client.go new file mode 100644 index 0000000..0dc0494 --- /dev/null +++ b/services/dummy-sink/internal/signer/client.go @@ -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 (OIDC client identity) +// - X-Workload-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]) + "…" +} diff --git a/services/dummy-sink/internal/sink/storage.go b/services/dummy-sink/internal/sink/storage.go new file mode 100644 index 0000000..5e71cc9 --- /dev/null +++ b/services/dummy-sink/internal/sink/storage.go @@ -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 `/.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 /.json. +// It writes to a sibling `.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 +} diff --git a/services/dummy-sink/internal/sink/storage_test.go b/services/dummy-sink/internal/sink/storage_test.go new file mode 100644 index 0000000..8d2c64c --- /dev/null +++ b/services/dummy-sink/internal/sink/storage_test.go @@ -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"]) + } +} diff --git a/services/dummy-sink/internal/tlog/client.go b/services/dummy-sink/internal/tlog/client.go new file mode 100644 index 0000000..c41785a --- /dev/null +++ b/services/dummy-sink/internal/tlog/client.go @@ -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]) + "…" +} diff --git a/services/dummy-sink/internal/verify/client.go b/services/dummy-sink/internal/verify/client.go new file mode 100644 index 0000000..075ba31 --- /dev/null +++ b/services/dummy-sink/internal/verify/client.go @@ -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]) + "…" +}