From ed9af10037ffc28fc01cc132169959fe9387ee0d Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Mon, 26 Oct 2026 19:37:09 +0200 Subject: [PATCH 01/14] feat(identity): dev-mode self-signed identity for the rogue agent Self-signing signer + self-signed artifact source selected by dev_mode config, deduplicated COSE minting with selfsign coverage. The rogue is stamped at the egress but rejected only at the ingress. Squashes: a904935 5cbb964 d8d3e03 34214fc 16aa51a Signed-off-by: Andrei Ciobanu --- internal/identity/artifacts.go | 51 ++++++ internal/identity/artifacts_test.go | 79 ++++++++ internal/identity/registry.go | 39 ++++ internal/identity/registry_test.go | 178 ++++++++++++++++++ internal/identity/signer.go | 69 +++++++ internal/identity/signer_test.go | 116 ++++++++++++ internal/middleware/e2e_test.go | 100 +++++++++++ internal/selfsign/cose.go | 148 +++++++++++++++ internal/selfsign/cose_test.go | 199 +++++++++++++++++++++ internal/testfixtures/testfixtures_test.go | 10 -- internal/testfixtures/tl.go | 104 +---------- 11 files changed, 987 insertions(+), 106 deletions(-) create mode 100644 internal/selfsign/cose.go create mode 100644 internal/selfsign/cose_test.go 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/selfsign/cose.go b/internal/selfsign/cose.go new file mode 100644 index 0000000..e18a023 --- /dev/null +++ b/internal/selfsign/cose.go @@ -0,0 +1,148 @@ +// 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 +) + +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 +} + +// 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) + } + h := sha256.Sum256(spki) + var kid [kidLen]byte + copy(kid[:], h[:kidLen]) + return kid +} + +// 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/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..88bb50a 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,32 +21,7 @@ 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 @@ -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,8 @@ 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) + receipt, err := selfsign.Receipt(t.key, agentID, ansName) must(err) - return b + return receipt } -// 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) - must(err) - return b -} From f67312ed354080cdf93507c9f8b9570e03d616af Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Mon, 26 Oct 2026 19:37:10 +0200 Subject: [PATCH 02/14] feat(callee): ingress verifier with live TL, seller forwarding, receipt toggle Seed+refresh root keys from the transparency log, configurable SCITT receipt enforcement, forward verified requests to the seller, reject oversize bodies (400) before verification, and the ans-reference-callee flags that drive it. Squashes: 416a3d8 5256e52 51f571d bb41bbf 38681b4 f4558a9 Signed-off-by: Andrei Ciobanu --- cmd/ans-reference-callee/main.go | 39 +- internal/callee/callee.go | 230 +++++++++- internal/callee/callee_test.go | 760 +++++++++++++++++++++++++++++++ internal/testfixtures/tl.go | 1 - 4 files changed, 999 insertions(+), 31 deletions(-) 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/internal/callee/callee.go b/internal/callee/callee.go index aba9fd1..df0edae 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,101 @@ 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, + } + + if cfg.SellerURL != "" { + // Forward mode: pin htu to seller URL (G1). WithExternalURL requires + // appending r.URL.RequestURI() to bind the full request target. + opts = []pop.MiddlewareOption{ + pop.WithExternalURL(func(r *http.Request) string { + return cfg.SellerURL + r.URL.RequestURI() + }), + 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(keys, replay, opts...)(http.HandlerFunc(h.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 +271,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 +288,58 @@ 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 request with the same body and Content-Type. + upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, sellerURL, 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) + 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..f058804 100644 --- a/internal/callee/callee_test.go +++ b/internal/callee/callee_test.go @@ -197,3 +197,763 @@ 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) + } +} diff --git a/internal/testfixtures/tl.go b/internal/testfixtures/tl.go index 88bb50a..a9199da 100644 --- a/internal/testfixtures/tl.go +++ b/internal/testfixtures/tl.go @@ -96,4 +96,3 @@ func (t *TL) Receipt(agentID, ansName string) []byte { must(err) return receipt } - From bc6984b0eb2a206c2b5823251fdc10e72bea69af Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Mon, 26 Oct 2026 19:37:37 +0200 Subject: [PATCH 03/14] =?UTF-8?q?feat(commerce):=20AP2=20commerce=20leg=20?= =?UTF-8?q?=E2=80=94=20mandate,=20DPoP,=20R1-ordered=20rewrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P-256 commerce DPoP key (JWK + RFC 7638 jkt), byte-exact RFC 9449 DPoP minter, mandate type with ans_host version-stripping + validation, single-slot mandate cache with skew + injectable clock, authority request_mandate JSON-RPC client, and the rewrite_book_flight orchestrator. The middleware rewrites the body THEN signs the rewritten bytes (R1), behind a commerce gate/config with demo prefetch. Squashes: 3dd638d c4cc4a8 c594177 8a5f72a 5df9704 7a913c0 7704ade cf02479 978635d 404c9fe 7f6df35 Signed-off-by: Andrei Ciobanu --- cmd/ans-openshell-middleware/main.go | 129 ++++++++- internal/commerce/authority.go | 162 +++++++++++ internal/commerce/authority_test.go | 227 +++++++++++++++ internal/commerce/cache.go | 107 +++++++ internal/commerce/cache_test.go | 337 +++++++++++++++++++++++ internal/commerce/dpop.go | 105 +++++++ internal/commerce/dpop_test.go | 219 +++++++++++++++ internal/commerce/mandate.go | 143 ++++++++++ internal/commerce/mandate_test.go | 283 +++++++++++++++++++ internal/commerce/rewrite.go | 178 ++++++++++++ internal/commerce/rewrite_test.go | 308 +++++++++++++++++++++ internal/commercekey/commercekey.go | 117 ++++++++ internal/commercekey/commercekey_test.go | 229 +++++++++++++++ internal/config/config.go | 61 ++++ internal/config/config_test.go | 108 ++++++++ internal/middleware/commerce.go | 53 ++++ internal/middleware/commerce_test.go | 116 ++++++++ internal/middleware/reasons.go | 2 + internal/middleware/service.go | 80 +++++- internal/middleware/service_test.go | 219 ++++++++++++++- 20 files changed, 3173 insertions(+), 10 deletions(-) create mode 100644 internal/commerce/authority.go create mode 100644 internal/commerce/authority_test.go create mode 100644 internal/commerce/cache.go create mode 100644 internal/commerce/cache_test.go create mode 100644 internal/commerce/dpop.go create mode 100644 internal/commerce/dpop_test.go create mode 100644 internal/commerce/mandate.go create mode 100644 internal/commerce/mandate_test.go create mode 100644 internal/commerce/rewrite.go create mode 100644 internal/commerce/rewrite_test.go create mode 100644 internal/commercekey/commercekey.go create mode 100644 internal/commercekey/commercekey_test.go create mode 100644 internal/middleware/commerce.go create mode 100644 internal/middleware/commerce_test.go diff --git a/cmd/ans-openshell-middleware/main.go b/cmd/ans-openshell-middleware/main.go index dc6b945..6b118e1 100644 --- a/cmd/ans-openshell-middleware/main.go +++ b/cmd/ans-openshell-middleware/main.go @@ -4,6 +4,8 @@ package main import ( "context" + "crypto/rand" + "encoding/hex" "errors" "flag" "fmt" @@ -17,6 +19,8 @@ import ( "google.golang.org/grpc" "github.com/agentnameservice/ans-openshell/gen/openshell/middlewarev1" + "github.com/agentnameservice/ans-openshell/internal/commerce" + "github.com/agentnameservice/ans-openshell/internal/commercekey" "github.com/agentnameservice/ans-openshell/internal/config" "github.com/agentnameservice/ans-openshell/internal/extauth" "github.com/agentnameservice/ans-openshell/internal/grpcserver" @@ -52,6 +56,8 @@ type fileConfig struct { // MaxPayloadBytes and RequestTimeout are advertised in the manifest binding. MaxPayloadBytes uint64 `yaml:"max_payload_bytes"` RequestTimeout time.Duration `yaml:"request_timeout"` + // Commerce enables commerce rewriting for Tier 2 demo. + Commerce config.Commerce `yaml:"commerce"` } func main() { @@ -144,13 +150,74 @@ func serve(ctx context.Context, cfg fileConfig, logger *slog.Logger) error { return err } svc := middleware.NewService(registry, authz, vet, serviceConfig(cfg), logger) + + // Wire commerce if enabled + if cfg.Commerce.Enabled() { + rewriter, startPrefetch, err := setupCommerce(cfg, logger) + if err != nil { + return err + } + svc.WithCommerce(rewriter) + if startPrefetch != nil { + startPrefetch() + } + logger.InfoContext(ctx, "commerce rewriter enabled") + } + 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) + "version", version, "identities", registry.Names(), "vetting", vet != nil, "commerce", cfg.Commerce.Enabled()) return srv.Serve(ctx) } +// setupCommerce builds the commerce rewriter and returns an optional demo-prefetch closure. +func setupCommerce(cfg fileConfig, logger *slog.Logger) (middleware.CommerceRewriter, func(), error) { + if err := cfg.Commerce.Validate(); err != nil { + return nil, nil, fmt.Errorf("commerce config: %w", err) + } + keyPath := cfg.Commerce.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(cfg.Commerce.AuthorityMCPURL) + clock := realClock{} + nonce := &uuidNonce{} + deps := commerce.Deps{ + Key: key, + Cache: cache, + Authority: authority, + Clock: clock, + Nonce: nonce, + } + commerceCfg := commerce.Config{ + SellerMCPURL: cfg.Commerce.SellerMCPURL, + SellerANS: cfg.Commerce.SellerANS, + ScopePrefix: cfg.Commerce.ScopeHint, + SubjectANS: cfg.Commerce.SubjectANS, + MaxBodyBytes: cfg.Commerce.MaxBodyBytes, + } + sellerHost, err := middleware.ParseSellerHost(cfg.Commerce.SellerMCPURL) + if err != nil { + return nil, nil, fmt.Errorf("commerce seller host: %w", err) + } + rewriter := middleware.NewCommerceRewriter(deps, commerceCfg, sellerHost) + + // Return optional demo-prefetch closure if demo fields are set + var prefetch func() + if cfg.Commerce.DemoQuoteID != "" && cfg.Commerce.DemoAmount > 0 && cfg.Commerce.DemoCurrency != "" { + prefetch = func() { + go prefetchDemoMandate(cache, key, cfg.Commerce, authority, clock, logger) + } + } + return rewriter, prefetch, nil +} + // serviceConfig maps the file configuration onto the middleware service. func serviceConfig(cfg fileConfig) middleware.Config { c := middleware.Config{ @@ -210,3 +277,63 @@ func (h healthState) level() slog.Level { } return slog.LevelWarn } + +// 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()) + } + // UUID v4 format: set version (4) and variant (10b) + 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, cfg config.Commerce, + authority commerce.AuthorityClient, clock commerce.Clock, logger *slog.Logger) { + qk := commerce.QuoteKey{ + QuoteID: cfg.DemoQuoteID, + Amount: cfg.DemoAmount, + Currency: cfg.DemoCurrency, + } + _, err := cache.GetOrFetch( + context.Background(), //nolint:contextcheck // Prefetch uses background context after server started + qk, + key.JKT(), + cfg.SellerANS, + cfg.ScopeHint, + authority, + clock, + func(qk commerce.QuoteKey) commerce.MandateInput { + return commerce.MandateInput{ + QuoteID: qk.QuoteID, + Total: qk.Amount, + Currency: qk.Currency, + MerchantANS: cfg.SellerANS, + ScopeHint: cfg.ScopeHint, + SubjectANS: cfg.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", cfg.DemoQuoteID) + } +} diff --git a/internal/commerce/authority.go b/internal/commerce/authority.go new file mode 100644 index 0000000..188ada1 --- /dev/null +++ b/internal/commerce/authority.go @@ -0,0 +1,162 @@ +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, 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, 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, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + // Send request + resp, err := h.client.Do(req) + if err != nil { + return 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, 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, 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 an error if the response contains an error object, has no content, +// or cannot be parsed. +func parseMandateResponse(body []byte) (*Mandate, error) { + var response jsonRPCResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("JSON parse failed: %w", err) + } + + // Check for error object + if response.Error != nil { + return nil, fmt.Errorf("authority error: %s", response.Error.Message) + } + + // Check for result + if response.Result == nil { + return nil, errors.New("no result in response") + } + + // Check for content + if len(response.Result.Content) == 0 { + return nil, errors.New("no content in authority response") + } + + // Parse mandate from first content item text + mandateText := response.Result.Content[0].Text + var mandate Mandate + if err := json.Unmarshal([]byte(mandateText), &mandate); err != nil { + return nil, fmt.Errorf("failed to parse mandate from content: %w", err) + } + + return &mandate, nil +} diff --git a/internal/commerce/authority_test.go b/internal/commerce/authority_test.go new file mode 100644 index 0000000..8a9b5be --- /dev/null +++ b/internal/commerce/authority_test.go @@ -0,0 +1,227 @@ +package commerce + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/agentnameservice/ans-openshell/internal/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, 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) + } +} + +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 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/internal/commerce/cache.go b/internal/commerce/cache.go new file mode 100644 index 0000000..8329a00 --- /dev/null +++ b/internal/commerce/cache.go @@ -0,0 +1,107 @@ +package commerce + +import ( + "context" + "sync" + "time" + + "github.com/agentnameservice/ans-openshell/internal/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, 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 and quote key. +type cachedMandate struct { + mandate *Mandate + 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 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. +func (c *MandateCache) GetOrFetch( + ctx context.Context, + qk QuoteKey, + ourJKT, sellerANS, scopePrefix string, + authority AuthorityClient, + clock Clock, + build func(QuoteKey) MandateInput, +) (*Mandate, 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, nil + } + } + + // Cache miss: fetch new mandate + input := build(qk) + mandate, err := authority.RequestMandate(ctx, input) + if err != nil { + return nil, err + } + + // Validate the mandate + if err := Validate(mandate, qk, ourJKT, sellerANS, scopePrefix); err != nil { + return 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, err + } + notAfterUnix := notAfterTime.Unix() + + // Store in cache + c.entry = &cachedMandate{ + mandate: mandate, + notAfterUnix: notAfterUnix, + quoteKey: qk, + } + + return mandate, nil +} diff --git a/internal/commerce/cache_test.go b/internal/commerce/cache_test.go new file mode 100644 index 0000000..9a6e351 --- /dev/null +++ b/internal/commerce/cache_test.go @@ -0,0 +1,337 @@ +package commerce + +import ( + "context" + "errors" + "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 + calls int +} + +func (f *fakeAuthority) RequestMandate(_ context.Context, _ MandateInput) (*Mandate, error) { + f.calls++ + if f.mandate == nil { + return nil, errors.New("authority returned nil mandate") + } + return f.mandate, 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, 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, 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) + } +} + +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) + } +} diff --git a/internal/commerce/dpop.go b/internal/commerce/dpop.go new file mode 100644 index 0000000..ec03286 --- /dev/null +++ b/internal/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/internal/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/internal/commerce/dpop_test.go b/internal/commerce/dpop_test.go new file mode 100644 index 0000000..7c72f12 --- /dev/null +++ b/internal/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/internal/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/internal/commerce/mandate.go b/internal/commerce/mandate.go new file mode 100644 index 0000000..cdce74d --- /dev/null +++ b/internal/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.skybound-travel.com" -> "seller.skybound-travel.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/internal/commerce/mandate_test.go b/internal/commerce/mandate_test.go new file mode 100644 index 0000000..4528d9d --- /dev/null +++ b/internal/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/internal/commerce/rewrite.go b/internal/commerce/rewrite.go new file mode 100644 index 0000000..a0471dc --- /dev/null +++ b/internal/commerce/rewrite.go @@ -0,0 +1,178 @@ +package commerce + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + "github.com/agentnameservice/ans-openshell/internal/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 +} + +// 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 = cfg.SellerMCPURL (verbatim, not reconstructed) +// - Splice mandate and dpop_proof into arguments, re-marshal +// - If re-marshaled body exceeds cfg.MaxBodyBytes → Deny; else Allow +// +// 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) 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) + mandate, 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 cfg.SellerMCPURL verbatim (trailing slash preserved), not reconstructed + proof, err := MintDPoP(d.Key, cfg.SellerMCPURL, 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) + args["mandate"] = mandate + 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} +} diff --git a/internal/commerce/rewrite_test.go b/internal/commerce/rewrite_test.go new file mode 100644 index 0000000..6835bf0 --- /dev/null +++ b/internal/commerce/rewrite_test.go @@ -0,0 +1,308 @@ +package commerce + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/agentnameservice/ans-openshell/internal/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 + err error +} + +func (f *fakeAuthorityWithError) RequestMandate(_ context.Context, _ MandateInput) (*Mandate, error) { + if f.err != nil { + return nil, f.err + } + return f.mandate, 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_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)) + } +} diff --git a/internal/commercekey/commercekey.go b/internal/commercekey/commercekey.go new file mode 100644 index 0000000..7e6c54f --- /dev/null +++ b/internal/commercekey/commercekey.go @@ -0,0 +1,117 @@ +// 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 + pubBytes := elliptic.Marshal(k.key.Curve, k.key.PublicKey.X, k.key.PublicKey.Y) //nolint:staticcheck // Using X/Y for JWK encoding + 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/internal/commercekey/commercekey_test.go b/internal/commercekey/commercekey_test.go new file mode 100644 index 0000000..2feb2c7 --- /dev/null +++ b/internal/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/internal/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/internal/config/config.go b/internal/config/config.go index 5aedd7d..0d9750e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -118,6 +118,67 @@ type Vetting struct { ExemptHosts []string `yaml:"exempt_hosts"` } +// Commerce configures the commerce rewriter for Tier 2 demo. +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"` +} + +// Enabled reports whether commerce is configured. +func (c Commerce) Enabled() bool { + return c.CommerceDPoPKeyPath != "" || os.Getenv("COMMERCE_DPOP_KEY") != "" +} + +// Validate checks that required fields are set 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 +} + // Load reads the YAML file at path into dst, rejecting unknown fields so a // misspelled key cannot silently disable a control. func Load(path string, dst any) error { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9bbdb5a..f332737 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -163,3 +163,111 @@ func TestLoad(t *testing.T) { t.Fatal("Load() = nil for a missing file") } } + +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 + if err := Parse([]byte(yml), &c); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + 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) + } + + // Marshal back and check + var c2 Commerce + if err := Parse([]byte(yml), &c2); err != nil { + t.Fatalf("re-parse: %v", err) + } + if c2.SellerMCPURL != c.SellerMCPURL { + t.Errorf("round-trip mismatch") + } + }) + + 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{} + // Not enabled, Validate should pass + if err := c.Validate(); err != nil { + t.Errorf("Validate() = %v for disabled config", err) + } + + // Enabled but missing fields + c.CommerceDPoPKeyPath = "/path/to/key.json" + if err := c.Validate(); err == nil { + t.Errorf("Validate() = nil, want error for missing URLs/ANS") + } + + // Set all required fields + 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) + } + }) +} diff --git a/internal/middleware/commerce.go b/internal/middleware/commerce.go new file mode 100644 index 0000000..7b04178 --- /dev/null +++ b/internal/middleware/commerce.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "context" + "net/url" + + "github.com/agentnameservice/ans-openshell/internal/commerce" +) + +// commerceRewriter implements CommerceRewriter with a host/path/method gate. +type commerceRewriter struct { + deps commerce.Deps + cfg commerce.Config + sellerHost string +} + +// NewCommerceRewriter creates a CommerceRewriter that gates on host==sellerHost, +// method=="POST", and path in {"/mcp/", "/a2a", "/a2a/"}. +// sellerHost is derived from cfg.SellerMCPURL's host. +func NewCommerceRewriter(deps commerce.Deps, cfg commerce.Config, sellerHost string) CommerceRewriter { + return &commerceRewriter{deps: deps, cfg: cfg, sellerHost: sellerHost} +} + +// Rewrite implements CommerceRewriter. +func (r *commerceRewriter) Rewrite(ctx context.Context, host, method, path string, body []byte) CommerceOutcome { + // Gate: host == sellerHost && method == POST && path in {"/mcp/", "/a2a", "/a2a/"} + if host != r.sellerHost { + return CommerceOutcome{Kind: CommercePassthrough} + } + if method != "POST" { + return CommerceOutcome{Kind: CommercePassthrough} + } + if path != "/mcp/" && path != "/a2a" && path != "/a2a/" { + return CommerceOutcome{Kind: CommercePassthrough} + } + + // Delegate to commerce.RewriteBookFlight + outcome := commerce.RewriteBookFlight(ctx, body, r.deps, r.cfg) + return CommerceOutcome{ + Kind: CommerceKind(outcome.Kind), + Body: outcome.Body, + Reason: outcome.Reason, + } +} + +// 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/internal/middleware/commerce_test.go b/internal/middleware/commerce_test.go new file mode 100644 index 0000000..574b0eb --- /dev/null +++ b/internal/middleware/commerce_test.go @@ -0,0 +1,116 @@ +package middleware + +import ( + "context" + "testing" + + "github.com/agentnameservice/ans-openshell/internal/commerce" +) + +// panicAuthority panics if RequestMandate is called, proving the gate skipped. +type panicAuthority struct{} + +func (panicAuthority) RequestMandate(_ context.Context, _ commerce.MandateInput) (*commerce.Mandate, 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 CommerceRewriter 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, + } + rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com") + + 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":{}}`) + outcome := rewriter.Rewrite(context.Background(), tt.host, tt.method, tt.path, body) + // Gate matched (didn't panic from panicAuthority), commerce processed it → Passthrough + if outcome.Kind != CommercePassthrough { + t.Errorf("outcome.Kind = %v, want CommercePassthrough", outcome.Kind) + } + }) + } +} + +func TestCommerceRewriter_GateSkips(t *testing.T) { + t.Parallel() + // Build a CommerceRewriter 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, + } + rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com") + + 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"}}`) + outcome := rewriter.Rewrite(context.Background(), tt.host, tt.method, tt.path, body) + // Should return Passthrough without calling RewriteBookFlight (which would panic via authority) + if outcome.Kind != CommercePassthrough { + t.Errorf("outcome.Kind = %v, want CommercePassthrough", outcome.Kind) + } + if len(outcome.Body) != 0 { + t.Errorf("outcome.Body should be empty on Passthrough, got %d bytes", len(outcome.Body)) + } + }) + } +} diff --git a/internal/middleware/reasons.go b/internal/middleware/reasons.go index 6abc6d4..8cf905c 100644 --- a/internal/middleware/reasons.go +++ b/internal/middleware/reasons.go @@ -36,6 +36,8 @@ const ( ReasonIdentityUnauthorized = "ans_identity_unauthorized" // ReasonRequestRewriteUnsupported denies content OpenShell could rewrite after signing. ReasonRequestRewriteUnsupported = "ans_credential_rewrite_unsupported" + // ReasonCommerceDenied is returned when the commerce rewriter denies a request. + ReasonCommerceDenied = "ans_commerce_denied" // 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..7015a8f 100644 --- a/internal/middleware/service.go +++ b/internal/middleware/service.go @@ -94,16 +94,41 @@ type IdentityLookup interface { Identity(name string) (*identity.Identity, bool) } +// CommerceRewriter upgrades a recognized commerce call in place; nil disables it. +type CommerceRewriter interface { + Rewrite(ctx context.Context, host, method, path string, body []byte) CommerceOutcome +} + +// CommerceOutcome represents the result of a commerce rewrite operation. +type CommerceOutcome struct { + Kind CommerceKind + Body []byte + Reason string +} + +// CommerceKind represents the commerce decision. +type CommerceKind int + +const ( + // CommercePassthrough means the request was not a commerce call and should be forwarded as-is. + CommercePassthrough CommerceKind = iota + // CommerceDeny means the request was rejected by the commerce rewriter. + CommerceDeny + // CommerceAllow means the request was successfully rewritten with commerce data. + CommerceAllow +) + // Service implements openshell.middleware.v1.SupervisorMiddleware. The // WebSocket stream RPC is not implemented and the manifest does not advertise // 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 + commerce CommerceRewriter } // NewService builds the service. vet may be nil when destination vetting is not @@ -122,6 +147,12 @@ func NewService(ids IdentityLookup, authz IdentityAuthorizer, vet vetting.Verifi return &Service{ids: ids, authz: authz, vet: vet, cfg: cfg.withDefaults(), log: logger} } +// WithCommerce injects a CommerceRewriter into the service. +func (s *Service) WithCommerce(c CommerceRewriter) *Service { + s.commerce = c + 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 @@ -273,10 +304,31 @@ 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, optionally rewrite via commerce, guard on rewritten bytes, then sign. + signBody := req.GetBody() + + // Commerce rewrite stage (optional) + if s.commerce != nil { + outcome := s.commerce.Rewrite(ctx, req.GetTarget().GetHost(), method, req.GetTarget().GetPath(), signBody) + switch outcome.Kind { + case CommerceDeny: + return out.deny(&denial{ReasonCommerceDenied, "commerce rewrite denied: " + outcome.Reason}) + case CommerceAllow: + signBody = outcome.Body + out.setBody(outcome.Body) + case CommercePassthrough: + // Leave signBody as the original + } + } + + // Credential marker guard runs on signBody (post-rewrite 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 + if d := s.attach(ctx, id, method, target, signBody, out); d != nil { return out.deny(d) } return out.allow() @@ -400,6 +452,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 +479,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 +495,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 { diff --git a/internal/middleware/service_test.go b/internal/middleware/service_test.go index 3382f4b..40763aa 100644 --- a/internal/middleware/service_test.go +++ b/internal/middleware/service_test.go @@ -548,7 +548,7 @@ func TestReasonCodeGrammar(t *testing.T) { for _, code := range []string{ ReasonUnsupportedPhase, ReasonCallerUnauthenticated, ReasonCallerNotSupervisor, ReasonCallerSandboxMismatch, ReasonPolicyInvalid, ReasonTargetInvalid, ReasonIdentityUnknown, - ReasonStatusTokenUnavailable, ReasonReceiptUnavailable, ReasonSignFailed, + ReasonStatusTokenUnavailable, ReasonReceiptUnavailable, ReasonSignFailed, ReasonCommerceDenied, vetting.ReasonUnregistered, vetting.ReasonRevoked, vetting.ReasonUntrustedTL, vetting.ReasonBelowThreshold, vetting.ReasonUnverified, FindingIdentityAttached, FindingIdentityStripped, } { @@ -559,3 +559,220 @@ 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()) + } +} + +// fakeCommerceRewriter is a stub commerce rewriter for testing. +type fakeCommerceRewriter struct { + outcome CommerceOutcome +} + +func (f *fakeCommerceRewriter) Rewrite(_ context.Context, _, _, _ string, _ []byte) CommerceOutcome { + return f.outcome +} + +// TestEvaluate_CommerceRewriteThenSignRewrittenBody verifies that commerce rewrite +// happens BEFORE signing, so the signed digest is sha256(rewritten), not sha256(original). +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} + + commerce := &fakeCommerceRewriter{ + outcome: CommerceOutcome{Kind: CommerceAllow, Body: rewritten}, + } + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.commerce = commerce + + 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_commerce_denied, with no Sign call and no body mutation. +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} + + commerce := &fakeCommerceRewriter{ + outcome: CommerceOutcome{Kind: CommerceDeny, Reason: "missing mandate"}, + } + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.commerce = commerce + + 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() != ReasonCommerceDenied { + t.Errorf("reason_code = %q, want %q", res.GetReasonCode(), ReasonCommerceDenied) + } + + // 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. +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} + + commerce := &fakeCommerceRewriter{ + outcome: CommerceOutcome{Kind: CommercePassthrough}, + } + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.commerce = commerce + + 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-rewrite body, so a rewrite cannot smuggle a marker past it. +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} + + commerce := &fakeCommerceRewriter{ + outcome: CommerceOutcome{Kind: CommerceAllow, Body: rewritten}, + } + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.commerce = commerce + + 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)) + } +} From 8415e1848ca0cb956091d9217234d41bf06b2ce5 Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Mon, 26 Oct 2026 19:37:39 +0200 Subject: [PATCH 04/14] feat(demo): two-agent egress configs, policies, and gateway registration Agent A (commerce) + Agent B (rogue) example configs, sandbox policies, and the Tier 2 gateway registration; docs/ and secrets/ gitignored (local-only). Squashes: c45a9b0 17b5eb8 7a95bc4 9ff2072 526db12 Signed-off-by: Andrei Ciobanu --- .gitignore | 6 + deploy/examples/gateway-tier2.toml | 16 + deploy/examples/middleware-agent-a.yaml | 41 +++ deploy/examples/middleware-agent-b.yaml | 27 ++ deploy/examples/policy-a.yaml | 32 ++ deploy/examples/policy-b.yaml | 32 ++ deploy/examples_test.go | 378 ++++++++++++++++++++++++ 7 files changed, 532 insertions(+) create mode 100644 deploy/examples/gateway-tier2.toml create mode 100644 deploy/examples/middleware-agent-a.yaml create mode 100644 deploy/examples/middleware-agent-b.yaml create mode 100644 deploy/examples/policy-a.yaml create mode 100644 deploy/examples/policy-b.yaml create mode 100644 deploy/examples_test.go diff --git a/.gitignore b/.gitignore index 0aeb50e..a4a0b55 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,9 @@ Thumbs.db *.pem *.key *.csr + +# Identity material for the Tier 2 demo (Agent A bundle, commerce key) — never committed +/.secrets/ + +# Design docs, plans, and runbooks are local-only (user instruction: ignore docs folder) +/docs/ 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..5177875 --- /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/example.com/key.pem + cert_file: /.secrets/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/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/examples_test.go b/deploy/examples_test.go new file mode 100644 index 0000000..c67fe7e --- /dev/null +++ b/deploy/examples_test.go @@ -0,0 +1,378 @@ +package deploy + +import ( + "encoding/json" + "os/exec" + "strings" + "testing" + "time" + + "github.com/agentnameservice/ans-openshell/internal/config" + "github.com/agentnameservice/ans-openshell/internal/identity" +) + +// middlewareFile mirrors the unexported fileConfig from cmd/ans-openshell-middleware/main.go +// for testing example YAML files. +type middlewareFile struct { + Listen string `yaml:"listen"` + 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 *config.Vetting `yaml:"vetting"` + MaxPayloadBytes uint64 `yaml:"max_payload_bytes"` + RequestTimeout time.Duration `yaml:"request_timeout"` + Commerce config.Commerce `yaml:"commerce"` +} + +// loadMiddlewareFile loads a middleware YAML config into the mirror struct. +func loadMiddlewareFile(t *testing.T, path string) middlewareFile { + t.Helper() + var cfg middlewareFile + 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, "examples/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, "examples/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, "examples/policy-a.yaml") + policyB := loadPolicyFile(t, "examples/policy-b.yaml") + + // Parse the Tier 2 gateway registration + gatewayMiddlewares := loadGatewayTier2(t, "examples/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) + } +} From 7fe582386fce012afcbab6bbcc5477c282780a73 Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Mon, 26 Oct 2026 19:37:40 +0200 Subject: [PATCH 05/14] feat(capability): pluggable transform chain + discovery + pre-return hint Replace the hardcoded commerce branch with a capability.Transform chain driven by a Discoverer (A2A agent-card JWS + MCP tools/list); commerce becomes one transform with a lenient static fallback. Discovery hardened: tool-gated probing, verified-htu only, hermetic tests. HttpResponsePreReturn stage surfaces a seller capability rejection as a machine-readable middleware_hint. Squashes: 29ffc2b 36cc56e 5ed69a7 c02c3f1 293c1ae 36a593b 1120c1c ff1e05b 9789157 4833761 546fc61 ea8f1d1 057afaf Signed-off-by: Andrei Ciobanu --- cmd/ans-openshell-middleware/main.go | 23 +- internal/capability/capability.go | 91 ++ internal/capability/capability_test.go | 145 +++ internal/capability/discover.go | 558 ++++++++ internal/capability/discover_test.go | 1159 +++++++++++++++++ .../testdata/seller-agent-card.json | 1 + .../testdata/seller-tools-list.json | 1 + internal/commerce/rewrite.go | 14 +- internal/commerce/rewrite_test.go | 129 +- internal/config/config.go | 2 + internal/config/config_test.go | 38 + internal/middleware/commerce.go | 63 +- internal/middleware/commerce_test.go | 360 ++++- internal/middleware/grpc_test.go | 2 +- internal/middleware/prereturn.go | 280 ++++ internal/middleware/prereturn_test.go | 745 +++++++++++ internal/middleware/reasons.go | 6 +- internal/middleware/service.go | 120 +- internal/middleware/service_test.go | 543 +++++++- 19 files changed, 4154 insertions(+), 126 deletions(-) create mode 100644 internal/capability/capability.go create mode 100644 internal/capability/capability_test.go create mode 100644 internal/capability/discover.go create mode 100644 internal/capability/discover_test.go create mode 100644 internal/capability/testdata/seller-agent-card.json create mode 100644 internal/capability/testdata/seller-tools-list.json create mode 100644 internal/middleware/prereturn.go create mode 100644 internal/middleware/prereturn_test.go diff --git a/cmd/ans-openshell-middleware/main.go b/cmd/ans-openshell-middleware/main.go index 6b118e1..30d2504 100644 --- a/cmd/ans-openshell-middleware/main.go +++ b/cmd/ans-openshell-middleware/main.go @@ -10,6 +10,7 @@ import ( "flag" "fmt" "log/slog" + "net/http" "os" "os/signal" "slices" @@ -19,6 +20,7 @@ import ( "google.golang.org/grpc" "github.com/agentnameservice/ans-openshell/gen/openshell/middlewarev1" + "github.com/agentnameservice/ans-openshell/internal/capability" "github.com/agentnameservice/ans-openshell/internal/commerce" "github.com/agentnameservice/ans-openshell/internal/commercekey" "github.com/agentnameservice/ans-openshell/internal/config" @@ -153,11 +155,20 @@ func serve(ctx context.Context, cfg fileConfig, logger *slog.Logger) error { // Wire commerce if enabled if cfg.Commerce.Enabled() { - rewriter, startPrefetch, err := setupCommerce(cfg, logger) + transform, startPrefetch, err := setupCommerce(cfg, logger) if err != nil { return err } - svc.WithCommerce(rewriter) + + // Wire the discoverer (no I/O at startup; discovery happens per-request) + ttl := cfg.Commerce.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(transform) if startPrefetch != nil { startPrefetch() } @@ -165,14 +176,16 @@ func serve(ctx context.Context, cfg fileConfig, logger *slog.Logger) error { } 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", version, "identities", registry.Names(), "vetting", vet != nil, "commerce", cfg.Commerce.Enabled()) return srv.Serve(ctx) } -// setupCommerce builds the commerce rewriter and returns an optional demo-prefetch closure. -func setupCommerce(cfg fileConfig, logger *slog.Logger) (middleware.CommerceRewriter, func(), error) { +// setupCommerce builds the commerce transform and returns an optional demo-prefetch closure. +func setupCommerce(cfg fileConfig, logger *slog.Logger) (capability.Transform, func(), error) { if err := cfg.Commerce.Validate(); err != nil { return nil, nil, fmt.Errorf("commerce config: %w", err) } @@ -206,7 +219,7 @@ func setupCommerce(cfg fileConfig, logger *slog.Logger) (middleware.CommerceRewr if err != nil { return nil, nil, fmt.Errorf("commerce seller host: %w", err) } - rewriter := middleware.NewCommerceRewriter(deps, commerceCfg, sellerHost) + rewriter := middleware.NewCommerceRewriter(deps, commerceCfg, sellerHost, logger) // Return optional demo-prefetch closure if demo fields are set var prefetch func() diff --git a/internal/capability/capability.go b/internal/capability/capability.go new file mode 100644 index 0000000..918dd4c --- /dev/null +++ b/internal/capability/capability.go @@ -0,0 +1,91 @@ +// 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" + +// 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). +type Outcome struct { + Kind OutcomeKind + Body []byte // Set only for Allow + Reason string // Set only for Deny +} + +// 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 +} 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..ba0cf6f --- /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) + 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) + 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/commerce/rewrite.go b/internal/commerce/rewrite.go index a0471dc..0b49201 100644 --- a/internal/commerce/rewrite.go +++ b/internal/commerce/rewrite.go @@ -58,12 +58,14 @@ type Outcome struct { // - 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 = cfg.SellerMCPURL (verbatim, not reconstructed) +// - 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) Outcome { +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 @@ -153,8 +155,12 @@ func RewriteBookFlight(ctx context.Context, body []byte, d Deps, cfg Config) Out } // Mint DPoP proof - // CRITICAL: htu is cfg.SellerMCPURL verbatim (trailing slash preserved), not reconstructed - proof, err := MintDPoP(d.Key, cfg.SellerMCPURL, quoteID, d.Clock.NowUnix(), d.Nonce.JTI()) + // 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)} } diff --git a/internal/commerce/rewrite_test.go b/internal/commerce/rewrite_test.go index 6835bf0..c2e8be9 100644 --- a/internal/commerce/rewrite_test.go +++ b/internal/commerce/rewrite_test.go @@ -2,6 +2,7 @@ package commerce import ( "context" + "encoding/base64" "encoding/json" "os" "strings" @@ -58,7 +59,7 @@ func TestRewrite_PassthroughNonJSON(t *testing.T) { deps := Deps{} cfg := Config{} - outcome := RewriteBookFlight(context.Background(), body, deps, cfg) + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") if outcome.Kind != Passthrough { t.Errorf("expected Passthrough, got %v", outcome.Kind) @@ -73,7 +74,7 @@ func TestRewrite_PassthroughNotBookFlight(t *testing.T) { deps := Deps{} cfg := Config{} - outcome := RewriteBookFlight(context.Background(), body, deps, cfg) + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") if outcome.Kind != Passthrough { t.Errorf("expected Passthrough, got %v", outcome.Kind) @@ -89,7 +90,7 @@ func TestRewrite_DenyMissingArgs(t *testing.T) { deps := Deps{} cfg := Config{} - outcome := RewriteBookFlight(context.Background(), body, deps, cfg) + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") if outcome.Kind != Deny { t.Errorf("expected Deny, got %v", outcome.Kind) @@ -144,7 +145,7 @@ func TestRewrite_AllowSplicesMandateAndProof(t *testing.T) { 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) + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") if outcome.Kind != Allow { t.Fatalf("expected Allow, got %v: %s", outcome.Kind, outcome.Reason) @@ -254,7 +255,7 @@ func TestRewrite_DenyOversize(t *testing.T) { 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) + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") if outcome.Kind != Deny { t.Errorf("expected Deny for oversize, got %v", outcome.Kind) @@ -269,6 +270,122 @@ func TestRewrite_DenyOversize(t *testing.T) { } } +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) @@ -296,7 +413,7 @@ func TestRewrite_DenyOnMandateError(t *testing.T) { 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) + outcome := RewriteBookFlight(context.Background(), body, deps, cfg, "") if outcome.Kind != Deny { t.Errorf("expected Deny on mandate error, got %v", outcome.Kind) diff --git a/internal/config/config.go b/internal/config/config.go index 0d9750e..a4ea94b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -142,6 +142,8 @@ type Commerce struct { 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. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f332737..845cdc9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -270,4 +270,42 @@ max_body_bytes: 10000 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 + if err := Parse([]byte(yml), &c); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if c.DiscoveryTTL != 5*time.Minute { + t.Errorf("DiscoveryTTL = %v, want 5m", c.DiscoveryTTL) + } + + // Test omitted field yields zero + 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 + if err := Parse([]byte(yml2), &c2); err != nil { + t.Fatalf("unmarshal without discovery_ttl: %v", err) + } + if c2.DiscoveryTTL != 0 { + t.Errorf("DiscoveryTTL = %v, want 0 when omitted", c2.DiscoveryTTL) + } + }) } diff --git a/internal/middleware/commerce.go b/internal/middleware/commerce.go index 7b04178..ee62ffb 100644 --- a/internal/middleware/commerce.go +++ b/internal/middleware/commerce.go @@ -2,42 +2,69 @@ package middleware import ( "context" + "log/slog" + "net/http" "net/url" + "github.com/agentnameservice/ans-openshell/internal/capability" "github.com/agentnameservice/ans-openshell/internal/commerce" ) -// commerceRewriter implements CommerceRewriter with a host/path/method gate. +// 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 CommerceRewriter that gates on host==sellerHost, -// method=="POST", and path in {"/mcp/", "/a2a", "/a2a/"}. +// 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) CommerceRewriter { - return &commerceRewriter{deps: deps, cfg: cfg, sellerHost: sellerHost} +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} } -// Rewrite implements CommerceRewriter. -func (r *commerceRewriter) Rewrite(ctx context.Context, host, method, path string, body []byte) CommerceOutcome { - // Gate: host == sellerHost && method == POST && path in {"/mcp/", "/a2a", "/a2a/"} - if host != r.sellerHost { - return CommerceOutcome{Kind: CommercePassthrough} +// 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} } - if method != "POST" { - return CommerceOutcome{Kind: CommercePassthrough} + + // 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) } - if path != "/mcp/" && path != "/a2a" && path != "/a2a/" { - return CommerceOutcome{Kind: CommercePassthrough} + + // Extract discovered htu if present + var htuOverride string + if req.DPoP != nil && req.DPoP.Htu != "" { + htuOverride = req.DPoP.Htu } - // Delegate to commerce.RewriteBookFlight - outcome := commerce.RewriteBookFlight(ctx, body, r.deps, r.cfg) - return CommerceOutcome{ - Kind: CommerceKind(outcome.Kind), + // Delegate to commerce.RewriteBookFlight with discovered htu + outcome := commerce.RewriteBookFlight(ctx, body, r.deps, r.cfg, htuOverride) + return capability.Outcome{ + Kind: capability.OutcomeKind(outcome.Kind), Body: outcome.Body, Reason: outcome.Reason, } diff --git a/internal/middleware/commerce_test.go b/internal/middleware/commerce_test.go index 574b0eb..40f09ba 100644 --- a/internal/middleware/commerce_test.go +++ b/internal/middleware/commerce_test.go @@ -2,9 +2,16 @@ package middleware import ( "context" + "encoding/base64" + "encoding/json" + "log/slog" + "os" + "strings" "testing" + "github.com/agentnameservice/ans-openshell/internal/capability" "github.com/agentnameservice/ans-openshell/internal/commerce" + "github.com/agentnameservice/ans-openshell/internal/commercekey" ) // panicAuthority panics if RequestMandate is called, proving the gate skipped. @@ -24,7 +31,7 @@ func (panicNonce) JTI() string { panic("JTI called") } func TestCommerceRewriter_GateMatches(t *testing.T) { t.Parallel() - // Build a minimal CommerceRewriter that would delegate to RewriteBookFlight. + // 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{ @@ -41,7 +48,8 @@ func TestCommerceRewriter_GateMatches(t *testing.T) { SubjectANS: "ans://v2.0.0.buyer.example.com", MaxBodyBytes: 10000, } - rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com") + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com", logger) tests := []struct { name string @@ -59,18 +67,27 @@ func TestCommerceRewriter_GateMatches(t *testing.T) { t.Parallel() // Non-book_flight body → commerce.RewriteBookFlight returns Passthrough body := []byte(`{"method":"other","params":{}}`) - outcome := rewriter.Rewrite(context.Background(), tt.host, tt.method, tt.path, body) + 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 != CommercePassthrough { - t.Errorf("outcome.Kind = %v, want CommercePassthrough", outcome.Kind) + 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 CommerceRewriter with panic-on-call authority to prove the gate skips. + // Build a commerce transform with panic-on-call authority to prove the gate skips. deps := commerce.Deps{ Key: nil, Cache: commerce.NewMandateCache(), @@ -85,7 +102,8 @@ func TestCommerceRewriter_GateSkips(t *testing.T) { SubjectANS: "ans://v2.0.0.buyer.example.com", MaxBodyBytes: 10000, } - rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com") + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + rewriter := NewCommerceRewriter(deps, cfg, "seller.example.com", logger) tests := []struct { name string @@ -103,10 +121,11 @@ func TestCommerceRewriter_GateSkips(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() body := []byte(`{"method":"tools/call","params":{"name":"book_flight"}}`) - outcome := rewriter.Rewrite(context.Background(), tt.host, tt.method, tt.path, body) + 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 != CommercePassthrough { - t.Errorf("outcome.Kind = %v, want CommercePassthrough", outcome.Kind) + 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)) @@ -114,3 +133,324 @@ func TestCommerceRewriter_GateSkips(t *testing.T) { }) } } + +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") + } +} + +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 + err error +} + +func (f *fakeAuthorityForCommerce) RequestMandate(_ context.Context, _ commerce.MandateInput) (*commerce.Mandate, error) { + if f.err != nil { + return nil, f.err + } + return f.mandate, 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) +} 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 8cf905c..38c7cc7 100644 --- a/internal/middleware/reasons.go +++ b/internal/middleware/reasons.go @@ -36,8 +36,10 @@ const ( ReasonIdentityUnauthorized = "ans_identity_unauthorized" // ReasonRequestRewriteUnsupported denies content OpenShell could rewrite after signing. ReasonRequestRewriteUnsupported = "ans_credential_rewrite_unsupported" - // ReasonCommerceDenied is returned when the commerce rewriter denies a request. - ReasonCommerceDenied = "ans_commerce_denied" + // 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 7015a8f..557fc01 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" @@ -94,41 +96,18 @@ type IdentityLookup interface { Identity(name string) (*identity.Identity, bool) } -// CommerceRewriter upgrades a recognized commerce call in place; nil disables it. -type CommerceRewriter interface { - Rewrite(ctx context.Context, host, method, path string, body []byte) CommerceOutcome -} - -// CommerceOutcome represents the result of a commerce rewrite operation. -type CommerceOutcome struct { - Kind CommerceKind - Body []byte - Reason string -} - -// CommerceKind represents the commerce decision. -type CommerceKind int - -const ( - // CommercePassthrough means the request was not a commerce call and should be forwarded as-is. - CommercePassthrough CommerceKind = iota - // CommerceDeny means the request was rejected by the commerce rewriter. - CommerceDeny - // CommerceAllow means the request was successfully rewritten with commerce data. - CommerceAllow -) - // Service implements openshell.middleware.v1.SupervisorMiddleware. The // WebSocket stream RPC is not implemented and the manifest does not advertise // it. type Service struct { middlewarev1.UnimplementedSupervisorMiddlewareServer - ids IdentityLookup - authz IdentityAuthorizer - vet vetting.Verifier - cfg Config - log *slog.Logger - commerce CommerceRewriter + 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 @@ -147,9 +126,15 @@ func NewService(ids IdentityLookup, authz IdentityAuthorizer, vet vetting.Verifi return &Service{ids: ids, authz: authz, vet: vet, cfg: cfg.withDefaults(), log: logger} } -// WithCommerce injects a CommerceRewriter into the service. -func (s *Service) WithCommerce(c CommerceRewriter) *Service { - s.commerce = c +// 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 } @@ -175,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}, @@ -305,24 +298,39 @@ func (s *Service) evaluate(ctx context.Context, req *middlewarev1.HttpRequestEva return out.deny(&denial{ReasonIdentityUnauthorized, "sandbox is not authorized to use the selected identity"}) } - // R1 pipeline: compute signBody, optionally rewrite via commerce, guard on rewritten bytes, then sign. + // R1 pipeline: compute signBody, run transform chain, guard on rewritten bytes, then sign. signBody := req.GetBody() - // Commerce rewrite stage (optional) - if s.commerce != nil { - outcome := s.commerce.Rewrite(ctx, req.GetTarget().GetHost(), method, req.GetTarget().GetPath(), signBody) - switch outcome.Kind { - case CommerceDeny: - return out.deny(&denial{ReasonCommerceDenied, "commerce rewrite denied: " + outcome.Reason}) - case CommerceAllow: - signBody = outcome.Body - out.setBody(outcome.Body) - case CommercePassthrough: - // Leave signBody as the original + // 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 + 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) + case capability.Passthrough: + // Continue to next transform } } - // Credential marker guard runs on signBody (post-rewrite bytes) + // 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"}) } @@ -517,3 +525,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 40763aa..83a2dc8 100644 --- a/internal/middleware/service_test.go +++ b/internal/middleware/service_test.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "errors" "log/slog" + "os" "regexp" "strconv" "strings" @@ -20,6 +21,9 @@ 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/commerce" + "github.com/agentnameservice/ans-openshell/internal/commercekey" "github.com/agentnameservice/ans-openshell/internal/extauth" "github.com/agentnameservice/ans-openshell/internal/identity" "github.com/agentnameservice/ans-openshell/internal/vetting" @@ -188,14 +192,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 +220,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 +564,7 @@ func TestReasonCodeGrammar(t *testing.T) { for _, code := range []string{ ReasonUnsupportedPhase, ReasonCallerUnauthenticated, ReasonCallerNotSupervisor, ReasonCallerSandboxMismatch, ReasonPolicyInvalid, ReasonTargetInvalid, ReasonIdentityUnknown, - ReasonStatusTokenUnavailable, ReasonReceiptUnavailable, ReasonSignFailed, ReasonCommerceDenied, + ReasonStatusTokenUnavailable, ReasonReceiptUnavailable, ReasonSignFailed, ReasonRewriteDenied, vetting.ReasonUnregistered, vetting.ReasonRevoked, vetting.ReasonUntrustedTL, vetting.ReasonBelowThreshold, vetting.ReasonUnverified, FindingIdentityAttached, FindingIdentityStripped, } { @@ -590,17 +606,9 @@ func TestOutcome_AllowCarriesReplacementBody(t *testing.T) { } } -// fakeCommerceRewriter is a stub commerce rewriter for testing. -type fakeCommerceRewriter struct { - outcome CommerceOutcome -} - -func (f *fakeCommerceRewriter) Rewrite(_ context.Context, _, _, _ string, _ []byte) CommerceOutcome { - return f.outcome -} - // 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() @@ -611,12 +619,10 @@ func TestEvaluate_CommerceRewriteThenSignRewrittenBody(t *testing.T) { id := newIdentity(t, testIdentity, signer, fullArtifacts()) ids := lookup{testIdentity: id} - commerce := &fakeCommerceRewriter{ - outcome: CommerceOutcome{Kind: CommerceAllow, Body: rewritten}, - } + transform := &stubTransform{kind: capability.Allow, body: rewritten} s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) - s.commerce = commerce + s.AddTransform(transform) res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) if err != nil { @@ -657,7 +663,8 @@ func TestEvaluate_CommerceRewriteThenSignRewrittenBody(t *testing.T) { } // TestEvaluate_CommerceDenyShortCircuits verifies that commerce Deny returns -// DECISION_DENY with reason_code ans_commerce_denied, with no Sign call and no body mutation. +// 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() @@ -667,12 +674,10 @@ func TestEvaluate_CommerceDenyShortCircuits(t *testing.T) { id := newIdentity(t, testIdentity, signer, fullArtifacts()) ids := lookup{testIdentity: id} - commerce := &fakeCommerceRewriter{ - outcome: CommerceOutcome{Kind: CommerceDeny, Reason: "missing mandate"}, - } + transform := &stubTransform{kind: capability.Deny, reason: "missing mandate"} s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) - s.commerce = commerce + s.AddTransform(transform) res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), body)) if err != nil { @@ -682,8 +687,9 @@ func TestEvaluate_CommerceDenyShortCircuits(t *testing.T) { if res.GetDecision() != deny { t.Fatalf("decision = %v, want DENY", res.GetDecision()) } - if res.GetReasonCode() != ReasonCommerceDenied { - t.Errorf("reason_code = %q, want %q", res.GetReasonCode(), ReasonCommerceDenied) + // 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 @@ -699,6 +705,7 @@ func TestEvaluate_CommerceDenyShortCircuits(t *testing.T) { // 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() @@ -708,12 +715,10 @@ func TestEvaluate_CommercePassthroughSignsOriginal(t *testing.T) { id := newIdentity(t, testIdentity, signer, fullArtifacts()) ids := lookup{testIdentity: id} - commerce := &fakeCommerceRewriter{ - outcome: CommerceOutcome{Kind: CommercePassthrough}, - } + transform := &stubTransform{kind: capability.Passthrough} s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) - s.commerce = commerce + s.AddTransform(transform) res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) if err != nil { @@ -740,7 +745,8 @@ func TestEvaluate_CommercePassthroughSignsOriginal(t *testing.T) { } // TestEvaluate_CredentialMarkerInRewrittenBodyDenied verifies that the credential -// marker guard runs on the POST-rewrite body, so a rewrite cannot smuggle a marker past it. +// 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() @@ -752,12 +758,250 @@ func TestEvaluate_CredentialMarkerInRewrittenBodyDenied(t *testing.T) { id := newIdentity(t, testIdentity, signer, fullArtifacts()) ids := lookup{testIdentity: id} - commerce := &fakeCommerceRewriter{ - outcome: CommerceOutcome{Kind: CommerceAllow, Body: rewritten}, + 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 + 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, + } +} + +// 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.commerce = commerce + s.AddTransform(transform) res, err := s.EvaluateHttpRequest(t.Context(), evaluation(t, signPolicy(), original)) if err != nil { @@ -776,3 +1020,236 @@ func TestEvaluate_CredentialMarkerInRewrittenBodyDenied(t *testing.T) { t.Errorf("Sign calls = %d, want 0", len(signer.calls)) } } + +// stubDiscoverer implements capability.Discoverer. +type stubDiscoverer struct { + req capability.Requirement +} + +func (s stubDiscoverer) Discover(_ context.Context, _ capability.Target) capability.Requirement { + return s.req +} + +// TestEvaluate_E2E_DiscoveryDrivesCommerce verifies that the discovered Requirement +// drives the commerce transform: a registered discoverer returns the seller Requirement, +// the commerce transform receives it and rewrites the body, and the final result has +// the rewritten body signed with 3 identity headers. +func TestEvaluate_E2E_DiscoveryDrivesCommerce(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} + + // Stub discoverer returns a Requirement with capabilities + DPoP htu + sellerReq := capability.Requirement{ + Tool: "book_flight", + Capabilities: []capability.Capability{"mandate", "dpop_proof"}, + DPoP: &capability.DPoPParams{ + Htu: "https://seller.example.com/mcp/", + }, + } + discoverer := stubDiscoverer{req: sellerReq} + + // Stub transform mimics commerce rewriter: receives Requirement, returns rewritten body + transform := &stubTransform{kind: capability.Allow, body: rewritten} + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) + s.WithDiscoverer(discoverer) + 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) + if len(signer.calls) != 1 { + t.Fatalf("Sign calls = %d, want 1", len(signer.calls)) + } + 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_E2E_DiscoveryEmptyStaticFallback verifies that when discovery returns +// an empty Requirement, the commerce transform's lenient fallback still rewrites the body +// for a seller host/POST/path match, and a WARN is logged. +func TestEvaluate_E2E_DiscoveryEmptyStaticFallback(t *testing.T) { + t.Parallel() + + original := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500.0,"currency":"USD"}}}`) + + signer := &fakeSigner{} + id := newIdentity(t, testIdentity, signer, fullArtifacts()) + ids := lookup{testIdentity: id} + + // Stub discoverer returns empty Requirement (discovery failed) + discoverer := stubDiscoverer{req: capability.Requirement{}} + + // Load a real commerce key for the commerce rewriter + 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) + } + + // Create commerce stubs (fakes from commerce/cache_test.go patterns) + cache := commerce.NewMandateCache() + authority := &fakeCommerceAuthority{mandate: &commerce.Mandate{ + MandateID: "mandate-e2e-test", + QuoteID: "demo-quote-001", + SubjectANS: "ans://v2.0.0.buyer.example.com", + AudienceANS: "ans://v1.0.8.seller.example.com", + Scope: "purchase:flight:demo", + MaxAmount: 500.0, + Currency: "USD", + NotBefore: "2026-01-01T00:00:00Z", + NotAfter: "2026-12-31T23:59:59Z", + JKT: key.JKT(), + AuthorityANS: "ans://v1.0.8.authority.example.com", + Signature: "fake-signature-e2e", + }} + clock := fakeCommerceClock{now: 1735689600} + nonce := &fakeCommerceNonce{jti: "test-jti"} + + deps := commerce.Deps{ + Key: key, + Cache: cache, + Authority: authority, + Clock: clock, + Nonce: nonce, + } + cfg := commerce.Config{ + SellerMCPURL: "https://seller.example.com/mcp/", + SellerANS: "ans://v1.0.8.seller.example.com", + ScopePrefix: "purchase:flight:demo", + SubjectANS: "ans://v2.0.0.buyer.example.com", + MaxBodyBytes: 100000, + } + + // Capture logs to verify WARN + var buf bytes.Buffer + var mu sync.Mutex + handler := slog.NewTextHandler(&syncLogWriter{w: &buf, mu: &mu}, &slog.HandlerOptions{Level: slog.LevelWarn}) + logger := slog.New(handler) + + // Use the REAL NewCommerceRewriter + transform := NewCommerceRewriter(deps, cfg, "seller.example.com", logger) + + // Set up the target to match seller host + POST + /mcp/ (fallback trigger) + policy := map[string]any{"identity": testIdentity} + eval := &middlewarev1.HttpRequestEvaluation{ + Phase: preCredentials, + Context: &middlewarev1.RequestContext{RequestId: "req-1", SandboxId: testSandbox}, + Config: structOf(t, policy), + Target: &middlewarev1.HttpRequestTarget{ + Scheme: "https", Host: "seller.example.com", Port: 443, Method: "POST", Path: "/mcp/", Query: "", + }, + Headers: []*middlewarev1.HttpHeader{}, + Body: original, + } + + s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, logger) + s.WithDiscoverer(discoverer) + s.AddTransform(transform) + + res, err := s.EvaluateHttpRequest(t.Context(), eval) + if err != nil { + t.Fatal(err) + } + + if res.GetDecision() != allow { + t.Fatalf("decision = %v, want ALLOW", res.GetDecision()) + } + + // The body should be rewritten (fallback path) + if !res.GetHasBody() { + t.Errorf("HasBody = false, want true (fallback should rewrite)") + } + + // The signed digest must be sha256(rewritten) + if len(signer.calls) != 1 { + t.Fatalf("Sign calls = %d, want 1", len(signer.calls)) + } + + // Verify the WARN was logged with "commerce static fallback" + mu.Lock() + logs := buf.String() + mu.Unlock() + if !strings.Contains(logs, "commerce static fallback") { + t.Errorf("logs do not contain 'commerce static fallback' WARN; got: %q", logs) + } +} + +// syncLogWriter wraps a bytes.Buffer with mutex for race-free log capture. +type syncLogWriter struct { + w *bytes.Buffer + mu *sync.Mutex +} + +func (sw *syncLogWriter) Write(p []byte) (int, error) { + sw.mu.Lock() + defer sw.mu.Unlock() + return sw.w.Write(p) +} + +// fakeCommerceClock implements commerce.Clock for testing. +type fakeCommerceClock struct { + now int64 +} + +func (f fakeCommerceClock) NowUnix() int64 { + return f.now +} + +// fakeCommerceNonce implements commerce.Nonce for testing. +type fakeCommerceNonce struct { + jti string +} + +func (f *fakeCommerceNonce) JTI() string { + return f.jti +} + +// fakeCommerceAuthority implements commerce.AuthorityClient for testing. +type fakeCommerceAuthority struct { + mandate *commerce.Mandate + err error +} + +func (f *fakeCommerceAuthority) RequestMandate(_ context.Context, _ commerce.MandateInput) (*commerce.Mandate, error) { + if f.err != nil { + return nil, f.err + } + return f.mandate, nil +} From 5bed12469b230a80ce591c5c39f2e40c2b0e5fee Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Mon, 26 Oct 2026 19:37:41 +0200 Subject: [PATCH 06/14] fix(commerce,middleware): mandate byte-forwarding, G1 htu binding, cleanup Forward the authority's mandate bytes verbatim (avoid float-format divergence), bind the identity-proof htu to the transform's external (seller) URL (G1), drop deprecated elliptic.Marshal + unify kid computation, neutralize a doc-comment example. Squashes: 57d5338 b604a91 7428387 c42823e Signed-off-by: Andrei Ciobanu --- cmd/ans-openshell-middleware/main.go | 2 +- internal/callee/callee.go | 17 ++- internal/callee/callee_test.go | 76 ++++++++++ internal/capability/capability.go | 10 +- internal/commerce/authority.go | 35 +++-- internal/commerce/authority_test.go | 61 +++++++- internal/commerce/cache.go | 29 ++-- internal/commerce/cache_test.go | 110 ++++++++++++-- internal/commerce/mandate.go | 2 +- internal/commerce/rewrite.go | 15 +- internal/commerce/rewrite_test.go | 216 ++++++++++++++++++++++++++- internal/commercekey/commercekey.go | 8 +- internal/middleware/commerce.go | 7 +- internal/middleware/commerce_test.go | 99 +++++++++++- internal/middleware/service.go | 11 +- internal/middleware/service_test.go | 122 +++++++++++++-- internal/selfsign/cose.go | 19 ++- internal/testfixtures/keys.go | 15 +- internal/testfixtures/tl.go | 2 +- 19 files changed, 744 insertions(+), 112 deletions(-) diff --git a/cmd/ans-openshell-middleware/main.go b/cmd/ans-openshell-middleware/main.go index 30d2504..76e05d3 100644 --- a/cmd/ans-openshell-middleware/main.go +++ b/cmd/ans-openshell-middleware/main.go @@ -324,7 +324,7 @@ func prefetchDemoMandate(cache *commerce.MandateCache, key *commercekey.Key, cfg Amount: cfg.DemoAmount, Currency: cfg.DemoCurrency, } - _, err := cache.GetOrFetch( + _, _, err := cache.GetOrFetch( context.Background(), //nolint:contextcheck // Prefetch uses background context after server started qk, key.JKT(), diff --git a/internal/callee/callee.go b/internal/callee/callee.go index df0edae..34fc973 100644 --- a/internal/callee/callee.go +++ b/internal/callee/callee.go @@ -197,11 +197,13 @@ func New(ctx context.Context, cfg Config, logger *slog.Logger) (*Handler, error) } if cfg.SellerURL != "" { - // Forward mode: pin htu to seller URL (G1). WithExternalURL requires - // appending r.URL.RequestURI() to bind the full request target. + // 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.RequestURI() + return cfg.SellerURL + r.URL.Path }), pop.WithMiddlewareLogger(logger), pop.WithMiddlewareCallerOptions(callerOpts...), @@ -305,8 +307,13 @@ func forwardToSeller(w http.ResponseWriter, r *http.Request, sellerURL string, c 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, sellerURL, bytes.NewReader(body)) + upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, upstreamURL, bytes.NewReader(body)) //nolint:gosec // G704: upstreamURL is sellerURL (operator config) + verified path if err != nil { logger.ErrorContext(r.Context(), "build upstream request", "err", err) http.Error(w, "failed to build upstream request", http.StatusInternalServerError) @@ -317,7 +324,7 @@ func forwardToSeller(w http.ResponseWriter, r *http.Request, sellerURL string, c } // Send to the seller. - upstreamResp, err := client.Do(upstreamReq) + 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) diff --git a/internal/callee/callee_test.go b/internal/callee/callee_test.go index f058804..abfb73d 100644 --- a/internal/callee/callee_test.go +++ b/internal/callee/callee_test.go @@ -957,3 +957,79 @@ func TestBodyGuard_ReadError(t *testing.T) { 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 index 918dd4c..67f464f 100644 --- a/internal/capability/capability.go +++ b/internal/capability/capability.go @@ -61,10 +61,14 @@ const ( // 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 + Kind OutcomeKind + Body []byte // Set only for Allow + Reason string // Set only for Deny + SignURL string // Set only for Allow } // Target describes the request being evaluated: the destination (Host, Method, Path) diff --git a/internal/commerce/authority.go b/internal/commerce/authority.go index 188ada1..3e547c0 100644 --- a/internal/commerce/authority.go +++ b/internal/commerce/authority.go @@ -75,7 +75,7 @@ type jsonRPCError struct { // 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, error) { +func (h *HTTPAuthority) RequestMandate(ctx context.Context, in MandateInput) (*Mandate, json.RawMessage, error) { // Build JSON-RPC request envelope envelope := jsonRPCRequest{ Jsonrpc: "2.0", @@ -90,27 +90,27 @@ func (h *HTTPAuthority) RequestMandate(ctx context.Context, in MandateInput) (*M // Marshal request to JSON requestBody, err := json.Marshal(envelope) if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) + 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, fmt.Errorf("failed to create request: %w", err) + 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) if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) + 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, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } // Check for non-2xx status @@ -120,7 +120,7 @@ func (h *HTTPAuthority) RequestMandate(ctx context.Context, in MandateInput) (*M if len(truncated) > maxErrBodyLen { truncated = truncated[:maxErrBodyLen] } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncated) + return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncated) } // Parse mandate from response @@ -128,35 +128,38 @@ func (h *HTTPAuthority) RequestMandate(ctx context.Context, in MandateInput) (*M } // parseMandateResponse parses a mandate from a JSON-RPC response body. -// Returns an error if the response contains an error object, has no content, -// or cannot be parsed. -func parseMandateResponse(body []byte) (*Mandate, error) { +// 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, fmt.Errorf("JSON parse failed: %w", err) + return nil, nil, fmt.Errorf("JSON parse failed: %w", err) } // Check for error object if response.Error != nil { - return nil, fmt.Errorf("authority error: %s", response.Error.Message) + return nil, nil, fmt.Errorf("authority error: %s", response.Error.Message) } // Check for result if response.Result == nil { - return nil, errors.New("no result in response") + return nil, nil, errors.New("no result in response") } // Check for content if len(response.Result.Content) == 0 { - return nil, errors.New("no content in authority response") + return nil, nil, errors.New("no content in authority response") } - // Parse mandate from first content item text + // 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, fmt.Errorf("failed to parse mandate from content: %w", err) + return nil, nil, fmt.Errorf("failed to parse mandate from content: %w", err) } - return &mandate, nil + // Return both the parsed mandate and the raw bytes + return &mandate, json.RawMessage(mandateText), nil } diff --git a/internal/commerce/authority_test.go b/internal/commerce/authority_test.go index 8a9b5be..e52b5e3 100644 --- a/internal/commerce/authority_test.go +++ b/internal/commerce/authority_test.go @@ -27,7 +27,7 @@ func TestParseMandateResponse_Success(t *testing.T) { } }` - mandate, err := parseMandateResponse([]byte(responseBody)) + mandate, rawBytes, err := parseMandateResponse([]byte(responseBody)) if err != nil { t.Fatalf("expected no error, got: %v", err) } @@ -41,6 +41,12 @@ func TestParseMandateResponse_Success(t *testing.T) { 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) { @@ -54,7 +60,7 @@ func TestParseMandateResponse_Error(t *testing.T) { } }` - mandate, err := parseMandateResponse([]byte(responseBody)) + mandate, _, err := parseMandateResponse([]byte(responseBody)) if err == nil { t.Fatalf("expected error, got mandate: %+v", mandate) } @@ -77,7 +83,7 @@ func TestParseMandateResponse_NoContent(t *testing.T) { } }` - mandate, err := parseMandateResponse([]byte(responseBody)) + mandate, _, err := parseMandateResponse([]byte(responseBody)) if err == nil { t.Fatalf("expected error for empty content, got mandate: %+v", mandate) } @@ -91,12 +97,55 @@ func TestParseMandateResponse_Malformed(t *testing.T) { // Malformed JSON responseBody := `{"invalid": "not a valid response"}` - mandate, err := parseMandateResponse([]byte(responseBody)) + 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 @@ -183,7 +232,7 @@ func TestHTTPAuthority_RequestMandate(t *testing.T) { }, } - mandate, err := client.RequestMandate(context.Background(), input) + mandate, _, err := client.RequestMandate(context.Background(), input) if err != nil { t.Fatalf("expected no error, got: %v", err) } @@ -214,7 +263,7 @@ func TestHTTPAuthority_RequestMandate(t *testing.T) { Currency: "USD", } - mandate, err := client.RequestMandate(context.Background(), input) + mandate, _, err := client.RequestMandate(context.Background(), input) if err == nil { t.Fatalf("expected error for non-2xx, got mandate: %+v", mandate) } diff --git a/internal/commerce/cache.go b/internal/commerce/cache.go index 8329a00..2b3c312 100644 --- a/internal/commerce/cache.go +++ b/internal/commerce/cache.go @@ -2,6 +2,7 @@ package commerce import ( "context" + "encoding/json" "sync" "time" @@ -17,7 +18,7 @@ type Clock interface { // AuthorityClient requests mandates from an authority service. type AuthorityClient interface { - RequestMandate(ctx context.Context, in MandateInput) (*Mandate, error) + RequestMandate(ctx context.Context, in MandateInput) (*Mandate, json.RawMessage, error) } // MandateInput contains the parameters needed to request a mandate. @@ -31,9 +32,10 @@ type MandateInput struct { TravelerDPoPJWK commercekey.JwkPublic `json:"traveler_dpop_jwk"` } -// cachedMandate holds a cached mandate with its expiry and quote key. +// 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 } @@ -49,9 +51,9 @@ func NewMandateCache() *MandateCache { return &MandateCache{} } -// GetOrFetch returns a cached mandate 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. +// 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, @@ -59,7 +61,7 @@ func (c *MandateCache) GetOrFetch( authority AuthorityClient, clock Clock, build func(QuoteKey) MandateInput, -) (*Mandate, error) { +) (*Mandate, json.RawMessage, error) { c.mu.Lock() defer c.mu.Unlock() @@ -72,36 +74,37 @@ func (c *MandateCache) GetOrFetch( if cached.quoteKey == qk && cached.mandate.JKT == ourJKT && now < cached.notAfterUnix-skewSecs { - return cached.mandate, nil + return cached.mandate, cached.rawBytes, nil } } // Cache miss: fetch new mandate input := build(qk) - mandate, err := authority.RequestMandate(ctx, input) + mandate, rawBytes, err := authority.RequestMandate(ctx, input) if err != nil { - return nil, err + return nil, nil, err } // Validate the mandate if err := Validate(mandate, qk, ourJKT, sellerANS, scopePrefix); err != nil { - return nil, err + 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, err + return nil, nil, err } notAfterUnix := notAfterTime.Unix() - // Store in cache + // Store in cache with raw bytes c.entry = &cachedMandate{ mandate: mandate, + rawBytes: rawBytes, notAfterUnix: notAfterUnix, quoteKey: qk, } - return mandate, nil + return mandate, rawBytes, nil } diff --git a/internal/commerce/cache_test.go b/internal/commerce/cache_test.go index 9a6e351..03bf148 100644 --- a/internal/commerce/cache_test.go +++ b/internal/commerce/cache_test.go @@ -2,7 +2,9 @@ package commerce import ( "context" + "encoding/json" "errors" + "strings" "testing" ) @@ -17,16 +19,23 @@ func (f fakeClock) NowUnix() int64 { // fakeAuthority implements AuthorityClient and counts calls type fakeAuthority struct { - mandate *Mandate - calls int + mandate *Mandate + rawBytes json.RawMessage + calls int } -func (f *fakeAuthority) RequestMandate(_ context.Context, _ MandateInput) (*Mandate, error) { +func (f *fakeAuthority) RequestMandate(_ context.Context, _ MandateInput) (*Mandate, json.RawMessage, error) { f.calls++ if f.mandate == nil { - return nil, errors.New("authority returned nil mandate") + return nil, nil, errors.New("authority returned nil mandate") } - return f.mandate, nil + // 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) { @@ -64,7 +73,7 @@ func TestCache_HitOnMatchingQuoteAndJKT(t *testing.T) { } // First call should fetch from authority - m1, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + m1, raw1, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) if err != nil { t.Fatalf("GetOrFetch failed: %v", err) } @@ -76,7 +85,7 @@ func TestCache_HitOnMatchingQuoteAndJKT(t *testing.T) { } // Second call with same parameters should hit cache - m2, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + 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) } @@ -86,6 +95,11 @@ func TestCache_HitOnMatchingQuoteAndJKT(t *testing.T) { 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) { @@ -124,7 +138,7 @@ func TestCache_MissOnQuoteIDChange(t *testing.T) { AuthorityANS: "ans://authority.example.com", Signature: "sig1", } - _, err := cache.GetOrFetch(context.Background(), qk1, ourJKT, sellerANS, scopePrefix, authority, clock, build) + _, _, err := cache.GetOrFetch(context.Background(), qk1, ourJKT, sellerANS, scopePrefix, authority, clock, build) if err != nil { t.Fatalf("GetOrFetch failed: %v", err) } @@ -147,7 +161,7 @@ func TestCache_MissOnQuoteIDChange(t *testing.T) { AuthorityANS: "ans://authority.example.com", Signature: "sig2", } - _, err = cache.GetOrFetch(context.Background(), qk2, ourJKT, sellerANS, scopePrefix, authority, clock, build) + _, _, err = cache.GetOrFetch(context.Background(), qk2, ourJKT, sellerANS, scopePrefix, authority, clock, build) if err != nil { t.Fatalf("GetOrFetch failed on second call: %v", err) } @@ -194,7 +208,7 @@ func TestCache_MissOnExpiryWithinSkew(t *testing.T) { } // First call should fetch - _, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + _, _, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) if err != nil { t.Fatalf("GetOrFetch failed: %v", err) } @@ -203,7 +217,7 @@ func TestCache_MissOnExpiryWithinSkew(t *testing.T) { } // Second call should refetch because we're within 60s of expiry - _, err = cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + _, _, err = cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) if err != nil { t.Fatalf("GetOrFetch failed on second call: %v", err) } @@ -246,7 +260,7 @@ func TestCache_MissOnJKTChange(t *testing.T) { AuthorityANS: "ans://authority.example.com", Signature: "sig1", } - _, err := cache.GetOrFetch(context.Background(), qk, "jkt1", sellerANS, scopePrefix, authority, clock, build) + _, _, err := cache.GetOrFetch(context.Background(), qk, "jkt1", sellerANS, scopePrefix, authority, clock, build) if err != nil { t.Fatalf("GetOrFetch failed: %v", err) } @@ -269,7 +283,7 @@ func TestCache_MissOnJKTChange(t *testing.T) { AuthorityANS: "ans://authority.example.com", Signature: "sig2", } - _, err = cache.GetOrFetch(context.Background(), qk, "jkt2", sellerANS, scopePrefix, authority, clock, build) + _, _, err = cache.GetOrFetch(context.Background(), qk, "jkt2", sellerANS, scopePrefix, authority, clock, build) if err != nil { t.Fatalf("GetOrFetch failed on second call: %v", err) } @@ -315,7 +329,7 @@ func TestCache_FetchValidatesAndPropagatesError(t *testing.T) { } // Call should fail validation and return error - _, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + _, _, err := cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) if err == nil { t.Fatal("Expected validation error, got nil") } @@ -327,7 +341,7 @@ func TestCache_FetchValidatesAndPropagatesError(t *testing.T) { } // Second call should also fail and refetch (nothing was cached) - _, err = cache.GetOrFetch(context.Background(), qk, ourJKT, sellerANS, scopePrefix, authority, clock, build) + _, _, 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") } @@ -335,3 +349,69 @@ func TestCache_FetchValidatesAndPropagatesError(t *testing.T) { 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/internal/commerce/mandate.go b/internal/commerce/mandate.go index cdce74d..8851778 100644 --- a/internal/commerce/mandate.go +++ b/internal/commerce/mandate.go @@ -40,7 +40,7 @@ type QuoteKey struct { // Mirrors Rust mandate.rs:86-111. // // Examples: -// - "ans://v1.0.8.seller.skybound-travel.com" -> "seller.skybound-travel.com" +// - "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" diff --git a/internal/commerce/rewrite.go b/internal/commerce/rewrite.go index 0b49201..868497b 100644 --- a/internal/commerce/rewrite.go +++ b/internal/commerce/rewrite.go @@ -46,9 +46,10 @@ type Deps struct { // 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 + 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 } // RewriteBookFlight orchestrates mandate fetching and DPoP injection for book_flight requests. @@ -130,7 +131,8 @@ func RewriteBookFlight(ctx context.Context, body []byte, d Deps, cfg Config, htu } // Fetch mandate (with caching and validation) - mandate, err := d.Cache.GetOrFetch( + // Returns typed mandate (already validated by GetOrFetch) and raw bytes (for forwarding) + _, rawMandateBytes, err := d.Cache.GetOrFetch( ctx, qk, d.Key.JKT(), @@ -166,7 +168,8 @@ func RewriteBookFlight(ctx context.Context, body []byte, d Deps, cfg Config, htu } // Splice mandate and dpop_proof into arguments (overwrite if present) - args["mandate"] = mandate + // CRITICAL: Use json.RawMessage so Marshal emits the authority's bytes VERBATIM + args["mandate"] = rawMandateBytes args["dpop_proof"] = proof // Re-marshal the request @@ -180,5 +183,5 @@ func RewriteBookFlight(ctx context.Context, body []byte, d Deps, cfg Config, htu return Outcome{Kind: Deny, Reason: fmt.Sprintf("oversize: %d > %d", len(rewritten), cfg.MaxBodyBytes)} } - return Outcome{Kind: Allow, Body: rewritten} + return Outcome{Kind: Allow, Body: rewritten, SignURL: htu} } diff --git a/internal/commerce/rewrite_test.go b/internal/commerce/rewrite_test.go index c2e8be9..bcdf557 100644 --- a/internal/commerce/rewrite_test.go +++ b/internal/commerce/rewrite_test.go @@ -34,15 +34,22 @@ qxLJH8nppWLHVdJvxMlvmJNLcQh3PRe6mXF1wnTvzWFhZj0c+0/qrjPd // fakeAuthorityWithError is a fake AuthorityClient that can return errors. type fakeAuthorityWithError struct { - mandate *Mandate - err error + mandate *Mandate + rawBytes json.RawMessage + err error } -func (f *fakeAuthorityWithError) RequestMandate(_ context.Context, _ MandateInput) (*Mandate, error) { +func (f *fakeAuthorityWithError) RequestMandate(_ context.Context, _ MandateInput) (*Mandate, json.RawMessage, error) { if f.err != nil { - return nil, f.err + return nil, nil, f.err } - return f.mandate, nil + // 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. @@ -423,3 +430,202 @@ func TestRewrite_DenyOnMandateError(t *testing.T) { 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/internal/commercekey/commercekey.go b/internal/commercekey/commercekey.go index 7e6c54f..c27c3c4 100644 --- a/internal/commercekey/commercekey.go +++ b/internal/commercekey/commercekey.go @@ -76,8 +76,12 @@ func Load(path string) (*Key, error) { 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 - pubBytes := elliptic.Marshal(k.key.Curve, k.key.PublicKey.X, k.key.PublicKey.Y) //nolint:staticcheck // Using X/Y for JWK encoding - const p256UncompressedLen = 65 // 1 prefix + 32 X + 32 Y + 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") } diff --git a/internal/middleware/commerce.go b/internal/middleware/commerce.go index ee62ffb..269f4cd 100644 --- a/internal/middleware/commerce.go +++ b/internal/middleware/commerce.go @@ -64,9 +64,10 @@ func (r *commerceRewriter) Apply(ctx context.Context, target capability.Target, // Delegate to commerce.RewriteBookFlight with discovered htu outcome := commerce.RewriteBookFlight(ctx, body, r.deps, r.cfg, htuOverride) return capability.Outcome{ - Kind: capability.OutcomeKind(outcome.Kind), - Body: outcome.Body, - Reason: outcome.Reason, + Kind: capability.OutcomeKind(outcome.Kind), + Body: outcome.Body, + Reason: outcome.Reason, + SignURL: outcome.SignURL, } } diff --git a/internal/middleware/commerce_test.go b/internal/middleware/commerce_test.go index 40f09ba..d091bc1 100644 --- a/internal/middleware/commerce_test.go +++ b/internal/middleware/commerce_test.go @@ -17,7 +17,7 @@ import ( // panicAuthority panics if RequestMandate is called, proving the gate skipped. type panicAuthority struct{} -func (panicAuthority) RequestMandate(_ context.Context, _ commerce.MandateInput) (*commerce.Mandate, error) { +func (panicAuthority) RequestMandate(_ context.Context, _ commerce.MandateInput) (*commerce.Mandate, json.RawMessage, error) { panic("RequestMandate called when gate should have skipped") } @@ -407,15 +407,22 @@ qxLJH8nppWLHVdJvxMlvmJNLcQh3PRe6mXF1wnTvzWFhZj0c+0/qrjPd } type fakeAuthorityForCommerce struct { - mandate *commerce.Mandate - err error + mandate *commerce.Mandate + rawBytes json.RawMessage + err error } -func (f *fakeAuthorityForCommerce) RequestMandate(_ context.Context, _ commerce.MandateInput) (*commerce.Mandate, error) { +func (f *fakeAuthorityForCommerce) RequestMandate(_ context.Context, _ commerce.MandateInput) (*commerce.Mandate, json.RawMessage, error) { if f.err != nil { - return nil, f.err + return nil, nil, f.err } - return f.mandate, nil + // 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 { @@ -454,3 +461,83 @@ func (b *logBuffer) ContainsLevel(level string) bool { 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) + } + }) +} diff --git a/internal/middleware/service.go b/internal/middleware/service.go index 557fc01..621d6b8 100644 --- a/internal/middleware/service.go +++ b/internal/middleware/service.go @@ -317,6 +317,7 @@ func (s *Service) evaluate(ctx context.Context, req *middlewarev1.HttpRequestEva } // Run transform chain + var signURL string for _, t := range s.rewriters { tr := t.Apply(ctx, capTarget, requirement, signBody) switch tr.Kind { @@ -325,6 +326,8 @@ func (s *Service) evaluate(ctx context.Context, req *middlewarev1.HttpRequestEva case capability.Allow: signBody = tr.Body out.setBody(tr.Body) + // G1: capture SignURL from transforms (last writer wins). + signURL = tr.SignURL case capability.Passthrough: // Continue to next transform } @@ -335,8 +338,12 @@ func (s *Service) evaluate(ctx context.Context, req *middlewarev1.HttpRequestEva return out.deny(&denial{ReasonRequestRewriteUnsupported, "credential placeholders in a signed body or path require signing after credential rewriting"}) } - // Sign the (potentially rewritten) body - if d := s.attach(ctx, id, method, target, signBody, 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() diff --git a/internal/middleware/service_test.go b/internal/middleware/service_test.go index 83a2dc8..e2c8828 100644 --- a/internal/middleware/service_test.go +++ b/internal/middleware/service_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/json" "errors" "log/slog" "os" @@ -783,10 +784,11 @@ func TestEvaluate_CredentialMarkerInRewrittenBodyDenied(t *testing.T) { // stubTransform is a test stub implementing capability.Transform. type stubTransform struct { - kind capability.OutcomeKind - body []byte - reason string - called *bool + kind capability.OutcomeKind + body []byte + reason string + signURL string + called *bool } func (s *stubTransform) Provides() []capability.Capability { @@ -798,9 +800,10 @@ func (s *stubTransform) Apply(_ context.Context, _ capability.Target, _ capabili *s.called = true } return capability.Outcome{ - Kind: s.kind, - Body: s.body, - Reason: s.reason, + Kind: s.kind, + Body: s.body, + Reason: s.reason, + SignURL: s.signURL, } } @@ -1243,13 +1246,108 @@ func (f *fakeCommerceNonce) JTI() string { // fakeCommerceAuthority implements commerce.AuthorityClient for testing. type fakeCommerceAuthority struct { - mandate *commerce.Mandate - err error + mandate *commerce.Mandate + rawBytes json.RawMessage + err error } -func (f *fakeCommerceAuthority) RequestMandate(_ context.Context, _ commerce.MandateInput) (*commerce.Mandate, error) { +func (f *fakeCommerceAuthority) RequestMandate(_ context.Context, _ commerce.MandateInput) (*commerce.Mandate, json.RawMessage, error) { if f.err != nil { - return nil, f.err + return nil, nil, f.err } - return f.mandate, nil + // 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 +} + +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 index e18a023..9061087 100644 --- a/internal/selfsign/cose.go +++ b/internal/selfsign/cose.go @@ -40,7 +40,8 @@ const ( claimIdentityCerts = 6 ) -const kidLen = 4 +// 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, @@ -103,16 +104,22 @@ func mustCBOR(v any) []byte { 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 { +func kidFor(key *ecdsa.PrivateKey) [KidLen]byte { spki, err := x509.MarshalPKIXPublicKey(&key.PublicKey) if err != nil { panic(err) } - h := sha256.Sum256(spki) - var kid [kidLen]byte - copy(kid[:], h[:kidLen]) - return kid + return KidFromSPKI(spki) } // leafEnvelope is the transparency-log leaf a receipt signs: the event wrapped 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/tl.go b/internal/testfixtures/tl.go index a9199da..180d3b4 100644 --- a/internal/testfixtures/tl.go +++ b/internal/testfixtures/tl.go @@ -28,7 +28,7 @@ const statusTokenBackoff = time.Minute // issues to its agents. type TL struct { key *ecdsa.PrivateKey - kid [kidLen]byte + kid [selfsign.KidLen]byte rootKey string keyStore *scitt.KeyStore } From 57dd64ad11e1a04da332c0d6d6042e7f53f7dfdd Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Mon, 26 Oct 2026 19:37:44 +0200 Subject: [PATCH 07/14] feat(demo): live .env-driven two-agent demo + narrated SAFE-reviewer mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-demo.sh drives the live two-agent OpenShell demo end-to-end from a single .env (no values/secrets in the repo; renders configs at runtime). --narrate adds a trust-boundary diagram and a step-by-step walkthrough with the REAL decoded evidence — cert chain, transparency-log vouch, scoped mandate, DPoP proof claims, ticket, and the rogue rejection; --report writes a markdown copy. The egress surfaces the attached mandate/proof and logs their decoded form at DEBUG only. README/RUNBOOK/SECRETS document setup and the no-secret-leak guarantees. Squashes: 875c19c 5cb5284 6af259c 8bbdc70 6fb2df9 fc78aaf ba47018 Signed-off-by: Andrei Ciobanu --- .gitignore | 6 + deploy/examples/middleware-agent-a.yaml | 6 +- deploy/live-demo/.env.example | 56 +++ deploy/live-demo/README.md | 173 +++++++++ deploy/live-demo/RUNBOOK.md | 183 +++++++++ deploy/live-demo/SECRETS.md | 46 +++ deploy/live-demo/run-demo.sh | 495 ++++++++++++++++++++++++ internal/capability/capability.go | 6 + internal/commerce/narrate.go | 54 +++ internal/commerce/narrate_test.go | 89 +++++ internal/commerce/rewrite.go | 6 +- internal/middleware/commerce.go | 10 +- internal/middleware/commerce_test.go | 7 + internal/middleware/service.go | 32 ++ 14 files changed, 1161 insertions(+), 8 deletions(-) create mode 100644 deploy/live-demo/.env.example create mode 100644 deploy/live-demo/README.md create mode 100644 deploy/live-demo/RUNBOOK.md create mode 100644 deploy/live-demo/SECRETS.md create mode 100755 deploy/live-demo/run-demo.sh create mode 100644 internal/commerce/narrate.go create mode 100644 internal/commerce/narrate_test.go diff --git a/.gitignore b/.gitignore index a4a0b55..8a923ba 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ Thumbs.db # Secrets and certificates .env .env.* +!.env.example *.pem *.key *.csr @@ -45,5 +46,10 @@ Thumbs.db # 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/deploy/examples/middleware-agent-a.yaml b/deploy/examples/middleware-agent-a.yaml index 5177875..0f09c48 100644 --- a/deploy/examples/middleware-agent-a.yaml +++ b/deploy/examples/middleware-agent-a.yaml @@ -17,8 +17,8 @@ identities: buyer-agent: ans_name: ans://v2.0.0.buyer.example.com agent_id: 00000000-0000-0000-0000-000000000000 - key_file: /.secrets/example.com/key.pem - cert_file: /.secrets/example.com/cert.pem + key_file: /.secrets/buyer.example.com/key.pem + cert_file: /.secrets/buyer.example.com/cert.pem dev_mode: false sandbox_identities: {} vetting: @@ -34,7 +34,7 @@ commerce: 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/example.com/commerce-dpop.pem + commerce_dpop_key_path: /.secrets/buyer.example.com/commerce-dpop.pem demo_quote_id: demo-quote-001 demo_amount: 500 demo_currency: USD 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..8ee876d --- /dev/null +++ b/deploy/live-demo/README.md @@ -0,0 +1,173 @@ +# 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 build # produces ./bin/ans-openshell-middleware and ./bin/ans-reference-callee + ``` + +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-openshell-middleware" --config "$WORK/agent-a-local.yaml" --log-level info & + "$REPO/bin/ans-openshell-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..796bd91 --- /dev/null +++ b/deploy/live-demo/RUNBOOK.md @@ -0,0 +1,183 @@ +# 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: + +```sh +cd $REPO && make build # ./bin/ans-openshell-middleware, ./bin/ans-reference-callee +``` + +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-openshell-middleware --config $WORK/agent-a-local.yaml --log-level info >egress-a.log 2>&1 & +$REPO/bin/ans-openshell-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..8d34c62 --- /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-openshell-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 "middleware binary not at $MW_BIN — run 'make 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/capability/capability.go b/internal/capability/capability.go index 67f464f..c6b4e0d 100644 --- a/internal/capability/capability.go +++ b/internal/capability/capability.go @@ -69,6 +69,12 @@ type Outcome struct { Body []byte // Set only for Allow Reason string // Set only for Deny SignURL string // Set only for Allow + // MandateBytes and DPoPProof carry the artifacts a transform attached to an + // Allow'd request, for observability only (debug narration). Both may be + // empty when the transform attaches no such artifacts. They are never used + // for control flow. + MandateBytes []byte // Set only for Allow, when the transform attached a mandate + DPoPProof string // Set only for Allow, when the transform minted a DPoP proof } // Target describes the request being evaluated: the destination (Host, Method, Path) diff --git a/internal/commerce/narrate.go b/internal/commerce/narrate.go new file mode 100644 index 0000000..74ff989 --- /dev/null +++ b/internal/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/internal/commerce/narrate_test.go b/internal/commerce/narrate_test.go new file mode 100644 index 0000000..b54fc20 --- /dev/null +++ b/internal/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/internal/commerce/rewrite.go b/internal/commerce/rewrite.go index 868497b..8a870d4 100644 --- a/internal/commerce/rewrite.go +++ b/internal/commerce/rewrite.go @@ -50,6 +50,10 @@ type Outcome struct { 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. @@ -183,5 +187,5 @@ func RewriteBookFlight(ctx context.Context, body []byte, d Deps, cfg Config, htu return Outcome{Kind: Deny, Reason: fmt.Sprintf("oversize: %d > %d", len(rewritten), cfg.MaxBodyBytes)} } - return Outcome{Kind: Allow, Body: rewritten, SignURL: htu} + return Outcome{Kind: Allow, Body: rewritten, SignURL: htu, MandateBytes: rawMandateBytes, DPoPProof: proof} } diff --git a/internal/middleware/commerce.go b/internal/middleware/commerce.go index 269f4cd..a2bc7fb 100644 --- a/internal/middleware/commerce.go +++ b/internal/middleware/commerce.go @@ -64,10 +64,12 @@ func (r *commerceRewriter) Apply(ctx context.Context, target capability.Target, // Delegate to commerce.RewriteBookFlight with discovered htu outcome := commerce.RewriteBookFlight(ctx, body, r.deps, r.cfg, htuOverride) return capability.Outcome{ - Kind: capability.OutcomeKind(outcome.Kind), - Body: outcome.Body, - Reason: outcome.Reason, - SignURL: outcome.SignURL, + Kind: capability.OutcomeKind(outcome.Kind), + Body: outcome.Body, + Reason: outcome.Reason, + SignURL: outcome.SignURL, + MandateBytes: outcome.MandateBytes, + DPoPProof: outcome.DPoPProof, } } diff --git a/internal/middleware/commerce_test.go b/internal/middleware/commerce_test.go index d091bc1..f4474c1 100644 --- a/internal/middleware/commerce_test.go +++ b/internal/middleware/commerce_test.go @@ -190,6 +190,13 @@ func TestCommerce_AppliesViaDiscoveredRequirement(t *testing.T) { if len(outcome.Body) == 0 { t.Fatal("expected body for Allow, got none") } + // The Allow must surface the attached artifacts for debug narration. + if len(outcome.MandateBytes) == 0 { + t.Error("expected MandateBytes surfaced on Allow, got none") + } + if outcome.DPoPProof == "" { + t.Error("expected DPoPProof surfaced on Allow, got empty") + } } func TestCommerce_AppliesViaStaticFallbackWhenEmpty(t *testing.T) { diff --git a/internal/middleware/service.go b/internal/middleware/service.go index 621d6b8..10e0414 100644 --- a/internal/middleware/service.go +++ b/internal/middleware/service.go @@ -19,6 +19,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/commerce" "github.com/agentnameservice/ans-openshell/internal/extauth" "github.com/agentnameservice/ans-openshell/internal/identity" "github.com/agentnameservice/ans-openshell/internal/vetting" @@ -328,6 +329,10 @@ func (s *Service) evaluate(ctx context.Context, req *middlewarev1.HttpRequestEva out.setBody(tr.Body) // G1: capture SignURL from transforms (last writer wins). signURL = tr.SignURL + // Observability only: when debug is on, decode and log the artifacts + // the transform attached (proof claims + mandate summary). Never + // affects control flow; decoding is skipped unless debug is enabled. + s.narrateArtifacts(ctx, tr) case capability.Passthrough: // Continue to next transform } @@ -435,6 +440,33 @@ func (s *Service) attach(ctx context.Context, id *identity.Identity, method, tar return nil } +// narrateArtifacts logs, at DEBUG only, the decoded artifacts a transform +// attached to an Allow'd request: the minted DPoP proof's claims and the +// authority mandate's summary. It is observability for the demo narrator and +// has no effect on the request. Decoding is skipped entirely unless the logger +// is at debug, so there is no cost (and nothing is emitted) in normal operation. +func (s *Service) narrateArtifacts(ctx context.Context, tr capability.Outcome) { + if !s.log.Enabled(ctx, slog.LevelDebug) { + return + } + if tr.DPoPProof != "" { + if c, err := commerce.DecodeDPoPClaims(tr.DPoPProof); err == nil { + s.log.DebugContext(ctx, "dpop proof minted", + "htm", c.Htm, "htu", c.Htu, "ath", c.Ath, "iat", c.Iat, "jti", c.Jti) + } + } + if len(tr.MandateBytes) > 0 { + if m, err := commerce.SummarizeMandate(tr.MandateBytes); err == nil { + s.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) + } + } +} + func destinationSeverity(st vetting.Status) string { switch st { case vetting.StatusRegistered, vetting.StatusExempt: From 348d249c247829fba06b40446791f39ed13426f1 Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Tue, 27 Oct 2026 09:34:40 +0200 Subject: [PATCH 08/14] fix(lint): align gosec G704 suppressions with CI Under the CI toolchain's gosec, the discovery + authority client.Do calls (operator/policy-scoped URLs, not request input) are flagged G704, while the duplicate directive on callee.go's NewRequestWithContext line is unused (the real sink client.Do is already suppressed). Add the three needed suppressions and drop the redundant one. Signed-off-by: Andrei Ciobanu --- internal/callee/callee.go | 2 +- internal/capability/discover.go | 4 ++-- internal/commerce/authority.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/callee/callee.go b/internal/callee/callee.go index 34fc973..e310b5a 100644 --- a/internal/callee/callee.go +++ b/internal/callee/callee.go @@ -313,7 +313,7 @@ func forwardToSeller(w http.ResponseWriter, r *http.Request, sellerURL string, c 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)) //nolint:gosec // G704: upstreamURL is sellerURL (operator config) + verified path + 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) diff --git a/internal/capability/discover.go b/internal/capability/discover.go index ba0cf6f..fcaf1a9 100644 --- a/internal/capability/discover.go +++ b/internal/capability/discover.go @@ -161,7 +161,7 @@ func (d *HTTPDiscoverer) fetchURL(ctx context.Context, url string) ([]byte, erro return nil, err } - resp, err := d.client.Do(req) + 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 } @@ -195,7 +195,7 @@ func (d *HTTPDiscoverer) fetchToolsList(ctx context.Context, mcpURL string) ([]b } req.Header.Set("Content-Type", "application/json") - resp, err := d.client.Do(req) + 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 } diff --git a/internal/commerce/authority.go b/internal/commerce/authority.go index 3e547c0..404c5dc 100644 --- a/internal/commerce/authority.go +++ b/internal/commerce/authority.go @@ -101,7 +101,7 @@ func (h *HTTPAuthority) RequestMandate(ctx context.Context, in MandateInput) (*M req.Header.Set("Content-Type", "application/json") // Send request - resp, err := h.client.Do(req) + 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) } From f2b741d092be417ee82333c8e2e6e0927a819c45 Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Tue, 27 Oct 2026 10:44:36 +0200 Subject: [PATCH 09/14] refactor(app): extract reusable server assembly into app package Pull the generic middleware assembly (identity, caller auth, vetting, gRPC server, readiness, serve loop) out of cmd/ans-openshell-middleware into a new exported app package: app.Run(ctx, cfg, logger, ...Option). A WithCustomize option receives the built *middleware.Service so a caller can register capability transforms + a discoverer before serving. The exported location (not internal/) lets a separate module (the forthcoming demo) reuse it. The production main shrinks to flag parsing + config load + app.Run; commerce is wired via WithCustomize for now (it moves to the demo module in a later commit). Server-assembly tests move to app/app_test.go. No behavior change. Signed-off-by: Andrei Ciobanu --- app/app.go | 221 ++++++++++++++++++ .../main_test.go => app/app_test.go | 23 +- cmd/ans-openshell-middleware/main.go | 219 ++++------------- 3 files changed, 274 insertions(+), 189 deletions(-) create mode 100644 app/app.go rename cmd/ans-openshell-middleware/main_test.go => app/app_test.go (93%) 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 76e05d3..95f6707 100644 --- a/cmd/ans-openshell-middleware/main.go +++ b/cmd/ans-openshell-middleware/main.go @@ -13,52 +13,26 @@ import ( "net/http" "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/capability" "github.com/agentnameservice/ans-openshell/internal/commerce" "github.com/agentnameservice/ans-openshell/internal/commercekey" "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 -) - +// fileConfig is the YAML the binary loads: the generic app.Config plus the +// (currently in-tree) commerce block. 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"` - // Commerce enables commerce rewriting for Tier 2 demo. + app.Config `yaml:",inline"` + // Commerce enables commerce rewriting for the demo. Commerce config.Commerce `yaml:"commerce"` } @@ -96,100 +70,48 @@ func run() error { 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 + opts := []app.Option{app.WithVersion(version)} + if cfg.Commerce.Enabled() { + opts = append(opts, app.WithCustomize(commerceCustomizer(cfg.Commerce))) } - svc := middleware.NewService(registry, authz, vet, serviceConfig(cfg), logger) + return app.Run(ctx, cfg.Config, logger, opts...) +} - // Wire commerce if enabled - if cfg.Commerce.Enabled() { - transform, startPrefetch, err := setupCommerce(cfg, logger) +// commerceCustomizer returns an app customize hook that registers the commerce +// transform + discoverer and starts the optional demo prefetch. +func commerceCustomizer(cc config.Commerce) func(context.Context, *middleware.Service, *slog.Logger) error { + return func(ctx context.Context, svc *middleware.Service, logger *slog.Logger) error { + transform, startPrefetch, err := setupCommerce(cc, logger) if err != nil { return err } - - // Wire the discoverer (no I/O at startup; discovery happens per-request) - ttl := cfg.Commerce.DiscoveryTTL + 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(transform) if startPrefetch != nil { startPrefetch() } logger.InfoContext(ctx, "commerce rewriter enabled") + return nil } - - 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", version, "identities", registry.Names(), "vetting", vet != nil, "commerce", cfg.Commerce.Enabled()) - return srv.Serve(ctx) } // setupCommerce builds the commerce transform and returns an optional demo-prefetch closure. -func setupCommerce(cfg fileConfig, logger *slog.Logger) (capability.Transform, func(), error) { - if err := cfg.Commerce.Validate(); err != nil { +func setupCommerce(cc config.Commerce, logger *slog.Logger) (capability.Transform, func(), error) { + if err := cc.Validate(); err != nil { return nil, nil, fmt.Errorf("commerce config: %w", err) } - keyPath := cfg.Commerce.CommerceDPoPKeyPath + keyPath := cc.CommerceDPoPKeyPath if keyPath == "" { keyPath = os.Getenv("COMMERCE_DPOP_KEY") } @@ -198,7 +120,7 @@ func setupCommerce(cfg fileConfig, logger *slog.Logger) (capability.Transform, f return nil, nil, fmt.Errorf("commerce key: %w", err) } cache := commerce.NewMandateCache() - authority := commerce.NewHTTPAuthority(cfg.Commerce.AuthorityMCPURL) + authority := commerce.NewHTTPAuthority(cc.AuthorityMCPURL) clock := realClock{} nonce := &uuidNonce{} deps := commerce.Deps{ @@ -209,88 +131,27 @@ func setupCommerce(cfg fileConfig, logger *slog.Logger) (capability.Transform, f Nonce: nonce, } commerceCfg := commerce.Config{ - SellerMCPURL: cfg.Commerce.SellerMCPURL, - SellerANS: cfg.Commerce.SellerANS, - ScopePrefix: cfg.Commerce.ScopeHint, - SubjectANS: cfg.Commerce.SubjectANS, - MaxBodyBytes: cfg.Commerce.MaxBodyBytes, + SellerMCPURL: cc.SellerMCPURL, + SellerANS: cc.SellerANS, + ScopePrefix: cc.ScopeHint, + SubjectANS: cc.SubjectANS, + MaxBodyBytes: cc.MaxBodyBytes, } - sellerHost, err := middleware.ParseSellerHost(cfg.Commerce.SellerMCPURL) + sellerHost, err := middleware.ParseSellerHost(cc.SellerMCPURL) if err != nil { return nil, nil, fmt.Errorf("commerce seller host: %w", err) } rewriter := middleware.NewCommerceRewriter(deps, commerceCfg, sellerHost, logger) - // Return optional demo-prefetch closure if demo fields are set var prefetch func() - if cfg.Commerce.DemoQuoteID != "" && cfg.Commerce.DemoAmount > 0 && cfg.Commerce.DemoCurrency != "" { + if cc.DemoQuoteID != "" && cc.DemoAmount > 0 && cc.DemoCurrency != "" { prefetch = func() { - go prefetchDemoMandate(cache, key, cfg.Commerce, authority, clock, logger) + go prefetchDemoMandate(cache, key, cc, authority, clock, logger) } } return rewriter, prefetch, nil } -// 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 -} - // realClock implements commerce.Clock using time.Now(). type realClock struct{} @@ -317,19 +178,19 @@ func (u *uuidNonce) JTI() string { } // prefetchDemoMandate warms the mandate cache with a demo quote in the background. -func prefetchDemoMandate(cache *commerce.MandateCache, key *commercekey.Key, cfg config.Commerce, +func prefetchDemoMandate(cache *commerce.MandateCache, key *commercekey.Key, cc config.Commerce, authority commerce.AuthorityClient, clock commerce.Clock, logger *slog.Logger) { qk := commerce.QuoteKey{ - QuoteID: cfg.DemoQuoteID, - Amount: cfg.DemoAmount, - Currency: cfg.DemoCurrency, + 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(), - cfg.SellerANS, - cfg.ScopeHint, + cc.SellerANS, + cc.ScopeHint, authority, clock, func(qk commerce.QuoteKey) commerce.MandateInput { @@ -337,9 +198,9 @@ func prefetchDemoMandate(cache *commerce.MandateCache, key *commercekey.Key, cfg QuoteID: qk.QuoteID, Total: qk.Amount, Currency: qk.Currency, - MerchantANS: cfg.SellerANS, - ScopeHint: cfg.ScopeHint, - SubjectANS: cfg.SubjectANS, + MerchantANS: cc.SellerANS, + ScopeHint: cc.ScopeHint, + SubjectANS: cc.SubjectANS, TravelerDPoPJWK: key.PublicJWK(), } }, @@ -347,6 +208,6 @@ func prefetchDemoMandate(cache *commerce.MandateCache, key *commercekey.Key, cfg if err != nil { logger.WarnContext(context.Background(), "demo mandate prefetch failed", "error", err) } else { - logger.InfoContext(context.Background(), "demo mandate prefetched", "quote_id", cfg.DemoQuoteID) + logger.InfoContext(context.Background(), "demo mandate prefetched", "quote_id", cc.DemoQuoteID) } } From 21ab0fd3dd6c860984bedccbee9da7f17220640a Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Tue, 27 Oct 2026 10:51:45 +0200 Subject: [PATCH 10/14] refactor(capability): generic Artifacts map; decouple core from commerce Replace Outcome.MandateBytes/DPoPProof (commerce-named) with a generic Artifacts map[string]any the core treats as opaque, plus an optional Narratable interface a transform may implement to render its artifacts at debug. service.go no longer imports internal/commerce: it just calls Narratable if the transform implements it. The commerce adapter now stashes its mandate/proof into Artifacts and implements Narrate (the decoding moved out of the core). Extracted a narrate() helper to keep evaluate() under the cognitive-complexity gate. No behavior change. Signed-off-by: Andrei Ciobanu --- internal/capability/capability.go | 26 ++++++++++---- internal/middleware/commerce.go | 52 ++++++++++++++++++++++++---- internal/middleware/commerce_test.go | 10 +++--- internal/middleware/service.go | 36 ++++--------------- 4 files changed, 76 insertions(+), 48 deletions(-) diff --git a/internal/capability/capability.go b/internal/capability/capability.go index c6b4e0d..5e5a89e 100644 --- a/internal/capability/capability.go +++ b/internal/capability/capability.go @@ -4,7 +4,10 @@ // result from applying transforms (Outcome). package capability -import "context" +import ( + "context" + "log/slog" +) // Capability represents a capability identifier from the card vocabulary // (e.g., "mandate", "dpop_proof") or an extension URI. @@ -69,12 +72,12 @@ type Outcome struct { Body []byte // Set only for Allow Reason string // Set only for Deny SignURL string // Set only for Allow - // MandateBytes and DPoPProof carry the artifacts a transform attached to an - // Allow'd request, for observability only (debug narration). Both may be - // empty when the transform attaches no such artifacts. They are never used - // for control flow. - MandateBytes []byte // Set only for Allow, when the transform attached a mandate - DPoPProof string // Set only for Allow, when the transform minted a DPoP proof + // 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) @@ -99,3 +102,12 @@ type Transform interface { // 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/middleware/commerce.go b/internal/middleware/commerce.go index a2bc7fb..00dc764 100644 --- a/internal/middleware/commerce.go +++ b/internal/middleware/commerce.go @@ -63,13 +63,51 @@ func (r *commerceRewriter) Apply(ctx context.Context, target capability.Target, // Delegate to commerce.RewriteBookFlight with discovered htu outcome := commerce.RewriteBookFlight(ctx, body, r.deps, r.cfg, htuOverride) - return capability.Outcome{ - Kind: capability.OutcomeKind(outcome.Kind), - Body: outcome.Body, - Reason: outcome.Reason, - SignURL: outcome.SignURL, - MandateBytes: outcome.MandateBytes, - DPoPProof: outcome.DPoPProof, + out := capability.Outcome{ + Kind: capability.OutcomeKind(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 +} + +// 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) + } } } diff --git a/internal/middleware/commerce_test.go b/internal/middleware/commerce_test.go index f4474c1..dbc1cac 100644 --- a/internal/middleware/commerce_test.go +++ b/internal/middleware/commerce_test.go @@ -190,12 +190,12 @@ func TestCommerce_AppliesViaDiscoveredRequirement(t *testing.T) { if len(outcome.Body) == 0 { t.Fatal("expected body for Allow, got none") } - // The Allow must surface the attached artifacts for debug narration. - if len(outcome.MandateBytes) == 0 { - t.Error("expected MandateBytes surfaced on 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 outcome.DPoPProof == "" { - t.Error("expected DPoPProof surfaced on Allow, got empty") + if proof, ok := outcome.Artifacts[artifactDPoPProof].(string); !ok || proof == "" { + t.Error("expected dpop proof in Artifacts on Allow, got empty") } } diff --git a/internal/middleware/service.go b/internal/middleware/service.go index 10e0414..43bbea8 100644 --- a/internal/middleware/service.go +++ b/internal/middleware/service.go @@ -19,7 +19,6 @@ 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/commerce" "github.com/agentnameservice/ans-openshell/internal/extauth" "github.com/agentnameservice/ans-openshell/internal/identity" "github.com/agentnameservice/ans-openshell/internal/vetting" @@ -329,10 +328,7 @@ func (s *Service) evaluate(ctx context.Context, req *middlewarev1.HttpRequestEva out.setBody(tr.Body) // G1: capture SignURL from transforms (last writer wins). signURL = tr.SignURL - // Observability only: when debug is on, decode and log the artifacts - // the transform attached (proof claims + mandate summary). Never - // affects control flow; decoding is skipped unless debug is enabled. - s.narrateArtifacts(ctx, tr) + s.narrate(ctx, t, tr) // observability only; no effect on the request case capability.Passthrough: // Continue to next transform } @@ -440,30 +436,12 @@ func (s *Service) attach(ctx context.Context, id *identity.Identity, method, tar return nil } -// narrateArtifacts logs, at DEBUG only, the decoded artifacts a transform -// attached to an Allow'd request: the minted DPoP proof's claims and the -// authority mandate's summary. It is observability for the demo narrator and -// has no effect on the request. Decoding is skipped entirely unless the logger -// is at debug, so there is no cost (and nothing is emitted) in normal operation. -func (s *Service) narrateArtifacts(ctx context.Context, tr capability.Outcome) { - if !s.log.Enabled(ctx, slog.LevelDebug) { - return - } - if tr.DPoPProof != "" { - if c, err := commerce.DecodeDPoPClaims(tr.DPoPProof); err == nil { - s.log.DebugContext(ctx, "dpop proof minted", - "htm", c.Htm, "htu", c.Htu, "ath", c.Ath, "iat", c.Iat, "jti", c.Jti) - } - } - if len(tr.MandateBytes) > 0 { - if m, err := commerce.SummarizeMandate(tr.MandateBytes); err == nil { - s.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) - } +// 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) } } From bd74518e7131574db1da8200df2a2265603f5483 Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Tue, 27 Oct 2026 11:05:30 +0200 Subject: [PATCH 11/14] refactor(tutorial): move commerce into a separate dev-only module Commerce (AP2 mandate + DPoP) is a demo vertical, not generic middleware, so it moves out of the core module entirely into a new in-repo module 'tutorial' (github.com/agentnameservice/ans-openshell/tutorial): internal/commerce -> tutorial/commerce internal/commercekey -> tutorial/commercekey internal/middleware/commerce.go (the capability.Transform adapter) -> tutorial/transform (package transform) The production binary + core packages are now commerce-free (verified: no internal/commerce references outside tutorial/). The prod main is pure generic middleware over app.Run. Two service-level commerce E2E tests were dropped from internal/middleware (their coverage is retained by tutorial/transform tests + the stub-based evaluate tests + the live demo); service_test.go no longer imports commerce. Module wiring uses a git-ignored go.work (harness/CI generate it); no replace directive in go.mod. Named 'tutorial' to avoid colliding with the existing examples/demo and the make-demo binary. Signed-off-by: Andrei Ciobanu --- cmd/ans-openshell-middleware/main.go | 159 +------------ internal/middleware/service_test.go | 223 ------------------ {internal => tutorial}/commerce/authority.go | 0 .../commerce/authority_test.go | 2 +- {internal => tutorial}/commerce/cache.go | 2 +- {internal => tutorial}/commerce/cache_test.go | 0 {internal => tutorial}/commerce/dpop.go | 2 +- {internal => tutorial}/commerce/dpop_test.go | 2 +- {internal => tutorial}/commerce/mandate.go | 0 .../commerce/mandate_test.go | 0 {internal => tutorial}/commerce/narrate.go | 0 .../commerce/narrate_test.go | 0 {internal => tutorial}/commerce/rewrite.go | 2 +- .../commerce/rewrite_test.go | 2 +- .../commercekey/commercekey.go | 0 .../commercekey/commercekey_test.go | 2 +- tutorial/go.mod | 5 + .../transform/transform.go | 4 +- .../transform/transform_test.go | 6 +- 19 files changed, 24 insertions(+), 387 deletions(-) rename {internal => tutorial}/commerce/authority.go (100%) rename {internal => tutorial}/commerce/authority_test.go (99%) rename {internal => tutorial}/commerce/cache.go (97%) rename {internal => tutorial}/commerce/cache_test.go (100%) rename {internal => tutorial}/commerce/dpop.go (98%) rename {internal => tutorial}/commerce/dpop_test.go (98%) rename {internal => tutorial}/commerce/mandate.go (100%) rename {internal => tutorial}/commerce/mandate_test.go (100%) rename {internal => tutorial}/commerce/narrate.go (100%) rename {internal => tutorial}/commerce/narrate_test.go (100%) rename {internal => tutorial}/commerce/rewrite.go (99%) rename {internal => tutorial}/commerce/rewrite_test.go (99%) rename {internal => tutorial}/commercekey/commercekey.go (100%) rename {internal => tutorial}/commercekey/commercekey_test.go (99%) create mode 100644 tutorial/go.mod rename internal/middleware/commerce.go => tutorial/transform/transform.go (98%) rename internal/middleware/commerce_test.go => tutorial/transform/transform_test.go (99%) diff --git a/cmd/ans-openshell-middleware/main.go b/cmd/ans-openshell-middleware/main.go index 95f6707..594b933 100644 --- a/cmd/ans-openshell-middleware/main.go +++ b/cmd/ans-openshell-middleware/main.go @@ -1,41 +1,29 @@ // 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 demo module +// (demo/cmd/ans-demo-middleware), which reuses the same app.Run and registers +// its transform via an option. 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/commerce" - "github.com/agentnameservice/ans-openshell/internal/commercekey" "github.com/agentnameservice/ans-openshell/internal/config" "github.com/agentnameservice/ans-openshell/internal/logging" - "github.com/agentnameservice/ans-openshell/internal/middleware" ) // version is set at build time with -ldflags "-X main.version=...". var version = "dev" -// fileConfig is the YAML the binary loads: the generic app.Config plus the -// (currently in-tree) commerce block. -type fileConfig struct { - app.Config `yaml:",inline"` - // Commerce enables commerce rewriting for the demo. - Commerce config.Commerce `yaml:"commerce"` -} - func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "ans-openshell-middleware:", err) @@ -63,7 +51,7 @@ 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 } @@ -76,138 +64,5 @@ func run() error { 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 returns an app customize hook that registers the commerce -// transform + discoverer and starts the optional demo prefetch. -func commerceCustomizer(cc config.Commerce) func(context.Context, *middleware.Service, *slog.Logger) error { - return func(ctx context.Context, svc *middleware.Service, logger *slog.Logger) error { - transform, 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(transform) - if startPrefetch != nil { - startPrefetch() - } - logger.InfoContext(ctx, "commerce rewriter enabled") - return nil - } -} - -// setupCommerce builds the commerce transform and returns an optional demo-prefetch closure. -func setupCommerce(cc config.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 := middleware.ParseSellerHost(cc.SellerMCPURL) - if err != nil { - return nil, nil, fmt.Errorf("commerce seller host: %w", err) - } - rewriter := middleware.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()) - } - // UUID v4 format: set version (4) and variant (10b) - 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 config.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) - } + return app.Run(ctx, cfg, logger, app.WithVersion(version)) } diff --git a/internal/middleware/service_test.go b/internal/middleware/service_test.go index e2c8828..2089373 100644 --- a/internal/middleware/service_test.go +++ b/internal/middleware/service_test.go @@ -4,10 +4,8 @@ import ( "bytes" "context" "crypto/sha256" - "encoding/json" "errors" "log/slog" - "os" "regexp" "strconv" "strings" @@ -23,8 +21,6 @@ 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/commerce" - "github.com/agentnameservice/ans-openshell/internal/commercekey" "github.com/agentnameservice/ans-openshell/internal/extauth" "github.com/agentnameservice/ans-openshell/internal/identity" "github.com/agentnameservice/ans-openshell/internal/vetting" @@ -1033,187 +1029,6 @@ func (s stubDiscoverer) Discover(_ context.Context, _ capability.Target) capabil return s.req } -// TestEvaluate_E2E_DiscoveryDrivesCommerce verifies that the discovered Requirement -// drives the commerce transform: a registered discoverer returns the seller Requirement, -// the commerce transform receives it and rewrites the body, and the final result has -// the rewritten body signed with 3 identity headers. -func TestEvaluate_E2E_DiscoveryDrivesCommerce(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} - - // Stub discoverer returns a Requirement with capabilities + DPoP htu - sellerReq := capability.Requirement{ - Tool: "book_flight", - Capabilities: []capability.Capability{"mandate", "dpop_proof"}, - DPoP: &capability.DPoPParams{ - Htu: "https://seller.example.com/mcp/", - }, - } - discoverer := stubDiscoverer{req: sellerReq} - - // Stub transform mimics commerce rewriter: receives Requirement, returns rewritten body - transform := &stubTransform{kind: capability.Allow, body: rewritten} - - s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, nil) - s.WithDiscoverer(discoverer) - 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) - if len(signer.calls) != 1 { - t.Fatalf("Sign calls = %d, want 1", len(signer.calls)) - } - 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_E2E_DiscoveryEmptyStaticFallback verifies that when discovery returns -// an empty Requirement, the commerce transform's lenient fallback still rewrites the body -// for a seller host/POST/path match, and a WARN is logged. -func TestEvaluate_E2E_DiscoveryEmptyStaticFallback(t *testing.T) { - t.Parallel() - - original := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"book_flight","arguments":{"quote_id":"demo-quote-001","amount":500.0,"currency":"USD"}}}`) - - signer := &fakeSigner{} - id := newIdentity(t, testIdentity, signer, fullArtifacts()) - ids := lookup{testIdentity: id} - - // Stub discoverer returns empty Requirement (discovery failed) - discoverer := stubDiscoverer{req: capability.Requirement{}} - - // Load a real commerce key for the commerce rewriter - 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) - } - - // Create commerce stubs (fakes from commerce/cache_test.go patterns) - cache := commerce.NewMandateCache() - authority := &fakeCommerceAuthority{mandate: &commerce.Mandate{ - MandateID: "mandate-e2e-test", - QuoteID: "demo-quote-001", - SubjectANS: "ans://v2.0.0.buyer.example.com", - AudienceANS: "ans://v1.0.8.seller.example.com", - Scope: "purchase:flight:demo", - MaxAmount: 500.0, - Currency: "USD", - NotBefore: "2026-01-01T00:00:00Z", - NotAfter: "2026-12-31T23:59:59Z", - JKT: key.JKT(), - AuthorityANS: "ans://v1.0.8.authority.example.com", - Signature: "fake-signature-e2e", - }} - clock := fakeCommerceClock{now: 1735689600} - nonce := &fakeCommerceNonce{jti: "test-jti"} - - deps := commerce.Deps{ - Key: key, - Cache: cache, - Authority: authority, - Clock: clock, - Nonce: nonce, - } - cfg := commerce.Config{ - SellerMCPURL: "https://seller.example.com/mcp/", - SellerANS: "ans://v1.0.8.seller.example.com", - ScopePrefix: "purchase:flight:demo", - SubjectANS: "ans://v2.0.0.buyer.example.com", - MaxBodyBytes: 100000, - } - - // Capture logs to verify WARN - var buf bytes.Buffer - var mu sync.Mutex - handler := slog.NewTextHandler(&syncLogWriter{w: &buf, mu: &mu}, &slog.HandlerOptions{Level: slog.LevelWarn}) - logger := slog.New(handler) - - // Use the REAL NewCommerceRewriter - transform := NewCommerceRewriter(deps, cfg, "seller.example.com", logger) - - // Set up the target to match seller host + POST + /mcp/ (fallback trigger) - policy := map[string]any{"identity": testIdentity} - eval := &middlewarev1.HttpRequestEvaluation{ - Phase: preCredentials, - Context: &middlewarev1.RequestContext{RequestId: "req-1", SandboxId: testSandbox}, - Config: structOf(t, policy), - Target: &middlewarev1.HttpRequestTarget{ - Scheme: "https", Host: "seller.example.com", Port: 443, Method: "POST", Path: "/mcp/", Query: "", - }, - Headers: []*middlewarev1.HttpHeader{}, - Body: original, - } - - s := NewService(ids, sandboxAuthorizer(testSandbox), nil, Config{}, logger) - s.WithDiscoverer(discoverer) - s.AddTransform(transform) - - res, err := s.EvaluateHttpRequest(t.Context(), eval) - if err != nil { - t.Fatal(err) - } - - if res.GetDecision() != allow { - t.Fatalf("decision = %v, want ALLOW", res.GetDecision()) - } - - // The body should be rewritten (fallback path) - if !res.GetHasBody() { - t.Errorf("HasBody = false, want true (fallback should rewrite)") - } - - // The signed digest must be sha256(rewritten) - if len(signer.calls) != 1 { - t.Fatalf("Sign calls = %d, want 1", len(signer.calls)) - } - - // Verify the WARN was logged with "commerce static fallback" - mu.Lock() - logs := buf.String() - mu.Unlock() - if !strings.Contains(logs, "commerce static fallback") { - t.Errorf("logs do not contain 'commerce static fallback' WARN; got: %q", logs) - } -} - // syncLogWriter wraps a bytes.Buffer with mutex for race-free log capture. type syncLogWriter struct { w *bytes.Buffer @@ -1226,44 +1041,6 @@ func (sw *syncLogWriter) Write(p []byte) (int, error) { return sw.w.Write(p) } -// fakeCommerceClock implements commerce.Clock for testing. -type fakeCommerceClock struct { - now int64 -} - -func (f fakeCommerceClock) NowUnix() int64 { - return f.now -} - -// fakeCommerceNonce implements commerce.Nonce for testing. -type fakeCommerceNonce struct { - jti string -} - -func (f *fakeCommerceNonce) JTI() string { - return f.jti -} - -// fakeCommerceAuthority implements commerce.AuthorityClient for testing. -type fakeCommerceAuthority struct { - mandate *commerce.Mandate - rawBytes json.RawMessage - err error -} - -func (f *fakeCommerceAuthority) 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 -} - func TestEvaluate_TransformSignURLBindsIdentityProof(t *testing.T) { //nolint:tparallel // subtests share signer state t.Parallel() diff --git a/internal/commerce/authority.go b/tutorial/commerce/authority.go similarity index 100% rename from internal/commerce/authority.go rename to tutorial/commerce/authority.go diff --git a/internal/commerce/authority_test.go b/tutorial/commerce/authority_test.go similarity index 99% rename from internal/commerce/authority_test.go rename to tutorial/commerce/authority_test.go index e52b5e3..d1660a3 100644 --- a/internal/commerce/authority_test.go +++ b/tutorial/commerce/authority_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/agentnameservice/ans-openshell/internal/commercekey" + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" ) func TestParseMandateResponse_Success(t *testing.T) { diff --git a/internal/commerce/cache.go b/tutorial/commerce/cache.go similarity index 97% rename from internal/commerce/cache.go rename to tutorial/commerce/cache.go index 2b3c312..27ebaf4 100644 --- a/internal/commerce/cache.go +++ b/tutorial/commerce/cache.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/agentnameservice/ans-openshell/internal/commercekey" + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" ) const skewSecs = 60 diff --git a/internal/commerce/cache_test.go b/tutorial/commerce/cache_test.go similarity index 100% rename from internal/commerce/cache_test.go rename to tutorial/commerce/cache_test.go diff --git a/internal/commerce/dpop.go b/tutorial/commerce/dpop.go similarity index 98% rename from internal/commerce/dpop.go rename to tutorial/commerce/dpop.go index ec03286..aa82293 100644 --- a/internal/commerce/dpop.go +++ b/tutorial/commerce/dpop.go @@ -9,7 +9,7 @@ import ( "encoding/json" "fmt" - "github.com/agentnameservice/ans-openshell/internal/commercekey" + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" ) const ( diff --git a/internal/commerce/dpop_test.go b/tutorial/commerce/dpop_test.go similarity index 98% rename from internal/commerce/dpop_test.go rename to tutorial/commerce/dpop_test.go index 7c72f12..d03778d 100644 --- a/internal/commerce/dpop_test.go +++ b/tutorial/commerce/dpop_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/agentnameservice/ans-openshell/internal/commercekey" + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" ) // loadTestKey loads a fixed test key for deterministic tests. diff --git a/internal/commerce/mandate.go b/tutorial/commerce/mandate.go similarity index 100% rename from internal/commerce/mandate.go rename to tutorial/commerce/mandate.go diff --git a/internal/commerce/mandate_test.go b/tutorial/commerce/mandate_test.go similarity index 100% rename from internal/commerce/mandate_test.go rename to tutorial/commerce/mandate_test.go diff --git a/internal/commerce/narrate.go b/tutorial/commerce/narrate.go similarity index 100% rename from internal/commerce/narrate.go rename to tutorial/commerce/narrate.go diff --git a/internal/commerce/narrate_test.go b/tutorial/commerce/narrate_test.go similarity index 100% rename from internal/commerce/narrate_test.go rename to tutorial/commerce/narrate_test.go diff --git a/internal/commerce/rewrite.go b/tutorial/commerce/rewrite.go similarity index 99% rename from internal/commerce/rewrite.go rename to tutorial/commerce/rewrite.go index 8a870d4..eb2b9d7 100644 --- a/internal/commerce/rewrite.go +++ b/tutorial/commerce/rewrite.go @@ -6,7 +6,7 @@ import ( "encoding/json" "fmt" - "github.com/agentnameservice/ans-openshell/internal/commercekey" + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" ) // Nonce provides unique JWT IDs for DPoP token replay prevention. diff --git a/internal/commerce/rewrite_test.go b/tutorial/commerce/rewrite_test.go similarity index 99% rename from internal/commerce/rewrite_test.go rename to tutorial/commerce/rewrite_test.go index bcdf557..e02cade 100644 --- a/internal/commerce/rewrite_test.go +++ b/tutorial/commerce/rewrite_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/agentnameservice/ans-openshell/internal/commercekey" + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" ) // loadTestKey loads a fixed test key for deterministic tests. diff --git a/internal/commercekey/commercekey.go b/tutorial/commercekey/commercekey.go similarity index 100% rename from internal/commercekey/commercekey.go rename to tutorial/commercekey/commercekey.go diff --git a/internal/commercekey/commercekey_test.go b/tutorial/commercekey/commercekey_test.go similarity index 99% rename from internal/commercekey/commercekey_test.go rename to tutorial/commercekey/commercekey_test.go index 2feb2c7..1851046 100644 --- a/internal/commercekey/commercekey_test.go +++ b/tutorial/commercekey/commercekey_test.go @@ -15,7 +15,7 @@ import ( "strings" "testing" - "github.com/agentnameservice/ans-openshell/internal/commercekey" + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" ) func TestLoad_FileAndEnvShadow(t *testing.T) { 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/internal/middleware/commerce.go b/tutorial/transform/transform.go similarity index 98% rename from internal/middleware/commerce.go rename to tutorial/transform/transform.go index 00dc764..188aa8c 100644 --- a/internal/middleware/commerce.go +++ b/tutorial/transform/transform.go @@ -1,4 +1,4 @@ -package middleware +package transform import ( "context" @@ -6,8 +6,8 @@ import ( "net/http" "net/url" + "github.com/agentnameservice/ans-openshell/tutorial/commerce" "github.com/agentnameservice/ans-openshell/internal/capability" - "github.com/agentnameservice/ans-openshell/internal/commerce" ) // commerceRewriter implements capability.Transform with discovery-aware + lenient fallback logic. diff --git a/internal/middleware/commerce_test.go b/tutorial/transform/transform_test.go similarity index 99% rename from internal/middleware/commerce_test.go rename to tutorial/transform/transform_test.go index dbc1cac..58b25d4 100644 --- a/internal/middleware/commerce_test.go +++ b/tutorial/transform/transform_test.go @@ -1,4 +1,4 @@ -package middleware +package transform import ( "context" @@ -9,9 +9,9 @@ import ( "strings" "testing" + "github.com/agentnameservice/ans-openshell/tutorial/commerce" + "github.com/agentnameservice/ans-openshell/tutorial/commercekey" "github.com/agentnameservice/ans-openshell/internal/capability" - "github.com/agentnameservice/ans-openshell/internal/commerce" - "github.com/agentnameservice/ans-openshell/internal/commercekey" ) // panicAuthority panics if RequestMandate is called, proving the gate skipped. From 56d2264e9e4ae0cffc5564297f1d8c4fb8663c15 Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Tue, 27 Oct 2026 11:23:48 +0200 Subject: [PATCH 12/14] refactor(tutorial): add demo binary; remove commerce config from core The production binary (cmd/ans-openshell-middleware) now links zero commerce code. The commerce vertical lives entirely in the tutorial module, assembled by its own dev-only binary (tutorial/cmd/ans-demo-middleware) that reuses the generic app.Run and registers the commerce transform + discoverer via an option. - tutorial/cmd/ans-demo-middleware: the demo binary (commerce customizer, mandate prefetch, DPoP clock/nonce), built via the go.work workspace. - tutorial/config: the Commerce config block (moved out of internal/config); the core config no longer parses or knows about commerce. - tutorial/deploy/examples_test.go: the demo example-config test (moved from deploy/, asserts tutorialcfg.Commerce); deployment_test.go stays in core. - internal/middleware/service_test.go: drop helpers left unused after the M3 commerce E2E test removal. Verified: GOWORK=off go list -deps ./cmd/ans-openshell-middleware | grep -c commerce = 0; both modules build and test clean; lint shows only the known CI-authoritative gosec-directive set. Signed-off-by: Andrei Ciobanu --- internal/config/config.go | 63 ------ internal/config/config_test.go | 146 ------------- internal/middleware/service_test.go | 21 -- tutorial/cmd/ans-demo-middleware/main.go | 215 +++++++++++++++++++ tutorial/config/config.go | 74 +++++++ tutorial/config/config_test.go | 143 ++++++++++++ {deploy => tutorial/deploy}/examples_test.go | 21 +- tutorial/transform/transform.go | 2 +- tutorial/transform/transform_test.go | 2 +- 9 files changed, 447 insertions(+), 240 deletions(-) create mode 100644 tutorial/cmd/ans-demo-middleware/main.go create mode 100644 tutorial/config/config.go create mode 100644 tutorial/config/config_test.go rename {deploy => tutorial/deploy}/examples_test.go (93%) diff --git a/internal/config/config.go b/internal/config/config.go index a4ea94b..5aedd7d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -118,69 +118,6 @@ type Vetting struct { ExemptHosts []string `yaml:"exempt_hosts"` } -// Commerce configures the commerce rewriter for Tier 2 demo. -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. -func (c Commerce) Enabled() bool { - return c.CommerceDPoPKeyPath != "" || os.Getenv("COMMERCE_DPOP_KEY") != "" -} - -// Validate checks that required fields are set 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 -} - // Load reads the YAML file at path into dst, rejecting unknown fields so a // misspelled key cannot silently disable a control. func Load(path string, dst any) error { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 845cdc9..9bbdb5a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -163,149 +163,3 @@ func TestLoad(t *testing.T) { t.Fatal("Load() = nil for a missing file") } } - -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 - if err := Parse([]byte(yml), &c); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - 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) - } - - // Marshal back and check - var c2 Commerce - if err := Parse([]byte(yml), &c2); err != nil { - t.Fatalf("re-parse: %v", err) - } - if c2.SellerMCPURL != c.SellerMCPURL { - t.Errorf("round-trip mismatch") - } - }) - - 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{} - // Not enabled, Validate should pass - if err := c.Validate(); err != nil { - t.Errorf("Validate() = %v for disabled config", err) - } - - // Enabled but missing fields - c.CommerceDPoPKeyPath = "/path/to/key.json" - if err := c.Validate(); err == nil { - t.Errorf("Validate() = nil, want error for missing URLs/ANS") - } - - // Set all required fields - 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 - if err := Parse([]byte(yml), &c); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if c.DiscoveryTTL != 5*time.Minute { - t.Errorf("DiscoveryTTL = %v, want 5m", c.DiscoveryTTL) - } - - // Test omitted field yields zero - 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 - if err := Parse([]byte(yml2), &c2); err != nil { - t.Fatalf("unmarshal without discovery_ttl: %v", err) - } - if c2.DiscoveryTTL != 0 { - t.Errorf("DiscoveryTTL = %v, want 0 when omitted", c2.DiscoveryTTL) - } - }) -} diff --git a/internal/middleware/service_test.go b/internal/middleware/service_test.go index 2089373..d2c86f9 100644 --- a/internal/middleware/service_test.go +++ b/internal/middleware/service_test.go @@ -1020,27 +1020,6 @@ func TestEvaluate_CredentialMarkerOnFinalBytes(t *testing.T) { } } -// stubDiscoverer implements capability.Discoverer. -type stubDiscoverer struct { - req capability.Requirement -} - -func (s stubDiscoverer) Discover(_ context.Context, _ capability.Target) capability.Requirement { - return s.req -} - -// syncLogWriter wraps a bytes.Buffer with mutex for race-free log capture. -type syncLogWriter struct { - w *bytes.Buffer - mu *sync.Mutex -} - -func (sw *syncLogWriter) Write(p []byte) (int, error) { - sw.mu.Lock() - defer sw.mu.Unlock() - return sw.w.Write(p) -} - func TestEvaluate_TransformSignURLBindsIdentityProof(t *testing.T) { //nolint:tparallel // subtests share signer state t.Parallel() diff --git a/tutorial/cmd/ans-demo-middleware/main.go b/tutorial/cmd/ans-demo-middleware/main.go new file mode 100644 index 0000000..2c35855 --- /dev/null +++ b/tutorial/cmd/ans-demo-middleware/main.go @@ -0,0 +1,215 @@ +// 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" + +// fileConfig is the YAML the demo binary loads: the generic app.Config plus the +// tutorial commerce block. +type fileConfig struct { + app.Config `yaml:",inline"` + Commerce tutorialcfg.Commerce `yaml:"commerce"` +} + +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 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/config/config.go b/tutorial/config/config.go new file mode 100644 index 0000000..25e117a --- /dev/null +++ b/tutorial/config/config.go @@ -0,0 +1,74 @@ +// 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" +) + +// 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/deploy/examples_test.go b/tutorial/deploy/examples_test.go similarity index 93% rename from deploy/examples_test.go rename to tutorial/deploy/examples_test.go index c67fe7e..dbaf45f 100644 --- a/deploy/examples_test.go +++ b/tutorial/deploy/examples_test.go @@ -9,10 +9,15 @@ import ( "github.com/agentnameservice/ans-openshell/internal/config" "github.com/agentnameservice/ans-openshell/internal/identity" + tutorialcfg "github.com/agentnameservice/ans-openshell/tutorial/config" ) -// middlewareFile mirrors the unexported fileConfig from cmd/ans-openshell-middleware/main.go -// for testing example YAML files. +// 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/" + +// middlewareFile mirrors the demo binary's fileConfig (cmd/ans-demo-middleware): +// the generic middleware config plus the tutorial commerce block. type middlewareFile struct { Listen string `yaml:"listen"` HealthListen string `yaml:"health_listen"` @@ -24,7 +29,7 @@ type middlewareFile struct { Vetting *config.Vetting `yaml:"vetting"` MaxPayloadBytes uint64 `yaml:"max_payload_bytes"` RequestTimeout time.Duration `yaml:"request_timeout"` - Commerce config.Commerce `yaml:"commerce"` + Commerce tutorialcfg.Commerce `yaml:"commerce"` } // loadMiddlewareFile loads a middleware YAML config into the mirror struct. @@ -38,7 +43,7 @@ func loadMiddlewareFile(t *testing.T, path string) middlewareFile { } func TestExampleConfig_AgentA(t *testing.T) { - cfg := loadMiddlewareFile(t, "examples/middleware-agent-a.yaml") + cfg := loadMiddlewareFile(t, examplesDir+"middleware-agent-a.yaml") if cfg.Listen != ":50051" { t.Errorf("Listen = %q, want :50051", cfg.Listen) @@ -89,7 +94,7 @@ func TestExampleConfig_AgentA(t *testing.T) { } func TestExampleConfig_AgentB(t *testing.T) { - cfg := loadMiddlewareFile(t, "examples/middleware-agent-b.yaml") + cfg := loadMiddlewareFile(t, examplesDir+"middleware-agent-b.yaml") if cfg.Listen != ":50052" { t.Errorf("Listen = %q, want :50052", cfg.Listen) @@ -129,11 +134,11 @@ func TestExampleConfig_AgentB(t *testing.T) { func TestDemoWiring_NamesAndHostsAgree(t *testing.T) { // Parse the two sandbox policies - policyA := loadPolicyFile(t, "examples/policy-a.yaml") - policyB := loadPolicyFile(t, "examples/policy-b.yaml") + policyA := loadPolicyFile(t, examplesDir+"policy-a.yaml") + policyB := loadPolicyFile(t, examplesDir+"policy-b.yaml") // Parse the Tier 2 gateway registration - gatewayMiddlewares := loadGatewayTier2(t, "examples/gateway-tier2.toml") + 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 diff --git a/tutorial/transform/transform.go b/tutorial/transform/transform.go index 188aa8c..92707a8 100644 --- a/tutorial/transform/transform.go +++ b/tutorial/transform/transform.go @@ -6,8 +6,8 @@ import ( "net/http" "net/url" - "github.com/agentnameservice/ans-openshell/tutorial/commerce" "github.com/agentnameservice/ans-openshell/internal/capability" + "github.com/agentnameservice/ans-openshell/tutorial/commerce" ) // commerceRewriter implements capability.Transform with discovery-aware + lenient fallback logic. diff --git a/tutorial/transform/transform_test.go b/tutorial/transform/transform_test.go index 58b25d4..9981905 100644 --- a/tutorial/transform/transform_test.go +++ b/tutorial/transform/transform_test.go @@ -9,9 +9,9 @@ import ( "strings" "testing" + "github.com/agentnameservice/ans-openshell/internal/capability" "github.com/agentnameservice/ans-openshell/tutorial/commerce" "github.com/agentnameservice/ans-openshell/tutorial/commercekey" - "github.com/agentnameservice/ans-openshell/internal/capability" ) // panicAuthority panics if RequestMandate is called, proving the gate skipped. From 858258ebd16ec862c6473fdf1d21b1ab09cc7b34 Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Tue, 27 Oct 2026 11:34:24 +0200 Subject: [PATCH 13/14] build(tutorial): demo-build target, CI for the tutorial module, docs Wire the two-module layout into the build harness, CI, and docs. - Makefile: 'workspace' (re)generates the gitignored go.work with a version-pinned replace; 'demo-build' builds bin/ans-demo-middleware via it. The production 'build' target is unchanged and needs no workspace. - run-demo.sh: MW_BIN now defaults to bin/ans-demo-middleware (the demo egress is the production middleware plus the commerce tutorial transform); the preflight hint points at 'make demo-build'. - ci.yml: a new 'tutorial' job generates the workspace (not committed) and runs build, demo-binary build, race tests, and lint for ./tutorial/...; the core build-and-test and lint jobs stay core-only (no go.work in a fresh checkout). - README: a Repository layout section documenting the core/tutorial split and that the shipped binary contains no commerce code; live-demo README/RUNBOOK build the demo binary and run it as the egress. Signed-off-by: Andrei Ciobanu --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ Makefile | 17 ++++++++++++++++- README.md | 20 ++++++++++++++++++++ deploy/live-demo/README.md | 12 +++++++++--- deploy/live-demo/RUNBOOK.md | 11 +++++++---- deploy/live-demo/run-demo.sh | 4 ++-- 6 files changed, 82 insertions(+), 10 deletions(-) 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/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/deploy/live-demo/README.md b/deploy/live-demo/README.md index 8ee876d..162456f 100644 --- a/deploy/live-demo/README.md +++ b/deploy/live-demo/README.md @@ -53,8 +53,14 @@ Nothing here is committed. 1. **Build the Go binaries** (from the repo root): ```sh - make build # produces ./bin/ans-openshell-middleware and ./bin/ans-reference-callee + 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). @@ -85,8 +91,8 @@ Nothing here is committed. # 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-openshell-middleware" --config "$WORK/agent-a-local.yaml" --log-level info & - "$REPO/bin/ans-openshell-middleware" --config "$WORK/agent-b-local.yaml" --log-level info & + "$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" \ diff --git a/deploy/live-demo/RUNBOOK.md b/deploy/live-demo/RUNBOOK.md index 796bd91..cc9eca6 100644 --- a/deploy/live-demo/RUNBOOK.md +++ b/deploy/live-demo/RUNBOOK.md @@ -32,10 +32,13 @@ 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: +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 build # ./bin/ans-openshell-middleware, ./bin/ans-reference-callee +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, @@ -75,8 +78,8 @@ one is unreachable — so **egresses first, gateway last**. ```sh cd $WORK -$REPO/bin/ans-openshell-middleware --config $WORK/agent-a-local.yaml --log-level info >egress-a.log 2>&1 & -$REPO/bin/ans-openshell-middleware --config $WORK/agent-b-local.yaml --log-level info >egress-b.log 2>&1 & +$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 & ``` diff --git a/deploy/live-demo/run-demo.sh b/deploy/live-demo/run-demo.sh index 8d34c62..21d12c3 100755 --- a/deploy/live-demo/run-demo.sh +++ b/deploy/live-demo/run-demo.sh @@ -94,7 +94,7 @@ done export OPENSHELL_GATEWAY_ENDPOINT OSH="${OPENSHELL:-$WORK/bin/openshell}" -MW_BIN="${MW_BIN:-$REPO/bin/ans-openshell-middleware}" +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\"}}}" @@ -139,7 +139,7 @@ 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 "middleware binary not at $MW_BIN — run 'make build' in $REPO (or set MW_BIN=...)" +[ -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 From d6ac6fdab0a61ebb03b9c9f1acd326ab394d00f9 Mon Sep 17 00:00:00 2001 From: Andrei Ciobanu Date: Tue, 27 Oct 2026 12:10:43 +0200 Subject: [PATCH 14/14] =?UTF-8?q?refactor(tutorial):=20address=20PR=20revi?= =?UTF-8?q?ew=20=E2=80=94=20enum=20guard,=20shared=20FileConfig,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the demo/core separation, from a self-review of PR #11: - cmd/ans-openshell-middleware: fix the stale 'demo/cmd/...' doc path (renamed to tutorial/cmd/... during extraction) and document that the production binary strict-rejects a config carrying a commerce: block, since app.Config has no commerce field and the loader is strict. - tutorial/transform: replace the raw capability.OutcomeKind(outcome.Kind) int conversion with an explicit capabilityKind switch so the commerce and capability enums are not assumed to share integer values; an unknown kind fails closed to Deny. Add TestCapabilityKind to pin the mapping. - tutorial/config: add an exported FileConfig (app.Config inline + Commerce) used by both the demo binary and the example-config test, so the test exercises the real load path instead of a hand-maintained mirror struct that could silently drift from app.Config. Signed-off-by: Andrei Ciobanu --- cmd/ans-openshell-middleware/main.go | 11 ++++++++--- tutorial/cmd/ans-demo-middleware/main.go | 9 +-------- tutorial/config/config.go | 11 +++++++++++ tutorial/deploy/examples_test.go | 25 +++++------------------- tutorial/transform/transform.go | 20 ++++++++++++++++++- tutorial/transform/transform_test.go | 24 +++++++++++++++++++++++ 6 files changed, 68 insertions(+), 32 deletions(-) diff --git a/cmd/ans-openshell-middleware/main.go b/cmd/ans-openshell-middleware/main.go index 594b933..1f60026 100644 --- a/cmd/ans-openshell-middleware/main.go +++ b/cmd/ans-openshell-middleware/main.go @@ -2,9 +2,14 @@ // 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 demo module -// (demo/cmd/ans-demo-middleware), which reuses the same app.Run and registers -// its transform via an option. +// 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 ( diff --git a/tutorial/cmd/ans-demo-middleware/main.go b/tutorial/cmd/ans-demo-middleware/main.go index 2c35855..afa7e7d 100644 --- a/tutorial/cmd/ans-demo-middleware/main.go +++ b/tutorial/cmd/ans-demo-middleware/main.go @@ -32,13 +32,6 @@ import ( // version is set at build time with -ldflags "-X main.version=...". var version = "dev" -// fileConfig is the YAML the demo binary loads: the generic app.Config plus the -// tutorial commerce block. -type fileConfig struct { - app.Config `yaml:",inline"` - Commerce tutorialcfg.Commerce `yaml:"commerce"` -} - func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "ans-demo-middleware:", err) @@ -66,7 +59,7 @@ func run() error { if err != nil { return err } - var cfg fileConfig + var cfg tutorialcfg.FileConfig if err := config.Load(*configPath, &cfg); err != nil { return err } diff --git a/tutorial/config/config.go b/tutorial/config/config.go index 25e117a..2c9c5c7 100644 --- a/tutorial/config/config.go +++ b/tutorial/config/config.go @@ -7,8 +7,19 @@ 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). diff --git a/tutorial/deploy/examples_test.go b/tutorial/deploy/examples_test.go index dbaf45f..c58b787 100644 --- a/tutorial/deploy/examples_test.go +++ b/tutorial/deploy/examples_test.go @@ -5,7 +5,6 @@ import ( "os/exec" "strings" "testing" - "time" "github.com/agentnameservice/ans-openshell/internal/config" "github.com/agentnameservice/ans-openshell/internal/identity" @@ -16,26 +15,12 @@ import ( // repo's deploy/examples directory (this test lives in the tutorial module). const examplesDir = "../../deploy/examples/" -// middlewareFile mirrors the demo binary's fileConfig (cmd/ans-demo-middleware): -// the generic middleware config plus the tutorial commerce block. -type middlewareFile struct { - Listen string `yaml:"listen"` - 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 *config.Vetting `yaml:"vetting"` - MaxPayloadBytes uint64 `yaml:"max_payload_bytes"` - RequestTimeout time.Duration `yaml:"request_timeout"` - Commerce tutorialcfg.Commerce `yaml:"commerce"` -} - -// loadMiddlewareFile loads a middleware YAML config into the mirror struct. -func loadMiddlewareFile(t *testing.T, path string) middlewareFile { +// 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 middlewareFile + var cfg tutorialcfg.FileConfig if err := config.Load(path, &cfg); err != nil { t.Fatalf("failed to load %s: %v", path, err) } diff --git a/tutorial/transform/transform.go b/tutorial/transform/transform.go index 92707a8..7e1317e 100644 --- a/tutorial/transform/transform.go +++ b/tutorial/transform/transform.go @@ -64,7 +64,7 @@ func (r *commerceRewriter) Apply(ctx context.Context, target capability.Target, // Delegate to commerce.RewriteBookFlight with discovered htu outcome := commerce.RewriteBookFlight(ctx, body, r.deps, r.cfg, htuOverride) out := capability.Outcome{ - Kind: capability.OutcomeKind(outcome.Kind), + Kind: capabilityKind(outcome.Kind), Body: outcome.Body, Reason: outcome.Reason, SignURL: outcome.SignURL, @@ -80,6 +80,24 @@ func (r *commerceRewriter) Apply(ctx context.Context, target capability.Target, 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" diff --git a/tutorial/transform/transform_test.go b/tutorial/transform/transform_test.go index 9981905..f6c0e10 100644 --- a/tutorial/transform/transform_test.go +++ b/tutorial/transform/transform_test.go @@ -548,3 +548,27 @@ func TestCommerce_SurfacesSignURL(t *testing.T) { //nolint:tparallel // subtests } }) } + +// 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) + } +}