diff --git a/services/testagent-transform/Dockerfile b/services/testagent-transform/Dockerfile new file mode 100644 index 0000000..c95cba9 --- /dev/null +++ b/services/testagent-transform/Dockerfile @@ -0,0 +1,35 @@ +# 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/testagent-transform/go.mod ./services/testagent-transform/ +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + cd services/testagent-transform && go mod download + +COPY services/testagent-transform/ ./services/testagent-transform/ + +WORKDIR /src/services/testagent-transform +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/testagent-transform ./cmd/testagent-transform + +# ---------- runtime ---------- +# Distroless static+nonroot. The projected ServiceAccount token lives on +# a tmpfs volume mounted by kubelet into /var/run/secrets/tokens/trust-api, +# so the container only needs read access to that path at runtime. +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=build /out/testagent-transform /testagent-transform + +USER 65532:65532 +ENTRYPOINT ["/testagent-transform"] diff --git a/services/testagent-transform/README.md b/services/testagent-transform/README.md new file mode 100644 index 0000000..56acb72 --- /dev/null +++ b/services/testagent-transform/README.md @@ -0,0 +1,105 @@ +# testagent-transform + +Phase-1 end-to-end demonstrator of the ProofedMesh trust chain's **Transform** +step ([DEV-610](/DEV/issues/DEV-610), parent +[DEV-606](/DEV/issues/DEV-606)). It is a one-shot CLI, not an HTTP service: +given an approval attestation and a `handover_payload`, it produces a signed +transform attestation, logs it to the transparency-log, and hands the payload +to the Phase-1 dummy-sink. The exit code is 0 on success. + +## Flow + +1. **Fetch approval.** `GET {TRANSFORM_TLOG_URL}/api/v1/log/entries/{approval_ref}`. + The TLog returns the stored DSSE envelope plus `log_index`. +2. **Verify.** The returned envelope is DSSE-verified against the pinned + approval-signer keyring (`APPROVAL_SIGNER_KEYS`). On failure the agent + stops before any signing call — nothing new is attested on top of an + untrusted approval. +3. **Build transform statement.** in-toto v1 Statement with + `predicateType = https://basicstack.de/attestations/transform/v1`, + `operation = expense.map_to_sink`, `materials[0].attestation_ref = + approval_ref`, and `subject[0].digest.sha256 = sha256(canonical(handover_payload))`. + `canonical` here is byte-identical to the approval-service's canonical + serialisation so a downstream verifier reproduces the same hash. +4. **Sign.** `POST {TRANSFORM_SIGNING_URL}/api/v1/sign/dsse` with BOTH + identities set as headers: + - `Authorization: Bearer ` (`client_credentials` + grant against `POCKET_ID_ISSUER`) + - `X-Workload-Token: ` (read from + `TRANSFORM_WORKLOAD_TOKEN_PATH`, default + `/var/run/secrets/tokens/trust-api`) + The signing-service's structured policy evaluates BOTH identities against + the agent registry and OPA; a Pocket-ID bearer alone is not enough. See + `services/signing-service/internal/httpapi/sign.go`. +5. **Log.** `POST {TRANSFORM_TLOG_URL}/api/v1/log/entries` with the + envelope + `content_hash` from step 4. +6. **Handover.** `POST {TRANSFORM_SINK_URL}/api/v1/handover` with the + canonical `handover_payload` bytes. Headers set: + - `X-Payload-Schema: sink.dummy.v1` + - `X-Attestation-Ref: ` + +## Non-scope + +- No read-side attestations (Phase 2). +- No persistent store; the agent is stateless and idempotent given the same + `(approval_ref, handover_payload)` input (the TLog deduplicates on + `content_hash`). + +## Configuration + +| Env var | Default | Purpose | +| ------------------------------------ | ------------------------------------------------------------- | ---------------------------------------------------------- | +| `TRANSFORM_TLOG_URL` | *(required)* | Base URL of transparency-log. | +| `TRANSFORM_SIGNING_URL` | *(required)* | Base URL of signing-service. | +| `TRANSFORM_SINK_URL` | *(required)* | Base URL of dummy-sink. | +| `TRANSFORM_WORKLOAD_TOKEN_PATH` | `/var/run/secrets/tokens/trust-api` | Projected ServiceAccount token path. | +| `POCKET_ID_ISSUER` | *(required)* | Pocket-ID OIDC issuer URL. | +| `POCKET_ID_CLIENT_ID` | *(required)* | OIDC client id for this agent. | +| `POCKET_ID_CLIENT_SECRET` | *(required)* | OIDC client secret for this agent. | +| `POCKET_ID_AUDIENCE` | `trust-api` | OIDC token audience. | +| `APPROVAL_SIGNER_KEYS` | *(required)* | Comma-separated `kid=base64(public-ed25519)` entries. | +| `TRANSFORM_ACTOR` | `agent-testagent-transform` | Registry id written into the attestation. | +| `TRANSFORM_OPERATION` | `expense.map_to_sink` | Transform operation name (must match OPA policy). | +| `TRANSFORM_SINK_TARGET` | `sink:dummy-sink` | Logical sink identifier. | +| `TRANSFORM_SIGNING_KEY_REF` | `trust-api/agent-testagent-transform-2026-q4` | OpenBao Transit key ref for the transform signer. | +| `TRANSFORM_INPUT_SCHEMA` / `_VERSION` | `expense.canonical` / `1.2.0` | Input schema name/version carried in the attestation. | +| `TRANSFORM_OUTPUT_SCHEMA` / `_VERSION` | `sink.db.expense` / `1.2.0` | Output schema name/version carried in the attestation. | +| `TRANSFORM_VERSION` | `0.1.0` | Agent's own version string, carried in the attestation. | +| `TRANSFORM_SINK_PAYLOAD_SCHEMA` | `sink.dummy.v1` | Value sent on the sink's `X-Payload-Schema` header. | + +## CLI + +``` +testagent-transform \ + -workflow WF-0042 \ + -approval-ref sha256:... \ + -handover ./handover.json +``` + +`handover.json` must be a JSON object; the agent canonicalises it before +hashing + sending to the sink. + +## Tests + +``` +cd services/testagent-transform +go vet ./... +go test ./... +``` + +The end-to-end `transform` tests spin up four `httptest` servers — approval +read, signing-service, transparency-log, dummy-sink — and run the full +pipeline against them: + +- **Happy path.** All four backends respond; the subject digest the agent + signs equals the sha256 of the canonical bytes POSTed to the sink; the + TLog's recorded content_hash equals the agent's returned `TransformRef`. +- **Invalid approval signature.** The approval mock returns a DSSE envelope + whose Ed25519 signature does not verify against the pinned public key. + The agent fails with `ErrInvalidApproval` and never calls the + signing-service, TLog, or sink. +- **Signing denied.** The signing-service answers `403 signing_denied`. + The agent surfaces `signer.ErrSigningDenied` and never calls the TLog or + sink. +- **Missing Pocket-ID token.** The TokenSource returns `""`. The signer + client rejects before any network traffic. diff --git a/services/testagent-transform/cmd/testagent-transform/main.go b/services/testagent-transform/cmd/testagent-transform/main.go new file mode 100644 index 0000000..4691eb6 --- /dev/null +++ b/services/testagent-transform/cmd/testagent-transform/main.go @@ -0,0 +1,227 @@ +// Command testagent-transform is the Phase-1 end-to-end demonstrator of +// the ProofedMesh trust chain's Transform step. It is intentionally a +// one-shot CLI: given an approval attestation_ref + a handover_payload, +// it fetches/verifies the approval, builds a transform attestation, +// signs it against the signing-service with BOTH identities, logs the +// envelope to the transparency-log, and POSTs the handover_payload bytes +// to the dummy-sink. The exit code is 0 on success, non-zero on any error. +// +// See services/testagent-transform/README.md for how env vars map to the +// Runner wiring and how the agent reads its two identities. +package main + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "basicstack.de/proofedmesh/services/testagent-transform/internal/approval" + "basicstack.de/proofedmesh/services/testagent-transform/internal/auth" + "basicstack.de/proofedmesh/services/testagent-transform/internal/dsse" + "basicstack.de/proofedmesh/services/testagent-transform/internal/signer" + "basicstack.de/proofedmesh/services/testagent-transform/internal/sink" + "basicstack.de/proofedmesh/services/testagent-transform/internal/tlog" + "basicstack.de/proofedmesh/services/testagent-transform/internal/transform" +) + +func main() { + if err := run(context.Background(), os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "testagent-transform:", err) + os.Exit(1) + } +} + +func run(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("testagent-transform", flag.ContinueOnError) + var ( + workflowID = fs.String("workflow", "", "workflow id to tag on the transform attestation") + approvalRef = fs.String("approval-ref", "", "sha256: content_hash of the approval attestation to fetch") + handoverPath = fs.String("handover", "", "path to the handover_payload JSON file") + approvalKeysEnv = fs.String("approval-keys-env", "APPROVAL_SIGNER_KEYS", "env var listing approval signer keys as kid=base64(public)") + ) + if err := fs.Parse(args); err != nil { + return err + } + + cfg, err := loadConfig(*approvalKeysEnv) + if err != nil { + return err + } + if *workflowID == "" || *approvalRef == "" || *handoverPath == "" { + return errors.New("-workflow, -approval-ref, and -handover are required") + } + payload, err := loadHandoverPayload(*handoverPath) + if err != nil { + return err + } + + runner := &transform.Runner{ + Config: cfg.transformCfg, + Approvals: approval.Client{ + BaseURL: cfg.tlogURL, + }, + ApprovalKeyring: cfg.approvalKeyring, + Signer: signer.Client{ + BaseURL: cfg.signingURL, + PocketIDToken: cfg.pocketIDSource, + WorkloadToken: cfg.workloadSource, + }, + TLog: tlog.Client{BaseURL: cfg.tlogURL}, + Sink: sink.Client{BaseURL: cfg.sinkURL}, + Now: time.Now, + } + + res, err := runner.Execute(ctx, transform.Request{ + WorkflowID: *workflowID, + ApprovalRef: *approvalRef, + HandoverPayload: payload, + }) + if err != nil { + return err + } + slog.Info("transform complete", + "workflow_id", *workflowID, + "approval_ref", *approvalRef, + "transform_ref", res.TransformRef, + "log_index", res.LogIndex, + "sink_status", res.SinkStatus, + "handover_sha256", res.HandoverHashHex, + ) + fmt.Fprintln(os.Stdout, res.TransformRef) + return nil +} + +type runtimeConfig struct { + tlogURL string + signingURL string + sinkURL string + pocketIDSource auth.TokenSource + workloadSource auth.TokenSource + approvalKeyring dsse.Keyring + transformCfg transform.Config +} + +func loadConfig(keysEnv string) (runtimeConfig, error) { + var cfg runtimeConfig + cfg.tlogURL = envRequired("TRANSFORM_TLOG_URL") + cfg.signingURL = envRequired("TRANSFORM_SIGNING_URL") + cfg.sinkURL = envRequired("TRANSFORM_SINK_URL") + + if cfg.tlogURL == "" || cfg.signingURL == "" || cfg.sinkURL == "" { + return cfg, errors.New("TRANSFORM_TLOG_URL, TRANSFORM_SIGNING_URL, TRANSFORM_SINK_URL are required") + } + + issuer := envRequired("POCKET_ID_ISSUER") + clientID := envRequired("POCKET_ID_CLIENT_ID") + clientSecret := envRequired("POCKET_ID_CLIENT_SECRET") + if issuer == "" || clientID == "" || clientSecret == "" { + return cfg, errors.New("POCKET_ID_ISSUER, POCKET_ID_CLIENT_ID, POCKET_ID_CLIENT_SECRET are required") + } + cfg.pocketIDSource = &auth.PocketIDTokenSource{ + Issuer: issuer, + ClientID: clientID, + ClientSecret: clientSecret, + Audience: envDefault("POCKET_ID_AUDIENCE", "trust-api"), + } + + saPath := envDefault("TRANSFORM_WORKLOAD_TOKEN_PATH", "/var/run/secrets/tokens/trust-api") + cfg.workloadSource = &auth.ProjectedSATokenSource{Path: saPath} + + kr, err := loadKeyring(keysEnv) + if err != nil { + return cfg, err + } + if len(kr) == 0 { + return cfg, fmt.Errorf("%s must list at least one approval signer key", keysEnv) + } + cfg.approvalKeyring = kr + + cfg.transformCfg = transform.Config{ + Actor: envDefault("TRANSFORM_ACTOR", "agent-testagent-transform"), + Operation: envDefault("TRANSFORM_OPERATION", "expense.map_to_sink"), + SinkTarget: envDefault("TRANSFORM_SINK_TARGET", "sink:dummy-sink"), + SigningKeyRef: envDefault("TRANSFORM_SIGNING_KEY_REF", "trust-api/agent-testagent-transform-2026-q4"), + Input: transform.SchemaRef{Name: envDefault("TRANSFORM_INPUT_SCHEMA", "expense.canonical"), Version: envDefault("TRANSFORM_INPUT_VERSION", "1.2.0")}, + Output: transform.SchemaRef{Name: envDefault("TRANSFORM_OUTPUT_SCHEMA", "sink.db.expense"), Version: envDefault("TRANSFORM_OUTPUT_VERSION", "1.2.0")}, + TransformVersion: envDefault("TRANSFORM_VERSION", "0.1.0"), + SinkPayloadSchema: envDefault("TRANSFORM_SINK_PAYLOAD_SCHEMA", "sink.dummy.v1"), + } + return cfg, nil +} + +func loadHandoverPayload(path string) (map[string]any, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read handover payload %q: %w", path, err) + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("decode handover payload %q: %w", path, err) + } + return out, nil +} + +func loadKeyring(envName string) (dsse.Keyring, error) { + raw := strings.TrimSpace(os.Getenv(envName)) + if raw == "" { + return nil, nil + } + kr := dsse.Keyring{} + for _, entry := range strings.Split(raw, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + eq := strings.IndexByte(entry, '=') + if eq <= 0 { + return nil, fmt.Errorf("%s: entry %q must be kid=base64(public-key)", envName, entry) + } + kid := entry[:eq] + pubEnc := entry[eq+1:] + pubRaw, err := decodePubKey(pubEnc) + if err != nil { + return nil, fmt.Errorf("%s: kid %q: %w", envName, kid, err) + } + if len(pubRaw) != ed25519.PublicKeySize { + return nil, fmt.Errorf("%s: kid %q: expected %d-byte ed25519 public key, got %d", envName, kid, ed25519.PublicKeySize, len(pubRaw)) + } + kr[kid] = ed25519.PublicKey(pubRaw) + } + return kr, nil +} + +// decodePubKey accepts base64url (no padding) or hex. +func decodePubKey(s string) ([]byte, error) { + s = strings.TrimSpace(s) + if b, err := base64.RawURLEncoding.DecodeString(s); err == nil && len(b) == ed25519.PublicKeySize { + return b, nil + } + if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == ed25519.PublicKeySize { + return b, nil + } + if b, err := hex.DecodeString(s); err == nil && len(b) == ed25519.PublicKeySize { + return b, nil + } + return nil, errors.New("public key is not base64url/base64/hex of a 32-byte ed25519 key") +} + +func envRequired(name string) string { + return strings.TrimSpace(os.Getenv(name)) +} + +func envDefault(name, def string) string { + v := strings.TrimSpace(os.Getenv(name)) + if v == "" { + return def + } + return v +} diff --git a/services/testagent-transform/go.mod b/services/testagent-transform/go.mod new file mode 100644 index 0000000..1fff89b --- /dev/null +++ b/services/testagent-transform/go.mod @@ -0,0 +1,3 @@ +module basicstack.de/proofedmesh/services/testagent-transform + +go 1.23 diff --git a/services/testagent-transform/internal/approval/client.go b/services/testagent-transform/internal/approval/client.go new file mode 100644 index 0000000..efd74e3 --- /dev/null +++ b/services/testagent-transform/internal/approval/client.go @@ -0,0 +1,119 @@ +// Package approval retrieves an approval attestation from the transparency +// log by its content_hash (`attestation_ref`). +// +// The TLog's write path (POST /api/v1/log/entries) is defined in +// docs/adr/0005-signing-service-and-transparency-log-api.md. The read path +// is this agent's companion: GET /api/v1/log/entries/{content_hash}, which +// returns the originally-submitted DSSE envelope plus its log metadata so +// a downstream verifier can replay the signature check without trusting +// the TLog's own storage of the plaintext. +// +// Phase-1 scope: the agent assumes the TLog exposes this GET endpoint. +// For CI/dev the end-to-end test wires an httptest handler of the exact +// same shape. +package approval + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "basicstack.de/proofedmesh/services/testagent-transform/internal/auth" + "basicstack.de/proofedmesh/services/testagent-transform/internal/dsse" +) + +// Entry is the response the testagent-transform expects from +// GET /api/v1/log/entries/{content_hash}. +type Entry struct { + ContentHash string `json:"content_hash"` + Envelope dsse.Envelope `json:"envelope"` + LogIndex int64 `json:"log_index"` + LoggedAt time.Time `json:"logged_at"` +} + +// ErrNotFound is returned on a 404 from the TLog read endpoint. +var ErrNotFound = errors.New("approval: attestation_ref not in transparency-log") + +// ErrTLogUnavailable classifies connection errors and 5xx responses. +var ErrTLogUnavailable = errors.New("approval: transparency-log unavailable") + +// Client is the TLog read client. +type Client struct { + BaseURL string + HTTPClient *http.Client + TokenSource auth.TokenSource +} + +// Fetch retrieves the DSSE envelope and log metadata for attestationRef. +// attestationRef must be `sha256:<64 hex>` (the stable content_hash). +func (c Client) Fetch(ctx context.Context, attestationRef string) (Entry, error) { + if c.BaseURL == "" { + return Entry{}, errors.New("approval.Client: BaseURL is empty") + } + ref := strings.TrimSpace(attestationRef) + if ref == "" { + return Entry{}, errors.New("approval.Client: attestation_ref is empty") + } + hc := c.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: 10 * time.Second} + } + endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/log/entries/", url.PathEscape(ref)) + if err != nil { + return Entry{}, fmt.Errorf("approval: build url: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return Entry{}, err + } + req.Header.Set("Accept", "application/json") + if c.TokenSource != nil { + tok, err := c.TokenSource.Token(ctx) + if err != nil { + return Entry{}, fmt.Errorf("approval token: %w", err) + } + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + } + resp, err := hc.Do(req) + if err != nil { + return Entry{}, fmt.Errorf("%w: %v", ErrTLogUnavailable, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + switch { + case resp.StatusCode == http.StatusOK: + // fall through + case resp.StatusCode == http.StatusNotFound: + return Entry{}, ErrNotFound + case resp.StatusCode >= 500: + return Entry{}, fmt.Errorf("%w: status %d: %s", ErrTLogUnavailable, resp.StatusCode, truncate(raw, 256)) + default: + return Entry{}, fmt.Errorf("approval: status %d: %s", resp.StatusCode, truncate(raw, 256)) + } + var out Entry + if err := json.Unmarshal(raw, &out); err != nil { + return Entry{}, fmt.Errorf("approval: decode response: %w", err) + } + if out.ContentHash == "" { + return Entry{}, errors.New("approval: response missing content_hash") + } + if out.ContentHash != ref { + return Entry{}, fmt.Errorf("approval: TLog returned content_hash %q != requested %q", out.ContentHash, ref) + } + return out, nil +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} diff --git a/services/testagent-transform/internal/approval/client_test.go b/services/testagent-transform/internal/approval/client_test.go new file mode 100644 index 0000000..2e307e4 --- /dev/null +++ b/services/testagent-transform/internal/approval/client_test.go @@ -0,0 +1,84 @@ +package approval + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "basicstack.de/proofedmesh/services/testagent-transform/internal/dsse" +) + +func TestFetch_HappyPath(t *testing.T) { + ref := "sha256:" + "aa" + stringRepeat("b", 62) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/log/entries/"+ref { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(Entry{ + ContentHash: ref, + Envelope: dsse.Envelope{PayloadType: "x", Payload: "aGk"}, + LogIndex: 99, + LoggedAt: time.Now(), + }) + })) + defer srv.Close() + c := Client{BaseURL: srv.URL} + e, err := c.Fetch(context.Background(), ref) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if e.LogIndex != 99 || e.ContentHash != ref { + t.Fatalf("entry = %+v", e) + } +} + +func TestFetch_404MapsToNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.NotFound(w, nil) + })) + defer srv.Close() + c := Client{BaseURL: srv.URL} + _, err := c.Fetch(context.Background(), "sha256:x") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("want ErrNotFound, got %v", err) + } +} + +func TestFetch_5xxMapsToUnavailable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + c := Client{BaseURL: srv.URL} + _, err := c.Fetch(context.Background(), "sha256:x") + if !errors.Is(err, ErrTLogUnavailable) { + t.Fatalf("want ErrTLogUnavailable, got %v", err) + } +} + +func TestFetch_ContentHashMismatchIsReported(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(Entry{ContentHash: "sha256:other", Envelope: dsse.Envelope{}}) + })) + defer srv.Close() + c := Client{BaseURL: srv.URL} + _, err := c.Fetch(context.Background(), "sha256:expected") + if err == nil { + t.Fatal("expected error on content_hash mismatch") + } +} + +func stringRepeat(s string, n int) string { + out := make([]byte, 0, len(s)*n) + for i := 0; i < n; i++ { + out = append(out, s...) + } + return string(out) +} diff --git a/services/testagent-transform/internal/auth/pocket_id.go b/services/testagent-transform/internal/auth/pocket_id.go new file mode 100644 index 0000000..1a87e40 --- /dev/null +++ b/services/testagent-transform/internal/auth/pocket_id.go @@ -0,0 +1,178 @@ +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. +// +// Mirrors services/admin-api/internal/attest/pocket_id_token_source.go in +// shape so the signing-service's Pocket-ID validator sees the same token +// format admin-api and approval-service already send. +type PocketIDTokenSource struct { + // Issuer is the Pocket-ID base URL, e.g. "https://auth.basicstack.de". + // Trailing slash optional. + Issuer string + // ClientID / ClientSecret identify the testagent-transform's Pocket-ID + // OIDC client. Sourced from env (POCKET_ID_CLIENT_ID / + // POCKET_ID_CLIENT_SECRET) in production so they do not land in + // process listings. + ClientID string + ClientSecret string + // Scope / Audience are optional OIDC parameters; empty when the + // server-side policy does not require them. + Scope string + Audience string + // Client overrides the default HTTP client. Default: 10s timeout. + Client *http.Client + // RefreshLeeway is the window before token expiry at which the cache + // is considered invalid. Zero defaults to 30s. + RefreshLeeway time.Duration + // Now is injected for deterministic tests. Zero defaults to time.Now. + Now func() time.Time + + mu sync.Mutex + tokenEndpoint string + cachedToken string + cachedExpiry time.Time +} + +type pocketIDTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +type pocketIDDiscoveryDoc struct { + TokenEndpoint string `json:"token_endpoint"` +} + +// Token returns a cached access token when still valid, or fetches a fresh +// one from Pocket-ID. +func (p *PocketIDTokenSource) Token(ctx context.Context) (string, error) { + if p == nil { + return "", errors.New("auth.PocketIDTokenSource: nil receiver") + } + if p.Issuer == "" { + return "", errors.New("auth.PocketIDTokenSource: Issuer is required") + } + if p.ClientID == "" || p.ClientSecret == "" { + return "", errors.New("auth.PocketIDTokenSource: ClientID and ClientSecret are required") + } + + p.mu.Lock() + defer p.mu.Unlock() + + leeway := p.RefreshLeeway + if leeway <= 0 { + leeway = 30 * time.Second + } + now := p.nowLocked() + if p.cachedToken != "" && now.Add(leeway).Before(p.cachedExpiry) { + return p.cachedToken, nil + } + + if p.tokenEndpoint == "" { + if err := p.discoverLocked(ctx); err != nil { + return "", err + } + } + + tok, expiry, err := p.fetchLocked(ctx, now) + if err != nil { + return "", err + } + p.cachedToken = tok + p.cachedExpiry = expiry + return tok, nil +} + +func (p *PocketIDTokenSource) nowLocked() time.Time { + if p.Now != nil { + return p.Now() + } + return time.Now() +} + +func (p *PocketIDTokenSource) httpClient() *http.Client { + if p.Client != nil { + return p.Client + } + return &http.Client{Timeout: 10 * time.Second} +} + +func (p *PocketIDTokenSource) discoverLocked(ctx context.Context) error { + base := strings.TrimRight(p.Issuer, "/") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/.well-known/openid-configuration", nil) + if err != nil { + return fmt.Errorf("pocket id discovery: %w", err) + } + req.Header.Set("Accept", "application/json") + resp, err := p.httpClient().Do(req) + if err != nil { + return fmt.Errorf("pocket id discovery: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("pocket id discovery: status %d: %s", resp.StatusCode, string(raw)) + } + var doc pocketIDDiscoveryDoc + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("pocket id discovery: decode: %w", err) + } + if doc.TokenEndpoint == "" { + return errors.New("pocket id discovery: token_endpoint missing from OIDC configuration") + } + p.tokenEndpoint = doc.TokenEndpoint + return nil +} + +func (p *PocketIDTokenSource) fetchLocked(ctx context.Context, now time.Time) (string, time.Time, error) { + form := url.Values{} + form.Set("grant_type", "client_credentials") + if p.Scope != "" { + form.Set("scope", p.Scope) + } + if p.Audience != "" { + form.Set("audience", p.Audience) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", time.Time{}, fmt.Errorf("pocket id token: %w", err) + } + req.SetBasicAuth(p.ClientID, p.ClientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := p.httpClient().Do(req) + if err != nil { + return "", time.Time{}, fmt.Errorf("pocket id token: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return "", time.Time{}, fmt.Errorf("pocket id token: status %d: %s", resp.StatusCode, string(raw)) + } + var out pocketIDTokenResponse + if err := json.Unmarshal(raw, &out); err != nil { + return "", time.Time{}, fmt.Errorf("pocket id token: decode: %w", err) + } + if out.AccessToken == "" { + return "", time.Time{}, errors.New("pocket id token: response missing access_token") + } + if out.ExpiresIn <= 0 { + return "", time.Time{}, errors.New("pocket id token: response has non-positive expires_in") + } + return out.AccessToken, now.Add(time.Duration(out.ExpiresIn) * time.Second), nil +} diff --git a/services/testagent-transform/internal/auth/sa_token.go b/services/testagent-transform/internal/auth/sa_token.go new file mode 100644 index 0000000..a165ee4 --- /dev/null +++ b/services/testagent-transform/internal/auth/sa_token.go @@ -0,0 +1,80 @@ +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. The cache is bypassed by setting +// CacheTTL <= 0. +type ProjectedSATokenSource struct { + // Path is the absolute path to the projected token file. Required. + // The Volume is typically defined with: + // projected: + // sources: + // - serviceAccountToken: + // path: trust-api + // audience: trust-api + // expirationSeconds: 3600 + Path string + // CacheTTL bounds how long an already-read token value is reused + // without re-reading the file. Zero defaults to 30 seconds. Negative + // disables caching. + CacheTTL time.Duration + // Now is injected for deterministic tests. Zero defaults to time.Now. + Now func() time.Time + + mu sync.Mutex + cachedToken string + cachedAt time.Time +} + +// Token returns the current projected ServiceAccount token. +func (p *ProjectedSATokenSource) Token(_ context.Context) (string, error) { + if p == nil { + return "", fmt.Errorf("auth.ProjectedSATokenSource: nil receiver") + } + if p.Path == "" { + return "", fmt.Errorf("auth.ProjectedSATokenSource: Path is required") + } + p.mu.Lock() + defer p.mu.Unlock() + + ttl := p.CacheTTL + if ttl == 0 { + ttl = 30 * time.Second + } + now := p.now() + if ttl > 0 && p.cachedToken != "" && now.Sub(p.cachedAt) < ttl { + return p.cachedToken, nil + } + raw, err := os.ReadFile(p.Path) + if err != nil { + return "", fmt.Errorf("read projected SA token %q: %w", p.Path, err) + } + tok := strings.TrimSpace(string(raw)) + if tok == "" { + return "", fmt.Errorf("projected SA token at %q is empty", p.Path) + } + p.cachedToken = tok + p.cachedAt = now + return tok, nil +} + +func (p *ProjectedSATokenSource) now() time.Time { + if p.Now != nil { + return p.Now() + } + return time.Now() +} diff --git a/services/testagent-transform/internal/auth/sa_token_test.go b/services/testagent-transform/internal/auth/sa_token_test.go new file mode 100644 index 0000000..56ca153 --- /dev/null +++ b/services/testagent-transform/internal/auth/sa_token_test.go @@ -0,0 +1,84 @@ +package auth + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestProjectedSATokenSource_ReadsTokenFromFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "trust-api") + if err := os.WriteFile(path, []byte("tok-abc\n"), 0o600); err != nil { + t.Fatal(err) + } + src := &ProjectedSATokenSource{Path: path} + tok, err := src.Token(context.Background()) + if err != nil { + t.Fatal(err) + } + if tok != "tok-abc" { + t.Fatalf("tok = %q", tok) + } +} + +func TestProjectedSATokenSource_CachesWithinTTL(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "trust-api") + if err := os.WriteFile(path, []byte("v1"), 0o600); err != nil { + t.Fatal(err) + } + start := time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC) + nowOffset := time.Duration(0) + src := &ProjectedSATokenSource{ + Path: path, + CacheTTL: time.Second, + Now: func() time.Time { return start.Add(nowOffset) }, + } + first, err := src.Token(context.Background()) + if err != nil { + t.Fatal(err) + } + // Overwrite the file; cache should hide the new value. + if err := os.WriteFile(path, []byte("v2"), 0o600); err != nil { + t.Fatal(err) + } + nowOffset = 500 * time.Millisecond + second, err := src.Token(context.Background()) + if err != nil { + t.Fatal(err) + } + if first != "v1" || second != "v1" { + t.Fatalf("cache missed: first=%q second=%q", first, second) + } + // Past TTL: must re-read. + nowOffset = 2 * time.Second + third, err := src.Token(context.Background()) + if err != nil { + t.Fatal(err) + } + if third != "v2" { + t.Fatalf("expected v2 after TTL expiry, got %q", third) + } +} + +func TestProjectedSATokenSource_EmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "trust-api") + if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + src := &ProjectedSATokenSource{Path: path} + if _, err := src.Token(context.Background()); err == nil { + t.Fatal("expected error on empty token") + } +} + +func TestProjectedSATokenSource_MissingFile(t *testing.T) { + src := &ProjectedSATokenSource{Path: "/nonexistent/trust-api"} + if _, err := src.Token(context.Background()); err == nil { + t.Fatal("expected error on missing token file") + } +} diff --git a/services/testagent-transform/internal/auth/token_source.go b/services/testagent-transform/internal/auth/token_source.go new file mode 100644 index 0000000..522416b --- /dev/null +++ b/services/testagent-transform/internal/auth/token_source.go @@ -0,0 +1,30 @@ +// Package auth exposes the two identity sources the testagent-transform +// 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 as the same TokenSource interface so the orchestration +// code never cares about the identity's origin. +package auth + +import "context" + +// TokenSource returns a bearer-style token. The caller is expected to +// retry on error; implementations may cache internally. +type TokenSource interface { + Token(ctx context.Context) (string, error) +} + +// StaticToken is a constant TokenSource. Primarily used in tests. +type StaticToken string + +// Token returns s. +func (s StaticToken) Token(_ context.Context) (string, error) { return string(s), nil } + +// TokenFunc adapts a plain function to TokenSource. +type TokenFunc func(ctx context.Context) (string, error) + +// Token calls f. +func (f TokenFunc) Token(ctx context.Context) (string, error) { return f(ctx) } diff --git a/services/testagent-transform/internal/canonical/canonical.go b/services/testagent-transform/internal/canonical/canonical.go new file mode 100644 index 0000000..babbd8d --- /dev/null +++ b/services/testagent-transform/internal/canonical/canonical.go @@ -0,0 +1,102 @@ +// Package canonical implements the deterministic JSON serialisation the +// testagent-transform uses to compute the handover_payload content hash. +// +// MUST stay byte-for-byte compatible with +// services/approval-service/internal/canonical/canonical.go and +// services/signing-service/internal/canonical/canonical.go so a subject +// digest signed here reproduces identically on the verifier side. +package canonical + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" +) + +// SHA256Hex returns the hex-encoded SHA-256 of b. +func SHA256Hex(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// Marshal returns the canonical JSON encoding of v (sorted object keys). +func Marshal(v any) ([]byte, error) { + first, err := json.Marshal(v) + if err != nil { + return nil, err + } + var any_ any + if err := json.Unmarshal(first, &any_); err != nil { + return nil, err + } + var buf bytes.Buffer + if err := writeSorted(&buf, any_); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// Hash returns the sha256: content hash of v's canonical JSON along +// with the raw canonical bytes. +func Hash(v any) (string, []byte, error) { + raw, err := Marshal(v) + if err != nil { + return "", nil, err + } + return "sha256:" + SHA256Hex(raw), raw, nil +} + +func writeSorted(buf *bytes.Buffer, v any) error { + switch x := v.(type) { + case nil: + buf.WriteString("null") + case bool: + if x { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case float64: + b, _ := json.Marshal(x) + buf.Write(b) + case string: + b, _ := json.Marshal(x) + buf.Write(b) + case []any: + buf.WriteByte('[') + for i, el := range x { + if i > 0 { + buf.WriteByte(',') + } + if err := writeSorted(buf, el); err != nil { + return err + } + } + buf.WriteByte(']') + case map[string]any: + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + kb, _ := json.Marshal(k) + buf.Write(kb) + buf.WriteByte(':') + if err := writeSorted(buf, x[k]); err != nil { + return err + } + } + buf.WriteByte('}') + default: + return fmt.Errorf("canonical.writeSorted: unsupported type %T", v) + } + return nil +} diff --git a/services/testagent-transform/internal/dsse/dsse.go b/services/testagent-transform/internal/dsse/dsse.go new file mode 100644 index 0000000..7917cff --- /dev/null +++ b/services/testagent-transform/internal/dsse/dsse.go @@ -0,0 +1,99 @@ +// Package dsse models the DSSE envelope shape the signing-service produces +// and verifies an envelope's Ed25519 signature using the DSSEv1 Pre- +// Authentication Encoding (PAE) from the DSSE spec. +// +// The envelope shape mirrors +// services/approval-service/internal/signer.Envelope so an approval fetched +// from the transparency-log round-trips through this package byte-for-byte. +package dsse + +import ( + "crypto/ed25519" + "encoding/base64" + "errors" + "fmt" +) + +// EnvelopeSignature mirrors the DSSE spec. +type EnvelopeSignature struct { + KeyID string `json:"keyid"` + Sig string `json:"sig"` +} + +// Envelope is the DSSE envelope the signing-service returns. Payload is +// base64url (no padding) of the canonical in-toto Statement bytes. +type Envelope struct { + PayloadType string `json:"payloadType"` + Payload string `json:"payload"` + Signatures []EnvelopeSignature `json:"signatures"` +} + +// Keyring maps keyid (as embedded in the envelope signature) to the +// Ed25519 public key that signed it. The testagent-transform ships a +// pinned keyring for the approval-signer key; a Phase-2 verifier would +// pull the key material from the Agent Registry (`agent_keys` table). +type Keyring map[string]ed25519.PublicKey + +// ErrNoSignatures is returned when the envelope has no signatures to try. +var ErrNoSignatures = errors.New("dsse: envelope has no signatures") + +// ErrUnknownKey is returned when none of the envelope's signatures has a +// keyid that is present in the verifier's keyring. +var ErrUnknownKey = errors.New("dsse: no signature keyid is in the keyring") + +// ErrSignatureInvalid is returned when at least one keyring-matching +// signature was tried and none verified. +var ErrSignatureInvalid = errors.New("dsse: no signature verified against its keyring entry") + +// Verify checks that at least one signature on env matches a public key in +// kr. The payload is base64url-decoded, the DSSEv1 PAE is reconstructed, +// and ed25519.Verify is applied. On success the decoded payload bytes are +// returned so the caller can parse the in-toto Statement. +// +// Returns ErrUnknownKey when the envelope has signatures but none list a +// keyid that is in the keyring (so no verification was even attempted), +// and ErrSignatureInvalid when attempts ran but all failed. +func Verify(env Envelope, kr Keyring) ([]byte, error) { + if len(env.Signatures) == 0 { + return nil, ErrNoSignatures + } + payload, err := base64.RawURLEncoding.DecodeString(env.Payload) + if err != nil { + return nil, fmt.Errorf("dsse: decode payload: %w", err) + } + pae := pae(env.PayloadType, payload) + + tried := false + for _, sig := range env.Signatures { + pub, ok := kr[sig.KeyID] + if !ok { + continue + } + tried = true + raw, err := base64.RawURLEncoding.DecodeString(sig.Sig) + if err != nil { + continue + } + if ed25519.Verify(pub, pae, raw) { + return payload, nil + } + } + if !tried { + return nil, ErrUnknownKey + } + return nil, ErrSignatureInvalid +} + +// pae reconstructs the DSSEv1 Pre-Authentication Encoding: +// +// "DSSEv1 " || len(payloadType) || " " || payloadType || " " || len(payload) || " " || payload +// +// (ASCII decimal lengths, bytes.) See +// https://github.com/secure-systems-lab/dsse/blob/master/protocol.md. +func pae(payloadType string, payload []byte) []byte { + prefix := fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(payload)) + out := make([]byte, 0, len(prefix)+len(payload)) + out = append(out, prefix...) + out = append(out, payload...) + return out +} diff --git a/services/testagent-transform/internal/dsse/dsse_test.go b/services/testagent-transform/internal/dsse/dsse_test.go new file mode 100644 index 0000000..b3ce1d8 --- /dev/null +++ b/services/testagent-transform/internal/dsse/dsse_test.go @@ -0,0 +1,70 @@ +package dsse + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "testing" +) + +func TestVerify_HappyPath(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + payload := []byte(`{"hello":"world"}`) + env := signEnv(t, "application/vnd.in-toto+json", payload, "k1", priv) + got, err := Verify(env, Keyring{"k1": pub}) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if string(got) != string(payload) { + t.Fatalf("payload = %q", got) + } +} + +func TestVerify_UnknownKeyID(t *testing.T) { + _, priv, _ := ed25519.GenerateKey(rand.Reader) + otherPub, _, _ := ed25519.GenerateKey(rand.Reader) + env := signEnv(t, "x", []byte("hi"), "k1", priv) + _, err := Verify(env, Keyring{"other-kid": otherPub}) + if !errors.Is(err, ErrUnknownKey) { + t.Fatalf("want ErrUnknownKey, got %v", err) + } +} + +func TestVerify_SignatureInvalid(t *testing.T) { + pub, _, _ := ed25519.GenerateKey(rand.Reader) + // Build an envelope whose signature was made by a DIFFERENT private + // key but is listed under the keyring's keyid. + _, otherPriv, _ := ed25519.GenerateKey(rand.Reader) + env := signEnv(t, "x", []byte("hi"), "k1", otherPriv) + _, err := Verify(env, Keyring{"k1": pub}) + if !errors.Is(err, ErrSignatureInvalid) { + t.Fatalf("want ErrSignatureInvalid, got %v", err) + } +} + +func TestVerify_NoSignatures(t *testing.T) { + _, err := Verify(Envelope{PayloadType: "x", Payload: base64.RawURLEncoding.EncodeToString([]byte("hi"))}, Keyring{}) + if !errors.Is(err, ErrNoSignatures) { + t.Fatalf("want ErrNoSignatures, got %v", err) + } +} + +func signEnv(t *testing.T, payloadType string, payload []byte, keyID string, priv ed25519.PrivateKey) Envelope { + t.Helper() + pae := []byte(fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(payload))) + pae = append(pae, payload...) + sig := ed25519.Sign(priv, pae) + return Envelope{ + PayloadType: payloadType, + Payload: base64.RawURLEncoding.EncodeToString(payload), + Signatures: []EnvelopeSignature{{ + KeyID: keyID, + Sig: base64.RawURLEncoding.EncodeToString(sig), + }}, + } +} diff --git a/services/testagent-transform/internal/signer/client.go b/services/testagent-transform/internal/signer/client.go new file mode 100644 index 0000000..cf05a81 --- /dev/null +++ b/services/testagent-transform/internal/signer/client.go @@ -0,0 +1,150 @@ +// Package signer calls the signing-service's POST /api/v1/sign/dsse per +// ADR-0005 using the double-identity flow: +// +// - Authorization: Bearer (OIDC client identity) +// - X-Workload-Token: (K8s workload identity) +// +// The signing-service's structured policy evaluates BOTH identities against +// the Agent Registry and the OPA policy — a Pocket-ID identity alone is not +// enough to sign a transform statement in prod. See +// services/signing-service/internal/httpapi/sign.go#useStructuredPath. +package signer + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "basicstack.de/proofedmesh/services/testagent-transform/internal/auth" + "basicstack.de/proofedmesh/services/testagent-transform/internal/dsse" +) + +// WorkloadTokenHeader is the HTTP header carrying the projected +// ServiceAccount token. Kept in sync with +// services/signing-service/internal/token.WorkloadTokenHeader; duplicated +// here so this module stays independent. +const WorkloadTokenHeader = "X-Workload-Token" + +// Statement is the in-toto v1 Statement handed to the signing-service. +// Loose typing lets arbitrary predicate payloads flow through without a +// client-side schema registry; the signing-service validates the required +// fields. +type Statement map[string]any + +// SignedResult pairs the returned DSSE envelope with its content_hash. +// content_hash is the stable attestation ID that the transparency-log is +// addressed by. +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 +// transform agent surfaces this to the caller — it is NOT retryable with +// the same identities. +var ErrSigningDenied = errors.New("signer: signing-service denied the request") + +// Client is the DSSE sign client. +type Client struct { + BaseURL string + HTTPClient *http.Client + PocketIDToken auth.TokenSource // Authorization: Bearer … + WorkloadToken auth.TokenSource // X-Workload-Token: … +} + +type signRequest struct { + KeyRef string `json:"key_ref"` + Statement Statement `json:"statement"` +} + +// Sign calls POST /api/v1/sign/dsse. The signing-service returns a DSSE +// envelope plus the stable content_hash of the envelope. +func (c Client) Sign(ctx context.Context, keyRef string, stmt Statement) (SignedResult, error) { + if c.BaseURL == "" { + return SignedResult{}, errors.New("signer.Client: BaseURL is empty") + } + if c.PocketIDToken == nil { + return SignedResult{}, errors.New("signer.Client: PocketIDToken is required") + } + if c.WorkloadToken == nil { + return SignedResult{}, errors.New("signer.Client: WorkloadToken is required") + } + hc := c.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: 10 * time.Second} + } + body, err := json.Marshal(signRequest{KeyRef: keyRef, Statement: stmt}) + if err != nil { + return SignedResult{}, fmt.Errorf("signer: marshal request: %w", err) + } + endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/sign/dsse") + if err != nil { + return SignedResult{}, fmt.Errorf("signer: build url: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return SignedResult{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + pTok, err := c.PocketIDToken.Token(ctx) + if err != nil { + return SignedResult{}, fmt.Errorf("signer: pocket-id token: %w", err) + } + if pTok == "" { + return SignedResult{}, errors.New("signer: pocket-id token is empty") + } + req.Header.Set("Authorization", "Bearer "+pTok) + + wTok, err := c.WorkloadToken.Token(ctx) + if err != nil { + return SignedResult{}, fmt.Errorf("signer: workload token: %w", err) + } + if wTok == "" { + return SignedResult{}, errors.New("signer: workload token is empty") + } + req.Header.Set(WorkloadTokenHeader, wTok) + + resp, err := hc.Do(req) + if err != nil { + return SignedResult{}, fmt.Errorf("%w: %v", ErrSignerUnavailable, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + switch { + case resp.StatusCode == http.StatusOK: + // fall through + case resp.StatusCode == http.StatusForbidden: + return SignedResult{}, fmt.Errorf("%w: %s", ErrSigningDenied, truncate(raw, 256)) + case resp.StatusCode >= 500: + return SignedResult{}, fmt.Errorf("%w: status %d: %s", ErrSignerUnavailable, resp.StatusCode, truncate(raw, 256)) + default: + return SignedResult{}, fmt.Errorf("signer: status %d: %s", resp.StatusCode, truncate(raw, 256)) + } + var out SignedResult + if err := json.Unmarshal(raw, &out); err != nil { + return SignedResult{}, fmt.Errorf("signer: decode response: %w", err) + } + if out.ContentHash == "" { + return SignedResult{}, errors.New("signer: response missing content_hash") + } + return out, nil +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} diff --git a/services/testagent-transform/internal/signer/client_test.go b/services/testagent-transform/internal/signer/client_test.go new file mode 100644 index 0000000..53ec6ee --- /dev/null +++ b/services/testagent-transform/internal/signer/client_test.go @@ -0,0 +1,120 @@ +package signer + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "basicstack.de/proofedmesh/services/testagent-transform/internal/auth" + "basicstack.de/proofedmesh/services/testagent-transform/internal/dsse" +) + +const ( + testBearer = "pocket-id-bearer-xyz" + testWorkload = "wl-tok" +) + +func TestSign_SendsBothIdentities(t *testing.T) { + seen := struct { + auth string + workload string + keyRef string + }{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen.auth = r.Header.Get("Authorization") + seen.workload = r.Header.Get(WorkloadTokenHeader) + var body struct { + KeyRef string `json:"key_ref"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + seen.keyRef = body.KeyRef + resp := SignedResult{ + Envelope: dsse.Envelope{PayloadType: "application/vnd.in-toto+json", Payload: "aGk"}, + ContentHash: "sha256:deadbeef", + SignedAt: time.Now(), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + c := Client{ + BaseURL: srv.URL, + PocketIDToken: auth.StaticToken(testBearer), + WorkloadToken: auth.StaticToken(testWorkload), + } + res, err := c.Sign(context.Background(), "key-x", Statement{"_type": "x"}) + if err != nil { + t.Fatalf("Sign: %v", err) + } + if res.ContentHash != "sha256:deadbeef" { + t.Fatalf("ContentHash = %q", res.ContentHash) + } + if seen.auth != "Bearer "+testBearer { + t.Fatalf("Authorization = %q", seen.auth) + } + if seen.workload != testWorkload { + t.Fatalf("X-Workload-Token = %q", seen.workload) + } + if seen.keyRef != "key-x" { + t.Fatalf("key_ref = %q", seen.keyRef) + } +} + +func TestSign_403MapsToSigningDenied(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":{"code":"signing_denied","message":"nope"}}`)) + })) + defer srv.Close() + c := Client{BaseURL: srv.URL, PocketIDToken: auth.StaticToken("a"), WorkloadToken: auth.StaticToken("b")} + _, err := c.Sign(context.Background(), "k", Statement{"_type": "x"}) + if !errors.Is(err, ErrSigningDenied) { + t.Fatalf("want ErrSigningDenied, got %v", err) + } +} + +func TestSign_5xxMapsToUnavailable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + c := Client{BaseURL: srv.URL, PocketIDToken: auth.StaticToken("a"), WorkloadToken: auth.StaticToken("b")} + _, err := c.Sign(context.Background(), "k", Statement{"_type": "x"}) + if !errors.Is(err, ErrSignerUnavailable) { + t.Fatalf("want ErrSignerUnavailable, got %v", err) + } +} + +func TestSign_ConnRefusedMapsToUnavailable(t *testing.T) { + c := Client{ + BaseURL: "http://127.0.0.1:1", + HTTPClient: &http.Client{Timeout: 500 * time.Millisecond}, + PocketIDToken: auth.StaticToken("a"), + WorkloadToken: auth.StaticToken("b"), + } + _, err := c.Sign(context.Background(), "k", Statement{"_type": "x"}) + if !errors.Is(err, ErrSignerUnavailable) { + t.Fatalf("want ErrSignerUnavailable, got %v", err) + } +} + +func TestSign_RequiresWorkloadToken(t *testing.T) { + c := Client{BaseURL: "http://localhost", PocketIDToken: auth.StaticToken("a")} + _, err := c.Sign(context.Background(), "k", Statement{"_type": "x"}) + if err == nil { + t.Fatal("expected error when WorkloadToken source is missing") + } +} + +func TestSign_RequiresPocketIDToken(t *testing.T) { + c := Client{BaseURL: "http://localhost", WorkloadToken: auth.StaticToken("a")} + _, err := c.Sign(context.Background(), "k", Statement{"_type": "x"}) + if err == nil { + t.Fatal("expected error when PocketIDToken source is missing") + } +} diff --git a/services/testagent-transform/internal/sink/client.go b/services/testagent-transform/internal/sink/client.go new file mode 100644 index 0000000..525466b --- /dev/null +++ b/services/testagent-transform/internal/sink/client.go @@ -0,0 +1,108 @@ +// Package sink posts a handover_payload to the (phase-1) dummy sink. +// +// The payload is sent as the canonical JSON bytes the agent already hashed +// into the transform attestation's subject digest — a byte-for-byte match +// is a hard requirement for a downstream verifier, so we send those bytes +// directly rather than re-marshaling. +package sink + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "basicstack.de/proofedmesh/services/testagent-transform/internal/auth" +) + +// SchemaHeader carries the payload's schema identifier so the sink knows +// which decoder to pick. Phase-1 sinks are told to expect "sink.dummy.v1". +const SchemaHeader = "X-Payload-Schema" + +// AttestationHeader carries the transform attestation's content_hash so +// the sink can resolve the full attestation chain in the TLog without +// re-parsing the signed envelope. +const AttestationHeader = "X-Attestation-Ref" + +// ErrSinkUnavailable classifies connection errors and 5xx responses. +var ErrSinkUnavailable = errors.New("sink: dummy-sink unavailable") + +// Client is the dummy-sink POST client. +type Client struct { + BaseURL string + HTTPClient *http.Client + TokenSource auth.TokenSource +} + +// DeliverResult is what the agent reports upstream after a sink POST. +type DeliverResult struct { + Status int +} + +// Deliver POSTs payloadBytes to the sink. The caller passes the schema +// identifier (e.g. "sink.dummy.v1") and the transform attestation's +// content_hash so both go on the request headers. +func (c Client) Deliver(ctx context.Context, schema, attestationRef string, payloadBytes []byte) (DeliverResult, error) { + if c.BaseURL == "" { + return DeliverResult{}, errors.New("sink.Client: BaseURL is empty") + } + if schema == "" { + return DeliverResult{}, errors.New("sink.Client: schema is required") + } + if attestationRef == "" { + return DeliverResult{}, errors.New("sink.Client: attestationRef is required") + } + if len(payloadBytes) == 0 { + return DeliverResult{}, errors.New("sink.Client: payloadBytes is empty") + } + hc := c.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: 10 * time.Second} + } + endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/handover") + if err != nil { + return DeliverResult{}, fmt.Errorf("sink: build url: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payloadBytes)) + if err != nil { + return DeliverResult{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set(SchemaHeader, schema) + req.Header.Set(AttestationHeader, attestationRef) + if c.TokenSource != nil { + tok, err := c.TokenSource.Token(ctx) + if err != nil { + return DeliverResult{}, fmt.Errorf("sink: token: %w", err) + } + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + } + resp, err := hc.Do(req) + if err != nil { + return DeliverResult{}, fmt.Errorf("%w: %v", ErrSinkUnavailable, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + return DeliverResult{Status: resp.StatusCode}, nil + case resp.StatusCode >= 500: + return DeliverResult{}, fmt.Errorf("%w: status %d: %s", ErrSinkUnavailable, resp.StatusCode, truncate(raw, 256)) + default: + return DeliverResult{}, fmt.Errorf("sink: status %d: %s", resp.StatusCode, truncate(raw, 256)) + } +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} diff --git a/services/testagent-transform/internal/tlog/client.go b/services/testagent-transform/internal/tlog/client.go new file mode 100644 index 0000000..efc7858 --- /dev/null +++ b/services/testagent-transform/internal/tlog/client.go @@ -0,0 +1,119 @@ +// Package tlog submits attestation envelopes to the transparency-log per +// POST /api/v1/log/entries (ADR-0005). The TLog is idempotent on +// content_hash: re-submitting the same (envelope, content_hash) returns +// the original log_index. +package tlog + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "basicstack.de/proofedmesh/services/testagent-transform/internal/auth" + "basicstack.de/proofedmesh/services/testagent-transform/internal/dsse" +) + +// InclusionProof mirrors the server-side ADR-0005 inclusion proof. +type InclusionProof struct { + TreeSize int64 `json:"tree_size"` + TreeRootHash string `json:"tree_root_hash"` + SignedTreeHeadRef string `json:"signed_tree_head_ref"` + Hashes []string `json:"hashes"` +} + +// Receipt is what the TLog returns on a successful submit. +type Receipt struct { + LogIndex int64 `json:"log_index"` + ContentHash string `json:"content_hash"` + LoggedAt time.Time `json:"logged_at"` + InclusionProof InclusionProof `json:"inclusion_proof"` +} + +// ErrTLogUnavailable classifies connection errors and 5xx responses. +var ErrTLogUnavailable = errors.New("tlog: transparency-log unavailable") + +// Client is the TLog submit client. +type Client struct { + BaseURL string + HTTPClient *http.Client + TokenSource auth.TokenSource +} + +type submitRequest struct { + Envelope dsse.Envelope `json:"envelope"` + ContentHash string `json:"content_hash"` +} + +// Submit posts env + its contentHash to the TLog. Returns the receipt on +// 200/201 (idempotent repeat / fresh insert) and ErrTLogUnavailable on +// transport/5xx. Other non-2xx responses are reported with the raw status. +func (c Client) Submit(ctx context.Context, env dsse.Envelope, contentHash string) (Receipt, error) { + if c.BaseURL == "" { + return Receipt{}, errors.New("tlog.Client: BaseURL is empty") + } + hc := c.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: 10 * time.Second} + } + body, err := json.Marshal(submitRequest{Envelope: env, ContentHash: contentHash}) + if err != nil { + return Receipt{}, fmt.Errorf("tlog: marshal submit: %w", err) + } + endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/log/entries") + if err != nil { + return Receipt{}, fmt.Errorf("tlog: build url: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return Receipt{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if c.TokenSource != nil { + tok, err := c.TokenSource.Token(ctx) + if err != nil { + return Receipt{}, fmt.Errorf("tlog: token: %w", err) + } + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + } + resp, err := hc.Do(req) + if err != nil { + return Receipt{}, fmt.Errorf("%w: %v", ErrTLogUnavailable, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + switch { + case resp.StatusCode == http.StatusOK, resp.StatusCode == http.StatusCreated: + // ok + case resp.StatusCode >= 500: + return Receipt{}, fmt.Errorf("%w: status %d: %s", ErrTLogUnavailable, resp.StatusCode, truncate(raw, 256)) + default: + return Receipt{}, fmt.Errorf("tlog: status %d: %s", resp.StatusCode, truncate(raw, 256)) + } + var out Receipt + if err := json.Unmarshal(raw, &out); err != nil { + return Receipt{}, fmt.Errorf("tlog: decode receipt: %w", err) + } + if out.ContentHash != contentHash { + return out, fmt.Errorf("tlog returned content_hash %q != sent %q", out.ContentHash, contentHash) + } + if out.LogIndex < 0 { + return out, fmt.Errorf("tlog returned negative log_index %d", out.LogIndex) + } + return out, nil +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} diff --git a/services/testagent-transform/internal/tlog/client_test.go b/services/testagent-transform/internal/tlog/client_test.go new file mode 100644 index 0000000..008ccfd --- /dev/null +++ b/services/testagent-transform/internal/tlog/client_test.go @@ -0,0 +1,72 @@ +package tlog + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "basicstack.de/proofedmesh/services/testagent-transform/internal/dsse" +) + +func TestSubmit_HappyPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/log/entries" { + http.NotFound(w, r) + return + } + var body struct { + Envelope dsse.Envelope `json:"envelope"` + ContentHash string `json:"content_hash"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(Receipt{ + LogIndex: 7, + ContentHash: body.ContentHash, + LoggedAt: time.Now(), + }) + })) + defer srv.Close() + c := Client{BaseURL: srv.URL} + r, err := c.Submit(context.Background(), dsse.Envelope{PayloadType: "x", Payload: "aGk"}, "sha256:abc") + if err != nil { + t.Fatalf("Submit: %v", err) + } + if r.LogIndex != 7 || r.ContentHash != "sha256:abc" { + t.Fatalf("receipt = %+v", r) + } +} + +func TestSubmit_5xxMapsToUnavailable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusServiceUnavailable) + })) + defer srv.Close() + c := Client{BaseURL: srv.URL} + _, err := c.Submit(context.Background(), dsse.Envelope{}, "sha256:abc") + if !errors.Is(err, ErrTLogUnavailable) { + t.Fatalf("want ErrTLogUnavailable, got %v", err) + } +} + +func TestSubmit_ContentHashMismatchIsReported(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(Receipt{ + LogIndex: 1, + ContentHash: "sha256:different", + }) + })) + defer srv.Close() + c := Client{BaseURL: srv.URL} + _, err := c.Submit(context.Background(), dsse.Envelope{}, "sha256:expected") + if err == nil { + t.Fatal("expected error on content_hash mismatch") + } +} diff --git a/services/testagent-transform/internal/transform/runner.go b/services/testagent-transform/internal/transform/runner.go new file mode 100644 index 0000000..abcabdf --- /dev/null +++ b/services/testagent-transform/internal/transform/runner.go @@ -0,0 +1,228 @@ +// Package transform wires the five Phase-1 steps of the testagent-transform +// into a single Execute call: +// +// 1. Fetch the approval attestation from the transparency-log by +// attestation_ref. +// 2. Verify the fetched DSSE envelope against a pinned approval-signer +// keyring. +// 3. Build an in-toto v1 Statement with predicateType +// "https://basicstack.de/attestations/transform/v1", operation +// "expense.map_to_sink", and subject.digest.sha256 = +// canonical_sha256(handover_payload). +// 4. Call the signing-service with BOTH identities (Pocket-ID bearer + +// projected SA token) to obtain a DSSE envelope + content_hash. +// 5. Submit the envelope to the transparency-log. +// 6. POST the exact canonical handover_payload bytes to the dummy-sink. +// +// Failures are classified so a Phase-1 ops dashboard can distinguish an +// invalid upstream approval (reject at step 2, before any new signature +// is created) from a signing-service denial (step 4). +package transform + +import ( + "context" + "errors" + "fmt" + "time" + + "basicstack.de/proofedmesh/services/testagent-transform/internal/approval" + "basicstack.de/proofedmesh/services/testagent-transform/internal/canonical" + "basicstack.de/proofedmesh/services/testagent-transform/internal/dsse" + "basicstack.de/proofedmesh/services/testagent-transform/internal/signer" + "basicstack.de/proofedmesh/services/testagent-transform/internal/sink" + "basicstack.de/proofedmesh/services/testagent-transform/internal/tlog" +) + +// SchemaRef names and versions a schema. Mirrors the architecture doc's +// `schema.input`/`schema.output` shape. +type SchemaRef struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// Config holds the per-agent fixed parameters. All fields are required. +type Config struct { + // Actor is the agent's registry id (e.g. "agent-testagent-transform"). + Actor string + // Operation is the OPA-vetted transformation name + // ("expense.map_to_sink" in Phase 1). + Operation string + // SinkTarget is the logical sink identifier the architecture doc uses + // to tie the transform to a sink policy ("sink:dummy-sink" here). + SinkTarget string + // SigningKeyRef is the OpenBao Transit key reference the + // signing-service uses on behalf of this agent + // ("trust-api/agent-testagent-transform-2026-q4" in Phase 1). + SigningKeyRef string + // Input / Output schemas carried through on the attestation so the + // verifier knows which decoder to run. + Input, Output SchemaRef + // TransformVersion is the agent's own version string, carried into + // the attestation so bugfixes are auditable. + TransformVersion string + // SinkPayloadSchema is the schema identifier the sink expects on the + // `X-Payload-Schema` header. Phase-1: "sink.dummy.v1". + SinkPayloadSchema string +} + +// ApprovalFetcher pulls an approval entry from the TLog by attestation_ref. +type ApprovalFetcher interface { + Fetch(ctx context.Context, attestationRef string) (approval.Entry, error) +} + +// SignerClient produces a signed DSSE envelope. Prod wiring is a +// signer.Client; tests swap in a fake. +type SignerClient interface { + Sign(ctx context.Context, keyRef string, stmt signer.Statement) (signer.SignedResult, error) +} + +// TLogSubmitter adds the envelope to the transparency log. +type TLogSubmitter interface { + Submit(ctx context.Context, env dsse.Envelope, contentHash string) (tlog.Receipt, error) +} + +// SinkDeliverer posts the handover_payload bytes to the sink. +type SinkDeliverer interface { + Deliver(ctx context.Context, schema, attestationRef string, payloadBytes []byte) (sink.DeliverResult, error) +} + +// Request is a single transform invocation. HandoverPayload is JSON-shaped +// (`map[string]any` after unmarshal); the agent canonicalises it to compute +// the subject digest and ships the same canonical bytes to the sink. +type Request struct { + WorkflowID string + ApprovalRef string + HandoverPayload map[string]any +} + +// Result is what the agent reports after a successful Execute. +type Result struct { + TransformRef string + LogIndex int64 + SinkStatus int + HandoverHashHex string +} + +// Runner is the orchestration root. +type Runner struct { + Config + + Approvals ApprovalFetcher + ApprovalKeyring dsse.Keyring + Signer SignerClient + TLog TLogSubmitter + Sink SinkDeliverer + Now func() time.Time +} + +// ErrInvalidApproval wraps a DSSE verification failure on the fetched +// approval envelope. The runner stops immediately — no new attestation +// should be issued on top of an untrusted approval. +var ErrInvalidApproval = errors.New("transform: fetched approval attestation did not verify") + +// Execute runs the five-step pipeline. +func (r *Runner) Execute(ctx context.Context, req Request) (Result, error) { + if err := r.validate(req); err != nil { + return Result{}, err + } + + ap, err := r.Approvals.Fetch(ctx, req.ApprovalRef) + if err != nil { + return Result{}, fmt.Errorf("fetch approval %s: %w", req.ApprovalRef, err) + } + + if _, err := dsse.Verify(ap.Envelope, r.ApprovalKeyring); err != nil { + return Result{}, fmt.Errorf("%w: %v", ErrInvalidApproval, err) + } + + payloadBytes, err := canonical.Marshal(req.HandoverPayload) + if err != nil { + return Result{}, fmt.Errorf("canonical handover_payload: %w", err) + } + payloadHashHex := canonical.SHA256Hex(payloadBytes) + + stmt := r.buildStatement(req, payloadHashHex) + + signed, err := r.Signer.Sign(ctx, r.SigningKeyRef, stmt) + if err != nil { + return Result{}, fmt.Errorf("sign transform statement: %w", err) + } + + receipt, err := r.TLog.Submit(ctx, signed.Envelope, signed.ContentHash) + if err != nil { + return Result{}, fmt.Errorf("tlog submit transform: %w", err) + } + + deliv, err := r.Sink.Deliver(ctx, r.SinkPayloadSchema, signed.ContentHash, payloadBytes) + if err != nil { + return Result{}, fmt.Errorf("sink deliver: %w", err) + } + + return Result{ + TransformRef: signed.ContentHash, + LogIndex: receipt.LogIndex, + SinkStatus: deliv.Status, + HandoverHashHex: payloadHashHex, + }, nil +} + +func (r *Runner) validate(req Request) error { + if r.Approvals == nil || r.ApprovalKeyring == nil || r.Signer == nil || r.TLog == nil || r.Sink == nil { + return errors.New("transform.Runner: missing dependency wiring") + } + if r.Actor == "" || r.Operation == "" || r.SinkTarget == "" || r.SigningKeyRef == "" { + return errors.New("transform.Runner: Config missing required field (Actor/Operation/SinkTarget/SigningKeyRef)") + } + if r.SinkPayloadSchema == "" { + return errors.New("transform.Runner: Config.SinkPayloadSchema is required") + } + if r.Input.Name == "" || r.Input.Version == "" || r.Output.Name == "" || r.Output.Version == "" { + return errors.New("transform.Runner: Config.Input/Output schema is required") + } + if req.WorkflowID == "" || req.ApprovalRef == "" || len(req.HandoverPayload) == 0 { + return errors.New("transform.Runner: Request missing required field (WorkflowID/ApprovalRef/HandoverPayload)") + } + return nil +} + +func (r *Runner) buildStatement(req Request, payloadHashHex string) signer.Statement { + issuedAt := r.now().UTC().Format(time.RFC3339) + return signer.Statement{ + "_type": "https://in-toto.io/Statement/v1", + "subject": []any{ + map[string]any{ + "name": "transform/" + req.WorkflowID + "/handover", + "digest": map[string]any{ + "sha256": payloadHashHex, + }, + }, + }, + "predicateType": "https://basicstack.de/attestations/transform/v1", + "predicate": map[string]any{ + "workflow_id": req.WorkflowID, + "actor": r.Actor, + "operation": r.Operation, + "sink_target": r.SinkTarget, + "approval_ref": req.ApprovalRef, + "materials": []any{ + map[string]any{ + "name": "approval/" + req.ApprovalRef, + "attestation_ref": req.ApprovalRef, + }, + }, + "schema": map[string]any{ + "input": map[string]any{"name": r.Input.Name, "version": r.Input.Version}, + "output": map[string]any{"name": r.Output.Name, "version": r.Output.Version}, + }, + "transform_version": r.TransformVersion, + "issued_at": issuedAt, + }, + } +} + +func (r *Runner) now() time.Time { + if r.Now != nil { + return r.Now() + } + return time.Now() +} diff --git a/services/testagent-transform/internal/transform/runner_test.go b/services/testagent-transform/internal/transform/runner_test.go new file mode 100644 index 0000000..559e8f9 --- /dev/null +++ b/services/testagent-transform/internal/transform/runner_test.go @@ -0,0 +1,405 @@ +package transform + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "basicstack.de/proofedmesh/services/testagent-transform/internal/approval" + "basicstack.de/proofedmesh/services/testagent-transform/internal/auth" + "basicstack.de/proofedmesh/services/testagent-transform/internal/canonical" + "basicstack.de/proofedmesh/services/testagent-transform/internal/dsse" + "basicstack.de/proofedmesh/services/testagent-transform/internal/signer" + "basicstack.de/proofedmesh/services/testagent-transform/internal/sink" + "basicstack.de/proofedmesh/services/testagent-transform/internal/tlog" +) + +// mocks wires httptest servers for approval-service (via TLog GET), +// signing-service, transparency-log (POST), and the dummy-sink. +type mocks struct { + approvalSrv *httptest.Server + signSrv *httptest.Server + tlogSrv *httptest.Server + sinkSrv *httptest.Server + + approvalPub ed25519.PublicKey + transformPub ed25519.PublicKey + transformPriv ed25519.PrivateKey + + sinkPayload []byte + sinkSchema string + sinkAttestRef string + sinkCalled bool + + tlogSubmitted []string // sha256:… content_hashes seen on POST /log/entries +} + +// buildMocks creates all four backends. By default the approval envelope +// is validly signed by approvalPriv (keyring key id "approval-key"). +// Caller may override behaviour via opts before calling run. +type mockOpts struct { + // tamperApproval replaces the valid approval signature with a bogus + // one so DSSE verify fails before any sign-call happens. + tamperApproval bool + // signDenies makes the signing-service answer 403 signing_denied so + // the agent surfaces ErrSigningDenied. + signDenies bool + // pocketIDTokenEmpty makes the Pocket-ID TokenSource return "" so the + // signer.Client refuses to even send the request. + pocketIDTokenEmpty bool +} + +const ( + approvalKeyID = "approval-key" + transformKeyID = "trust-api/agent-testagent-transform-2026-q4" + pocketIDBearer = "pocket-id-access-token-for-testagent-transform" + workloadToken = "projected-sa-token-trust-api" + attestationRef = "sha256:1111111111111111111111111111111111111111111111111111111111111111" +) + +func buildMocks(t *testing.T, opts mockOpts) *mocks { + t.Helper() + m := &mocks{sinkSchema: "sink.dummy.v1"} + + // Keys. + approvalPub, approvalPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + transformPub, transformPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + m.approvalPub = approvalPub + m.transformPub = transformPub + m.transformPriv = transformPriv + + // A real DSSE envelope the agent must fetch, verify, and reference. + approvalPayload := map[string]any{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://basicstack.de/attestations/approval/v1", + "subject": []any{ + map[string]any{ + "name": "approval/APR-TEST", + "digest": map[string]any{"sha256": "deadbeef"}, + }, + }, + "predicate": map[string]any{"approval_id": "APR-TEST"}, + } + approvalEnv := dsseSign(t, "application/vnd.in-toto+json", approvalPayload, approvalKeyID, approvalPriv) + if opts.tamperApproval { + // Replace sig with bogus bytes so Verify fails. + approvalEnv.Signatures[0].Sig = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xAA}, ed25519.SignatureSize)) + } + + // approvalSrv: TLog read endpoint GET /api/v1/log/entries/{content_hash}. + m.approvalSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if r.URL.Path != "/api/v1/log/entries/"+attestationRef { + http.NotFound(w, r) + return + } + entry := approval.Entry{ + ContentHash: attestationRef, + Envelope: approvalEnv, + LogIndex: 42, + LoggedAt: time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(entry) + })) + + // signSrv: POST /api/v1/sign/dsse — requires BOTH identities and + // returns a real DSSE envelope signed by transformPriv so the TLog + // path can verify content_hash consistency. + m.signSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/sign/dsse" || r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer "+pocketIDBearer { + http.Error(w, `{"error":{"code":"unauthorized","message":"bad bearer"}}`, http.StatusUnauthorized) + return + } + if got := r.Header.Get(signer.WorkloadTokenHeader); got != workloadToken { + http.Error(w, `{"error":{"code":"unauthorized","message":"bad workload token"}}`, http.StatusUnauthorized) + return + } + if opts.signDenies { + http.Error(w, `{"error":{"code":"signing_denied","message":"policy denies key_ref"}}`, http.StatusForbidden) + return + } + var body struct { + KeyRef string `json:"key_ref"` + Statement map[string]any `json:"statement"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "decode", http.StatusBadRequest) + return + } + if body.KeyRef != transformKeyID { + http.Error(w, `{"error":{"code":"bad_request","message":"unexpected key_ref"}}`, http.StatusBadRequest) + return + } + + env := dsseSign(t, "application/vnd.in-toto+json", body.Statement, transformKeyID, transformPriv) + envRaw, _ := canonical.Marshal(env) + contentHash := "sha256:" + canonical.SHA256Hex(envRaw) + resp := signer.SignedResult{ + Envelope: env, + ContentHash: contentHash, + SignedAt: time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + + // tlogSrv: POST /api/v1/log/entries. Verifies content_hash matches + // the request's envelope then returns a canned receipt. + m.tlogSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/log/entries" || r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + var body struct { + Envelope dsse.Envelope `json:"envelope"` + ContentHash string `json:"content_hash"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "decode", http.StatusBadRequest) + return + } + // Independent recompute: the TLog mock re-canonicalises and + // confirms the submitted content_hash really does name this + // envelope. + envRaw, _ := canonical.Marshal(body.Envelope) + got := "sha256:" + canonical.SHA256Hex(envRaw) + if got != body.ContentHash { + http.Error(w, fmt.Sprintf(`{"error":"content_hash mismatch: got %s want %s"}`, got, body.ContentHash), http.StatusConflict) + return + } + m.tlogSubmitted = append(m.tlogSubmitted, body.ContentHash) + rcpt := tlog.Receipt{ + LogIndex: 1234, + ContentHash: body.ContentHash, + LoggedAt: time.Date(2026, 10, 25, 20, 0, 1, 0, time.UTC), + InclusionProof: tlog.InclusionProof{ + TreeSize: 1235, + TreeRootHash: "sha256:" + strings.Repeat("a", 64), + SignedTreeHeadRef: "sha256:" + strings.Repeat("b", 64), + Hashes: []string{"sha256:" + strings.Repeat("c", 64)}, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(rcpt) + })) + + // sinkSrv: POST /api/v1/handover; captures body + headers so the + // test can assert the agent sent exactly the canonical bytes. + m.sinkSrv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/handover" || r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + m.sinkCalled = true + m.sinkSchema = r.Header.Get(sink.SchemaHeader) + m.sinkAttestRef = r.Header.Get(sink.AttestationHeader) + m.sinkPayload, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusAccepted) + })) + + t.Cleanup(func() { + m.approvalSrv.Close() + m.signSrv.Close() + m.tlogSrv.Close() + m.sinkSrv.Close() + }) + return m +} + +func newRunner(t *testing.T, m *mocks, opts mockOpts) *Runner { + t.Helper() + pocketIDToken := auth.StaticToken(pocketIDBearer) + if opts.pocketIDTokenEmpty { + pocketIDToken = "" + } + return &Runner{ + Config: Config{ + Actor: "agent-testagent-transform", + Operation: "expense.map_to_sink", + SinkTarget: "sink:dummy-sink", + SigningKeyRef: transformKeyID, + Input: SchemaRef{Name: "expense.canonical", Version: "1.2.0"}, + Output: SchemaRef{Name: "sink.db.expense", Version: "1.2.0"}, + TransformVersion: "0.1.0", + SinkPayloadSchema: "sink.dummy.v1", + }, + Approvals: approval.Client{ + BaseURL: m.approvalSrv.URL, + }, + ApprovalKeyring: dsse.Keyring{approvalKeyID: m.approvalPub}, + Signer: signer.Client{ + BaseURL: m.signSrv.URL, + PocketIDToken: pocketIDToken, + WorkloadToken: auth.StaticToken(workloadToken), + }, + TLog: tlog.Client{BaseURL: m.tlogSrv.URL}, + Sink: sink.Client{BaseURL: m.sinkSrv.URL}, + Now: func() time.Time { return time.Date(2026, 10, 25, 20, 0, 0, 0, time.UTC) }, + } +} + +func sampleRequest() Request { + return Request{ + WorkflowID: "WF-TEST-1", + ApprovalRef: attestationRef, + HandoverPayload: map[string]any{ + "schema": map[string]any{"name": "sink.dummy.v1", "version": "1.0.0"}, + "items": []any{ + map[string]any{"line": "1", "amount": "12.34"}, + }, + "workflow_id": "WF-TEST-1", + }, + } +} + +// --- happy path --- + +func TestExecute_HappyPath(t *testing.T) { + m := buildMocks(t, mockOpts{}) + r := newRunner(t, m, mockOpts{}) + req := sampleRequest() + + res, err := r.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute: %v", err) + } + + // Content-hash of the handover_payload we sent to the sink must + // equal the subject digest the agent signed into the transform + // attestation. + wantBytes, _ := canonical.Marshal(req.HandoverPayload) + wantHash := canonical.SHA256Hex(wantBytes) + if res.HandoverHashHex != wantHash { + t.Fatalf("HandoverHashHex = %q, want %q", res.HandoverHashHex, wantHash) + } + if !m.sinkCalled { + t.Fatal("sink was never called") + } + if !bytes.Equal(m.sinkPayload, wantBytes) { + t.Fatalf("sink payload bytes differ:\n got %q\nwant %q", m.sinkPayload, wantBytes) + } + if m.sinkSchema != "sink.dummy.v1" { + t.Fatalf("sink schema header = %q", m.sinkSchema) + } + if m.sinkAttestRef != res.TransformRef { + t.Fatalf("sink attestation header = %q, want %q", m.sinkAttestRef, res.TransformRef) + } + + // TLog must have seen exactly our transform content_hash. + if len(m.tlogSubmitted) != 1 || m.tlogSubmitted[0] != res.TransformRef { + t.Fatalf("tlog submits = %v, want [%q]", m.tlogSubmitted, res.TransformRef) + } + if res.LogIndex == 0 { + t.Fatalf("LogIndex not set") + } + if res.SinkStatus != http.StatusAccepted { + t.Fatalf("SinkStatus = %d, want 202", res.SinkStatus) + } +} + +// --- negative: approval signature invalid --- + +func TestExecute_InvalidApprovalSignature_StopsBeforeSign(t *testing.T) { + m := buildMocks(t, mockOpts{tamperApproval: true}) + r := newRunner(t, m, mockOpts{}) + + _, err := r.Execute(context.Background(), sampleRequest()) + if err == nil { + t.Fatal("expected error on invalid approval signature") + } + if !errors.Is(err, ErrInvalidApproval) { + t.Fatalf("expected ErrInvalidApproval, got %v", err) + } + if len(m.tlogSubmitted) != 0 { + t.Fatalf("tlog must not be touched when approval is invalid; saw %v", m.tlogSubmitted) + } + if m.sinkCalled { + t.Fatal("sink must not be touched when approval is invalid") + } +} + +// --- negative: signing-service refuses (403 signing_denied) --- + +func TestExecute_SigningDenied_SurfacesError(t *testing.T) { + m := buildMocks(t, mockOpts{signDenies: true}) + r := newRunner(t, m, mockOpts{}) + + _, err := r.Execute(context.Background(), sampleRequest()) + if err == nil { + t.Fatal("expected error when signing-service denies") + } + if !errors.Is(err, signer.ErrSigningDenied) { + t.Fatalf("expected signer.ErrSigningDenied, got %v", err) + } + if len(m.tlogSubmitted) != 0 { + t.Fatalf("tlog must not be touched on sign denial") + } + if m.sinkCalled { + t.Fatal("sink must not be touched on sign denial") + } +} + +// --- negative: both identities required --- + +func TestExecute_MissingPocketIDToken_FailsBeforeNetwork(t *testing.T) { + m := buildMocks(t, mockOpts{}) + r := newRunner(t, m, mockOpts{pocketIDTokenEmpty: true}) + + _, err := r.Execute(context.Background(), sampleRequest()) + if err == nil { + t.Fatal("expected error when Pocket-ID token is empty") + } + if !strings.Contains(err.Error(), "pocket-id token is empty") { + t.Fatalf("expected pocket-id token error, got %v", err) + } +} + +// --- helpers --- + +// dsseSign produces a DSSE envelope with payloadBytes = canonical(stmt) +// and signatures = [ed25519(PAE(payloadType, payloadBytes))]. +func dsseSign(t *testing.T, payloadType string, stmt map[string]any, keyID string, priv ed25519.PrivateKey) dsse.Envelope { + t.Helper() + payloadBytes, err := canonical.Marshal(stmt) + if err != nil { + t.Fatalf("canonical: %v", err) + } + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes) + pae := []byte(fmt.Sprintf("DSSEv1 %d %s %d ", len(payloadType), payloadType, len(payloadBytes))) + pae = append(pae, payloadBytes...) + sig := ed25519.Sign(priv, pae) + return dsse.Envelope{ + PayloadType: payloadType, + Payload: payloadB64, + Signatures: []dsse.EnvelopeSignature{{ + KeyID: keyID, + Sig: base64.RawURLEncoding.EncodeToString(sig), + }}, + } +}