testagent-transform: Phase-1 trust-chain demo agent (DEV-610)
Some checks failed
build-images / signing-service image (push) Failing after 16s
validate / JSON Schema (ajv) (push) Successful in 19s
validate / Go (vet + test) (push) Failing after 4s
validate / Rego (opa fmt + opa test) (push) Successful in 13s
validate / DB (migrate up/down dry-run) (push) Failing after 21s
Some checks failed
build-images / signing-service image (push) Failing after 16s
validate / JSON Schema (ajv) (push) Successful in 19s
validate / Go (vet + test) (push) Failing after 4s
validate / Rego (opa fmt + opa test) (push) Successful in 13s
validate / DB (migrate up/down dry-run) (push) Failing after 21s
Minimal CLI agent process that end-to-end demonstrates the Transform step
of the ProofedMesh trust chain:
1. Fetches an approval attestation from the transparency-log by
attestation_ref (`sha256:<hex>`).
2. DSSE-verifies the fetched envelope against a pinned approval-signer
ed25519 keyring; stops before any signing call on failure.
3. Builds an in-toto v1 Statement with predicateType
`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)).
4. Calls signing-service /api/v1/sign/dsse with BOTH identities:
Authorization: Bearer <pocket-id client_credentials token> AND
X-Workload-Token: <projected SA token from
/var/run/secrets/tokens/trust-api>.
5. Submits the returned DSSE envelope + content_hash to the
transparency-log /api/v1/log/entries.
6. POSTs the canonical handover_payload bytes to the dummy-sink
(schema sink.dummy.v1), carrying the transform content_hash on the
X-Attestation-Ref header.
Tests use httptest for all four backends:
- Happy path (verifies the bytes sent to the sink match the signed
subject digest and that the TLog recorded the same content_hash the
agent returned).
- Invalid approval signature -> stops before sign-call; TLog and sink
untouched.
- Signing-service returns 403 signing_denied -> surfaced as
signer.ErrSigningDenied; TLog and sink untouched.
- Missing Pocket-ID token -> fails before any network call.
Includes PocketID client_credentials TokenSource (OIDC discovery + cache)
and ProjectedSATokenSource (reads kubelet-managed Volume) mirroring
admin-api/approval-service.
Non-scope: no read-side attestations (Phase 2), no K8s/CI image build
wiring (follow-up ticket).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
30dd2f8b65
commit
ba1b88d490
20 changed files with 2418 additions and 0 deletions
35
services/testagent-transform/Dockerfile
Normal file
35
services/testagent-transform/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
105
services/testagent-transform/README.md
Normal file
105
services/testagent-transform/README.md
Normal file
|
|
@ -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 <pocket-id-access-token>` (`client_credentials`
|
||||||
|
grant against `POCKET_ID_ISSUER`)
|
||||||
|
- `X-Workload-Token: <projected-sa-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: <transform content_hash>`
|
||||||
|
|
||||||
|
## 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.
|
||||||
227
services/testagent-transform/cmd/testagent-transform/main.go
Normal file
227
services/testagent-transform/cmd/testagent-transform/main.go
Normal file
|
|
@ -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:<hex> 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
|
||||||
|
}
|
||||||
3
services/testagent-transform/go.mod
Normal file
3
services/testagent-transform/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module basicstack.de/proofedmesh/services/testagent-transform
|
||||||
|
|
||||||
|
go 1.23
|
||||||
119
services/testagent-transform/internal/approval/client.go
Normal file
119
services/testagent-transform/internal/approval/client.go
Normal file
|
|
@ -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]) + "…"
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
178
services/testagent-transform/internal/auth/pocket_id.go
Normal file
178
services/testagent-transform/internal/auth/pocket_id.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
80
services/testagent-transform/internal/auth/sa_token.go
Normal file
80
services/testagent-transform/internal/auth/sa_token.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
84
services/testagent-transform/internal/auth/sa_token_test.go
Normal file
84
services/testagent-transform/internal/auth/sa_token_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
30
services/testagent-transform/internal/auth/token_source.go
Normal file
30
services/testagent-transform/internal/auth/token_source.go
Normal file
|
|
@ -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) }
|
||||||
102
services/testagent-transform/internal/canonical/canonical.go
Normal file
102
services/testagent-transform/internal/canonical/canonical.go
Normal file
|
|
@ -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:<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
|
||||||
|
}
|
||||||
99
services/testagent-transform/internal/dsse/dsse.go
Normal file
99
services/testagent-transform/internal/dsse/dsse.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
70
services/testagent-transform/internal/dsse/dsse_test.go
Normal file
70
services/testagent-transform/internal/dsse/dsse_test.go
Normal file
|
|
@ -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),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
150
services/testagent-transform/internal/signer/client.go
Normal file
150
services/testagent-transform/internal/signer/client.go
Normal file
|
|
@ -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 <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 — 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]) + "…"
|
||||||
|
}
|
||||||
120
services/testagent-transform/internal/signer/client_test.go
Normal file
120
services/testagent-transform/internal/signer/client_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
108
services/testagent-transform/internal/sink/client.go
Normal file
108
services/testagent-transform/internal/sink/client.go
Normal file
|
|
@ -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]) + "…"
|
||||||
|
}
|
||||||
119
services/testagent-transform/internal/tlog/client.go
Normal file
119
services/testagent-transform/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/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]) + "…"
|
||||||
|
}
|
||||||
72
services/testagent-transform/internal/tlog/client_test.go
Normal file
72
services/testagent-transform/internal/tlog/client_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
228
services/testagent-transform/internal/transform/runner.go
Normal file
228
services/testagent-transform/internal/transform/runner.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
405
services/testagent-transform/internal/transform/runner_test.go
Normal file
405
services/testagent-transform/internal/transform/runner_test.go
Normal file
|
|
@ -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),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue