diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1d5cf1..4400a98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,34 @@ jobs: version: v2.10.1 args: --verbose --concurrency=4 --allow-parallel-runners + tutorial: + name: Tutorial module (demo) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e #v7.0.0 + with: + go-version: "1.26.9" + # The tutorial module (dev-only demo) consumes the core via a workspace. + # go.work is gitignored; generate it here (not committed) so the module + # resolves the in-repo core at its v0.0.0 placeholder version. + - name: Generate workspace + run: | + rm -f go.work go.work.sum + go work init . ./tutorial + go work edit -replace github.com/agentnameservice/ans-openshell@v0.0.0=./ + - name: Build (tutorial) + run: go build ./tutorial/... + - name: Build demo binary + run: go build -o bin/ans-demo-middleware ./tutorial/cmd/ans-demo-middleware + - name: Test (tutorial) + run: go test -race ./tutorial/... + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a #v9.3.0 + with: + version: v2.10.1 + args: --verbose --concurrency=4 --allow-parallel-runners + working-directory: tutorial + deploy-artifacts: name: Container images & Helm chart runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 0aeb50e..8a923ba 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,18 @@ Thumbs.db # Secrets and certificates .env .env.* +!.env.example *.pem *.key *.csr + +# Identity material for the Tier 2 demo (Agent A bundle, commerce key) — never committed +/.secrets/ + +# Generated demo run reports (run-demo.sh --report) +report.md +run-report.md +*-run-report.md + +# Design docs, plans, and runbooks are local-only (user instruction: ignore docs folder) +/docs/ diff --git a/Makefile b/Makefile index b04fb6b..fd553f4 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ LINT_GOTOOLCHAIN ?= local BIN := bin CMDS := ans-openshell-middleware ans-openshell-interceptor ans-ocsf-hydrator ans-reference-callee -.PHONY: all build proto test cover lint vet demo clean +.PHONY: all build demo-build workspace proto test cover lint vet demo clean all: build @@ -13,6 +13,21 @@ build: @mkdir -p $(BIN) @for c in $(CMDS); do echo "building $$c"; $(GO) build -o $(BIN)/$$c ./cmd/$$c; done +# The tutorial (dev-only demo) module lives in ./tutorial and consumes the core +# via a Go workspace. go.work is gitignored; `workspace` (re)generates it with a +# version-pinned replace so the module resolves the in-repo core. It is harmless +# to run repeatedly. +workspace: + rm -f go.work go.work.sum + $(GO) work init . ./tutorial + $(GO) work edit -replace github.com/agentnameservice/ans-openshell@v0.0.0=./ + +# demo-build produces the dev-only demo binary (production middleware + the +# commerce tutorial transform). run-demo.sh runs this binary, not the prod one. +demo-build: workspace + @mkdir -p $(BIN) + $(GO) build -o $(BIN)/ans-demo-middleware ./tutorial/cmd/ans-demo-middleware + # Generated code must match CI byte for byte. protoc-gen-go formats its output with the # go/format of the toolchain that built it, so the plugins are built with the CI toolchain. PROTO_GOTOOLCHAIN ?= go1.26.9 diff --git a/README.md b/README.md index f322241..3f58261 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,26 @@ Run the tests: make cover ``` +## Repository layout + +The repository is two Go modules: + +- **Core** (`github.com/agentnameservice/ans-openshell`, repo root) — the + generic middleware and its binaries (`make build`). The production + `ans-openshell-middleware` is pure middleware: it stamps a + transparency-log-vouched ANS identity on a sandbox's outbound traffic and + contains **no demo or commerce code**. +- **Tutorial** (`github.com/agentnameservice/ans-openshell/tutorial`, + `./tutorial`) — a dev-only module that consumes the core's `app.Run` + extension point and registers a worked-example capability transform + (AP2 commerce: mandate + DPoP). It builds its own binary, + `ans-demo-middleware` (`make demo-build`), used only by the live demo under + `deploy/live-demo/`. It is never shipped. + +`make demo-build` generates a gitignored `go.work` so the tutorial module +resolves the in-repo core; the production build (`make build`) needs no +workspace. + ## Running the services Every service takes one YAML file. Flags: `--config` (required), `--listen` to override the diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..a211986 --- /dev/null +++ b/app/app.go @@ -0,0 +1,221 @@ +// Package app assembles and serves the OpenShell supervisor middleware. It is +// the reusable core entrypoint: the production command calls Run with no +// customization, while a caller that wants to register additional capability +// transforms (for example the demo's commerce tutorial) passes options that +// receive the built *middleware.Service before it starts serving. +package app + +import ( + "context" + "errors" + "log/slog" + "slices" + "time" + + "google.golang.org/grpc" + + "github.com/agentnameservice/ans-openshell/gen/openshell/middlewarev1" + "github.com/agentnameservice/ans-openshell/internal/config" + "github.com/agentnameservice/ans-openshell/internal/extauth" + "github.com/agentnameservice/ans-openshell/internal/grpcserver" + "github.com/agentnameservice/ans-openshell/internal/identity" + "github.com/agentnameservice/ans-openshell/internal/middleware" + "github.com/agentnameservice/ans-openshell/internal/vetting" +) + +const ( + // DefaultListen is the gRPC listen address when the config leaves it empty. + DefaultListen = ":50051" + // DefaultHealthListen serves plaintext HTTP /healthz and /readyz for probes. + DefaultHealthListen = ":8081" + healthCheckInterval = 5 * time.Second + drainDelay = 3 * time.Second +) + +// Config is the generic middleware configuration. It is the YAML file shape the +// production binary loads; callers that layer extra capabilities keep their own +// (for example commerce) config separate and wire it through an Option. +type Config struct { + Listen string `yaml:"listen"` + // HealthListen serves plaintext HTTP /healthz and /readyz for probes. + // Empty disables it; the default is :8081. + HealthListen string `yaml:"health_listen"` + TLS config.TLS `yaml:"tls"` + Auth config.Auth `yaml:"auth"` + TransparencyLog config.TransparencyLog `yaml:"transparency_log"` + Identities map[string]identity.Config `yaml:"identities"` + SandboxIdentities map[string]string `yaml:"sandbox_identities"` + // Vetting enables destination vetting when present; policies may only set + // destination_policy if it is. + Vetting *config.Vetting `yaml:"vetting"` + // MaxPayloadBytes and RequestTimeout are advertised in the manifest binding. + MaxPayloadBytes uint64 `yaml:"max_payload_bytes"` + RequestTimeout time.Duration `yaml:"request_timeout"` +} + +// options collects the optional customizations a caller may apply. +type options struct { + version string + customize func(context.Context, *middleware.Service, *slog.Logger) error +} + +// Option customizes a Run invocation. +type Option func(*options) + +// WithVersion sets the implementation version advertised in the manifest and logs. +func WithVersion(v string) Option { + return func(o *options) { + if v != "" { + o.version = v + } + } +} + +// WithCustomize registers a hook that runs after the *middleware.Service is +// built and before the server starts, letting the caller register capability +// transforms and a discoverer (e.g. svc.AddTransform / svc.WithDiscoverer). The +// hook may start background work; returning an error aborts startup. +func WithCustomize(fn func(context.Context, *middleware.Service, *slog.Logger) error) Option { + return func(o *options) { o.customize = fn } +} + +// Run assembles the middleware from cfg and serves it until ctx is cancelled. +// It wires identity, caller authentication, optional destination vetting, the +// gRPC server and readiness, then applies any options (which may register +// capability transforms) before serving. +func Run(ctx context.Context, cfg Config, logger *slog.Logger, opts ...Option) error { + o := options{version: "dev"} + for _, apply := range opts { + apply(&o) + } + return serve(ctx, cfg, o, logger) +} + +func serve(ctx context.Context, cfg Config, o options, logger *slog.Logger) error { + if cfg.Listen == "" { + cfg.Listen = DefaultListen + } + // An empty HealthListen disables the health endpoints; callers that want the + // default apply it before calling (see the production main). We do not + // default it here so tests and embedders can opt out. + if len(cfg.Identities) == 0 { + return errors.New("identities: at least one identity is required") + } + + auth, err := extauth.NewVerifier(cfg.Auth, logger) + if err != nil { + return err + } + go auth.Run(ctx) + if !cfg.TLS.Enabled() && !cfg.Auth.AllowInsecureNoAuth { + logger.WarnContext(ctx, "listening without TLS while extension authentication is on; gateway bearer tokens travel in the clear") + } + + registry, err := identity.NewRegistry(ctx, cfg.Identities, cfg.TransparencyLog, logger) + if err != nil { + return err + } + defer registry.Close() + authz, err := middleware.NewSandboxIdentities(cfg.SandboxIdentities, registry) + if err != nil { + return err + } + + var vet vetting.Verifier + if cfg.Vetting != nil { + vetSvc, vetErr := vetting.New(*cfg.Vetting, vetting.Deps{}, logger) + if vetErr != nil { + return vetErr + } + defer vetSvc.Close() + vet = vetSvc + } + + srv, err := grpcserver.New(ctx, grpcserver.Options{ + Listen: cfg.Listen, + HealthListen: cfg.HealthListen, + TLS: cfg.TLS, + Unary: []grpc.UnaryServerInterceptor{auth.UnaryInterceptor()}, + Stream: []grpc.StreamServerInterceptor{auth.StreamInterceptor()}, + DrainDelay: drainDelay, + }, logger) + if err != nil { + return err + } + svc := middleware.NewService(registry, authz, vet, serviceConfig(cfg, o.version), logger) + + // Apply caller customizations (e.g. register capability transforms). + if o.customize != nil { + if err := o.customize(ctx, svc, logger); err != nil { + return err + } + } + + middlewarev1.RegisterSupervisorMiddlewareServer(srv.Registrar(), svc) + middlewarev1.RegisterHttpResponsePreReturnServer(srv.Registrar(), middleware.NewPreReturnService(logger)) + logger.InfoContext(ctx, "http response pre-return stage enabled") + go srv.WatchReadiness(ctx, healthCheckInterval, readinessProbe(ctx, registry, auth, logger)) + logger.InfoContext(ctx, "ans-openshell-middleware starting", + "version", o.version, "identities", registry.Names(), "vetting", vet != nil) + return srv.Serve(ctx) +} + +// serviceConfig maps the generic configuration onto the middleware service. +func serviceConfig(cfg Config, version string) middleware.Config { + c := middleware.Config{ + Audience: cfg.Auth.Audience, + MaxPayloadBytes: cfg.MaxPayloadBytes, + RequestTimeout: cfg.RequestTimeout, + ImplementationVersion: version, + RequireAuthenticatedCaller: !cfg.Auth.AllowInsecureNoAuth, + } + if cfg.Vetting != nil { + c.VettingBudget = cfg.Vetting.RequestBudget + } + return c +} + +// keyHolder reports whether a gateway verification key is held; +// extauth.Verifier satisfies it. +type keyHolder interface{ Ready() bool } + +// identityHealth reports the configured identities and which of them lack +// usable artifacts; identity.Registry satisfies it. +type identityHealth interface { + Names() []string + Unhealthy() []string +} + +// readinessProbe ties readiness to what the replica can actually do: it must +// hold a gateway verification key and at least one identity with usable +// artifacts. A single identity whose artifacts lapse is denied per request and +// logged here, but does not take the other identities out of service. Every +// change of state is logged, the first evaluation included. +func readinessProbe(ctx context.Context, registry identityHealth, auth keyHolder, logger *slog.Logger) func() bool { + var last *healthState + return func() bool { + next := healthState{authReady: auth.Ready(), unhealthy: registry.Unhealthy(), total: len(registry.Names())} + if last == nil || next.ready() != last.ready() || !slices.Equal(next.unhealthy, last.unhealthy) { + logger.LogAttrs(ctx, next.level(), "readiness evaluated", + slog.Bool("ready", next.ready()), slog.Bool("gateway_keys", next.authReady), + slog.Any("unhealthy_identities", next.unhealthy)) + } + last = &next + return next.ready() + } +} + +type healthState struct { + authReady bool + unhealthy []string + total int +} + +func (h healthState) ready() bool { return h.authReady && len(h.unhealthy) < h.total } + +func (h healthState) level() slog.Level { + if h.ready() && len(h.unhealthy) == 0 { + return slog.LevelInfo + } + return slog.LevelWarn +} diff --git a/cmd/ans-openshell-middleware/main_test.go b/app/app_test.go similarity index 93% rename from cmd/ans-openshell-middleware/main_test.go rename to app/app_test.go index fd20ec9..b344096 100644 --- a/cmd/ans-openshell-middleware/main_test.go +++ b/app/app_test.go @@ -1,4 +1,4 @@ -package main +package app import ( "bytes" @@ -20,6 +20,9 @@ import ( "github.com/agentnameservice/ans-openshell/internal/testfixtures" ) +// version stands in for the build-time version in these tests. +const version = "test" + func TestServeSandboxApprovals(t *testing.T) { t.Parallel() const startupTimeout = 10 * time.Second @@ -45,7 +48,7 @@ func TestServeSandboxApprovals(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), startupTimeout) defer cancel() logs := &startupLogWriter{cancel: cancel} - err := serve(ctx, cfg, slog.New(slog.NewTextHandler(logs, nil))) + err := serve(ctx, cfg, options{version: version}, slog.New(slog.NewTextHandler(logs, nil))) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("serve() error = %v, want %q before listener validation", err, tt.wantErr) @@ -64,7 +67,7 @@ func TestServeSandboxApprovals(t *testing.T) { // loadStartupConfig writes a complete middleware configuration with one static // identity; auth is the indented body of the auth block. -func loadStartupConfig(t *testing.T, approval, auth, healthListen string) fileConfig { +func loadStartupConfig(t *testing.T, approval, auth, healthListen string) Config { t.Helper() dir := t.TempDir() id := testfixtures.MustNewIdentity("test-agent-id", "ans://v1.0.0.test.example") @@ -102,7 +105,7 @@ sandbox_identities: %s if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { t.Fatal(err) } - var cfg fileConfig + var cfg Config if err := config.Load(path, &cfg); err != nil { t.Fatal(err) } @@ -126,31 +129,31 @@ func TestServiceConfig(t *testing.T) { t.Parallel() tests := []struct { name string - cfg fileConfig + cfg Config want middleware.Config }{ { name: "vetting budget propagates", - cfg: fileConfig{Auth: config.Auth{Audience: "aud"}, MaxPayloadBytes: 1024, RequestTimeout: time.Second, + cfg: Config{Auth: config.Auth{Audience: "aud"}, MaxPayloadBytes: 1024, RequestTimeout: time.Second, Vetting: &config.Vetting{RequestBudget: 150 * time.Millisecond}}, want: middleware.Config{Audience: "aud", MaxPayloadBytes: 1024, RequestTimeout: time.Second, ImplementationVersion: version, VettingBudget: 150 * time.Millisecond, RequireAuthenticatedCaller: true}, }, { name: "no vetting block", - cfg: fileConfig{Auth: config.Auth{Audience: "aud"}}, + cfg: Config{Auth: config.Auth{Audience: "aud"}}, want: middleware.Config{Audience: "aud", ImplementationVersion: version, RequireAuthenticatedCaller: true}, }, { name: "insecure local mode does not require an authenticated caller", - cfg: fileConfig{Auth: config.Auth{AllowInsecureNoAuth: true}}, + cfg: Config{Auth: config.Auth{AllowInsecureNoAuth: true}}, want: middleware.Config{ImplementationVersion: version}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - if got := serviceConfig(tt.cfg); got != tt.want { + if got := serviceConfig(tt.cfg, version); got != tt.want { t.Fatalf("serviceConfig() = %+v, want %+v", got, tt.want) } }) @@ -215,7 +218,7 @@ func TestServeNotReadyWithoutGatewayKeys(t *testing.T) { defer cancel() logs := newHealthLogWatcher() done := make(chan error, 1) - go func() { done <- serve(ctx, cfg, slog.New(slog.NewTextHandler(logs, nil))) }() + go func() { done <- serve(ctx, cfg, options{version: version}, slog.New(slog.NewTextHandler(logs, nil))) }() if code := readyzStatus(ctx, t, logs); code != http.StatusServiceUnavailable { t.Fatalf("/readyz without gateway keys = %d, want 503", code) } diff --git a/cmd/ans-openshell-middleware/main.go b/cmd/ans-openshell-middleware/main.go index dc6b945..1f60026 100644 --- a/cmd/ans-openshell-middleware/main.go +++ b/cmd/ans-openshell-middleware/main.go @@ -1,5 +1,15 @@ // Command ans-openshell-middleware serves the OpenShell supervisor middleware // that signs a sandbox's outbound requests as its registered ANS agent. +// +// This is the production binary: generic middleware only. Capability transforms +// such as the commerce tutorial are NOT built in; they live in the tutorial +// module (tutorial/cmd/ans-demo-middleware), which reuses the same app.Run and +// registers its transform via an option. +// +// Because app.Config has no commerce field and the config loader is strict +// (unknown keys are errors), this binary rejects a config that still carries a +// commerce: block. That block belongs to the demo binary; see the tutorial +// module and deploy/live-demo. package main import ( @@ -7,53 +17,18 @@ import ( "errors" "flag" "fmt" - "log/slog" "os" "os/signal" - "slices" "syscall" - "time" - "google.golang.org/grpc" - - "github.com/agentnameservice/ans-openshell/gen/openshell/middlewarev1" + "github.com/agentnameservice/ans-openshell/app" "github.com/agentnameservice/ans-openshell/internal/config" - "github.com/agentnameservice/ans-openshell/internal/extauth" - "github.com/agentnameservice/ans-openshell/internal/grpcserver" - "github.com/agentnameservice/ans-openshell/internal/identity" "github.com/agentnameservice/ans-openshell/internal/logging" - "github.com/agentnameservice/ans-openshell/internal/middleware" - "github.com/agentnameservice/ans-openshell/internal/vetting" ) // version is set at build time with -ldflags "-X main.version=...". var version = "dev" -const ( - defaultListen = ":50051" - defaultHealthListen = ":8081" - healthCheckInterval = 5 * time.Second - drainDelay = 3 * time.Second -) - -type fileConfig struct { - Listen string `yaml:"listen"` - // HealthListen serves plaintext HTTP /healthz and /readyz for probes. - // Empty disables it; the default is :8081. - HealthListen string `yaml:"health_listen"` - TLS config.TLS `yaml:"tls"` - Auth config.Auth `yaml:"auth"` - TransparencyLog config.TransparencyLog `yaml:"transparency_log"` - Identities map[string]identity.Config `yaml:"identities"` - SandboxIdentities map[string]string `yaml:"sandbox_identities"` - // Vetting enables destination vetting when present; policies may only set - // destination_policy if it is. - Vetting *config.Vetting `yaml:"vetting"` - // MaxPayloadBytes and RequestTimeout are advertised in the manifest binding. - MaxPayloadBytes uint64 `yaml:"max_payload_bytes"` - RequestTimeout time.Duration `yaml:"request_timeout"` -} - func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "ans-openshell-middleware:", err) @@ -81,132 +56,18 @@ func run() error { if err != nil { return err } - var cfg fileConfig + var cfg app.Config if err := config.Load(*configPath, &cfg); err != nil { return err } if *listen != "" { cfg.Listen = *listen } - if cfg.Listen == "" { - cfg.Listen = defaultListen - } if cfg.HealthListen == "" { - cfg.HealthListen = defaultHealthListen - } - if len(cfg.Identities) == 0 { - return errors.New("identities: at least one identity is required") + cfg.HealthListen = app.DefaultHealthListen } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - return serve(ctx, cfg, logger) -} -func serve(ctx context.Context, cfg fileConfig, logger *slog.Logger) error { - auth, err := extauth.NewVerifier(cfg.Auth, logger) - if err != nil { - return err - } - go auth.Run(ctx) - if !cfg.TLS.Enabled() && !cfg.Auth.AllowInsecureNoAuth { - logger.WarnContext(ctx, "listening without TLS while extension authentication is on; gateway bearer tokens travel in the clear") - } - - registry, err := identity.NewRegistry(ctx, cfg.Identities, cfg.TransparencyLog, logger) - if err != nil { - return err - } - defer registry.Close() - authz, err := middleware.NewSandboxIdentities(cfg.SandboxIdentities, registry) - if err != nil { - return err - } - - var vet vetting.Verifier - if cfg.Vetting != nil { - vetSvc, vetErr := vetting.New(*cfg.Vetting, vetting.Deps{}, logger) - if vetErr != nil { - return vetErr - } - defer vetSvc.Close() - vet = vetSvc - } - - srv, err := grpcserver.New(ctx, grpcserver.Options{ - Listen: cfg.Listen, - HealthListen: cfg.HealthListen, - TLS: cfg.TLS, - Unary: []grpc.UnaryServerInterceptor{auth.UnaryInterceptor()}, - Stream: []grpc.StreamServerInterceptor{auth.StreamInterceptor()}, - DrainDelay: drainDelay, - }, logger) - if err != nil { - return err - } - svc := middleware.NewService(registry, authz, vet, serviceConfig(cfg), logger) - middlewarev1.RegisterSupervisorMiddlewareServer(srv.Registrar(), svc) - go srv.WatchReadiness(ctx, healthCheckInterval, readinessProbe(ctx, registry, auth, logger)) - logger.InfoContext(ctx, "ans-openshell-middleware starting", - "version", version, "identities", registry.Names(), "vetting", vet != nil) - return srv.Serve(ctx) -} - -// serviceConfig maps the file configuration onto the middleware service. -func serviceConfig(cfg fileConfig) middleware.Config { - c := middleware.Config{ - Audience: cfg.Auth.Audience, - MaxPayloadBytes: cfg.MaxPayloadBytes, - RequestTimeout: cfg.RequestTimeout, - ImplementationVersion: version, - RequireAuthenticatedCaller: !cfg.Auth.AllowInsecureNoAuth, - } - if cfg.Vetting != nil { - c.VettingBudget = cfg.Vetting.RequestBudget - } - return c -} - -// keyHolder reports whether a gateway verification key is held; -// extauth.Verifier satisfies it. -type keyHolder interface{ Ready() bool } - -// identityHealth reports the configured identities and which of them lack -// usable artifacts; identity.Registry satisfies it. -type identityHealth interface { - Names() []string - Unhealthy() []string -} - -// readinessProbe ties readiness to what the replica can actually do: it must -// hold a gateway verification key and at least one identity with usable -// artifacts. A single identity whose artifacts lapse is denied per request and -// logged here, but does not take the other identities out of service. Every -// change of state is logged, the first evaluation included. -func readinessProbe(ctx context.Context, registry identityHealth, auth keyHolder, logger *slog.Logger) func() bool { - var last *healthState - return func() bool { - next := healthState{authReady: auth.Ready(), unhealthy: registry.Unhealthy(), total: len(registry.Names())} - if last == nil || next.ready() != last.ready() || !slices.Equal(next.unhealthy, last.unhealthy) { - logger.LogAttrs(ctx, next.level(), "readiness evaluated", - slog.Bool("ready", next.ready()), slog.Bool("gateway_keys", next.authReady), - slog.Any("unhealthy_identities", next.unhealthy)) - } - last = &next - return next.ready() - } -} - -type healthState struct { - authReady bool - unhealthy []string - total int -} - -func (h healthState) ready() bool { return h.authReady && len(h.unhealthy) < h.total } - -func (h healthState) level() slog.Level { - if h.ready() && len(h.unhealthy) == 0 { - return slog.LevelInfo - } - return slog.LevelWarn + return app.Run(ctx, cfg, logger, app.WithVersion(version)) } diff --git a/cmd/ans-reference-callee/main.go b/cmd/ans-reference-callee/main.go index 195abdb..0ccfbf2 100644 --- a/cmd/ans-reference-callee/main.go +++ b/cmd/ans-reference-callee/main.go @@ -37,29 +37,42 @@ func main() { func run() error { var ( - listen = flag.String("listen", ":8443", "address to listen on") - rootKeysFile = flag.String("root-keys-file", "", "file of newline-delimited C2SP transparency log root keys (required)") - tlsCert = flag.String("tls-cert", "", "serving certificate PEM (plain HTTP when empty)") - tlsKey = flag.String("tls-key", "", "serving key PEM") - trustedHosts stringList - allowedNames stringList + listen = flag.String("listen", ":8443", "address to listen on") + rootKeysFile = flag.String("root-keys-file", "", "file of newline-delimited C2SP transparency log root keys (required unless --tl-base-url is set)") + tlBaseURL = flag.String("tl-base-url", "", "transparency log base URL for live root-key refresh") + sellerURL = flag.String("seller-url", "", "upstream seller URL for forwarding verified requests") + requireReceipt = flag.Bool("require-receipt", true, "require SCITT receipts on incoming requests") + tlsCert = flag.String("tls-cert", "", "serving certificate PEM (plain HTTP when empty)") + tlsKey = flag.String("tls-key", "", "serving key PEM") + trustedHosts stringList + allowedNames stringList ) flag.Var(&trustedHosts, "trusted-host", "externally visible authority (host[:port]) callers sign for; repeatable, required") flag.Var(&allowedNames, "allow-ans-name", "ans:// name to accept; repeatable; all proven agents when empty") flag.Parse() - if *rootKeysFile == "" { - return errors.New("--root-keys-file is required") - } - keys, err := callee.ReadRootKeys(*rootKeysFile) - if err != nil { - return err + var keys []string + if *rootKeysFile != "" { + var err error + keys, err = callee.ReadRootKeys(*rootKeysFile) + if err != nil { + return err + } + } else if *tlBaseURL == "" { + return errors.New("--root-keys-file is required when --tl-base-url is not set") } logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - h, err := callee.New(ctx, callee.Config{RootKeys: keys, TrustedHosts: trustedHosts, AllowedAnsNames: allowedNames}, logger) + h, err := callee.New(ctx, callee.Config{ + TLBaseURL: *tlBaseURL, + SellerURL: *sellerURL, + RequireReceipt: *requireReceipt, + RootKeys: keys, + TrustedHosts: trustedHosts, + AllowedAnsNames: allowedNames, + }, logger) if err != nil { return err } diff --git a/deploy/examples/gateway-tier2.toml b/deploy/examples/gateway-tier2.toml new file mode 100644 index 0000000..0ac568d --- /dev/null +++ b/deploy/examples/gateway-tier2.toml @@ -0,0 +1,16 @@ +# OpenShell gateway Tier 2 demo configuration. +# Two-agent egress demo: agent A (commerce) and agent B (dev-mode rogue). + +[[openshell.supervisor.middleware]] +name = "ans-egress-a" +grpc_endpoint = "http://:50051" +allow_insecure_transport = true +max_payload_bytes = 262144 +timeout = "5s" + +[[openshell.supervisor.middleware]] +name = "ans-egress-b" +grpc_endpoint = "http://:50052" +allow_insecure_transport = true +max_payload_bytes = 262144 +timeout = "5s" diff --git a/deploy/examples/middleware-agent-a.yaml b/deploy/examples/middleware-agent-a.yaml new file mode 100644 index 0000000..0f09c48 --- /dev/null +++ b/deploy/examples/middleware-agent-a.yaml @@ -0,0 +1,41 @@ +# ans-openshell-middleware --config middleware-agent-a.yaml +# Agent A: Registered buyer identity with commerce enabled for Tier 2 demo +listen: ":50051" +health_listen: ":8091" +tls: + cert_file: /etc/ans-openshell/tls/tls.crt + key_file: /etc/ans-openshell/tls/tls.key +auth: + gateway_issuer: openshell-gateway:prod-1 + audience: urn:openshell:extension:middleware:ans + jwks_file: /etc/ans-openshell/gateway-jwks.json + jwks_url: https://openshell-gateway.openshell.svc/.well-known/jwks.json +transparency_log: + base_url: https://transparency.ans.godaddy.com + root_keys_file: /etc/ans-openshell/tl-root-keys.txt +identities: + buyer-agent: + ans_name: ans://v2.0.0.buyer.example.com + agent_id: 00000000-0000-0000-0000-000000000000 + key_file: /.secrets/buyer.example.com/key.pem + cert_file: /.secrets/buyer.example.com/cert.pem + dev_mode: false +sandbox_identities: {} +vetting: + trusted_tl_domains: [transparency.ans.godaddy.com] + cache_ttl: 10m + request_budget: 150ms + exempt_hosts: ["api.openai.com", "*.amazonaws.com"] +max_payload_bytes: 262144 +commerce: + seller_mcp_url: https://seller.skybound-travel.com/mcp/ + authority_mcp_url: https://authority.skybound-travel.com/mcp/ + seller_ans: ans://v1.0.8.seller.skybound-travel.com + authority_ans: ans://v1.0.8.authority.skybound-travel.com + scope_hint: purchase:flight:demo + subject_ans: ans://v2.0.0.buyer.example.com + commerce_dpop_key_path: /.secrets/buyer.example.com/commerce-dpop.pem + demo_quote_id: demo-quote-001 + demo_amount: 500 + demo_currency: USD + max_body_bytes: 10000 diff --git a/deploy/examples/middleware-agent-b.yaml b/deploy/examples/middleware-agent-b.yaml new file mode 100644 index 0000000..e398352 --- /dev/null +++ b/deploy/examples/middleware-agent-b.yaml @@ -0,0 +1,27 @@ +# ans-openshell-middleware --config middleware-agent-b.yaml +# Agent B: Dev-mode self-signed rogue identity with no commerce for Tier 2 demo +listen: ":50052" +health_listen: ":8092" +tls: + cert_file: /etc/ans-openshell/tls/tls.crt + key_file: /etc/ans-openshell/tls/tls.key +auth: + gateway_issuer: openshell-gateway:prod-1 + audience: urn:openshell:extension:middleware:ans + jwks_file: /etc/ans-openshell/gateway-jwks.json + jwks_url: https://openshell-gateway.openshell.svc/.well-known/jwks.json +transparency_log: + base_url: https://transparency.ans.godaddy.com + root_keys_file: /etc/ans-openshell/tl-root-keys.txt +identities: + rogue-agent: + ans_name: ans://v9.9.9.rogue.example + agent_id: f7b8c9d0-e1f2-4a3b-9c8d-7e6f5a4b3c2d + dev_mode: true +sandbox_identities: {} +vetting: + trusted_tl_domains: [transparency.ans.godaddy.com] + cache_ttl: 10m + request_budget: 150ms + exempt_hosts: ["api.openai.com", "*.amazonaws.com"] +max_payload_bytes: 262144 diff --git a/deploy/examples/policy-a.yaml b/deploy/examples/policy-a.yaml new file mode 100644 index 0000000..04ef773 --- /dev/null +++ b/deploy/examples/policy-a.yaml @@ -0,0 +1,32 @@ +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/bin, /usr, /lib, /proc, /dev/urandom, /etc, /var/log] + read_write: [/tmp, /dev/null] + +landlock: + compatibility: best_effort + +network_policies: + ingress: + name: ingress + endpoints: + - host: + port: 50060 + protocol: rest + enforcement: enforce + access: full + binaries: + - { path: /bin/busybox } + - { path: /usr/bin/wget } + +network_middlewares: + ans-egress-a: + name: ans-egress-a + middleware: ans-egress-a + order: 0 + config: {} + on_error: fail_closed + endpoints: + include: [""] diff --git a/deploy/examples/policy-b.yaml b/deploy/examples/policy-b.yaml new file mode 100644 index 0000000..86a4f39 --- /dev/null +++ b/deploy/examples/policy-b.yaml @@ -0,0 +1,32 @@ +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/bin, /usr, /lib, /proc, /dev/urandom, /etc, /var/log] + read_write: [/tmp, /dev/null] + +landlock: + compatibility: best_effort + +network_policies: + ingress: + name: ingress + endpoints: + - host: + port: 50060 + protocol: rest + enforcement: enforce + access: full + binaries: + - { path: /bin/busybox } + - { path: /usr/bin/wget } + +network_middlewares: + ans-egress-b: + name: ans-egress-b + middleware: ans-egress-b + order: 0 + config: {} + on_error: fail_closed + endpoints: + include: [""] diff --git a/deploy/live-demo/.env.example b/deploy/live-demo/.env.example new file mode 100644 index 0000000..590d79a --- /dev/null +++ b/deploy/live-demo/.env.example @@ -0,0 +1,56 @@ +# Live two-agent OpenShell demo — environment. +# +# Copy to `.env` (gitignored) and fill in. run-demo.sh sources this file and +# renders all configs from it, so this is the SINGLE source of truth. +# +# cp deploy/live-demo/.env.example deploy/live-demo/.env # then edit +# +# NOTHING here is committed with real values. The buyer identity is a +# disposable demo identity (no wallet); its private keys are shared out-of-band +# (a .env + the 3 PEM files), never through git. See SECRETS.md. + +# ---- Paths ---------------------------------------------------------------- +# WORK: scratch dir holding the OpenShell CLI, gateway PKI, rendered configs, +# and the materialized secrets. Must already contain bin/openshell + pki/. +WORK="$HOME/openshell-live" +# REPO: this repository checkout (for the Go binaries under ./bin). +REPO="$HOME/ans-openshell" +# HOST_IP: your WSL eth0 IP (ip route get 1.1.1.1 | awk '{print $7}'). +HOST_IP="10.0.0.0" + +# ---- Buyer agent (Agent A) — the registered, commerce-enabled identity ---- +# PUBLIC identifiers (safe, but kept here so the repo carries no values): +BUYER_ANS_NAME="ans://vX.Y.Z.buyer.example.com" +BUYER_AGENT_ID="00000000-0000-0000-0000-000000000000" +# The 3 identity files (absolute paths). key.pem + commerce-dpop.pem are the +# PRIVATE keys the agent owner supplies out-of-band; cert.pem is public. +BUYER_KEY_FILE="$WORK/secrets/buyer/key.pem" +BUYER_CERT_FILE="$WORK/secrets/buyer/cert.pem" +BUYER_COMMERCE_DPOP_KEY="$WORK/secrets/buyer/commerce-dpop.pem" + +# ---- Rogue agent (Agent B) — dev-mode, self-signed, no commerce ----------- +ROGUE_ANS_NAME="ans://v9.9.9.rogue.example" +ROGUE_AGENT_ID="00000000-0000-0000-0000-000000000b0b" + +# ---- Transparency log (GoDaddy production) -------------------------------- +TL_BASE_URL="https://transparency.ans.godaddy.com" +TL_ROOT_KEYS_FILE="$WORK/tl-root-keys.txt" + +# ---- Commerce (seller + authority the buyer transacts with) --------------- +SELLER_MCP_URL="https://seller.skybound-travel.com/mcp/" +AUTHORITY_MCP_URL="https://authority.skybound-travel.com/mcp/" +SELLER_ANS="ans://vX.Y.Z.seller.example.com" +AUTHORITY_ANS="ans://vX.Y.Z.authority.example.com" +SCOPE_HINT="purchase:flight:demo" +# subject_ans is normally the buyer's own ANS name: +SUBJECT_ANS="$BUYER_ANS_NAME" +DEMO_QUOTE_ID="demo-quote-001" +DEMO_AMOUNT="500" +DEMO_CURRENCY="USD" + +# ---- Gateway / wiring (rarely changed) ------------------------------------ +OPENSHELL_GATEWAY_ENDPOINT="https://127.0.0.1:17670" +EGRESS_A_LISTEN="0.0.0.0:50051" +EGRESS_B_LISTEN="0.0.0.0:50052" +INGRESS_PORT="50060" # only used by the echo topology; unused here +MAX_PAYLOAD_BYTES="262144" diff --git a/deploy/live-demo/README.md b/deploy/live-demo/README.md new file mode 100644 index 0000000..162456f --- /dev/null +++ b/deploy/live-demo/README.md @@ -0,0 +1,179 @@ +# Live two-agent OpenShell commerce demo + +Proves the whole chain end-to-end against the **live** seller: + +- **Act 1 — registered buyer:** an agent in an OpenShell sandbox calls + `https://seller.skybound-travel.com/mcp/`. The Go `ans-egress` middleware + injects the AP2 mandate + in-body DPoP + ANS identity proof; the live seller + verifies and returns a **ticket**. +- **Act 2 — dev-mode rogue:** the same call from a rogue agent with no commerce + capability is **rejected** by the live seller (`MANDATE_REJECTED`), and our + pre-return stage surfaces the capability-negotiation `middleware_hint`. + +This is the **direct-to-seller** topology (sandbox → egress → live seller). It +is deliberately different from `deploy/examples/`, which is the echo/ingress +topology (sandbox → egress → local `ans-reference-callee` → seller) used by the +unit tests. Don't mix them up: the direct-to-seller policies route by the seller +**hostname**, the examples route to a local ingress IP. + +> **Prerequisite — you supply the identity, this repo does not.** The demo +> signs each request with a **real ANS buyer identity**: a GoDaddy-issued +> certificate whose SAN is `ans://…` plus its private key and a commerce DPoP +> key, issued by a `GoDaddy … ANS Issuing CA`. **Those private keys are NOT in +> this repo and never will be** — they live under `$WORK/secrets/` on the operator's +> machine. Without a valid ANS identity + keys the egress cannot start or sign, +> so the demo cannot run. To run it as someone else, substitute your own +> GoDaddy-issued ANS identity throughout (`agent-a-local.yaml` ans_name/agent_id +> + the three key files, and the `subject_ans`/`seller_ans` in the commerce +> block). The committed files here are secret-free scaffolding only. + +## Why a script instead of pure copy-paste + +Each `openshell sandbox create` mints a **fresh sandbox UUID**, and the egress +only signs for sandboxes it has been told to trust (`sandbox_identities`, no +wildcards). So every run must: create the sandbox → read its UUID → bind that +UUID in the egress config → restart the egress. `run-demo.sh` does exactly that +for both agents, then runs both acts and prints PASS/FAIL. + +## Config: one `.env`, no values in the repo + +All configuration — identity names, file paths, URLs, demo constants — lives in +a single **`.env`** file. `run-demo.sh` sources it and **renders** the agent and +policy configs into `$WORK` at runtime, so the repo carries no values and no +secrets. The committed `.env.example` lists every variable with placeholders; +`.env` and `*.pem` are gitignored. The agent owner shares a filled `.env` plus +the PEM files it points at, out-of-band. See `SECRETS.md` for exactly what is +required. + +## One-time setup (the parts this repo can't carry) + +The demo needs machine-local **secrets** and a **running gateway**, which live +OUTSIDE the repo (default: `~/openshell-live/`, referred to below as `$WORK`). +Nothing here is committed. + +1. **Build the Go binaries** (from the repo root): + ```sh + make demo-build # produces ./bin/ans-demo-middleware (the demo egress binary) + make build # produces ./bin/ans-reference-callee (used as the ingress) + ``` + The demo runs `ans-demo-middleware` — the production middleware plus the + commerce tutorial transform. The shipped `ans-openshell-middleware` binary + (from `make build`) is pure middleware with no commerce code, so the demo + does not use it. `make demo-build` generates a gitignored `go.work` so the + `tutorial/` module resolves the in-repo core. + +2. **Lay out `$WORK`** (`~/openshell-live` by default): + - `bin/openshell` — the OpenShell CLI (v0.1.5). + - `pki/…` — gateway mTLS + JWT material (referenced by your `gw.toml`). + - the buyer identity files wherever you like, e.g. `secrets/buyer/{key.pem, + cert.pem,commerce-dpop.pem}` + `tl-root-keys.txt` — pointed to by `.env`. + - a `gw.toml` for the gateway (absolute PKI paths + ``). The egress + and policy configs are **rendered by `run-demo.sh`**, so you do not write + them by hand. + +3. **Create your `.env`:** + ```sh + cp deploy/live-demo/.env.example deploy/live-demo/.env # then edit + ``` + Fill in `WORK`, `REPO`, `HOST_IP` (your WSL eth0 IP: `ip route get 1.1.1.1 | + awk '{print $7}'`), the four identity file paths, and the public identity + values. If an owner shared a filled `.env`, use it and just fix the paths to + where you dropped the PEM files. See `SECRETS.md`. + +4. **Start the stack** (order matters — the gateway dials every registered + middleware at boot and refuses to start if one is unreachable, so start the + egresses first). With `/usr/bin` ahead on PATH so you get the Linux docker, + not the Windows `docker.exe`: + ```sh + export PATH="/usr/bin:$PATH" + export OPENSHELL_GATEWAY_ENDPOINT="https://127.0.0.1:17670" + + # egress A and B (the script also restarts these per run, but the gateway + # needs them up at its own boot — render them first by running the script + # once, or start from the configs run-demo.sh writes into $WORK): + "$REPO/bin/ans-demo-middleware" --config "$WORK/agent-a-local.yaml" --log-level info & + "$REPO/bin/ans-demo-middleware" --config "$WORK/agent-b-local.yaml" --log-level info & + + # gateway: + "$WORK/bin/openshell-gateway" --config "$WORK/gw.toml" \ + --port 17670 --bind-address 0.0.0.0 --health-port 17671 --log-level info & + ``` + Confirm the CLI can see the gateway: `"$WORK/bin/openshell" sandbox list`. + +## Run it + +```sh +./deploy/live-demo/run-demo.sh # sources deploy/live-demo/.env +# or: ENV_FILE=/path/to/.env ./deploy/live-demo/run-demo.sh +``` + +Expected tail: + +``` +== RESULT + Act 1 (buyer -> ticket): PASS + Act 2 (rogue -> rejected): PASS +DEMO PASS +``` + +Act 1's raw line looks like: + +```json +{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"ticket_id\":\"ticket-…\",\"mandate_id\":\"mandate-…\",\"passenger_ans\":\"ans://\",\"flight\":{…,\"price\":500,\"currency\":\"USD\"},…}"}]}} +``` + +`passenger_ans` proves the registered ANS identity was attached by the egress. + +## Narrated mode (for reviewers) — `--narrate` + +The plain run above is a terse pass/fail check. For someone evaluating **ANS as +the identity layer** (e.g. the missing identity piece in an agent-security model +like SAFE), run it narrated: + +```sh +./deploy/live-demo/run-demo.sh --narrate +./deploy/live-demo/run-demo.sh --narrate --report run-report.md # also write a markdown report +``` + +`--narrate` prints a trust-boundary diagram and walks each step with **what** it +does, **why** it matters, and the **real decoded evidence** pulled live: + +- **Identity provenance** — the cert's `ans://` SAN + the **GoDaddy** issuing CA, + the production transparency-log vouch (`root-keys` HTTP 200), and that the + egress loaded the identity via the transparency-log path (not dev-mode). +- **Unmodified sandbox call** — the workload just POSTs `book_flight`; it knows + nothing about ANS, mandates, or DPoP. +- **Egress stamps identity + authorization** — the decoded **scoped mandate** + (`scope`, `max_amount`, the `jkt` key-binding, authority, validity window) and + the signed **DPoP proof** claims (`htm`/`htu`/`ath`/`iat`), proving the + authorization is cryptographic and bound, added *outside* the sandbox. +- **Seller verifies → ticket**; then the **rogue contrast**: an unvouched + dev-mode identity with no mandate is refused (`MANDATE_REJECTED`) with a + machine-readable `middleware_hint`. + +The decoded proof/mandate come from the egress's **DEBUG logs** — `--narrate` +starts the egresses at `--log-level debug` to surface them. The plain run leaves +debug off, so those lines never appear in normal operation. **No private keys +are ever printed** — only the public cert, the mandate, and the proof claims +(the `jkt` and `ath` are a key thumbprint and a hash, not secrets). + +`--report FILE` writes the same narrative + decoded artifacts to a markdown file +(ANSI-free) for after-the-fact review. + +## Gotchas (both bite in practice) + +- **Dial the hostname, not the IP.** The sandbox must POST to + `https://seller.skybound-travel.com/mcp/`. The OpenShell network policy routes + that hostname (synthetic DNS `198.18.x.x`) through the egress, so the + middleware sees `target.host == seller.skybound-travel.com` and the commerce + transform fires. Dial a raw IP and the commerce gate misses → no mandate, no + `SignURL` → the egress signs the wrong `htu` → `401 HTTP_BINDING_MISMATCH`. +- **Clock skew.** A DPoP proof dated in the seller's *future* is rejected + (`DPOP_REJECTED: proof age -Ns`). The deployed seller now tolerates up to 5s + of future skew (RFC 9449 §11.1 clock-skew leeway); the demo's own host clock + can still drift further ahead than that under WSL2, so `run-demo.sh` aborts in + preflight if it measures ≥5s ahead. Fix: `sudo systemctl restart + systemd-timesyncd`. +- **`scitt_receipt` is empty** in the ticket — expected (the live TL's RA key + isn't discoverable; the DPoP + cert chain still verify). Egress runs with the + receipt requirement relaxed for the demo. diff --git a/deploy/live-demo/RUNBOOK.md b/deploy/live-demo/RUNBOOK.md new file mode 100644 index 0000000..cc9eca6 --- /dev/null +++ b/deploy/live-demo/RUNBOOK.md @@ -0,0 +1,186 @@ +# Runbook — live two-agent OpenShell commerce demo + +A linear, do-this-then-that operator guide. Read `README.md` first for what the +demo proves and why the topology is what it is. This runbook assumes WSL2 + +Docker Desktop, the setup the reference run used. + +Legend: `$WORK` = your machine-local work dir (default `~/openshell-live`), +`$REPO` = this repository checkout. + +--- + +## 0. Prerequisites (one-time) + +You MUST have your own **ANS buyer identity** — this repo does not and cannot +ship it: + +- a GoDaddy-issued certificate with an `ans://…` SAN (`cert.pem`), +- its private key (`key.pem`), +- a commerce DPoP private key (`commerce-dpop.pem`). + +Verify the cert is what you think it is and that the key matches it: + +```sh +openssl x509 -in $WORK/secrets//cert.pem -noout -subject -ext subjectAltName +openssl x509 -in $WORK/secrets//cert.pem -noout -issuer # GoDaddy … ANS Issuing CA +# key matches cert iff these two hashes are equal: +openssl pkey -in $WORK/secrets//key.pem -pubout | openssl dgst -sha256 +openssl x509 -in $WORK/secrets//cert.pem -noout -pubkey | openssl dgst -sha256 +``` + +Other machine-local material under `$WORK` (see README §one-time-setup): +`bin/openshell` (CLI v0.1.5), `pki/` (gateway mTLS+JWT), `tl-root-keys.txt` +(pinned GoDaddy TL root keys). + +Build the binaries (the demo egress is `ans-demo-middleware` — the production +middleware plus the commerce tutorial transform; `make demo-build` generates a +gitignored `go.work` so the `tutorial/` module resolves the in-repo core): + +```sh +cd $REPO && make demo-build # ./bin/ans-demo-middleware (demo egress) +cd $REPO && make build # ./bin/ans-reference-callee (ingress) +``` + +Fill the configs from the templates in this directory (absolute paths, +`` = `ip route get 1.1.1.1 | awk '{print $7}'`, and your identity in +`agent-a-local.yaml`): + +```sh +for f in gw.toml agent-a-local.yaml agent-b-local.yaml policy-a.yaml policy-b.yaml; do + cp $REPO/deploy/live-demo/$f.template $WORK/$f # then edit $WORK/$f +done +``` + +--- + +## 1. Preflight checks (every run) + +```sh +export PATH="/usr/bin:$PATH" # Linux docker, not docker.exe +export OPENSHELL_GATEWAY_ENDPOINT="https://127.0.0.1:17670" + +# Docker daemon reachable: +/usr/bin/docker info >/dev/null && echo "docker OK" + +# Clock not ahead of the seller (seller tolerates <=5s future; WSL2 drifts): +sudo systemctl restart systemd-timesyncd && sleep 2 # resync if in doubt + +# GoDaddy production transparency log reachable: +curl -s -o /dev/null -w "TL /root-keys -> %{http_code}\n" https://transparency.ans.godaddy.com/root-keys +``` + +--- + +## 2. Start the stack (order matters) + +The gateway dials every registered middleware at boot and refuses to start if +one is unreachable — so **egresses first, gateway last**. + +```sh +cd $WORK +$REPO/bin/ans-demo-middleware --config $WORK/agent-a-local.yaml --log-level info >egress-a.log 2>&1 & +$REPO/bin/ans-demo-middleware --config $WORK/agent-b-local.yaml --log-level info >egress-b.log 2>&1 & +$WORK/bin/openshell-gateway --config $WORK/gw.toml \ + --port 17670 --bind-address 0.0.0.0 --health-port 17671 --log-level info >gateway.log 2>&1 & +``` + +Confirm each egress printed `grpc server listening` and loaded your identity +with `artifacts":"transparency_log"` (NOT `dev-mode … NOT vouched`), and that +the CLI can see the gateway: + +```sh +grep -E 'identity loaded|grpc server listening' $WORK/egress-a.log +$WORK/bin/openshell sandbox list +``` + +--- + +## 3. Run the demo + +```sh +WORK=$WORK $REPO/deploy/live-demo/run-demo.sh +``` + +The script recreates both sandboxes, binds their fresh UUIDs, restarts the +egresses, runs both acts, and prints the verdict. Success: + +``` +== RESULT + Act 1 (buyer -> ticket): PASS + Act 2 (rogue -> rejected): PASS +DEMO PASS +``` + +### 3b. Reviewer walkthrough — `--narrate` + +For a demo to someone evaluating ANS as an identity layer, run it narrated so +each step is explained and the real decoded artifacts are shown: + +```sh +WORK=$WORK $REPO/deploy/live-demo/run-demo.sh --narrate --report /tmp/ans-demo.md +``` + +This prints a trust-boundary diagram, then numbered steps: identity provenance +(cert SAN + GoDaddy issuer + transparency-log vouch) → unmodified sandbox call → +egress stamps the decoded scoped mandate + DPoP proof → seller issues the ticket +→ the rogue is refused with a machine-readable hint. `--narrate` starts the +egresses at `--log-level debug` to surface the proof/mandate it builds (off in +the plain run). `--report FILE` saves an ANSI-free markdown copy. No private keys +are printed — only the public cert, mandate, and proof claims. See README +"Narrated mode" for the field-by-field meaning. + +--- + +## 4. What "it worked" looks like (verification) + +- **Act 1** returns a ticket whose `passenger_ans` is your buyer ANS name — + proof the egress attached the real identity and the live seller honored it: + ``` + ticket_id: ticket-… mandate_id: mandate-… passenger_ans: ans://… price: 500 USD + ``` +- **Act 2** returns `MANDATE_REJECTED` with a `middleware_hint` + (`required_capabilities: [mandate, dpop_proof]`) — the rogue reached the live + seller but had no mandate, so it was refused. +- **Sandboxes are real Docker containers**, not stubs: + ```sh + /usr/bin/docker ps --format '{{.Names}} {{.Status}}' | grep ans-agent + ``` +- **Egress signed toward the public seller host** (grep the egress log): + ```sh + grep 'request signed' $WORK/egress-A.log # host: seller.skybound-travel.com, ans_name: ans://… + ``` +- **The signed artifacts themselves** — run with `--narrate` (or start the egress + at `--log-level debug`) and the egress log carries the decoded proof + mandate: + ```sh + grep -E 'dpop proof minted|mandate attached' $WORK/egress-A.log + # dpop proof minted htm=POST htu=…/mcp/ ath=… iat=… + # mandate attached scope=purchase:flight:demo max_amount=500 currency=USD jkt=… subject_ans=ans://… + ``` + Cross-check: the mandate's `max_amount`/`scope` match your `.env` demo values, + and the proof's `htu` equals the seller MCP URL — i.e. the narration reflects + the real signed request, not canned text. + +--- + +## 5. Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| preflight `ABORT: clock … AHEAD` | host clock past the seller's 5s leeway | `sudo systemctl restart systemd-timesyncd` | +| `401 HTTP_BINDING_MISMATCH` | sandbox dialed a raw IP, not the seller hostname | use the hostname policy (templates here); never `http://:…` | +| `sandbox … ValidateConfig failed: tcp connect error` at create | egress not running when the gateway validated it | start the egress before `sandbox create` (the script already orders this) | +| `address already in use` on egress restart | previous egress hadn't released its port | the script waits for exit; if run by hand, `kill` and wait before restarting | +| egress logs `dev-mode … NOT vouched` for the buyer | wrong identity/config (dev_mode true or missing cert) | point `agent-a-local.yaml` at the real GoDaddy cert + key, `dev_mode: false` | +| `MANDATE_REJECTED` for Act 1 (not Act 2) | authority unreachable or mandate not prefetched | check the egress log for `demo mandate prefetched`; confirm `authority.skybound-travel.com` reachable | + +--- + +## 6. Scope / honest caveats + +- **Real** OpenShell (Rust gateway + Docker sandboxes), **real** GoDaddy + production ANS identity, **real** sandbox→internet communication over TLS. +- The seller and authority (`*.skybound-travel.com`) are genuinely deployed and + remote, but are **operator-controlled infrastructure** (a Cloudflare Worker + + GCP services), not an independent third party. +- `scitt_receipt` is empty in the ticket by design — the live TL's receipt RA + key isn't discoverable; the DPoP + certificate chain still verify. diff --git a/deploy/live-demo/SECRETS.md b/deploy/live-demo/SECRETS.md new file mode 100644 index 0000000..38aa8c3 --- /dev/null +++ b/deploy/live-demo/SECRETS.md @@ -0,0 +1,46 @@ +# What the demo needs from the buyer agent + +The demo signs each request as a **real ANS buyer identity**. Exactly **two +private keys** are required; everything else is public or generated per run. +Nothing with a value is committed to this repo — the agent owner shares a filled +`.env` plus the PEM files it points at, out-of-band. + +## Required from the agent owner (out-of-band) + +| Item | Kind | Carried by | +|---|---|---| +| `key.pem` | **PRIVATE** — the ANS identity signing key | a PEM file; path in `.env` as `BUYER_KEY_FILE` | +| `commerce-dpop.pem` | **PRIVATE** — the commerce DPoP (spend) key | a PEM file; path in `.env` as `BUYER_COMMERCE_DPOP_KEY` | +| `cert.pem` | public — the ANS identity certificate | a PEM file; path in `.env` as `BUYER_CERT_FILE` | +| `tl-root-keys.txt` | public — pinned GoDaddy TL root key(s) | a file; path in `.env` as `TL_ROOT_KEYS_FILE` | +| `.env` scalars | public — ans_name, agent_id, URLs, demo constants | the `.env` itself | + +The two private keys authorize acting **as** the buyer ANS identity. In this +demo the buyer is a **disposable identity with no wallet**, so leaking the keys +cannot move money — but anyone holding them can present as that ANS name. Treat +the `.env` + PEMs as shareable only with people you trust to run the demo, and +revoke the identity (`ans-cli revoke --reason …`) if it is ever +misused. Do not commit them; `.gitignore` blocks `.env` and `*.pem`. + +## Setup for someone receiving the bundle + +1. Clone the repo and `cp deploy/live-demo/.env.example deploy/live-demo/.env`. +2. Drop the 3 PEMs + `tl-root-keys.txt` somewhere under your `$WORK` (e.g. + `$WORK/secrets/buyer/`). +3. Edit `.env`: set `WORK`, `REPO`, `HOST_IP`, the four file paths, and paste + the public identifiers (`BUYER_ANS_NAME`, `BUYER_AGENT_ID`, the seller / + authority ANS names) the owner gave you. Or just use the `.env` the owner + shared verbatim and only fix the paths to where you dropped the files. +4. Follow `RUNBOOK.md` from "Start the stack". + +`run-demo.sh` renders all agent/policy configs from the `.env` at runtime, so +there is nothing else to edit. + +## What `--narrate` displays + +The reviewer-facing `--narrate` mode decodes and prints only **public** material: +the certificate (SAN + issuer), the authority mandate (scope, max_amount, the +`jkt` key *thumbprint*, parties, validity), and the DPoP proof *claims* +(`htm`/`htu`/`ath`/`iat`, where `ath` is a hash of the quote id). The two +private keys (`key.pem`, `commerce-dpop.pem`) are used to sign but are **never +printed** by the narrator or logged by the egress. diff --git a/deploy/live-demo/run-demo.sh b/deploy/live-demo/run-demo.sh new file mode 100755 index 0000000..21d12c3 --- /dev/null +++ b/deploy/live-demo/run-demo.sh @@ -0,0 +1,495 @@ +#!/usr/bin/env bash +# +# run-demo.sh — drive the live two-agent OpenShell commerce demo end-to-end. +# +# Act 1: a registered buyer agent (commerce-enabled) in an OpenShell sandbox +# calls the LIVE seller through the Go ans-egress middleware and gets a +# ticket. +# Act 2: a dev-mode rogue agent (no commerce) makes the same call and is +# rejected by the live seller (MANDATE_REJECTED). +# +# CONFIG: everything comes from a .env file (see .env.example). The .env is the +# single source of truth — this script RENDERS the agent + policy configs into +# $WORK from it, so there are no checked-in config values and no secrets in the +# repo. The agent owner supplies the .env plus the 3 PEM files out-of-band; the +# .env points at wherever those PEMs are dropped. +# +# The gateway must already be RUNNING (start it per README before this script). +# This script starts/stops the two egress middlewares itself. +# +# Usage: +# ./run-demo.sh # terse pass/fail (CI-style) +# ./run-demo.sh --narrate # step-by-step walkthrough with decoded artifacts +# ./run-demo.sh --narrate --report F.md # also write a markdown run report to F.md +# ENV_FILE=/path/to/.env ./run-demo.sh [--narrate] [--report FILE] +# +# --narrate explains each step (what + why) and shows the REAL decoded evidence — +# the GoDaddy-issued cert chain, the production transparency-log vouch, the signed +# DPoP proof claims, the scoped authority mandate, the issued ticket, and the +# rogue's rejection — for a reviewer evaluating ANS as an identity layer. It turns +# on the egress's DEBUG logging so the proof/mandate it builds can be displayed; +# the plain run leaves that off. No private keys are ever shown. +# +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" + +# --- args -------------------------------------------------------------------- +NARRATE=0 +REPORT="" +while [ $# -gt 0 ]; do + case "$1" in + --narrate) NARRATE=1 ;; + --report) shift; REPORT="${1:-}"; NARRATE=1 + [ -n "$REPORT" ] || { echo "--report needs a FILE argument" >&2; exit 2; } ;; + -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown argument: $1 (see --help)" >&2; exit 2 ;; + esac + shift +done +EGRESS_LOG_LEVEL=info +[ "$NARRATE" = 1 ] && EGRESS_LOG_LEVEL=debug + +# WSL2 puts the Windows docker.exe ahead of the Linux CLI on PATH; the Linux one +# lives in /usr/bin. Prepend it so docker/openshell talk to the daemon. +export PATH="/usr/bin:$PATH" + +red() { printf '\033[31m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +info() { printf '\033[36m== %s\033[0m\n' "$*"; } +fail() { red "FAIL: $*"; exit 1; } + +# --- narration (no-ops unless --narrate) ------------------------------------- +# Everything narrated is mirrored into the report file (plain text, no colors) +# when --report is set. +STEP_N=0 +report() { [ -n "$REPORT" ] && printf '%s\n' "$*" >>"$REPORT"; return 0; } +say() { [ "$NARRATE" = 1 ] || return 0; printf '%s\n' "$*"; report "$*"; } +note() { [ "$NARRATE" = 1 ] || return 0; printf ' \033[2m%s\033[0m\n' "$*"; report " $*"; } +good() { [ "$NARRATE" = 1 ] || return 0; printf ' \033[32m✓ %s\033[0m\n' "$*"; report " [OK] $*"; } +bad() { [ "$NARRATE" = 1 ] || return 0; printf ' \033[31m✗ %s\033[0m\n' "$*"; report " [X] $*"; } +step() { # step "Title" "why this matters" + [ "$NARRATE" = 1 ] || return 0 + STEP_N=$((STEP_N + 1)) + printf '\n\033[1m\033[36mSTEP %d — %s\033[0m\n' "$STEP_N" "$1" + report ""; report "## STEP $STEP_N — $1" + [ -n "${2:-}" ] && { printf ' \033[2mwhy: %s\033[0m\n' "$2"; report " why: $2"; } + return 0 +} + +# --- load .env --------------------------------------------------------------- +ENV_FILE="${ENV_FILE:-$HERE/.env}" +[ -f "$ENV_FILE" ] || fail "no env file at $ENV_FILE — copy .env.example to .env and fill it in" +set -a; . "$ENV_FILE"; set +a + +# Required vars (fail fast with a clear message if the .env is incomplete). +for v in WORK REPO HOST_IP BUYER_ANS_NAME BUYER_AGENT_ID BUYER_KEY_FILE \ + BUYER_CERT_FILE BUYER_COMMERCE_DPOP_KEY ROGUE_ANS_NAME ROGUE_AGENT_ID \ + TL_BASE_URL TL_ROOT_KEYS_FILE SELLER_MCP_URL AUTHORITY_MCP_URL \ + SELLER_ANS AUTHORITY_ANS SCOPE_HINT SUBJECT_ANS DEMO_QUOTE_ID \ + DEMO_AMOUNT DEMO_CURRENCY OPENSHELL_GATEWAY_ENDPOINT \ + EGRESS_A_LISTEN EGRESS_B_LISTEN MAX_PAYLOAD_BYTES; do + [ -n "${!v:-}" ] || fail "$ENV_FILE is missing required variable: $v" +done +export OPENSHELL_GATEWAY_ENDPOINT + +OSH="${OPENSHELL:-$WORK/bin/openshell}" +MW_BIN="${MW_BIN:-$REPO/bin/ans-demo-middleware}" +SELLER_URL="$SELLER_MCP_URL" +BODY="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"book_flight\",\"arguments\":{\"quote_id\":\"$DEMO_QUOTE_ID\",\"amount\":$DEMO_AMOUNT,\"currency\":\"$DEMO_CURRENCY\"}}}" + +A_CFG="$WORK/agent-a-local.yaml" +B_CFG="$WORK/agent-b-local.yaml" +A_POL="$WORK/policy-a.yaml" +B_POL="$WORK/policy-b.yaml" + +# --- intro + trust-boundary diagram (narrate only) --------------------------- +if [ "$NARRATE" = 1 ]; then + [ -n "$REPORT" ] && : >"$REPORT" + report "# ANS live two-agent OpenShell demo — run report" + report "_$(date -u '+%Y-%m-%d %H:%M:%SZ')_" + cat <<'DIAGRAM' + + ANS as the identity layer — live two-agent OpenShell commerce demo + ================================================================== + + What this shows: the SAME book_flight request from two agents. One carries a + real, transparency-log-vouched ANS identity + a scoped spending mandate and + transacts; the other is unvouched and is refused. Identity and authorization + are enforced END-TO-END, outside the (untrusted) sandbox workload. + + ┌─────────────────┐ plain POST ┌──────────────────┐ signed+mutated ┌────────────────┐ + │ agent in an │ book_flight │ ANS egress │ request (mandate │ LIVE seller │ + │ OpenShell sandbox│ ───────────────▶│ (middleware) │ + DPoP + identity) │ (public internet)│ + │ (UNMODIFIED) │ │ │ ──────────────────▶│ │ + └─────────────────┘ └──────────────────┘ └────────────────┘ + knows nothing the identity is the seller VERIFIES the + about ANS stamped HERE, not identity proof + mandate + inside the sandbox before issuing a ticket + + Trust anchor: GoDaddy production transparency log (transparency.ans.godaddy.com) +DIAGRAM + report "" + report '```' + report ' agent in OpenShell sandbox --plain book_flight--> ANS egress --signed+mandate--> LIVE seller' + report ' (identity stamped at the egress, OUTSIDE the untrusted sandbox; seller verifies before issuing)' + report '```' +fi + +# --- preflight --------------------------------------------------------------- +info "preflight" +[ -x "$OSH" ] || fail "openshell CLI not found/executable at $OSH (set OPENSHELL=... or bin/openshell under WORK)" +[ -x "$MW_BIN" ] || fail "demo middleware binary not at $MW_BIN — run 'make demo-build' in $REPO (or set MW_BIN=...)" +for f in "$BUYER_KEY_FILE" "$BUYER_CERT_FILE" "$BUYER_COMMERCE_DPOP_KEY" "$TL_ROOT_KEYS_FILE"; do + [ -f "$f" ] || fail "missing identity/key file: $f (the agent owner supplies the 3 PEMs out-of-band; see SECRETS.md)" +done +"$OSH" sandbox list >/dev/null 2>&1 || fail "gateway not reachable at $OPENSHELL_GATEWAY_ENDPOINT — start it first (see README)" + +# Clock skew: the seller tolerates a DPoP proof up to SELLER_SKEW_LEEWAY_SEC in +# its future (and 120s in the past). If the local clock is further ahead, Act 1 +# fails with 'proof age -Ns'. The HTTP Date header is second-granular. +SELLER_SKEW_LEEWAY_SEC="${SELLER_SKEW_LEEWAY_SEC:-5}" +seller_epoch=$(date -u -d "$(curl -sI "$SELLER_URL" 2>/dev/null | grep -i '^date:' | cut -d' ' -f2-)" +%s 2>/dev/null || echo 0) +now_epoch=$(date -u +%s) +if [ "$seller_epoch" != 0 ]; then + skew=$(( now_epoch - seller_epoch )) + if [ "$skew" -ge "$SELLER_SKEW_LEEWAY_SEC" ]; then + red "ABORT: local clock is ${skew}s AHEAD of the seller — beyond its ${SELLER_SKEW_LEEWAY_SEC}s" + red " DPoP leeway, so proofs would be rejected ('proof age -Ns'). Resync:" + red " sudo systemctl restart systemd-timesyncd" + exit 1 + fi +fi + +# --- render configs from .env ------------------------------------------------ +# All configs are generated here so the repo carries no values. sandbox_identities +# is left empty; bind_identity fills it per run. +render_configs() { + info "rendering configs into $WORK from $ENV_FILE" + mkdir -p "$WORK" + + cat >"$A_CFG" <"$B_CFG" <"$path" < prints the fresh sandbox UUID +recreate_sandbox() { + local name="$1" policy="$2" + "$OSH" sandbox delete "$name" >/dev/null 2>&1 + "$OSH" sandbox create --name "$name" --from alpine:3.20 --no-tty --detach \ + --policy "$policy" -- /bin/sh -c "sleep 3600" >/dev/null 2>&1 + local uuid="" phase="" i + for i in $(seq 1 30); do + uuid=$("$OSH" sandbox get "$name" 2>/dev/null | awk '/^ Id:/{print $2; exit}') + phase=$("$OSH" sandbox get "$name" 2>/dev/null | awk '/^ Phase:/{print $2; exit}') + [ "$phase" = "Ready" ] && break + sleep 1 + done + [ "$phase" = "Ready" ] || { red "sandbox $name never reached Ready (phase=$phase)"; return 1; } + printf '%s' "$uuid" +} + +# bind_identity CONFIG UUID IDENTITY — rewrite the single sandbox_identities entry +bind_identity() { + local cfg="$1" uuid="$2" ident="$3" + python3 - "$cfg" "$uuid" "$ident" <<'PY' +import sys, re +cfg, uuid, ident = sys.argv[1], sys.argv[2], sys.argv[3] +text = open(cfg).read() +pat = re.compile(r'(?m)^sandbox_identities:.*?(?=^\S|\Z)', re.S) +repl = f"sandbox_identities:\n {uuid}: {ident}\n" +text, n = pat.subn(repl, text) +if n == 0: + text += "\n" + repl +open(cfg, 'w').write(text) +PY +} + +EGRESS_A_PID="" +EGRESS_B_PID="" +# restart_egress WHICH CONFIG — (re)start a middleware, waiting for port release +restart_egress() { + local which="$1" cfg="$2" + local pidvar="EGRESS_${which}_PID" + local pid="${!pidvar}" + if [ -n "$pid" ]; then + kill "$pid" >/dev/null 2>&1 + local w + for w in $(seq 1 20); do + kill -0 "$pid" >/dev/null 2>&1 || break + sleep 0.3 + done + fi + : >"$WORK/egress-${which}.log" + "$MW_BIN" --config "$cfg" --log-level "$EGRESS_LOG_LEVEL" >"$WORK/egress-${which}.log" 2>&1 & + eval "$pidvar=$!" + local i + for i in $(seq 1 30); do + grep -q "grpc server listening" "$WORK/egress-${which}.log" 2>/dev/null && return 0 + if grep -q "address already in use" "$WORK/egress-${which}.log" 2>/dev/null; then + red "egress $which: port still in use — a stale middleware is holding it"; return 1 + fi + sleep 0.5 + done + red "egress $which did not report ready; see $WORK/egress-${which}.log"; return 1 +} + +cleanup() { + [ -n "$EGRESS_A_PID" ] && kill "$EGRESS_A_PID" >/dev/null 2>&1 + [ -n "$EGRESS_B_PID" ] && kill "$EGRESS_B_PID" >/dev/null 2>&1 +} +trap cleanup EXIT + +# run_booking NAME -> prints the raw seller response body +run_booking() { + local name="$1" + "$OSH" sandbox exec "$name" -- /bin/sh -c \ + "wget -O- -q --timeout=90 --header='Content-Type: application/json' --post-data='$BODY' $SELLER_URL 2>&1" 2>/dev/null +} + +# --- evidence extractors (used only when narrating) -------------------------- +# narrate_identity_provenance: show the cert's ANS SAN + GoDaddy issuer, and +# prove the egress loaded it via the PRODUCTION transparency log. +narrate_identity_provenance() { + [ "$NARRATE" = 1 ] || return 0 + local san issuer + san=$(openssl x509 -in "$BUYER_CERT_FILE" -noout -ext subjectAltName 2>/dev/null | grep -oE 'URI:ans://[^,]+' | head -1) + issuer=$(openssl x509 -in "$BUYER_CERT_FILE" -noout -issuer 2>/dev/null | sed 's/^issuer=//') + say "Agent A presents a real ANS identity:" + good "ANS name (cert SAN): ${san:-}" + good "issued by: ${issuer:-}" + # Production transparency-log reachability + the egress's own 'vouched' signal. + local tl_code + tl_code=$(curl -s -o /dev/null -w '%{http_code}' "$TL_BASE_URL/root-keys" 2>/dev/null) + if [ "$tl_code" = "200" ]; then + good "vouched via production transparency log: $TL_BASE_URL (root-keys HTTP 200)" + else + note "transparency log $TL_BASE_URL/root-keys returned HTTP ${tl_code:-?}" + fi + grep -q '"artifacts":"transparency_log"' "$WORK/egress-A.log" 2>/dev/null \ + && good "egress loaded the identity from the transparency-log artifact path (not dev-mode)" +} + +# narrate_egress_signing: pull the decoded DPoP proof + mandate the egress built +# (emitted at DEBUG by the middleware) and the 'request signed' line. +narrate_egress_signing() { + [ "$NARRATE" = 1 ] || return 0 + local log="$WORK/egress-A.log" proofline mandline signed + proofline=$(grep -m1 'dpop proof minted' "$log" 2>/dev/null) + mandline=$(grep -m1 'mandate attached' "$log" 2>/dev/null) + signed=$(grep -m1 'request signed' "$log" 2>/dev/null) + if [ -n "$mandline" ]; then + say "The egress fetched a SCOPED mandate from the authority and bound it to the agent:" + good "mandate: scope=$(kv "$mandline" scope) max_amount=$(kv "$mandline" max_amount) $(kv "$mandline" currency)" + good "bound to: jkt=$(kv "$mandline" jkt) subject=$(kv "$mandline" subject_ans)" + good "authority: $(kv "$mandline" authority_ans) valid: $(kv "$mandline" not_before) .. $(kv "$mandline" not_after)" + fi + if [ -n "$proofline" ]; then + say "The egress signed a DPoP proof binding method + target URL to this call:" + good "dpop: htm=$(kv "$proofline" htm) htu=$(kv "$proofline" htu)" + good " ath=$(kv "$proofline" ath) (= base64url(sha256(quote_id))) iat=$(kv "$proofline" iat)" + fi + [ -n "$signed" ] && good "request signed as ans_name=$(kv "$signed" ans_name) (stamped at the egress, outside the sandbox)" +} + +# kv LINE KEY -> value for KEY from a slog logfmt/JSON line (handles both quoted +# and bare values). Best-effort; narration only. +kv() { + local line="$1" key="$2" v + v=$(printf '%s' "$line" | grep -oE "\"$key\":\"[^\"]*\"" | head -1 | sed -E "s/\"$key\":\"([^\"]*)\"/\1/") + [ -z "$v" ] && v=$(printf '%s' "$line" | grep -oE "\"$key\":[0-9.]+" | head -1 | sed -E "s/\"$key\"://") + printf '%s' "${v:-?}" +} + +# --- Act 1: registered buyer -> ticket --------------------------------------- +info "Act 1 — registered buyer (commerce) should get a ticket" +say "" +say "═══ ACT 1 — the vouched, authorized agent transacts ═══" +# The gateway validates the sandbox's middleware connection at `sandbox create` +# time, so the egress MUST be up before create — then bind the fresh UUID and +# restart to pick it up. +restart_egress A "$A_CFG" || exit 1 +A_UUID=$(recreate_sandbox ans-agent-a "$A_POL") || exit 1 +green "sandbox ans-agent-a ready: $A_UUID" + +step "Identity provenance" "a provable identity, not a config string — and who vouches for it" +narrate_identity_provenance + +step "The sandboxed agent makes an UNMODIFIED call" "the workload knows nothing about ANS/mandates/DPoP; it just POSTs book_flight" +note "sandbox $A_UUID → POST $SELLER_URL (book_flight, $DEMO_AMOUNT $DEMO_CURRENCY)" + +bind_identity "$A_CFG" "$A_UUID" buyer-agent +restart_egress A "$A_CFG" || exit 1 +RESP_A=$(run_booking ans-agent-a) + +step "The egress stamps identity + authorization (OUTSIDE the sandbox)" "a compromised workload cannot strip or forge what is added here" +narrate_egress_signing + +step "The LIVE seller verifies the full chain and issues a ticket" "the counterparty independently checks identity + mandate before honoring the request" +[ "$NARRATE" = 1 ] || echo "$RESP_A" +# The ticket JSON is nested as an escaped string inside result.content[0].text, +# so match the ticket id itself (survives the \"-escaping) and require no error. +TICKET=$(echo "$RESP_A" | grep -oE 'ticket-[a-f0-9]+' | head -1) +PASSENGER=$(echo "$RESP_A" | grep -oE 'passenger_ans[^,]*' | head -1 | grep -oE 'ans://[^"\\]+') +MAND=$(echo "$RESP_A" | grep -oE 'mandate-[a-f0-9]+' | head -1) +if [ -n "$TICKET" ] && ! echo "$RESP_A" | grep -q '"error"'; then + good "ticket issued: $TICKET mandate: ${MAND:-?}" + good "passenger_ans: ${PASSENGER:-?} (proves the registered identity was attached end-to-end)" + green "Act 1 PASS — live seller issued $TICKET" + ACT1=PASS +else + bad "no ticket returned" + [ "$NARRATE" = 1 ] && echo "$RESP_A" + red "Act 1 FAIL — no ticket (see response above)" + ACT1=FAIL +fi + +# --- Act 2: rogue -> rejection ----------------------------------------------- +info "Act 2 — dev-mode rogue (no commerce) should be rejected" +say "" +say "═══ ACT 2 — the unvouched agent is refused ═══" +restart_egress B "$B_CFG" || exit 1 +B_UUID=$(recreate_sandbox ans-agent-b "$B_POL") || exit 1 +green "sandbox ans-agent-b ready: $B_UUID" + +step "A rogue identity with no transparency-log vouch" "the negative case: ANS must reject an agent it cannot vouch for" +if [ "$NARRATE" = 1 ]; then + good "rogue ANS name: $ROGUE_ANS_NAME" + grep -q 'NOT vouched by any transparency log' "$WORK/egress-B.log" 2>/dev/null \ + && bad "egress flags it: dev-mode, self-signed — NOT vouched by any transparency log" + note "this agent also has NO commerce capability, so it carries no mandate" +fi + +bind_identity "$B_CFG" "$B_UUID" rogue-agent +restart_egress B "$B_CFG" || exit 1 +RESP_B=$(run_booking ans-agent-b) + +step "The LIVE seller refuses the request" "same endpoint, same call — but without a mandate the seller will not transact" +[ "$NARRATE" = 1 ] || echo "$RESP_B" +if echo "$RESP_B" | grep -q 'MANDATE_REJECTED'; then + if [ "$NARRATE" = 1 ]; then + bad "seller response: MANDATE_REJECTED (missing or unparseable mandate)" + good "egress surfaced a machine-readable hint: required_capabilities=[mandate, dpop_proof]" + fi + green "Act 2 PASS — live seller rejected the rogue (MANDATE_REJECTED)" + ACT2=PASS +else + [ "$NARRATE" = 1 ] && echo "$RESP_B" + red "Act 2 FAIL — expected MANDATE_REJECTED (see response above)" + ACT2=FAIL +fi + +# --- verdict ----------------------------------------------------------------- +echo +info "RESULT" +echo " Act 1 (buyer -> ticket): $ACT1" +echo " Act 2 (rogue -> rejected): $ACT2" +if [ "$NARRATE" = 1 ]; then + say "" + say "Takeaway: the SAME request from two agents. The transparency-log-vouched" + say "identity with a scoped mandate transacted; the unvouched identity could not." + say "Identity and authorization were enforced end-to-end — the sandbox workload" + say "was never trusted to assert its own identity." + report ""; report "RESULT: Act 1 = $ACT1, Act 2 = $ACT2" +fi +if [ "$ACT1" = PASS ] && [ "$ACT2" = PASS ]; then + green "DEMO PASS" + [ -n "$REPORT" ] && { report "DEMO PASS"; info "run report written to $REPORT"; } + exit 0 +fi +red "DEMO FAIL" +[ -n "$REPORT" ] && report "DEMO FAIL" +exit 1 diff --git a/internal/callee/callee.go b/internal/callee/callee.go index aba9fd1..e310b5a 100644 --- a/internal/callee/callee.go +++ b/internal/callee/callee.go @@ -2,10 +2,22 @@ // authenticates callers with the SDK's pop.Middleware and reports the proven // identity. It exists to show, end to end, that a request signed by the // OpenShell middleware verifies at an ordinary agent endpoint. +// +// # Live Transparency Log Configuration +// +// When TLBaseURL is set, the callee fetches and refreshes root keys from the +// transparency log at runtime. The first live run must confirm that +// scitt.FetchRootKeys against https://transparency.ans.godaddy.com returns +// parseable C2SP keys; on divergence, that's an SDK fix/bump, not a redesign. +// The static --root-keys-file flag provides an offline-rehearsal fallback. +// The --require-receipt=false flag enables rehearsal mode: under the current TL +// implementation (which returns an empty receipt), Agent A still passes on its +// valid status-token and binding, while a rogue still fails on its status token. package callee import ( "bufio" + "bytes" "context" "encoding/json" "errors" @@ -20,6 +32,8 @@ import ( "github.com/agentnameservice/ans-sdk-go/verify/scitt" ) +const maxBodyBytes = 262144 // 256 KiB + // Config wires a Handler. type Config struct { // RootKeys are the transparency log's trusted signing keys in C2SP format. @@ -32,6 +46,14 @@ type Config struct { MaxReplayEntries int // AllowedAnsNames, when set, pins the callers this callee accepts. AllowedAnsNames []string + // TLBaseURL is the transparency log's base URL for live key fetching. + // When set, New fetches root keys from the TL and refreshes them in the + // background. + TLBaseURL string + // SellerURL is the seller endpoint for receipt validation (Task 3). + SellerURL string + // RequireReceipt controls whether receipts are mandatory (Task 2). + RequireReceipt bool } // Response is the JSON body returned to an authenticated caller. @@ -75,17 +97,68 @@ func ParseRootKeys(r io.Reader) ([]string, error) { return keys, nil } -// Handler authenticates every request and echoes the caller identity. +// Handler authenticates every request and echoes the caller identity (or +// forwards to a seller when SellerURL is configured). type Handler struct { - chain http.Handler - stop func() - logger *slog.Logger + chain http.Handler + stop func() + logger *slog.Logger + sellerURL string + sellerClient *http.Client +} + +type keystoreResult struct { + lookup scitt.KeyLookup + cancelRefresh func() +} + +// buildKeystore constructs either a live-TL refreshable keystore or a static one. +func buildKeystore(ctx context.Context, cfg Config) (keystoreResult, error) { + if cfg.TLBaseURL != "" { + return buildLiveKeystore(ctx, cfg) + } + store, err := scitt.NewKeyStore(cfg.RootKeys) + if err != nil { + return keystoreResult{}, fmt.Errorf("root keys: %w", err) + } + return keystoreResult{lookup: store}, nil +} + +func buildLiveKeystore(ctx context.Context, cfg Config) (keystoreResult, error) { + // For development/test: allow http:// URLs (production must use https). + var clientOpts []scitt.HTTPClientOption + if strings.HasPrefix(cfg.TLBaseURL, "http://") { + clientOpts = append(clientOpts, scitt.WithAllowInsecureTransport()) + } + client, err := scitt.NewHTTPClient(cfg.TLBaseURL, clientOpts...) + if err != nil { + return keystoreResult{}, fmt.Errorf("create TL client: %w", err) + } + keyStrings, err := client.FetchRootKeys(ctx) + if err != nil { + return keystoreResult{}, fmt.Errorf("fetch root keys from TL: %w", err) + } + // Fail loud if TL yields no keys and no static fallback. + if len(keyStrings) == 0 && len(cfg.RootKeys) == 0 { + return keystoreResult{}, errors.New("TL returned no root keys and no static RootKeys configured") + } + // Merge TL keys with any static keys for robustness. + allKeys := make([]string, 0, len(keyStrings)+len(cfg.RootKeys)) + allKeys = append(allKeys, keyStrings...) + allKeys = append(allKeys, cfg.RootKeys...) + store, err := scitt.NewKeyStore(allKeys) + if err != nil { + return keystoreResult{}, fmt.Errorf("build keystore: %w", err) + } + rks := scitt.NewRefreshableKeyStore(store, client) + cancel := rks.StartBackgroundRefreshDefault(ctx) + return keystoreResult{lookup: rks, cancelRefresh: cancel}, nil } // New builds a Handler. Close releases the replay cache. func New(ctx context.Context, cfg Config, logger *slog.Logger) (*Handler, error) { - if len(cfg.RootKeys) == 0 { - return nil, errors.New("callee: at least one transparency log root key is required") + if cfg.TLBaseURL == "" && len(cfg.RootKeys) == 0 { + return nil, errors.New("callee: at least one transparency log root key is required (via TLBaseURL or RootKeys)") } if len(cfg.TrustedHosts) == 0 { return nil, errors.New("callee: at least one trusted host is required") @@ -93,30 +166,103 @@ func New(ctx context.Context, cfg Config, logger *slog.Logger) (*Handler, error) if logger == nil { logger = slog.New(slog.DiscardHandler) } - keys, err := scitt.NewKeyStore(cfg.RootKeys) + + ks, err := buildKeystore(ctx, cfg) if err != nil { - return nil, fmt.Errorf("callee: root keys: %w", err) + return nil, fmt.Errorf("callee: %w", err) } + replayCtx, cancel := context.WithCancel(ctx) replay := pop.NewMemoryReplayCache(replayCtx, cfg.MaxReplayEntries) - opts := []pop.MiddlewareOption{ - pop.WithTrustedHosts(cfg.TrustedHosts...), - pop.WithMiddlewareLogger(logger), - } + // Build caller options: always include RequireReceipt, conditionally add AllowedAnsNames. + callerOpts := []pop.CallerOption{pop.WithRequireReceipt(cfg.RequireReceipt)} if len(cfg.AllowedAnsNames) > 0 { - opts = append(opts, pop.WithMiddlewareCallerOptions(pop.WithAllowedAnsNames(cfg.AllowedAnsNames...))) + callerOpts = append(callerOpts, pop.WithAllowedAnsNames(cfg.AllowedAnsNames...)) } + + // Choose htu verification strategy and terminal handler based on SellerURL. + var opts []pop.MiddlewareOption + var terminalHandler http.Handler h := &Handler{ stop: func() { + if ks.cancelRefresh != nil { + ks.cancelRefresh() + } cancel() replay.Close() }, - logger: logger, + logger: logger, + sellerURL: cfg.SellerURL, + sellerClient: http.DefaultClient, } - h.chain = pop.Middleware(keys, replay, opts...)(http.HandlerFunc(h.echo)) + + if cfg.SellerURL != "" { + // Forward mode: pin htu to seller URL + path (G1). + // The egress signs the identity proof with the external (seller) URL (commerce's htu). + // The ingress verifies against SellerURL (base) + the request path. + // This differs from the old "+ RequestURI()" which included query strings and caused double-slash issues. + opts = []pop.MiddlewareOption{ + pop.WithExternalURL(func(r *http.Request) string { + return cfg.SellerURL + r.URL.Path + }), + pop.WithMiddlewareLogger(logger), + pop.WithMiddlewareCallerOptions(callerOpts...), + } + terminalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + forwardToSeller(w, r, h.sellerURL, h.sellerClient, h.logger) + }) + } else { + // Echo mode: verify against trusted hosts. + opts = []pop.MiddlewareOption{ + pop.WithTrustedHosts(cfg.TrustedHosts...), + pop.WithMiddlewareLogger(logger), + pop.WithMiddlewareCallerOptions(callerOpts...), + } + terminalHandler = http.HandlerFunc(echo) + } + + h.chain = pop.Middleware(ks.lookup, replay, opts...)(terminalHandler) + // Wrap chain with body guard as the outermost layer. + h.chain = bodyGuard(h.chain) return h, nil } +// bodyGuard is an outer http.Handler that rejects oversize bodies with 400 +// BEFORE they reach verification or the seller (matching the Rust ingress +// verifier's 256 KiB cap → 400). +func bodyGuard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Fast path: known Content-Length > maxBodyBytes → reject immediately. + if r.ContentLength > maxBodyBytes { + http.Error(w, "request body too large", http.StatusBadRequest) + return + } + + // Unknown length (ContentLength == -1 or 0): buffer up to maxBodyBytes+1 + // to detect overflow. + if r.ContentLength < 0 { + buf := make([]byte, maxBodyBytes+1) + n, err := io.ReadFull(r.Body, buf) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + // Read error other than EOF/UnexpectedEOF (expected when body < buffer). + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + // If we read maxBodyBytes+1 bytes, the body is oversize. + if n > maxBodyBytes { + http.Error(w, "request body too large", http.StatusBadRequest) + return + } + // Body is within limit: reset r.Body to the buffered bytes for downstream. + r.Body = io.NopCloser(bytes.NewReader(buf[:n])) + r.ContentLength = int64(n) + } + + // Body is within limit (or zero): pass through to the chain. + next.ServeHTTP(w, r) + }) +} + // ServeHTTP implements http.Handler. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.chain.ServeHTTP(w, r) @@ -127,7 +273,7 @@ func (h *Handler) Close() { h.stop() } // echo runs behind pop.Middleware, which only calls it with a verified caller // in the context. -func (h *Handler) echo(w http.ResponseWriter, r *http.Request) { +func echo(w http.ResponseWriter, r *http.Request) { id, _ := pop.CallerFromContext(r.Context()) n, err := io.Copy(io.Discard, r.Body) if err != nil { @@ -144,6 +290,63 @@ func (h *Handler) echo(w http.ResponseWriter, r *http.Request) { BodyBytes: n, } if err := json.NewEncoder(w).Encode(resp); err != nil { - h.logger.WarnContext(r.Context(), "encode response", "err", err) + // Echo handler has no logger; use default. + slog.Default().WarnContext(r.Context(), "encode response", "err", err) + } +} + +// forwardToSeller forwards a verified request to the seller endpoint, relaying +// the body, Content-Type, and response. It runs behind pop.Middleware (caller +// is already verified). On transport error, returns 502; on upstream error, +// relays the status as-is. +func forwardToSeller(w http.ResponseWriter, r *http.Request, sellerURL string, client *http.Client, logger *slog.Logger) { + // Read the body (middleware has buffered and reset r.Body to the verified bytes). + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + + // Build the upstream URL: sellerURL (base) + request path (G1). + // This matches the external URL used for htu verification. + // G704: sellerURL is operator configuration (validated at New); r.URL.Path is verified against the identity proof htu. + upstreamURL := sellerURL + r.URL.Path + + // Build the upstream request with the same body and Content-Type. + upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, upstreamURL, bytes.NewReader(body)) + if err != nil { + logger.ErrorContext(r.Context(), "build upstream request", "err", err) + http.Error(w, "failed to build upstream request", http.StatusInternalServerError) + return + } + if ct := r.Header.Get("Content-Type"); ct != "" { + upstreamReq.Header.Set("Content-Type", ct) + } + + // Send to the seller. + upstreamResp, err := client.Do(upstreamReq) //nolint:gosec // G704: upstreamReq targets operator-configured sellerURL + verified path + if err != nil { + // Transport error (unreachable) → 502. + logger.ErrorContext(r.Context(), "forward to seller", "err", err) + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + defer upstreamResp.Body.Close() + + // Relay the seller's response status and body. + upstreamBody, err := io.ReadAll(upstreamResp.Body) + if err != nil { + logger.ErrorContext(r.Context(), "read upstream response", "err", err) + http.Error(w, "failed to read upstream response", http.StatusBadGateway) + return + } + + // Copy upstream headers (at least Content-Type). + if ct := upstreamResp.Header.Get("Content-Type"); ct != "" { + w.Header().Set("Content-Type", ct) + } + w.WriteHeader(upstreamResp.StatusCode) + if _, err := w.Write(upstreamBody); err != nil { + logger.WarnContext(r.Context(), "write upstream response", "err", err) } } diff --git a/internal/callee/callee_test.go b/internal/callee/callee_test.go index d499df0..abfb73d 100644 --- a/internal/callee/callee_test.go +++ b/internal/callee/callee_test.go @@ -197,3 +197,839 @@ func TestHandler_AcceptsSignedRequest(t *testing.T) { t.Fatalf("tampered body status = %d, want 401", resp2.StatusCode) } } + +// TestNew_FetchesRootKeysFromTL verifies that when TLBaseURL is set, New +// fetches root keys from a live TL server and uses them for verification, +// without requiring static RootKeys. +func TestNew_FetchesRootKeysFromTL(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + ts := testfixtures.FakeTLServer(tl) + defer ts.Close() + + h, err := New(context.Background(), Config{ + TLBaseURL: ts.URL, + TrustedHosts: []string{"callee.example"}, + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v, want nil (TL should supply keys)", err) + } + defer h.Close() + + // Verify the handler uses the fetched key by checking it accepts a signed request. + // The handler would reject the request if it didn't have the TL's key. + id := testfixtures.MustNewIdentity("agent-test", "ans://v1.0.0.test.example") + signer, err := pop.NewSigner(id.Key, id.CertDER) + if err != nil { + t.Fatal(err) + } + receipt, token := testfixtures.StaticArtifacts(tl, id) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://callee.example/test", nil) + req.Host = "callee.example" + if attachErr := pop.AttachIdentity(req, signer, http.Header{ + scitt.HeaderReceipt: {receipt}, + scitt.HeaderStatusToken: {token}, + }); attachErr != nil { + t.Fatal(attachErr) + } + + srv := httptest.NewServer(h) + defer srv.Close() + req.URL.Host = srv.Listener.Addr().String() + req.URL.Scheme = "http" + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (fetched key should verify the request)", resp.StatusCode) + } +} + +// TestNew_StaticRootKeysFallback verifies that when TLBaseURL is unset and +// static RootKeys are provided, New succeeds using the static keystore. +func TestNew_StaticRootKeysFallback(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + h, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{"callee.example"}, + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v, want nil (static RootKeys should work)", err) + } + defer h.Close() +} + +// TestNew_FailsLoudWhenTLYieldsNoKeys verifies that when TLBaseURL is set but +// the TL returns no keys AND no static RootKeys are provided, New fails with a +// non-nil error (fail loud, do not serve zero keys). +func TestNew_FailsLoudWhenTLYieldsNoKeys(t *testing.T) { + t.Parallel() + // Empty TL server: returns 200 OK with an empty /root-keys response. + mux := http.NewServeMux() + mux.HandleFunc("GET /root-keys", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + // Return empty content - no keys. + }) + ts := httptest.NewServer(mux) + defer ts.Close() + + _, err := New(context.Background(), Config{ + TLBaseURL: ts.URL, + TrustedHosts: []string{"callee.example"}, + }, slog.New(slog.DiscardHandler)) + if err == nil { + t.Fatal("New() error = nil, want non-nil error when TL yields no keys") + } +} + +// TestReceiptToggle_OffAcceptsEmptyReceipt verifies that when RequireReceipt +// is false, a request with a valid status token + binding but an EMPTY +// X-SCITT-Receipt header is accepted (forwarded / 200-path). +func TestReceiptToggle_OffAcceptsEmptyReceipt(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + id := testfixtures.MustNewIdentity("agent-123", "ans://v1.0.0.agent.example") + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + authority := lis.Addr().String() + h, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{authority}, + RequireReceipt: false, // Toggle OFF: should accept empty receipt. + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer h.Close() + srv := &http.Server{Handler: h, ReadHeaderTimeout: time.Second} + go func() { _ = srv.Serve(lis) }() + defer srv.Close() + + signer, err := pop.NewSigner(id.Key, id.CertDER) + if err != nil { + t.Fatal(err) + } + _, token := testfixtures.StaticArtifacts(tl, id) + + // Build a request with valid status token but EMPTY receipt. + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://"+authority+"/v1/tasks", + strings.NewReader(`{"task":"hello"}`)) + req.Header.Set("Content-Type", "application/json") + if attachErr := pop.AttachIdentity(req, signer, http.Header{ + scitt.HeaderReceipt: {""}, // EMPTY receipt. + scitt.HeaderStatusToken: {token}, + }); attachErr != nil { + t.Fatal(attachErr) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (empty receipt should be accepted when RequireReceipt=false)", resp.StatusCode) + } + var got Response + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatal(err) + } + if got.Caller != id.AnsName || got.AgentID != id.AgentID { + t.Fatalf("response = %+v", got) + } +} + +// TestReceiptToggle_OnRejectsEmptyReceipt verifies that when RequireReceipt +// is true, a request with a valid status token + binding but an EMPTY +// X-SCITT-Receipt header is rejected (401). +func TestReceiptToggle_OnRejectsEmptyReceipt(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + id := testfixtures.MustNewIdentity("agent-123", "ans://v1.0.0.agent.example") + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + authority := lis.Addr().String() + h, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{authority}, + RequireReceipt: true, // Toggle ON: should reject empty receipt. + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer h.Close() + srv := &http.Server{Handler: h, ReadHeaderTimeout: time.Second} + go func() { _ = srv.Serve(lis) }() + defer srv.Close() + + signer, err := pop.NewSigner(id.Key, id.CertDER) + if err != nil { + t.Fatal(err) + } + _, token := testfixtures.StaticArtifacts(tl, id) + + // Build a request with valid status token but EMPTY receipt. + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://"+authority+"/v1/tasks", + strings.NewReader(`{"task":"hello"}`)) + req.Header.Set("Content-Type", "application/json") + if attachErr := pop.AttachIdentity(req, signer, http.Header{ + scitt.HeaderReceipt: {""}, // EMPTY receipt. + scitt.HeaderStatusToken: {token}, + }); attachErr != nil { + t.Fatal(attachErr) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 (empty receipt should be rejected when RequireReceipt=true)", resp.StatusCode) + } +} + +// TestForward_RelaysVerifiedRequestToSeller verifies that a verified request +// is forwarded to the seller with the same body bytes and Content-Type, and +// the seller's response is relayed verbatim. +func TestForward_RelaysVerifiedRequestToSeller(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + id := testfixtures.MustNewIdentity("agent-123", "ans://v1.0.0.agent.example") + + // Fake seller records what it received. + var receivedBody []byte + var receivedContentType string + seller := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedContentType = r.Header.Get("Content-Type") + var err error + receivedBody, err = io.ReadAll(r.Body) + if err != nil { + t.Errorf("seller failed to read body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ticket":"t123"}`)) + })) + defer seller.Close() + + h, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{"ingress.example"}, // Placeholder; will use WithExternalURL when SellerURL is set. + SellerURL: seller.URL, + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer h.Close() + + srv := httptest.NewServer(h) + defer srv.Close() + + signer, err := pop.NewSigner(id.Key, id.CertDER) + if err != nil { + t.Fatal(err) + } + receipt, token := testfixtures.StaticArtifacts(tl, id) + + // Sign a request with a specific body. + // CRITICAL: Sign for the SELLER URL (htu is pinned to seller by WithExternalURL). + originalBody := `{"task":"test-forward"}` + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, seller.URL+"/v1/tasks", + strings.NewReader(originalBody)) + req.Header.Set("Content-Type", "application/json") + if attachErr := pop.AttachIdentity(req, signer, http.Header{ + scitt.HeaderReceipt: {receipt}, + scitt.HeaderStatusToken: {token}, + }); attachErr != nil { + t.Fatal(attachErr) + } + // Update the URL to point at the ingress (srv), but the signature is for the seller. + req.URL.Host = srv.Listener.Addr().String() + req.URL.Scheme = "http" + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + // Assert the seller received the original body byte-for-byte (R3). + if string(receivedBody) != originalBody { + t.Errorf("seller received body %q, want %q (BODY REREAD FAILED)", receivedBody, originalBody) + } + if receivedContentType != "application/json" { + t.Errorf("seller received Content-Type %q, want application/json", receivedContentType) + } + + // Assert the client received the seller's response. + respBody, _ := io.ReadAll(resp.Body) + if string(respBody) != `{"ticket":"t123"}` { + t.Errorf("client received body %q, want seller's response", respBody) + } +} + +// TestForward_RelaysUpstreamErrorStatus verifies that a non-2xx status from +// the seller is relayed as-is (not masked as 502). +func TestForward_RelaysUpstreamErrorStatus(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + id := testfixtures.MustNewIdentity("agent-123", "ans://v1.0.0.agent.example") + + // Seller returns 500. + seller := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"seller internal error"}`)) + })) + defer seller.Close() + + h, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{"ingress.example"}, // Placeholder; will use WithExternalURL when SellerURL is set. + SellerURL: seller.URL, + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer h.Close() + + srv := httptest.NewServer(h) + defer srv.Close() + + signer, err := pop.NewSigner(id.Key, id.CertDER) + if err != nil { + t.Fatal(err) + } + receipt, token := testfixtures.StaticArtifacts(tl, id) + + // Sign for the seller URL, then redirect to ingress. + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, seller.URL+"/v1/tasks", + strings.NewReader(`{"task":"test"}`)) + req.Header.Set("Content-Type", "application/json") + if attachErr := pop.AttachIdentity(req, signer, http.Header{ + scitt.HeaderReceipt: {receipt}, + scitt.HeaderStatusToken: {token}, + }); attachErr != nil { + t.Fatal(attachErr) + } + req.URL.Host = srv.Listener.Addr().String() + req.URL.Scheme = "http" + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // Upstream 500 should be relayed as 500, NOT masked as 502. + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (relay upstream status)", resp.StatusCode) + } +} + +// TestForward_UpstreamUnreachableIs502 verifies that when the seller is +// unreachable (transport error), the client receives 502. +func TestForward_UpstreamUnreachableIs502(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + id := testfixtures.MustNewIdentity("agent-123", "ans://v1.0.0.agent.example") + + // Point at a closed port to trigger a dial failure. + // Find an unused port by opening and closing a listener. + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + unreachableURL := "http://" + lis.Addr().String() + lis.Close() + + h, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{"ingress.example"}, // Placeholder; will use WithExternalURL when SellerURL is set. + SellerURL: unreachableURL, + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer h.Close() + + srv := httptest.NewServer(h) + defer srv.Close() + + signer, err := pop.NewSigner(id.Key, id.CertDER) + if err != nil { + t.Fatal(err) + } + receipt, token := testfixtures.StaticArtifacts(tl, id) + + // Sign for the seller URL (even though it's unreachable), then redirect to ingress. + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, unreachableURL+"/v1/tasks", + strings.NewReader(`{"task":"test"}`)) + req.Header.Set("Content-Type", "application/json") + if attachErr := pop.AttachIdentity(req, signer, http.Header{ + scitt.HeaderReceipt: {receipt}, + scitt.HeaderStatusToken: {token}, + }); attachErr != nil { + t.Fatal(attachErr) + } + req.URL.Host = srv.Listener.Addr().String() + req.URL.Scheme = "http" + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // Transport error should be masked as 502 Bad Gateway. + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("status = %d, want 502 (bad gateway for unreachable upstream)", resp.StatusCode) + } +} + +// TestForward_NoContentType verifies that when a request has no Content-Type +// header, the forwarder does not set one in the upstream request. +func TestForward_NoContentType(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + id := testfixtures.MustNewIdentity("agent-123", "ans://v1.0.0.agent.example") + + // Fake seller records the Content-Type it received. + var receivedContentType string + seller := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedContentType = r.Header.Get("Content-Type") + w.WriteHeader(http.StatusOK) + })) + defer seller.Close() + + h, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{"ingress.example"}, + SellerURL: seller.URL, + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer h.Close() + + srv := httptest.NewServer(h) + defer srv.Close() + + signer, err := pop.NewSigner(id.Key, id.CertDER) + if err != nil { + t.Fatal(err) + } + receipt, token := testfixtures.StaticArtifacts(tl, id) + + // Sign a request WITHOUT a Content-Type header. + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, seller.URL+"/v1/tasks", + strings.NewReader(`{"task":"test"}`)) + // Do NOT set Content-Type. + if attachErr := pop.AttachIdentity(req, signer, http.Header{ + scitt.HeaderReceipt: {receipt}, + scitt.HeaderStatusToken: {token}, + }); attachErr != nil { + t.Fatal(attachErr) + } + req.URL.Host = srv.Listener.Addr().String() + req.URL.Scheme = "http" + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + // Assert the seller did NOT receive a Content-Type header. + if receivedContentType != "" { + t.Errorf("seller received Content-Type %q, want empty (no header should be forwarded)", receivedContentType) + } +} + +// TestForward_UpstreamResponseNoContentType verifies that when the seller +// response has no Content-Type, the forwarder does not set one in the client +// response. +func TestForward_UpstreamResponseNoContentType(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + id := testfixtures.MustNewIdentity("agent-123", "ans://v1.0.0.agent.example") + + // Seller returns a response without Content-Type. + seller := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Do NOT set Content-Type. + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`plain text`)) + })) + defer seller.Close() + + h, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{"ingress.example"}, + SellerURL: seller.URL, + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer h.Close() + + srv := httptest.NewServer(h) + defer srv.Close() + + signer, err := pop.NewSigner(id.Key, id.CertDER) + if err != nil { + t.Fatal(err) + } + receipt, token := testfixtures.StaticArtifacts(tl, id) + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, seller.URL+"/v1/tasks", + strings.NewReader(`{"task":"test"}`)) + req.Header.Set("Content-Type", "application/json") + if attachErr := pop.AttachIdentity(req, signer, http.Header{ + scitt.HeaderReceipt: {receipt}, + scitt.HeaderStatusToken: {token}, + }); attachErr != nil { + t.Fatal(attachErr) + } + req.URL.Host = srv.Listener.Addr().String() + req.URL.Scheme = "http" + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + // The forwarder should NOT set a Content-Type if the seller didn't provide one. + // (httptest may add a default, so we just verify the response is relayed). + body, _ := io.ReadAll(resp.Body) + if string(body) != `plain text` { + t.Errorf("client received body %q, want seller's response", body) + } +} + +// TestBodyGuard_OversizeReturns400 verifies that a request whose body exceeds +// 262144 bytes (256 KiB) returns 400 and NEVER reaches verification or the +// seller. +func TestBodyGuard_OversizeReturns400(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + + // Fake seller that records hits. + var sellerHits int + seller := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + sellerHits++ + w.WriteHeader(http.StatusOK) + })) + defer seller.Close() + + h, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{"ingress.example"}, + SellerURL: seller.URL, + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer h.Close() + + srv := httptest.NewServer(h) + defer srv.Close() + + // Create an oversize body: 262145 bytes (one more than the limit). + oversizeBody := strings.Repeat("a", 262145) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL+"/v1/tasks", + strings.NewReader(oversizeBody)) + req.Header.Set("Content-Type", "application/json") + // Set Content-Length explicitly to test the known-length path. + req.ContentLength = int64(len(oversizeBody)) + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // Assert: status is 400 (NOT 413), and the seller was never called. + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (oversize body rejected before verification)", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "request body too large") { + t.Errorf("response body = %q, want 'request body too large'", body) + } + if sellerHits != 0 { + t.Errorf("seller received %d hits, want 0 (oversize body should never reach verification/seller)", sellerHits) + } +} + +// TestBodyGuard_OversizeReturns400_UnknownLength verifies that when +// Content-Length is not set (chunked/unknown), a body exceeding 256 KiB is +// rejected with 400 after buffering detects the overflow. +func TestBodyGuard_OversizeReturns400_UnknownLength(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + + var sellerHits int + seller := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + sellerHits++ + w.WriteHeader(http.StatusOK) + })) + defer seller.Close() + + h, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{"ingress.example"}, + SellerURL: seller.URL, + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer h.Close() + + srv := httptest.NewServer(h) + defer srv.Close() + + // Create an oversize body: 262145 bytes. + oversizeBody := strings.Repeat("b", 262145) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL+"/v1/tasks", + strings.NewReader(oversizeBody)) + req.Header.Set("Content-Type", "application/json") + // Set ContentLength to -1 to simulate unknown length (chunked). + req.ContentLength = -1 + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // Assert: status is 400, and the seller was never called. + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (oversize body rejected after buffering)", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "request body too large") { + t.Errorf("response body = %q, want 'request body too large'", body) + } + if sellerHits != 0 { + t.Errorf("seller received %d hits, want 0 (oversize body should never reach seller)", sellerHits) + } +} + +// TestBodyGuard_AtLimitPasses verifies that a request with EXACTLY 262144 +// bytes (256 KiB) is allowed through to verification (may then return 401 for +// missing signature, but NOT the guard's 400). +func TestBodyGuard_AtLimitPasses(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + + h, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{"callee.example"}, + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer h.Close() + + srv := httptest.NewServer(h) + defer srv.Close() + + // Create a body at exactly the limit: 262144 bytes. + atLimitBody := strings.Repeat("c", 262144) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL+"/v1/tasks", + strings.NewReader(atLimitBody)) + req.Host = "callee.example" + req.Header.Set("Content-Type", "application/json") + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // Assert: status is NOT the guard's 400 "request body too large". + // It will be 401 (unsigned request), but that's verification rejecting it, + // not the guard. + if resp.StatusCode == http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + if strings.Contains(string(body), "request body too large") { + t.Fatalf("at-limit body rejected by guard (status 400 'request body too large'), should pass through to verification") + } + } + // Expect 401 from verification (unsigned request). + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 (at-limit body should reach verification)", resp.StatusCode) + } +} + +// failingReader is an io.ReadCloser that returns an error after reading n bytes. +type failingReader struct { + n int + err error +} + +func (r *failingReader) Read(p []byte) (int, error) { + if r.n <= 0 { + return 0, r.err + } + if len(p) > r.n { + p = p[:r.n] + } + n := len(p) + r.n -= n + return n, nil +} + +func (r *failingReader) Close() error { + return nil +} + +// TestBodyGuard_ReadError verifies that when reading a body with unknown length +// encounters a non-EOF/non-UnexpectedEOF error (e.g., network failure), the +// guard returns 400 "failed to read body". +func TestBodyGuard_ReadError(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + + // Create a test handler that wraps the callee handler and replaces r.Body + // with a failing reader to simulate a read error. + inner, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{"callee.example"}, + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer inner.Close() + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Replace r.Body with a failing reader to simulate a read error in the guard. + r.Body = &failingReader{ + n: 100, + err: io.ErrNoProgress, // Non-EOF/non-UnexpectedEOF error. + } + r.ContentLength = -1 // Unknown length to trigger buffering path. + inner.ServeHTTP(w, r) + }) + + srv := httptest.NewServer(h) + defer srv.Close() + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL+"/v1/tasks", + strings.NewReader(`{"task":"test"}`)) + req.Host = "callee.example" + req.Header.Set("Content-Type", "application/json") + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // The body guard should catch the read error and return 400. + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (read error should be caught by guard)", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "failed to read body") { + t.Errorf("response body = %q, want 'failed to read body'", body) + } +} + +func TestForward_AcceptsIdentityProofWithVerbatimSellerURLHtu(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + id := testfixtures.MustNewIdentity("agent-123", "ans://v1.0.0.agent.example") + + // Seller with a specific path (e.g., "/mcp/") + var receivedPath string + seller := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer seller.Close() + + // Configure SellerURL as the base (no trailing slash). + // G1: The ingress computes expected htu = SellerURL + r.URL.Path (no query). + // This fixes the old "+ RequestURI()" which caused double-slash issues. + sellerBase := seller.URL + "/mcp" + + h, err := New(context.Background(), Config{ + RootKeys: []string{tl.RootKeyC2SP()}, + TrustedHosts: []string{"ingress.example"}, // Placeholder + SellerURL: sellerBase, + }, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + defer h.Close() + + srv := httptest.NewServer(h) + defer srv.Close() + + signer, err := pop.NewSigner(id.Key, id.CertDER) + if err != nil { + t.Fatal(err) + } + receipt, token := testfixtures.StaticArtifacts(tl, id) + + // G1: Sign for sellerBase + "/" (the external URL the egress signs). + // The ingress receives the request at "/" and computes expected htu = sellerBase + "/". + originalBody := `{"task":"commerce-book-flight"}` + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, sellerBase+"/", + strings.NewReader(originalBody)) + req.Header.Set("Content-Type", "application/json") + if attachErr := pop.AttachIdentity(req, signer, http.Header{ + scitt.HeaderReceipt: {receipt}, + scitt.HeaderStatusToken: {token}, + }); attachErr != nil { + t.Fatal(attachErr) + } + + // Now redirect the request to the ingress at path "/". + // The identity proof's htu is sellerBase + "/" = the external URL. + req.URL.Host = srv.Listener.Addr().String() + req.URL.Scheme = "http" + req.URL.Path = "/" + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + // The ingress should ACCEPT the identity proof (htu = sellerBase + "/"). + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200 (G1: identity proof htu should match SellerURL + path), body: %s", + resp.StatusCode, respBody) + } + + // Assert the seller received the request at "/mcp/" (the ingress forwarded to sellerBase + path). + if receivedPath != "/mcp/" { + t.Errorf("seller received path %q, want /mcp/", receivedPath) + } +} diff --git a/internal/capability/capability.go b/internal/capability/capability.go new file mode 100644 index 0000000..5e5a89e --- /dev/null +++ b/internal/capability/capability.go @@ -0,0 +1,113 @@ +// Package capability defines the vocabulary for the capability-negotiation egress layer. +// It provides types for expressing what capabilities a request needs (Requirement), +// how transforms can satisfy those needs (Transform interface), and what outcomes +// result from applying transforms (Outcome). +package capability + +import ( + "context" + "log/slog" +) + +// Capability represents a capability identifier from the card vocabulary +// (e.g., "mandate", "dpop_proof") or an extension URI. +type Capability string + +// DPoPParams holds parameters for DPoP proof generation. +type DPoPParams struct { + Alg string `json:"alg"` + Ath string `json:"ath"` + Htm string `json:"htm"` + Htu string `json:"htu"` + Typ string `json:"typ"` + Claims []string `json:"claims"` +} + +// Requirement describes what an egress request needs: which MCP tool it calls, +// what capabilities it requires, and optional DPoP parameters. +type Requirement struct { + Tool string + Capabilities []Capability + DPoP *DPoPParams +} + +// Needs returns true if the given capability is present in this requirement's +// capabilities list. +func (r Requirement) Needs(c Capability) bool { + for _, cap := range r.Capabilities { + if cap == c { + return true + } + } + return false +} + +// Empty returns true when this requirement carries no tool and no capabilities. +// DPoP is ignored for emptiness checks. +func (r Requirement) Empty() bool { + return r.Tool == "" && len(r.Capabilities) == 0 +} + +// OutcomeKind describes what action a Transform's Apply method decided. +type OutcomeKind int + +const ( + // Passthrough means the transform did not handle the request; try the next transform. + // Must be the zero value. + Passthrough OutcomeKind = iota + // Deny means the transform rejected the request; stop processing and return an error. + Deny + // Allow means the transform approved the request and potentially modified the body. + Allow +) + +// Outcome is the result of applying a Transform to a request. +// Body is set only for Allow outcomes (the potentially modified request body). +// Reason is set only for Deny outcomes (why the request was rejected). +// SignURL, when non-empty on Allow, is the externally-visible URL the identity +// proof's htu must bind, instead of the request target. Set by a transform that +// rewrites the request toward an external endpoint (e.g. commerce → seller). +type Outcome struct { + Kind OutcomeKind + Body []byte // Set only for Allow + Reason string // Set only for Deny + SignURL string // Set only for Allow + // Artifacts is an optional, transform-defined bag of values describing what + // the transform attached to an Allow'd request (e.g. a mandate or a proof). + // It is for observability only (debug narration) and is never used for + // control flow. The core does not interpret its contents; a transform that + // also implements Narratable renders them. + Artifacts map[string]any +} + +// Target describes the request being evaluated: the destination (Host, Method, Path) +// and the MCP Tool name extracted from the request body (may be empty). +type Target struct { + Host string + Method string + Path string + Tool string // MCP tool name extracted from request body by caller, may be "" +} + +// Transform is a pluggable component that can inspect an egress request, +// check if it can satisfy the required capabilities, and either pass through, +// deny, or allow (with optional body modification). +type Transform interface { + // Provides returns the list of capabilities this transform can satisfy. + Provides() []Capability + + // Apply evaluates the request against this transform's logic. + // Returns Passthrough if this transform doesn't handle the request, + // Deny with a reason if the request should be rejected, + // or Allow with an optionally modified body if the request should proceed. + Apply(ctx context.Context, target Target, req Requirement, body []byte) Outcome +} + +// Narratable is an OPTIONAL interface a Transform may implement to render, at +// debug level, a human-readable view of the artifacts it attached to an Allow'd +// request (the Outcome it returned). The core calls it for observability only; +// it never affects the request, and a Transform that does not implement it is +// simply not narrated. +type Narratable interface { + Narrate(ctx context.Context, out Outcome, log *slog.Logger) +} diff --git a/internal/capability/capability_test.go b/internal/capability/capability_test.go new file mode 100644 index 0000000..367c0fa --- /dev/null +++ b/internal/capability/capability_test.go @@ -0,0 +1,145 @@ +package capability_test + +import ( + "testing" + + "github.com/agentnameservice/ans-openshell/internal/capability" +) + +func TestRequirement_Needs(t *testing.T) { + tests := []struct { + name string + req capability.Requirement + cap capability.Capability + want bool + }{ + { + name: "capability present", + req: capability.Requirement{ + Capabilities: []capability.Capability{"mandate", "dpop_proof"}, + }, + cap: "mandate", + want: true, + }, + { + name: "capability absent", + req: capability.Requirement{ + Capabilities: []capability.Capability{"mandate"}, + }, + cap: "dpop_proof", + want: false, + }, + { + name: "empty capabilities list", + req: capability.Requirement{ + Capabilities: []capability.Capability{}, + }, + cap: "mandate", + want: false, + }, + { + name: "nil capabilities list", + req: capability.Requirement{ + Capabilities: nil, + }, + cap: "mandate", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.req.Needs(tt.cap); got != tt.want { + t.Errorf("Requirement.Needs() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRequirement_Empty(t *testing.T) { + tests := []struct { + name string + req capability.Requirement + want bool + }{ + { + name: "empty requirement - no tool and no capabilities", + req: capability.Requirement{ + Tool: "", + Capabilities: nil, + }, + want: true, + }, + { + name: "empty requirement - empty string tool and empty slice", + req: capability.Requirement{ + Tool: "", + Capabilities: []capability.Capability{}, + }, + want: true, + }, + { + name: "not empty - has tool", + req: capability.Requirement{ + Tool: "mcp_tool", + Capabilities: nil, + }, + want: false, + }, + { + name: "not empty - has capability", + req: capability.Requirement{ + Tool: "", + Capabilities: []capability.Capability{"mandate"}, + }, + want: false, + }, + { + name: "not empty - has both tool and capability", + req: capability.Requirement{ + Tool: "mcp_tool", + Capabilities: []capability.Capability{"mandate"}, + }, + want: false, + }, + { + name: "empty with DPoP set - DPoP ignored for emptiness", + req: capability.Requirement{ + Tool: "", + Capabilities: nil, + DPoP: &capability.DPoPParams{ + Alg: "ES256", + Ath: "hash", + }, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.req.Empty(); got != tt.want { + t.Errorf("Requirement.Empty() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestOutcomeKind_ZeroValueIsPassthrough(t *testing.T) { + var o capability.OutcomeKind + if o != capability.Passthrough { + t.Errorf("zero value of OutcomeKind = %v, want Passthrough", o) + } +} + +func TestOutcomeKind_ConstOrder(t *testing.T) { + if capability.Passthrough != 0 { + t.Errorf("Passthrough = %d, want 0", capability.Passthrough) + } + if capability.Deny != 1 { + t.Errorf("Deny = %d, want 1", capability.Deny) + } + if capability.Allow != 2 { + t.Errorf("Allow = %d, want 2", capability.Allow) + } +} diff --git a/internal/capability/discover.go b/internal/capability/discover.go new file mode 100644 index 0000000..fcaf1a9 --- /dev/null +++ b/internal/capability/discover.go @@ -0,0 +1,558 @@ +package capability + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "sync" + "time" +) + +const ( + defaultDiscoveryTTL = 10 * time.Minute + negativeCacheTTL = 1 * time.Minute +) + +// Discoverer fetches and parses a target agent's requirements from its agent card +// and MCP tools/list. All failures yield an empty Requirement (never an error return). +type Discoverer interface { + Discover(ctx context.Context, target Target) Requirement +} + +// HTTPDiscoverer is a caching HTTP-based implementation of Discoverer. +type HTTPDiscoverer struct { + client *http.Client + ttl time.Duration + + mu sync.RWMutex + cache map[cacheKey]cacheEntry +} + +type cacheKey struct { + host string + tool string +} + +type cacheEntry struct { + requirement Requirement + expiresAt time.Time +} + +// NewHTTPDiscoverer creates an HTTPDiscoverer with the given HTTP client and cache TTL. +// A default TTL of 10 minutes is used if ttl is zero. +// Negative cache entries (empty requirements from failed fetches) are cached for 1 minute. +func NewHTTPDiscoverer(client *http.Client, ttl time.Duration) *HTTPDiscoverer { + if ttl == 0 { + ttl = defaultDiscoveryTTL + } + return &HTTPDiscoverer{ + client: client, + ttl: ttl, + cache: make(map[cacheKey]cacheEntry), + } +} + +// Discover fetches the agent card and MCP tools/list for the target, returning +// a Requirement with the tool's capabilities and DPoP parameters. +// Any failure returns an empty Requirement. +func (d *HTTPDiscoverer) Discover(ctx context.Context, target Target) Requirement { + // Short-circuit: discovery only matters for MCP tool calls + if target.Tool == "" { + return Requirement{} + } + + // Normalize host for caching (strip scheme if present) + scheme := "https" + host := target.Host + if strings.HasPrefix(host, "http://") { + scheme = "http" + host = strings.TrimPrefix(host, "http://") + } else if strings.HasPrefix(host, "https://") { + host = strings.TrimPrefix(host, "https://") + } + + key := cacheKey{host: host, tool: target.Tool} + + // Check cache + d.mu.RLock() + entry, found := d.cache[key] + d.mu.RUnlock() + if found && time.Now().Before(entry.expiresAt) { + return entry.requirement + } + + // Fetch agent card + cardURL := fmt.Sprintf("%s://%s/.well-known/agent-card.json", scheme, host) + cardBody, err := d.fetchURL(ctx, cardURL) + if err != nil { + //nolint:sloglint // best-effort discovery logging + slog.Info("discover: failed to fetch agent card", "url", cardURL, "error", err) + return d.cacheEmpty(key) + } + + // Verify card JWS (returns true only when positively verified against fetched jku key) + verified := verifyCardJWS(cardBody) + + // Parse DPoP params from card (only trust htu if card was positively verified) + dpopParams, err := parseCardDPoP(cardBody) + if err != nil { + //nolint:sloglint // best-effort discovery logging + slog.Info("discover: failed to parse DPoP from card", "host", target.Host, "error", err) + // DPoP is optional; continue without it + dpopParams = nil + } else if !verified { + // Card not positively verified → do NOT trust discovered htu (would reach signed DPoP) + //nolint:sloglint // best-effort discovery logging + slog.Info("discover: card not verified, clearing discovered htu", "host", target.Host) + dpopParams = nil + } + + // Derive MCP endpoint URL from card + mcpURL, err := extractMCPURL(cardBody) + if err != nil { + //nolint:sloglint // best-effort discovery logging + slog.Info("discover: failed to extract MCP URL from card", "host", target.Host, "error", err) + return d.cacheEmpty(key) + } + + // Fetch MCP tools/list + toolsList, err := d.fetchToolsList(ctx, mcpURL) + if err != nil { + //nolint:sloglint // best-effort discovery logging + slog.Info("discover: failed to fetch tools/list", "url", mcpURL, "error", err) + return d.cacheEmpty(key) + } + + // Parse required capabilities + capabilities, err := parseToolsListRequired(toolsList, target.Tool) + if err != nil { + //nolint:sloglint // best-effort discovery logging + slog.Info("discover: failed to parse tools/list for tool", "tool", target.Tool, "error", err) + return d.cacheEmpty(key) + } + + // Confirmatory check: parse prerequisites from card skills + prereqCaps := d.parsePrerequisitesFromCard(cardBody, target.Tool) + if prereqCaps != nil && !capsEqual(capabilities, prereqCaps) { + //nolint:sloglint // best-effort discovery logging + slog.Info("discover: prerequisite mismatch (preferring structured)", "tool", target.Tool, "structured", capabilities, "confirmatory", prereqCaps) + } + + // Build and cache requirement + req := Requirement{ + Tool: target.Tool, + Capabilities: capabilities, + DPoP: dpopParams, + } + d.cacheRequirement(key, req, d.ttl) + return req +} + +func (d *HTTPDiscoverer) fetchURL(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := d.client.Do(req) //nolint:gosec // G704: url is derived from the egress target host (policy-scoped), not request input + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return body, nil +} + +func (d *HTTPDiscoverer) fetchToolsList(ctx context.Context, mcpURL string) ([]byte, error) { + reqBody := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + } + reqBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, mcpURL, strings.NewReader(string(reqBytes))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := d.client.Do(req) //nolint:gosec // G704: mcpURL is derived from the egress target host (policy-scoped), not request input + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Extract result from JSON-RPC response + var rpcResp struct { + Result json.RawMessage `json:"result"` + Error interface{} `json:"error"` + } + if err := json.Unmarshal(body, &rpcResp); err != nil { + return nil, err + } + if rpcResp.Error != nil { + return nil, fmt.Errorf("JSON-RPC error: %v", rpcResp.Error) + } + return rpcResp.Result, nil +} + +func (d *HTTPDiscoverer) parsePrerequisitesFromCard(cardBody []byte, tool string) []Capability { + var card struct { + Skills []struct { + ID string `json:"id"` + Description string `json:"description"` + } `json:"skills"` + } + if err := json.Unmarshal(cardBody, &card); err != nil { + return nil + } + + // Map tool name to skill ID (tool=book_flight may match skill id=checkout) + // For simplicity, search both exact matches and description matches + for _, skill := range card.Skills { + // Try matching by description content (looking for the tool name) + if strings.Contains(skill.Description, tool) || skill.ID == tool { + return parsePrerequisites(skill.Description) + } + } + return nil +} + +func (d *HTTPDiscoverer) cacheEmpty(key cacheKey) Requirement { + // Cache empty requirement for 1 minute (negative cache) + d.cacheRequirement(key, Requirement{}, negativeCacheTTL) + return Requirement{} +} + +func (d *HTTPDiscoverer) cacheRequirement(key cacheKey, req Requirement, ttl time.Duration) { + d.mu.Lock() + defer d.mu.Unlock() + d.cache[key] = cacheEntry{ + requirement: req, + expiresAt: time.Now().Add(ttl), + } +} + +// parseToolsListRequired extracts the required capabilities from an MCP tools/list response +// for the given tool name. +func parseToolsListRequired(body []byte, tool string) ([]Capability, error) { + var toolsList struct { + Tools []struct { + Name string `json:"name"` + InputSchema struct { + Required []string `json:"required"` + } `json:"inputSchema"` + } `json:"tools"` + } + if err := json.Unmarshal(body, &toolsList); err != nil { + return nil, err + } + + for _, t := range toolsList.Tools { + if t.Name == tool { + if len(t.InputSchema.Required) == 0 { + return nil, nil + } + caps := make([]Capability, len(t.InputSchema.Required)) + for i, r := range t.InputSchema.Required { + caps[i] = Capability(r) + } + return caps, nil + } + } + return nil, fmt.Errorf("tool %q not found", tool) +} + +// parseCardDPoP extracts DPoP parameters from an agent card's capabilities.extensions. +func parseCardDPoP(card []byte) (*DPoPParams, error) { + var cardData struct { + Capabilities struct { + Extensions []struct { + URI string `json:"uri"` + Params DPoPParams `json:"params"` + } `json:"extensions"` + } `json:"capabilities"` + } + if err := json.Unmarshal(card, &cardData); err != nil { + return nil, err + } + + for _, ext := range cardData.Capabilities.Extensions { + if ext.URI == "https://ans.dev/ext/dpop/v1" { + return &ext.Params, nil + } + } + return nil, errors.New("DPoP extension not found") +} + +// verifyCardJWS verifies the EdDSA detached-JWS signature on an agent card. +// Returns true only when the signature was positively verified against a successfully-fetched jku key. +// Returns false for: no signatures block, jku fetch failure, or signature verification failure. +// +// Current implementation: structural validation + Ed25519 signature verification +// against the key extracted from the card's trust card (jku). +// Full jku fetch + trust chain verification is deferred to a follow-up (tracked). +func verifyCardJWS(card []byte) bool { + var cardData struct { + Signatures []struct { + Header map[string]interface{} `json:"header"` + Protected string `json:"protected"` + Signature string `json:"signature"` + } `json:"signatures"` + } + if err := json.Unmarshal(card, &cardData); err != nil { + //nolint:sloglint // best-effort JWS verification logging + slog.Info("verifyCardJWS: invalid card JSON", "error", err) + return false + } + + if len(cardData.Signatures) == 0 { + //nolint:sloglint // best-effort JWS verification logging + slog.Info("verifyCardJWS: no signatures block found (allowed but unverified)") + return false + } + + sig := cardData.Signatures[0] + + // Decode protected header + protectedBytes, err := base64.RawURLEncoding.DecodeString(sig.Protected) + if err != nil { + //nolint:sloglint // best-effort JWS verification logging + slog.Info("verifyCardJWS: invalid protected header base64", "error", err) + return false + } + + var protectedHeader struct { + Alg string `json:"alg"` + Jku string `json:"jku"` + Kid string `json:"kid"` + Typ string `json:"typ"` + } + if err := json.Unmarshal(protectedBytes, &protectedHeader); err != nil { + //nolint:sloglint // best-effort JWS verification logging + slog.Info("verifyCardJWS: invalid protected header JSON", "error", err) + return false + } + + // Check algorithm + if protectedHeader.Alg != "EdDSA" { + //nolint:sloglint // best-effort JWS verification logging + slog.Info("verifyCardJWS: unsupported signature algorithm", "alg", protectedHeader.Alg) + return false + } + + // Decode signature + sigBytes, err := base64.RawURLEncoding.DecodeString(sig.Signature) + if err != nil { + //nolint:sloglint // best-effort JWS verification logging + slog.Info("verifyCardJWS: invalid signature base64", "error", err) + return false + } + + // Reconstruct the JWS signing input (detached payload) + // For agent-card+jws, the signing input is: protected || '.' || '' (empty payload, detached) + // However, the ACTUAL card body (without signatures) is what was signed. + // We need to reconstruct the canonical card body without the signatures field. + + // Remove signatures field from card + var cardMap map[string]interface{} + if err := json.Unmarshal(card, &cardMap); err != nil { + //nolint:sloglint // best-effort JWS verification logging + slog.Info("verifyCardJWS: failed to parse card for signature verification", "error", err) + return false + } + delete(cardMap, "signatures") + + // Re-serialize to canonical JSON (compact, sorted keys) + canonicalCard, err := json.Marshal(cardMap) + if err != nil { + //nolint:sloglint // best-effort JWS verification logging + slog.Info("verifyCardJWS: failed to canonicalize card", "error", err) + return false + } + + // Base64url-encode the payload + payloadB64 := base64.RawURLEncoding.EncodeToString(canonicalCard) + + // JWS signing input: protected.payload + signingInput := sig.Protected + "." + payloadB64 + + // Fetch the public key from jku (trust card) + // Return false if jku fetch fails (not positively verified) + pubKey, err := fetchEdDSAPublicKey(protectedHeader.Jku, protectedHeader.Kid) + if err != nil { + //nolint:sloglint // best-effort JWS verification logging + slog.Info("verifyCardJWS: failed to fetch public key", "jku", protectedHeader.Jku, "kid", protectedHeader.Kid, "error", err) + return false + } + + // Verify Ed25519 signature + if !ed25519.Verify(pubKey, []byte(signingInput), sigBytes) { + //nolint:sloglint // best-effort JWS verification logging + slog.Info("verifyCardJWS: signature verification failed") + return false + } + + // Positively verified + return true +} + +// fetchEdDSAPublicKey fetches the Ed25519 public key from a JWK Set URL. +// This is a best-effort implementation for v1. +func fetchEdDSAPublicKey(jkuURL, kid string) (ed25519.PublicKey, error) { + // For v1, we'll fetch the key but won't do full trust chain validation. + // That's a follow-up task. + //nolint:gosec,noctx // v1 best-effort key fetch; context propagation deferred + resp, err := http.Get(jkuURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var jwks struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + } `json:"keys"` + } + if err := json.Unmarshal(body, &jwks); err != nil { + return nil, err + } + + for _, key := range jwks.Keys { + if key.Kid == kid && key.Kty == "OKP" && key.Crv == "Ed25519" { + xBytes, err := base64.RawURLEncoding.DecodeString(key.X) + if err != nil { + return nil, fmt.Errorf("invalid key X coordinate: %w", err) + } + if len(xBytes) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid Ed25519 key size: %d", len(xBytes)) + } + return ed25519.PublicKey(xBytes), nil + } + } + return nil, fmt.Errorf("key %s not found in JWK Set", kid) +} + +// parsePrerequisites extracts capability names from a skill description's "Prerequisites:" clause. +// Returns nil if the clause is absent or unparseable. Never panics. +func parsePrerequisites(desc string) []Capability { + idx := strings.Index(desc, "Prerequisites:") + if idx == -1 { + return nil + } + + // Extract the substring after "Prerequisites:" + remainder := desc[idx+len("Prerequisites:"):] + + // Find the end (period or end of string) + endIdx := strings.Index(remainder, ".") + if endIdx != -1 { + remainder = remainder[:endIdx] + } + + // Split on ", " and trim spaces + parts := strings.Split(remainder, ",") + var caps []Capability + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + caps = append(caps, Capability(part)) + } + } + return caps +} + +// extractMCPURL extracts the MCP endpoint URL from an agent card. +func extractMCPURL(card []byte) (string, error) { + // Try extensions first (preferred) + var cardData struct { + Capabilities struct { + Extensions []struct { + URI string `json:"uri"` + Params struct { + Endpoint string `json:"endpoint"` + } `json:"params"` + } `json:"extensions"` + } `json:"capabilities"` + SupportedInterfaces []struct { + ProtocolBinding string `json:"protocolBinding"` + URL string `json:"url"` + } `json:"supportedInterfaces"` + } + if err := json.Unmarshal(card, &cardData); err != nil { + return "", err + } + + // Check for MCP extension + for _, ext := range cardData.Capabilities.Extensions { + if ext.URI == "https://modelcontextprotocol.io/ext/server/v1" && ext.Params.Endpoint != "" { + return ext.Params.Endpoint, nil + } + } + + // Fallback to supportedInterfaces + for _, iface := range cardData.SupportedInterfaces { + if strings.Contains(iface.ProtocolBinding, "modelcontextprotocol.io") { + return iface.URL, nil + } + } + + return "", errors.New("MCP URL not found in card") +} + +// capsEqual checks if two capability slices contain the same elements (order-independent). +func capsEqual(a, b []Capability) bool { + if len(a) != len(b) { + return false + } + aMap := make(map[Capability]bool) + for _, cap := range a { + aMap[cap] = true + } + for _, cap := range b { + if !aMap[cap] { + return false + } + } + return true +} diff --git a/internal/capability/discover_test.go b/internal/capability/discover_test.go new file mode 100644 index 0000000..00b5a1a --- /dev/null +++ b/internal/capability/discover_test.go @@ -0,0 +1,1159 @@ +package capability + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "testing" + "time" +) + +func TestParseToolsListRequired(t *testing.T) { + toolsListBytes, err := os.ReadFile(filepath.Join("testdata", "seller-tools-list.json")) + if err != nil { + t.Fatalf("failed to read seller-tools-list.json: %v", err) + } + + tests := []struct { + name string + body []byte + tool string + want []Capability + wantErr bool + }{ + { + name: "book_flight from seller tools", + body: toolsListBytes, + tool: "book_flight", + want: []Capability{"quote_id", "mandate", "dpop_proof"}, + wantErr: false, + }, + { + name: "tool not found", + body: toolsListBytes, + tool: "nonexistent_tool", + want: nil, + wantErr: true, + }, + { + name: "invalid JSON", + body: []byte(`{invalid`), + tool: "book_flight", + want: nil, + wantErr: true, + }, + { + name: "no required field", + body: []byte(`{"tools":[{"name":"test_tool","inputSchema":{"type":"object","properties":{}}}]}`), + tool: "test_tool", + want: nil, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseToolsListRequired(tt.body, tt.tool) + if (err != nil) != tt.wantErr { + t.Errorf("parseToolsListRequired() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseToolsListRequired() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseCardDPoP(t *testing.T) { + cardBytes, err := os.ReadFile(filepath.Join("testdata", "seller-agent-card.json")) + if err != nil { + t.Fatalf("failed to read seller-agent-card.json: %v", err) + } + + tests := []struct { + name string + card []byte + want *DPoPParams + wantErr bool + }{ + { + name: "seller card DPoP params", + card: cardBytes, + want: &DPoPParams{ + Alg: "ES256", + Ath: "base64url(SHA-256(quote_id))", + Htm: "POST", + Htu: "https://seller.skybound-travel.com/mcp/", + Typ: "dpop+jwt", + Claims: []string{"jti", "htm", "htu", "iat", "ath"}, + }, + wantErr: false, + }, + { + name: "invalid JSON", + card: []byte(`{invalid`), + want: nil, + wantErr: true, + }, + { + name: "no DPoP extension", + card: []byte(`{"capabilities":{"extensions":[{"uri":"https://other.uri"}]}}`), + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseCardDPoP(tt.card) + if (err != nil) != tt.wantErr { + t.Errorf("parseCardDPoP() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parseCardDPoP() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestVerifyCardJWS(t *testing.T) { + // Generate a throwaway Ed25519 keypair + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("failed to generate Ed25519 key: %v", err) + } + + // Create JWK representation of the public key + jwk := map[string]interface{}{ + "kid": "test-key-001", + "kty": "OKP", + "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(pubKey), + } + jwks := map[string]interface{}{ + "keys": []interface{}{jwk}, + } + + // Serve the JWK from httptest + jkuServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(jwks) + })) + defer jkuServer.Close() + + // Build an agent card WITHOUT signatures + cardWithoutSig := map[string]interface{}{ + "version": "1.0", + "name": "test-agent", + "capabilities": map[string]interface{}{ + "extensions": []interface{}{ + map[string]interface{}{ + "uri": "https://modelcontextprotocol.io/ext/server/v1", + "params": map[string]interface{}{ + "endpoint": "https://example.com/mcp/", + }, + }, + }, + }, + } + + // Serialize to canonical JSON + cardBodyBytes, err := json.Marshal(cardWithoutSig) + if err != nil { + t.Fatalf("failed to marshal card body: %v", err) + } + + // Build JWS protected header + protectedHeader := map[string]interface{}{ + "alg": "EdDSA", + "jku": jkuServer.URL, + "kid": "test-key-001", + "typ": "application/agent-card+json", + } + protectedBytes, err := json.Marshal(protectedHeader) + if err != nil { + t.Fatalf("failed to marshal protected header: %v", err) + } + protectedB64 := base64.RawURLEncoding.EncodeToString(protectedBytes) + payloadB64 := base64.RawURLEncoding.EncodeToString(cardBodyBytes) + + // Sign: Ed25519.Sign(protected.payload) + signingInput := protectedB64 + "." + payloadB64 + signature := ed25519.Sign(privKey, []byte(signingInput)) + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) + + // Build the signed card + cardWithoutSig["signatures"] = []interface{}{ + map[string]interface{}{ + "protected": protectedB64, + "signature": signatureB64, + }, + } + signedCardBytes, err := json.Marshal(cardWithoutSig) + if err != nil { + t.Fatalf("failed to marshal signed card: %v", err) + } + + tests := []struct { + name string + card []byte + want bool + }{ + { + name: "valid hermetic card", + card: signedCardBytes, + want: true, + }, + { + name: "no signatures block", + card: []byte(`{"name":"test","capabilities":{}}`), + want: false, + }, + { + name: "invalid JSON", + card: []byte(`{invalid`), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := verifyCardJWS(tt.card) + if got != tt.want { + t.Errorf("verifyCardJWS() = %v, want %v", got, tt.want) + } + }) + } + + // Test tamper detection + t.Run("tampered card body fails Ed25519 verification", func(t *testing.T) { + var tamperedCard map[string]interface{} + if err := json.Unmarshal(signedCardBytes, &tamperedCard); err != nil { + t.Fatalf("failed to unmarshal signed card: %v", err) + } + // Tamper: change the version field + tamperedCard["version"] = "2.0" + tamperedBytes, err := json.Marshal(tamperedCard) + if err != nil { + t.Fatalf("failed to marshal tampered card: %v", err) + } + + // Tampered card should fail signature verification + if got := verifyCardJWS(tamperedBytes); got { + t.Error("verifyCardJWS() = true for tampered card, want false") + } + }) +} + +func TestParsePrerequisites(t *testing.T) { + tests := []struct { + name string + desc string + want []Capability + }{ + { + name: "valid prerequisites", + desc: "A2A + MCP. Ticket { ticket_id }. Prerequisites: quote_id, mandate, dpop_proof.", + want: []Capability{"quote_id", "mandate", "dpop_proof"}, + }, + { + name: "no prerequisites", + desc: "Some description without the keyword", + want: nil, + }, + { + name: "empty description", + desc: "", + want: nil, + }, + { + name: "prerequisites with spaces", + desc: "Prerequisites: foo, bar , baz", + want: []Capability{"foo", "bar", "baz"}, + }, + { + name: "prerequisites at end", + desc: "Description. Prerequisites: a, b", + want: []Capability{"a", "b"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parsePrerequisites(tt.desc) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parsePrerequisites() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHTTPDiscoverer_Discover(t *testing.T) { + toolsListBytes, err := os.ReadFile(filepath.Join("testdata", "seller-tools-list.json")) + if err != nil { + t.Fatalf("failed to read seller-tools-list.json: %v", err) + } + + // Create JSON-RPC tools/list response + toolsListResponse := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "result": json.RawMessage(toolsListBytes), + } + toolsListResponseBytes, _ := json.Marshal(toolsListResponse) + + t.Run("successful discovery", func(t *testing.T) { + // Generate a throwaway Ed25519 keypair + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("failed to generate Ed25519 key: %v", err) + } + + // Create JWK representation of the public key + jwk := map[string]interface{}{ + "kid": "test-key-001", + "kty": "OKP", + "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(pubKey), + } + jwks := map[string]interface{}{ + "keys": []interface{}{jwk}, + } + + // Serve the JWK from httptest + jkuServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(jwks) + })) + defer jkuServer.Close() + + // Create a placeholder for the server (will be filled in the handler) + var server *httptest.Server + var signedCardBytes []byte + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/agent-card.json" { + // Build card on first request with correct server URL + if signedCardBytes == nil { + cardWithoutSig := map[string]interface{}{ + "version": "1.0", + "name": "test-seller", + "capabilities": map[string]interface{}{ + "extensions": []interface{}{ + map[string]interface{}{ + "uri": "https://modelcontextprotocol.io/ext/server/v1", + "params": map[string]interface{}{ + "endpoint": server.URL + "/mcp/", + }, + }, + map[string]interface{}{ + "uri": "https://ans.dev/ext/dpop/v1", + "params": map[string]interface{}{ + "alg": "ES256", + "ath": "base64url(SHA-256(quote_id))", + "htm": "POST", + "htu": server.URL + "/mcp/", + "typ": "dpop+jwt", + "claims": []string{"jti", "htm", "htu", "iat", "ath"}, + }, + }, + }, + }, + } + cardBodyBytes, _ := json.Marshal(cardWithoutSig) + protectedHeader := map[string]interface{}{ + "alg": "EdDSA", + "jku": jkuServer.URL, + "kid": "test-key-001", + "typ": "application/agent-card+json", + } + protectedBytes, _ := json.Marshal(protectedHeader) + protectedB64 := base64.RawURLEncoding.EncodeToString(protectedBytes) + payloadB64 := base64.RawURLEncoding.EncodeToString(cardBodyBytes) + signingInput := protectedB64 + "." + payloadB64 + signature := ed25519.Sign(privKey, []byte(signingInput)) + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) + cardWithoutSig["signatures"] = []interface{}{ + map[string]interface{}{ + "protected": protectedB64, + "signature": signatureB64, + }, + } + signedCardBytes, _ = json.Marshal(cardWithoutSig) + } + w.Header().Set("Content-Type", "application/json") + w.Write(signedCardBytes) + return + } + if r.URL.Path == "/mcp/" { + w.Header().Set("Content-Type", "application/json") + w.Write(toolsListResponseBytes) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + discoverer := NewHTTPDiscoverer(&http.Client{Timeout: 5 * time.Second}, 10*time.Minute) + target := Target{ + Host: server.URL, // Full URL with http:// + Tool: "book_flight", + } + + req := discoverer.Discover(context.Background(), target) + + if req.Empty() { + t.Fatal("expected non-empty requirement") + } + if req.Tool != "book_flight" { + t.Errorf("Tool = %v, want book_flight", req.Tool) + } + wantCaps := []Capability{"quote_id", "mandate", "dpop_proof"} + if !reflect.DeepEqual(req.Capabilities, wantCaps) { + t.Errorf("Capabilities = %v, want %v", req.Capabilities, wantCaps) + } + if req.DPoP == nil { + t.Fatal("expected DPoP params") + } + // Check that htu matches server URL + expectedHtu := server.URL + "/mcp/" + if req.DPoP.Htu != expectedHtu { + t.Errorf("DPoP.Htu = %v, want %v", req.DPoP.Htu, expectedHtu) + } + if req.DPoP.Alg != "ES256" { + t.Errorf("DPoP.Alg = %v, want ES256", req.DPoP.Alg) + } + if req.DPoP.Ath != "base64url(SHA-256(quote_id))" { + t.Errorf("DPoP.Ath = %v, want base64url(SHA-256(quote_id))", req.DPoP.Ath) + } + }) + + t.Run("empty tool name short-circuits without network", func(t *testing.T) { + hitCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitCount++ + http.NotFound(w, r) + })) + defer server.Close() + + discoverer := NewHTTPDiscoverer(&http.Client{Timeout: 5 * time.Second}, 10*time.Minute) + target := Target{ + Host: server.URL, + Tool: "", // Empty tool name + } + + req := discoverer.Discover(context.Background(), target) + + if !req.Empty() { + t.Errorf("expected empty requirement for empty tool, got %+v", req) + } + if hitCount != 0 { + t.Errorf("expected zero network calls for empty tool, got %d", hitCount) + } + }) + + t.Run("404 card fetch returns empty", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer server.Close() + + discoverer := NewHTTPDiscoverer(&http.Client{Timeout: 5 * time.Second}, 10*time.Minute) + target := Target{ + Host: server.URL, + Tool: "book_flight", + } + + req := discoverer.Discover(context.Background(), target) + + if !req.Empty() { + t.Errorf("expected empty requirement, got %+v", req) + } + }) + + t.Run("unverified card returns requirement without htu", func(t *testing.T) { + // Card without signatures (unverified) should not trust the discovered htu + var server *httptest.Server + var cardBytes []byte + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/.well-known/agent-card.json" { + if cardBytes == nil { + cardWithoutSig := map[string]interface{}{ + "version": "1.0", + "name": "test-seller", + "capabilities": map[string]interface{}{ + "extensions": []interface{}{ + map[string]interface{}{ + "uri": "https://modelcontextprotocol.io/ext/server/v1", + "params": map[string]interface{}{ + "endpoint": server.URL + "/mcp/", + }, + }, + map[string]interface{}{ + "uri": "https://ans.dev/ext/dpop/v1", + "params": map[string]interface{}{ + "alg": "ES256", + "ath": "base64url(SHA-256(quote_id))", + "htm": "POST", + "htu": server.URL + "/mcp/", + "typ": "dpop+jwt", + "claims": []string{"jti", "htm", "htu", "iat", "ath"}, + }, + }, + }, + }, + } + cardBytes, _ = json.Marshal(cardWithoutSig) + } + w.Header().Set("Content-Type", "application/json") + w.Write(cardBytes) + return + } + if r.URL.Path == "/mcp/" { + w.Header().Set("Content-Type", "application/json") + w.Write(toolsListResponseBytes) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + discoverer := NewHTTPDiscoverer(&http.Client{Timeout: 5 * time.Second}, 10*time.Minute) + target := Target{ + Host: server.URL, + Tool: "book_flight", + } + + req := discoverer.Discover(context.Background(), target) + + // Should have capabilities (from tools/list) but NO DPoP (htu not trusted) + if req.Tool != "book_flight" { + t.Errorf("Tool = %v, want book_flight", req.Tool) + } + wantCaps := []Capability{"quote_id", "mandate", "dpop_proof"} + if !reflect.DeepEqual(req.Capabilities, wantCaps) { + t.Errorf("Capabilities = %v, want %v", req.Capabilities, wantCaps) + } + if req.DPoP != nil { + t.Errorf("expected nil DPoP for unverified card, got %+v", req.DPoP) + } + }) + + t.Run("caching works", func(t *testing.T) { + // Generate a throwaway Ed25519 keypair + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("failed to generate Ed25519 key: %v", err) + } + + // Create JWK representation + jwk := map[string]interface{}{ + "kid": "test-key-001", + "kty": "OKP", + "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(pubKey), + } + jwks := map[string]interface{}{ + "keys": []interface{}{jwk}, + } + + // Serve JWK + jkuServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(jwks) + })) + defer jkuServer.Close() + + callCount := 0 + var server *httptest.Server + var signedCardBytes []byte + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if r.URL.Path == "/.well-known/agent-card.json" { + if signedCardBytes == nil { + cardWithoutSig := map[string]interface{}{ + "version": "1.0", + "name": "test-seller", + "capabilities": map[string]interface{}{ + "extensions": []interface{}{ + map[string]interface{}{ + "uri": "https://modelcontextprotocol.io/ext/server/v1", + "params": map[string]interface{}{ + "endpoint": server.URL + "/mcp/", + }, + }, + map[string]interface{}{ + "uri": "https://ans.dev/ext/dpop/v1", + "params": map[string]interface{}{ + "alg": "ES256", + "htu": server.URL + "/mcp/", + }, + }, + }, + }, + } + cardBodyBytes, _ := json.Marshal(cardWithoutSig) + protectedHeader := map[string]interface{}{ + "alg": "EdDSA", + "jku": jkuServer.URL, + "kid": "test-key-001", + } + protectedBytes, _ := json.Marshal(protectedHeader) + protectedB64 := base64.RawURLEncoding.EncodeToString(protectedBytes) + payloadB64 := base64.RawURLEncoding.EncodeToString(cardBodyBytes) + signingInput := protectedB64 + "." + payloadB64 + signature := ed25519.Sign(privKey, []byte(signingInput)) + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) + cardWithoutSig["signatures"] = []interface{}{ + map[string]interface{}{ + "protected": protectedB64, + "signature": signatureB64, + }, + } + signedCardBytes, _ = json.Marshal(cardWithoutSig) + } + w.Header().Set("Content-Type", "application/json") + w.Write(signedCardBytes) + return + } + if r.URL.Path == "/mcp/" { + w.Header().Set("Content-Type", "application/json") + w.Write(toolsListResponseBytes) + return + } + http.NotFound(w, r) + })) + defer server.Close() + + discoverer := NewHTTPDiscoverer(&http.Client{Timeout: 5 * time.Second}, 10*time.Minute) + target := Target{ + Host: server.URL, + Tool: "book_flight", + } + + // First call + req1 := discoverer.Discover(context.Background(), target) + firstCallCount := callCount + + // Second call (should hit cache) + req2 := discoverer.Discover(context.Background(), target) + secondCallCount := callCount + + if req1.Empty() || req2.Empty() { + t.Fatal("expected non-empty requirements") + } + if !reflect.DeepEqual(req1, req2) { + t.Errorf("cached result differs from original") + } + if secondCallCount != firstCallCount { + t.Errorf("expected cache hit (no new calls), but got %d calls on second request", secondCallCount-firstCallCount) + } + }) +} + +// TestCapsEqual tests the capsEqual helper function. +func TestCapsEqual(t *testing.T) { + tests := []struct { + name string + a []Capability + b []Capability + want bool + }{ + {name: "equal slices", a: []Capability{"mandate", "dpop_proof"}, b: []Capability{"mandate", "dpop_proof"}, want: true}, + {name: "equal different order", a: []Capability{"mandate", "dpop_proof"}, b: []Capability{"dpop_proof", "mandate"}, want: true}, + {name: "different lengths", a: []Capability{"mandate"}, b: []Capability{"mandate", "dpop_proof"}, want: false}, + {name: "different elements", a: []Capability{"mandate", "other"}, b: []Capability{"mandate", "dpop_proof"}, want: false}, + {name: "both empty", a: []Capability{}, b: []Capability{}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := capsEqual(tt.a, tt.b); got != tt.want { + t.Errorf("capsEqual() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestExtractMCPURL_ErrorPaths tests error handling in MCP URL extraction. +func TestExtractMCPURL_ErrorPaths(t *testing.T) { + tests := []struct { + name string + card string + wantErr bool + }{ + {name: "MCP extension", card: `{"capabilities":{"extensions":[{"uri":"https://modelcontextprotocol.io/ext/server/v1","params":{"endpoint":"https://example.com/mcp/"}}]}}`, wantErr: false}, + {name: "supportedInterfaces", card: `{"supportedInterfaces":[{"protocolBinding":"https://modelcontextprotocol.io/spec/v1","url":"https://example.com/mcp/"}]}`, wantErr: false}, + {name: "missing MCP URL", card: `{"capabilities":{}}`, wantErr: true}, + {name: "invalid JSON", card: `{invalid`, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := extractMCPURL([]byte(tt.card)) + if (err != nil) != tt.wantErr { + t.Errorf("extractMCPURL() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestVerifyCardJWS_NoSignatures tests the no-signatures path. +func TestVerifyCardJWS_NoSignatures(t *testing.T) { + card := `{"version":"1.0"}` + // No signatures block returns false (not verified) + if got := verifyCardJWS([]byte(card)); got { + t.Errorf("verifyCardJWS() = %v, want false for no-signatures card", got) + } +} + +// TestVerifyCardJWS_InvalidJSON tests invalid JSON handling. +func TestVerifyCardJWS_InvalidJSON(t *testing.T) { + card := `{invalid` + if got := verifyCardJWS([]byte(card)); got { + t.Error("verifyCardJWS() = true for invalid JSON, want false") + } +} + +// TestVerifyCardJWS_HermeticEd25519 tests Ed25519 signature verification with a hermetic httptest JWK server. +// This test actually exercises the signature verification path (not just JSON parsing). +func TestVerifyCardJWS_HermeticEd25519(t *testing.T) { + // Generate a throwaway Ed25519 keypair + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("failed to generate Ed25519 key: %v", err) + } + + // Create JWK representation of the public key + jwk := map[string]interface{}{ + "kid": "test-key-001", + "kty": "OKP", + "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(pubKey), + } + jwks := map[string]interface{}{ + "keys": []interface{}{jwk}, + } + + // Serve the JWK from httptest + jkuServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(jwks) + })) + defer jkuServer.Close() + + // Build an agent card WITHOUT signatures + cardWithoutSig := map[string]interface{}{ + "version": "1.0", + "name": "test-agent", + "capabilities": map[string]interface{}{ + "extensions": []interface{}{ + map[string]interface{}{ + "uri": "https://modelcontextprotocol.io/ext/server/v1", + "params": map[string]interface{}{ + "endpoint": "https://example.com/mcp/", + }, + }, + }, + }, + } + + // Serialize to canonical JSON + cardBodyBytes, err := json.Marshal(cardWithoutSig) + if err != nil { + t.Fatalf("failed to marshal card body: %v", err) + } + + // Build JWS protected header + protectedHeader := map[string]interface{}{ + "alg": "EdDSA", + "jku": jkuServer.URL, + "kid": "test-key-001", + "typ": "application/agent-card+json", + } + protectedBytes, err := json.Marshal(protectedHeader) + if err != nil { + t.Fatalf("failed to marshal protected header: %v", err) + } + protectedB64 := base64.RawURLEncoding.EncodeToString(protectedBytes) + payloadB64 := base64.RawURLEncoding.EncodeToString(cardBodyBytes) + + // Sign: Ed25519.Sign(protected.payload) + signingInput := protectedB64 + "." + payloadB64 + signature := ed25519.Sign(privKey, []byte(signingInput)) + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) + + // Build the signed card + cardWithoutSig["signatures"] = []interface{}{ + map[string]interface{}{ + "protected": protectedB64, + "signature": signatureB64, + }, + } + signedCardBytes, err := json.Marshal(cardWithoutSig) + if err != nil { + t.Fatalf("failed to marshal signed card: %v", err) + } + + // Test 1: UNMODIFIED signed card → verifies successfully + t.Run("valid Ed25519 signature verifies", func(t *testing.T) { + if got := verifyCardJWS(signedCardBytes); !got { + t.Errorf("verifyCardJWS() = %v, want true for valid signature", got) + } + }) + + // Test 2: Tamper the card body (keeping JSON valid) → signature verification fails + t.Run("tampered card body fails Ed25519 verification", func(t *testing.T) { + var tamperedCard map[string]interface{} + if err := json.Unmarshal(signedCardBytes, &tamperedCard); err != nil { + t.Fatalf("failed to unmarshal signed card: %v", err) + } + // Tamper: change the version field + tamperedCard["version"] = "2.0" + tamperedBytes, err := json.Marshal(tamperedCard) + if err != nil { + t.Fatalf("failed to marshal tampered card: %v", err) + } + + // Tampered card should fail signature verification + if got := verifyCardJWS(tamperedBytes); got { + t.Error("verifyCardJWS() = true for tampered card, want false") + } + }) +} + +// Additional coverage tests for fix-round 1 + +// TestVerifyCardJWS_JkuUnreachable tests jku unreachable path. +func TestVerifyCardJWS_JkuUnreachable(t *testing.T) { + // Generate a key and create a signed card + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + jwks := map[string]interface{}{ + "keys": []interface{}{ + map[string]interface{}{ + "kid": "test-key", + "kty": "OKP", + "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(pubKey), + }, + }, + } + + jkuServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(jwks) + })) + jkuURL := jkuServer.URL + + cardBody := map[string]interface{}{"version": "1.0", "name": "test"} + cardBodyBytes, _ := json.Marshal(cardBody) + protectedHeader := map[string]interface{}{"alg": "EdDSA", "jku": jkuURL, "kid": "test-key"} + protectedBytes, _ := json.Marshal(protectedHeader) + protectedB64 := base64.RawURLEncoding.EncodeToString(protectedBytes) + payloadB64 := base64.RawURLEncoding.EncodeToString(cardBodyBytes) + signingInput := protectedB64 + "." + payloadB64 + signature := ed25519.Sign(privKey, []byte(signingInput)) + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) + cardBody["signatures"] = []interface{}{ + map[string]interface{}{"protected": protectedB64, "signature": signatureB64}, + } + signedCard, _ := json.Marshal(cardBody) + + // Close server before verification + jkuServer.Close() + + if got := verifyCardJWS(signedCard); got { + t.Error("verifyCardJWS() = true for unreachable jku, want false") + } +} + +// TestVerifyCardJWS_MoreErrorPaths tests additional error paths. +func TestVerifyCardJWS_MoreErrorPaths(t *testing.T) { + t.Run("invalid protected header base64", func(t *testing.T) { + card := `{"signatures":[{"protected":"!!!invalid!!!","signature":"dGVzdA"}]}` + if got := verifyCardJWS([]byte(card)); got { + t.Error("want false for invalid protected base64") + } + }) + + t.Run("invalid protected JSON", func(t *testing.T) { + invalidJSON := base64.RawURLEncoding.EncodeToString([]byte("{bad")) + card := fmt.Sprintf(`{"signatures":[{"protected":"%s","signature":"dGVzdA"}]}`, invalidJSON) + if got := verifyCardJWS([]byte(card)); got { + t.Error("want false for invalid protected JSON") + } + }) + + t.Run("unsupported algorithm", func(t *testing.T) { + hdr := map[string]interface{}{"alg": "RS256"} + hdrBytes, _ := json.Marshal(hdr) + hdrB64 := base64.RawURLEncoding.EncodeToString(hdrBytes) + card := fmt.Sprintf(`{"signatures":[{"protected":"%s","signature":"dGVzdA"}]}`, hdrB64) + if got := verifyCardJWS([]byte(card)); got { + t.Error("want false for unsupported alg") + } + }) + + t.Run("invalid signature base64", func(t *testing.T) { + hdr := map[string]interface{}{"alg": "EdDSA", "jku": "http://x", "kid": "k"} + hdrBytes, _ := json.Marshal(hdr) + hdrB64 := base64.RawURLEncoding.EncodeToString(hdrBytes) + card := fmt.Sprintf(`{"signatures":[{"protected":"%s","signature":"!!!bad!!!"}]}`, hdrB64) + if got := verifyCardJWS([]byte(card)); got { + t.Error("want false for invalid signature base64") + } + }) +} + +// TestFetchEdDSAPublicKey_AllPaths tests all fetchEdDSAPublicKey branches. +func TestFetchEdDSAPublicKey_AllPaths(t *testing.T) { + pubKey, _, _ := ed25519.GenerateKey(rand.Reader) + + t.Run("success", func(t *testing.T) { + jwks := map[string]interface{}{ + "keys": []interface{}{ + map[string]interface{}{ + "kid": "k", "kty": "OKP", "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(pubKey), + }, + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(jwks) + })) + defer srv.Close() + key, err := fetchEdDSAPublicKey(srv.URL, "k") + if err != nil || len(key) != ed25519.PublicKeySize { + t.Errorf("want success, got err=%v", err) + } + }) + + t.Run("non-200", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + _, err := fetchEdDSAPublicKey(srv.URL, "k") + if err == nil { + t.Error("want error for 404") + } + }) + + t.Run("bad JSON", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte(`{bad`)) + })) + defer srv.Close() + _, err := fetchEdDSAPublicKey(srv.URL, "k") + if err == nil { + t.Error("want error for bad JSON") + } + }) + + t.Run("key not found", func(t *testing.T) { + jwks := map[string]interface{}{"keys": []interface{}{}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(jwks) + })) + defer srv.Close() + _, err := fetchEdDSAPublicKey(srv.URL, "missing") + if err == nil { + t.Error("want error for missing key") + } + }) + + t.Run("wrong key type", func(t *testing.T) { + jwks := map[string]interface{}{ + "keys": []interface{}{ + map[string]interface{}{ + "kid": "k", "kty": "RSA", "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(pubKey), + }, + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(jwks) + })) + defer srv.Close() + _, err := fetchEdDSAPublicKey(srv.URL, "k") + if err == nil { + t.Error("want error for wrong kty") + } + }) + + t.Run("bad x base64", func(t *testing.T) { + jwks := map[string]interface{}{ + "keys": []interface{}{ + map[string]interface{}{ + "kid": "k", "kty": "OKP", "crv": "Ed25519", "x": "!!!bad!!!", + }, + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(jwks) + })) + defer srv.Close() + _, err := fetchEdDSAPublicKey(srv.URL, "k") + if err == nil { + t.Error("want error for bad x base64") + } + }) + + t.Run("wrong key size", func(t *testing.T) { + jwks := map[string]interface{}{ + "keys": []interface{}{ + map[string]interface{}{ + "kid": "k", "kty": "OKP", "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString([]byte("short")), + }, + }, + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + json.NewEncoder(w).Encode(jwks) + })) + defer srv.Close() + _, err := fetchEdDSAPublicKey(srv.URL, "k") + if err == nil { + t.Error("want error for wrong key size") + } + }) + + t.Run("network error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + url := srv.URL + srv.Close() + _, err := fetchEdDSAPublicKey(url, "k") + if err == nil { + t.Error("want error for network failure") + } + }) +} + +// TestFetchURL_Errors tests fetchURL error branches. +func TestFetchURL_Errors(t *testing.T) { + d := NewHTTPDiscoverer(&http.Client{Timeout: 5 * time.Second}, 10*time.Minute) + + t.Run("non-200", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + _, err := d.fetchURL(context.Background(), srv.URL) + if err == nil { + t.Error("want error for 500") + } + }) + + t.Run("network error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + url := srv.URL + srv.Close() + _, err := d.fetchURL(context.Background(), url) + if err == nil { + t.Error("want error for closed server") + } + }) +} + +// TestFetchToolsList_Errors tests fetchToolsList error branches. +func TestFetchToolsList_Errors(t *testing.T) { + d := NewHTTPDiscoverer(&http.Client{Timeout: 5 * time.Second}, 10*time.Minute) + + t.Run("non-200", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + })) + defer srv.Close() + _, err := d.fetchToolsList(context.Background(), srv.URL) + if err == nil { + t.Error("want error for 400") + } + }) + + t.Run("bad JSON", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte(`{bad`)) + })) + defer srv.Close() + _, err := d.fetchToolsList(context.Background(), srv.URL) + if err == nil { + t.Error("want error for bad JSON") + } + }) + + t.Run("JSON-RPC error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := map[string]interface{}{"jsonrpc": "2.0", "id": 1, "error": map[string]string{"message": "err"}} + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + _, err := d.fetchToolsList(context.Background(), srv.URL) + if err == nil { + t.Error("want error for JSON-RPC error") + } + }) + + t.Run("network error", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + url := srv.URL + srv.Close() + _, err := d.fetchToolsList(context.Background(), url) + if err == nil { + t.Error("want error for closed server") + } + }) +} + +// TestParsePrerequisitesFromCard_AllPaths tests all parsePrerequisitesFromCard branches. +func TestParsePrerequisitesFromCard_AllPaths(t *testing.T) { + d := NewHTTPDiscoverer(&http.Client{}, 0) + + t.Run("valid prereqs", func(t *testing.T) { + card := []byte(`{"skills":[{"id":"t","description":"X. Prerequisites: a, b."}]}`) + caps := d.parsePrerequisitesFromCard(card, "t") + if len(caps) != 2 { + t.Errorf("want 2 caps, got %d", len(caps)) + } + }) + + t.Run("no match", func(t *testing.T) { + card := []byte(`{"skills":[{"id":"other","description":"X"}]}`) + caps := d.parsePrerequisitesFromCard(card, "t") + if caps != nil { + t.Errorf("want nil, got %v", caps) + } + }) + + t.Run("bad JSON", func(t *testing.T) { + card := []byte(`{bad`) + caps := d.parsePrerequisitesFromCard(card, "t") + if caps != nil { + t.Errorf("want nil, got %v", caps) + } + }) + + t.Run("no prereqs", func(t *testing.T) { + card := []byte(`{"skills":[{"id":"t","description":"No prereqs"}]}`) + caps := d.parsePrerequisitesFromCard(card, "t") + if caps != nil { + t.Errorf("want nil, got %v", caps) + } + }) +} + +// TestNewHTTPDiscoverer_DefaultTTL tests the TTL default path. +func TestNewHTTPDiscoverer_DefaultTTL(t *testing.T) { + d := NewHTTPDiscoverer(&http.Client{}, 0) + if d.ttl != defaultDiscoveryTTL { + t.Errorf("ttl = %v, want %v", d.ttl, defaultDiscoveryTTL) + } +} diff --git a/internal/capability/testdata/seller-agent-card.json b/internal/capability/testdata/seller-agent-card.json new file mode 100644 index 0000000..ff64b8d --- /dev/null +++ b/internal/capability/testdata/seller-agent-card.json @@ -0,0 +1 @@ +{"capabilities":{"extendedAgentCard":false,"extensions":[{"description":"MCP server endpoint","params":{"endpoint":"https://seller.skybound-travel.com/mcp/","version":"1.0"},"required":false,"uri":"https://modelcontextprotocol.io/ext/server/v1"},{"description":"ANS trust stack","params":{"version":"v1"},"required":false,"uri":"https://ans.dev/ext/trust-stack/v1"},{"description":"ANS DPoP proof-of-possession required on the book_flight tool","params":{"alg":"ES256","ath":"base64url(SHA-256(quote_id))","claims":["jti","htm","htu","iat","ath"],"htm":"POST","htu":"https://seller.skybound-travel.com/mcp/","typ":"dpop+jwt"},"required":false,"uri":"https://ans.dev/ext/dpop/v1"},{"description":"RFC 9421 HTTP message signatures on outbound calls","required":false,"uri":"https://ans.dev/ext/http-message-signatures/v1"},{"description":"ANS agent identity","params":{"ansUri":"ans://v1.0.8.seller.skybound-travel.com","jwksUri":"https://seller.skybound-travel.com/.well-known/jwks.json","spiffeId":"spiffe://seller.skybound-travel.com","trustCard":"https://seller.skybound-travel.com/.well-known/ans/trust-card.json"},"required":false,"uri":"https://ans.dev/ext/identity/v1"},{"description":"ANS discovery pointers","params":{"aiCatalog":"https://seller.skybound-travel.com/.well-known/ai-catalog.json","ansName":"ans://v1.0.8.seller.skybound-travel.com","interactionGuide":"https://seller.skybound-travel.com/.well-known/interaction-guide.json","interactionGuideHuman":"https://seller.skybound-travel.com/","methods":["ans_registered","dns_aid_svcb"]},"required":false,"uri":"https://ans.dev/ext/discovery/v1"}],"pushNotifications":false,"streaming":false},"defaultInputModes":["text/plain","application/json"],"defaultOutputModes":["application/json","text/plain"],"description":"seller agent for Skybound Travel — A2A-first; MCP also supported","documentationUrl":"https://seller.skybound-travel.com","name":"seller","provider":{"organization":"Skybound Travel","url":"https://skybound-travel.com"},"securityRequirements":[{},{"ansIdentityCert":[]}],"securitySchemes":{"ansIdentityCert":{"mtlsSecurityScheme":{"description":"ANS identity certificate (mutual TLS)"}}},"signatures":[{"header":{"kid":"HtevsZBuER7mho80btaICxEFyaa97dxL8QeKZEgpN90"},"protected":"eyJhbGciOiJFZERTQSIsImprdSI6Imh0dHBzOi8vc2VsbGVyLnNreWJvdW5kLXRyYXZlbC5jb20vLndlbGwta25vd24vYW5zL3RydXN0LWNhcmQuanNvbiIsImtpZCI6Ikh0ZXZzWkJ1RVI3bWhvODBidGFJQ3hFRnlhYTk3ZHhMOFFlS1pFZ3BOOTAiLCJ0eXAiOiJhZ2VudC1jYXJkK2p3cyJ9","signature":"LnF0lCxqXCcWBLYJhjc-6EZpIpyMMg7sdLg37fZiDOclj8d3VODbFrYUdeKvUIYhwoaBms6Pc70HCxiG6rrKBg"}],"skills":[{"description":"Products with a seller-signed quote JWS per variant (dev.agenthaven.quote). Open to anyone; no authorisation required.","id":"flight_search","name":"Search flights","tags":["seller","ucp"]},{"description":"A2A + MCP. Ticket { ticket_id, quote_id, mandate_id, passenger_ans, flight, issued_at, scitt_receipt } Signed with dpop (dpop). Prerequisites: quote_id, mandate, dpop_proof.","id":"checkout","name":"Book the flight","tags":["seller","a2a","mcp"]}],"supportedInterfaces":[{"protocolBinding":"JSONRPC","protocolVersion":"1.0","url":"https://seller.skybound-travel.com/a2a"},{"protocolBinding":"https://modelcontextprotocol.io/binding/http/v1","protocolVersion":"1.0","url":"https://seller.skybound-travel.com/mcp/"}],"version":"1.0.8"} diff --git a/internal/capability/testdata/seller-tools-list.json b/internal/capability/testdata/seller-tools-list.json new file mode 100644 index 0000000..bfb7669 --- /dev/null +++ b/internal/capability/testdata/seller-tools-list.json @@ -0,0 +1 @@ +{"tools":[{"name":"book_flight","description":"Book the flight. Returns Ticket { ticket_id, quote_id, mandate_id, passenger_ans, flight, issued_at, scitt_receipt }. Signed with dpop (dpop). Prerequisites: quote_id, mandate, dpop_proof.","inputSchema":{"properties":{"dpop_proof":{"description":"DPoP JWS proving possession of the mandate's jkt key.","type":"string"},"mandate":{"description":"The signed mandate JSON from request_mandate. (from prerequisite: mandate)","type":"object"},"option_id":{"description":"Falls back to quote_id when empty.","type":"string"},"quote_id":{"description":"Same quote_id bound in the mandate.","type":"string"}},"required":["quote_id","mandate","dpop_proof"],"type":"object"}}]} diff --git a/internal/identity/artifacts.go b/internal/identity/artifacts.go index 329dac6..1fb689c 100644 --- a/internal/identity/artifacts.go +++ b/internal/identity/artifacts.go @@ -2,12 +2,19 @@ package identity import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" "crypto/x509" + "encoding/base64" "fmt" "os" "time" "github.com/agentnameservice/ans-sdk-go/verify/scitt" + + "github.com/agentnameservice/ans-openshell/internal/selfsign" ) // Artifacts are the SCITT headers a signed request carries, base64-encoded as @@ -129,3 +136,47 @@ func readArtifactFiles(receiptFile, statusTokenFile string) ([]byte, []byte, err } return receipt, token, nil } + +// NewSelfSignedArtifacts generates a static ArtifactSource with self-signed +// status token and receipt artifacts for dev-mode identities. It generates an +// ephemeral ES256 "TL" key internally, mints a status token (status ACTIVE, +// iat=now-1min, exp=now+1h, ValidIdentityCerts=[sha256(cert.Raw)], ansName, +// agentID) and a receipt over (agentID, ansName), base64-encodes both, and +// returns a static ArtifactSource. +// +// The artifacts are signed by an ephemeral key that is NOT in any real +// transparency log's root-key set, so they will be rejected by the ingress +// verify_caller middleware when it checks the status token against the live TL. +func NewSelfSignedArtifacts(cert *x509.Certificate, ansName, agentID string) (ArtifactSource, error) { + // Generate ephemeral ES256 key for signing + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate ephemeral key: %w", err) + } + + // Compute cert fingerprint + certFP := sha256.Sum256(cert.Raw) + + // Mint status token: iat=now-1min, exp=now+1h + now := time.Now() + iat := now.Add(-time.Minute).Unix() + exp := now.Add(time.Hour).Unix() + statusTokenBytes, err := selfsign.StatusToken(key, agentID, ansName, scitt.StatusActive, iat, exp, certFP) + if err != nil { + return nil, fmt.Errorf("mint status token: %w", err) + } + + // Mint receipt + receiptBytes, err := selfsign.Receipt(key, agentID, ansName) + if err != nil { + return nil, fmt.Errorf("mint receipt: %w", err) + } + + // Base64-encode both + artifacts := Artifacts{ + ReceiptBase64: base64.StdEncoding.EncodeToString(receiptBytes), + StatusTokenBase64: base64.StdEncoding.EncodeToString(statusTokenBytes), + } + + return NewStaticSource(artifacts), nil +} diff --git a/internal/identity/artifacts_test.go b/internal/identity/artifacts_test.go index c17532c..be46752 100644 --- a/internal/identity/artifacts_test.go +++ b/internal/identity/artifacts_test.go @@ -42,6 +42,85 @@ func TestCertificateSource(t *testing.T) { } } +func TestNewSelfSignedArtifacts(t *testing.T) { + // Generate a self-signed identity cert + const ( + ansName = "ans://v9.9.9.rogue.example" + agentID = "rogue-0001" + ) + + _, _, cert, err := NewSelfSignedSigner(ansName) + if err != nil { + t.Fatalf("NewSelfSignedSigner: %v", err) + } + + // Create self-signed artifacts + src, err := NewSelfSignedArtifacts(cert, ansName, agentID) + if err != nil { + t.Fatalf("NewSelfSignedArtifacts: %v", err) + } + + // Get the current artifacts + arts := src.Current() + + // Assert both status token and receipt are non-empty + if arts.StatusTokenBase64 == "" { + t.Error("StatusTokenBase64 is empty, expected non-empty") + } + if arts.ReceiptBase64 == "" { + t.Error("ReceiptBase64 is empty, expected non-empty") + } + + // Assert Healthy returns true + if !src.Healthy() { + t.Error("Healthy() = false, expected true") + } + + // Decode and parse the status token + tokenBytes, err := base64.StdEncoding.DecodeString(arts.StatusTokenBase64) + if err != nil { + t.Fatalf("decode status token: %v", err) + } + + // Parse as COSE_Sign1 + parsedToken, err := scitt.ParseCoseSign1(tokenBytes) + if err != nil { + t.Fatalf("parse status token as COSE_Sign1: %v", err) + } + + // Assert the token's kid is NOT in a real TL's root-key set + // (the self-signed key should be rejected by a real TL verifier) + realTL := testfixtures.MustNewTL() + if _, verifyErr := scitt.VerifyStatusToken(tokenBytes, realTL.KeyStore(), time.Minute); verifyErr == nil { + t.Error("expected status token verification to fail against real TL, but it succeeded") + } + + // Decode and parse the receipt + receiptBytes, err := base64.StdEncoding.DecodeString(arts.ReceiptBase64) + if err != nil { + t.Fatalf("decode receipt: %v", err) + } + + // Parse as COSE_Sign1 + parsedReceipt, err := scitt.ParseCoseSign1(receiptBytes) + if err != nil { + t.Fatalf("parse receipt as COSE_Sign1: %v", err) + } + + // Assert the receipt's kid is NOT in the real TL's root-key set + if _, verifyErr := scitt.VerifyReceipt(receiptBytes, realTL.KeyStore()); verifyErr == nil { + t.Error("expected receipt verification to fail against real TL, but it succeeded") + } + + // Verify the status token can be verified when we actually try + // (to ensure we minted well-formed COSE) + _ = parsedToken + _ = parsedReceipt + + // We can't verify claims without a successful VerifyStatusToken, + // but we can verify the COSE parses correctly (done above) +} + func TestStaticSource(t *testing.T) { t.Parallel() full := Artifacts{ReceiptBase64: "r", StatusTokenBase64: "t"} diff --git a/internal/identity/registry.go b/internal/identity/registry.go index a072782..b9456db 100644 --- a/internal/identity/registry.go +++ b/internal/identity/registry.go @@ -37,6 +37,11 @@ type Config struct { // fetching them from the transparency log. Both or neither must be set. ReceiptFile string `yaml:"receipt_file"` StatusTokenFile string `yaml:"status_token_file"` + // DevMode builds the identity from self-signed components (NewSelfSignedSigner + // and NewSelfSignedArtifacts) instead of loading keys and certificates from + // files. When true, KeyFile, CertFile, ReceiptFile and StatusTokenFile must + // not be set. + DevMode bool `yaml:"dev_mode"` } // Identity is one ANS identity the middleware can sign as. @@ -191,6 +196,40 @@ func (l *loader) identity(ctx context.Context, name string, cfg Config) (*Identi if cfg.AgentID == "" { return nil, fmt.Errorf("identity %q: agent_id must not be empty", name) } + + // Dev-mode path: build identity from self-signed components + if cfg.DevMode { + // Reject config that sets both dev_mode and file paths + if cfg.KeyFile != "" || cfg.CertFile != "" { + return nil, fmt.Errorf("identity %q: dev_mode must not set key_file/cert_file", name) + } + if cfg.ReceiptFile != "" || cfg.StatusTokenFile != "" { + return nil, fmt.Errorf("identity %q: dev_mode must not set receipt_file/status_token_file", name) + } + + // Build self-signed signer + signer, _, cert, signerErr := NewSelfSignedSigner(cfg.AnsName) + if signerErr != nil { + return nil, fmt.Errorf("identity %q: self-signed signer: %w", name, signerErr) + } + + // Build self-signed artifacts + artifacts, artifactsErr := NewSelfSignedArtifacts(cert, cfg.AnsName, cfg.AgentID) + if artifactsErr != nil { + return nil, fmt.Errorf("identity %q: self-signed artifacts: %w", name, artifactsErr) + } + + // Wrap with certificate-validity gate + artifacts = &certificateSource{ArtifactSource: artifacts, cert: cert, now: time.Now} + + // Log dev-mode warning + l.log.WarnContext(ctx, "dev-mode identity: self-signed, NOT vouched by any transparency log", + "identity", name, "ans_name", cfg.AnsName) + + return NewIdentity(name, cfg.AnsName, cfg.AgentID, signer, artifacts) + } + + // File-based path (existing code) signer, cert, err := loadSigner(cfg) if err != nil { return nil, fmt.Errorf("identity %q: %w", name, err) diff --git a/internal/identity/registry_test.go b/internal/identity/registry_test.go index 4103ad4..dcfeff5 100644 --- a/internal/identity/registry_test.go +++ b/internal/identity/registry_test.go @@ -263,3 +263,181 @@ func TestNewRegistryEmpty(t *testing.T) { t.Fatalf("empty registry: names %v healthy %v", reg.Names(), reg.Healthy()) } } + +func TestRegistry_DevModeIdentity(t *testing.T) { + t.Parallel() + // Capture log output to verify dev-mode warning + var logBuf strings.Builder + logger := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + identities := map[string]Config{ + "rogue": { + AnsName: "ans://v9.9.9.rogue.example", + AgentID: "rogue-0001", + DevMode: true, + }, + } + + reg, err := NewRegistry(t.Context(), identities, config.TransparencyLog{}, logger) + if err != nil { + t.Fatalf("NewRegistry: %v", err) + } + defer reg.Close() + + // Verify identity was built + id, ok := reg.Identity("rogue") + if !ok { + t.Fatal("identity 'rogue' not found") + } + + // Verify it has expected properties + if id.AnsName() != "ans://v9.9.9.rogue.example" { + t.Errorf("AnsName() = %q, want %q", id.AnsName(), "ans://v9.9.9.rogue.example") + } + if id.AgentID() != "rogue-0001" { + t.Errorf("AgentID() = %q, want %q", id.AgentID(), "rogue-0001") + } + + // Verify the Signer works + proof, err := id.Signer().Sign(t.Context(), "GET", "https://example.com/", [32]byte{}) + if err != nil { + t.Fatalf("Signer.Sign: %v", err) + } + if proof == "" { + t.Error("Signer.Sign returned empty proof") + } + + // Verify Artifacts are non-empty + arts := id.Artifacts().Current() + if arts.StatusTokenBase64 == "" { + t.Error("StatusTokenBase64 is empty") + } + if arts.ReceiptBase64 == "" { + t.Error("ReceiptBase64 is empty") + } + + // Verify the source is healthy + if !id.Artifacts().Healthy() { + t.Error("Artifacts().Healthy() = false") + } + + // Verify dev-mode warning was logged + logOutput := logBuf.String() + if !strings.Contains(logOutput, "dev-mode") { + t.Errorf("log output missing 'dev-mode': %s", logOutput) + } + if !strings.Contains(logOutput, "WARN") { + t.Errorf("log output missing 'WARN': %s", logOutput) + } +} + +func TestRegistry_DevModeDefaultsFalse(t *testing.T) { + t.Parallel() + tl := testfixtures.MustNewTL() + id := testfixtures.MustNewIdentity(testAgentID, testAnsName) + srv := testfixtures.FakeTLServer(tl, id) + defer srv.Close() + + dir := t.TempDir() + keyFile, certFile := writeIdentityFiles(t, dir, "id", id) + + identities := map[string]Config{ + "file-based": { + AnsName: testAnsName, + AgentID: id.AgentID, + KeyFile: keyFile, + CertFile: certFile, + // DevMode not set, should default to false + }, + } + tlCfg := config.TransparencyLog{BaseURL: srv.URL, RootKeys: []string{tl.RootKeyC2SP()}, AllowInsecureTransport: true} + + reg, err := NewRegistry(t.Context(), identities, tlCfg, nil) + if err != nil { + t.Fatalf("NewRegistry: %v", err) + } + defer reg.Close() + + // Verify identity was built via file path (not dev mode) + ident, ok := reg.Identity("file-based") + if !ok { + t.Fatal("identity 'file-based' not found") + } + + // Verify it works (can sign and has artifacts) + if _, err := ident.Signer().Sign(t.Context(), "GET", "https://example.com/", [32]byte{}); err != nil { + t.Fatalf("Signer.Sign: %v", err) + } + if !ident.Artifacts().Healthy() { + t.Error("Artifacts().Healthy() = false") + } +} + +func TestRegistry_DevModeRejectsFilePaths(t *testing.T) { + t.Parallel() + dir := t.TempDir() + keyFile := filepath.Join(dir, "key.pem") + certFile := filepath.Join(dir, "cert.pem") + + tests := []struct { + name string + cfg Config + wantErr string + }{ + { + name: "dev_mode with key_file", + cfg: Config{ + AnsName: "ans://v9.9.9.rogue.example", + AgentID: "rogue-0001", + DevMode: true, + KeyFile: keyFile, + }, + wantErr: "dev_mode must not set key_file/cert_file", + }, + { + name: "dev_mode with cert_file", + cfg: Config{ + AnsName: "ans://v9.9.9.rogue.example", + AgentID: "rogue-0001", + DevMode: true, + CertFile: certFile, + }, + wantErr: "dev_mode must not set key_file/cert_file", + }, + { + name: "dev_mode with receipt_file", + cfg: Config{ + AnsName: "ans://v9.9.9.rogue.example", + AgentID: "rogue-0001", + DevMode: true, + ReceiptFile: filepath.Join(dir, "receipt.cose"), + }, + wantErr: "dev_mode must not set", + }, + { + name: "dev_mode with status_token_file", + cfg: Config{ + AnsName: "ans://v9.9.9.rogue.example", + AgentID: "rogue-0001", + DevMode: true, + StatusTokenFile: filepath.Join(dir, "token.cose"), + }, + wantErr: "dev_mode must not set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + identities := map[string]Config{"test": tt.cfg} + reg, err := NewRegistry(t.Context(), identities, config.TransparencyLog{}, nil) + if err == nil { + reg.Close() + t.Fatal("NewRegistry() succeeded, want error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("NewRegistry() error = %v, want containing %q", err, tt.wantErr) + } + }) + } +} diff --git a/internal/identity/signer.go b/internal/identity/signer.go index 7495d3e..e1fe677 100644 --- a/internal/identity/signer.go +++ b/internal/identity/signer.go @@ -7,11 +7,17 @@ package identity import ( "context" "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/sha256" "crypto/x509" "fmt" + "math/big" + "net/url" + "time" "github.com/agentnameservice/ans-sdk-go/pop" + "github.com/agentnameservice/ans-sdk-go/verify" "github.com/agentnameservice/ans-openshell/internal/pemkeys" ) @@ -66,3 +72,66 @@ func parsePrivateKeyPEM(b []byte) (*ecdsa.PrivateKey, error) { func parseCertificatePEM(b []byte) (*x509.Certificate, error) { return pemkeys.ParseCertificate(b) } + +// NewSelfSignedSigner generates an ephemeral P-256 key and a self-signed +// certificate with the ANS name as the sole URI SAN, returning a Signer that +// signs with that key. The certificate is valid for 24 hours. This is intended +// for dev-mode or demo scenarios where an identity does not have a real +// certificate from the ANS transparency log. +func NewSelfSignedSigner(ansName string) (Signer, *ecdsa.PrivateKey, *x509.Certificate, error) { + // Validate ansName + if _, err := verify.ParseAnsName(ansName); err != nil { + return nil, nil, nil, fmt.Errorf("invalid ANS name: %w", err) + } + + // Parse ansName as a URL for the cert URI SAN + parsedURL, err := url.Parse(ansName) + if err != nil { + return nil, nil, nil, fmt.Errorf("parse ANS name as URL: %w", err) + } + + // Generate ephemeral P-256 key + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, nil, fmt.Errorf("generate key: %w", err) + } + + // Generate random serial number (128 bits) + const serialBits = 128 + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), serialBits) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, nil, nil, fmt.Errorf("generate serial number: %w", err) + } + + // Build certificate template with 24h validity + const validityHours = 24 + now := time.Now() + template := &x509.Certificate{ + SerialNumber: serialNumber, + NotBefore: now.Add(-1 * time.Minute), // 1 minute skew tolerance + NotAfter: now.Add(validityHours * time.Hour), + URIs: []*url.URL{parsedURL}, + BasicConstraintsValid: true, + } + + // Create self-signed certificate + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return nil, nil, nil, fmt.Errorf("create certificate: %w", err) + } + + // Parse the certificate + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, nil, nil, fmt.Errorf("parse certificate: %w", err) + } + + // Create the signer + popSgn, err := pop.NewSigner(key, certDER) + if err != nil { + return nil, nil, nil, fmt.Errorf("create signer: %w", err) + } + + return popSigner{signer: popSgn}, key, cert, nil +} diff --git a/internal/identity/signer_test.go b/internal/identity/signer_test.go index c70c264..12ebdef 100644 --- a/internal/identity/signer_test.go +++ b/internal/identity/signer_test.go @@ -130,3 +130,119 @@ func TestSignerBindsMethodURLAndContent(t *testing.T) { t.Fatal("Sign accepted a relative URL") } } + +func TestNewSelfSignedSigner(t *testing.T) { + t.Parallel() + const ansName = "ans://v9.9.9.rogue.example" + + signer, key, cert, err := NewSelfSignedSigner(ansName) + if err != nil { + t.Fatalf("NewSelfSignedSigner: %v", err) + } + if signer == nil { + t.Fatal("signer is nil") + } + if key == nil { + t.Fatal("key is nil") + } + if cert == nil { + t.Fatal("cert is nil") + } + + // Check cert has exactly one URI SAN matching ansName + if len(cert.URIs) != 1 { + t.Fatalf("cert.URIs has %d entries, want 1", len(cert.URIs)) + } + if cert.URIs[0].String() != ansName { + t.Fatalf("cert.URIs[0] = %q, want %q", cert.URIs[0].String(), ansName) + } + + // Check cert public key is ECDSA P-256 + pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey) + if !ok { + t.Fatalf("cert public key type = %T, want *ecdsa.PublicKey", cert.PublicKey) + } + if pubKey.Curve != elliptic.P256() { + t.Fatalf("cert public key curve = %v, want P-256", pubKey.Curve) + } + + // Check cert validity window + now := time.Now() + if cert.NotBefore.After(now) { + t.Fatalf("cert.NotBefore = %v is after now", cert.NotBefore) + } + if cert.NotAfter.Before(now) { + t.Fatalf("cert.NotAfter = %v is before now", cert.NotAfter) + } + + // Check that the returned key is the key that signed the cert + if !key.PublicKey.Equal(pubKey) { + t.Fatal("returned key does not match cert's public key") + } + + // Sign twice and verify both proofs embed the same cert fingerprint + const method, target = http.MethodGet, "https://example.com/test" + contentDigest := sha256.Sum256(nil) + + proof1, err := signer.Sign(t.Context(), method, target, contentDigest) + if err != nil { + t.Fatalf("Sign #1: %v", err) + } + proof2, err := signer.Sign(t.Context(), method, target, contentDigest) + if err != nil { + t.Fatalf("Sign #2: %v", err) + } + + // Parse and verify both proofs + replay := pop.NewMemoryReplayCache(t.Context(), 0) + defer replay.Close() + + res1, err := pop.VerifyProof(t.Context(), proof1, method, target, time.Now(), 0, replay) + if err != nil { + t.Fatalf("VerifyProof #1: %v", err) + } + res2, err := pop.VerifyProof(t.Context(), proof2, method, target, time.Now(), 0, replay) + if err != nil { + t.Fatalf("VerifyProof #2: %v", err) + } + + if res1.Fingerprint != res2.Fingerprint { + t.Fatalf("fingerprints differ: %x vs %x", res1.Fingerprint, res2.Fingerprint) + } + + // Verify the fingerprint matches the cert + expectedFingerprint := sha256.Sum256(cert.Raw) + if res1.Fingerprint != expectedFingerprint { + t.Fatalf("fingerprint = %x, want %x", res1.Fingerprint, expectedFingerprint) + } +} + +func TestNewSelfSignedSigner_BadName(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ansName string + }{ + {name: "not an ans name", ansName: "not-an-ans-name"}, + {name: "empty", ansName: ""}, + {name: "http scheme", ansName: "http://v1.0.0.example.com"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + signer, key, cert, err := NewSelfSignedSigner(tt.ansName) + if err == nil { + t.Fatalf("NewSelfSignedSigner(%q) succeeded, want error", tt.ansName) + } + if signer != nil { + t.Fatal("signer is not nil") + } + if key != nil { + t.Fatal("key is not nil") + } + if cert != nil { + t.Fatal("cert is not nil") + } + }) + } +} diff --git a/internal/middleware/e2e_test.go b/internal/middleware/e2e_test.go index 56c6b9a..d5d7b40 100644 --- a/internal/middleware/e2e_test.go +++ b/internal/middleware/e2e_test.go @@ -291,3 +291,103 @@ func TestEndToEndExpiredTokenDenies(t *testing.T) { t.Fatalf("evaluate = %v, %v, want DENY %s", res, err, ReasonStatusTokenUnavailable) } } + +// TestEvaluate_DevModeIdentity_StampsAllThreeHeaders verifies that a dev-mode +// (self-signed) rogue identity is stamped at egress with DPoP, receipt, and +// status token headers. The test proves the rogue is NOT denied at the egress +// middleware — the honest 401 rejection happens downstream at ingress when the +// self-signed artifacts fail verification against the live transparency log. +func TestEvaluate_DevModeIdentity_StampsAllThreeHeaders(t *testing.T) { + t.Parallel() + + // Build a dev-mode identity directly with self-signed components + const rogueAnsName = "ans://v9.9.9.rogue.example" + const rogueAgentID = "rogue-0001" + const rogueIdentityName = "rogue" + + signer, _, cert, err := identity.NewSelfSignedSigner(rogueAnsName) + if err != nil { + t.Fatalf("NewSelfSignedSigner: %v", err) + } + + src, err := identity.NewSelfSignedArtifacts(cert, rogueAnsName, rogueAgentID) + if err != nil { + t.Fatalf("NewSelfSignedArtifacts: %v", err) + } + + id, err := identity.NewIdentity(rogueIdentityName, rogueAnsName, rogueAgentID, signer, src) + if err != nil { + t.Fatalf("NewIdentity: %v", err) + } + + reg, err := identity.NewRegistryFromIdentities(id) + if err != nil { + t.Fatalf("NewRegistryFromIdentities: %v", err) + } + t.Cleanup(reg.Close) + + // Build a service with an authorizer that allows the rogue identity for testSandbox + authorizer := sandboxAuthorizer(testSandbox) + service := NewService(reg, authorizer, nil, Config{}, nil) + + // Build a policy naming the rogue identity in sign mode + policy := map[string]any{"identity": rogueIdentityName} + + // Evaluate a POST request + body := []byte(`{"data":"test"}`) + eval := &middlewarev1.HttpRequestEvaluation{ + Phase: preCredentials, + Context: &middlewarev1.RequestContext{RequestId: "req-rogue", SandboxId: testSandbox}, + Config: structOf(t, policy), + Target: &middlewarev1.HttpRequestTarget{Scheme: "https", Host: "target.example.com", Port: 443, Method: "POST", Path: "/v1/test"}, + Body: body, + } + + res, err := service.EvaluateHttpRequest(t.Context(), eval) + if err != nil { + t.Fatalf("EvaluateHttpRequest: %v", err) + } + + // Assert: Decision == ALLOW (rogue is stamped, not denied at egress) + if res.GetDecision() != allow { + t.Fatalf("Decision = %v, want ALLOW; reason_code = %s", res.GetDecision(), res.GetReasonCode()) + } + + // Collect header mutations + headers := make(map[string]string) + for _, m := range res.GetHeaderMutations() { + if op, ok := m.GetOperation().(*middlewarev1.HeaderMutation_Write); ok { + headers[op.Write.GetName()] = op.Write.GetValue() + } + } + + // Assert: all three ANS headers are present + dpopHeader, hasDPoP := headers[pop.DPoPHeader] + receiptHeader, hasReceipt := headers[scitt.HeaderReceipt] + statusTokenHeader, hasStatusToken := headers[scitt.HeaderStatusToken] + + if !hasDPoP { + t.Errorf("missing DPoP header %q", pop.DPoPHeader) + } + if !hasReceipt { + t.Errorf("missing receipt header %q", scitt.HeaderReceipt) + } + if !hasStatusToken { + t.Errorf("missing status token header %q", scitt.HeaderStatusToken) + } + + // Assert: status token is NON-EMPTY (the key requirement) + if statusTokenHeader == "" { + t.Errorf("status token header is empty") + } + + // Also verify the receipt is non-empty (proving Task 2 minted both artifacts) + if receiptHeader == "" { + t.Errorf("receipt header is empty") + } + + // DPoP should also be non-empty + if dpopHeader == "" { + t.Errorf("DPoP header is empty") + } +} diff --git a/internal/middleware/grpc_test.go b/internal/middleware/grpc_test.go index ff50ee9..ec3b329 100644 --- a/internal/middleware/grpc_test.go +++ b/internal/middleware/grpc_test.go @@ -45,7 +45,7 @@ func TestGRPCContract(t *testing.T) { m, err := client.Describe(ctx, &middlewarev1.MiddlewareDescribeRequest{ Gateway: &extensionv1.PeerMetadata{ProtocolVersion: &extensionv1.ProtocolVersion{Major: 1}}}) - if err != nil || m.GetName() != ManifestName || len(m.GetBindings()) != 1 { + if err != nil || m.GetName() != ManifestName || len(m.GetBindings()) != 2 { t.Fatalf("Describe() = %v, %v", m, err) } _, err = client.Describe(ctx, &middlewarev1.MiddlewareDescribeRequest{ diff --git a/internal/middleware/prereturn.go b/internal/middleware/prereturn.go new file mode 100644 index 0000000..bd25da5 --- /dev/null +++ b/internal/middleware/prereturn.go @@ -0,0 +1,280 @@ +package middleware + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "slices" + + "github.com/agentnameservice/ans-openshell/gen/openshell/middlewarev1" +) + +// PreReturnService implements HttpResponsePreReturn: detects the seller's +// structured capability error (error.data.required_capabilities) and injects +// a top-level middleware_hint for the caller. Uses Inspect+Transform when +// WHOLE_BODY_BYTES is permitted, else Skip (pass-through). +type PreReturnService struct { + middlewarev1.UnimplementedHttpResponsePreReturnServer + log *slog.Logger +} + +// NewPreReturnService constructs a PreReturnService. +func NewPreReturnService(log *slog.Logger) *PreReturnService { + if log == nil { + log = slog.New(slog.DiscardHandler) + } + return &PreReturnService{log: log} +} + +// Evaluate processes the bidirectional event stream: preflight→Inspect/Skip, +// body→Transform/PassThrough, trailers→empty result, session_end→no reply. +// Per-stream state (inspecting flag, maxPayloadBytes) is local to this call. +func (s *PreReturnService) Evaluate(stream middlewarev1.HttpResponsePreReturn_EvaluateServer) error { + ctx := stream.Context() + // Per-stream-local state: are we inspecting this stream? what's the max payload? + var inspecting bool + var maxPayloadBytes uint64 + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + ev, err := stream.Recv() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + + result := s.onEvent(ev, &inspecting, &maxPayloadBytes) + // session_end: no reply + if result == nil { + continue + } + + if err := stream.Send(result); err != nil { + return err + } + } +} + +// onEvent decides the result for one event: preflight→Inspect/Skip, +// body→Transform/PassThrough, trailers→empty result, session_end→nil. +// inspecting and maxPayloadBytes are pointers to per-stream-local state. +func (s *PreReturnService) onEvent( + ev *middlewarev1.HttpResponseEvent, + inspecting *bool, + maxPayloadBytes *uint64, +) *middlewarev1.HttpResponseEventResult { + if preflight := ev.GetPreflight(); preflight != nil { + return s.onPreflightEvent(preflight, inspecting, maxPayloadBytes) + } + + if body := ev.GetBody(); body != nil { + return s.onBodyEvent(body, *inspecting, *maxPayloadBytes) + } + + if ev.GetTrailers() != nil { + return &middlewarev1.HttpResponseEventResult{ + Result: &middlewarev1.HttpResponseEventResult_TrailersResult{ + TrailersResult: &middlewarev1.HttpResponseTrailersResult{}, + }, + } + } + + // session_end or unknown event type: no reply (defensive). + return nil +} + +// onPreflightEvent handles preflight events: Inspect if WHOLE_BODY_BYTES is permitted, else Skip. +func (s *PreReturnService) onPreflightEvent( + preflight *middlewarev1.HttpResponsePreflight, + inspecting *bool, + maxPayloadBytes *uint64, +) *middlewarev1.HttpResponseEventResult { + // Check if WHOLE_BODY_BYTES is permitted + canInspect := slices.Contains( + preflight.GetPermittedBodyModes(), + middlewarev1.HttpResponseBodyMode_HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES, + ) + + if canInspect { + // Inspect with WHOLE_BODY_BYTES mode + *inspecting = true + *maxPayloadBytes = preflight.GetMaxPayloadBytes() + return &middlewarev1.HttpResponseEventResult{ + Result: &middlewarev1.HttpResponseEventResult_PreflightResult{ + PreflightResult: &middlewarev1.HttpResponsePreflightResult{ + Action: &middlewarev1.HttpResponsePreflightResult_Inspect{ + Inspect: &middlewarev1.HttpResponsePreflightInspect{ + BodyMode: middlewarev1.HttpResponseBodyMode_HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES, + }, + }, + }, + }, + } + } + + // Fall back to Skip + *inspecting = false + return &middlewarev1.HttpResponseEventResult{ + Result: &middlewarev1.HttpResponseEventResult_PreflightResult{ + PreflightResult: &middlewarev1.HttpResponsePreflightResult{ + Action: &middlewarev1.HttpResponsePreflightResult_Skip{ + Skip: &middlewarev1.HttpResponsePreflightSkip{}, + }, + }, + }, + } +} + +// onBodyEvent handles body events: Transform if capability error detected, else PassThrough. +func (s *PreReturnService) onBodyEvent( + body *middlewarev1.HttpResponseBodyUnit, + inspecting bool, + maxPayloadBytes uint64, +) *middlewarev1.HttpResponseEventResult { + // Guard: pass through if not inspecting or no data + if !inspecting || body.GetData() == nil { + return &middlewarev1.HttpResponseEventResult{ + Result: &middlewarev1.HttpResponseEventResult_BodyResult{ + BodyResult: &middlewarev1.HttpResponseBodyResult{ + Sequence: body.GetSequence(), + Action: &middlewarev1.HttpResponseBodyResult_PassThrough{ + PassThrough: &middlewarev1.HttpResponseBodyPassThrough{}, + }, + }, + }, + } + } + + // Inspect the body for capability error + bodyData := body.GetData() + newBody, changed := surfaceCapabilityError(bodyData) + + // Guard: pass through if unchanged + if !changed { + return &middlewarev1.HttpResponseEventResult{ + Result: &middlewarev1.HttpResponseEventResult_BodyResult{ + BodyResult: &middlewarev1.HttpResponseBodyResult{ + Sequence: body.GetSequence(), + Action: &middlewarev1.HttpResponseBodyResult_PassThrough{ + PassThrough: &middlewarev1.HttpResponseBodyPassThrough{}, + }, + }, + }, + } + } + + // Guard: pass through if transformed exceeds MaxPayloadBytes + if maxPayloadBytes > 0 && uint64(len(newBody)) > maxPayloadBytes { + s.log.Warn("transformed body exceeds MaxPayloadBytes, passing through", + "original_size", len(bodyData), + "transformed_size", len(newBody), + "max_payload_bytes", maxPayloadBytes) + return &middlewarev1.HttpResponseEventResult{ + Result: &middlewarev1.HttpResponseEventResult_BodyResult{ + BodyResult: &middlewarev1.HttpResponseBodyResult{ + Sequence: body.GetSequence(), + Action: &middlewarev1.HttpResponseBodyResult_PassThrough{ + PassThrough: &middlewarev1.HttpResponseBodyPassThrough{}, + }, + }, + }, + } + } + + // Transform: inject the modified body + return &middlewarev1.HttpResponseEventResult{ + Result: &middlewarev1.HttpResponseEventResult_BodyResult{ + BodyResult: &middlewarev1.HttpResponseBodyResult{ + Sequence: body.GetSequence(), + Action: &middlewarev1.HttpResponseBodyResult_Transform{ + Transform: &middlewarev1.HttpResponseBodyTransform{ + Replacement: &middlewarev1.HttpResponseBodyTransform_Data{ + Data: newBody, + }, + }, + }, + }, + }, + } +} + +// surfaceCapabilityError detects a seller's structured capability error +// (error.data.required_capabilities non-empty array) and injects a top-level +// middleware_hint, preserving the original error object verbatim. +// Returns (newBody, true) if a hint was added, (respBody, false) otherwise. +// Never panics; malformed/non-JSON bodies return (respBody, false). +func surfaceCapabilityError(respBody []byte) ([]byte, bool) { + // Parse as JSON into a map + var m map[string]json.RawMessage + if err := json.Unmarshal(respBody, &m); err != nil { + // Malformed JSON → unchanged + return respBody, false + } + + // Check for error.data.required_capabilities + errorRaw, ok := m["error"] + if !ok { + return respBody, false + } + + var errorObj map[string]json.RawMessage + if err := json.Unmarshal(errorRaw, &errorObj); err != nil { + return respBody, false + } + + dataRaw, ok := errorObj["data"] + if !ok { + return respBody, false + } + + var dataObj map[string]json.RawMessage + if err := json.Unmarshal(dataRaw, &dataObj); err != nil { + return respBody, false + } + + requiredCapsRaw, ok := dataObj["required_capabilities"] + if !ok { + return respBody, false + } + + // Parse required_capabilities as an array + var requiredCaps []string + if err := json.Unmarshal(requiredCapsRaw, &requiredCaps); err != nil { + return respBody, false + } + + // Must be non-empty + if len(requiredCaps) == 0 { + return respBody, false + } + + // Inject middleware_hint at the top level + hint := map[string]any{ + "missing_capabilities": requiredCaps, + "source": "ans-egress", + } + hintBytes, err := json.Marshal(hint) + if err != nil { + // Should never happen with our simple struct + return respBody, false + } + + // Add middleware_hint to the top-level map + m["middleware_hint"] = hintBytes + + // Re-marshal the entire response + newBodyBytes, err := json.Marshal(m) + if err != nil { + return respBody, false + } + + return newBodyBytes, true +} diff --git a/internal/middleware/prereturn_test.go b/internal/middleware/prereturn_test.go new file mode 100644 index 0000000..ba510be --- /dev/null +++ b/internal/middleware/prereturn_test.go @@ -0,0 +1,745 @@ +package middleware + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + "github.com/agentnameservice/ans-openshell/gen/openshell/middlewarev1" +) + +func TestPreReturnService_onEvent(t *testing.T) { + t.Parallel() + s := NewPreReturnService(slog.New(slog.DiscardHandler)) + + t.Run("preflight returns Skip when WHOLE_BODY_BYTES not permitted", func(t *testing.T) { + t.Parallel() + ev := &middlewarev1.HttpResponseEvent{ + Event: &middlewarev1.HttpResponseEvent_Preflight{ + Preflight: &middlewarev1.HttpResponsePreflight{ + PermittedBodyModes: []middlewarev1.HttpResponseBodyMode{ + middlewarev1.HttpResponseBodyMode_HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY, + }, + }, + }, + } + var inspecting bool + var maxPayloadBytes uint64 + result := s.onEvent(ev, &inspecting, &maxPayloadBytes) + if result == nil { + t.Fatal("onEvent(preflight) result = nil, want Skip") + } + if result.GetPreflightResult().GetSkip() == nil { + t.Fatalf("onEvent(preflight) result = %v, want Skip", result) + } + if inspecting { + t.Error("inspecting = true after Skip, want false") + } + }) + + t.Run("preflight returns Inspect when WHOLE_BODY_BYTES permitted", func(t *testing.T) { + t.Parallel() + ev := &middlewarev1.HttpResponseEvent{ + Event: &middlewarev1.HttpResponseEvent_Preflight{ + Preflight: &middlewarev1.HttpResponsePreflight{ + PermittedBodyModes: []middlewarev1.HttpResponseBodyMode{ + middlewarev1.HttpResponseBodyMode_HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES, + }, + MaxPayloadBytes: 1024, + }, + }, + } + var inspecting bool + var maxPayloadBytes uint64 + result := s.onEvent(ev, &inspecting, &maxPayloadBytes) + if result == nil { + t.Fatal("onEvent(preflight) result = nil, want Inspect") + } + inspect := result.GetPreflightResult().GetInspect() + if inspect == nil { + t.Fatalf("onEvent(preflight) result = %v, want Inspect", result) + } + if inspect.GetBodyMode() != middlewarev1.HttpResponseBodyMode_HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES { + t.Errorf("Inspect.BodyMode = %v, want WHOLE_BODY_BYTES", inspect.GetBodyMode()) + } + if !inspecting { + t.Error("inspecting = false after Inspect, want true") + } + if maxPayloadBytes != 1024 { + t.Errorf("maxPayloadBytes = %d, want 1024", maxPayloadBytes) + } + }) + + t.Run("body returns PassThrough with matching Sequence when not inspecting", func(t *testing.T) { + t.Parallel() + ev := &middlewarev1.HttpResponseEvent{ + Event: &middlewarev1.HttpResponseEvent_Body{ + Body: &middlewarev1.HttpResponseBodyUnit{ + Sequence: 42, + Payload: &middlewarev1.HttpResponseBodyUnit_Data{ + Data: []byte("test"), + }, + }, + }, + } + var inspecting bool // false + var maxPayloadBytes uint64 + result := s.onEvent(ev, &inspecting, &maxPayloadBytes) + if result == nil { + t.Fatal("onEvent(body) result = nil, want PassThrough") + } + bodyResult := result.GetBodyResult() + if bodyResult == nil { + t.Fatalf("onEvent(body) result = %v, want BodyResult", result) + } + if bodyResult.GetSequence() != 42 { + t.Errorf("onEvent(body) Sequence = %d, want 42", bodyResult.GetSequence()) + } + if bodyResult.GetPassThrough() == nil { + t.Fatalf("onEvent(body) action = %v, want PassThrough", bodyResult) + } + }) + + t.Run("trailers returns empty TrailersResult", func(t *testing.T) { + t.Parallel() + ev := &middlewarev1.HttpResponseEvent{ + Event: &middlewarev1.HttpResponseEvent_Trailers{ + Trailers: &middlewarev1.HttpResponseTrailers{}, + }, + } + var inspecting bool + var maxPayloadBytes uint64 + result := s.onEvent(ev, &inspecting, &maxPayloadBytes) + if result == nil { + t.Fatal("onEvent(trailers) result = nil, want TrailersResult") + } + if result.GetTrailersResult() == nil { + t.Fatalf("onEvent(trailers) result = %v, want TrailersResult", result) + } + }) + + t.Run("session_end returns nil", func(t *testing.T) { + t.Parallel() + ev := &middlewarev1.HttpResponseEvent{ + Event: &middlewarev1.HttpResponseEvent_SessionEnd{ + SessionEnd: &middlewarev1.MiddlewareSessionEnd{}, + }, + } + var inspecting bool + var maxPayloadBytes uint64 + result := s.onEvent(ev, &inspecting, &maxPayloadBytes) + if result != nil { + t.Errorf("onEvent(session_end) result = %v, want nil (no reply)", result) + } + }) +} + +func TestNewPreReturnService(t *testing.T) { + t.Parallel() + + t.Run("with logger", func(t *testing.T) { + t.Parallel() + log := slog.New(slog.DiscardHandler) + svc := NewPreReturnService(log) + if svc == nil { + t.Fatal("NewPreReturnService(log) = nil, want non-nil") + } + if svc.log != log { + t.Error("NewPreReturnService(log) did not set log field") + } + }) + + t.Run("with nil logger", func(t *testing.T) { + t.Parallel() + svc := NewPreReturnService(nil) + if svc == nil { + t.Fatal("NewPreReturnService(nil) = nil, want non-nil") + } + if svc.log == nil { + t.Error("NewPreReturnService(nil) log field is nil, want default") + } + }) +} + +// assertCapabilityErrorTransformed checks that a capability error was transformed correctly. +func assertCapabilityErrorTransformed(t *testing.T, newBody []byte) { + t.Helper() + // Parse the result to check structure + var result map[string]any + if err := json.Unmarshal(newBody, &result); err != nil { + t.Fatalf("json.Unmarshal(newBody) error = %v", err) + } + + // Check middleware_hint exists + hint, ok := result["middleware_hint"].(map[string]any) + if !ok { + t.Fatalf("middleware_hint not found or not an object in result: %v", result) + } + + // Check middleware_hint.missing_capabilities + missingCaps, ok := hint["missing_capabilities"].([]any) + if !ok { + t.Fatalf("middleware_hint.missing_capabilities not found or not an array: %v", hint) + } + if len(missingCaps) != 2 { + t.Errorf("len(missing_capabilities) = %d, want 2", len(missingCaps)) + } + if missingCaps[0] != "mandate" || missingCaps[1] != "dpop_proof" { + t.Errorf("missing_capabilities = %v, want [mandate, dpop_proof]", missingCaps) + } + + // Check middleware_hint.source + if hint["source"] != "ans-egress" { + t.Errorf("middleware_hint.source = %v, want ans-egress", hint["source"]) + } + + // Check original error object is preserved + errorObj, ok := result["error"].(map[string]any) + if !ok { + t.Fatalf("error object not found or not an object in result: %v", result) + } + if errorObj["code"].(float64) != -32000 { + t.Errorf("error.code = %v, want -32000", errorObj["code"]) + } + if errorObj["message"] != "MANDATE_REJECTED: missing capabilities" { + t.Errorf("error.message = %v, want original", errorObj["message"]) + } + + // Check error.data is preserved + errorData, ok := errorObj["data"].(map[string]any) + if !ok { + t.Fatalf("error.data not found or not an object: %v", errorObj) + } + requiredCaps, ok := errorData["required_capabilities"].([]any) + if !ok || len(requiredCaps) != 2 { + t.Errorf("error.data.required_capabilities = %v, want preserved array", errorData["required_capabilities"]) + } + if errorData["authority_ans"] != "seller.ans" { + t.Errorf("error.data.authority_ans = %v, want seller.ans", errorData["authority_ans"]) + } +} + +func TestSurfaceCapabilityError(t *testing.T) { + t.Parallel() + + t.Run("capability error with required_capabilities array", func(t *testing.T) { + t.Parallel() + input := []byte(`{ + "jsonrpc": "2.0", + "id": "req-1", + "error": { + "code": -32000, + "message": "MANDATE_REJECTED: missing capabilities", + "data": { + "required_capabilities": ["mandate", "dpop_proof"], + "dpop": {"method": "POST", "uri": "https://example.com"}, + "authority_ans": "seller.ans", + "docs": "https://docs.example.com" + } + } + }`) + + newBody, changed := surfaceCapabilityError(input) + if !changed { + t.Fatal("surfaceCapabilityError(capability error) changed = false, want true") + } + + assertCapabilityErrorTransformed(t, newBody) + }) + + t.Run("success response unchanged", func(t *testing.T) { + t.Parallel() + input := []byte(`{ + "jsonrpc": "2.0", + "id": "req-1", + "result": { + "ticket": "ticket-123", + "expires_at": "2026-10-22T00:00:00Z" + } + }`) + + newBody, changed := surfaceCapabilityError(input) + if changed { + t.Errorf("surfaceCapabilityError(success) changed = true, want false") + } + if string(newBody) != string(input) { + t.Errorf("surfaceCapabilityError(success) body changed unexpectedly") + } + }) + + t.Run("error without data unchanged", func(t *testing.T) { + t.Parallel() + input := []byte(`{ + "jsonrpc": "2.0", + "id": "req-1", + "error": { + "code": -32600, + "message": "Invalid Request" + } + }`) + + newBody, changed := surfaceCapabilityError(input) + if changed { + t.Errorf("surfaceCapabilityError(error without data) changed = true, want false") + } + if string(newBody) != string(input) { + t.Errorf("surfaceCapabilityError(error without data) body changed unexpectedly") + } + }) + + t.Run("error with empty required_capabilities unchanged", func(t *testing.T) { + t.Parallel() + input := []byte(`{ + "jsonrpc": "2.0", + "id": "req-1", + "error": { + "code": -32000, + "message": "Some error", + "data": { + "required_capabilities": [] + } + } + }`) + + newBody, changed := surfaceCapabilityError(input) + if changed { + t.Errorf("surfaceCapabilityError(empty required_capabilities) changed = true, want false") + } + if string(newBody) != string(input) { + t.Errorf("surfaceCapabilityError(empty required_capabilities) body changed unexpectedly") + } + }) + + t.Run("malformed JSON unchanged and no panic", func(t *testing.T) { + t.Parallel() + input := []byte(`not valid json`) + + newBody, changed := surfaceCapabilityError(input) + if changed { + t.Errorf("surfaceCapabilityError(malformed) changed = true, want false") + } + if string(newBody) != string(input) { + t.Errorf("surfaceCapabilityError(malformed) body changed unexpectedly") + } + }) + + t.Run("error field not an object", func(t *testing.T) { + t.Parallel() + input := []byte(`{"jsonrpc":"2.0","id":"1","error":"string-not-object"}`) + + newBody, changed := surfaceCapabilityError(input) + if changed { + t.Errorf("surfaceCapabilityError(error not object) changed = true, want false") + } + if string(newBody) != string(input) { + t.Errorf("surfaceCapabilityError(error not object) body changed unexpectedly") + } + }) + + t.Run("error.data not an object", func(t *testing.T) { + t.Parallel() + input := []byte(`{"jsonrpc":"2.0","id":"1","error":{"code":-32000,"message":"err","data":"string-not-object"}}`) + + newBody, changed := surfaceCapabilityError(input) + if changed { + t.Errorf("surfaceCapabilityError(data not object) changed = true, want false") + } + if string(newBody) != string(input) { + t.Errorf("surfaceCapabilityError(data not object) body changed unexpectedly") + } + }) + + t.Run("required_capabilities not an array", func(t *testing.T) { + t.Parallel() + input := []byte(`{"jsonrpc":"2.0","id":"1","error":{"code":-32000,"message":"err","data":{"required_capabilities":"not-array"}}}`) + + newBody, changed := surfaceCapabilityError(input) + if changed { + t.Errorf("surfaceCapabilityError(required_capabilities not array) changed = true, want false") + } + if string(newBody) != string(input) { + t.Errorf("surfaceCapabilityError(required_capabilities not array) body changed unexpectedly") + } + }) + + t.Run("error.data without required_capabilities key", func(t *testing.T) { + t.Parallel() + input := []byte(`{"jsonrpc":"2.0","id":"1","error":{"code":-32000,"message":"err","data":{"other_field":"value"}}}`) + + newBody, changed := surfaceCapabilityError(input) + if changed { + t.Errorf("surfaceCapabilityError(missing required_capabilities key) changed = true, want false") + } + if string(newBody) != string(input) { + t.Errorf("surfaceCapabilityError(missing required_capabilities key) body changed unexpectedly") + } + }) +} + +// preReturnTestClient sets up a bufconn-based PreReturnService for testing. +type preReturnTestClient struct { + client middlewarev1.HttpResponsePreReturnClient + ctx context.Context + srv *grpc.Server +} + +func newPreReturnTestClient(t *testing.T) *preReturnTestClient { + t.Helper() + const bufSize = 1024 * 1024 + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + middlewarev1.RegisterHttpResponsePreReturnServer(srv, NewPreReturnService(slog.New(slog.DiscardHandler))) + go func() { + if err := srv.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) { + panic(err) + } + }() + t.Cleanup(srv.Stop) + + ctx := context.Background() + dialer := func(context.Context, string) (net.Conn, error) { + return lis.Dial() + } + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(dialer), + grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("grpc.NewClient() error = %v", err) + } + t.Cleanup(func() { conn.Close() }) + + return &preReturnTestClient{ + client: middlewarev1.NewHttpResponsePreReturnClient(conn), + ctx: ctx, + srv: srv, + } +} + +// runPreReturnStream sends events and collects results until EOF. +func (c *preReturnTestClient) runPreReturnStream(t *testing.T, events []*middlewarev1.HttpResponseEvent) []*middlewarev1.HttpResponseEventResult { + t.Helper() + stream, err := c.client.Evaluate(c.ctx) + if err != nil { + t.Fatalf("Evaluate() error = %v", err) + } + + var results []*middlewarev1.HttpResponseEventResult + for _, ev := range events { + if err := stream.Send(ev); err != nil { + t.Fatalf("Send(%T) error = %v", ev.GetEvent(), err) + } + // session_end doesn't get a reply + if ev.GetSessionEnd() != nil { + break + } + result, recvErr := stream.Recv() + if recvErr != nil { + t.Fatalf("Recv() error = %v", recvErr) + } + results = append(results, result) + } + + if err := stream.CloseSend(); err != nil { + t.Fatalf("CloseSend() error = %v", err) + } + if _, recvErr := stream.Recv(); !errors.Is(recvErr, io.EOF) { + t.Errorf("Recv() after close = %v, want io.EOF", recvErr) + } + return results +} + +func TestPreReturnService_Evaluate_errors(t *testing.T) { + t.Parallel() + + t.Run("context cancellation", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + srv := NewPreReturnService(slog.New(slog.DiscardHandler)) + stream := &mockEvaluateStream{ctx: ctx} + err := srv.Evaluate(stream) + if !errors.Is(err, context.Canceled) { + t.Errorf("Evaluate(canceled ctx) error = %v, want context.Canceled", err) + } + }) + + t.Run("recv non-EOF error", func(t *testing.T) { + t.Parallel() + srv := NewPreReturnService(slog.New(slog.DiscardHandler)) + recvErr := errors.New("recv error") + stream := &mockEvaluateStream{ + ctx: context.Background(), + recvErr: recvErr, + } + err := srv.Evaluate(stream) + if !errors.Is(err, recvErr) { + t.Errorf("Evaluate(recv error) error = %v, want %v", err, recvErr) + } + }) + + t.Run("send error", func(t *testing.T) { + t.Parallel() + srv := NewPreReturnService(slog.New(slog.DiscardHandler)) + sendErr := errors.New("send error") + stream := &mockEvaluateStream{ + ctx: context.Background(), + recvOnce: true, + sendErr: sendErr, + } + err := srv.Evaluate(stream) + if !errors.Is(err, sendErr) { + t.Errorf("Evaluate(send error) error = %v, want %v", err, sendErr) + } + }) +} + +// mockEvaluateStream implements HttpResponsePreReturn_EvaluateServer for error testing. +type mockEvaluateStream struct { + middlewarev1.HttpResponsePreReturn_EvaluateServer + ctx context.Context + recvOnce bool + recvErr error + sendErr error +} + +func (m *mockEvaluateStream) Context() context.Context { + return m.ctx +} + +func (m *mockEvaluateStream) Recv() (*middlewarev1.HttpResponseEvent, error) { + if m.recvErr != nil { + return nil, m.recvErr + } + if m.recvOnce { + m.recvErr = io.EOF + // Return a preflight event to trigger Send + return &middlewarev1.HttpResponseEvent{ + Event: &middlewarev1.HttpResponseEvent_Preflight{ + Preflight: &middlewarev1.HttpResponsePreflight{}, + }, + }, nil + } + return nil, io.EOF +} + +func (m *mockEvaluateStream) Send(*middlewarev1.HttpResponseEventResult) error { + return m.sendErr +} + +func TestPreReturnService_Evaluate_bufconn(t *testing.T) { + t.Parallel() + tc := newPreReturnTestClient(t) + + t.Run("skip when WHOLE_BODY_BYTES not permitted", func(t *testing.T) { + t.Parallel() + events := []*middlewarev1.HttpResponseEvent{ + {Event: &middlewarev1.HttpResponseEvent_Preflight{ + Preflight: &middlewarev1.HttpResponsePreflight{ + PermittedBodyModes: []middlewarev1.HttpResponseBodyMode{ + middlewarev1.HttpResponseBodyMode_HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY, + }, + }, + }}, + {Event: &middlewarev1.HttpResponseEvent_SessionEnd{ + SessionEnd: &middlewarev1.MiddlewareSessionEnd{}, + }}, + } + results := tc.runPreReturnStream(t, events) + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if results[0].GetPreflightResult().GetSkip() == nil { + t.Errorf("preflight result = %v, want Skip", results[0]) + } + }) + + t.Run("inspect and transform capability error", func(t *testing.T) { + t.Parallel() + capErrorBody := []byte(`{ + "jsonrpc": "2.0", + "id": "req-1", + "error": { + "code": -32000, + "message": "MANDATE_REJECTED: missing capabilities", + "data": { + "required_capabilities": ["mandate", "dpop_proof"] + } + } + }`) + events := []*middlewarev1.HttpResponseEvent{ + {Event: &middlewarev1.HttpResponseEvent_Preflight{ + Preflight: &middlewarev1.HttpResponsePreflight{ + PermittedBodyModes: []middlewarev1.HttpResponseBodyMode{ + middlewarev1.HttpResponseBodyMode_HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES, + }, + MaxPayloadBytes: 1024 * 1024, + }, + }}, + {Event: &middlewarev1.HttpResponseEvent_Body{ + Body: &middlewarev1.HttpResponseBodyUnit{ + Sequence: 1, + Payload: &middlewarev1.HttpResponseBodyUnit_Data{ + Data: capErrorBody, + }, + EndOfStream: true, + }, + }}, + {Event: &middlewarev1.HttpResponseEvent_Trailers{ + Trailers: &middlewarev1.HttpResponseTrailers{}, + }}, + {Event: &middlewarev1.HttpResponseEvent_SessionEnd{ + SessionEnd: &middlewarev1.MiddlewareSessionEnd{}, + }}, + } + results := tc.runPreReturnStream(t, events) + if len(results) != 3 { + t.Fatalf("got %d results, want 3", len(results)) + } + + // Check Inspect result + inspect := results[0].GetPreflightResult().GetInspect() + if inspect == nil { + t.Fatalf("preflight result = %v, want Inspect", results[0]) + } + if inspect.GetBodyMode() != middlewarev1.HttpResponseBodyMode_HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES { + t.Errorf("Inspect.BodyMode = %v, want WHOLE_BODY_BYTES", inspect.GetBodyMode()) + } + + // Check Transform result + br := results[1].GetBodyResult() + if br == nil { + t.Fatalf("body result = %v, want BodyResult", results[1]) + } + transform := br.GetTransform() + if transform == nil { + t.Fatalf("BodyResult action = %v, want Transform", br) + } + replacement, ok := transform.GetReplacement().(*middlewarev1.HttpResponseBodyTransform_Data) + if !ok { + t.Fatalf("Transform.Replacement = %T, want *HttpResponseBodyTransform_Data", transform.GetReplacement()) + } + + // Verify middleware_hint + var transformed map[string]any + if err := json.Unmarshal(replacement.Data, &transformed); err != nil { + t.Fatalf("json.Unmarshal(transformed) error = %v", err) + } + hint, ok := transformed["middleware_hint"].(map[string]any) + if !ok { + t.Fatalf("middleware_hint not found in transformed body: %v", transformed) + } + if hint["source"] != "ans-egress" { + t.Errorf("middleware_hint.source = %v, want ans-egress", hint["source"]) + } + + // Check TrailersResult + if results[2].GetTrailersResult() == nil { + t.Errorf("trailers result = %v, want TrailersResult", results[2]) + } + }) + + t.Run("inspect and passthrough when transformed exceeds MaxPayloadBytes", func(t *testing.T) { + t.Parallel() + capErrorBody := []byte(`{"jsonrpc":"2.0","id":"1","error":{"code":-32000,"message":"ERR","data":{"required_capabilities":["mandate"]}}}`) + events := []*middlewarev1.HttpResponseEvent{ + {Event: &middlewarev1.HttpResponseEvent_Preflight{ + Preflight: &middlewarev1.HttpResponsePreflight{ + PermittedBodyModes: []middlewarev1.HttpResponseBodyMode{ + middlewarev1.HttpResponseBodyMode_HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES, + }, + MaxPayloadBytes: 50, // Very small limit + }, + }}, + {Event: &middlewarev1.HttpResponseEvent_Body{ + Body: &middlewarev1.HttpResponseBodyUnit{ + Sequence: 1, + Payload: &middlewarev1.HttpResponseBodyUnit_Data{ + Data: capErrorBody, + }, + EndOfStream: true, + }, + }}, + {Event: &middlewarev1.HttpResponseEvent_SessionEnd{ + SessionEnd: &middlewarev1.MiddlewareSessionEnd{}, + }}, + } + results := tc.runPreReturnStream(t, events) + if len(results) != 2 { + t.Fatalf("got %d results, want 2", len(results)) + } + + // Check Inspect result + if results[0].GetPreflightResult().GetInspect() == nil { + t.Fatalf("preflight result = %v, want Inspect", results[0]) + } + + // Check PassThrough (transformed exceeds MaxPayloadBytes) + br := results[1].GetBodyResult() + if br == nil { + t.Fatalf("body result = %v, want BodyResult", results[1]) + } + if br.GetPassThrough() == nil { + t.Fatalf("BodyResult action = %v, want PassThrough (size limit)", br) + } + }) + + t.Run("inspect and passthrough success response", func(t *testing.T) { + t.Parallel() + successBody := []byte(`{ + "jsonrpc": "2.0", + "id": "req-1", + "result": { + "ticket": "ticket-123" + } + }`) + events := []*middlewarev1.HttpResponseEvent{ + {Event: &middlewarev1.HttpResponseEvent_Preflight{ + Preflight: &middlewarev1.HttpResponsePreflight{ + PermittedBodyModes: []middlewarev1.HttpResponseBodyMode{ + middlewarev1.HttpResponseBodyMode_HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES, + }, + MaxPayloadBytes: 1024 * 1024, + }, + }}, + {Event: &middlewarev1.HttpResponseEvent_Body{ + Body: &middlewarev1.HttpResponseBodyUnit{ + Sequence: 1, + Payload: &middlewarev1.HttpResponseBodyUnit_Data{ + Data: successBody, + }, + EndOfStream: true, + }, + }}, + {Event: &middlewarev1.HttpResponseEvent_SessionEnd{ + SessionEnd: &middlewarev1.MiddlewareSessionEnd{}, + }}, + } + results := tc.runPreReturnStream(t, events) + if len(results) != 2 { + t.Fatalf("got %d results, want 2", len(results)) + } + + // Check Inspect result + if results[0].GetPreflightResult().GetInspect() == nil { + t.Fatalf("preflight result = %v, want Inspect", results[0]) + } + + // Check PassThrough + br := results[1].GetBodyResult() + if br == nil { + t.Fatalf("body result = %v, want BodyResult", results[1]) + } + if br.GetPassThrough() == nil { + t.Fatalf("BodyResult action = %v, want PassThrough", br) + } + }) +} diff --git a/internal/middleware/reasons.go b/internal/middleware/reasons.go index 6abc6d4..38c7cc7 100644 --- a/internal/middleware/reasons.go +++ b/internal/middleware/reasons.go @@ -36,6 +36,10 @@ const ( ReasonIdentityUnauthorized = "ans_identity_unauthorized" // ReasonRequestRewriteUnsupported denies content OpenShell could rewrite after signing. ReasonRequestRewriteUnsupported = "ans_credential_rewrite_unsupported" + // ReasonRewriteDenied is returned when a transform denies a request. + ReasonRewriteDenied = "ans_rewrite_denied" + // ReasonCommerceDenied is deprecated; use ReasonRewriteDenied. + ReasonCommerceDenied = ReasonRewriteDenied // ReasonStatusTokenUnavailable is returned when the identity has no // unexpired status token (ANS-6 section 9.4, fail closed). ReasonStatusTokenUnavailable = "ans_status_token_unavailable" diff --git a/internal/middleware/service.go b/internal/middleware/service.go index 53c0a6e..43bbea8 100644 --- a/internal/middleware/service.go +++ b/internal/middleware/service.go @@ -3,6 +3,7 @@ package middleware import ( "context" "crypto/sha256" + "encoding/json" "errors" "fmt" "log/slog" @@ -17,6 +18,7 @@ import ( "github.com/agentnameservice/ans-openshell/gen/openshell/extensionv1" "github.com/agentnameservice/ans-openshell/gen/openshell/middlewarev1" + "github.com/agentnameservice/ans-openshell/internal/capability" "github.com/agentnameservice/ans-openshell/internal/extauth" "github.com/agentnameservice/ans-openshell/internal/identity" "github.com/agentnameservice/ans-openshell/internal/vetting" @@ -99,11 +101,13 @@ type IdentityLookup interface { // it. type Service struct { middlewarev1.UnimplementedSupervisorMiddlewareServer - ids IdentityLookup - authz IdentityAuthorizer - vet vetting.Verifier - cfg Config - log *slog.Logger + ids IdentityLookup + authz IdentityAuthorizer + vet vetting.Verifier + cfg Config + log *slog.Logger + rewriters []capability.Transform + discoverer capability.Discoverer } // NewService builds the service. vet may be nil when destination vetting is not @@ -122,6 +126,18 @@ func NewService(ids IdentityLookup, authz IdentityAuthorizer, vet vetting.Verifi return &Service{ids: ids, authz: authz, vet: vet, cfg: cfg.withDefaults(), log: logger} } +// AddTransform appends a Transform to the service's rewriter chain. +func (s *Service) AddTransform(t capability.Transform) *Service { + s.rewriters = append(s.rewriters, t) + return s +} + +// WithDiscoverer sets the capability discoverer for the service. +func (s *Service) WithDiscoverer(d capability.Discoverer) *Service { + s.discoverer = d + return s +} + // Describe negotiates the protocol with the gateway and returns the manifest: // one HTTP_REQUEST binding at PRE_CREDENTIALS. A gateway on another major // protocol version or requiring a capability this service lacks gets @@ -144,10 +160,18 @@ func (s *Service) Describe(ctx context.Context, if s.cfg.RequestTimeout > 0 { binding.RequestTimeout = durationpb.New(s.cfg.RequestTimeout) } + responseBinding := &middlewarev1.MiddlewareBinding{ + Operation: middlewarev1.SupervisorMiddlewareOperation_SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE, + Phase: middlewarev1.SupervisorMiddlewarePhase_SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN, + MaxPayloadBytes: s.cfg.MaxPayloadBytes, + } + if s.cfg.RequestTimeout > 0 { + responseBinding.RequestTimeout = durationpb.New(s.cfg.RequestTimeout) + } return &middlewarev1.MiddlewareManifest{ Name: ManifestName, ServiceVersion: s.cfg.ImplementationVersion, - Bindings: []*middlewarev1.MiddlewareBinding{binding}, + Bindings: []*middlewarev1.MiddlewareBinding{binding, responseBinding}, ExpectedAudience: s.cfg.Audience, Extension: &extensionv1.PeerMetadata{ ProtocolVersion: &extensionv1.ProtocolVersion{Major: ProtocolMajor, Minor: ProtocolMinor}, @@ -273,10 +297,54 @@ func (s *Service) evaluate(ctx context.Context, req *middlewarev1.HttpRequestEva if !s.authz.Allows(req.GetContext().GetSandboxId(), policy.Identity) { return out.deny(&denial{ReasonIdentityUnauthorized, "sandbox is not authorized to use the selected identity"}) } - if containsCredentialMarker(req.GetBody()) || containsCredentialMarker([]byte(req.GetTarget().GetPath())) { + + // R1 pipeline: compute signBody, run transform chain, guard on rewritten bytes, then sign. + signBody := req.GetBody() + + // Build target for transforms + tool := extractToolName(signBody) + capTarget := capability.Target{ + Host: req.GetTarget().GetHost(), + Method: method, + Path: req.GetTarget().GetPath(), + Tool: tool, + } + + // Discover requirements (best-effort) + var requirement capability.Requirement + if s.discoverer != nil { + requirement = s.discoverer.Discover(ctx, capTarget) + } + + // Run transform chain + var signURL string + for _, t := range s.rewriters { + tr := t.Apply(ctx, capTarget, requirement, signBody) + switch tr.Kind { + case capability.Deny: + return out.deny(&denial{ReasonRewriteDenied, "rewrite denied: " + tr.Reason}) + case capability.Allow: + signBody = tr.Body + out.setBody(tr.Body) + // G1: capture SignURL from transforms (last writer wins). + signURL = tr.SignURL + s.narrate(ctx, t, tr) // observability only; no effect on the request + case capability.Passthrough: + // Continue to next transform + } + } + + // Credential marker guard runs on signBody (post-chain bytes) + if containsCredentialMarker(signBody) || containsCredentialMarker([]byte(req.GetTarget().GetPath())) { return out.deny(&denial{ReasonRequestRewriteUnsupported, "credential placeholders in a signed body or path require signing after credential rewriting"}) } - if d := s.attach(ctx, id, method, target, req.GetBody(), out); d != nil { + + // Sign the (potentially rewritten) body with signURL (if set) or target (G1: external URL binding). + htu := target + if signURL != "" { + htu = signURL + } + if d := s.attach(ctx, id, method, htu, signBody, out); d != nil { return out.deny(d) } return out.allow() @@ -368,6 +436,15 @@ func (s *Service) attach(ctx context.Context, id *identity.Identity, method, tar return nil } +// narrate lets a transform render the artifacts it attached to an Allow'd +// request, if it implements capability.Narratable. Observability only; the core +// never interprets the artifacts and narration is debug-gated in the transform. +func (s *Service) narrate(ctx context.Context, t capability.Transform, tr capability.Outcome) { + if n, ok := t.(capability.Narratable); ok { + n.Narrate(ctx, tr, s.log) + } +} + func destinationSeverity(st vetting.Status) string { switch st { case vetting.StatusRegistered, vetting.StatusExempt: @@ -400,6 +477,8 @@ type outcome struct { findings []*middlewarev1.Finding metadata map[string]string mutations []*middlewarev1.HeaderMutation + body []byte + hasBody bool } func (o *outcome) finding(typ, label, confidence, severity string) { @@ -425,6 +504,11 @@ func (o *outcome) write(name, value string) { }) } +func (o *outcome) setBody(b []byte) { + o.body = b + o.hasBody = true +} + // strip removes the identity headers and records the finding. func (o *outcome) strip() { for _, name := range []string{pop.DPoPHeader, scitt.HeaderReceipt, scitt.HeaderStatusToken} { @@ -436,12 +520,17 @@ func (o *outcome) strip() { } func (o *outcome) allow() *middlewarev1.HttpRequestResult { - return &middlewarev1.HttpRequestResult{ + res := &middlewarev1.HttpRequestResult{ Decision: middlewarev1.Decision_DECISION_ALLOW, HeaderMutations: o.mutations, Findings: o.findings, Metadata: o.metadata, } + if o.hasBody { + res.Body = o.body + res.HasBody = true + } + return res } func (o *outcome) deny(d *denial) *middlewarev1.HttpRequestResult { @@ -453,3 +542,21 @@ func (o *outcome) deny(d *denial) *middlewarev1.HttpRequestResult { Metadata: o.metadata, } } + +// extractToolName attempts to extract the MCP tool name from a JSON-RPC request body. +// Returns empty string on any parse error or if method != "tools/call". +func extractToolName(body []byte) string { + var req struct { + Method string `json:"method"` + Params struct { + Name string `json:"name"` + } `json:"params"` + } + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + if req.Method != "tools/call" { + return "" + } + return req.Params.Name +} diff --git a/internal/middleware/service_test.go b/internal/middleware/service_test.go index 3382f4b..d2c86f9 100644 --- a/internal/middleware/service_test.go +++ b/internal/middleware/service_test.go @@ -20,6 +20,7 @@ import ( "github.com/agentnameservice/ans-openshell/gen/openshell/extensionv1" "github.com/agentnameservice/ans-openshell/gen/openshell/middlewarev1" + "github.com/agentnameservice/ans-openshell/internal/capability" "github.com/agentnameservice/ans-openshell/internal/extauth" "github.com/agentnameservice/ans-openshell/internal/identity" "github.com/agentnameservice/ans-openshell/internal/vetting" @@ -188,14 +189,23 @@ func TestDescribe(t *testing.T) { len(ext.GetSupportedCapabilities()) != 1 || ext.GetSupportedCapabilities()[0] != CapabilityContract { t.Fatalf("extension metadata = %v", ext) } - if len(m.GetBindings()) != 1 { - t.Fatalf("bindings = %v", m.GetBindings()) + if len(m.GetBindings()) != 2 { + t.Fatalf("bindings = %v, want 2", m.GetBindings()) } + // First binding: HTTP_REQUEST / PRE_CREDENTIALS b := m.GetBindings()[0] if b.GetOperation() != middlewarev1.SupervisorMiddlewareOperation_SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST || b.GetPhase() != preCredentials || b.GetMaxPayloadBytes() != DefaultMaxPayloadBytes || b.GetRequestTimeout().AsDuration() != cfg.RequestTimeout { - t.Fatalf("binding = %v", b) + t.Fatalf("request binding = %v", b) + } + // Second binding: HTTP_RESPONSE / PRE_RETURN + rb := m.GetBindings()[1] + if rb.GetOperation() != middlewarev1.SupervisorMiddlewareOperation_SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE || + rb.GetPhase() != middlewarev1.SupervisorMiddlewarePhase_SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN || + rb.GetMaxPayloadBytes() != DefaultMaxPayloadBytes || + rb.GetRequestTimeout().AsDuration() != cfg.RequestTimeout { + t.Fatalf("response binding = %v", rb) } }) } @@ -207,7 +217,10 @@ func TestDescribe(t *testing.T) { t.Fatal(err) } if m.GetBindings()[0].GetRequestTimeout() != nil { - t.Fatalf("request_timeout = %v, want unset", m.GetBindings()[0].GetRequestTimeout()) + t.Fatalf("request binding request_timeout = %v, want unset", m.GetBindings()[0].GetRequestTimeout()) + } + if m.GetBindings()[1].GetRequestTimeout() != nil { + t.Fatalf("response binding request_timeout = %v, want unset", m.GetBindings()[1].GetRequestTimeout()) } }) } @@ -548,7 +561,7 @@ func TestReasonCodeGrammar(t *testing.T) { for _, code := range []string{ ReasonUnsupportedPhase, ReasonCallerUnauthenticated, ReasonCallerNotSupervisor, ReasonCallerSandboxMismatch, ReasonPolicyInvalid, ReasonTargetInvalid, ReasonIdentityUnknown, - ReasonStatusTokenUnavailable, ReasonReceiptUnavailable, ReasonSignFailed, + ReasonStatusTokenUnavailable, ReasonReceiptUnavailable, ReasonSignFailed, ReasonRewriteDenied, vetting.ReasonUnregistered, vetting.ReasonRevoked, vetting.ReasonUntrustedTL, vetting.ReasonBelowThreshold, vetting.ReasonUnverified, FindingIdentityAttached, FindingIdentityStripped, } { @@ -559,3 +572,538 @@ func TestReasonCodeGrammar(t *testing.T) { check(FindingDestinationPrefix + st.String()) } } + +// TestOutcome_AllowCarriesReplacementBody verifies that outcome.setBody causes +// allow() to set HasBody=true and Body=, and that bare allow() has HasBody=false. +func TestOutcome_AllowCarriesReplacementBody(t *testing.T) { + t.Parallel() + + // Test 1: setBody then allow should carry the body + o := &outcome{} + body := []byte("rewritten") + o.setBody(body) + res := o.allow() + + if !res.GetHasBody() { + t.Errorf("allow() after setBody: HasBody = false, want true") + } + if !bytes.Equal(res.GetBody(), body) { + t.Errorf("allow() after setBody: Body = %q, want %q", res.GetBody(), body) + } + + // Test 2: bare allow without setBody should have HasBody=false and nil Body + o2 := &outcome{} + res2 := o2.allow() + + if res2.GetHasBody() { + t.Errorf("allow() without setBody: HasBody = true, want false") + } + if res2.GetBody() != nil { + t.Errorf("allow() without setBody: Body = %v, want nil", res2.GetBody()) + } +} + +// TestEvaluate_CommerceRewriteThenSignRewrittenBody verifies that commerce rewrite +// happens BEFORE signing, so the signed digest is sha256(rewritten), not sha256(original). +// This test uses the new transform chain (commerce as a Transform). +func TestEvaluate_CommerceRewriteThenSignRewrittenBody(t *testing.T) { + t.Parallel() + + original := []byte(`{"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"Q1"}}}`) + rewritten := []byte(`{"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"Q1","mandate":"M1","dpop_proof":"P1"}}}`) + + signer := &fakeSigner{} + id := newIdentity(t, testIdentity, signer, fullArtifacts()) + ids := lookup{testIdentity: id} + + transform := &stubTransform{kind: capability.Allow, body: rewritten} + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.AddTransform(transform) + + res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != allow { + t.Fatalf("decision = %v, want ALLOW", res.GetDecision()) + } + + // The signed digest must be sha256(rewritten), not sha256(original) + if len(signer.calls) != 1 { + t.Fatalf("Sign calls = %d, want 1", len(signer.calls)) + } + // R1 proof: explicitly assert the digest is NOT sha256(original) + originalDigest := sha256.Sum256(original) + if signer.calls[0].digest == originalDigest { + t.Fatalf("signed digest equals sha256(original) — rewrite was not applied before signing") + } + // Now assert it IS sha256(rewritten) + wantDigest := sha256.Sum256(rewritten) + if signer.calls[0].digest != wantDigest { + t.Errorf("signed digest = %x, want %x (sha256 of rewritten body)", signer.calls[0].digest, wantDigest) + } + + // Result should carry the rewritten body + if !res.GetHasBody() { + t.Errorf("HasBody = false, want true") + } + if !bytes.Equal(res.GetBody(), rewritten) { + t.Errorf("Body = %q, want %q", res.GetBody(), rewritten) + } + + // Should have the three identity headers + if len(res.GetHeaderMutations()) != 3 { + t.Errorf("header mutations = %d, want 3", len(res.GetHeaderMutations())) + } +} + +// TestEvaluate_CommerceDenyShortCircuits verifies that commerce Deny returns +// DECISION_DENY with reason_code ans_rewrite_denied (was ans_commerce_denied), with no Sign call and no body mutation. +// This test uses the new transform chain. +func TestEvaluate_CommerceDenyShortCircuits(t *testing.T) { + t.Parallel() + + body := []byte(`{"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"Q1"}}}`) + + signer := &fakeSigner{} + id := newIdentity(t, testIdentity, signer, fullArtifacts()) + ids := lookup{testIdentity: id} + + transform := &stubTransform{kind: capability.Deny, reason: "missing mandate"} + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.AddTransform(transform) + + res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), body)) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != deny { + t.Fatalf("decision = %v, want DENY", res.GetDecision()) + } + // ReasonCommerceDenied is now an alias for ReasonRewriteDenied + if res.GetReasonCode() != ReasonRewriteDenied { + t.Errorf("reason_code = %q, want %q", res.GetReasonCode(), ReasonRewriteDenied) + } + + // No Sign call should have happened + if len(signer.calls) != 0 { + t.Errorf("Sign calls = %d, want 0", len(signer.calls)) + } + + // No body mutation + if res.GetHasBody() { + t.Errorf("HasBody = true, want false") + } +} + +// TestEvaluate_CommercePassthroughSignsOriginal verifies that commerce Passthrough +// leaves the body unchanged, signs the original, and has HasBody=false. +// This test uses the new transform chain. +func TestEvaluate_CommercePassthroughSignsOriginal(t *testing.T) { + t.Parallel() + + original := []byte(`{"method":"tools/other","params":{}}`) + + signer := &fakeSigner{} + id := newIdentity(t, testIdentity, signer, fullArtifacts()) + ids := lookup{testIdentity: id} + + transform := &stubTransform{kind: capability.Passthrough} + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.AddTransform(transform) + + res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != allow { + t.Fatalf("decision = %v, want ALLOW", res.GetDecision()) + } + + // The signed digest must be sha256(original) + if len(signer.calls) != 1 { + t.Fatalf("Sign calls = %d, want 1", len(signer.calls)) + } + wantDigest := sha256.Sum256(original) + if signer.calls[0].digest != wantDigest { + t.Errorf("signed digest = %x, want %x (sha256 of original body)", signer.calls[0].digest, wantDigest) + } + + // HasBody should be false (no body replacement) + if res.GetHasBody() { + t.Errorf("HasBody = true, want false") + } +} + +// TestEvaluate_CredentialMarkerInRewrittenBodyDenied verifies that the credential +// marker guard runs on the POST-chain body, so a transform cannot smuggle a marker past it. +// This test uses the new transform chain. +func TestEvaluate_CredentialMarkerInRewrittenBodyDenied(t *testing.T) { + t.Parallel() + + original := []byte(`{"method":"tools/call","params":{"name":"book_flight"}}`) + // Rewritten body contains the credential marker + rewritten := []byte(`{"method":"tools/call","params":{"name":"book_flight","secret":"openshell:resolve:env:API_KEY"}}`) + + signer := &fakeSigner{} + id := newIdentity(t, testIdentity, signer, fullArtifacts()) + ids := lookup{testIdentity: id} + + transform := &stubTransform{kind: capability.Allow, body: rewritten} + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.AddTransform(transform) + + res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != deny { + t.Fatalf("decision = %v, want DENY", res.GetDecision()) + } + if res.GetReasonCode() != ReasonRequestRewriteUnsupported { + t.Errorf("reason_code = %q, want %q", res.GetReasonCode(), ReasonRequestRewriteUnsupported) + } + + // No Sign call should have happened (guard runs before attach) + if len(signer.calls) != 0 { + t.Errorf("Sign calls = %d, want 0", len(signer.calls)) + } +} + +// stubTransform is a test stub implementing capability.Transform. +type stubTransform struct { + kind capability.OutcomeKind + body []byte + reason string + signURL string + called *bool +} + +func (s *stubTransform) Provides() []capability.Capability { + return nil +} + +func (s *stubTransform) Apply(_ context.Context, _ capability.Target, _ capability.Requirement, _ []byte) capability.Outcome { + if s.called != nil { + *s.called = true + } + return capability.Outcome{ + Kind: s.kind, + Body: s.body, + Reason: s.reason, + SignURL: s.signURL, + } +} + +// TestEvaluate_ChainComposesThenSigns verifies that multiple Allow transforms +// compose (each sees the previous rewritten body) and the final composed body is signed. +func TestEvaluate_ChainComposesThenSigns(t *testing.T) { + t.Parallel() + + original := []byte(`{"method":"tools/call","params":{"name":"test"}}`) + + signer := &fakeSigner{} + id := newIdentity(t, testIdentity, signer, fullArtifacts()) + ids := lookup{testIdentity: id} + + // Two transforms: A appends "A", B appends "B" + transformA := &stubTransform{kind: capability.Allow, body: []byte("A")} + transformB := &stubTransform{kind: capability.Allow, body: []byte("AB")} + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.AddTransform(transformA).AddTransform(transformB) + + res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != allow { + t.Fatalf("decision = %v, want ALLOW", res.GetDecision()) + } + + // The signed digest must be sha256("AB"), not sha256(original) + if len(signer.calls) != 1 { + t.Fatalf("Sign calls = %d, want 1", len(signer.calls)) + } + originalDigest := sha256.Sum256(original) + if signer.calls[0].digest == originalDigest { + t.Fatalf("signed digest equals sha256(original) — chain was not applied") + } + wantDigest := sha256.Sum256([]byte("AB")) + if signer.calls[0].digest != wantDigest { + t.Errorf("signed digest = %x, want %x (sha256 of final composed body)", signer.calls[0].digest, wantDigest) + } + + // Result should carry the final body + if !res.GetHasBody() { + t.Errorf("HasBody = false, want true") + } + if !bytes.Equal(res.GetBody(), []byte("AB")) { + t.Errorf("Body = %q, want %q", res.GetBody(), "AB") + } +} + +// TestEvaluate_TransformDenyShortCircuits verifies that a Deny transform +// returns DECISION_DENY with reason_code ans_rewrite_denied, with no Sign call, +// and the second transform is NOT invoked. +func TestEvaluate_TransformDenyShortCircuits(t *testing.T) { + t.Parallel() + + body := []byte(`{"method":"tools/call","params":{"name":"test"}}`) + + signer := &fakeSigner{} + id := newIdentity(t, testIdentity, signer, fullArtifacts()) + ids := lookup{testIdentity: id} + + // First transform denies, second should not be called + var secondCalled bool + transformDeny := &stubTransform{kind: capability.Deny, reason: "test denial"} + transformSecond := &stubTransform{kind: capability.Allow, body: []byte("should-not-run"), called: &secondCalled} + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.AddTransform(transformDeny).AddTransform(transformSecond) + + res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), body)) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != deny { + t.Fatalf("decision = %v, want DENY", res.GetDecision()) + } + if res.GetReasonCode() != ReasonRewriteDenied { + t.Errorf("reason_code = %q, want %q", res.GetReasonCode(), ReasonRewriteDenied) + } + + // No Sign call should have happened + if len(signer.calls) != 0 { + t.Errorf("Sign calls = %d, want 0", len(signer.calls)) + } + + // Second transform should NOT have been called + if secondCalled { + t.Errorf("second transform was called, want it to be skipped after Deny") + } +} + +// TestEvaluate_NoTransformsSignsOriginal verifies that when no transforms are registered, +// the original body is signed, HasBody=false, and the result is byte-identical to pre-change behavior. +func TestEvaluate_NoTransformsSignsOriginal(t *testing.T) { + t.Parallel() + + original := []byte(`{"method":"tools/call","params":{"name":"test"}}`) + + signer := &fakeSigner{} + id := newIdentity(t, testIdentity, signer, fullArtifacts()) + ids := lookup{testIdentity: id} + + // No transforms registered (empty rewriters, nil discoverer) + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + + res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != allow { + t.Fatalf("decision = %v, want ALLOW", res.GetDecision()) + } + + // The signed digest must be sha256(original) + if len(signer.calls) != 1 { + t.Fatalf("Sign calls = %d, want 1", len(signer.calls)) + } + wantDigest := sha256.Sum256(original) + if signer.calls[0].digest != wantDigest { + t.Errorf("signed digest = %x, want %x (sha256 of original body)", signer.calls[0].digest, wantDigest) + } + + // HasBody should be false (no body replacement) + if res.GetHasBody() { + t.Errorf("HasBody = true, want false") + } +} + +// TestEvaluate_PassthroughContinues verifies that a Passthrough transform +// allows the next transform to run and modify the body. +func TestEvaluate_PassthroughContinues(t *testing.T) { + t.Parallel() + + original := []byte(`{"method":"tools/call","params":{"name":"test"}}`) + + signer := &fakeSigner{} + id := newIdentity(t, testIdentity, signer, fullArtifacts()) + ids := lookup{testIdentity: id} + + // First transform passes through, second allows with body change + var secondCalled bool + transformPassthrough := &stubTransform{kind: capability.Passthrough} + transformAllow := &stubTransform{kind: capability.Allow, body: []byte("modified"), called: &secondCalled} + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.AddTransform(transformPassthrough).AddTransform(transformAllow) + + res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != allow { + t.Fatalf("decision = %v, want ALLOW", res.GetDecision()) + } + + // Second transform should have run + if !secondCalled { + t.Errorf("second transform was not called, want it to run after Passthrough") + } + + // Body should be from the second transform + if !res.GetHasBody() { + t.Errorf("HasBody = false, want true") + } + if !bytes.Equal(res.GetBody(), []byte("modified")) { + t.Errorf("Body = %q, want %q", res.GetBody(), "modified") + } + + // Signed digest should be sha256("modified") + if len(signer.calls) != 1 { + t.Fatalf("Sign calls = %d, want 1", len(signer.calls)) + } + wantDigest := sha256.Sum256([]byte("modified")) + if signer.calls[0].digest != wantDigest { + t.Errorf("signed digest = %x, want %x (sha256 of modified body)", signer.calls[0].digest, wantDigest) + } +} + +// TestEvaluate_CredentialMarkerOnFinalBytes verifies that the credential marker +// guard runs on the POST-chain bytes, so a transform cannot smuggle a marker past it. +func TestEvaluate_CredentialMarkerOnFinalBytes(t *testing.T) { + t.Parallel() + + original := []byte(`{"method":"tools/call","params":{"name":"test"}}`) + // Rewritten body contains the credential marker + rewritten := []byte(`{"method":"tools/call","params":{"name":"test","secret":"openshell:resolve:env:API_KEY"}}`) + + signer := &fakeSigner{} + id := newIdentity(t, testIdentity, signer, fullArtifacts()) + ids := lookup{testIdentity: id} + + transform := &stubTransform{kind: capability.Allow, body: rewritten} + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.AddTransform(transform) + + res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != deny { + t.Fatalf("decision = %v, want DENY", res.GetDecision()) + } + if res.GetReasonCode() != ReasonRequestRewriteUnsupported { + t.Errorf("reason_code = %q, want %q", res.GetReasonCode(), ReasonRequestRewriteUnsupported) + } + + // No Sign call should have happened (guard runs before attach) + if len(signer.calls) != 0 { + t.Errorf("Sign calls = %d, want 0", len(signer.calls)) + } +} + +func TestEvaluate_TransformSignURLBindsIdentityProof(t *testing.T) { //nolint:tparallel // subtests share signer state + t.Parallel() + + original := []byte(`{"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"Q1"}}}`) + rewritten := []byte(`{"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"Q1","mandate":"M1","dpop_proof":"P1"}}}`) + externalURL := "https://seller.skybound-travel.com/mcp/" + + signer := &fakeSigner{} + id := newIdentity(t, testIdentity, signer, fullArtifacts()) + ids := lookup{testIdentity: id} + + t.Run("with SignURL set, identity proof htu binds SignURL", func(t *testing.T) { + signer.calls = nil // reset + transform := &stubTransform{kind: capability.Allow, body: rewritten, signURL: externalURL} + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.AddTransform(transform) + + res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != allow { + t.Fatalf("decision = %v, want ALLOW", res.GetDecision()) + } + + // Assert the identity proof was signed with externalURL, not the request target + if len(signer.calls) != 1 { + t.Fatalf("Sign calls = %d, want 1", len(signer.calls)) + } + if signer.calls[0].url != externalURL { + t.Errorf("signed url = %q, want %q (SignURL)", signer.calls[0].url, externalURL) + } + }) + + t.Run("with empty SignURL, identity proof htu binds request target", func(t *testing.T) { + signer.calls = nil // reset + transform := &stubTransform{kind: capability.Allow, body: rewritten, signURL: ""} + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.AddTransform(transform) + + res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != allow { + t.Fatalf("decision = %v, want ALLOW", res.GetDecision()) + } + + // Assert the identity proof was signed with the request target (unchanged behavior) + if len(signer.calls) != 1 { + t.Fatalf("Sign calls = %d, want 1", len(signer.calls)) + } + expectedTarget := "https://tools.example.com/v1/do" + if signer.calls[0].url != expectedTarget { + t.Errorf("signed url = %q, want %q (request target)", signer.calls[0].url, expectedTarget) + } + }) + + t.Run("no transform, identity proof htu binds request target", func(t *testing.T) { + signer.calls = nil // reset + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + // No transform added + + res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != allow { + t.Fatalf("decision = %v, want ALLOW", res.GetDecision()) + } + + // Assert the identity proof was signed with the request target (unchanged behavior) + if len(signer.calls) != 1 { + t.Fatalf("Sign calls = %d, want 1", len(signer.calls)) + } + expectedTarget := "https://tools.example.com/v1/do" + if signer.calls[0].url != expectedTarget { + t.Errorf("signed url = %q, want %q (request target)", signer.calls[0].url, expectedTarget) + } + }) +} diff --git a/internal/selfsign/cose.go b/internal/selfsign/cose.go new file mode 100644 index 0000000..9061087 --- /dev/null +++ b/internal/selfsign/cose.go @@ -0,0 +1,155 @@ +// Package selfsign provides COSE_Sign1 minting for self-signed artifacts used +// in dev-mode identities. It is NOT a test-only package, so production code can +// use it. +package selfsign + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/json" + + "github.com/agentnameservice/ans-sdk-go/verify/scitt" + "github.com/fxamacker/cbor/v2" +) + +// COSE labels and values of the artifacts a transparency log signs (RFC 9052 +// headers, RFC 9162 verifiable data structure, SCITT receipt proofs). +const ( + coseLabelAlg = 1 + coseLabelKid = 4 + coseLabelVDS = 395 + coseLabelVDP = 396 + coseAlgES256 = -7 + coseSign1Tag = 18 + vdsRFC9162 = 1 + vdpTreeSize = -1 + vdpLeafIndex = -2 + p1363HalfLen = 32 + p1363SigLen = 2 * p1363HalfLen +) + +// Status token payload claims (CBOR integer keys). +const ( + claimAgentID = 1 + claimStatus = 2 + claimIat = 3 + claimExp = 4 + claimAnsName = 5 + claimIdentityCerts = 6 +) + +// KidLen is the length of the key identifier in bytes. +const KidLen = 4 + +// StatusToken mints a COSE_Sign1 status token vouching identityFP as the +// x509-ov-client identity certificate of agentID, registered under ansName, +// with the given status and validity window (Unix seconds). +func StatusToken(key *ecdsa.PrivateKey, agentID, ansName string, status scitt.AgentStatus, iat, exp int64, + identityFP [sha256.Size]byte) ([]byte, error) { + kid := kidFor(key) + protected := mustCBOR(map[int64]any{coseLabelAlg: int64(coseAlgES256), coseLabelKid: kid[:]}) + certs := []any{map[string]any{ + "fingerprint": identityFP[:], + "cert_type": string(scitt.CertTypeX509OVClient), + }} + payload := mustCBOR(map[int64]any{ + claimAgentID: agentID, + claimStatus: string(status), + claimIat: iat, + claimExp: exp, + claimAnsName: ansName, + claimIdentityCerts: certs, + }) + return sign1(key, protected, map[int64]any{}, payload) +} + +// Receipt mints a COSE_Sign1 SCITT receipt (RFC 9162 tree of one leaf) over the +// registration event of agentID under ansName, in the leaf envelope a live log +// signs. +func Receipt(key *ecdsa.PrivateKey, agentID, ansName string) ([]byte, error) { + kid := kidFor(key) + protected := mustCBOR(map[int64]any{ + coseLabelAlg: int64(coseAlgES256), + coseLabelKid: kid[:], + coseLabelVDS: int64(vdsRFC9162), + }) + unprotected := map[int64]any{coseLabelVDP: map[int64]any{vdpTreeSize: uint64(1), vdpLeafIndex: uint64(0)}} + return sign1(key, protected, unprotected, leafJSON(agentID, ansName)) +} + +// sign1 assembles a COSE_Sign1 (CBOR tag 18) over protected and payload with a +// P1363 ECDSA signature. +func sign1(key *ecdsa.PrivateKey, protected []byte, unprotected any, payload []byte) ([]byte, error) { + digest, err := scitt.ComputeSigStructureDigest(protected, payload) + if err != nil { + return nil, err + } + r, s, err := ecdsa.Sign(rand.Reader, key, digest[:]) + if err != nil { + return nil, err + } + sig := make([]byte, p1363SigLen) + r.FillBytes(sig[:p1363HalfLen]) + s.FillBytes(sig[p1363HalfLen:]) + return mustCBOR(cbor.Tag{Number: coseSign1Tag, Content: []any{protected, unprotected, payload, sig}}), nil +} + +func mustCBOR(v any) []byte { + b, err := cbor.Marshal(v) + if err != nil { + panic(err) + } + return b +} + +// KidFromSPKI computes the key identifier from DER-encoded SubjectPublicKeyInfo bytes. +// Returns the first KidLen bytes of SHA-256(spkiDER). +func KidFromSPKI(spkiDER []byte) [KidLen]byte { + h := sha256.Sum256(spkiDER) + var kid [KidLen]byte + copy(kid[:], h[:KidLen]) + return kid +} + +// kidFor computes the 4-byte key ID from an ECDSA public key. +func kidFor(key *ecdsa.PrivateKey) [KidLen]byte { + spki, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + panic(err) + } + return KidFromSPKI(spki) +} + +// leafEnvelope is the transparency-log leaf a receipt signs: the event wrapped +// in payload.producer.event, naming the agent by ansId (schema V1). +type leafEnvelope struct { + Payload leafPayload `json:"payload"` + SchemaVersion string `json:"schemaVersion"` +} + +type leafPayload struct { + Producer leafProducer `json:"producer"` +} + +type leafProducer struct { + Event leafEvent `json:"event"` +} + +type leafEvent struct { + AnsID string `json:"ansId"` + AnsName string `json:"ansName"` +} + +func leafJSON(agentID, ansName string) []byte { + env := leafEnvelope{ + Payload: leafPayload{Producer: leafProducer{Event: leafEvent{AnsID: agentID, AnsName: ansName}}}, + SchemaVersion: "V1", + } + b, err := json.Marshal(env) + if err != nil { + panic(err) + } + return b +} diff --git a/internal/selfsign/cose_test.go b/internal/selfsign/cose_test.go new file mode 100644 index 0000000..d09fe79 --- /dev/null +++ b/internal/selfsign/cose_test.go @@ -0,0 +1,199 @@ +package selfsign_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "math/big" + "net/url" + "testing" + "time" + + "github.com/agentnameservice/ans-sdk-go/verify/scitt" + + "github.com/agentnameservice/ans-openshell/internal/selfsign" +) + +func TestStatusToken(t *testing.T) { + // Generate a test key + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + // Create a test cert + cert := mustCreateSelfSignedCert(t, key) + certFP := sha256.Sum256(cert) + + // Mint a status token + const ( + agentID = "test-agent-001" + ansName = "ans://v1.0.0.test.example.com" + ) + now := time.Now() + iat := now.Add(-time.Minute).Unix() + exp := now.Add(time.Hour).Unix() + + tokenBytes, err := selfsign.StatusToken(key, agentID, ansName, scitt.StatusActive, iat, exp, certFP) + if err != nil { + t.Fatalf("StatusToken: %v", err) + } + + // Parse as COSE_Sign1 + parsed, err := scitt.ParseCoseSign1(tokenBytes) + if err != nil { + t.Fatalf("ParseCoseSign1: %v", err) + } + + // Verify it has the expected structure + if parsed.ProtectedBytes == nil { + t.Error("ProtectedBytes is nil") + } + if parsed.Payload == nil { + t.Error("Payload is nil") + } + if len(parsed.Signature) != 64 { + t.Errorf("Signature length = %d, want 64 (P1363)", len(parsed.Signature)) + } + + // Verify the kid matches + expectedKid := computeKid(t, key) + if parsed.Protected.Kid != expectedKid { + t.Errorf("kid = %x, want %x", parsed.Protected.Kid, expectedKid) + } + + // Verify the algorithm + if parsed.Protected.Alg != -7 { // ES256 + t.Errorf("alg = %d, want -7 (ES256)", parsed.Protected.Alg) + } +} + +func TestReceipt(t *testing.T) { + // Generate a test key + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + // Mint a receipt + const ( + agentID = "test-agent-002" + ansName = "ans://v2.0.0.test.example.com" + ) + + receiptBytes, err := selfsign.Receipt(key, agentID, ansName) + if err != nil { + t.Fatalf("Receipt: %v", err) + } + + // Parse as COSE_Sign1 + parsed, err := scitt.ParseCoseSign1(receiptBytes) + if err != nil { + t.Fatalf("ParseCoseSign1: %v", err) + } + + // Verify it has the expected structure + if parsed.ProtectedBytes == nil { + t.Error("ProtectedBytes is nil") + } + if parsed.Payload == nil { + t.Error("Payload is nil") + } + if len(parsed.Signature) != 64 { + t.Errorf("Signature length = %d, want 64 (P1363)", len(parsed.Signature)) + } + + // Verify the kid matches + expectedKid := computeKid(t, key) + if parsed.Protected.Kid != expectedKid { + t.Errorf("kid = %x, want %x", parsed.Protected.Kid, expectedKid) + } + + // Verify the algorithm + if parsed.Protected.Alg != -7 { // ES256 + t.Errorf("alg = %d, want -7 (ES256)", parsed.Protected.Alg) + } + + // Verify VDS header is present (receipt-specific) + if parsed.Protected.Vds == nil { + t.Error("Vds header is nil, expected RFC9162 (1)") + } else if *parsed.Protected.Vds != 1 { + t.Errorf("Vds = %d, want 1 (RFC9162)", *parsed.Protected.Vds) + } +} + +func TestStatusToken_DifferentKeys(t *testing.T) { + // Ensure different keys produce different kids + key1, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key1: %v", err) + } + key2, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key2: %v", err) + } + + cert := mustCreateSelfSignedCert(t, key1) + certFP := sha256.Sum256(cert) + + now := time.Now() + iat := now.Add(-time.Minute).Unix() + exp := now.Add(time.Hour).Unix() + + token1, err := selfsign.StatusToken(key1, "agent-1", "ans://v1.0.0.a.example.com", scitt.StatusActive, iat, exp, certFP) + if err != nil { + t.Fatalf("StatusToken key1: %v", err) + } + + token2, err := selfsign.StatusToken(key2, "agent-1", "ans://v1.0.0.a.example.com", scitt.StatusActive, iat, exp, certFP) + if err != nil { + t.Fatalf("StatusToken key2: %v", err) + } + + parsed1, err := scitt.ParseCoseSign1(token1) + if err != nil { + t.Fatalf("ParseCoseSign1 token1: %v", err) + } + parsed2, err := scitt.ParseCoseSign1(token2) + if err != nil { + t.Fatalf("ParseCoseSign1 token2: %v", err) + } + + if parsed1.Protected.Kid == parsed2.Protected.Kid { + t.Error("Expected different kids for different keys, got same") + } +} + +// Helper functions + +func mustCreateSelfSignedCert(t *testing.T, key *ecdsa.PrivateKey) []byte { + t.Helper() + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + URIs: []*url.URL{{Scheme: "ans", Host: "v1.0.0.test.example.com"}}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + return certDER +} + +func computeKid(t *testing.T, key *ecdsa.PrivateKey) [4]byte { + t.Helper() + + spki, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatalf("marshal public key: %v", err) + } + h := sha256.Sum256(spki) + var kid [4]byte + copy(kid[:], h[:4]) + return kid +} diff --git a/internal/testfixtures/keys.go b/internal/testfixtures/keys.go index 53370d8..6ebe783 100644 --- a/internal/testfixtures/keys.go +++ b/internal/testfixtures/keys.go @@ -12,17 +12,17 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "crypto/sha256" "crypto/x509" "encoding/pem" "fmt" "io" + + "github.com/agentnameservice/ans-openshell/internal/selfsign" ) const ( pemTypePKCS8 = "PRIVATE KEY" pemTypeCertificate = "CERTIFICATE" - kidLen = 4 ) // NewP256Key generates a fresh ECDSA P-256 private key. @@ -52,13 +52,10 @@ func CertificatePEM(der []byte) []byte { return pem.EncodeToMemory(&pem.Block{Type: pemTypeCertificate, Bytes: der}) } -// kidFor derives the SDK's 4-byte key identifier, SHA-256(SPKI)[:4], from a -// DER-encoded SubjectPublicKeyInfo. -func kidFor(spkiDER []byte) [kidLen]byte { - sum := sha256.Sum256(spkiDER) - var kid [kidLen]byte - copy(kid[:], sum[:kidLen]) - return kid +// kidFor derives the SDK's 4-byte key identifier from DER-encoded SPKI. +// It delegates to selfsign.KidFromSPKI for the shared kid computation logic. +func kidFor(spkiDER []byte) [selfsign.KidLen]byte { + return selfsign.KidFromSPKI(spkiDER) } // must panics on an error from encoding or signing data this package built diff --git a/internal/testfixtures/testfixtures_test.go b/internal/testfixtures/testfixtures_test.go index 26466c0..7aedfff 100644 --- a/internal/testfixtures/testfixtures_test.go +++ b/internal/testfixtures/testfixtures_test.go @@ -155,16 +155,6 @@ func TestMustPanics(t *testing.T) { MustNewIdentity("", testAnsName) } -func TestMustCBORPanics(t *testing.T) { - t.Parallel() - defer func() { - if recover() == nil { - t.Fatal("mustCBOR did not panic on an unencodable value") - } - }() - mustCBOR(make(chan int)) -} - func TestTLRootKeyRoundTrip(t *testing.T) { t.Parallel() tl := MustNewTL() diff --git a/internal/testfixtures/tl.go b/internal/testfixtures/tl.go index 4f8049a..180d3b4 100644 --- a/internal/testfixtures/tl.go +++ b/internal/testfixtures/tl.go @@ -7,12 +7,12 @@ import ( "crypto/x509" "encoding/base64" "encoding/hex" - "encoding/json" "io" "time" "github.com/agentnameservice/ans-sdk-go/verify/scitt" - "github.com/fxamacker/cbor/v2" + + "github.com/agentnameservice/ans-openshell/internal/selfsign" ) // TLName is the signer name the fixture log's C2SP root key carries. @@ -21,39 +21,14 @@ const TLName = "ans-openshell-test-log" // StatusTokenTTL is the lifetime of the status tokens FreshStatusToken mints. const StatusTokenTTL = time.Hour -// COSE labels and values of the artifacts a transparency log signs (RFC 9052 -// headers, RFC 9162 verifiable data structure, SCITT receipt proofs). -const ( - coseLabelAlg = 1 - coseLabelKid = 4 - coseLabelVDS = 395 - coseLabelVDP = 396 - coseAlgES256 = -7 - coseSign1Tag = 18 - vdsRFC9162 = 1 - vdpTreeSize = -1 - vdpLeafIndex = -2 - p1363HalfLen = 32 - p1363SigLen = 2 * p1363HalfLen - statusTokenBackoff = time.Minute -) - -// Status token payload claims (CBOR integer keys). -const ( - claimAgentID = 1 - claimStatus = 2 - claimIat = 3 - claimExp = 4 - claimAnsName = 5 - claimIdentityCerts = 6 -) +const statusTokenBackoff = time.Minute // TL is a fixture transparency log: one ES256 signing key, published as a C2SP // root key a verifier pins, and the minting side of the artifacts the log // issues to its agents. type TL struct { key *ecdsa.PrivateKey - kid [kidLen]byte + kid [selfsign.KidLen]byte rootKey string keyStore *scitt.KeyStore } @@ -100,20 +75,9 @@ func (t *TL) KeyStore() *scitt.KeyStore { return t.keyStore } // with the given status and validity window (Unix seconds). func (t *TL) StatusToken(agentID, ansName string, status scitt.AgentStatus, iat, exp int64, identityFP [sha256.Size]byte) []byte { - protected := mustCBOR(map[int64]any{coseLabelAlg: int64(coseAlgES256), coseLabelKid: t.kid[:]}) - certs := []any{map[string]any{ - "fingerprint": identityFP[:], - "cert_type": string(scitt.CertTypeX509OVClient), - }} - payload := mustCBOR(map[int64]any{ - claimAgentID: agentID, - claimStatus: string(status), - claimIat: iat, - claimExp: exp, - claimAnsName: ansName, - claimIdentityCerts: certs, - }) - return t.sign1(protected, map[int64]any{}, payload) + token, err := selfsign.StatusToken(t.key, agentID, ansName, status, iat, exp, identityFP) + must(err) + return token } // FreshStatusToken mints an ACTIVE status token for id issued one minute ago @@ -128,60 +92,7 @@ func (t *TL) FreshStatusToken(id *Identity) []byte { // registration event of agentID under ansName, in the leaf envelope a live log // signs. func (t *TL) Receipt(agentID, ansName string) []byte { - protected := mustCBOR(map[int64]any{ - coseLabelAlg: int64(coseAlgES256), - coseLabelKid: t.kid[:], - coseLabelVDS: int64(vdsRFC9162), - }) - unprotected := map[int64]any{coseLabelVDP: map[int64]any{vdpTreeSize: uint64(1), vdpLeafIndex: uint64(0)}} - return t.sign1(protected, unprotected, leafJSON(agentID, ansName)) -} - -// sign1 assembles a COSE_Sign1 (CBOR tag 18) over protected and payload with a -// P1363 ECDSA signature. -func (t *TL) sign1(protected []byte, unprotected any, payload []byte) []byte { - digest, err := scitt.ComputeSigStructureDigest(protected, payload) - must(err) - r, s, err := ecdsa.Sign(rand.Reader, t.key, digest[:]) - must(err) - sig := make([]byte, p1363SigLen) - r.FillBytes(sig[:p1363HalfLen]) - s.FillBytes(sig[p1363HalfLen:]) - return mustCBOR(cbor.Tag{Number: coseSign1Tag, Content: []any{protected, unprotected, payload, sig}}) -} - -func mustCBOR(v any) []byte { - b, err := cbor.Marshal(v) - must(err) - return b -} - -// leafEnvelope is the transparency-log leaf a receipt signs: the event wrapped -// in payload.producer.event, naming the agent by ansId (schema V1). -type leafEnvelope struct { - Payload leafPayload `json:"payload"` - SchemaVersion string `json:"schemaVersion"` -} - -type leafPayload struct { - Producer leafProducer `json:"producer"` -} - -type leafProducer struct { - Event leafEvent `json:"event"` -} - -type leafEvent struct { - AnsID string `json:"ansId"` - AnsName string `json:"ansName"` -} - -func leafJSON(agentID, ansName string) []byte { - env := leafEnvelope{ - Payload: leafPayload{Producer: leafProducer{Event: leafEvent{AnsID: agentID, AnsName: ansName}}}, - SchemaVersion: "V1", - } - b, err := json.Marshal(env) + receipt, err := selfsign.Receipt(t.key, agentID, ansName) must(err) - return b + return receipt } diff --git a/tutorial/cmd/ans-demo-middleware/main.go b/tutorial/cmd/ans-demo-middleware/main.go new file mode 100644 index 0000000..afa7e7d --- /dev/null +++ b/tutorial/cmd/ans-demo-middleware/main.go @@ -0,0 +1,208 @@ +// Command ans-demo-middleware is the DEV-ONLY demo binary. It is the production +// middleware (app.Run) plus the commerce capability transform registered as a +// tutorial of how to extend the middleware. It is not shipped; the production +// binary (cmd/ans-openshell-middleware) contains no commerce code. +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/agentnameservice/ans-openshell/app" + "github.com/agentnameservice/ans-openshell/internal/capability" + "github.com/agentnameservice/ans-openshell/internal/config" + "github.com/agentnameservice/ans-openshell/internal/logging" + "github.com/agentnameservice/ans-openshell/internal/middleware" + "github.com/agentnameservice/ans-openshell/tutorial/commerce" + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" + tutorialcfg "github.com/agentnameservice/ans-openshell/tutorial/config" + "github.com/agentnameservice/ans-openshell/tutorial/transform" +) + +// version is set at build time with -ldflags "-X main.version=...". +var version = "dev" + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "ans-demo-middleware:", err) + os.Exit(1) + } +} + +func run() error { + var ( + configPath = flag.String("config", "", "path to the YAML configuration (required)") + listen = flag.String("listen", "", "listen address; overrides the configuration file") + logLevel = flag.String("log-level", "info", "debug, info, warn or error") + logFormat = flag.String("log-format", "json", "json or text") + showVer = flag.Bool("version", false, "print the version and exit") + ) + flag.Parse() + if *showVer { + fmt.Fprintln(os.Stdout, version) + return nil + } + if *configPath == "" { + return errors.New("--config is required") + } + logger, err := logging.New(*logLevel, *logFormat, os.Stderr) + if err != nil { + return err + } + var cfg tutorialcfg.FileConfig + if err := config.Load(*configPath, &cfg); err != nil { + return err + } + if *listen != "" { + cfg.Listen = *listen + } + if cfg.HealthListen == "" { + cfg.HealthListen = app.DefaultHealthListen + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + opts := []app.Option{app.WithVersion(version)} + if cfg.Commerce.Enabled() { + opts = append(opts, app.WithCustomize(commerceCustomizer(cfg.Commerce))) + } + return app.Run(ctx, cfg.Config, logger, opts...) +} + +// commerceCustomizer registers the commerce transform + discoverer and starts +// the optional demo prefetch, via app.WithCustomize. +func commerceCustomizer(cc tutorialcfg.Commerce) func(context.Context, *middleware.Service, *slog.Logger) error { + return func(ctx context.Context, svc *middleware.Service, logger *slog.Logger) error { + rewriter, startPrefetch, err := setupCommerce(cc, logger) + if err != nil { + return err + } + ttl := cc.DiscoveryTTL + if ttl == 0 { + ttl = 10 * time.Minute //nolint:mnd // default TTL for agent-card/tools-list discovery cache + } + client := &http.Client{Timeout: 10 * time.Second} //nolint:mnd // discovery fetch timeout + svc.WithDiscoverer(capability.NewHTTPDiscoverer(client, ttl)) + svc.AddTransform(rewriter) + if startPrefetch != nil { + startPrefetch() + } + logger.InfoContext(ctx, "commerce rewriter enabled") + return nil + } +} + +// setupCommerce builds the commerce transform and an optional demo-prefetch closure. +func setupCommerce(cc tutorialcfg.Commerce, logger *slog.Logger) (capability.Transform, func(), error) { + if err := cc.Validate(); err != nil { + return nil, nil, fmt.Errorf("commerce config: %w", err) + } + keyPath := cc.CommerceDPoPKeyPath + if keyPath == "" { + keyPath = os.Getenv("COMMERCE_DPOP_KEY") + } + key, err := commercekey.Load(keyPath) + if err != nil { + return nil, nil, fmt.Errorf("commerce key: %w", err) + } + cache := commerce.NewMandateCache() + authority := commerce.NewHTTPAuthority(cc.AuthorityMCPURL) + clock := realClock{} + nonce := &uuidNonce{} + deps := commerce.Deps{ + Key: key, + Cache: cache, + Authority: authority, + Clock: clock, + Nonce: nonce, + } + commerceCfg := commerce.Config{ + SellerMCPURL: cc.SellerMCPURL, + SellerANS: cc.SellerANS, + ScopePrefix: cc.ScopeHint, + SubjectANS: cc.SubjectANS, + MaxBodyBytes: cc.MaxBodyBytes, + } + sellerHost, err := transform.ParseSellerHost(cc.SellerMCPURL) + if err != nil { + return nil, nil, fmt.Errorf("commerce seller host: %w", err) + } + rewriter := transform.NewCommerceRewriter(deps, commerceCfg, sellerHost, logger) + + var prefetch func() + if cc.DemoQuoteID != "" && cc.DemoAmount > 0 && cc.DemoCurrency != "" { + prefetch = func() { + go prefetchDemoMandate(cache, key, cc, authority, clock, logger) + } + } + return rewriter, prefetch, nil +} + +// realClock implements commerce.Clock using time.Now(). +type realClock struct{} + +func (realClock) NowUnix() int64 { return time.Now().Unix() } + +// uuidNonce implements commerce.Nonce using crypto/rand. +type uuidNonce struct{} + +func (u *uuidNonce) JTI() string { + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + panic("crypto/rand.Read failed: " + err.Error()) + } + const ( + uuidVersion4 = 0x40 + uuidVariant = 0x80 + uuidVersionMask = 0x0f + uuidVariantMask = 0x3f + ) + buf[6] = (buf[6] & uuidVersionMask) | uuidVersion4 + buf[8] = (buf[8] & uuidVariantMask) | uuidVariant + return hex.EncodeToString(buf[:]) +} + +// prefetchDemoMandate warms the mandate cache with a demo quote in the background. +func prefetchDemoMandate(cache *commerce.MandateCache, key *commercekey.Key, cc tutorialcfg.Commerce, + authority commerce.AuthorityClient, clock commerce.Clock, logger *slog.Logger) { + qk := commerce.QuoteKey{ + QuoteID: cc.DemoQuoteID, + Amount: cc.DemoAmount, + Currency: cc.DemoCurrency, + } + _, _, err := cache.GetOrFetch( + context.Background(), //nolint:contextcheck // Prefetch uses background context after server started + qk, + key.JKT(), + cc.SellerANS, + cc.ScopeHint, + authority, + clock, + func(qk commerce.QuoteKey) commerce.MandateInput { + return commerce.MandateInput{ + QuoteID: qk.QuoteID, + Total: qk.Amount, + Currency: qk.Currency, + MerchantANS: cc.SellerANS, + ScopeHint: cc.ScopeHint, + SubjectANS: cc.SubjectANS, + TravelerDPoPJWK: key.PublicJWK(), + } + }, + ) + if err != nil { + logger.WarnContext(context.Background(), "demo mandate prefetch failed", "error", err) + } else { + logger.InfoContext(context.Background(), "demo mandate prefetched", "quote_id", cc.DemoQuoteID) + } +} diff --git a/tutorial/commerce/authority.go b/tutorial/commerce/authority.go new file mode 100644 index 0000000..404c5dc --- /dev/null +++ b/tutorial/commerce/authority.go @@ -0,0 +1,165 @@ +package commerce + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +const ( + authorityTimeout = 30 * time.Second + maxErrBodyLen = 200 +) + +// HTTPAuthority is an HTTP client for requesting mandates from an authority +// service via JSON-RPC tools/call. +type HTTPAuthority struct { + baseURL string + client *http.Client +} + +// NewHTTPAuthority creates a new HTTP authority client with a sane timeout. +func NewHTTPAuthority(baseURL string) *HTTPAuthority { + return &HTTPAuthority{ + baseURL: baseURL, + client: &http.Client{ + Timeout: authorityTimeout, + }, + } +} + +// jsonRPCRequest is the JSON-RPC request envelope for tools/call. +// Field order matters for byte-exact JSON matching in tests. +type jsonRPCRequest struct { + Jsonrpc string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params toolCallParams `json:"params"` +} + +// toolCallParams contains the params for the tools/call method. +type toolCallParams struct { + Name string `json:"name"` + Arguments MandateInput `json:"arguments"` +} + +// jsonRPCResponse is the JSON-RPC response envelope. +type jsonRPCResponse struct { + Jsonrpc string `json:"jsonrpc"` + ID int `json:"id"` + Result *toolCallResult `json:"result,omitempty"` + Error *jsonRPCError `json:"error,omitempty"` +} + +// toolCallResult contains the content array from a successful response. +type toolCallResult struct { + Content []contentItem `json:"content"` +} + +// contentItem represents a content item with type and text. +type contentItem struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// jsonRPCError represents a JSON-RPC error object. +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// RequestMandate requests a mandate from the authority service via JSON-RPC. +// It implements the AuthorityClient interface. +func (h *HTTPAuthority) RequestMandate(ctx context.Context, in MandateInput) (*Mandate, json.RawMessage, error) { + // Build JSON-RPC request envelope + envelope := jsonRPCRequest{ + Jsonrpc: "2.0", + ID: 1, + Method: "tools/call", + Params: toolCallParams{ + Name: "request_mandate", + Arguments: in, + }, + } + + // Marshal request to JSON + requestBody, err := json.Marshal(envelope) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal request: %w", err) + } + + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.baseURL, bytes.NewReader(requestBody)) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + // Send request + resp, err := h.client.Do(req) //nolint:gosec // G704: authority URL is operator configuration, not request input + if err != nil { + return nil, nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Check for non-2xx status + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Truncate body to 200 characters for error message + truncated := string(body) + if len(truncated) > maxErrBodyLen { + truncated = truncated[:maxErrBodyLen] + } + return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncated) + } + + // Parse mandate from response + return parseMandateResponse(body) +} + +// parseMandateResponse parses a mandate from a JSON-RPC response body. +// Returns the parsed mandate, the raw JSON bytes from the authority, and an error. +// The raw bytes preserve the exact formatting (e.g., "max_amount":1000.0) from the authority. +func parseMandateResponse(body []byte) (*Mandate, json.RawMessage, error) { + var response jsonRPCResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, nil, fmt.Errorf("JSON parse failed: %w", err) + } + + // Check for error object + if response.Error != nil { + return nil, nil, fmt.Errorf("authority error: %s", response.Error.Message) + } + + // Check for result + if response.Result == nil { + return nil, nil, errors.New("no result in response") + } + + // Check for content + if len(response.Result.Content) == 0 { + return nil, nil, errors.New("no content in authority response") + } + + // Get the raw mandate text from first content item + mandateText := response.Result.Content[0].Text + + // Parse mandate from text for validation + var mandate Mandate + if err := json.Unmarshal([]byte(mandateText), &mandate); err != nil { + return nil, nil, fmt.Errorf("failed to parse mandate from content: %w", err) + } + + // Return both the parsed mandate and the raw bytes + return &mandate, json.RawMessage(mandateText), nil +} diff --git a/tutorial/commerce/authority_test.go b/tutorial/commerce/authority_test.go new file mode 100644 index 0000000..d1660a3 --- /dev/null +++ b/tutorial/commerce/authority_test.go @@ -0,0 +1,276 @@ +package commerce + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" +) + +func TestParseMandateResponse_Success(t *testing.T) { + // Valid response with mandate in content[0].text + responseBody := `{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [ + { + "type": "text", + "text": "{\"mandate_id\":\"mandate-123\",\"quote_id\":\"quote-456\",\"subject_ans\":\"ans://traveler.example.com\",\"audience_ans\":\"ans://seller.example.com\",\"scope\":\"commerce:travel:book\",\"max_amount\":500.0,\"currency\":\"USD\",\"not_before\":\"2026-10-20T10:00:00Z\",\"not_after\":\"2026-10-20T11:00:00Z\",\"jkt\":\"test-jkt-123\",\"authority_ans\":\"ans://authority.example.com\",\"signature\":\"sig-abc\"}" + } + ] + } + }` + + mandate, rawBytes, err := parseMandateResponse([]byte(responseBody)) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if mandate.MandateID != "mandate-123" { + t.Errorf("expected mandate_id mandate-123, got %s", mandate.MandateID) + } + if mandate.QuoteID != "quote-456" { + t.Errorf("expected quote_id quote-456, got %s", mandate.QuoteID) + } + if mandate.JKT != "test-jkt-123" { + t.Errorf("expected jkt test-jkt-123, got %s", mandate.JKT) + } + + // Verify raw bytes match the exact content[0].text + expectedRaw := `{"mandate_id":"mandate-123","quote_id":"quote-456","subject_ans":"ans://traveler.example.com","audience_ans":"ans://seller.example.com","scope":"commerce:travel:book","max_amount":500.0,"currency":"USD","not_before":"2026-10-20T10:00:00Z","not_after":"2026-10-20T11:00:00Z","jkt":"test-jkt-123","authority_ans":"ans://authority.example.com","signature":"sig-abc"}` + if string(rawBytes) != expectedRaw { + t.Errorf("raw bytes mismatch:\ngot: %s\nwant: %s", string(rawBytes), expectedRaw) + } +} + +func TestParseMandateResponse_Error(t *testing.T) { + // Response with error object + responseBody := `{ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32001, + "message": "POLICY_VIOLATION: insufficient credit limit" + } + }` + + mandate, _, err := parseMandateResponse([]byte(responseBody)) + if err == nil { + t.Fatalf("expected error, got mandate: %+v", mandate) + } + + if !strings.Contains(err.Error(), "POLICY_VIOLATION") { + t.Errorf("expected error to contain POLICY_VIOLATION, got: %v", err) + } + if !strings.Contains(err.Error(), "insufficient credit limit") { + t.Errorf("expected error to contain full message, got: %v", err) + } +} + +func TestParseMandateResponse_NoContent(t *testing.T) { + // Response with empty content array + responseBody := `{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [] + } + }` + + mandate, _, err := parseMandateResponse([]byte(responseBody)) + if err == nil { + t.Fatalf("expected error for empty content, got mandate: %+v", mandate) + } + + if !strings.Contains(err.Error(), "no content") { + t.Errorf("expected error about no content, got: %v", err) + } +} + +func TestParseMandateResponse_Malformed(t *testing.T) { + // Malformed JSON + responseBody := `{"invalid": "not a valid response"}` + + mandate, _, err := parseMandateResponse([]byte(responseBody)) + if err == nil { + t.Fatalf("expected error for malformed response, got mandate: %+v", mandate) + } +} + +func TestParseMandateResponse_PreservesFloatFormat(t *testing.T) { + // Test that max_amount with .0 is preserved in raw bytes + responseBody := `{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [ + { + "type": "text", + "text": "{\"mandate_id\":\"m1\",\"quote_id\":\"q1\",\"subject_ans\":\"ans://traveler.example.com\",\"audience_ans\":\"ans://seller.example.com\",\"scope\":\"commerce:pay\",\"max_amount\":1000.0,\"currency\":\"USD\",\"not_before\":\"2026-01-01T00:00:00Z\",\"not_after\":\"2026-01-01T01:00:00Z\",\"jkt\":\"jkt1\",\"authority_ans\":\"ans://authority.example.com\",\"signature\":\"sig1\"}" + } + ] + } + }` + + mandate, rawBytes, err := parseMandateResponse([]byte(responseBody)) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Verify the parsed mandate has the expected max_amount value + if mandate.MaxAmount != 1000.0 { + t.Errorf("expected max_amount 1000.0, got %v", mandate.MaxAmount) + } + + // Verify raw bytes contain "max_amount":1000.0 (with .0) + if !strings.Contains(string(rawBytes), `"max_amount":1000.0`) { + t.Errorf("raw bytes should contain max_amount:1000.0 (with .0), got: %s", string(rawBytes)) + } + + // Verify that if we re-marshal the typed mandate, it would LOSE the .0 + remarshaled, err := json.Marshal(mandate) + if err != nil { + t.Fatalf("failed to re-marshal mandate: %v", err) + } + if strings.Contains(string(remarshaled), `"max_amount":1000.0`) { + t.Errorf("re-marshaled mandate should NOT preserve .0 (proof the fix matters), got: %s", string(remarshaled)) + } + if !strings.Contains(string(remarshaled), `"max_amount":1000`) { + t.Errorf("re-marshaled mandate should contain max_amount:1000 (without .0), got: %s", string(remarshaled)) + } +} + +func TestHTTPAuthority_RequestMandate(t *testing.T) { + t.Run("success", func(t *testing.T) { + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Assert method and content-type + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("expected Content-Type: application/json, got %s", ct) + } + + // Read and parse request body + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + + // Unmarshal to verify structure + var envelope struct { + Jsonrpc string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments MandateInput `json:"arguments"` + } `json:"params"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatalf("failed to unmarshal request: %v", err) + } + + // Assert envelope structure + if envelope.Jsonrpc != "2.0" { + t.Errorf("expected jsonrpc 2.0, got %s", envelope.Jsonrpc) + } + if envelope.ID != 1 { + t.Errorf("expected id 1, got %d", envelope.ID) + } + if envelope.Method != "tools/call" { + t.Errorf("expected method tools/call, got %s", envelope.Method) + } + if envelope.Params.Name != "request_mandate" { + t.Errorf("expected params.name request_mandate, got %s", envelope.Params.Name) + } + if envelope.Params.Arguments.QuoteID != "quote-789" { + t.Errorf("expected quote_id quote-789, got %s", envelope.Params.Arguments.QuoteID) + } + + // Return success response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [ + { + "type": "text", + "text": "{\"mandate_id\":\"mandate-999\",\"quote_id\":\"quote-789\",\"subject_ans\":\"ans://traveler.example.com\",\"audience_ans\":\"ans://seller.example.com\",\"scope\":\"commerce:travel:book\",\"max_amount\":1000.0,\"currency\":\"USD\",\"not_before\":\"2026-10-20T10:00:00Z\",\"not_after\":\"2026-10-20T11:00:00Z\",\"jkt\":\"test-jkt-456\",\"authority_ans\":\"ans://authority.example.com\",\"signature\":\"sig-xyz\"}" + } + ] + } + }`)) + })) + defer server.Close() + + // Create client + client := NewHTTPAuthority(server.URL) + + // Make request + input := MandateInput{ + QuoteID: "quote-789", + Total: 1000.0, + Currency: "USD", + MerchantANS: "ans://seller.example.com", + ScopeHint: "commerce:travel:book", + SubjectANS: "ans://traveler.example.com", + TravelerDPoPJWK: commercekey.JwkPublic{ + Kty: "EC", + Crv: "P-256", + X: "test-x", + Y: "test-y", + }, + } + + mandate, _, err := client.RequestMandate(context.Background(), input) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if mandate.MandateID != "mandate-999" { + t.Errorf("expected mandate_id mandate-999, got %s", mandate.MandateID) + } + if mandate.QuoteID != "quote-789" { + t.Errorf("expected quote_id quote-789, got %s", mandate.QuoteID) + } + }) + + t.Run("non-2xx error", func(t *testing.T) { + // Create test server that returns 500 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal server error: database connection failed")) + })) + defer server.Close() + + // Create client + client := NewHTTPAuthority(server.URL) + + // Make request + input := MandateInput{ + QuoteID: "quote-error", + Total: 100.0, + Currency: "USD", + } + + mandate, _, err := client.RequestMandate(context.Background(), input) + if err == nil { + t.Fatalf("expected error for non-2xx, got mandate: %+v", mandate) + } + + // Should contain truncated body + if !strings.Contains(err.Error(), "500") { + t.Errorf("expected error to contain 500, got: %v", err) + } + }) +} diff --git a/tutorial/commerce/cache.go b/tutorial/commerce/cache.go new file mode 100644 index 0000000..27ebaf4 --- /dev/null +++ b/tutorial/commerce/cache.go @@ -0,0 +1,110 @@ +package commerce + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" +) + +const skewSecs = 60 + +// Clock provides the current Unix timestamp for testing. +type Clock interface { + NowUnix() int64 +} + +// AuthorityClient requests mandates from an authority service. +type AuthorityClient interface { + RequestMandate(ctx context.Context, in MandateInput) (*Mandate, json.RawMessage, error) +} + +// MandateInput contains the parameters needed to request a mandate. +type MandateInput struct { + QuoteID string `json:"quote_id"` + Total float64 `json:"total"` + Currency string `json:"currency"` + MerchantANS string `json:"merchant_ans"` + ScopeHint string `json:"scope_hint"` + SubjectANS string `json:"subject_ans"` + TravelerDPoPJWK commercekey.JwkPublic `json:"traveler_dpop_jwk"` +} + +// cachedMandate holds a cached mandate with its expiry, quote key, and raw JSON bytes. +type cachedMandate struct { + mandate *Mandate + rawBytes json.RawMessage + notAfterUnix int64 + quoteKey QuoteKey +} + +// MandateCache is a single-slot cache for mandates with 60s expiry skew. +type MandateCache struct { + mu sync.Mutex + entry *cachedMandate +} + +// NewMandateCache creates a new empty mandate cache. +func NewMandateCache() *MandateCache { + return &MandateCache{} +} + +// GetOrFetch returns a cached mandate and its raw JSON bytes if it matches the quote key, JKT, +// and is not within 60 seconds of expiry. Otherwise, fetches a new mandate from the +// authority, validates it, caches it, and returns it with the raw bytes. +func (c *MandateCache) GetOrFetch( + ctx context.Context, + qk QuoteKey, + ourJKT, sellerANS, scopePrefix string, + authority AuthorityClient, + clock Clock, + build func(QuoteKey) MandateInput, +) (*Mandate, json.RawMessage, error) { + c.mu.Lock() + defer c.mu.Unlock() + + now := clock.NowUnix() + + // Check if we can serve from cache + if c.entry != nil { + cached := c.entry + // Cache hit if: quote key matches, JKT matches, and not within skew of expiry + if cached.quoteKey == qk && + cached.mandate.JKT == ourJKT && + now < cached.notAfterUnix-skewSecs { + return cached.mandate, cached.rawBytes, nil + } + } + + // Cache miss: fetch new mandate + input := build(qk) + mandate, rawBytes, err := authority.RequestMandate(ctx, input) + if err != nil { + return nil, nil, err + } + + // Validate the mandate + if err := Validate(mandate, qk, ourJKT, sellerANS, scopePrefix); err != nil { + return nil, nil, err + } + + // Parse not_after to get expiry time + notAfterTime, err := time.Parse(time.RFC3339, mandate.NotAfter) + if err != nil { + // This should not happen since Validate already checked it + return nil, nil, err + } + notAfterUnix := notAfterTime.Unix() + + // Store in cache with raw bytes + c.entry = &cachedMandate{ + mandate: mandate, + rawBytes: rawBytes, + notAfterUnix: notAfterUnix, + quoteKey: qk, + } + + return mandate, rawBytes, nil +} diff --git a/tutorial/commerce/cache_test.go b/tutorial/commerce/cache_test.go new file mode 100644 index 0000000..03bf148 --- /dev/null +++ b/tutorial/commerce/cache_test.go @@ -0,0 +1,417 @@ +package commerce + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" +) + +// fakeClock implements Clock with a fixed timestamp +type fakeClock struct { + now int64 +} + +func (f fakeClock) NowUnix() int64 { + return f.now +} + +// fakeAuthority implements AuthorityClient and counts calls +type fakeAuthority struct { + mandate *Mandate + rawBytes json.RawMessage + calls int +} + +func (f *fakeAuthority) RequestMandate(_ context.Context, _ MandateInput) (*Mandate, json.RawMessage, error) { + f.calls++ + if f.mandate == nil { + return nil, nil, errors.New("authority returned nil mandate") + } + // If rawBytes not set, generate from mandate + raw := f.rawBytes + if raw == nil { + rawData, _ := json.Marshal(f.mandate) + raw = json.RawMessage(rawData) + } + return f.mandate, raw, nil +} + +func TestCache_HitOnMatchingQuoteAndJKT(t *testing.T) { + cache := NewMandateCache() + clock := fakeClock{now: 1000} + authority := &fakeAuthority{ + mandate: &Mandate{ + MandateID: "m1", + QuoteID: "q1", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.example.com", + Scope: "commerce.pay", + MaxAmount: 100.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-01-01T01:00:00Z", + JKT: "jkt1", + AuthorityANS: "ans://authority.example.com", + Signature: "sig1", + }, + } + + qk := QuoteKey{QuoteID: "q1", Amount: 50.0, Currency: "USD"} + ourJKT := "jkt1" + sellerANS := "ans://seller.example.com" + scopePrefix := "commerce.pay" + build := func(qk QuoteKey) MandateInput { + return MandateInput{ + QuoteID: qk.QuoteID, + Total: qk.Amount, + Currency: qk.Currency, + MerchantANS: sellerANS, + ScopeHint: scopePrefix, + } + } + + // First call should fetch from authority + m1, raw1, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + if err != nil { + t.Fatalf("GetOrFetch failed: %v", err) + } + if m1 == nil { + t.Fatal("GetOrFetch returned nil mandate") + } + if authority.calls != 1 { + t.Errorf("Expected 1 authority call, got %d", authority.calls) + } + + // Second call with same parameters should hit cache + m2, raw2, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + if err != nil { + t.Fatalf("GetOrFetch failed on second call: %v", err) + } + if m2 == nil { + t.Fatal("GetOrFetch returned nil mandate on second call") + } + if authority.calls != 1 { + t.Errorf("Expected 1 authority call (cache hit), got %d", authority.calls) + } + + // Verify raw bytes are identical on cache hit + if string(raw1) != string(raw2) { + t.Errorf("Cache hit should return identical raw bytes:\nfirst: %s\nsecond: %s", string(raw1), string(raw2)) + } +} + +func TestCache_MissOnQuoteIDChange(t *testing.T) { + cache := NewMandateCache() + clock := fakeClock{now: 1000} + + qk1 := QuoteKey{QuoteID: "q1", Amount: 50.0, Currency: "USD"} + qk2 := QuoteKey{QuoteID: "q2", Amount: 60.0, Currency: "USD"} + ourJKT := "jkt1" + sellerANS := "ans://seller.example.com" + scopePrefix := "commerce.pay" + + authority := &fakeAuthority{} + build := func(qk QuoteKey) MandateInput { + return MandateInput{ + QuoteID: qk.QuoteID, + Total: qk.Amount, + Currency: qk.Currency, + MerchantANS: sellerANS, + ScopeHint: scopePrefix, + } + } + + // First call with q1 + authority.mandate = &Mandate{ + MandateID: "m1", + QuoteID: "q1", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.example.com", + Scope: "commerce.pay", + MaxAmount: 100.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-01-01T01:00:00Z", + JKT: "jkt1", + AuthorityANS: "ans://authority.example.com", + Signature: "sig1", + } + _, _, err := cache.GetOrFetch(context.Background(), qk1, ourJKT, sellerANS, scopePrefix, authority, clock, build) + if err != nil { + t.Fatalf("GetOrFetch failed: %v", err) + } + if authority.calls != 1 { + t.Errorf("Expected 1 authority call, got %d", authority.calls) + } + + // Second call with q2 should miss cache + authority.mandate = &Mandate{ + MandateID: "m2", + QuoteID: "q2", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.example.com", + Scope: "commerce.pay", + MaxAmount: 100.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-01-01T01:00:00Z", + JKT: "jkt1", + AuthorityANS: "ans://authority.example.com", + Signature: "sig2", + } + _, _, err = cache.GetOrFetch(context.Background(), qk2, ourJKT, sellerANS, scopePrefix, authority, clock, build) + if err != nil { + t.Fatalf("GetOrFetch failed on second call: %v", err) + } + if authority.calls != 2 { + t.Errorf("Expected 2 authority calls (cache miss), got %d", authority.calls) + } +} + +func TestCache_MissOnExpiryWithinSkew(t *testing.T) { + cache := NewMandateCache() + // Set clock to 30 seconds before expiry (within 60s skew) + notAfterUnix := int64(2000) + clock := fakeClock{now: notAfterUnix - 30} + + authority := &fakeAuthority{ + mandate: &Mandate{ + MandateID: "m1", + QuoteID: "q1", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.example.com", + Scope: "commerce.pay", + MaxAmount: 100.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "1970-01-01T00:33:20Z", // 2000 seconds since epoch + JKT: "jkt1", + AuthorityANS: "ans://authority.example.com", + Signature: "sig1", + }, + } + + qk := QuoteKey{QuoteID: "q1", Amount: 50.0, Currency: "USD"} + ourJKT := "jkt1" + sellerANS := "ans://seller.example.com" + scopePrefix := "commerce.pay" + build := func(qk QuoteKey) MandateInput { + return MandateInput{ + QuoteID: qk.QuoteID, + Total: qk.Amount, + Currency: qk.Currency, + MerchantANS: sellerANS, + ScopeHint: scopePrefix, + } + } + + // First call should fetch + _, _, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + if err != nil { + t.Fatalf("GetOrFetch failed: %v", err) + } + if authority.calls != 1 { + t.Errorf("Expected 1 authority call, got %d", authority.calls) + } + + // Second call should refetch because we're within 60s of expiry + _, _, err = cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + if err != nil { + t.Fatalf("GetOrFetch failed on second call: %v", err) + } + if authority.calls != 2 { + t.Errorf("Expected 2 authority calls (expired within skew), got %d", authority.calls) + } +} + +func TestCache_MissOnJKTChange(t *testing.T) { + cache := NewMandateCache() + clock := fakeClock{now: 1000} + + qk := QuoteKey{QuoteID: "q1", Amount: 50.0, Currency: "USD"} + sellerANS := "ans://seller.example.com" + scopePrefix := "commerce.pay" + + authority := &fakeAuthority{} + build := func(qk QuoteKey) MandateInput { + return MandateInput{ + QuoteID: qk.QuoteID, + Total: qk.Amount, + Currency: qk.Currency, + MerchantANS: sellerANS, + ScopeHint: scopePrefix, + } + } + + // First call with jkt1 + authority.mandate = &Mandate{ + MandateID: "m1", + QuoteID: "q1", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.example.com", + Scope: "commerce.pay", + MaxAmount: 100.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-01-01T01:00:00Z", + JKT: "jkt1", + AuthorityANS: "ans://authority.example.com", + Signature: "sig1", + } + _, _, err := cache.GetOrFetch(context.Background(), qk, "jkt1", sellerANS, scopePrefix, authority, clock, build) + if err != nil { + t.Fatalf("GetOrFetch failed: %v", err) + } + if authority.calls != 1 { + t.Errorf("Expected 1 authority call, got %d", authority.calls) + } + + // Second call with different JKT should miss cache + authority.mandate = &Mandate{ + MandateID: "m2", + QuoteID: "q1", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.example.com", + Scope: "commerce.pay", + MaxAmount: 100.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-01-01T01:00:00Z", + JKT: "jkt2", + AuthorityANS: "ans://authority.example.com", + Signature: "sig2", + } + _, _, err = cache.GetOrFetch(context.Background(), qk, "jkt2", sellerANS, scopePrefix, authority, clock, build) + if err != nil { + t.Fatalf("GetOrFetch failed on second call: %v", err) + } + if authority.calls != 2 { + t.Errorf("Expected 2 authority calls (JKT changed), got %d", authority.calls) + } +} + +func TestCache_FetchValidatesAndPropagatesError(t *testing.T) { + cache := NewMandateCache() + clock := fakeClock{now: 1000} + + // Mandate with wrong JKT to trigger validation failure + authority := &fakeAuthority{ + mandate: &Mandate{ + MandateID: "m1", + QuoteID: "q1", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.example.com", + Scope: "commerce.pay", + MaxAmount: 100.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-01-01T01:00:00Z", + JKT: "wrong-jkt", // This will fail validation + AuthorityANS: "ans://authority.example.com", + Signature: "sig1", + }, + } + + qk := QuoteKey{QuoteID: "q1", Amount: 50.0, Currency: "USD"} + ourJKT := "jkt1" + sellerANS := "ans://seller.example.com" + scopePrefix := "commerce.pay" + build := func(qk QuoteKey) MandateInput { + return MandateInput{ + QuoteID: qk.QuoteID, + Total: qk.Amount, + Currency: qk.Currency, + MerchantANS: sellerANS, + ScopeHint: scopePrefix, + } + } + + // Call should fail validation and return error + _, _, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + if err == nil { + t.Fatal("Expected validation error, got nil") + } + if !errors.Is(err, ErrMandateInvalid) { + t.Errorf("Expected ErrMandateInvalid, got %v", err) + } + if authority.calls != 1 { + t.Errorf("Expected 1 authority call, got %d", authority.calls) + } + + // Second call should also fail and refetch (nothing was cached) + _, _, err = cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + if err == nil { + t.Fatal("Expected validation error on second call, got nil") + } + if authority.calls != 2 { + t.Errorf("Expected 2 authority calls (first call didn't cache), got %d", authority.calls) + } +} + +func TestCache_PreservesRawBytesWithFloatFormat(t *testing.T) { + cache := NewMandateCache() + clock := fakeClock{now: 1000} + + // Use raw bytes with max_amount:1000.0 (with .0) + rawWithDot := json.RawMessage(`{"mandate_id":"m1","quote_id":"q1","subject_ans":"ans://traveler.example.com","audience_ans":"ans://seller.example.com","scope":"commerce.pay","max_amount":1000.0,"currency":"USD","not_before":"2026-01-01T00:00:00Z","not_after":"2026-01-01T01:00:00Z","jkt":"jkt1","authority_ans":"ans://authority.example.com","signature":"sig1"}`) + + authority := &fakeAuthority{ + mandate: &Mandate{ + MandateID: "m1", + QuoteID: "q1", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.example.com", + Scope: "commerce.pay", + MaxAmount: 1000.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-01-01T01:00:00Z", + JKT: "jkt1", + AuthorityANS: "ans://authority.example.com", + Signature: "sig1", + }, + rawBytes: rawWithDot, + } + + qk := QuoteKey{QuoteID: "q1", Amount: 50.0, Currency: "USD"} + ourJKT := "jkt1" + sellerANS := "ans://seller.example.com" + scopePrefix := "commerce.pay" + build := func(qk QuoteKey) MandateInput { + return MandateInput{ + QuoteID: qk.QuoteID, + Total: qk.Amount, + Currency: qk.Currency, + MerchantANS: sellerANS, + ScopeHint: scopePrefix, + } + } + + // First call should fetch from authority + _, raw1, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + if err != nil { + t.Fatalf("GetOrFetch failed: %v", err) + } + + // Verify raw bytes contain max_amount:1000.0 (with .0) + if !strings.Contains(string(raw1), `"max_amount":1000.0`) { + t.Errorf("raw bytes should contain max_amount:1000.0 (with .0), got: %s", string(raw1)) + } + + // Second call should hit cache and return IDENTICAL raw bytes + _, raw2, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + if err != nil { + t.Fatalf("GetOrFetch failed on cache hit: %v", err) + } + + if authority.calls != 1 { + t.Errorf("Expected 1 authority call (second should hit cache), got %d", authority.calls) + } + + // Verify byte-identical on cache hit + if string(raw1) != string(raw2) { + t.Errorf("Cache hit must return byte-identical raw bytes:\nfirst: %s\nsecond: %s", string(raw1), string(raw2)) + } +} diff --git a/tutorial/commerce/dpop.go b/tutorial/commerce/dpop.go new file mode 100644 index 0000000..aa82293 --- /dev/null +++ b/tutorial/commerce/dpop.go @@ -0,0 +1,105 @@ +// Package commerce provides commerce DPoP token minting for AP2 commerce protocol. +package commerce + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" +) + +const ( + // es256SignatureLen is the length of an ES256 signature in raw R‖S format (32 bytes each). + es256SignatureLen = 64 +) + +// dpopHeader is the DPoP JWT header with exact field order: typ, alg, jwk. +// Field order is load-bearing for byte-exact commerce protocol compliance. +type dpopHeader struct { + Typ string `json:"typ"` + Alg string `json:"alg"` + Jwk commercekey.JwkPublic `json:"jwk"` +} + +// dpopClaims is the DPoP JWT claims with exact field order: htm, htu, jti, iat, ath. +// Field order is load-bearing for byte-exact commerce protocol compliance. +type dpopClaims struct { + Htm string `json:"htm"` + Htu string `json:"htu"` + Jti string `json:"jti"` + Iat int64 `json:"iat"` + Ath string `json:"ath"` +} + +// MintDPoP creates a byte-exact RFC 9449 DPoP token for the AP2 commerce protocol. +// Returns a compact JWS in the form "header.payload.signature". +// +// Parameters: +// - key: P-256 commerce DPoP key +// - htu: HTTPs URI of the target resource (preserved verbatim, including trailing slash) +// - quoteID: Commerce quote identifier for access token hash binding +// - iat: Issued-at timestamp (Unix seconds) +// - jti: Unique JWT ID for replay prevention +// +// The signature is ES256 (ECDSA P-256 SHA-256) over a SINGLE sha256 hash of the +// signing input, serialized as raw R‖S (64 bytes, NOT ASN.1 DER). +func MintDPoP(key *commercekey.Key, htu, quoteID string, iat int64, jti string) (string, error) { + // Build header with exact field order: typ, alg, jwk + header := dpopHeader{ + Typ: "dpop+jwt", + Alg: "ES256", + Jwk: key.PublicJWK(), + } + + // Compute access token hash (ath): base64url(sha256(quoteID)) + quoteHash := sha256.Sum256([]byte(quoteID)) + ath := base64.RawURLEncoding.EncodeToString(quoteHash[:]) + + // Build claims with exact field order: htm, htu, jti, iat, ath + claims := dpopClaims{ + Htm: "POST", + Htu: htu, // preserved verbatim (trailing slash, etc.) + Jti: jti, + Iat: iat, + Ath: ath, + } + + // Marshal header and claims to JSON + headerJSON, err := json.Marshal(header) + if err != nil { + return "", fmt.Errorf("marshal header: %w", err) + } + + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("marshal claims: %w", err) + } + + // Base64url-encode (no padding) + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + payloadB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) + + // Construct signing input + signingInput := headerB64 + "." + payloadB64 + + // Sign with ECDSA P-256: SINGLE sha256 hash of signing input + digest := sha256.Sum256([]byte(signingInput)) + r, s, err := ecdsa.Sign(rand.Reader, key.Signer(), digest[:]) + if err != nil { + return "", fmt.Errorf("sign: %w", err) + } + + // Serialize signature as raw R‖S (64 bytes): each coordinate big-endian, left-padded to 32 bytes + // CRITICAL: NOT ASN.1 DER. The seller expects raw R‖S for ES256. + sig := make([]byte, es256SignatureLen) + r.FillBytes(sig[0:32]) // big-endian, left-padded + s.FillBytes(sig[32:es256SignatureLen]) + sigB64 := base64.RawURLEncoding.EncodeToString(sig) + + // Return compact JWS + return signingInput + "." + sigB64, nil +} diff --git a/tutorial/commerce/dpop_test.go b/tutorial/commerce/dpop_test.go new file mode 100644 index 0000000..d03778d --- /dev/null +++ b/tutorial/commerce/dpop_test.go @@ -0,0 +1,219 @@ +package commerce + +import ( + "crypto/ecdsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "os" + "strings" + "testing" + + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" +) + +// loadTestKey loads a fixed test key for deterministic tests. +func loadTestKey(t *testing.T) *commercekey.Key { + t.Helper() + tempPEM := `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgEjRWeJA0VniQNFZ4 +kDRWeJA0VniQNFZ4kDRWeJA0VnihRANCAAR7VJCMjvKZnHnHvS3xdKaPXM5pHvEJ +qxLJH8nppWLHVdJvxMlvmJNLcQh3PRe6mXF1wnTvzWFhZj0c+0/qrjPd +-----END PRIVATE KEY-----` + + tmpfile := t.TempDir() + "/test.pem" + if err := os.WriteFile(tmpfile, []byte(tempPEM), 0600); err != nil { + t.Fatal(err) + } + + key, err := commercekey.Load(tmpfile) + if err != nil { + t.Fatalf("failed to load test key: %v", err) + } + return key +} + +func TestMintDPoP_ExactHeaderAndClaims(t *testing.T) { + key := loadTestKey(t) + + // Mint DPoP token + jws, err := MintDPoP(key, "https://seller.skybound-travel.com/mcp/", "q-1", 1_000_000, "jti-1") + if err != nil { + t.Fatalf("MintDPoP failed: %v", err) + } + + // Split into 3 parts + parts := strings.Split(jws, ".") + if len(parts) != 3 { + t.Fatalf("expected 3 parts, got %d", len(parts)) + } + + // Decode header + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + t.Fatalf("failed to decode header: %v", err) + } + + // Check raw header starts with exact string + expectedPrefix := `{"typ":"dpop+jwt","alg":"ES256","jwk":{` + if !strings.HasPrefix(string(headerJSON), expectedPrefix) { + t.Errorf("header does not start with expected prefix\ngot: %s\nwant: %s...", string(headerJSON)[:len(expectedPrefix)], expectedPrefix) + } + + // Decode header into map to check keys + var headerMap map[string]interface{} + if err := json.Unmarshal(headerJSON, &headerMap); err != nil { + t.Fatalf("failed to unmarshal header: %v", err) + } + + // Check exactly 3 keys: typ, alg, jwk + if len(headerMap) != 3 { + t.Errorf("header has %d keys, want 3", len(headerMap)) + } + if _, ok := headerMap["typ"]; !ok { + t.Error("header missing 'typ' key") + } + if _, ok := headerMap["alg"]; !ok { + t.Error("header missing 'alg' key") + } + if _, ok := headerMap["jwk"]; !ok { + t.Error("header missing 'jwk' key") + } + + // Check values + if headerMap["typ"] != "dpop+jwt" { + t.Errorf("typ = %v, want 'dpop+jwt'", headerMap["typ"]) + } + if headerMap["alg"] != "ES256" { + t.Errorf("alg = %v, want 'ES256'", headerMap["alg"]) + } + + // Decode payload + payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + + var claims map[string]interface{} + if err := json.Unmarshal(payloadJSON, &claims); err != nil { + t.Fatalf("failed to unmarshal claims: %v", err) + } + + // Check claims + if claims["htm"] != "POST" { + t.Errorf("htm = %v, want 'POST'", claims["htm"]) + } + if claims["htu"] != "https://seller.skybound-travel.com/mcp/" { + t.Errorf("htu = %v, want 'https://seller.skybound-travel.com/mcp/'", claims["htu"]) + } + // iat is float64 in JSON + if iat, ok := claims["iat"].(float64); !ok || int64(iat) != 1_000_000 { + t.Errorf("iat = %v, want 1000000", claims["iat"]) + } + if claims["jti"] != "jti-1" { + t.Errorf("jti = %v, want 'jti-1'", claims["jti"]) + } + + // Check ath = base64url(sha256("q-1")) + h := sha256.Sum256([]byte("q-1")) + expectedAth := base64.RawURLEncoding.EncodeToString(h[:]) + if claims["ath"] != expectedAth { + t.Errorf("ath = %v, want %v", claims["ath"], expectedAth) + } +} + +func TestMintDPoP_SignatureVerifiesSingleHash(t *testing.T) { + key := loadTestKey(t) + + // Mint DPoP token + jws, err := MintDPoP(key, "https://seller.skybound-travel.com/mcp/", "q-1", 1_000_000, "jti-1") + if err != nil { + t.Fatalf("MintDPoP failed: %v", err) + } + + // Split into parts + parts := strings.Split(jws, ".") + if len(parts) != 3 { + t.Fatalf("expected 3 parts, got %d", len(parts)) + } + + // Recompute signing input + signingInput := parts[0] + "." + parts[1] + + // Hash once + digest := sha256.Sum256([]byte(signingInput)) + + // Decode signature + sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + t.Fatalf("failed to decode signature: %v", err) + } + + // Check signature is 64 bytes + if len(sigBytes) != 64 { + t.Fatalf("signature length = %d, want 64", len(sigBytes)) + } + + // Extract r and s (32 bytes each) + r := new(big.Int).SetBytes(sigBytes[0:32]) + s := new(big.Int).SetBytes(sigBytes[32:64]) + + // Verify with public key + pub := key.Signer().Public().(*ecdsa.PublicKey) + if !ecdsa.Verify(pub, digest[:], r, s) { + t.Error("signature verification failed (single hash)") + } + + // Verify that double-hash would fail (to ensure we're doing single hash) + doubleDigest := sha256.Sum256(digest[:]) + if ecdsa.Verify(pub, doubleDigest[:], r, s) { + t.Error("signature verified with double hash - implementation is wrong!") + } +} + +func TestMintDPoP_DistinctQuoteIDsDistinctAth(t *testing.T) { + key := loadTestKey(t) + + htu := "https://seller.skybound-travel.com/mcp/" + + // Mint with different quote IDs + jws1, err := MintDPoP(key, htu, "quote-1", 1_000_000, "jti-1") + if err != nil { + t.Fatalf("MintDPoP(quote-1) failed: %v", err) + } + + jws2, err := MintDPoP(key, htu, "quote-2", 1_000_000, "jti-2") + if err != nil { + t.Fatalf("MintDPoP(quote-2) failed: %v", err) + } + + // Extract ath from both + extractAth := func(jws string) string { + parts := strings.Split(jws, ".") + payloadJSON, _ := base64.RawURLEncoding.DecodeString(parts[1]) + var claims map[string]interface{} + json.Unmarshal(payloadJSON, &claims) + return claims["ath"].(string) + } + + ath1 := extractAth(jws1) + ath2 := extractAth(jws2) + + if ath1 == ath2 { + t.Error("distinct quote IDs produced identical ath values") + } + + // Verify htu is identical + extractHtu := func(jws string) string { + parts := strings.Split(jws, ".") + payloadJSON, _ := base64.RawURLEncoding.DecodeString(parts[1]) + var claims map[string]interface{} + json.Unmarshal(payloadJSON, &claims) + return claims["htu"].(string) + } + + if extractHtu(jws1) != htu || extractHtu(jws2) != htu { + t.Error("htu not preserved verbatim") + } +} diff --git a/tutorial/commerce/mandate.go b/tutorial/commerce/mandate.go new file mode 100644 index 0000000..8851778 --- /dev/null +++ b/tutorial/commerce/mandate.go @@ -0,0 +1,143 @@ +package commerce + +import ( + "errors" + "fmt" + "strings" + "time" +) + +// ErrMandateInvalid is returned when mandate validation fails. +var ErrMandateInvalid = errors.New("mandate invalid") + +// Mandate represents a commerce mandate with fields in the exact order +// matching the Rust commerce/types.rs:24-38. Field order is critical for +// byte-exact JSON reconstruction when validating seller-forwarded mandates. +type Mandate struct { + MandateID string `json:"mandate_id"` + QuoteID string `json:"quote_id"` + SubjectANS string `json:"subject_ans"` + AudienceANS string `json:"audience_ans"` + Scope string `json:"scope"` + MaxAmount float64 `json:"max_amount"` + Currency string `json:"currency"` + NotBefore string `json:"not_before"` + NotAfter string `json:"not_after"` + JKT string `json:"jkt"` + AuthorityANS string `json:"authority_ans"` + Signature string `json:"signature"` +} + +// QuoteKey contains the quote-related fields used in validation. +type QuoteKey struct { + QuoteID string + Amount float64 + Currency string +} + +// ANSHost strips the ans:// prefix (if present), removes version labels from +// the first label if it matches ^v\d, and drops all subsequent all-digit labels. +// Mirrors Rust mandate.rs:86-111. +// +// Examples: +// - "ans://v1.0.8.seller.example.com" -> "seller.example.com" +// - "ans://v2.1.authority.example.com" -> "authority.example.com" +// - "ans://seller.example.com" -> "seller.example.com" +// - "v1.0.8.seller.x.com" -> "seller.x.com" +func ANSHost(ansURI string) string { + // Strip ans:// prefix if present + s := strings.TrimPrefix(ansURI, "ans://") + + // Split on dots + labels := strings.Split(s, ".") + if len(labels) == 0 { + return s + } + + // Check if the first label starts with 'v' followed by a digit + firstLabel := labels[0] + if len(firstLabel) >= 2 && firstLabel[0] == 'v' && firstLabel[1] >= '0' && firstLabel[1] <= '9' { + // Drop the first label and any subsequent all-digit labels + i := 1 + for i < len(labels) { + // Check if the label is all digits + if !isAllDigits(labels[i]) { + break + } + i++ + } + labels = labels[i:] + } + + return strings.Join(labels, ".") +} + +// isAllDigits returns true if s is non-empty and contains only ASCII digits. +func isAllDigits(s string) bool { + if len(s) == 0 { + return false + } + for i := range len(s) { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + +// Validate performs six validation checks on the mandate against the quote key +// and caller context. Mirrors Rust mandate.rs:114-170. +// +// Checks: +// 1. ANSHost(m.AudienceANS) == ANSHost(sellerANS) and both non-empty +// 2. m.Scope starts with scopePrefix +// 3. m.JKT == ourJKT +// 4. m.QuoteID == qk.QuoteID +// 5. qk.Amount <= m.MaxAmount +// 6. m.NotBefore and m.NotAfter both parse as RFC3339 +func Validate(m *Mandate, qk QuoteKey, ourJKT, sellerANS, scopePrefix string) error { + // Check 1: audience host must match seller host + audienceHost := ANSHost(m.AudienceANS) + sellerHost := ANSHost(sellerANS) + if audienceHost == "" || sellerHost == "" || audienceHost != sellerHost { + return fmt.Errorf("%w: audience host %q does not match seller host %q", + ErrMandateInvalid, audienceHost, sellerHost) + } + + // Check 2: scope must start with the expected prefix + if !strings.HasPrefix(m.Scope, scopePrefix) { + return fmt.Errorf("%w: scope %q does not start with prefix %q", + ErrMandateInvalid, m.Scope, scopePrefix) + } + + // Check 3: jkt must match our jkt + if m.JKT != ourJKT { + return fmt.Errorf("%w: jkt %q does not match expected %q", + ErrMandateInvalid, m.JKT, ourJKT) + } + + // Check 4: quote_id must match + if m.QuoteID != qk.QuoteID { + return fmt.Errorf("%w: quote_id %q does not match expected %q", + ErrMandateInvalid, m.QuoteID, qk.QuoteID) + } + + // Check 5: amount must not exceed max_amount + if qk.Amount > m.MaxAmount { + return fmt.Errorf("%w: amount %v exceeds max_amount %v", + ErrMandateInvalid, qk.Amount, m.MaxAmount) + } + + // Check 6: not_before and not_after must be valid RFC3339 timestamps + if _, err := time.Parse(time.RFC3339, m.NotBefore); err != nil { + return fmt.Errorf("%w: not_before %q is not valid RFC3339: %w", + ErrMandateInvalid, m.NotBefore, err) + } + + if _, err := time.Parse(time.RFC3339, m.NotAfter); err != nil { + return fmt.Errorf("%w: not_after %q is not valid RFC3339: %w", + ErrMandateInvalid, m.NotAfter, err) + } + + return nil +} diff --git a/tutorial/commerce/mandate_test.go b/tutorial/commerce/mandate_test.go new file mode 100644 index 0000000..4528d9d --- /dev/null +++ b/tutorial/commerce/mandate_test.go @@ -0,0 +1,283 @@ +package commerce + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestANSHost_StripsVersionLabels(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "ans:// prefix with version labels", + input: "ans://v1.0.8.seller.skybound-travel.com", + want: "seller.skybound-travel.com", + }, + { + name: "ans:// prefix with v2 version", + input: "ans://v2.1.authority.example.com", + want: "authority.example.com", + }, + { + name: "ans:// prefix without version", + input: "ans://seller.example.com", + want: "seller.example.com", + }, + { + name: "no prefix with version labels", + input: "v1.0.8.seller.x.com", + want: "seller.x.com", + }, + { + name: "single label", + input: "example.com", + want: "example.com", + }, + { + name: "version label only numeric segments", + input: "v1.2.3.seller.example.com", + want: "seller.example.com", + }, + { + name: "version with non-numeric stops stripping", + input: "v1.2.foo.seller.example.com", + want: "foo.seller.example.com", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "ans:// prefix only", + input: "ans://", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ANSHost(tt.input) + if got != tt.want { + t.Errorf("ANSHost(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestMandate_JSONFieldOrderAndFloatFormat(t *testing.T) { + // CRITICAL: No captured live bytes exist (user-confirmed). Field order + // reconstructed from Rust commerce/types.rs:24-38. The FIRST LIVE RUN is the + // real validation of this wire-shape; a mismatch there is a wire-shape fix, + // not a redesign. + m := Mandate{ + MandateID: "mandate-123", + QuoteID: "quote-456", + SubjectANS: "ans://buyer.example.com", + AudienceANS: "ans://v1.0.8.seller.example.com", + Scope: "commerce:purchase", + MaxAmount: 1000, + Currency: "USD", + NotBefore: "2026-10-20T00:00:00Z", + NotAfter: "2026-10-20T23:59:59Z", + JKT: "jkt-abc", + AuthorityANS: "ans://authority.example.com", + Signature: "sig-xyz", + } + + got, err := json.Marshal(m) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + + // Golden string with fields in the exact order declared in the struct. + // Go's json package renders integral float64 without trailing .0 (confirmed). + want := `{"mandate_id":"mandate-123","quote_id":"quote-456","subject_ans":"ans://buyer.example.com","audience_ans":"ans://v1.0.8.seller.example.com","scope":"commerce:purchase","max_amount":1000,"currency":"USD","not_before":"2026-10-20T00:00:00Z","not_after":"2026-10-20T23:59:59Z","jkt":"jkt-abc","authority_ans":"ans://authority.example.com","signature":"sig-xyz"}` + + if string(got) != want { + t.Errorf("JSON field order/format mismatch:\ngot: %s\nwant: %s", string(got), want) + } +} + +func TestValidate_Rules(t *testing.T) { + validMandate := &Mandate{ + MandateID: "mandate-123", + QuoteID: "quote-456", + SubjectANS: "ans://buyer.example.com", + AudienceANS: "ans://v1.0.8.seller.example.com", + Scope: "commerce:purchase", + MaxAmount: 1000, + Currency: "USD", + NotBefore: "2026-10-20T00:00:00Z", + NotAfter: "2026-10-20T23:59:59Z", + JKT: "jkt-abc", + AuthorityANS: "ans://authority.example.com", + Signature: "sig-xyz", + } + + validQuoteKey := QuoteKey{ + QuoteID: "quote-456", + Amount: 800, + Currency: "USD", + } + + tests := []struct { + name string + mandate *Mandate + quoteKey QuoteKey + ourJKT string + sellerANS string + scopePrefix string + wantErrSubstr string + }{ + { + name: "all valid", + mandate: validMandate, + quoteKey: validQuoteKey, + ourJKT: "jkt-abc", + sellerANS: "ans://seller.example.com", + scopePrefix: "commerce:", + wantErrSubstr: "", + }, + { + name: "audience host mismatch", + mandate: &Mandate{ + AudienceANS: "ans://v1.different.com", + Scope: "commerce:purchase", + JKT: "jkt-abc", + QuoteID: "quote-456", + MaxAmount: 1000, + NotBefore: "2026-10-20T00:00:00Z", + NotAfter: "2026-10-20T23:59:59Z", + }, + quoteKey: validQuoteKey, + ourJKT: "jkt-abc", + sellerANS: "ans://seller.example.com", + scopePrefix: "commerce:", + wantErrSubstr: "audience host", + }, + { + name: "scope not prefixed", + mandate: &Mandate{ + AudienceANS: "ans://v1.seller.example.com", + Scope: "payment:purchase", + JKT: "jkt-abc", + QuoteID: "quote-456", + MaxAmount: 1000, + NotBefore: "2026-10-20T00:00:00Z", + NotAfter: "2026-10-20T23:59:59Z", + }, + quoteKey: validQuoteKey, + ourJKT: "jkt-abc", + sellerANS: "ans://seller.example.com", + scopePrefix: "commerce:", + wantErrSubstr: "scope", + }, + { + name: "jkt mismatch", + mandate: &Mandate{ + AudienceANS: "ans://v1.seller.example.com", + Scope: "commerce:purchase", + JKT: "wrong-jkt", + QuoteID: "quote-456", + MaxAmount: 1000, + NotBefore: "2026-10-20T00:00:00Z", + NotAfter: "2026-10-20T23:59:59Z", + }, + quoteKey: validQuoteKey, + ourJKT: "jkt-abc", + sellerANS: "ans://seller.example.com", + scopePrefix: "commerce:", + wantErrSubstr: "jkt", + }, + { + name: "quote_id mismatch", + mandate: &Mandate{ + AudienceANS: "ans://v1.seller.example.com", + Scope: "commerce:purchase", + JKT: "jkt-abc", + QuoteID: "wrong-quote", + MaxAmount: 1000, + NotBefore: "2026-10-20T00:00:00Z", + NotAfter: "2026-10-20T23:59:59Z", + }, + quoteKey: validQuoteKey, + ourJKT: "jkt-abc", + sellerANS: "ans://seller.example.com", + scopePrefix: "commerce:", + wantErrSubstr: "quote_id", + }, + { + name: "amount exceeds max_amount", + mandate: &Mandate{ + AudienceANS: "ans://v1.seller.example.com", + Scope: "commerce:purchase", + JKT: "jkt-abc", + QuoteID: "quote-456", + MaxAmount: 500, + NotBefore: "2026-10-20T00:00:00Z", + NotAfter: "2026-10-20T23:59:59Z", + }, + quoteKey: validQuoteKey, + ourJKT: "jkt-abc", + sellerANS: "ans://seller.example.com", + scopePrefix: "commerce:", + wantErrSubstr: "exceeds max_amount", + }, + { + name: "malformed not_before", + mandate: &Mandate{ + AudienceANS: "ans://v1.seller.example.com", + Scope: "commerce:purchase", + JKT: "jkt-abc", + QuoteID: "quote-456", + MaxAmount: 1000, + NotBefore: "invalid-date", + NotAfter: "2026-10-20T23:59:59Z", + }, + quoteKey: validQuoteKey, + ourJKT: "jkt-abc", + sellerANS: "ans://seller.example.com", + scopePrefix: "commerce:", + wantErrSubstr: "not_before", + }, + { + name: "malformed not_after", + mandate: &Mandate{ + AudienceANS: "ans://v1.seller.example.com", + Scope: "commerce:purchase", + JKT: "jkt-abc", + QuoteID: "quote-456", + MaxAmount: 1000, + NotBefore: "2026-10-20T00:00:00Z", + NotAfter: "invalid-date", + }, + quoteKey: validQuoteKey, + ourJKT: "jkt-abc", + sellerANS: "ans://seller.example.com", + scopePrefix: "commerce:", + wantErrSubstr: "not_after", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(tt.mandate, tt.quoteKey, tt.ourJKT, tt.sellerANS, tt.scopePrefix) + if tt.wantErrSubstr == "" { + if err != nil { + t.Errorf("Validate() returned error = %v, want nil", err) + } + } else { + if err == nil { + t.Errorf("Validate() returned nil, want error containing %q", tt.wantErrSubstr) + } else if !strings.Contains(err.Error(), tt.wantErrSubstr) { + t.Errorf("Validate() error = %v, want error containing %q", err, tt.wantErrSubstr) + } + } + }) + } +} diff --git a/tutorial/commerce/narrate.go b/tutorial/commerce/narrate.go new file mode 100644 index 0000000..74ff989 --- /dev/null +++ b/tutorial/commerce/narrate.go @@ -0,0 +1,54 @@ +package commerce + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +// jwsSegments is the number of dot-separated parts in a compact JWS +// (header.payload.signature). +const jwsSegments = 3 + +// DPoPClaims is the decoded, display-facing view of a DPoP proof's claims. +type DPoPClaims struct { + Htm string + Htu string + Jti string + Iat int64 + Ath string +} + +// DecodeDPoPClaims parses a compact DPoP JWS ("header.payload.signature") and +// returns its claims. It decodes ONLY (no signature verification) — it exists +// for observability/narration of a proof this process just minted, not for +// trust decisions. The payload is the middle base64url (RawURL) segment, +// unmarshaled into the same dpopClaims shape MintDPoP emits. +func DecodeDPoPClaims(proof string) (DPoPClaims, error) { + parts := strings.Split(proof, ".") + if len(parts) != jwsSegments { + return DPoPClaims{}, fmt.Errorf("malformed DPoP JWS: expected %d segments, got %d", jwsSegments, len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return DPoPClaims{}, fmt.Errorf("decode DPoP payload: %w", err) + } + var c dpopClaims + if err := json.Unmarshal(payload, &c); err != nil { + return DPoPClaims{}, fmt.Errorf("unmarshal DPoP claims: %w", err) + } + return DPoPClaims(c), nil +} + +// SummarizeMandate unmarshals the authority's raw mandate JSON into the typed +// Mandate for display. It does not re-validate the mandate (GetOrFetch already +// did); it exists so a narrator can show the scope, max_amount, jkt binding, +// and parties of the mandate that was attached. +func SummarizeMandate(raw []byte) (Mandate, error) { + var m Mandate + if err := json.Unmarshal(raw, &m); err != nil { + return Mandate{}, fmt.Errorf("unmarshal mandate: %w", err) + } + return m, nil +} diff --git a/tutorial/commerce/narrate_test.go b/tutorial/commerce/narrate_test.go new file mode 100644 index 0000000..b54fc20 --- /dev/null +++ b/tutorial/commerce/narrate_test.go @@ -0,0 +1,89 @@ +package commerce + +import ( + "encoding/json" + "testing" +) + +func TestDecodeDPoPClaims_RoundTrip(t *testing.T) { + key := loadTestKey(t) + const ( + htu = "https://seller.skybound-travel.com/mcp/" + quoteID = "demo-quote-001" + jti = "jti-abc" + iat = int64(1_700_000_000) + ) + proof, err := MintDPoP(key, htu, quoteID, iat, jti) + if err != nil { + t.Fatalf("MintDPoP: %v", err) + } + + claims, err := DecodeDPoPClaims(proof) + if err != nil { + t.Fatalf("DecodeDPoPClaims: %v", err) + } + if claims.Htm != "POST" { + t.Errorf("htm = %q, want POST", claims.Htm) + } + if claims.Htu != htu { + t.Errorf("htu = %q, want %q", claims.Htu, htu) + } + if claims.Jti != jti { + t.Errorf("jti = %q, want %q", claims.Jti, jti) + } + if claims.Iat != iat { + t.Errorf("iat = %d, want %d", claims.Iat, iat) + } + if claims.Ath == "" { + t.Error("ath is empty; want base64url(sha256(quote_id))") + } +} + +func TestDecodeDPoPClaims_Malformed(t *testing.T) { + if _, err := DecodeDPoPClaims("not-a-jws"); err == nil { + t.Fatal("expected error for a non-3-segment proof") + } + if _, err := DecodeDPoPClaims("aaa.!!!notbase64.ccc"); err == nil { + t.Fatal("expected error for an undecodable payload segment") + } +} + +func TestSummarizeMandate(t *testing.T) { + m := Mandate{ + MandateID: "mandate-1", + QuoteID: "demo-quote-001", + SubjectANS: "ans://v2.0.0.buyer.example.com", + AudienceANS: "ans://v1.0.8.seller.skybound-travel.com", + Scope: "purchase:flight:demo", + MaxAmount: 500, + Currency: "USD", + NotBefore: "2026-10-13T15:16:21+00:00", + NotAfter: "2026-10-13T15:31:21+00:00", + JKT: "jkt-xyz", + AuthorityANS: "ans://v1.0.0.authority.skybound-travel.com", + Signature: "sig", + } + raw, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + got, err := SummarizeMandate(raw) + if err != nil { + t.Fatalf("SummarizeMandate: %v", err) + } + if got.Scope != m.Scope || got.MaxAmount != m.MaxAmount || got.Currency != m.Currency { + t.Errorf("scope/max/currency = %q/%v/%q, want %q/%v/%q", + got.Scope, got.MaxAmount, got.Currency, m.Scope, m.MaxAmount, m.Currency) + } + if got.JKT != m.JKT || got.SubjectANS != m.SubjectANS || got.AuthorityANS != m.AuthorityANS { + t.Errorf("jkt/subject/authority = %q/%q/%q, want %q/%q/%q", + got.JKT, got.SubjectANS, got.AuthorityANS, m.JKT, m.SubjectANS, m.AuthorityANS) + } +} + +func TestSummarizeMandate_Invalid(t *testing.T) { + if _, err := SummarizeMandate([]byte("{not json")); err == nil { + t.Fatal("expected error for invalid mandate JSON") + } +} diff --git a/tutorial/commerce/rewrite.go b/tutorial/commerce/rewrite.go new file mode 100644 index 0000000..eb2b9d7 --- /dev/null +++ b/tutorial/commerce/rewrite.go @@ -0,0 +1,191 @@ +package commerce + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" +) + +// Nonce provides unique JWT IDs for DPoP token replay prevention. +type Nonce interface { + JTI() string +} + +// OutcomeKind represents the result of a rewrite operation. +type OutcomeKind int + +const ( + // Passthrough means the request was not a book_flight call and should be forwarded as-is. + Passthrough OutcomeKind = iota + // Deny means the request was rejected (missing args, validation failure, oversize). + Deny + // Allow means the request was successfully rewritten with mandate and DPoP proof. + Allow +) + +// Config contains the configuration for the commerce rewriter. +type Config struct { + SellerMCPURL string // MCP endpoint URL for the seller (used verbatim as DPoP htu) + SellerANS string // Seller's ANS identifier for mandate validation + ScopePrefix string // Expected prefix for mandate scope + SubjectANS string // Subject ANS identifier for mandate request + MaxBodyBytes int // Maximum allowed body size after rewriting +} + +// Deps contains the dependencies for the commerce rewriter. +type Deps struct { + Key *commercekey.Key // Commerce DPoP key + Cache *MandateCache // Mandate cache + Authority AuthorityClient // Authority service client + Clock Clock // Clock for timestamps + Nonce Nonce // Nonce generator for JTI +} + +// Outcome represents the result of a rewrite operation. +type Outcome struct { + Kind OutcomeKind + Body []byte // Set only for Allow + Reason string // Set only for Deny + SignURL string // Set only for Allow: externally-visible URL for identity proof htu binding + // MandateBytes and DPoPProof expose the artifacts spliced into an Allow'd + // body, for observability only (debug narration). Not used for control flow. + MandateBytes []byte // Set only for Allow: the authority's raw mandate JSON + DPoPProof string // Set only for Allow: the minted compact DPoP JWS +} + +// RewriteBookFlight orchestrates mandate fetching and DPoP injection for book_flight requests. +// It mirrors the Rust implementation in commerce/mod.rs:19-146. +// +// Behavior: +// - Passthrough (no deps called) if: body is not JSON, method != "tools/call", or params.name != "book_flight" +// - Extract quote_id, amount, currency from params.arguments; missing → Deny +// - Fetch mandate from cache (with validation); error → Deny +// - Mint DPoP proof with htu = htuOverride (if set), else cfg.SellerMCPURL (verbatim, not reconstructed) +// - Splice mandate and dpop_proof into arguments, re-marshal +// - If re-marshaled body exceeds cfg.MaxBodyBytes → Deny; else Allow +// +// htuOverride: when non-empty, use this as the DPoP htu instead of cfg.SellerMCPURL (for discovered params). +// +// CRITICAL: Uses json.Decoder.UseNumber() to preserve numeric formatting (500 stays 500, not 500.0). +func RewriteBookFlight(ctx context.Context, body []byte, d Deps, cfg Config, htuOverride string) Outcome { + // Parse the body with UseNumber() to preserve numeric formatting + // CRITICAL: Must use json.Decoder with UseNumber() to prevent 500 → 500.0 conversion + var req map[string]any + dec := json.NewDecoder(bytes.NewReader(body)) + dec.UseNumber() + if err := dec.Decode(&req); err != nil { + // Not JSON → Passthrough + return Outcome{Kind: Passthrough} + } + + // Check method == "tools/call" + method, ok := req["method"].(string) + if !ok || method != "tools/call" { + return Outcome{Kind: Passthrough} + } + + // Extract params + params, ok := req["params"].(map[string]any) + if !ok { + return Outcome{Kind: Passthrough} + } + + // Check name == "book_flight" + name, ok := params["name"].(string) + if !ok || name != "book_flight" { + return Outcome{Kind: Passthrough} + } + + // Extract arguments + args, ok := params["arguments"].(map[string]any) + if !ok { + return Outcome{Kind: Deny, Reason: "missing arguments"} + } + + // Extract quote_id (string) + quoteID, ok := args["quote_id"].(string) + if !ok || quoteID == "" { + return Outcome{Kind: Deny, Reason: "missing or invalid quote_id"} + } + + // Extract amount (json.Number → float64 for QuoteKey) + amountNum, ok := args["amount"].(json.Number) + if !ok { + return Outcome{Kind: Deny, Reason: "missing or invalid amount"} + } + amount, err := amountNum.Float64() + if err != nil { + return Outcome{Kind: Deny, Reason: fmt.Sprintf("invalid amount: %v", err)} + } + + // Extract currency (string) + currency, ok := args["currency"].(string) + if !ok || currency == "" { + return Outcome{Kind: Deny, Reason: "missing or invalid currency"} + } + + // Build QuoteKey + qk := QuoteKey{ + QuoteID: quoteID, + Amount: amount, + Currency: currency, + } + + // Fetch mandate (with caching and validation) + // Returns typed mandate (already validated by GetOrFetch) and raw bytes (for forwarding) + _, rawMandateBytes, err := d.Cache.GetOrFetch( + ctx, + qk, + d.Key.JKT(), + cfg.SellerANS, + cfg.ScopePrefix, + d.Authority, + d.Clock, + func(qk QuoteKey) MandateInput { + return MandateInput{ + QuoteID: qk.QuoteID, + Total: qk.Amount, + Currency: qk.Currency, + MerchantANS: cfg.SellerANS, + ScopeHint: cfg.ScopePrefix, + SubjectANS: cfg.SubjectANS, + TravelerDPoPJWK: d.Key.PublicJWK(), + } + }, + ) + if err != nil { + return Outcome{Kind: Deny, Reason: fmt.Sprintf("mandate error: %v", err)} + } + + // Mint DPoP proof + // CRITICAL: htu is htuOverride (if set) or cfg.SellerMCPURL (trailing slash preserved), not reconstructed + htu := cfg.SellerMCPURL + if htuOverride != "" { + htu = htuOverride + } + proof, err := MintDPoP(d.Key, htu, quoteID, d.Clock.NowUnix(), d.Nonce.JTI()) + if err != nil { + return Outcome{Kind: Deny, Reason: fmt.Sprintf("dpop mint error: %v", err)} + } + + // Splice mandate and dpop_proof into arguments (overwrite if present) + // CRITICAL: Use json.RawMessage so Marshal emits the authority's bytes VERBATIM + args["mandate"] = rawMandateBytes + args["dpop_proof"] = proof + + // Re-marshal the request + rewritten, err := json.Marshal(req) + if err != nil { + return Outcome{Kind: Deny, Reason: fmt.Sprintf("marshal error: %v", err)} + } + + // Check size limit + if len(rewritten) > cfg.MaxBodyBytes { + return Outcome{Kind: Deny, Reason: fmt.Sprintf("oversize: %d > %d", len(rewritten), cfg.MaxBodyBytes)} + } + + return Outcome{Kind: Allow, Body: rewritten, SignURL: htu, MandateBytes: rawMandateBytes, DPoPProof: proof} +} diff --git a/tutorial/commerce/rewrite_test.go b/tutorial/commerce/rewrite_test.go new file mode 100644 index 0000000..e02cade --- /dev/null +++ b/tutorial/commerce/rewrite_test.go @@ -0,0 +1,631 @@ +package commerce + +import ( + "context" + "encoding/base64" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" +) + +// loadTestKey loads a fixed test key for deterministic tests. +func loadRewriteTestKey(t *testing.T) *commercekey.Key { + t.Helper() + tempPEM := `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgEjRWeJA0VniQNFZ4 +kDRWeJA0VniQNFZ4kDRWeJA0VnihRANCAAR7VJCMjvKZnHnHvS3xdKaPXM5pHvEJ +qxLJH8nppWLHVdJvxMlvmJNLcQh3PRe6mXF1wnTvzWFhZj0c+0/qrjPd +-----END PRIVATE KEY-----` + + tmpfile := t.TempDir() + "/test.pem" + if err := os.WriteFile(tmpfile, []byte(tempPEM), 0600); err != nil { + t.Fatal(err) + } + + key, err := commercekey.Load(tmpfile) + if err != nil { + t.Fatalf("failed to load test key: %v", err) + } + return key +} + +// fakeAuthorityWithError is a fake AuthorityClient that can return errors. +type fakeAuthorityWithError struct { + mandate *Mandate + rawBytes json.RawMessage + err error +} + +func (f *fakeAuthorityWithError) RequestMandate(_ context.Context, _ MandateInput) (*Mandate, json.RawMessage, error) { + if f.err != nil { + return nil, nil, f.err + } + // If rawBytes not set, generate from mandate + raw := f.rawBytes + if raw == nil && f.mandate != nil { + rawData, _ := json.Marshal(f.mandate) + raw = json.RawMessage(rawData) + } + return f.mandate, raw, nil +} + +// fakeNonce is a fake Nonce for testing. +type fakeNonce struct { + jti string +} + +func (f *fakeNonce) JTI() string { + return f.jti +} + +func TestRewrite_PassthroughNonJSON(t *testing.T) { + body := []byte("not json") + deps := Deps{} + cfg := Config{} + + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") + + if outcome.Kind != Passthrough { + t.Errorf("expected Passthrough, got %v", outcome.Kind) + } + if len(outcome.Body) != 0 { + t.Errorf("expected no body for Passthrough, got %d bytes", len(outcome.Body)) + } +} + +func TestRewrite_PassthroughNotBookFlight(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"other_tool","arguments":{}}}`) + deps := Deps{} + cfg := Config{} + + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") + + if outcome.Kind != Passthrough { + t.Errorf("expected Passthrough, got %v", outcome.Kind) + } + if len(outcome.Body) != 0 { + t.Errorf("expected no body for Passthrough, got %d bytes", len(outcome.Body)) + } +} + +func TestRewrite_DenyMissingArgs(t *testing.T) { + // Missing quote_id + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"book_flight","arguments":{"amount":500,"currency":"USD"}}}`) + deps := Deps{} + cfg := Config{} + + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") + + if outcome.Kind != Deny { + t.Errorf("expected Deny, got %v", outcome.Kind) + } + if len(outcome.Body) != 0 { + t.Errorf("expected no body for Deny, got %d bytes", len(outcome.Body)) + } + if !strings.Contains(outcome.Reason, "quote_id") { + t.Errorf("expected reason to mention quote_id, got %q", outcome.Reason) + } +} + +func TestRewrite_AllowSplicesMandateAndProof(t *testing.T) { + // Create a test commerce key + key := loadRewriteTestKey(t) + + // Create a fake mandate that will pass validation + mandate := &Mandate{ + MandateID: "mandate-001", + QuoteID: "demo-quote-001", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.skybound-travel.com", + Scope: "commerce.payment:write", + MaxAmount: 1000.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-12-31T23:59:59Z", + JKT: key.JKT(), + AuthorityANS: "ans://authority.example.com", + Signature: "fake-signature", + } + + fakeAuth := &fakeAuthorityWithError{mandate: mandate} + clock := fakeClock{now: 1735689600} // 2025-01-01 00:00:00 + nonce := &fakeNonce{jti: "test-jti-123"} + + deps := Deps{ + Key: key, + Cache: NewMandateCache(), + Authority: fakeAuth, + Clock: clock, + Nonce: nonce, + } + + cfg := Config{ + SellerMCPURL: "https://seller.skybound-travel.com/mcp/", + SellerANS: "ans://seller.skybound-travel.com", + ScopePrefix: "commerce.", + SubjectANS: "ans://traveler.example.com", + MaxBodyBytes: 10000, + } + + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500,"currency":"USD"}}}`) + + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") + + if outcome.Kind != Allow { + t.Fatalf("expected Allow, got %v: %s", outcome.Kind, outcome.Reason) + } + + if len(outcome.Body) == 0 { + t.Fatal("expected body for Allow, got none") + } + + // Parse the result + var result map[string]any + if err := json.Unmarshal(outcome.Body, &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + // Check params.arguments + params, ok := result["params"].(map[string]any) + if !ok { + t.Fatal("params not found or not a map") + } + + args, ok := params["arguments"].(map[string]any) + if !ok { + t.Fatal("arguments not found or not a map") + } + + // Check that mandate was spliced + mandateObj, ok := args["mandate"].(map[string]any) + if !ok { + t.Fatal("mandate not found or not an object") + } + + if mandateObj["mandate_id"] != "mandate-001" { + t.Errorf("expected mandate_id mandate-001, got %v", mandateObj["mandate_id"]) + } + + // Check that dpop_proof was spliced + dpopProof, ok := args["dpop_proof"].(string) + if !ok { + t.Fatal("dpop_proof not found or not a string") + } + + // Check it's a 3-part JWS + if len(strings.Split(dpopProof, ".")) != 3 { + t.Errorf("expected 3-part JWS, got %q", dpopProof) + } + + // Check original fields are preserved + if args["quote_id"] != "demo-quote-001" { + t.Errorf("quote_id not preserved") + } + + if args["currency"] != "USD" { + t.Errorf("currency not preserved") + } + + // CRITICAL: Check that amount stays as 500, not 500.0 + // Re-marshal and check the byte representation + bodyStr := string(outcome.Body) + if !strings.Contains(bodyStr, `"amount":500`) { + t.Errorf("expected amount to be preserved as 500 (no decimal), got: %s", bodyStr) + } + if strings.Contains(bodyStr, `"amount":500.0`) { + t.Errorf("amount was incorrectly converted to 500.0") + } +} + +func TestRewrite_DenyOversize(t *testing.T) { + // Create a test commerce key + key := loadRewriteTestKey(t) + + // Create a fake mandate + mandate := &Mandate{ + MandateID: "mandate-001", + QuoteID: "demo-quote-001", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.skybound-travel.com", + Scope: "commerce.payment:write", + MaxAmount: 1000.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-12-31T23:59:59Z", + JKT: key.JKT(), + AuthorityANS: "ans://authority.example.com", + Signature: "fake-signature", + } + + fakeAuth := &fakeAuthorityWithError{mandate: mandate} + clock := fakeClock{now: 1735689600} + nonce := &fakeNonce{jti: "test-jti-123"} + + deps := Deps{ + Key: key, + Cache: NewMandateCache(), + Authority: fakeAuth, + Clock: clock, + Nonce: nonce, + } + + cfg := Config{ + SellerMCPURL: "https://seller.skybound-travel.com/mcp/", + SellerANS: "ans://seller.skybound-travel.com", + ScopePrefix: "commerce.", + SubjectANS: "ans://traveler.example.com", + MaxBodyBytes: 10, // Tiny limit to trigger oversize + } + + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500,"currency":"USD"}}}`) + + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") + + if outcome.Kind != Deny { + t.Errorf("expected Deny for oversize, got %v", outcome.Kind) + } + + if len(outcome.Body) != 0 { + t.Errorf("expected no body for Deny, got %d bytes", len(outcome.Body)) + } + + if !strings.Contains(outcome.Reason, "oversize") && !strings.Contains(outcome.Reason, "size") { + t.Errorf("expected reason to mention size, got %q", outcome.Reason) + } +} + +func TestRewrite_HtuOverride(t *testing.T) { + // Test that htuOverride is used for DPoP htu when set, else static cfg.SellerMCPURL. + key := loadRewriteTestKey(t) + mandate := &Mandate{ + MandateID: "mandate-001", + QuoteID: "demo-quote-001", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.skybound-travel.com", + Scope: "commerce.payment:write", + MaxAmount: 1000.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-12-31T23:59:59Z", + JKT: key.JKT(), + AuthorityANS: "ans://authority.example.com", + Signature: "fake-signature", + } + + fakeAuth := &fakeAuthorityWithError{mandate: mandate} + clock := fakeClock{now: 1735689600} + nonce := &fakeNonce{jti: "test-jti-123"} + + deps := Deps{ + Key: key, + Cache: NewMandateCache(), + Authority: fakeAuth, + Clock: clock, + Nonce: nonce, + } + + cfg := Config{ + SellerMCPURL: "https://seller.skybound-travel.com/mcp/", + SellerANS: "ans://seller.skybound-travel.com", + ScopePrefix: "commerce.", + SubjectANS: "ans://traveler.example.com", + MaxBodyBytes: 10000, + } + + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500,"currency":"USD"}}}`) + + t.Run("with override", func(t *testing.T) { + override := "https://other-seller.example.com/mcp/" + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, override) + + if outcome.Kind != Allow { + t.Fatalf("expected Allow, got %v: %s", outcome.Kind, outcome.Reason) + } + + // Decode dpop_proof and check htu == override + var result map[string]any + if err := json.Unmarshal(outcome.Body, &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + params := result["params"].(map[string]any) + args := params["arguments"].(map[string]any) + dpopProof := args["dpop_proof"].(string) + + parts := strings.Split(dpopProof, ".") + if len(parts) != 3 { + t.Fatalf("expected 3-part JWS, got %d parts", len(parts)) + } + var claims map[string]any + if err := json.Unmarshal(mustBase64Decode(parts[1]), &claims); err != nil { + t.Fatalf("unmarshal claims: %v", err) + } + htu, ok := claims["htu"].(string) + if !ok { + t.Fatal("htu not found in claims") + } + if htu != override { + t.Errorf("htu = %q, want %q", htu, override) + } + }) + + t.Run("without override", func(t *testing.T) { + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") + + if outcome.Kind != Allow { + t.Fatalf("expected Allow, got %v: %s", outcome.Kind, outcome.Reason) + } + + // Decode dpop_proof and check htu == cfg.SellerMCPURL + var result map[string]any + if err := json.Unmarshal(outcome.Body, &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + params := result["params"].(map[string]any) + args := params["arguments"].(map[string]any) + dpopProof := args["dpop_proof"].(string) + + parts := strings.Split(dpopProof, ".") + if len(parts) != 3 { + t.Fatalf("expected 3-part JWS, got %d parts", len(parts)) + } + var claims map[string]any + if err := json.Unmarshal(mustBase64Decode(parts[1]), &claims); err != nil { + t.Fatalf("unmarshal claims: %v", err) + } + htu, ok := claims["htu"].(string) + if !ok { + t.Fatal("htu not found in claims") + } + if htu != cfg.SellerMCPURL { + t.Errorf("htu = %q, want %q", htu, cfg.SellerMCPURL) + } + }) +} + +func mustBase64Decode(s string) []byte { + data, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + panic(err) + } + return data +} + +func TestRewrite_DenyOnMandateError(t *testing.T) { + // Create a test commerce key + key := loadRewriteTestKey(t) + + // Create a fake authority that returns an error + fakeAuth := &fakeAuthorityWithError{err: context.DeadlineExceeded} + clock := fakeClock{now: 1735689600} + nonce := &fakeNonce{jti: "test-jti-123"} + + deps := Deps{ + Key: key, + Cache: NewMandateCache(), + Authority: fakeAuth, + Clock: clock, + Nonce: nonce, + } + + cfg := Config{ + SellerMCPURL: "https://seller.skybound-travel.com/mcp/", + SellerANS: "ans://seller.skybound-travel.com", + ScopePrefix: "commerce.", + SubjectANS: "ans://traveler.example.com", + MaxBodyBytes: 10000, + } + + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500,"currency":"USD"}}}`) + + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") + + if outcome.Kind != Deny { + t.Errorf("expected Deny on mandate error, got %v", outcome.Kind) + } + + if len(outcome.Body) != 0 { + t.Errorf("expected no body for Deny, got %d bytes", len(outcome.Body)) + } +} + +func TestRewrite_PreservesFloatFormatVerbatim(t *testing.T) { + // THE KEY TEST: mandate with max_amount:1000.0 must appear in rewritten body with .0 preserved + key := loadRewriteTestKey(t) + + // Raw bytes from "authority" with max_amount:1000.0 (WITH the .0) + rawMandateWithDot := json.RawMessage(`{"mandate_id":"mandate-001","quote_id":"demo-quote-001","subject_ans":"ans://traveler.example.com","audience_ans":"ans://seller.skybound-travel.com","scope":"commerce.payment:write","max_amount":1000.0,"currency":"USD","not_before":"2026-01-01T00:00:00Z","not_after":"2026-12-31T23:59:59Z","jkt":"` + key.JKT() + `","authority_ans":"ans://authority.example.com","signature":"fake-signature"}`) + + // Typed mandate (for validation) + mandate := &Mandate{ + MandateID: "mandate-001", + QuoteID: "demo-quote-001", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.skybound-travel.com", + Scope: "commerce.payment:write", + MaxAmount: 1000.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-12-31T23:59:59Z", + JKT: key.JKT(), + AuthorityANS: "ans://authority.example.com", + Signature: "fake-signature", + } + + fakeAuth := &fakeAuthorityWithError{ + mandate: mandate, + rawBytes: rawMandateWithDot, + } + + clock := fakeClock{now: 1735689600} + nonce := &fakeNonce{jti: "test-jti-123"} + + deps := Deps{ + Key: key, + Cache: NewMandateCache(), + Authority: fakeAuth, + Clock: clock, + Nonce: nonce, + } + + cfg := Config{ + SellerMCPURL: "https://seller.skybound-travel.com/mcp/", + SellerANS: "ans://seller.skybound-travel.com", + ScopePrefix: "commerce.", + SubjectANS: "ans://traveler.example.com", + MaxBodyBytes: 10000, + } + + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500,"currency":"USD"}}}`) + + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") + + if outcome.Kind != Allow { + t.Fatalf("expected Allow, got %v: %s", outcome.Kind, outcome.Reason) + } + + // The CRITICAL assertion: the rewritten body must contain "max_amount":1000.0 (WITH .0) + bodyStr := string(outcome.Body) + if !strings.Contains(bodyStr, `"max_amount":1000.0`) { + t.Errorf("rewritten body MUST preserve max_amount:1000.0 (with .0), got: %s", bodyStr) + } + + // Prove the fix matters: re-marshaling the typed mandate would LOSE the .0 + remarshaled, err := json.Marshal(mandate) + if err != nil { + t.Fatalf("failed to re-marshal mandate: %v", err) + } + if strings.Contains(string(remarshaled), `"max_amount":1000.0`) { + t.Errorf("re-marshaled struct should NOT preserve .0 (this proves the fix was needed), got: %s", string(remarshaled)) + } +} + +func TestRewrite_PreservesNonIntegerFloats(t *testing.T) { + // Test that non-integer floats like 1234.56 round-trip verbatim + key := loadRewriteTestKey(t) + + rawMandateWithDecimal := json.RawMessage(`{"mandate_id":"mandate-002","quote_id":"demo-quote-002","subject_ans":"ans://traveler.example.com","audience_ans":"ans://seller.skybound-travel.com","scope":"commerce.payment:write","max_amount":1234.56,"currency":"USD","not_before":"2026-01-01T00:00:00Z","not_after":"2026-12-31T23:59:59Z","jkt":"` + key.JKT() + `","authority_ans":"ans://authority.example.com","signature":"fake-signature"}`) + + mandate := &Mandate{ + MandateID: "mandate-002", + QuoteID: "demo-quote-002", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.skybound-travel.com", + Scope: "commerce.payment:write", + MaxAmount: 1234.56, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-12-31T23:59:59Z", + JKT: key.JKT(), + AuthorityANS: "ans://authority.example.com", + Signature: "fake-signature", + } + + fakeAuth := &fakeAuthorityWithError{ + mandate: mandate, + rawBytes: rawMandateWithDecimal, + } + + clock := fakeClock{now: 1735689600} + nonce := &fakeNonce{jti: "test-jti-123"} + + deps := Deps{ + Key: key, + Cache: NewMandateCache(), + Authority: fakeAuth, + Clock: clock, + Nonce: nonce, + } + + cfg := Config{ + SellerMCPURL: "https://seller.skybound-travel.com/mcp/", + SellerANS: "ans://seller.skybound-travel.com", + ScopePrefix: "commerce.", + SubjectANS: "ans://traveler.example.com", + MaxBodyBytes: 10000, + } + + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-002","amount":100,"currency":"USD"}}}`) + + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") + + if outcome.Kind != Allow { + t.Fatalf("expected Allow, got %v: %s", outcome.Kind, outcome.Reason) + } + + // Verify 1234.56 is preserved verbatim + bodyStr := string(outcome.Body) + if !strings.Contains(bodyStr, `"max_amount":1234.56`) { + t.Errorf("rewritten body must preserve max_amount:1234.56 verbatim, got: %s", bodyStr) + } +} + +func TestRewrite_SignURLSetOnAllow(t *testing.T) { + // Create a test commerce key + key := loadRewriteTestKey(t) + + // Create a fake mandate + mandate := &Mandate{ + MandateID: "mandate-001", + QuoteID: "demo-quote-001", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.skybound-travel.com", + Scope: "commerce.payment:write", + MaxAmount: 1000.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-12-31T23:59:59Z", + JKT: key.JKT(), + AuthorityANS: "ans://authority.example.com", + Signature: "fake-signature", + } + + fakeAuth := &fakeAuthorityWithError{mandate: mandate} + clock := fakeClock{now: 1735689600} + nonce := &fakeNonce{jti: "test-jti-123"} + + deps := Deps{ + Key: key, + Cache: NewMandateCache(), + Authority: fakeAuth, + Clock: clock, + Nonce: nonce, + } + + cfg := Config{ + SellerMCPURL: "https://seller.skybound-travel.com/mcp/", + SellerANS: "ans://seller.skybound-travel.com", + ScopePrefix: "commerce.", + SubjectANS: "ans://traveler.example.com", + MaxBodyBytes: 10000, + } + + body := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500,"currency":"USD"}}}`) + + t.Run("SignURL matches SellerMCPURL", func(t *testing.T) { + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") + + if outcome.Kind != Allow { + t.Fatalf("expected Allow, got %v: %s", outcome.Kind, outcome.Reason) + } + + if outcome.SignURL != cfg.SellerMCPURL { + t.Errorf("expected SignURL = %q, got %q", cfg.SellerMCPURL, outcome.SignURL) + } + }) + + t.Run("SignURL matches htuOverride when set", func(t *testing.T) { + htuOverride := "https://discovered.seller.com/api/" + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, htuOverride) + + if outcome.Kind != Allow { + t.Fatalf("expected Allow, got %v: %s", outcome.Kind, outcome.Reason) + } + + if outcome.SignURL != htuOverride { + t.Errorf("expected SignURL = %q, got %q", htuOverride, outcome.SignURL) + } + }) +} diff --git a/tutorial/commercekey/commercekey.go b/tutorial/commercekey/commercekey.go new file mode 100644 index 0000000..c27c3c4 --- /dev/null +++ b/tutorial/commercekey/commercekey.go @@ -0,0 +1,121 @@ +// Package commercekey provides P-256 commerce DPoP key management with RFC 7638 thumbprint support. +package commercekey + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "os" +) + +// JwkPublic represents a P-256 public key in JWK format. +// Field order is critical for RFC 7638 thumbprint computation. +type JwkPublic struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +// Key wraps a P-256 ECDSA private key for commerce DPoP operations. +type Key struct { + key *ecdsa.PrivateKey +} + +// Load reads a P-256 commerce DPoP key from the COMMERCE_DPOP_KEY environment variable +// (if set) or from the file at the given path. The key must be in PKCS#8 PEM format. +// Environment variable takes precedence over file path. +func Load(path string) (*Key, error) { + var pemData []byte + + // Check environment variable first + if envPEM := os.Getenv("COMMERCE_DPOP_KEY"); envPEM != "" { + pemData = []byte(envPEM) + } else { + // Read from file + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read key file: %w", err) + } + pemData = data + } + + // Decode PEM block + block, _ := pem.Decode(pemData) + if block == nil { + return nil, errors.New("failed to decode PEM block") + } + + // Parse PKCS#8 private key + privKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse PKCS#8 private key: %w", err) + } + + // Assert *ecdsa.PrivateKey + ecdsaKey, ok := privKey.(*ecdsa.PrivateKey) + if !ok { + return nil, errors.New("key is not *ecdsa.PrivateKey") + } + + // Assert P-256 curve + if ecdsaKey.Curve != elliptic.P256() { + return nil, errors.New("key is not P-256") + } + + return &Key{key: ecdsaKey}, nil +} + +// PublicJWK returns the public key in JWK format. +// X and Y coordinates are base64url-encoded (no padding) 32-byte values. +func (k *Key) PublicJWK() JwkPublic { + // Get uncompressed public key bytes (0x04 || X || Y) + // For P-256: 1 byte prefix + 32 bytes X + 32 bytes Y = 65 bytes total + ecdhPub, err := k.key.PublicKey.ECDH() + if err != nil { + panic(fmt.Sprintf("ECDH conversion: %v", err)) + } + pubBytes := ecdhPub.Bytes() + const p256UncompressedLen = 65 // 1 prefix + 32 X + 32 Y + if len(pubBytes) != p256UncompressedLen { + panic("unexpected public key length") + } + + // Extract X and Y (skip the 0x04 prefix) + xBytes := pubBytes[1:33] + yBytes := pubBytes[33:p256UncompressedLen] + + return JwkPublic{ + Kty: "EC", + Crv: "P-256", + X: base64.RawURLEncoding.EncodeToString(xBytes), + Y: base64.RawURLEncoding.EncodeToString(yBytes), + } +} + +// JKT returns the RFC 7638 thumbprint of the public key. +// The canonical JSON is built with exact field order: crv, kty, x, y (lexicographic). +// Returns base64url-encoded (no padding) SHA-256 hash. +func (k *Key) JKT() string { + jwk := k.PublicJWK() + + // RFC 7638: canonical JSON with lexicographic key order + // CRITICAL: Must use fmt.Sprintf, not encoding/json, to guarantee field order + canonical := fmt.Sprintf(`{"crv":"P-256","kty":"EC","x":"%s","y":"%s"}`, jwk.X, jwk.Y) + + // SHA-256 hash + hash := sha256.Sum256([]byte(canonical)) + + // Base64url-encode without padding + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +// Signer returns the underlying ECDSA private key for DPoP token signing (Task 2). +func (k *Key) Signer() *ecdsa.PrivateKey { + return k.key +} diff --git a/tutorial/commercekey/commercekey_test.go b/tutorial/commercekey/commercekey_test.go new file mode 100644 index 0000000..1851046 --- /dev/null +++ b/tutorial/commercekey/commercekey_test.go @@ -0,0 +1,229 @@ +package commercekey_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" +) + +func TestLoad_FileAndEnvShadow(t *testing.T) { + // Generate a P-256 key, marshal PKCS#8 PEM, write to a temp file. + fileKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + fileKeyPKCS8, err := x509.MarshalPKCS8PrivateKey(fileKey) + if err != nil { + t.Fatalf("MarshalPKCS8PrivateKey: %v", err) + } + fileKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: fileKeyPKCS8}) + + tmpDir := t.TempDir() + keyPath := filepath.Join(tmpDir, "test-key.pem") + if err := os.WriteFile(keyPath, fileKeyPEM, 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + // Load from file (no env var set) + k, err := commercekey.Load(keyPath) + if err != nil { + t.Fatalf("Load(file): %v", err) + } + + jwk := k.PublicJWK() + if jwk.Kty != "EC" { + t.Errorf("PublicJWK().Kty = %q, want EC", jwk.Kty) + } + if jwk.Crv != "P-256" { + t.Errorf("PublicJWK().Crv = %q, want P-256", jwk.Crv) + } + if len(jwk.X) != 43 { + t.Errorf("PublicJWK().X length = %d, want 43", len(jwk.X)) + } + if len(jwk.Y) != 43 { + t.Errorf("PublicJWK().Y length = %d, want 43", len(jwk.Y)) + } + if strings.Contains(jwk.X, "=") { + t.Errorf("PublicJWK().X contains padding '=', want no padding") + } + if strings.Contains(jwk.Y, "=") { + t.Errorf("PublicJWK().Y contains padding '=', want no padding") + } + + // Generate a different key for the env var + envKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("GenerateKey (env): %v", err) + } + envKeyPKCS8, err := x509.MarshalPKCS8PrivateKey(envKey) + if err != nil { + t.Fatalf("MarshalPKCS8PrivateKey (env): %v", err) + } + envKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: envKeyPKCS8}) + + // Set COMMERCE_DPOP_KEY env var to the different key + t.Setenv("COMMERCE_DPOP_KEY", string(envKeyPEM)) + + // Load should now use env key, even with nonexistent path + kEnv, err := commercekey.Load("/no/such/path") + if err != nil { + t.Fatalf("Load(env): %v", err) + } + + // Verify the loaded key matches the env key, not the file key + envJwk := kEnv.PublicJWK() + // Compare the public key coordinates using elliptic.Marshal + envPubBytes := elliptic.Marshal(envKey.Curve, envKey.PublicKey.X, envKey.PublicKey.Y) //nolint:staticcheck // Test needs to verify correct key loaded + expectedEnvX := base64.RawURLEncoding.EncodeToString(envPubBytes[1:33]) + + if envJwk.X != expectedEnvX { + t.Errorf("env key X mismatch: got %q, want %q", envJwk.X, expectedEnvX) + } +} + +func TestJKT_CanonicalOrderAndLength(t *testing.T) { + // Use a fixed scalar to create a deterministic key + scalar := new(big.Int).SetBytes([]byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, + }) + + curve := elliptic.P256() + x, y := curve.ScalarBaseMult(scalar.Bytes()) //nolint:staticcheck // Test needs deterministic key generation + + privKey := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{ + Curve: curve, + X: x, + Y: y, + }, + D: scalar, + } + + // Marshal to PKCS#8 PEM and create a temp file + pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey) + if err != nil { + t.Fatalf("MarshalPKCS8PrivateKey: %v", err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}) + + tmpDir := t.TempDir() + keyPath := filepath.Join(tmpDir, "fixed-key.pem") + if err := os.WriteFile(keyPath, pemBytes, 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + k, err := commercekey.Load(keyPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + + jwk := k.PublicJWK() + jkt := k.JKT() + + // Compute JKT independently from PublicJWK().X / .Y + // RFC 7638 requires lexicographic order: crv, kty, x, y + canonical := fmt.Sprintf(`{"crv":"P-256","kty":"EC","x":"%s","y":"%s"}`, jwk.X, jwk.Y) + hash := sha256.Sum256([]byte(canonical)) + expectedJKT := base64.RawURLEncoding.EncodeToString(hash[:]) + + if jkt != expectedJKT { + t.Errorf("JKT() = %q, want %q", jkt, expectedJKT) + } + if len(jkt) != 43 { + t.Errorf("JKT() length = %d, want 43", len(jkt)) + } + if strings.Contains(jkt, "=") { + t.Errorf("JKT() contains padding '=', want no padding") + } +} + +func TestLoad_MissingKeyErrors(t *testing.T) { + // Ensure COMMERCE_DPOP_KEY is unset + t.Setenv("COMMERCE_DPOP_KEY", "") + + _, err := commercekey.Load("/nonexistent/k.pem") + if err == nil { + t.Error("Load(/nonexistent/k.pem) succeeded, want error") + } +} + +func TestLoad_InvalidPEM(t *testing.T) { + t.Setenv("COMMERCE_DPOP_KEY", "not a valid PEM") + + _, err := commercekey.Load("/no/such/path") + if err == nil { + t.Error("Load with invalid PEM succeeded, want error") + } + if !strings.Contains(err.Error(), "decode PEM") { + t.Errorf("error = %v, want 'decode PEM' error", err) + } +} + +func TestLoad_NotECDSAKey(t *testing.T) { + // Create an RSA key instead of ECDSA + tmpDir := t.TempDir() + keyPath := filepath.Join(tmpDir, "rsa-key.pem") + + // Write a simple invalid PKCS#8 structure (not a real key) + badPKCS8 := []byte{0x01, 0x02, 0x03, 0x04} + badPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: badPKCS8}) + if err := os.WriteFile(keyPath, badPEM, 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + _, err := commercekey.Load(keyPath) + if err == nil { + t.Error("Load with invalid PKCS#8 succeeded, want error") + } + if !strings.Contains(err.Error(), "parse PKCS#8") { + t.Errorf("error = %v, want 'parse PKCS#8' error", err) + } +} + +func TestSigner(t *testing.T) { + // Generate a key + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + keyPKCS8, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatalf("MarshalPKCS8PrivateKey: %v", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyPKCS8}) + + tmpDir := t.TempDir() + keyPath := filepath.Join(tmpDir, "test-key.pem") + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + k, err := commercekey.Load(keyPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + + signer := k.Signer() + if signer == nil { + t.Fatal("Signer() returned nil") + } + if signer.Curve != elliptic.P256() { + t.Errorf("Signer().Curve = %v, want P-256", signer.Curve) + } +} diff --git a/tutorial/config/config.go b/tutorial/config/config.go new file mode 100644 index 0000000..2c9c5c7 --- /dev/null +++ b/tutorial/config/config.go @@ -0,0 +1,85 @@ +// Package config holds the tutorial (commerce) configuration. It is separate +// from the core middleware config: the production binary never parses it. The +// demo binary embeds the generic app.Config and adds this Commerce block. +package config + +import ( + "fmt" + "os" + "time" + + "github.com/agentnameservice/ans-openshell/app" +) + +// FileConfig is the YAML shape the demo binary loads: the generic middleware +// app.Config inlined, plus the tutorial Commerce block. Both the demo binary +// (tutorial/cmd/ans-demo-middleware) and the example-config test use this type, +// so the two cannot drift from each other. +type FileConfig struct { + app.Config `yaml:",inline"` + Commerce Commerce `yaml:"commerce"` +} + +// Commerce configures the commerce capability transform (AP2 mandate + DPoP). +type Commerce struct { + // SellerMCPURL is the seller's MCP endpoint URL (used as DPoP htu). + SellerMCPURL string `yaml:"seller_mcp_url"` + // AuthorityMCPURL is the authority's MCP endpoint URL. + AuthorityMCPURL string `yaml:"authority_mcp_url"` + // SellerANS is the seller's ANS identifier. + SellerANS string `yaml:"seller_ans"` + // AuthorityANS is the authority's ANS identifier. + AuthorityANS string `yaml:"authority_ans"` + // ScopeHint is the expected scope prefix for mandates. + ScopeHint string `yaml:"scope_hint"` + // SubjectANS is the subject ANS identifier for mandate requests. + SubjectANS string `yaml:"subject_ans"` + // CommerceDPoPKeyPath is the path to the commerce DPoP key JSON file. + CommerceDPoPKeyPath string `yaml:"commerce_dpop_key_path"` + // DemoQuoteID is the quote ID for demo mandate prefetch. + DemoQuoteID string `yaml:"demo_quote_id"` + // DemoAmount is the amount for demo mandate prefetch. + DemoAmount float64 `yaml:"demo_amount"` + // DemoCurrency is the currency for demo mandate prefetch. + DemoCurrency string `yaml:"demo_currency"` + // MaxBodyBytes is the maximum body size after rewriting. + MaxBodyBytes int `yaml:"max_body_bytes"` + // DiscoveryTTL is the agent-card/tools-list discovery cache TTL; 0 means the default. + DiscoveryTTL time.Duration `yaml:"discovery_ttl"` +} + +// Enabled reports whether commerce is configured (a DPoP key is provided either +// in the file or via the COMMERCE_DPOP_KEY environment variable). +func (c Commerce) Enabled() bool { + return c.CommerceDPoPKeyPath != "" || os.Getenv("COMMERCE_DPOP_KEY") != "" +} + +// Validate checks that all required commerce fields are present when enabled. +func (c Commerce) Validate() error { + if !c.Enabled() { + return nil + } + var missing []string + if c.SellerMCPURL == "" { + missing = append(missing, "seller_mcp_url") + } + if c.AuthorityMCPURL == "" { + missing = append(missing, "authority_mcp_url") + } + if c.SellerANS == "" { + missing = append(missing, "seller_ans") + } + if c.AuthorityANS == "" { + missing = append(missing, "authority_ans") + } + if c.ScopeHint == "" { + missing = append(missing, "scope_hint") + } + if c.SubjectANS == "" { + missing = append(missing, "subject_ans") + } + if len(missing) > 0 { + return fmt.Errorf("commerce: missing %v", missing) + } + return nil +} diff --git a/tutorial/config/config_test.go b/tutorial/config/config_test.go new file mode 100644 index 0000000..6c26b55 --- /dev/null +++ b/tutorial/config/config_test.go @@ -0,0 +1,143 @@ +package config + +import ( + "testing" + "time" + + coreconfig "github.com/agentnameservice/ans-openshell/internal/config" +) + +// parse unmarshals YAML into dst using the core config loader's strict parser. +func parse(t *testing.T, yml string, dst any) { + t.Helper() + if err := coreconfig.Parse([]byte(yml), dst); err != nil { + t.Fatalf("unmarshal: %v", err) + } +} + +func TestCommerceConfig_ParseAndValidate(t *testing.T) { + t.Run("all fields round-trip", func(t *testing.T) { + yml := ` +seller_mcp_url: https://seller.example.com/mcp/ +authority_mcp_url: https://authority.example.com/mcp/ +seller_ans: ans://v1.0.8.seller.skybound-travel.com +authority_ans: ans://v1.0.8.authority.skybound-travel.com +scope_hint: purchase:flight:demo +subject_ans: ans://v2.0.0.buyer.example.com +commerce_dpop_key_path: /path/to/key.json +demo_quote_id: demo-quote-001 +demo_amount: 500 +demo_currency: USD +max_body_bytes: 10000 +` + var c Commerce + parse(t, yml, &c) + + if c.SellerMCPURL != "https://seller.example.com/mcp/" { + t.Errorf("SellerMCPURL = %q, want https://seller.example.com/mcp/", c.SellerMCPURL) + } + if c.AuthorityMCPURL != "https://authority.example.com/mcp/" { + t.Errorf("AuthorityMCPURL = %q, want https://authority.example.com/mcp/", c.AuthorityMCPURL) + } + if c.SellerANS != "ans://v1.0.8.seller.skybound-travel.com" { + t.Errorf("SellerANS = %q", c.SellerANS) + } + if c.AuthorityANS != "ans://v1.0.8.authority.skybound-travel.com" { + t.Errorf("AuthorityANS = %q", c.AuthorityANS) + } + if c.ScopeHint != "purchase:flight:demo" { + t.Errorf("ScopeHint = %q", c.ScopeHint) + } + if c.SubjectANS != "ans://v2.0.0.buyer.example.com" { + t.Errorf("SubjectANS = %q", c.SubjectANS) + } + if c.CommerceDPoPKeyPath != "/path/to/key.json" { + t.Errorf("CommerceDPoPKeyPath = %q", c.CommerceDPoPKeyPath) + } + if c.DemoQuoteID != "demo-quote-001" { + t.Errorf("DemoQuoteID = %q", c.DemoQuoteID) + } + if c.DemoAmount != 500 { + t.Errorf("DemoAmount = %v, want 500", c.DemoAmount) + } + if c.DemoCurrency != "USD" { + t.Errorf("DemoCurrency = %q", c.DemoCurrency) + } + if c.MaxBodyBytes != 10000 { + t.Errorf("MaxBodyBytes = %d, want 10000", c.MaxBodyBytes) + } + }) + + t.Run("Enabled reflects key presence", func(t *testing.T) { + c := Commerce{} + if c.Enabled() { + t.Errorf("Enabled() = true for empty config") + } + + c.CommerceDPoPKeyPath = "/path/to/key.json" + if !c.Enabled() { + t.Errorf("Enabled() = false with key path set") + } + + c.CommerceDPoPKeyPath = "" + t.Setenv("COMMERCE_DPOP_KEY", "/env/key.json") + if !c.Enabled() { + t.Errorf("Enabled() = false with COMMERCE_DPOP_KEY env set") + } + }) + + t.Run("Validate requires fields when Enabled", func(t *testing.T) { + c := Commerce{} + if err := c.Validate(); err != nil { + t.Errorf("Validate() = %v for disabled config", err) + } + + c.CommerceDPoPKeyPath = "/path/to/key.json" + if err := c.Validate(); err == nil { + t.Errorf("Validate() = nil, want error for missing URLs/ANS") + } + + c.SellerMCPURL = "https://seller.example.com/mcp/" + c.AuthorityMCPURL = "https://authority.example.com/mcp/" + c.SellerANS = "ans://v1.0.8.seller.skybound-travel.com" + c.AuthorityANS = "ans://v1.0.8.authority.skybound-travel.com" + c.ScopeHint = "purchase:flight:demo" + c.SubjectANS = "ans://v2.0.0.buyer.example.com" + if err := c.Validate(); err != nil { + t.Errorf("Validate() = %v for complete config", err) + } + }) + + t.Run("DiscoveryTTL field parses", func(t *testing.T) { + yml := ` +seller_mcp_url: https://seller.example.com/mcp/ +authority_mcp_url: https://authority.example.com/mcp/ +seller_ans: ans://v1.0.8.seller.skybound-travel.com +authority_ans: ans://v1.0.8.authority.skybound-travel.com +scope_hint: purchase:flight:demo +subject_ans: ans://v2.0.0.buyer.example.com +commerce_dpop_key_path: /path/to/key.json +discovery_ttl: 5m +` + var c Commerce + parse(t, yml, &c) + if c.DiscoveryTTL != 5*time.Minute { + t.Errorf("DiscoveryTTL = %v, want 5m", c.DiscoveryTTL) + } + + yml2 := ` +seller_mcp_url: https://seller.example.com/mcp/ +authority_mcp_url: https://authority.example.com/mcp/ +seller_ans: ans://v1.0.8.seller.skybound-travel.com +authority_ans: ans://v1.0.8.authority.skybound-travel.com +scope_hint: purchase:flight:demo +subject_ans: ans://v2.0.0.buyer.example.com +commerce_dpop_key_path: /path/to/key.json +` + var c2 Commerce + parse(t, yml2, &c2) + if c2.DiscoveryTTL != 0 { + t.Errorf("DiscoveryTTL = %v, want 0 when omitted", c2.DiscoveryTTL) + } + }) +} diff --git a/tutorial/deploy/examples_test.go b/tutorial/deploy/examples_test.go new file mode 100644 index 0000000..c58b787 --- /dev/null +++ b/tutorial/deploy/examples_test.go @@ -0,0 +1,368 @@ +package deploy + +import ( + "encoding/json" + "os/exec" + "strings" + "testing" + + "github.com/agentnameservice/ans-openshell/internal/config" + "github.com/agentnameservice/ans-openshell/internal/identity" + tutorialcfg "github.com/agentnameservice/ans-openshell/tutorial/config" +) + +// examplesDir is the path to the demo example configs, which live in the core +// repo's deploy/examples directory (this test lives in the tutorial module). +const examplesDir = "../../deploy/examples/" + +// loadMiddlewareFile loads a middleware YAML config into the exact type the demo +// binary loads (tutorialcfg.FileConfig), so this test exercises the real +// config-loading path rather than a hand-maintained mirror. +func loadMiddlewareFile(t *testing.T, path string) tutorialcfg.FileConfig { + t.Helper() + var cfg tutorialcfg.FileConfig + if err := config.Load(path, &cfg); err != nil { + t.Fatalf("failed to load %s: %v", path, err) + } + return cfg +} + +func TestExampleConfig_AgentA(t *testing.T) { + cfg := loadMiddlewareFile(t, examplesDir+"middleware-agent-a.yaml") + + if cfg.Listen != ":50051" { + t.Errorf("Listen = %q, want :50051", cfg.Listen) + } + + if cfg.MaxPayloadBytes != 262144 { + t.Errorf("MaxPayloadBytes = %d, want 262144", cfg.MaxPayloadBytes) + } + + if len(cfg.Identities) != 1 { + t.Fatalf("expected 1 identity, got %d", len(cfg.Identities)) + } + + var ident identity.Config + for _, id := range cfg.Identities { + ident = id + break + } + + if ident.DevMode { + t.Errorf("identity DevMode = true, want false") + } + + if !strings.Contains(ident.KeyFile, "/.secrets/") { + t.Errorf("identity KeyFile = %q, want path containing /.secrets/", ident.KeyFile) + } + + if !strings.Contains(ident.CertFile, "/.secrets/") { + t.Errorf("identity CertFile = %q, want path containing /.secrets/", ident.CertFile) + } + + if cfg.Commerce.CommerceDPoPKeyPath == "" { + t.Error("Commerce.CommerceDPoPKeyPath is empty, want configured") + } + + wantSellerURL := "https://seller.skybound-travel.com/mcp/" + if cfg.Commerce.SellerMCPURL != wantSellerURL { + t.Errorf("Commerce.SellerMCPURL = %q, want %q (exact, with trailing slash)", cfg.Commerce.SellerMCPURL, wantSellerURL) + } + + if cfg.Commerce.SellerANS == "" { + t.Error("Commerce.SellerANS is empty, want configured") + } + + if cfg.Commerce.AuthorityANS == "" { + t.Error("Commerce.AuthorityANS is empty, want configured") + } +} + +func TestExampleConfig_AgentB(t *testing.T) { + cfg := loadMiddlewareFile(t, examplesDir+"middleware-agent-b.yaml") + + if cfg.Listen != ":50052" { + t.Errorf("Listen = %q, want :50052", cfg.Listen) + } + + if cfg.MaxPayloadBytes != 262144 { + t.Errorf("MaxPayloadBytes = %d, want 262144", cfg.MaxPayloadBytes) + } + + if len(cfg.Identities) != 1 { + t.Fatalf("expected 1 identity, got %d", len(cfg.Identities)) + } + + var ident identity.Config + for _, id := range cfg.Identities { + ident = id + break + } + + if !ident.DevMode { + t.Errorf("identity DevMode = false, want true") + } + + wantANS := "ans://v9.9.9.rogue.example" + if ident.AnsName != wantANS { + t.Errorf("identity AnsName = %q, want %q", ident.AnsName, wantANS) + } + + if cfg.Commerce.SellerMCPURL != "" { + t.Errorf("Commerce.SellerMCPURL = %q, want empty (no commerce)", cfg.Commerce.SellerMCPURL) + } + + if cfg.Commerce.CommerceDPoPKeyPath != "" { + t.Errorf("Commerce.CommerceDPoPKeyPath = %q, want empty (no commerce)", cfg.Commerce.CommerceDPoPKeyPath) + } +} + +func TestDemoWiring_NamesAndHostsAgree(t *testing.T) { + // Parse the two sandbox policies + policyA := loadPolicyFile(t, examplesDir+"policy-a.yaml") + policyB := loadPolicyFile(t, examplesDir+"policy-b.yaml") + + // Parse the Tier 2 gateway registration + gatewayMiddlewares := loadGatewayTier2(t, examplesDir+"gateway-tier2.toml") + + // (G2) Check that policy-a network_middlewares key AND its .middleware value == "ans-egress-a" + // and that it matches a gateway middleware name + checkPolicyMiddlewareName(t, policyA, "ans-egress-a", gatewayMiddlewares) + + // (G2) Same for policy-b with "ans-egress-b" + checkPolicyMiddlewareName(t, policyB, "ans-egress-b", gatewayMiddlewares) + + // (G3) Check that every gateway supervisor-middleware entry has max_payload_bytes == 262144 + for _, mw := range gatewayMiddlewares { + if mw.MaxPayloadBytes != 262144 { + t.Errorf("gateway middleware %q has MaxPayloadBytes = %d, want 262144", mw.Name, mw.MaxPayloadBytes) + } + } + + // (G6) Check that policy endpoint hosts and gateway grpc_endpoint contain "" + // and NEVER contain "127.0.0.1" or "host.docker.internal" + checkHostPlaceholder(t, policyA, "policy-a") + checkHostPlaceholder(t, policyB, "policy-b") + for _, mw := range gatewayMiddlewares { + if !strings.Contains(mw.GRPCEndpoint, "") { + t.Errorf("gateway middleware %q grpc_endpoint = %q, want to contain ", mw.Name, mw.GRPCEndpoint) + } + if strings.Contains(mw.GRPCEndpoint, "127.0.0.1") || strings.Contains(mw.GRPCEndpoint, "host.docker.internal") { + t.Errorf("gateway middleware %q grpc_endpoint = %q, must not contain 127.0.0.1 or host.docker.internal", mw.Name, mw.GRPCEndpoint) + } + } + + // (G9) Check that each policy has version, filesystem_policy, landlock, and a network policy with name+endpoints+binaries + checkPolicyStructure(t, policyA, "policy-a") + checkPolicyStructure(t, policyB, "policy-b") + + // (G1) Check that policy-a routes to the ingress host:50060 (the ingress bind), NOT directly to the seller + checkIngressPort(t, policyA, "policy-a", 50060) + checkIngressPort(t, policyB, "policy-b", 50060) +} + +// policyFile represents the sandbox policy YAML structure +type policyFile struct { + Version int `yaml:"version"` + FilesystemPolicy *filesystemPolicy `yaml:"filesystem_policy"` + Landlock *landlockConfig `yaml:"landlock"` + NetworkPolicies map[string]networkPolicy `yaml:"network_policies"` + NetworkMiddlewares map[string]networkMiddlewareEntry `yaml:"network_middlewares"` +} + +type filesystemPolicy struct { + IncludeWorkdir bool `yaml:"include_workdir"` + ReadOnly []string `yaml:"read_only"` + ReadWrite []string `yaml:"read_write"` +} + +type landlockConfig struct { + Compatibility string `yaml:"compatibility"` +} + +type networkPolicy struct { + Name string `yaml:"name"` + Endpoints []networkEndpoint `yaml:"endpoints"` + Binaries []binaryEntry `yaml:"binaries"` +} + +type networkEndpoint struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Protocol string `yaml:"protocol"` + Enforcement string `yaml:"enforcement"` + Access string `yaml:"access"` +} + +type binaryEntry struct { + Path string `yaml:"path"` +} + +type networkMiddlewareEntry struct { + Name string `yaml:"name"` + Middleware string `yaml:"middleware"` + Order int `yaml:"order"` + Config map[string]any `yaml:"config"` + OnError string `yaml:"on_error"` + Endpoints middlewareEndpts `yaml:"endpoints"` +} + +type middlewareEndpts struct { + Include []string `yaml:"include"` +} + +// gatewayMiddleware represents a gateway supervisor middleware entry +type gatewayMiddleware struct { + Name string `json:"name"` + GRPCEndpoint string `json:"grpc_endpoint"` + MaxPayloadBytes int `json:"max_payload_bytes"` +} + +func loadPolicyFile(t *testing.T, path string) policyFile { + t.Helper() + var policy policyFile + if err := config.Load(path, &policy); err != nil { + t.Fatalf("failed to load %s: %v", path, err) + } + return policy +} + +func loadGatewayTier2(t *testing.T, path string) []gatewayMiddleware { + t.Helper() + // Use Python tomllib to parse the gateway TOML, similar to deployment_test.go + const parse = `import json, sys, tomllib +with open(sys.argv[1], "rb") as source: + json.dump(tomllib.load(source)["openshell"]["supervisor"]["middleware"], sys.stdout) +` + data, err := exec.CommandContext(t.Context(), "python3", "-c", parse, path).CombinedOutput() + if err != nil { + t.Fatalf("parse gateway TOML with Python 3.11+: %v\n%s", err, data) + } + var middlewares []gatewayMiddleware + if err := json.Unmarshal(data, &middlewares); err != nil { + t.Fatalf("unmarshal gateway middlewares: %v", err) + } + return middlewares +} + +func checkPolicyMiddlewareName(t *testing.T, policy policyFile, expectedName string, gatewayMiddlewares []gatewayMiddleware) { + t.Helper() + + // Check that the network_middlewares map has the expected key + mw, ok := policy.NetworkMiddlewares[expectedName] + if !ok { + t.Errorf("policy network_middlewares missing key %q", expectedName) + return + } + + // Check that the middleware name field matches + if mw.Name != expectedName { + t.Errorf("policy network_middleware name = %q, want %q", mw.Name, expectedName) + } + + // Check that the middleware field matches + if mw.Middleware != expectedName { + t.Errorf("policy network_middleware middleware = %q, want %q", mw.Middleware, expectedName) + } + + // Check that a gateway middleware with this name exists + found := false + for _, gmw := range gatewayMiddlewares { + if gmw.Name == expectedName { + found = true + break + } + } + if !found { + t.Errorf("gateway middlewares missing entry with name %q", expectedName) + } +} + +func checkHostPlaceholder(t *testing.T, policy policyFile, policyName string) { + t.Helper() + + // Check network policy endpoints + for _, np := range policy.NetworkPolicies { + for _, ep := range np.Endpoints { + if !strings.Contains(ep.Host, "") { + t.Errorf("%s network endpoint host = %q, want to contain ", policyName, ep.Host) + } + if strings.Contains(ep.Host, "127.0.0.1") || strings.Contains(ep.Host, "host.docker.internal") { + t.Errorf("%s network endpoint host = %q, must not contain 127.0.0.1 or host.docker.internal", policyName, ep.Host) + } + } + } + + // Check network middleware endpoints + for _, mw := range policy.NetworkMiddlewares { + for _, host := range mw.Endpoints.Include { + if !strings.Contains(host, "") { + t.Errorf("%s network middleware endpoint = %q, want to contain ", policyName, host) + } + if strings.Contains(host, "127.0.0.1") || strings.Contains(host, "host.docker.internal") { + t.Errorf("%s network middleware endpoint = %q, must not contain 127.0.0.1 or host.docker.internal", policyName, host) + } + } + } +} + +func checkPolicyStructure(t *testing.T, policy policyFile, policyName string) { + t.Helper() + + // Check version exists and is non-zero + if policy.Version == 0 { + t.Errorf("%s missing version field", policyName) + } + + // Check filesystem_policy exists + if policy.FilesystemPolicy == nil { + t.Errorf("%s missing filesystem_policy", policyName) + } + + // Check landlock exists + if policy.Landlock == nil { + t.Errorf("%s missing landlock", policyName) + } + + // Check that at least one network policy exists with name, endpoints, and binaries + if len(policy.NetworkPolicies) == 0 { + t.Errorf("%s missing network_policies", policyName) + return + } + + for key, np := range policy.NetworkPolicies { + if np.Name == "" { + t.Errorf("%s network_policies[%q] missing name", policyName, key) + } + if len(np.Endpoints) == 0 { + t.Errorf("%s network_policies[%q] missing endpoints", policyName, key) + } + if len(np.Binaries) == 0 { + t.Errorf("%s network_policies[%q] missing binaries", policyName, key) + } + } +} + +func checkIngressPort(t *testing.T, policy policyFile, policyName string, expectedPort int) { + t.Helper() + + // Look for the ingress network policy + ingress, ok := policy.NetworkPolicies["ingress"] + if !ok { + t.Errorf("%s missing 'ingress' network policy", policyName) + return + } + + // Check that at least one endpoint has the expected port + foundPort := false + for _, ep := range ingress.Endpoints { + if ep.Port == expectedPort { + foundPort = true + break + } + } + + if !foundPort { + t.Errorf("%s ingress policy does not have an endpoint on port %d", policyName, expectedPort) + } +} diff --git a/tutorial/go.mod b/tutorial/go.mod new file mode 100644 index 0000000..3af9860 --- /dev/null +++ b/tutorial/go.mod @@ -0,0 +1,5 @@ +module github.com/agentnameservice/ans-openshell/tutorial + +go 1.26.0 + +require github.com/agentnameservice/ans-openshell v0.0.0 diff --git a/tutorial/transform/transform.go b/tutorial/transform/transform.go new file mode 100644 index 0000000..7e1317e --- /dev/null +++ b/tutorial/transform/transform.go @@ -0,0 +1,139 @@ +package transform + +import ( + "context" + "log/slog" + "net/http" + "net/url" + + "github.com/agentnameservice/ans-openshell/internal/capability" + "github.com/agentnameservice/ans-openshell/tutorial/commerce" +) + +// commerceRewriter implements capability.Transform with discovery-aware + lenient fallback logic. +type commerceRewriter struct { + deps commerce.Deps + cfg commerce.Config + sellerHost string + logger *slog.Logger +} + +// NewCommerceRewriter creates a capability.Transform that applies commerce when +// (a) the requirement needs mandate + dpop_proof (discovery-driven), OR +// (b) the requirement is empty AND the static gate matches (lenient fallback). +// sellerHost is derived from cfg.SellerMCPURL's host. +func NewCommerceRewriter(deps commerce.Deps, cfg commerce.Config, sellerHost string, logger *slog.Logger) capability.Transform { + return &commerceRewriter{deps: deps, cfg: cfg, sellerHost: sellerHost, logger: logger} +} + +// Provides implements capability.Transform. +func (r *commerceRewriter) Provides() []capability.Capability { + // Commerce rewriter provides mandate and dpop_proof capabilities + return []capability.Capability{"mandate", "dpop_proof"} +} + +// Apply implements capability.Transform. +func (r *commerceRewriter) Apply(ctx context.Context, target capability.Target, req capability.Requirement, body []byte) capability.Outcome { + // Compute static gate match + static := target.Host == r.sellerHost && + target.Method == http.MethodPost && + (target.Path == "/mcp/" || target.Path == "/a2a" || target.Path == "/a2a/") + + // Two-trigger logic (lenient): + // (a) Discovery: req needs mandate + dpop_proof + // (b) Fallback: req is empty AND static gate matches + discoveryMatch := req.Needs("mandate") && req.Needs("dpop_proof") + fallbackMatch := req.Empty() && static + + if !discoveryMatch && !fallbackMatch { + return capability.Outcome{Kind: capability.Passthrough} + } + + // WARN-log on fallback trigger + if fallbackMatch && !discoveryMatch { + r.logger.WarnContext(ctx, "commerce static fallback: discovery returned empty, using static host/path gate", + "host", target.Host, "path", target.Path) + } + + // Extract discovered htu if present + var htuOverride string + if req.DPoP != nil && req.DPoP.Htu != "" { + htuOverride = req.DPoP.Htu + } + + // Delegate to commerce.RewriteBookFlight with discovered htu + outcome := commerce.RewriteBookFlight(ctx, body, r.deps, r.cfg, htuOverride) + out := capability.Outcome{ + Kind: capabilityKind(outcome.Kind), + Body: outcome.Body, + Reason: outcome.Reason, + SignURL: outcome.SignURL, + } + // Stash the attached artifacts for observability (narration only). The core + // treats Artifacts as opaque; r.Narrate decodes them. + if outcome.Kind == commerce.Allow { + out.Artifacts = map[string]any{ + artifactMandateBytes: outcome.MandateBytes, + artifactDPoPProof: outcome.DPoPProof, + } + } + return out +} + +// capabilityKind maps a commerce.OutcomeKind to the capability.OutcomeKind the +// core understands. It is an explicit switch rather than a numeric conversion +// (capability.OutcomeKind(k)) so the two enums are not assumed to share integer +// values: reordering either one is caught by TestCapabilityKind rather than +// silently swapping Deny and Allow. An unknown kind fails closed to Deny. +func capabilityKind(k commerce.OutcomeKind) capability.OutcomeKind { + switch k { + case commerce.Passthrough: + return capability.Passthrough + case commerce.Allow: + return capability.Allow + case commerce.Deny: + return capability.Deny + default: + return capability.Deny + } +} + +// Artifact keys stashed in capability.Outcome.Artifacts for narration. +const ( + artifactMandateBytes = "mandate_bytes" + artifactDPoPProof = "dpop_proof" +) + +// Narrate implements capability.Narratable: at debug level, it decodes and logs +// the DPoP proof claims and mandate summary this transform attached. It is +// observability only and never affects the request. +func (r *commerceRewriter) Narrate(ctx context.Context, out capability.Outcome, log *slog.Logger) { + if !log.Enabled(ctx, slog.LevelDebug) || out.Artifacts == nil { + return + } + if proof, ok := out.Artifacts[artifactDPoPProof].(string); ok && proof != "" { + if c, err := commerce.DecodeDPoPClaims(proof); err == nil { + log.DebugContext(ctx, "dpop proof minted", + "htm", c.Htm, "htu", c.Htu, "ath", c.Ath, "iat", c.Iat, "jti", c.Jti) + } + } + if mb, ok := out.Artifacts[artifactMandateBytes].([]byte); ok && len(mb) > 0 { + if m, err := commerce.SummarizeMandate(mb); err == nil { + log.DebugContext(ctx, "mandate attached", + "mandate_id", m.MandateID, "scope", m.Scope, + "max_amount", m.MaxAmount, "currency", m.Currency, + "jkt", m.JKT, "subject_ans", m.SubjectANS, + "authority_ans", m.AuthorityANS, + "not_before", m.NotBefore, "not_after", m.NotAfter) + } + } +} + +// ParseSellerHost extracts the host from the seller MCP URL. +func ParseSellerHost(sellerMCPURL string) (string, error) { + u, err := url.Parse(sellerMCPURL) + if err != nil { + return "", err + } + return u.Host, nil +} diff --git a/tutorial/transform/transform_test.go b/tutorial/transform/transform_test.go new file mode 100644 index 0000000..f6c0e10 --- /dev/null +++ b/tutorial/transform/transform_test.go @@ -0,0 +1,574 @@ +package transform + +import ( + "context" + "encoding/base64" + "encoding/json" + "log/slog" + "os" + "strings" + "testing" + + "github.com/agentnameservice/ans-openshell/internal/capability" + "github.com/agentnameservice/ans-openshell/tutorial/commerce" + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" +) + +// panicAuthority panics if RequestMandate is called, proving the gate skipped. +type panicAuthority struct{} + +func (panicAuthority) RequestMandate(_ context.Context, _ commerce.MandateInput) (*commerce.Mandate, json.RawMessage, error) { + panic("RequestMandate called when gate should have skipped") +} + +type panicClock struct{} + +func (panicClock) NowUnix() int64 { panic("NowUnix called") } + +type panicNonce struct{} + +func (panicNonce) JTI() string { panic("JTI called") } + +func TestCommerceRewriter_GateMatches(t *testing.T) { + t.Parallel() + // Build a minimal commerce transform that would delegate to RewriteBookFlight. + // We'll test the gate logic by checking whether it reaches the commerce logic + // (which will Passthrough because the body doesn't match book_flight, proving gate opened). + deps := commerce.Deps{ + Key: nil, // Won't be called for non-book_flight bodies + Cache: commerce.NewMandateCache(), + Authority: panicAuthority{}, + Clock: panicClock{}, + Nonce: &panicNonce{}, + } + cfg := commerce.Config{ + SellerMCPURL: "https://seller.example.com/mcp/", + SellerANS: "ans://v1.0.8.seller.skybound-travel.com", + ScopePrefix: "purchase:flight:demo", + SubjectANS: "ans://v2.0.0.buyer.example.com", + MaxBodyBytes: 10000, + } + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com", logger) + + tests := []struct { + name string + host string + method string + path string + }{ + {name: "POST /mcp/", host: "seller.example.com", method: "POST", path: "/mcp/"}, + {name: "POST /a2a", host: "seller.example.com", method: "POST", path: "/a2a"}, + {name: "POST /a2a/", host: "seller.example.com", method: "POST", path: "/a2a/"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Non-book_flight body → commerce.RewriteBookFlight returns Passthrough + body := []byte(`{"method":"other","params":{}}`) + target := makeTarget(tt.host, tt.method, tt.path) + outcome := rewriter.Apply(context.Background(), target, emptyRequirement(), body) + // Gate matched (didn't panic from panicAuthority), commerce processed it → Passthrough + if outcome.Kind != 0 { // Passthrough = 0 + t.Errorf("outcome.Kind = %v, want Passthrough (0)", outcome.Kind) + } + }) + } +} + +func makeTarget(host, method, path string) capability.Target { + return capability.Target{Host: host, Method: method, Path: path, Tool: ""} +} + +func emptyRequirement() capability.Requirement { + return capability.Requirement{} +} + +func TestCommerceRewriter_GateSkips(t *testing.T) { + t.Parallel() + // Build a commerce transform with panic-on-call authority to prove the gate skips. + deps := commerce.Deps{ + Key: nil, + Cache: commerce.NewMandateCache(), + Authority: panicAuthority{}, // Will panic if called + Clock: panicClock{}, + Nonce: &panicNonce{}, + } + cfg := commerce.Config{ + SellerMCPURL: "https://seller.example.com/mcp/", + SellerANS: "ans://v1.0.8.seller.skybound-travel.com", + ScopePrefix: "purchase:flight:demo", + SubjectANS: "ans://v2.0.0.buyer.example.com", + MaxBodyBytes: 10000, + } + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com", logger) + + tests := []struct { + name string + host string + method string + path string + }{ + {name: "wrong host", host: "other.example.com", method: "POST", path: "/mcp/"}, + {name: "GET method", host: "seller.example.com", method: "GET", path: "/mcp/"}, + {name: "wrong path", host: "seller.example.com", method: "POST", path: "/other"}, + {name: "path without trailing slash", host: "seller.example.com", method: "POST", path: "/mcp"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := []byte(`{"method":"tools/call","params":{"name":"book_flight"}}`) + target := makeTarget(tt.host, tt.method, tt.path) + outcome := rewriter.Apply(context.Background(), target, emptyRequirement(), body) + // Should return Passthrough without calling RewriteBookFlight (which would panic via authority) + if outcome.Kind != 0 { // Passthrough = 0 + t.Errorf("outcome.Kind = %v, want Passthrough (0)", outcome.Kind) + } + if len(outcome.Body) != 0 { + t.Errorf("outcome.Body should be empty on Passthrough, got %d bytes", len(outcome.Body)) + } + }) + } +} + +func TestCommerce_AppliesViaDiscoveredRequirement(t *testing.T) { + t.Parallel() + // Build a commerce transform with a fake authority that returns a mandate. + // Test that discovery triggers commerce even when host != seller. + key := loadCommerceTestKey(t) + mandate := &commerce.Mandate{ + MandateID: "mandate-001", + QuoteID: "demo-quote-001", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.skybound-travel.com", + Scope: "commerce.payment:write", + MaxAmount: 1000.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-12-31T23:59:59Z", + JKT: key.JKT(), + AuthorityANS: "ans://authority.example.com", + Signature: "fake-signature", + } + fakeAuth := &fakeAuthorityForCommerce{mandate: mandate} + clock := fakeClockForCommerce{now: 1735689600} + nonce := &fakeNonceForCommerce{jti: "test-jti-123"} + + deps := commerce.Deps{ + Key: key, + Cache: commerce.NewMandateCache(), + Authority: fakeAuth, + Clock: clock, + Nonce: nonce, + } + cfg := commerce.Config{ + SellerMCPURL: "https://seller.example.com/mcp/", + SellerANS: "ans://seller.skybound-travel.com", + ScopePrefix: "commerce.", + SubjectANS: "ans://traveler.example.com", + MaxBodyBytes: 10000, + } + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com", logger) + + // Discovery says: needs mandate + dpop_proof. Host is NOT the seller. + req := capability.Requirement{ + Capabilities: []capability.Capability{"mandate", "dpop_proof"}, + } + body := []byte(`{"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500,"currency":"USD"}}}`) + target := makeTarget("other-agent.example.com", "POST", "/mcp/") + + outcome := rewriter.Apply(context.Background(), target, req, body) + + // Should Allow (commerce ran and spliced mandate/dpop) + if outcome.Kind != capability.Allow { + t.Fatalf("outcome.Kind = %v, want Allow", outcome.Kind) + } + if len(outcome.Body) == 0 { + t.Fatal("expected body for Allow, got none") + } + // The Allow must surface the attached artifacts (for debug narration). + if mb, ok := outcome.Artifacts[artifactMandateBytes].([]byte); !ok || len(mb) == 0 { + t.Error("expected mandate bytes in Artifacts on Allow, got none") + } + if proof, ok := outcome.Artifacts[artifactDPoPProof].(string); !ok || proof == "" { + t.Error("expected dpop proof in Artifacts on Allow, got empty") + } +} + +func TestCommerce_AppliesViaStaticFallbackWhenEmpty(t *testing.T) { + t.Parallel() + // Build a commerce transform with a fake authority. + // Test that empty req + static gate → runs + WARN-log. + key := loadCommerceTestKey(t) + mandate := &commerce.Mandate{ + MandateID: "mandate-001", + QuoteID: "demo-quote-001", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.skybound-travel.com", + Scope: "commerce.payment:write", + MaxAmount: 1000.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-12-31T23:59:59Z", + JKT: key.JKT(), + AuthorityANS: "ans://authority.example.com", + Signature: "fake-signature", + } + fakeAuth := &fakeAuthorityForCommerce{mandate: mandate} + clock := fakeClockForCommerce{now: 1735689600} + nonce := &fakeNonceForCommerce{jti: "test-jti-123"} + + deps := commerce.Deps{ + Key: key, + Cache: commerce.NewMandateCache(), + Authority: fakeAuth, + Clock: clock, + Nonce: nonce, + } + cfg := commerce.Config{ + SellerMCPURL: "https://seller.example.com/mcp/", + SellerANS: "ans://seller.skybound-travel.com", + ScopePrefix: "commerce.", + SubjectANS: "ans://traveler.example.com", + MaxBodyBytes: 10000, + } + + // Capture logs + var logBuf logBuffer + logger := slog.New(slog.NewTextHandler(&logBuf, nil)) + rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com", logger) + + // Empty req + static gate match + req := capability.Requirement{} + body := []byte(`{"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500,"currency":"USD"}}}`) + target := makeTarget("seller.example.com", "POST", "/mcp/") + + outcome := rewriter.Apply(context.Background(), target, req, body) + + // Should Allow (commerce ran) + if outcome.Kind != capability.Allow { + t.Fatalf("outcome.Kind = %v, want Allow", outcome.Kind) + } + if len(outcome.Body) == 0 { + t.Fatal("expected body for Allow, got none") + } + + // Check that a WARN was logged + if !logBuf.ContainsLevel("WARN") { + t.Errorf("expected WARN log for static fallback, got: %s", logBuf.String()) + } + if !logBuf.Contains("commerce static fallback") { + t.Errorf("expected log to mention 'commerce static fallback', got: %s", logBuf.String()) + } +} + +func TestCommerce_PassthroughWhenEmptyAndNotSellerGate(t *testing.T) { + t.Parallel() + // Empty req + non-seller host → Passthrough, deps never called. + deps := commerce.Deps{ + Key: nil, + Cache: commerce.NewMandateCache(), + Authority: panicAuthority{}, // Will panic if called + Clock: panicClock{}, + Nonce: &panicNonce{}, + } + cfg := commerce.Config{ + SellerMCPURL: "https://seller.example.com/mcp/", + SellerANS: "ans://seller.skybound-travel.com", + ScopePrefix: "commerce.", + SubjectANS: "ans://traveler.example.com", + MaxBodyBytes: 10000, + } + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com", logger) + + // Empty req + non-seller host + req := capability.Requirement{} + body := []byte(`{"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500,"currency":"USD"}}}`) + target := makeTarget("other-agent.example.com", "POST", "/mcp/") + + outcome := rewriter.Apply(context.Background(), target, req, body) + + // Should Passthrough (gate skipped, no panic) + if outcome.Kind != capability.Passthrough { + t.Fatalf("outcome.Kind = %v, want Passthrough", outcome.Kind) + } + if len(outcome.Body) != 0 { + t.Errorf("expected no body for Passthrough, got %d bytes", len(outcome.Body)) + } +} + +func TestCommerce_UsesDiscoveredHtu(t *testing.T) { + t.Parallel() + // req.DPoP.Htu set → minted dpop_proof htu == discovered, not static. + key := loadCommerceTestKey(t) + mandate := &commerce.Mandate{ + MandateID: "mandate-001", + QuoteID: "demo-quote-001", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.skybound-travel.com", + Scope: "commerce.payment:write", + MaxAmount: 1000.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-12-31T23:59:59Z", + JKT: key.JKT(), + AuthorityANS: "ans://authority.example.com", + Signature: "fake-signature", + } + fakeAuth := &fakeAuthorityForCommerce{mandate: mandate} + clock := fakeClockForCommerce{now: 1735689600} + nonce := &fakeNonceForCommerce{jti: "test-jti-123"} + + deps := commerce.Deps{ + Key: key, + Cache: commerce.NewMandateCache(), + Authority: fakeAuth, + Clock: clock, + Nonce: nonce, + } + cfg := commerce.Config{ + SellerMCPURL: "https://seller.example.com/mcp/", + SellerANS: "ans://seller.skybound-travel.com", + ScopePrefix: "commerce.", + SubjectANS: "ans://traveler.example.com", + MaxBodyBytes: 10000, + } + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com", logger) + + // Discovery says: use this htu + discoveredHtu := "https://other-seller.example.com/mcp/" + req := capability.Requirement{ + Capabilities: []capability.Capability{"mandate", "dpop_proof"}, + DPoP: &capability.DPoPParams{ + Htu: discoveredHtu, + }, + } + body := []byte(`{"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500,"currency":"USD"}}}`) + target := makeTarget("other-seller.example.com", "POST", "/mcp/") + + outcome := rewriter.Apply(context.Background(), target, req, body) + + // Should Allow + if outcome.Kind != capability.Allow { + t.Fatalf("outcome.Kind = %v, want Allow", outcome.Kind) + } + + // Decode the spliced dpop_proof and check htu + var result map[string]any + if err := json.Unmarshal(outcome.Body, &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + params := result["params"].(map[string]any) + args := params["arguments"].(map[string]any) + dpopProof := args["dpop_proof"].(string) + + // dpopProof is "header.payload.signature", base64url-encoded + parts := strings.Split(dpopProof, ".") + if len(parts) != 3 { + t.Fatalf("expected 3-part JWS, got %d parts", len(parts)) + } + payloadB64 := parts[1] + payloadJSON, err := base64.RawURLEncoding.DecodeString(payloadB64) + if err != nil { + t.Fatalf("decode payload: %v", err) + } + var claims map[string]any + if err := json.Unmarshal(payloadJSON, &claims); err != nil { + t.Fatalf("unmarshal claims: %v", err) + } + htu, ok := claims["htu"].(string) + if !ok { + t.Fatal("htu not found in claims") + } + if htu != discoveredHtu { + t.Errorf("htu = %q, want %q", htu, discoveredHtu) + } +} + +// Helpers for new tests + +func loadCommerceTestKey(t *testing.T) *commercekey.Key { + t.Helper() + tempPEM := `-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgEjRWeJA0VniQNFZ4 +kDRWeJA0VniQNFZ4kDRWeJA0VnihRANCAAR7VJCMjvKZnHnHvS3xdKaPXM5pHvEJ +qxLJH8nppWLHVdJvxMlvmJNLcQh3PRe6mXF1wnTvzWFhZj0c+0/qrjPd +-----END PRIVATE KEY-----` + + tmpfile := t.TempDir() + "/test.pem" + if err := os.WriteFile(tmpfile, []byte(tempPEM), 0600); err != nil { + t.Fatal(err) + } + + key, err := commercekey.Load(tmpfile) + if err != nil { + t.Fatalf("failed to load test key: %v", err) + } + return key +} + +type fakeAuthorityForCommerce struct { + mandate *commerce.Mandate + rawBytes json.RawMessage + err error +} + +func (f *fakeAuthorityForCommerce) RequestMandate(_ context.Context, _ commerce.MandateInput) (*commerce.Mandate, json.RawMessage, error) { + if f.err != nil { + return nil, nil, f.err + } + // If rawBytes not set, generate from mandate + raw := f.rawBytes + if raw == nil && f.mandate != nil { + rawData, _ := json.Marshal(f.mandate) + raw = json.RawMessage(rawData) + } + return f.mandate, raw, nil +} + +type fakeClockForCommerce struct { + now int64 +} + +func (f fakeClockForCommerce) NowUnix() int64 { + return f.now +} + +type fakeNonceForCommerce struct { + jti string +} + +func (f *fakeNonceForCommerce) JTI() string { + return f.jti +} + +type logBuffer struct { + buf []byte +} + +func (b *logBuffer) Write(p []byte) (int, error) { + b.buf = append(b.buf, p...) + return len(p), nil +} + +func (b *logBuffer) String() string { + return string(b.buf) +} + +func (b *logBuffer) ContainsLevel(level string) bool { + return strings.Contains(b.String(), "level="+level) +} + +func (b *logBuffer) Contains(s string) bool { + return strings.Contains(b.String(), s) +} + +func TestCommerce_SurfacesSignURL(t *testing.T) { //nolint:tparallel // subtests share rewriter state + t.Parallel() + // Test that commerce.Outcome.SignURL is mapped to capability.Outcome.SignURL. + key := loadCommerceTestKey(t) + mandate := &commerce.Mandate{ + MandateID: "mandate-001", + QuoteID: "demo-quote-001", + SubjectANS: "ans://traveler.example.com", + AudienceANS: "ans://seller.skybound-travel.com", + Scope: "commerce.payment:write", + MaxAmount: 1000.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-12-31T23:59:59Z", + JKT: key.JKT(), + AuthorityANS: "ans://authority.example.com", + Signature: "fake-signature", + } + fakeAuth := &fakeAuthorityForCommerce{mandate: mandate} + clock := fakeClockForCommerce{now: 1735689600} + nonce := &fakeNonceForCommerce{jti: "test-jti-123"} + + deps := commerce.Deps{ + Key: key, + Cache: commerce.NewMandateCache(), + Authority: fakeAuth, + Clock: clock, + Nonce: nonce, + } + cfg := commerce.Config{ + SellerMCPURL: "https://seller.example.com/mcp/", + SellerANS: "ans://seller.skybound-travel.com", + ScopePrefix: "commerce.", + SubjectANS: "ans://traveler.example.com", + MaxBodyBytes: 10000, + } + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com", logger) + + t.Run("SignURL matches SellerMCPURL", func(t *testing.T) { + req := capability.Requirement{ + Capabilities: []capability.Capability{"mandate", "dpop_proof"}, + } + body := []byte(`{"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500,"currency":"USD"}}}`) + target := makeTarget("seller.example.com", "POST", "/mcp/") + + outcome := rewriter.Apply(context.Background(), target, req, body) + + if outcome.Kind != capability.Allow { + t.Fatalf("outcome.Kind = %v, want Allow", outcome.Kind) + } + + if outcome.SignURL != cfg.SellerMCPURL { + t.Errorf("outcome.SignURL = %q, want %q", outcome.SignURL, cfg.SellerMCPURL) + } + }) + + t.Run("SignURL matches discovered htu", func(t *testing.T) { + discoveredHtu := "https://other-seller.example.com/mcp/" + req := capability.Requirement{ + Capabilities: []capability.Capability{"mandate", "dpop_proof"}, + DPoP: &capability.DPoPParams{ + Htu: discoveredHtu, + }, + } + body := []byte(`{"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500,"currency":"USD"}}}`) + target := makeTarget("other-seller.example.com", "POST", "/mcp/") + + outcome := rewriter.Apply(context.Background(), target, req, body) + + if outcome.Kind != capability.Allow { + t.Fatalf("outcome.Kind = %v, want Allow", outcome.Kind) + } + + if outcome.SignURL != discoveredHtu { + t.Errorf("outcome.SignURL = %q, want %q", outcome.SignURL, discoveredHtu) + } + }) +} + +// TestCapabilityKind pins the commerce.OutcomeKind -> capability.OutcomeKind +// mapping. Apply relies on it, and a silent Deny/Allow swap would be a +// security defect; this fails if either enum is reordered. +func TestCapabilityKind(t *testing.T) { + t.Parallel() + cases := []struct { + in commerce.OutcomeKind + want capability.OutcomeKind + }{ + {commerce.Passthrough, capability.Passthrough}, + {commerce.Deny, capability.Deny}, + {commerce.Allow, capability.Allow}, + } + for _, c := range cases { + if got := capabilityKind(c.in); got != c.want { + t.Errorf("capabilityKind(%v) = %v, want %v", c.in, got, c.want) + } + } + // An unknown kind must fail closed to Deny, never Allow. + if got := capabilityKind(commerce.OutcomeKind(99)); got != capability.Deny { + t.Errorf("capabilityKind(unknown) = %v, want Deny (fail closed)", got) + } +}