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
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:
parent
d6efabd96f
commit
b45ad34169
19 changed files with 2154 additions and 0 deletions
4
services/dummy-sink/.dockerignore
Normal file
4
services/dummy-sink/.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
**/*_test.go.bak
|
||||||
|
**/.DS_Store
|
||||||
|
**/*.swp
|
||||||
|
**/*.swo
|
||||||
36
services/dummy-sink/Dockerfile
Normal file
36
services/dummy-sink/Dockerfile
Normal 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"]
|
||||||
151
services/dummy-sink/README.md
Normal file
151
services/dummy-sink/README.md
Normal 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.
|
||||||
171
services/dummy-sink/cmd/dummy-sink/main.go
Normal file
171
services/dummy-sink/cmd/dummy-sink/main.go
Normal 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)
|
||||||
|
}
|
||||||
3
services/dummy-sink/go.mod
Normal file
3
services/dummy-sink/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module basicstack.de/proofedmesh/services/dummy-sink
|
||||||
|
|
||||||
|
go 1.23
|
||||||
165
services/dummy-sink/internal/auth/pocket_id.go
Normal file
165
services/dummy-sink/internal/auth/pocket_id.go
Normal 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
|
||||||
|
}
|
||||||
67
services/dummy-sink/internal/auth/sa_token.go
Normal file
67
services/dummy-sink/internal/auth/sa_token.go
Normal 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()
|
||||||
|
}
|
||||||
33
services/dummy-sink/internal/auth/token_source.go
Normal file
33
services/dummy-sink/internal/auth/token_source.go
Normal 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) }
|
||||||
103
services/dummy-sink/internal/canonical/canonical.go
Normal file
103
services/dummy-sink/internal/canonical/canonical.go
Normal 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
|
||||||
|
}
|
||||||
22
services/dummy-sink/internal/dsse/dsse.go
Normal file
22
services/dummy-sink/internal/dsse/dsse.go
Normal 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"`
|
||||||
|
}
|
||||||
95
services/dummy-sink/internal/handover/builder.go
Normal file
95
services/dummy-sink/internal/handover/builder.go
Normal 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
|
||||||
|
}
|
||||||
69
services/dummy-sink/internal/handover/builder_test.go
Normal file
69
services/dummy-sink/internal/handover/builder_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
286
services/dummy-sink/internal/httpapi/server.go
Normal file
286
services/dummy-sink/internal/httpapi/server.go
Normal 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()
|
||||||
|
}
|
||||||
407
services/dummy-sink/internal/httpapi/server_test.go
Normal file
407
services/dummy-sink/internal/httpapi/server_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
143
services/dummy-sink/internal/signer/client.go
Normal file
143
services/dummy-sink/internal/signer/client.go
Normal 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]) + "…"
|
||||||
|
}
|
||||||
85
services/dummy-sink/internal/sink/storage.go
Normal file
85
services/dummy-sink/internal/sink/storage.go
Normal 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
|
||||||
|
}
|
||||||
84
services/dummy-sink/internal/sink/storage_test.go
Normal file
84
services/dummy-sink/internal/sink/storage_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
119
services/dummy-sink/internal/tlog/client.go
Normal file
119
services/dummy-sink/internal/tlog/client.go
Normal 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]) + "…"
|
||||||
|
}
|
||||||
111
services/dummy-sink/internal/verify/client.go
Normal file
111
services/dummy-sink/internal/verify/client.go
Normal 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]) + "…"
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue