diff --git a/config/mappings/pmkisan/grievance.init.yaml b/config/mappings/pmkisan/grievance.init.yaml new file mode 100644 index 000000000..6b92d71ed --- /dev/null +++ b/config/mappings/pmkisan/grievance.init.yaml @@ -0,0 +1,47 @@ +# PM-KISAN grievance init -> /ChatbotOTP on the OTP service: send an OTP. + +request: | + { + "Types": "Ben_id", + "Values": beckn.message.contract.commitments[0].commitmentAttributes.enrolmentId + } + +# "True" is sent (on_init DRAFT, challenge.issued); anything else is a 502. +response: | + $exists(_status) + ? {} + : ( + $true := function($v) { $exists($v) and $lowercase($string($v)) = "true" }; + $sent := $true(response.Rsponce) or $true(response.Responce) or $true(response.Status); + $commitment := beckn.message.contract.commitments[0]; + $ca := $commitment.commitmentAttributes; + + $otpWindowMillis := 10 * 60 * 1000; + $not($sent) + ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", + "message": "PM-KISAN could not send the OTP" } } + : { + "context": $merge([beckn.context, { "action": "on_init", "timestamp": $now() }]), + "message": { + "contract": $merge([beckn.message.contract, { + "commitments": [$merge([$commitment, { + "status": { "descriptor": { "code": "DRAFT" } }, + "commitmentAttributes": { + "@context": $ca.`@context`, + "@type": $ca.`@type`, + "informationMode": "OnDemand", + "scheme": $ca.scheme, + "challenge": { + "issued": { + "method": "SMS_OTP", + "length": 4, + "valueType": "NUMERIC", + "expiresAt": $fromMillis($millis() + $otpWindowMillis) + } + } + } + }])] + }]) + } + } + ) diff --git a/config/mappings/pmkisan/grievance.status.yaml b/config/mappings/pmkisan/grievance.status.yaml new file mode 100644 index 000000000..5a83941f0 --- /dev/null +++ b/config/mappings/pmkisan/grievance.status.yaml @@ -0,0 +1,90 @@ +# PM-KISAN grievance status -> /GrievanceStatusCheck, matched on case.filedOn. + +request: | + { + "Type": "Reg_No_Status", + "IdentityNo": beckn.message.contract.commitments[0].commitmentAttributes.enrolmentId + } + +# "False": 502; no record on filedOn: 400. Only allow-listed fields are returned. +response: | + $exists(_status) + ? {} + : ( + $false := function($v) { $exists($v) and $lowercase($string($v)) = "false" }; + $refused := $false(response.Status) or $false(response.Responce) + or $false(response.Rsponce) or $false(response.status); + + $c := beckn.message.contract.commitments[0]; + $ca := $c.commitmentAttributes; + $records := $type(response) = "array" ? response + : $type(response.details) = "array" ? response.details + : $exists(response.details) ? [response.details] + : []; + + /* ISO or dd/MM/yyyy (or dd-MM-yyyy) to ISO; time dropped. */ + $iso := function($d) { + ( + $day := $type($d) = "string" ? $split($trim($d), /[ T]/)[0]; + $exists($match($day, /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/)[0]) ? $day + : $exists($match($day, /^[0-9]{2}[\/-][0-9]{2}[\/-][0-9]{4}$/)[0]) + ? $substring($day, 6, 4) & "-" & $substring($day, 3, 2) & "-" & $substring($day, 0, 2) + ) + }; + /* Non-empty trimmed string, else absent. */ + $text := function($v) { + $type($v) = "string" and $length($trim($v)) > 0 ? $trim($v) + }; + /* CaseStatusCode by lowercased, letters-only phrase. */ + $codes := { + "registered": "Registered", "underreview": "UnderReview", + "awaitingapplicant": "AwaitingApplicant", "replied": "Replied", + "disposed": "Replied", + "resolved": "Resolved", "rejected": "Rejected", "closed": "Closed" + }; + + $record := $filter($records, function($r) { $iso($r.GrievanceDate) = $ca.case.filedOn })[0]; + $phrase := $text($record.GrievanceStatus); + $remark := $text($record.OfficerReply); + $complaint := $text($record.GrievanceDescription); + + $refused + ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", + "message": "PM-KISAN could not read the grievance" } } + : $not($exists($record)) + ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", + "message": "No grievance matches this registration number and filing date." } } + : { + "context": $merge([beckn.context, { "action": "on_status", "timestamp": $now() }]), + "message": { + "contract": { + "id": beckn.message.contract.id, + "commitments": [{ + "status": { "descriptor": { "code": "ACTIVE" } }, + "offer": $c.offer, + "resources": $c.resources, + "commitmentAttributes": { + "@context": $ca.`@context`, + "@type": $ca.`@type`, + "informationMode": "Direct", + "scheme": $ca.scheme, + "grievance": $complaint ? { "description": $complaint }, + "case": { + "status": { + "code": $phrase + ? ( + $known := $lookup($codes, $lowercase($replace($phrase, /[^A-Za-z]/, ""))); + $exists($known) ? $known : "UnderReview" + ) + : ($remark ? "Replied" : "UnderReview") + }, + "filedOn": $iso($record.GrievanceDate), + "reply": $remark, + "repliedOn": $remark ? $iso($record.OfficeReplyDate) + } + } + }] + } + } + } + ) diff --git a/config/mappings/pmkisan/grievance.support.yaml b/config/mappings/pmkisan/grievance.support.yaml new file mode 100644 index 000000000..3567163cf --- /dev/null +++ b/config/mappings/pmkisan/grievance.support.yaml @@ -0,0 +1,79 @@ +# PM-KISAN grievance support -> /LodgeGrievance: lodge a grievance. + +request: | + ( + $ch := beckn.message.support.channels[0]; + { + "Type": "Reg_No_Details", + "IdentityNo": $ch.enrolmentId, + "GrievanceType": $ch.grievance.category.code, + "GrievanceDescription": $trim($ch.grievance.description) + } + ) + +# Known refusal: 400, else 502 (ADR-0004). Success adds case.ticketNo and filedOn (IST). +response: | + $exists(_status) + ? {} + : ( + $false := function($v) { $exists($v) and $lowercase($string($v)) = "false" }; + $refused := $false(response.Status) or $false(response.Responce) + or $false(response.Rsponce) or $false(response.status); + /* Text or number; empty is none. */ + $handle := function($v) { + ($type($v) = "string" and $v != "") or $type($v) = "number" ? $string($v) + }; + $ticketNo := $exists($handle(response.GrievanceID)) + ? $handle(response.GrievanceID) + : $handle(response.GrievanceNo); + $ch := beckn.message.support.channels[0]; + + /* The portal's reason, lower-cased, to what the caller is told. */ + $KNOWN_REFUSALS := { + "registration number not found": { + "message": "PM-KISAN has no registration for this enrolmentId.", + "path": "$.message.support.channels[0].enrolmentId" + } + }; + $portalMessage := ( + $m := response.message ? response.message : response.Message; + $type($m) = "string" ? $trim($m) + ); + $known := $portalMessage ? $lookup($KNOWN_REFUSALS, $lowercase($portalMessage)); + + $filedOn := $exists(beckn.context.timestamp) + ? $fromMillis($toMillis(beckn.context.timestamp), "[Y0001]-[M01]-[D01]", "+0530") + : $now("[Y0001]-[M01]-[D01]", "+0530"); + $refused and $exists($known) + ? { "_error": { "status": 400, "code": "BIZ_GENERIC_ERROR", + "message": $known.message, "path": $known.path, "cause": $portalMessage } } + : $refused + ? { "_error": { "status": 502, "code": "NET_DOWNSTREAM_UNAVAILABLE", + "message": "PM-KISAN did not accept the grievance", "cause": $portalMessage } } + : { + "context": $merge([beckn.context, { "action": "on_support", "timestamp": $now() }]), + "message": { + "support": { + "channels": [{ + "@context": $ch.`@context`, + "@type": $ch.`@type`, + "informationMode": "Direct", + "provider": $ch.provider, + "scheme": $ch.scheme, + "enrolmentId": $ch.enrolmentId, + /* The code and description sent; a name beside the code is dropped. */ + "grievance": { + "category": { "code": $ch.grievance.category.code }, + "description": $ch.grievance.description + }, + "case": { + "ticketNo": $ticketNo, + "status": { "code": "Registered" }, + /* IST: status matches on this date. */ + "filedOn": $filedOn + } + }] + } + } + } + ) diff --git a/config/provider-adapter.yaml b/config/provider-adapter.yaml index 4c94f991d..f6dd62b61 100644 --- a/config/provider-adapter.yaml +++ b/config/provider-adapter.yaml @@ -232,6 +232,9 @@ modules: # one-line change. extendedSchema_allowedDomains: "raw.githubusercontent.com,schema.beckn.io,schema.nfh.global" + # Fetch api-schemas/ from the branch until the published URL serves it (ADR-0003). + extendedSchema_contextRewrite: "https://openagrinet.github.io/network-specs/api-schemas/|https://raw.githubusercontent.com/OpenAgriNet/network-specs/api-schema-packs-v0.1/api-schemas/" + extendedSchema_cacheTTL: "86400" # 24h extendedSchema_maxCacheSize: "100" extendedSchema_downloadTimeout: "30" @@ -507,10 +510,11 @@ modules: # ------------------------------------------------------------------ # Grievance -- support files a grievance, status reads it back. + # PM-KISAN: init (OTP), then support or status with the OTP. # ------------------------------------------------------------------ - id: Grievance config: - bindingKeys: "pmfby|openagrinet:Grievance" + bindingKeys: "pmfby|openagrinet:Grievance,pmkisan|openagrinet:Grievance" # Paired by position, tried in order: status names the binding on # its contract, support on its channel. @@ -531,6 +535,27 @@ modules: tokenExpiryField: responseDynamic.token.validTo headerName: Authorization + # PM-KISAN grievance: encrypted bodies, OTP on the chatbot service (ADR-0001, ADR-0002). + pmkisan: + authScheme: none + envelope: aesGcm + envelopeKeyEnv: PMKISAN_GRIEVANCE_KEY + envelopeNonceEnv: PMKISAN_GRIEVANCE_IV + envelopeTokenEnv: PMKISAN_SERVICE_TOKEN + envelopeTokenField: TokenNo + envelopeRequestField: EncryptedRequest + envelopeResponsePaths: "d.output,output" + otpEnvelope: aesCbcKeyInBand + otpBindingKey: "pmkisan-chatbot|openagrinet:SchemeApplication" + otpTokenEnv: PMKISAN_OTP_TOKEN + otpTokenField: Token + otpRequestField: EncryptedRequest + otpResponsePaths: "d.output,output" + otpActions: init + otpVerifyPath: /ChatbotOTPVerified + otpVerifyActions: "support,status" + otpVerifyTimeoutMs: "10000" + # Declaring a step above is not enough: THIS list is what runs. A step # that appears under providerSteps but not here never executes, and the # request falls through to the 404 above -- which looks like a registry diff --git a/pkg/plugin/implementation/Grievance/cmd/plugin.go b/pkg/plugin/implementation/Grievance/cmd/plugin.go index dae3fc93a..75a033f4e 100644 --- a/pkg/plugin/implementation/Grievance/cmd/plugin.go +++ b/pkg/plugin/implementation/Grievance/cmd/plugin.go @@ -41,8 +41,16 @@ func (p grievanceProvider) parseConfig(config map[string]string) (*Grievance.Con cfg.MaxResponseBytes = value } + // Envelope settings first: the auth parser refuses unknown settings. + envelopes, rest, err := Grievance.ParseEnvelopes(config) + if err != nil { + return nil, err + } + cfg.EnvelopeByProvider = envelopes.Envelopes + cfg.HostedElsewhere = envelopes.HostedElsewhere + // One credential profile per provider. - auth, err := common.ParseProviderAuth(config) + auth, err := common.ParseProviderAuth(rest) if err != nil { return nil, err } diff --git a/pkg/plugin/implementation/Grievance/cmd/pmkisan_test.go b/pkg/plugin/implementation/Grievance/cmd/pmkisan_test.go new file mode 100644 index 000000000..ca111d8b5 --- /dev/null +++ b/pkg/plugin/implementation/Grievance/cmd/pmkisan_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "os" + "strings" + "testing" + + yaml "gopkg.in/yaml.v2" + + "github.com/beckn-one/beckn-onix/pkg/plugin" +) + +const pmkisanBindingKey = "pmkisan|openagrinet:Grievance" + +// bothSchemesConfig is PMFBY's block plus PM-KISAN's, flattened as the plugin +// receives them: one step serving both schemes. +func bothSchemesConfig() map[string]string { + config := pmfbyConfig() + config["bindingKeys"] = pmfbyBindingKey + "," + pmkisanBindingKey + for key, value := range map[string]string{ + "fallbackProviderIdAt": "message.support.channels[].provider.id", + "fallbackCapabilityCodeAt": "message.support.channels[].@type", + "authScheme-pmkisan": "none", + "envelope-pmkisan": "aesGcm", + "envelopeKeyEnv-pmkisan": "PMKISAN_GRIEVANCE_KEY", + "envelopeNonceEnv-pmkisan": "PMKISAN_GRIEVANCE_IV", + "envelopeTokenEnv-pmkisan": "PMKISAN_SERVICE_TOKEN", + "envelopeTokenField-pmkisan": "TokenNo", + "envelopeRequestField-pmkisan": "EncryptedRequest", + "envelopeResponsePaths-pmkisan": "d.output,output", + "otpEnvelope-pmkisan": "aesCbcKeyInBand", + "otpBindingKey-pmkisan": "pmkisan-chatbot|openagrinet:SchemeApplication", + "otpTokenEnv-pmkisan": "PMKISAN_OTP_TOKEN", + "otpTokenField-pmkisan": "Token", + "otpRequestField-pmkisan": "EncryptedRequest", + "otpResponsePaths-pmkisan": "d.output,output", + "otpActions-pmkisan": "init", + "otpVerifyPath-pmkisan": "/ChatbotOTPVerified", + "otpVerifyActions-pmkisan": "support,status", + } { + config[key] = value + } + return config +} + +func TestParseConfig_PMKISANBlock_ReadsEnvelope(t *testing.T) { + cfg, err := grievanceProvider{}.parseConfig(bothSchemesConfig()) + if err != nil { + t.Fatalf("parseConfig() = %v", err) + } + if len(cfg.BindingKeys) != 2 { + t.Errorf("BindingKeys = %v, want both schemes", cfg.BindingKeys) + } + if _, ok := cfg.EnvelopeByProvider["pmkisan"]; !ok || len(cfg.EnvelopeByProvider) != 1 { + t.Errorf("EnvelopeByProvider = %v, want pmkisan's alone -- PMFBY speaks plain JSON", cfg.EnvelopeByProvider) + } + if got := cfg.HostedElsewhere["pmkisan"]["init"]; got != "pmkisan-chatbot|openagrinet:SchemeApplication" { + t.Errorf("HostedElsewhere = %v, want pmkisan's init on the chatbot service's participant", cfg.HostedElsewhere) + } + if len(cfg.AuthByProvider) != 2 || cfg.AuthByProvider["pmkisan"].Scheme != "none" { + t.Errorf("AuthByProvider = %v, want pmfby and pmkisan", cfg.AuthByProvider) + } +} + +func TestNew_BothSchemes_BuildsStep(t *testing.T) { + step, closer, err := grievanceProvider{}.New(context.Background(), stubRegistry{}, stubMapper{}, bothSchemesConfig()) + if err != nil { + t.Fatalf("New() = %v", err) + } + if step == nil { + t.Fatal("New() returned no step") + } + _ = closer() +} + +func TestParseConfig_IncompleteOTPRealm_Refused(t *testing.T) { + config := bothSchemesConfig() + delete(config, "otpVerifyActions-pmkisan") + if _, err := (grievanceProvider{}).parseConfig(config); err == nil || + !strings.Contains(err.Error(), "otpVerifyActions") { + t.Fatalf("parseConfig() = %v, want the half-set verify named", err) + } +} + +func TestParseConfig_IncompleteEnvelope_Refused(t *testing.T) { + config := bothSchemesConfig() + delete(config, "envelopeKeyEnv-pmkisan") + if _, err := (grievanceProvider{}).parseConfig(config); err == nil || + !strings.Contains(err.Error(), "envelopeKeyEnv") { + t.Fatalf("parseConfig() = %v, want the missing envelopeKeyEnv named", err) + } +} + +func TestNew_EnvelopeForAnUnservedProvider_Refused(t *testing.T) { + config := bothSchemesConfig() + config["bindingKeys"] = pmfbyBindingKey + delete(config, "authScheme-pmkisan") + if _, _, err := (grievanceProvider{}).New(context.Background(), stubRegistry{}, stubMapper{}, config); err == nil { + t.Fatal("New() accepted an envelope for a provider the step does not serve") + } +} + +// The shipped config and this code move together: the fallback paths and the +// envelope settings are only valid if pkg/plugin flattens them and +// parseConfig reads them back. This reads the real file rather than a copy. +// +// Decoded with yaml.v2, the version cmd/adapter/main.go uses: a test on v3 +// passes while the adapter refuses the same file. +func TestShippedConfig_GrievanceEntry_BuildsStep(t *testing.T) { + raw, err := os.ReadFile("../../../../../config/provider-adapter.yaml") + if err != nil { + t.Skipf("reference config unavailable: %v", err) + } + var file struct { + Modules []struct { + Handler struct { + Plugins struct { + ProviderSteps []plugin.Config `yaml:"providerSteps"` + } `yaml:"plugins"` + } `yaml:"handler"` + } `yaml:"modules"` + } + if err := yaml.Unmarshal(raw, &file); err != nil { + t.Fatalf("the shipped config does not decode: %v", err) + } + + var shipped map[string]string + for _, m := range file.Modules { + for _, step := range m.Handler.Plugins.ProviderSteps { + if step.ID == "Grievance" { + shipped = step.Config + } + } + } + if shipped == nil { + t.Fatal("the shipped config has no Grievance entry") + } + + cfg, err := grievanceProvider{}.parseConfig(shipped) + if err != nil { + t.Fatalf("parseConfig on the shipped entry: %v", err) + } + if !strings.Contains(cfg.ProviderIDAt, "message.support.channels[]") { + t.Error("the shipped entry gives support no binding path; every PM-KISAN lodge would pass through unserved") + } + if _, ok := cfg.EnvelopeByProvider["pmkisan"]; !ok { + t.Error("the shipped entry gives pmkisan no envelope; the portal would be sent plaintext") + } + if _, _, err := (grievanceProvider{}).New(context.Background(), stubRegistry{}, stubMapper{}, shipped); err != nil { + t.Fatalf("the shipped entry does not build a step: %v", err) + } +} diff --git a/pkg/plugin/implementation/Grievance/otp.go b/pkg/plugin/implementation/Grievance/otp.go new file mode 100644 index 000000000..3741ef6e0 --- /dev/null +++ b/pkg/plugin/implementation/Grievance/otp.go @@ -0,0 +1,90 @@ +package Grievance + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/envelope" +) + +// challengeMethod is the only accepted challenge. +const challengeMethod = "SMS_OTP" + +// identityType is the OTP service's type for a registration number (enrolmentId). +const identityType = "Ben_id" + +// hooks are the grievance packs' side of the envelope: where the challenge +// sits in a payload, and what PM-KISAN's "yes" looks like. +var hooks = envelope.Hooks{Challenge: verifyRequest, Succeeded: succeeded} + +// ParseEnvelopes reads each provider's envelope settings out of the plugin +// config. See envelope.Parse. +func ParseEnvelopes(config map[string]string) (envelope.Parsed, map[string]string, error) { + return envelope.Parse(config, hooks) +} + +// verifyRequest is the OTP service's verify body: identifier, its type and +// the OTP. Missing or a wrong method is a 400. +func verifyRequest(exchange common.Exchange) (map[string]any, error) { + kind, identity, method, otp := challengeIn(exchange) + if kind == "" || identity == "" || otp == "" { + return nil, model.NewBadReqErr("SCH_REQUIRED_FIELD_MISSING", errors.New( + "this call needs the farmer's identifier and challenge.response.value, the OTP the farmer received")) + } + if method != challengeMethod { + return nil, model.NewBadReqErr("SCH_INVALID_FORMAT", fmt.Errorf( + "challenge.response.method must be %s", challengeMethod)) + } + return map[string]any{"Types": kind, "Values": identity, "OTP": otp}, nil +} + +// challengeIn reads enrolmentId and the challenge from support's channel or +// the commitment's attributes. +func challengeIn(exchange common.Exchange) (kind, identity, method, otp string) { + payload, _ := exchange.Beckn.(map[string]any) + message, _ := payload["message"].(map[string]any) + + var holder map[string]any + if support, isSupport := message["support"].(map[string]any); isSupport { + if channels, ok := support["channels"].([]any); ok && len(channels) > 0 { + holder, _ = channels[0].(map[string]any) + } + } else if contract, isContract := message["contract"].(map[string]any); isContract { + if commitments, ok := contract["commitments"].([]any); ok && len(commitments) > 0 { + commitment, _ := commitments[0].(map[string]any) + holder, _ = commitment["commitmentAttributes"].(map[string]any) + } + } + if id := text(holder["enrolmentId"]); id != "" { + kind, identity = identityType, id + } + challenge, _ := holder["challenge"].(map[string]any) + if response, ok := challenge["response"].(map[string]any); ok { + method, otp = text(response["method"]), text(response["value"]) + } + return kind, identity, method, otp +} + +// succeeded is true only for a "True" flag; anything else fails closed. +func succeeded(plain []byte) bool { + var answer map[string]any + if json.Unmarshal(plain, &answer) != nil { + return false + } + for _, key := range []string{"Rsponce", "Responce", "Status", "status"} { + if value, ok := answer[key]; ok { + return strings.EqualFold(text(value), "true") + } + } + return false +} + +// text returns a trimmed string, or "" if the value is not one. +func text(value any) string { + s, _ := value.(string) + return strings.TrimSpace(s) +} diff --git a/pkg/plugin/implementation/Grievance/otp_test.go b/pkg/plugin/implementation/Grievance/otp_test.go new file mode 100644 index 000000000..c2512a7c9 --- /dev/null +++ b/pkg/plugin/implementation/Grievance/otp_test.go @@ -0,0 +1,77 @@ +package Grievance + +import ( + "errors" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +func TestSucceededFailsClosed(t *testing.T) { + for answer, want := range map[string]bool{ + `{"Rsponce":"True"}`: true, + `{"Rsponce":"true"}`: true, + `{"Responce":"True"}`: true, + `{"Rsponce":"False"}`: false, + `{"Rsponce":""}`: false, + `{"Message":"sent"}`: false, + `not json`: false, + } { + if got := succeeded([]byte(answer)); got != want { + t.Errorf("succeeded(%s) = %v, want %v", answer, got, want) + } + } +} + +// The registration number is the only identifier. +func TestChallengeInReadsTheKindFromTheSlot(t *testing.T) { + for name, tc := range map[string]struct { + attributes map[string]any + kind, id string + }{ + "enrolmentId": {map[string]any{"enrolmentId": "UP12345678A"}, "Ben_id", "UP12345678A"}, + "a mobile": {map[string]any{"applicant": map[string]any{"mobile": "9812345670"}}, "", ""}, + "an Aadhaar": {map[string]any{"applicant": map[string]any{"aadhaar": "123456789012"}}, "", ""}, + "the old idType shape": {map[string]any{"applicant": map[string]any{"idType": "Mobile", "id": "9812345670"}}, "", ""}, + "nothing": {map[string]any{}, "", ""}, + } { + payload := map[string]any{"message": map[string]any{"contract": map[string]any{"commitments": []any{ + map[string]any{"commitmentAttributes": tc.attributes}}}}} + if kind, id, _, _ := challengeIn(common.Exchange{Beckn: payload}); kind != tc.kind || id != tc.id { + t.Errorf("challengeIn(%s) = %q, %q, want %q, %q", name, kind, id, tc.kind, tc.id) + } + } + support := map[string]any{"message": map[string]any{"support": map[string]any{"orderId": "UP12345678A", + "channels": []any{map[string]any{"enrolmentId": "BR295454592"}}}}} + if kind, id, _, _ := challengeIn(common.Exchange{Beckn: support}); kind != "Ben_id" || id != "BR295454592" { + t.Errorf("challengeIn(support) = %q, %q, want the channel's enrolmentId, not orderId", kind, id) + } +} + +func TestVerifyRequestRefusesWhatCannotBeVerified(t *testing.T) { + withOTP := func(method, value string) common.Exchange { + return common.Exchange{Beckn: map[string]any{"message": map[string]any{"contract": map[string]any{"commitments": []any{ + map[string]any{"commitmentAttributes": map[string]any{"enrolmentId": "UP12345678A", + "challenge": map[string]any{"response": map[string]any{"method": method, "value": value}}}}}}}}} + } + for name, tc := range map[string]struct { + exchange common.Exchange + code string + }{ + "no OTP": {withOTP("SMS_OTP", ""), "SCH_REQUIRED_FIELD_MISSING"}, + "another method": {withOTP("EMAIL_OTP", "4821"), "SCH_INVALID_FORMAT"}, + } { + t.Run(name, func(t *testing.T) { + _, err := verifyRequest(tc.exchange) + var be model.BecknErrorer + if !errors.As(err, &be) || be.BecknError().Code != tc.code { + t.Errorf("verifyRequest() = %v, want %s", err, tc.code) + } + }) + } + fields, err := verifyRequest(withOTP("SMS_OTP", "4821")) + if err != nil || fields["Types"] != "Ben_id" || fields["Values"] != "UP12345678A" || fields["OTP"] != "4821" { + t.Errorf("verifyRequest() = %v, %v; want the identifier, its type and the OTP", fields, err) + } +} diff --git a/pkg/plugin/implementation/Grievance/pmkisan_mappings_test.go b/pkg/plugin/implementation/Grievance/pmkisan_mappings_test.go new file mode 100644 index 000000000..45f260550 --- /dev/null +++ b/pkg/plugin/implementation/Grievance/pmkisan_mappings_test.go @@ -0,0 +1,909 @@ +package Grievance_test + +// pmkisan_mappings_test.go runs the shipped PM-KISAN mappings through the real mapper, +// the real provider step and the real envelope, against a stand-in pkPortal that +// decrypts what it is sent and encrypts what it answers -- as the live one +// does. It is the only test that proves the pieces fit: a mapping is JSONata +// inside YAML fetched over HTTP, the plaintext it produces is sealed before it +// leaves, and nothing but running it establishes what reaches the pkPortal and +// what comes back as Beckn. +// +// An external test package on purpose: it uses the plugins exactly as the +// adapter does, through their exported surface. + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/Grievance" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/jsonmapper" +) + +const pkMappingsDir = "../../../../config/mappings/pmkisan" + +const ( + pkCapability = "openagrinet:Grievance" + pkBindingKey = "pmkisan|" + pkCapability + + // Test material only, never the pkPortal's. + pkKeyHex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + pkNonceHex = "a0a1a2a3a4a5a6a7a8a9aaab" + pkToken = "test-service-token" + + pkRegistrationNo = "UP12345678A" +) + +const pkSupportRequest = `{ + "context": { + "version": "2.0.0", "action": "support", "networkId": "openagrinet", + "transactionId": "7f3a0000-0000-4000-8000-000000000001", + "messageId": "4a1e0000-0000-4000-8000-000000000002", + "timestamp": "2026-09-28T11:04:00Z" + }, + "message": { + "support": { + "channels": [{ + "@context": "https://openagrinet.github.io/network-specs/api-schemas/Grievance/v0.1/context.jsonld", + "@type": "openagrinet:Grievance", + "provider": { "id": "pmkisan", "descriptor": { "name": "PM-KISAN Grievance Portal" } }, + "informationMode": "OnDemand", + "scheme": { "code": "PM-KISAN", "name": "Pradhan Mantri Kisan Samman Nidhi" }, + "enrolmentId": "UP12345678A", + "grievance": { + "category": { "code": "G003", "name": "Installment not received" }, + "description": " Third instalment for 2026 has not been credited. " + }, + "challenge": { "response": { "method": "SMS_OTP", "value": "4821" } } + }] + } + } +}` + +const pkStatusRequest = `{ + "context": { + "version": "2.0.0", "action": "status", "networkId": "openagrinet", + "transactionId": "c04b0000-0000-4000-8000-000000000003", + "messageId": "e91f0000-0000-4000-8000-000000000004", + "timestamp": "2026-10-02T09:10:44Z" + }, + "message": { "contract": { + "id": "c9b31a45-0f78-4e2d-9a60-84b7d3e15c02", + "commitments": [{ + "status": { "descriptor": { "code": "ACTIVE" } }, + "offer": { + "id": "off:pmkisan:grievance", + "provider": { "id": "pmkisan", "descriptor": { "name": "PM-KISAN Grievance Portal" } }, + "resourceIds": ["res:pmkisan:grievance"] + }, + "resources": [{ "id": "res:pmkisan:grievance", "quantity": { "count": 1 } }], + "commitmentAttributes": { + "@context": "https://openagrinet.github.io/network-specs/api-schemas/Grievance/v0.1/context.jsonld", + "@type": "openagrinet:Grievance", + "informationMode": "OnDemand", + "scheme": { "code": "PM-KISAN", "name": "Pradhan Mantri Kisan Samman Nidhi" }, + "enrolmentId": "UP12345678A", + "case": { "filedOn": "2026-09-28" }, + "challenge": { "response": { "method": "SMS_OTP", "value": "4821" } } + } + }] + }} +}` + +const pkInitRequest = `{ + "context": { + "version": "2.0.0", "action": "init", "networkId": "openagrinet", + "transactionId": "7f3a0000-0000-4000-8000-000000000005", + "messageId": "1b850000-0000-4000-8000-000000000006", + "timestamp": "2026-09-28T10:58:00Z" + }, + "message": { "contract": { + "id": "5e2704c8-9d31-4f6a-b8c0-1a73e6d2f094", + "commitments": [{ + "status": { "descriptor": { "code": "DRAFT" } }, + "offer": { + "id": "off:pmkisan:grievance", + "provider": { "id": "pmkisan", "descriptor": { "name": "PM-KISAN Grievance Portal" } }, + "resourceIds": ["res:pmkisan:grievance"] + }, + "resources": [{ "id": "res:pmkisan:grievance", "quantity": { "count": 1 } }], + "commitmentAttributes": { + "@context": "https://openagrinet.github.io/network-specs/api-schemas/Grievance/v0.1/context.jsonld", + "@type": "openagrinet:Grievance", + "informationMode": "OnDemand", + "scheme": { "code": "PM-KISAN", "name": "Pradhan Mantri Kisan Samman Nidhi" }, + "enrolmentId": "UP12345678A" + } + }] + }} +}` + +// pkStatusAnswer is what /GrievanceStatusCheck decrypts to: every grievance on +// the registration number, each carrying the farmer's personal data beside +// the grievance. Three records, two dates, two date formats -- the pkPortal's +// is undocumented, so both shapes the mapping reads are exercised. +const pkStatusAnswer = `{ + "Responce": "True", + "details": [ + { + "Reg_No": "UP12345678A", "Farmer_Name": "Ramesh Kumar", "Father_Name": "Suresh Kumar", + "Gender": "Male", "MobileNo": "9876543210", "StateName": "Uttar Pradesh", + "DistrictName": "Lucknow", "BlockName": "Malihabad", "RevenueVillageName": "Rahimabad", + "GrievanceDate": "15/08/2026", "GrievanceStatus": "Closed", + "GrievanceDescription": "An older grievance.", "OfficerReply": "Resolved.", + "OfficeReplyDate": "20/08/2026" + }, + { + "Reg_No": "UP12345678A", "Farmer_Name": "Ramesh Kumar", "Father_Name": "Suresh Kumar", + "Gender": "Male", "MobileNo": "9876543210", "StateName": "Uttar Pradesh", + "DistrictName": "Lucknow", "BlockName": "Malihabad", "RevenueVillageName": "Rahimabad", + "GrievanceDate": "28/09/2026 11:04:12", + "GrievanceDescription": "Third instalment for 2026 has not been credited.", + "OfficerReply": "Instalment released on 2026-10-01, credited to the linked account.", + "OfficeReplyDate": "01/10/2026" + } + ] +}` + +// --- the stand-in pkPortal ---------------------------------------------------- + +type pkPortal struct { + t *testing.T + gcm cipher.AEAD + nonce []byte + answer string // plaintext to seal; ignored when raw is set + raw string // the exact body to answer with, unsealed + calls int + path string + sent map[string]any // the decrypted request +} + +func newPKPortal(t *testing.T, answer string) *pkPortal { + t.Helper() + key, _ := hex.DecodeString(pkKeyHex) + nonce, _ := hex.DecodeString(pkNonceHex) + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCMWithNonceSize(block, len(nonce)) + if err != nil { + t.Fatal(err) + } + return &pkPortal{t: t, gcm: gcm, nonce: nonce, answer: answer} +} + +func (p *pkPortal) ServeHTTP(w http.ResponseWriter, r *http.Request) { + p.calls++ + p.path = r.URL.Path + + body, _ := io.ReadAll(r.Body) + var envelope map[string]string + if err := json.Unmarshal(body, &envelope); err != nil || len(envelope) != 1 { + p.t.Errorf("portal received %s, want exactly {\"EncryptedRequest\": ...}", body) + w.WriteHeader(http.StatusBadRequest) + return + } + sealed, _ := base64.StdEncoding.DecodeString(envelope["EncryptedRequest"]) + plain, err := p.gcm.Open(nil, p.nonce, sealed, nil) + if err != nil { + p.t.Errorf("portal could not decrypt the request: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + p.sent = map[string]any{} + _ = json.Unmarshal(plain, &p.sent) + + if p.raw != "" { + fmt.Fprint(w, p.raw) + return + } + out := base64.StdEncoding.EncodeToString(p.gcm.Seal(nil, p.nonce, []byte(p.answer), nil)) + fmt.Fprintf(w, `{"d":{"__type":"Grievance.Output","output":%q}}`, out) +} + +// --- the stand-in OTP service --------------------------------------------------- + +const ( + pkOTPToken = "test-otp-token" + pkValidOTP = "4821" + // A registration the OTP service will not send an OTP for. + pkNoOTPFor = "FAILOTP0000" +) + +// pkOTPService stands in for PM-KISAN's OTP service: AES-128-CBC with the key +// sent beside the ciphertext, /ChatbotOTP to send one and /ChatbotOTPVerified +// to check it. +type pkOTPService struct { + t *testing.T + calls map[string]int + sent map[string]map[string]string // per path, the decrypted request +} + +func newPKOTPService(t *testing.T) *pkOTPService { + return &pkOTPService{t: t, calls: map[string]int{}, sent: map[string]map[string]string{}} +} + +func pkCBCKey(keyString string) []byte { + key := make([]byte, aes.BlockSize) + copy(key, keyString) + return key +} + +func (o *pkOTPService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + o.calls[r.URL.Path]++ + body, _ := io.ReadAll(r.Body) + var envelope map[string]string + _ = json.Unmarshal(body, &envelope) + sealed, keyString, found := strings.Cut(envelope["EncryptedRequest"], "@") + if !found { + o.t.Errorf("the OTP service received %s, want base64(ciphertext)@key", body) + w.WriteHeader(http.StatusBadRequest) + return + } + raw, _ := base64.StdEncoding.DecodeString(sealed) + key := pkCBCKey(keyString) + block, _ := aes.NewCipher(key) + plain := make([]byte, len(raw)) + cipher.NewCBCDecrypter(block, key).CryptBlocks(plain, raw) + plain = plain[:len(plain)-int(plain[len(plain)-1])] + sent := map[string]string{} + _ = json.Unmarshal(plain, &sent) + o.sent[r.URL.Path] = sent + + answer := `{"Rsponce":"False","Message":"Request could not be processed"}` + switch { + case sent["Token"] != pkOTPToken: + answer = `{"Rsponce":"False","Message":"Invalid Token"}` + case r.URL.Path == "/ChatbotOTP" && sent["Values"] != pkNoOTPFor: + answer = `{"Rsponce":"True","Message":"OTP has been sent to the registered mobile number"}` + case r.URL.Path == "/ChatbotOTPVerified" && sent["OTP"] == pkValidOTP: + answer = `{"Rsponce":"True","Message":"OTP Verified"}` + } + padded := []byte(answer) + n := aes.BlockSize - len(padded)%aes.BlockSize + padded = append(padded, bytes.Repeat([]byte{byte(n)}, n)...) + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, key).CryptBlocks(out, padded) + fmt.Fprintf(w, `{"d":{"__type":"Chatbot.Output","output":%q}}`, base64.StdEncoding.EncodeToString(out)) +} + +// --- harness ---------------------------------------------------------------- + +func pkServeMappings(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := os.ReadFile(filepath.Join(pkMappingsDir, filepath.Base(r.URL.Path))) + if err != nil { + t.Errorf("could not read the mapping %q: %v", r.URL.Path, err) + w.WriteHeader(http.StatusNotFound) + return + } + fmt.Fprint(w, string(body)) + })) + t.Cleanup(server.Close) + return server +} + +// pkRegistry answers the served binding with plan, and any other binding it +// publishes (the OTP service's) with its own record. +type pkRegistry struct { + plan *model.ProviderRecord + others map[string]*model.ProviderRecord +} + +func (s *pkRegistry) ProviderRecord(_ context.Context, key string) (*model.ProviderRecord, error) { + if record, ok := s.others[key]; ok { + return record, nil + } + return s.plan, nil +} + +// pkOTPBindingKey is the OTP service's participant, as the shipped config names it. +const pkOTPBindingKey = "pmkisan-chatbot|openagrinet:SchemeApplication" + +// runPMKISAN drives the real step over the shipped mappings against p, configured as +// config/provider-adapter.yaml configures it. +func runPMKISAN(t *testing.T, p *pkPortal, request string) ([]byte, error) { + t.Helper() + return runPMKISANWith(t, p, newPKOTPService(t), request) +} + +// runPMKISANWith is runPMKISAN with the OTP service under the test's eye. +func runPMKISANWith(t *testing.T, p *pkPortal, otp *pkOTPService, request string) ([]byte, error) { + t.Helper() + t.Setenv("TEST_PMKISAN_KEY", pkKeyHex) + t.Setenv("TEST_PMKISAN_NONCE", pkNonceHex) + t.Setenv("TEST_PMKISAN_TOKEN", pkToken) + t.Setenv("TEST_PMKISAN_OTP_TOKEN", pkOTPToken) + + mappings := pkServeMappings(t) + upstream := httptest.NewServer(p) + t.Cleanup(upstream.Close) + otpHost := httptest.NewServer(otp) + t.Cleanup(otpHost.Close) + + mapper, closeMapper, err := jsonmapper.New(context.Background(), &jsonmapper.Config{}) + if err != nil { + t.Fatalf("failed to build the mapper: %v", err) + } + t.Cleanup(func() { _ = closeMapper() }) + + registry := &pkRegistry{plan: &model.ProviderRecord{ + BindingKey: pkBindingKey, + ParticipantID: "pmkisan", + CapabilityCode: pkCapability, + BaseURL: upstream.URL, + Actions: map[string]model.ActionPlan{ + // The OTP lives on another participant, which the envelope names. + "init": {Method: http.MethodPost, Path: "/ChatbotOTP", + Mappings: mappings.URL + "/grievance.init.yaml", TimeoutMs: 20000}, + "support": {Method: http.MethodPost, Path: "/LodgeGrievance", + Mappings: mappings.URL + "/grievance.support.yaml", TimeoutMs: 30000}, + "status": {Method: http.MethodPost, Path: "/GrievanceStatusCheck", + Mappings: mappings.URL + "/grievance.status.yaml", TimeoutMs: 30000, RetryMax: 2}, + }, + }, others: map[string]*model.ProviderRecord{ + pkOTPBindingKey: {BindingKey: pkOTPBindingKey, ParticipantID: "pmkisan-chatbot", BaseURL: otpHost.URL}, + }} + + // The envelope is built from the provider block exactly as the plugin's + // cmd package builds it from config/provider-adapter.yaml. + envelopes, _, err := Grievance.ParseEnvelopes(map[string]string{ + "envelope-pmkisan": "aesGcm", + "envelopeKeyEnv-pmkisan": "TEST_PMKISAN_KEY", + "envelopeNonceEnv-pmkisan": "TEST_PMKISAN_NONCE", + "envelopeTokenEnv-pmkisan": "TEST_PMKISAN_TOKEN", + "envelopeTokenField-pmkisan": "TokenNo", + "envelopeRequestField-pmkisan": "EncryptedRequest", + "envelopeResponsePaths-pmkisan": "d.output,output", + "otpEnvelope-pmkisan": "aesCbcKeyInBand", + "otpBindingKey-pmkisan": pkOTPBindingKey, + "otpTokenEnv-pmkisan": "TEST_PMKISAN_OTP_TOKEN", + "otpTokenField-pmkisan": "Token", + "otpRequestField-pmkisan": "EncryptedRequest", + "otpResponsePaths-pmkisan": "d.output,output", + "otpActions-pmkisan": "init", + "otpVerifyPath-pmkisan": "/ChatbotOTPVerified", + "otpVerifyActions-pmkisan": "support,status", + }) + if err != nil { + t.Fatalf("ParseEnvelopes() = %v", err) + } + + step, closeStep, err := Grievance.New(context.Background(), registry, mapper, &Grievance.Config{ + BindingKeys: []string{pkBindingKey}, + // status names the binding on its contract, support on its channel. + ProviderIDAt: "message.contract.commitments[].offer.provider.id, message.support.channels[].provider.id", + CapabilityCodeAt: "message.contract.commitments[].commitmentAttributes.@type, message.support.channels[].@type", + AuthByProvider: map[string]*common.AuthProfile{ + "pmkisan": {Scheme: util.AuthSchemeNone}, + }, + EnvelopeByProvider: envelopes.Envelopes, + HostedElsewhere: envelopes.HostedElsewhere, + }) + if err != nil { + t.Fatalf("failed to build the step: %v", err) + } + t.Cleanup(func() { _ = closeStep() }) + + stepCtx := &model.StepContext{Context: t.Context(), Body: []byte(request)} + if err := step.Run(stepCtx); err != nil { + return nil, err + } + if len(stepCtx.ResponseBody) == 0 { + t.Fatal("the step produced no answer and no error") + } + return stepCtx.ResponseBody, nil +} + +func pkDecode(t *testing.T, body []byte) map[string]any { + t.Helper() + var answer map[string]any + if err := json.Unmarshal(body, &answer); err != nil { + t.Fatalf("the answer is not JSON: %v\n%s", err, body) + } + return answer +} + +// pkAt walks a decoded document by keys and indices. +func pkAt(t *testing.T, node any, path ...any) any { + t.Helper() + for _, step := range path { + switch key := step.(type) { + case string: + fields, ok := node.(map[string]any) + if !ok { + t.Fatalf("at %v: %T is not an object", path, node) + } + node = fields[key] + case int: + items, ok := node.([]any) + if !ok || key >= len(items) { + t.Fatalf("at %v: no element %d", path, key) + } + node = items[key] + } + } + return node +} + +func pkBecknErr(t *testing.T, err error) (*model.Error, int) { + t.Helper() + var coded *model.CodedErr + if errors.As(err, &coded) { + return coded.BecknError(), coded.HTTPStatus() + } + var ack *model.AckNoCallbackErr + if errors.As(err, &ack) { + return ack.BecknError(), http.StatusAccepted + } + var other model.BecknErrorer + if errors.As(err, &other) { + return other.BecknError(), http.StatusBadRequest + } + t.Fatalf("error %v (%T) is not a Beckn error", err, err) + return nil, 0 +} + +// --- support ---------------------------------------------------------------- + +func TestPMKISANSupportLodgesTheGrievanceInPlaintextTheMappingNeverSees(t *testing.T) { + p := newPKPortal(t, `{"Responce":"True","GrievanceID":"PMK2026091234","message":"Grievance submitted successfully"}`) + body, err := runPMKISAN(t, p, pkSupportRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + + // What the pkPortal decrypted: the mapping's fields plus the pkToken the + // envelope added. Description trimmed, as the pkPortal measures it. + if p.path != "/LodgeGrievance" { + t.Errorf("portal was called at %s, want /LodgeGrievance", p.path) + } + for field, want := range map[string]string{ + "Type": "Reg_No_Details", + "IdentityNo": pkRegistrationNo, + "GrievanceType": "G003", + "GrievanceDescription": "Third instalment for 2026 has not been credited.", + "TokenNo": pkToken, + } { + if got := p.sent[field]; got != want { + t.Errorf("portal received %s = %v, want %q", field, got, want) + } + } + + answer := pkDecode(t, body) + if got := pkAt(t, answer, "context", "action"); got != "on_support" { + t.Errorf("context.action = %v, want on_support", got) + } + support := pkAt(t, answer, "message", "support") + if _, present := support.(map[string]any)["orderId"]; present { + t.Error("on_support carries orderId; the registration travels as the channel's enrolmentId") + } + if _, present := support.(map[string]any)["descriptor"]; present { + t.Error("on_support carries a descriptor; the grievance now travels on the channel") + } + channel := pkAt(t, support, "channels", 0) + for field, want := range map[string]any{ + "@type": pkCapability, + "informationMode": "Direct", + } { + if got := pkAt(t, channel, field); got != want { + t.Errorf("channel %s = %v, want %v", field, got, want) + } + } + // The caller's own, echoed: provider and the grievance band. + if got := pkAt(t, channel, "provider", "id"); got != "pmkisan" { + t.Errorf("provider.id = %v, want pmkisan", got) + } + if got := pkAt(t, channel, "grievance", "category", "code"); got != "G003" { + t.Errorf("grievance.category.code = %v, want it echoed", got) + } + if got := pkAt(t, channel, "grievance", "category"); fmt.Sprint(got) != fmt.Sprint(map[string]any{"code": "G003"}) { + t.Errorf("grievance.category = %v, want the code alone: the pack returns no name on a lodge", got) + } + if got := pkAt(t, channel, "enrolmentId"); got != pkRegistrationNo { + t.Errorf("enrolmentId = %v, want the registration number echoed on the channel", got) + } + // The case band: the portal's handle, and the adapter's assertions -- the + // network's code with no name, and today in IST, the date a later status + // matches on. + if got := pkAt(t, channel, "case", "ticketNo"); got != "PMK2026091234" { + t.Errorf("case.ticketNo = %v, want the portal's GrievanceID", got) + } + if got := pkAt(t, channel, "case", "status"); fmt.Sprint(got) != fmt.Sprint(map[string]any{"code": "Registered"}) { + t.Errorf("case.status = %v, want {code: Registered} with no name", got) + } + // The IST date of the request's own timestamp, 2026-09-28T11:04:00Z. + if got := pkAt(t, channel, "case", "filedOn"); got != "2026-09-28" { + t.Errorf("case.filedOn = %v, want the request's date in IST, 2026-09-28", got) + } + + if strings.Contains(string(body), pkToken) { + t.Errorf("the answer carries the service token:\n%s", body) + } + if strings.Contains(string(body), "Grievance submitted successfully") { + t.Errorf("the answer passes the portal's message through:\n%s", body) + } +} + +func TestPMKISANSupportReportsAPortalRefusalInOurWords(t *testing.T) { + enrolment := "$.message.support.channels[0].enrolmentId" + for name, tc := range map[string]struct { + answer string + status int + code string + path string + }{ + // A known reason is the caller's to fix: a 400 naming the field. + "Responce, not found": {`{"Responce":"False","message":"Registration number not found"}`, http.StatusBadRequest, "BIZ_GENERIC_ERROR", enrolment}, + "Rsponce, not found": {`{"Rsponce":"false","message":"registration number not found "}`, http.StatusBadRequest, "BIZ_GENERIC_ERROR", enrolment}, + "status, not found": {`{"status":"False","Message":"Registration number not found"}`, http.StatusBadRequest, "BIZ_GENERIC_ERROR", enrolment}, + // Any other "False" is PM-KISAN failing: a 502. + "an unknown reason": {`{"Responce":"False","message":"Service temporarily unavailable"}`, http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE", ""}, + "a reason we do not read": {`{"Status":"False","Remark":"Registration number not found"}`, http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE", ""}, + "no reason": {`{"Responce":"False"}`, http.StatusBadGateway, "NET_DOWNSTREAM_UNAVAILABLE", ""}, + } { + t.Run(name, func(t *testing.T) { + _, err := runPMKISAN(t, newPKPortal(t, tc.answer), pkSupportRequest) + if err == nil { + t.Fatal("Run() succeeded on a portal refusal") + } + beckn, status := pkBecknErr(t, err) + if status != tc.status || beckn.Code != tc.code { + t.Errorf("got %s/%d, want %s/%d", beckn.Code, status, tc.code, tc.status) + } + if tc.path != "" && (beckn.Details == nil || beckn.Details.Path != tc.path) { + t.Errorf("details = %+v, want path %s", beckn.Details, tc.path) + } + for _, leak := range []string{"not found", "temporarily"} { + if strings.Contains(beckn.Message, leak) { + t.Errorf("message %q passes the portal's text through", beckn.Message) + } + } + }) + } +} + +func TestPMKISANSupportTreatsPlainTextFromThePortalAsARefusal(t *testing.T) { + p := newPKPortal(t, "") + p.raw = `{"d":{"__type":"Grievance.Output","output":"Invalid Token No."}}` + _, err := runPMKISAN(t, p, pkSupportRequest) + beckn, status := pkBecknErr(t, err) + if status != http.StatusBadGateway || beckn.Code != "NET_DOWNSTREAM_UNAVAILABLE" { + t.Errorf("got %s/%d, want NET_DOWNSTREAM_UNAVAILABLE/502", beckn.Code, status) + } +} + +// --- status ----------------------------------------------------------------- + +func TestPMKISANStatusAnswersTheGrievanceFiledOnThatDate(t *testing.T) { + p := newPKPortal(t, pkStatusAnswer) + body, err := runPMKISAN(t, p, pkStatusRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + + if p.path != "/GrievanceStatusCheck" { + t.Errorf("portal was called at %s, want /GrievanceStatusCheck", p.path) + } + if p.sent["Type"] != "Reg_No_Status" || p.sent["IdentityNo"] != pkRegistrationNo || p.sent["TokenNo"] != pkToken { + t.Errorf("portal received %v, want Reg_No_Status for the registration number, with TokenNo", p.sent) + } + + answer := pkDecode(t, body) + if got := pkAt(t, answer, "context", "action"); got != "on_status" { + t.Errorf("context.action = %v, want on_status", got) + } + contract := pkAt(t, answer, "message", "contract") + if got := pkAt(t, contract, "id"); got != "c9b31a45-0f78-4e2d-9a60-84b7d3e15c02" { + t.Errorf("contract.id = %v, want it echoed", got) + } + if _, present := contract.(map[string]any)["descriptor"]; present { + t.Error("on_status carries contract.descriptor; the complaint now travels in the grievance band") + } + attributes := pkAt(t, contract, "commitments", 0, "commitmentAttributes") + if got := pkAt(t, attributes, "informationMode"); got != "Direct" { + t.Errorf("informationMode = %v, want Direct", got) + } + if got := pkAt(t, attributes, "grievance"); fmt.Sprint(got) != + fmt.Sprint(map[string]any{"description": "Third instalment for 2026 has not been credited."}) { + t.Errorf("grievance = %v, want the matching record's description alone", got) + } + for field, want := range map[string]any{ + "filedOn": "2026-09-28", + "repliedOn": "2026-10-01", + "reply": "Instalment released on 2026-10-01, credited to the linked account.", + } { + if got := pkAt(t, attributes, "case", field); got != want { + t.Errorf("case.%s = %v, want %v", field, got, want) + } + } + // No GrievanceStatus on this record, and a remark exists: Replied. + if got := pkAt(t, attributes, "case", "status"); fmt.Sprint(got) != fmt.Sprint(map[string]any{"code": "Replied"}) { + t.Errorf("case.status = %v, want {code: Replied} with no name", got) + } + if _, present := attributes.(map[string]any)["enrolmentId"]; present { + t.Error("on_status echoes enrolmentId; the registration number is consumed, not surfaced") + } + commitmentStatus := pkAt(t, pkDecode(t, body), "message", "contract", "commitments", 0, "status", "descriptor") + if fmt.Sprint(commitmentStatus) != fmt.Sprint(map[string]any{"code": "ACTIVE"}) { + t.Errorf("commitment status = %v, want {code: ACTIVE}", commitmentStatus) + } + + // The allow-list: nothing about the farmer, and not the other grievance. + for _, leaked := range []string{"Ramesh", "Suresh", "9876543210", "Rahimabad", "Malihabad", + pkRegistrationNo, "An older grievance", "Male"} { + if strings.Contains(string(body), leaked) { + t.Errorf("the answer carries %q, which the allow-list must drop:\n%s", leaked, body) + } + } +} + +func TestPMKISANStatusMapsThePortalsPhraseToTheNetworksCode(t *testing.T) { + for phrase, code := range map[string]string{ + // A phrase naming a network state maps to it, case and spacing aside. + "Under Review": "UnderReview", + "REPLIED": "Replied", + "Closed": "Closed", + // The portal's word for a grievance it has answered. + "Disposed": "Replied", + // One it does not recognise is non-terminal: never inferred closed. + "Pending at District": "UnderReview", + } { + t.Run(phrase, func(t *testing.T) { + answer := strings.Replace(pkStatusAnswer, `"GrievanceDate": "28/09/2026 11:04:12",`, + `"GrievanceDate": "2026-09-28", "GrievanceStatus": "`+phrase+`",`, 1) + body, err := runPMKISAN(t, newPKPortal(t, answer), pkStatusRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + status := pkAt(t, pkDecode(t, body), "message", "contract", "commitments", 0, "commitmentAttributes", "case", "status") + if fmt.Sprint(status) != fmt.Sprint(map[string]any{"code": code}) { + t.Errorf("case.status = %v, want {code: %s} and no name: the pack refuses it", status, code) + } + }) + } +} + +func TestPMKISANStatusReportsNothingOnFileAsARefusal(t *testing.T) { + request := strings.Replace(pkStatusRequest, `"case": { "filedOn": "2026-09-28" }`, `"case": { "filedOn": "2026-09-29" }`, 1) + _, err := runPMKISAN(t, newPKPortal(t, pkStatusAnswer), request) + if err == nil { + t.Fatal("Run() answered a date with no grievance on it") + } + beckn, status := pkBecknErr(t, err) + if status != http.StatusBadRequest || beckn.Code != "BIZ_GENERIC_ERROR" { + t.Errorf("got %s/%d, want BIZ_GENERIC_ERROR/400", beckn.Code, status) + } +} + +func TestPMKISANStatusReportsAnAnswerThatWillNotDecryptAsOurs(t *testing.T) { + // The pkPortal answers with an envelope sealed under a nonce this adapter + // does not hold -- what key or nonce drift looks like from our side. + p := newPKPortal(t, "") + other, _ := hex.DecodeString("ffffffffffffffffffffffff") + sealed := base64.StdEncoding.EncodeToString(p.gcm.Seal(nil, other, []byte(pkStatusAnswer), nil)) + p.raw = `{"d":{"output":"` + sealed + `"}}` + + _, err := runPMKISAN(t, p, pkStatusRequest) + if err == nil { + t.Fatal("Run() succeeded on an answer it cannot decrypt") + } + beckn, status := pkBecknErr(t, err) + if status != http.StatusInternalServerError || beckn.Code != "NET_INTERNAL_ERROR" { + t.Errorf("got %s/%d, want NET_INTERNAL_ERROR/500: a portal that answered correctly is not to blame", + beckn.Code, status) + } +} + +func TestPMKISANStatusReportsAnAnswerWithNoRecordsAsNothingOnFile(t *testing.T) { + for name, answer := range map[string]string{ + "no details": `{"Responce":"True"}`, + "empty details": `{"Responce":"True","details":[]}`, + } { + t.Run(name, func(t *testing.T) { + _, err := runPMKISAN(t, newPKPortal(t, answer), pkStatusRequest) + if err == nil { + t.Fatal("Run() answered a reply with no records") + } + beckn, status := pkBecknErr(t, err) + if status != http.StatusBadRequest || beckn.Code != "BIZ_GENERIC_ERROR" { + t.Errorf("got %s/%d, want BIZ_GENERIC_ERROR/400", beckn.Code, status) + } + }) + } +} + +func TestPMKISANSupportReadsTheTicketFromEitherName(t *testing.T) { + for name, tc := range map[string]struct{ answer, want string }{ + "GrievanceID": {`{"Responce":"True","GrievanceID":"PMK1"}`, "PMK1"}, + "GrievanceNo when ID is absent": {`{"Responce":"True","GrievanceNo":"PMK2"}`, "PMK2"}, + "a numeric GrievanceNo": {`{"Responce":"True","GrievanceNo":20260912}`, "20260912"}, + "neither": {`{"Responce":"True"}`, ""}, + } { + t.Run(name, func(t *testing.T) { + body, err := runPMKISAN(t, newPKPortal(t, tc.answer), pkSupportRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + c := pkAt(t, pkDecode(t, body), "message", "support", "channels", 0, "case").(map[string]any) + got, present := c["ticketNo"] + if tc.want == "" { + if present { + t.Errorf("case.ticketNo = %v, want it absent when the portal sends no handle", got) + } + return + } + if got != tc.want { + t.Errorf("case.ticketNo = %v, want %s", got, tc.want) + } + }) + } +} + +// --- init and the OTP ------------------------------------------------------------ + +func TestPMKISANInitAsksTheOTPServiceForAnOTP(t *testing.T) { + p := newPKPortal(t, "") + otp := newPKOTPService(t) + body, err := runPMKISANWith(t, p, otp, pkInitRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + + // The OTP service was asked, by registration number, with its own token; + // the grievance service was not called at all. + sent := otp.sent["/ChatbotOTP"] + for field, want := range map[string]string{"Types": "Ben_id", "Values": pkRegistrationNo, "Token": pkOTPToken} { + if sent[field] != want { + t.Errorf("OTP service received %s = %q, want %q", field, sent[field], want) + } + } + if p.calls != 0 { + t.Errorf("the grievance service was called %d time(s) for an OTP", p.calls) + } + + answer := pkDecode(t, body) + if got := pkAt(t, answer, "context", "action"); got != "on_init" { + t.Errorf("context.action = %v, want on_init", got) + } + commitment := pkAt(t, answer, "message", "contract", "commitments", 0) + if got := pkAt(t, commitment, "status", "descriptor", "code"); got != "DRAFT" { + t.Errorf("commitment status = %v, want DRAFT", got) + } + attributes := pkAt(t, commitment, "commitmentAttributes").(map[string]any) + if attributes["informationMode"] != "OnDemand" { + t.Errorf("informationMode = %v, want OnDemand: an acknowledgement carries no case", attributes["informationMode"]) + } + challenge, _ := attributes["challenge"].(map[string]any) + issued, _ := challenge["issued"].(map[string]any) + if issued["method"] != "SMS_OTP" { + t.Errorf("challenge.issued.method = %v, want SMS_OTP", issued["method"]) + } + if issued["length"] != float64(4) || issued["valueType"] != "NUMERIC" { + t.Errorf("challenge.issued length, valueType = %v, %v, want 4, NUMERIC", issued["length"], issued["valueType"]) + } + if expires, _ := issued["expiresAt"].(string); expires == "" { + t.Error("challenge.issued.expiresAt is missing") + } else if at, err := time.Parse(time.RFC3339, expires); err != nil || at.Before(time.Now()) { + t.Errorf("challenge.issued.expiresAt = %q, want a future instant", expires) + } + if _, present := issued["sentTo"]; present { + t.Error("on_init carries sentTo; PM-KISAN does not disclose the number it texted") + } + if _, present := attributes["enrolmentId"]; present { + t.Error("on_init echoes enrolmentId") + } + if strings.Contains(string(body), pkOTPToken) { + t.Errorf("the answer carries the OTP service's token:\n%s", body) + } +} + +func TestPMKISANInitReportsAnOTPServiceRefusal(t *testing.T) { + request := strings.Replace(pkInitRequest, `"enrolmentId": "UP12345678A"`, `"enrolmentId": "`+pkNoOTPFor+`"`, 1) + _, err := runPMKISAN(t, newPKPortal(t, ""), request) + beckn, status := pkBecknErr(t, err) + if status != http.StatusBadGateway || beckn.Code != "NET_DOWNSTREAM_UNAVAILABLE" { + t.Errorf("got %s/%d, want NET_DOWNSTREAM_UNAVAILABLE/502", beckn.Code, status) + } +} + +func TestPMKISANSupportVerifiesTheOTPAndKeepsItOutOfTheLodge(t *testing.T) { + p := newPKPortal(t, `{"Responce":"True","GrievanceID":"PMK1"}`) + otp := newPKOTPService(t) + body, err := runPMKISANWith(t, p, otp, pkSupportRequest) + if err != nil { + t.Fatalf("Run() = %v", err) + } + verify := otp.sent["/ChatbotOTPVerified"] + for field, want := range map[string]string{"Types": "Ben_id", "Values": pkRegistrationNo, "OTP": pkValidOTP, "Token": pkOTPToken} { + if verify[field] != want { + t.Errorf("verify received %s = %q, want %q", field, verify[field], want) + } + } + for _, field := range []string{"OTP", "challenge", "Token"} { + if _, present := p.sent[field]; present { + t.Errorf("the lodge plaintext carries %s; the OTP goes to the verify and nowhere else", field) + } + } + if strings.Contains(string(body), pkValidOTP) { + t.Errorf("on_support echoes the OTP:\n%s", body) + } +} + +func TestPMKISANAWrongOTPLodgesAndReadsNothing(t *testing.T) { + for name, request := range map[string]string{ + "support": strings.Replace(pkSupportRequest, `"value": "4821"`, `"value": "0000"`, 1), + "status": strings.Replace(pkStatusRequest, `"value": "4821"`, `"value": "0000"`, 1), + } { + t.Run(name, func(t *testing.T) { + p := newPKPortal(t, pkStatusAnswer) + otp := newPKOTPService(t) + _, err := runPMKISANWith(t, p, otp, request) + beckn, status := pkBecknErr(t, err) + if status != http.StatusBadRequest || beckn.Code != "BIZ_GENERIC_ERROR" { + t.Errorf("got %s/%d, want BIZ_GENERIC_ERROR/400", beckn.Code, status) + } + if otp.calls["/ChatbotOTPVerified"] != 1 { + t.Errorf("verify called %d time(s), want 1", otp.calls["/ChatbotOTPVerified"]) + } + if p.calls != 0 { + t.Errorf("the grievance service was called %d time(s) after a failed OTP", p.calls) + } + }) + } +} + +func TestPMKISANStatusRefusesWithoutAnOTP(t *testing.T) { + request := strings.Replace(pkStatusRequest, `, + "challenge": { "response": { "method": "SMS_OTP", "value": "4821" } }`, ``, 1) + if request == pkStatusRequest { + t.Fatal("the edit did not apply to the request") + } + p := newPKPortal(t, pkStatusAnswer) + otp := newPKOTPService(t) + _, err := runPMKISANWith(t, p, otp, request) + beckn, status := pkBecknErr(t, err) + if status != http.StatusBadRequest || beckn.Code != "SCH_REQUIRED_FIELD_MISSING" { + t.Errorf("got %s/%d, want SCH_REQUIRED_FIELD_MISSING/400", beckn.Code, status) + } + if otp.calls["/ChatbotOTPVerified"] != 0 || p.calls != 0 { + t.Errorf("verify %d, read %d; a request refused before the call must reach neither service", + otp.calls["/ChatbotOTPVerified"], p.calls) + } +} + +// filedOn is the request's own date in IST, so a lodge sent at 19:00 UTC is +// filed on the next day -- the day it was in India -- and a later status +// matches on that day. +func TestPMKISANSupportDatesTheGrievanceByTheRequestInIST(t *testing.T) { + request := strings.Replace(pkSupportRequest, `"timestamp": "2026-09-28T11:04:00Z"`, `"timestamp": "2026-09-28T19:00:00Z"`, 1) + if request == pkSupportRequest { + t.Fatal("the edit did not apply to the request") + } + body, err := runPMKISAN(t, newPKPortal(t, `{"Responce":"True"}`), request) + if err != nil { + t.Fatalf("Run() = %v", err) + } + if got := pkAt(t, pkDecode(t, body), "message", "support", "channels", 0, "case", "filedOn"); got != "2026-09-29" { + t.Errorf("case.filedOn = %v, want 2026-09-29: 19:00 UTC is 00:30 the next day in IST", got) + } +} diff --git a/pkg/plugin/implementation/KnowledgeAdvisory/cmd/shippedconfig_test.go b/pkg/plugin/implementation/KnowledgeAdvisory/cmd/shippedconfig_test.go index 958ecccbd..e0f21a543 100644 --- a/pkg/plugin/implementation/KnowledgeAdvisory/cmd/shippedconfig_test.go +++ b/pkg/plugin/implementation/KnowledgeAdvisory/cmd/shippedconfig_test.go @@ -48,6 +48,10 @@ func TestShippedConfigParsesIntoAProfilePerProvider(t *testing.T) { for _, step := range steps { t.Run(step.ID, func(t *testing.T) { + if step.ID == "Grievance" { + // Envelope settings need the Grievance plugin's own parser. + t.Skip("checked by Grievance/cmd TestShippedConfig_GrievanceEntry_BuildsStep") + } // Flattened on the way in: a plugin never sees a block, so every // value here is a string. parseConfig failing would mean the // nesting did not survive. diff --git a/pkg/plugin/implementation/internal/common/common.go b/pkg/plugin/implementation/internal/common/common.go index 3012eb414..07f57e564 100644 --- a/pkg/plugin/implementation/internal/common/common.go +++ b/pkg/plugin/implementation/internal/common/common.go @@ -22,6 +22,7 @@ import ( "strings" "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" "github.com/beckn-one/beckn-onix/pkg/plugin/definition" "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" ) @@ -35,6 +36,24 @@ import ( // still decides what the provider is asked for. Most capabilities need none. type Prerequisites map[string]func(context.Context, any) (map[string]any, error) +// Envelope seals a provider's request once, before the retry loop, and +// returns the opener for its answer (ADR-0001). +type Envelope interface { + Seal(ctx context.Context, exchange Exchange, mapped []byte) (sealed []byte, open Opener, err error) +} + +// Opener decrypts the answer to one sealed request. +type Opener func(ctx context.Context, answer []byte) ([]byte, error) + +// Exchange is the call an envelope wraps: action, Beckn payload and call plan, +// and the registry, for an envelope that calls another participant too. +type Exchange struct { + Action string + Beckn any + Plan *model.ProviderRecord + Registry definition.ProviderRecordLookup +} + // Config holds configuration parameters for the step. type Config struct { // The capabilities this step answers to. A request for anything else @@ -58,6 +77,14 @@ type Config struct { ProviderIDAt string `yaml:"providerIdAt" json:"providerIdAt"` CapabilityCodeAt string `yaml:"capabilityCodeAt" json:"capabilityCodeAt"` + // EnvelopeByProvider, keyed by participant id. Unlisted providers get + // plain JSON. Built by the domain package. + EnvelopeByProvider map[string]Envelope `yaml:"-" json:"-"` + + // HostedElsewhere: provider -> action -> binding key of the participant + // that serves it (ADR-0002). + HostedElsewhere map[string]map[string]string `yaml:"-" json:"-"` + // One credential profile per provider, keyed by participant id -- the left // half of a binding key. // @@ -234,5 +261,34 @@ func applyDefaults(cfg *Config) error { return err } } + + // An envelope for an unserved provider is a typo; refuse it. + for provider, envelope := range cfg.EnvelopeByProvider { + if !served[provider] { + return fmt.Errorf( + "an envelope is configured for %q, which is not a provider in bindingKeys (%s)", + provider, strings.Join(cfg.BindingKeys, ", ")) + } + if envelope == nil { + return fmt.Errorf("the envelope configured for %q is nil", provider) + } + } + + // A host for an unserved provider is a typo too, and a key the registry + // cannot index would fail every call. + for provider, actions := range cfg.HostedElsewhere { + if !served[provider] { + return fmt.Errorf( + "an action host is configured for %q, which is not a provider in bindingKeys (%s)", + provider, strings.Join(cfg.BindingKeys, ", ")) + } + for action, key := range actions { + participant, capability, found := strings.Cut(key, separator) + if action == "" || !found || participant == "" || capability == "" { + return fmt.Errorf("%s: the host for %q must be a binding key, participant%scapability; got %q", + provider, action, separator, key) + } + } + } return nil } diff --git a/pkg/plugin/implementation/internal/common/envelope/call.go b/pkg/plugin/implementation/internal/common/envelope/call.go new file mode 100644 index 000000000..5efe74ba2 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/call.go @@ -0,0 +1,50 @@ +package envelope + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +// maxAnswerBytes caps the OTP verify and follow-up answers. +const maxAnswerBytes = 64 << 10 + +// postSealed POSTs a sealed body to base+path once, within its own budget. +// Unreachable or non-2xx: 502. +func postSealed(ctx context.Context, client *http.Client, provider, what, base, path string, + call model.ActionPlan, sealed []byte) ([]byte, error) { + endpoint, err := util.BuildEndpoint(base, model.ActionPlan{Method: http.MethodPost, Path: path}, nil) + if err != nil { + return nil, err + } + timeout, _ := util.Budget(call) + attemptCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, endpoint, bytes.NewReader(sealed)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + log.Warnf(ctx, "%s could not be reached for %s: %v", provider, what, err) + return nil, refused(fmt.Errorf("%s did not answer %s", provider, what)) + } + defer resp.Body.Close() + answer, err := io.ReadAll(io.LimitReader(resp.Body, maxAnswerBytes)) + if err != nil { + return nil, refused(fmt.Errorf("%s's answer to %s could not be read", provider, what)) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + log.Warnf(ctx, "%s returned %s for %s", provider, resp.Status, what) + return nil, refused(fmt.Errorf("%s returned %s for %s", provider, resp.Status, what)) + } + return answer, nil +} diff --git a/pkg/plugin/implementation/internal/common/envelope/cbc.go b/pkg/plugin/implementation/internal/common/envelope/cbc.go new file mode 100644 index 000000000..2de7b15be --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/cbc.go @@ -0,0 +1,196 @@ +package envelope + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +// keySeparator joins the ciphertext and its key on the wire: cipher@key. +const keySeparator = "@" + +// cbcCodec is AES-128-CBC with a fresh key per request, sent as cipher@key +// (key = IV = its first 16 bytes). Obfuscation only; the token is the secret. +type cbcCodec struct { + provider string + tokenEnv string + tokenField string + requestField string + responsePaths [][]string + + // crypto/rand outside tests. + random io.Reader +} + +// newCBCCodec validates otp* settings. +func newCBCCodec(provider string, s realmSettings) (*cbcCodec, error) { + return newCBCCodecFrom(provider, "otp", "otpEnvelope", s) +} + +// newCBCCodecFrom validates settings under the given prefix (otp or envelope). +func newCBCCodecFrom(provider, prefix, kindSetting string, s realmSettings) (*cbcCodec, error) { + if s.kind != envelopeAESCBCKeyInBand { + return nil, fmt.Errorf("%s: %s %q is not one this plugin knows; want %s", + provider, kindSetting, s.kind, envelopeAESCBCKeyInBand) + } + missing := missingSettings(map[string]string{ + prefix + "RequestField": s.requestField, + prefix + "ResponsePaths": s.responsePaths, + }) + missing = checkToken(s.tokenEnv, s.tokenField, missing, prefix) + if len(missing) > 0 { + return nil, fmt.Errorf("%s: %s %s needs %s", provider, kindSetting, envelopeAESCBCKeyInBand, + strings.Join(missing, ", ")) + } + paths, err := parsePaths(provider, prefix+"ResponsePaths", s.responsePaths) + if err != nil { + return nil, err + } + return &cbcCodec{provider: provider, tokenEnv: s.tokenEnv, tokenField: s.tokenField, + requestField: s.requestField, responsePaths: paths, random: rand.Reader}, nil +} + +// seal adds the token, encrypts under a fresh key, and returns its opener. +func (c *cbcCodec) seal(ctx context.Context, mapped []byte) ([]byte, common.Opener, error) { + plain, err := withToken(ctx, c.provider, mapped, c.tokenEnv, c.tokenField) + if err != nil { + return nil, nil, err + } + keyString, err := c.newKeyString() + if err != nil { + return nil, nil, fmt.Errorf("a key for %s could not be generated: %w", c.provider, err) + } + sealed, err := cbcEncrypt(keyString, plain) + if err != nil { + return nil, nil, fmt.Errorf("the %s request could not be encrypted: %w", c.provider, err) + } + body, err := json.Marshal(map[string]string{c.requestField: sealed + keySeparator + keyString}) + if err != nil { + return nil, nil, err + } + return body, func(ctx context.Context, answer []byte) ([]byte, error) { + return c.open(ctx, answer, keyString) + }, nil +} + +// open decrypts an answer; failures as in gcmCodec.Open (502/500/502). +func (c *cbcCodec) open(ctx context.Context, answer []byte, requestKey string) ([]byte, error) { + var wire any + if err := json.Unmarshal(answer, &wire); err != nil { + log.Warnf(ctx, "%s's OTP service answered with a body that is not JSON (%d bytes)", c.provider, len(answer)) + return nil, refused(fmt.Errorf("%s answered without an envelope", c.provider)) + } + sealed, found := sealedAnswer(wire, c.responsePaths) + if !found { + log.Warnf(ctx, "%s's OTP service answered with no envelope at any configured path", c.provider) + return nil, refused(fmt.Errorf("%s answered without an envelope", c.provider)) + } + + // base64 has no '@', so the last one, if any, starts the reply's key. + ciphertext, key := strings.TrimSpace(sealed), requestKey + if at := strings.LastIndex(ciphertext, keySeparator); at >= 0 { + if replyKey := ciphertext[at+1:]; replyKey != "" { + key = replyKey + } + ciphertext = ciphertext[:at] + } + raw, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil || len(raw) == 0 { + log.Warnf(ctx, "%s's OTP service answered with plain text where its envelope belongs: %s", + c.provider, redactToken(util.Explain([]byte(sealed)), c.tokenEnv)) + return nil, refused(fmt.Errorf("%s rejected the request", c.provider)) + } + + plain, err := cbcDecrypt(key, raw) + if err != nil { + log.Errorf(ctx, err, "%s's OTP service answer will not decrypt", c.provider) + return nil, model.NewCodedErr(http.StatusInternalServerError, codeInternal, + fmt.Errorf("the answer from %s could not be decrypted", c.provider)) + } + plain = bytes.Trim(plain, " \t\r\n\x00") + if !json.Valid(plain) { + log.Warnf(ctx, "%s's OTP service sealed something other than JSON (%d bytes)", c.provider, len(plain)) + return nil, refused(fmt.Errorf("%s answered with something that is not JSON", c.provider)) + } + return plain, nil +} + +// newKeyString is 16 random bytes as 32 lower-case hex characters. +func (c *cbcCodec) newKeyString() (string, error) { + raw := make([]byte, 16) + if _, err := io.ReadFull(c.random, raw); err != nil { + return "", err + } + return hex.EncodeToString(raw), nil +} + +// keyMaterial is the first 16 bytes of the key string, zero-padded. +func keyMaterial(keyString string) []byte { + key := make([]byte, aes.BlockSize) + copy(key, keyString) + return key +} + +// cbcEncrypt is base64(AES-128-CBC(PKCS#7(plain))) under a key string. +func cbcEncrypt(keyString string, plain []byte) (string, error) { + key := keyMaterial(keyString) + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + padded := pkcs7Pad(plain, aes.BlockSize) + out := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, key).CryptBlocks(out, padded) + return base64.StdEncoding.EncodeToString(out), nil +} + +// cbcDecrypt reverses cbcEncrypt. +func cbcDecrypt(keyString string, raw []byte) ([]byte, error) { + if len(raw)%aes.BlockSize != 0 { + return nil, errors.New("ciphertext is not a whole number of blocks") + } + key := keyMaterial(keyString) + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + out := make([]byte, len(raw)) + cipher.NewCBCDecrypter(block, key).CryptBlocks(out, raw) + return pkcs7Unpad(out, aes.BlockSize) +} + +func pkcs7Pad(data []byte, size int) []byte { + n := size - len(data)%size + return append(append([]byte{}, data...), bytes.Repeat([]byte{byte(n)}, n)...) +} + +func pkcs7Unpad(data []byte, size int) ([]byte, error) { + if len(data) == 0 { + return nil, errors.New("empty plaintext") + } + n := int(data[len(data)-1]) + if n == 0 || n > size || n > len(data) { + return nil, errors.New("bad padding") + } + for _, b := range data[len(data)-n:] { + if int(b) != n { + return nil, errors.New("bad padding") + } + } + return data[:len(data)-n], nil +} diff --git a/pkg/plugin/implementation/internal/common/envelope/cbc_test.go b/pkg/plugin/implementation/internal/common/envelope/cbc_test.go new file mode 100644 index 000000000..3dba40d20 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/cbc_test.go @@ -0,0 +1,154 @@ +package envelope + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "testing" +) + +// Produced by Node's crypto the way the legacy PM-KISAN chatbot client does it: +// key string 32 hex characters, AES-128-CBC key and IV both its first 16 UTF-8 +// bytes, PKCS#7, base64. Matching them proves this codec speaks the OTP +// service's format byte for byte, not merely that it round-trips with itself. +const ( + cbcKeyString = "0123456789abcdef0123456789abcdef" + cbcPlain = `{"Types":"Ben_id","Values":"UP12345678A","Token":"test-otp-token"}` + cbcCipher = "MZOLY5yjTYkg9P+xMELaU7hdOIMvjSD5JeV5oUSATtmHsgftQ6GwY8yYERabCGTAZc5bxTopE+wxXWzxwD/e49OKZ5itMwwbCBThrF/svOE=" + + // An answer sealed under its own key, named after the '@'. + cbcReply = "DladryWQZWg3x/IX77sFz5fyvL8fZS58iyeovMdphFl1xs/uwnFWA3cdIOe8A3wd@fedcba9876543210fedcba9876543210" + cbcReplyPlain = `{"Rsponce":"True","Message":"OTP Verified"}` + + testOTPTokenEnv = "TEST_PMKISAN_OTP_TOKEN" + testOTPBindingKey = "pmkisan-chatbot|openagrinet:SchemeApplication" + testOTPToken = "test-otp-token" +) + +func otpRealm() realmSettings { + return realmSettings{kind: envelopeAESCBCKeyInBand, bindingKey: testOTPBindingKey, tokenEnv: testOTPTokenEnv, tokenField: "Token", + requestField: "EncryptedRequest", responsePaths: "d.output,output"} +} + +// testCBC is a CBC codec whose key strings are cbcKeyString, so its output is +// predictable. +func testCBC(t *testing.T) *cbcCodec { + t.Helper() + c, err := newCBCCodec("pmkisan", otpRealm()) + if err != nil { + t.Fatalf("newCBCCodec() = %v", err) + } + raw, _ := hex.DecodeString(cbcKeyString) + c.random = bytes.NewReader(bytes.Repeat(raw, 8)) + return c +} + +func TestCBCEncryptsWhatTheLegacyClientEncrypts(t *testing.T) { + got, err := cbcEncrypt(cbcKeyString, []byte(cbcPlain)) + if err != nil { + t.Fatalf("cbcEncrypt() = %v", err) + } + if got != cbcCipher { + t.Errorf("cbcEncrypt() = %s, want the legacy client's %s", got, cbcCipher) + } + raw, _ := base64.StdEncoding.DecodeString(cbcCipher) + plain, err := cbcDecrypt(cbcKeyString, raw) + if err != nil || string(plain) != cbcPlain { + t.Errorf("cbcDecrypt() = %s, %v; want the plaintext back", plain, err) + } +} + +func TestCBCSealSendsTheKeyBesideTheCiphertext(t *testing.T) { + t.Setenv(testOTPTokenEnv, testOTPToken) + c := testCBC(t) + + wire, open, err := c.seal(context.Background(), []byte(`{"Types":"Ben_id","Values":"UP12345678A"}`)) + if err != nil { + t.Fatalf("seal() = %v", err) + } + var body map[string]string + if err := json.Unmarshal(wire, &body); err != nil || len(body) != 1 { + t.Fatalf("seal() = %s, want exactly {\"EncryptedRequest\": ...}", wire) + } + sealed, key, found := strings.Cut(body["EncryptedRequest"], "@") + if !found || key != cbcKeyString { + t.Fatalf("EncryptedRequest = %q, want base64(ciphertext)@%s", body["EncryptedRequest"], cbcKeyString) + } + raw, _ := base64.StdEncoding.DecodeString(sealed) + plain, err := cbcDecrypt(key, raw) + if err != nil { + t.Fatalf("the sealed request does not decrypt under its own key: %v", err) + } + var sent map[string]string + _ = json.Unmarshal(plain, &sent) + if sent["Token"] != testOTPToken || sent["Values"] != "UP12345678A" { + t.Errorf("plaintext = %s, want the mapped fields plus Token", plain) + } + + // An answer with no key of its own opens under the request's. + reply, _ := cbcEncrypt(cbcKeyString, []byte(cbcReplyPlain)) + opened, err := open(context.Background(), []byte(`{"d":{"output":"`+reply+`"}}`)) + if err != nil || string(opened) != cbcReplyPlain { + t.Errorf("open() = %s, %v; want the reply opened under the request's key", opened, err) + } +} + +func TestCBCOpensAnAnswerUnderItsOwnKey(t *testing.T) { + c := testCBC(t) + plain, err := c.open(context.Background(), []byte(`{"d":{"output":"`+cbcReply+`"}}`), "wrong-request-key-wrong-request-k") + if err != nil || string(plain) != cbcReplyPlain { + t.Fatalf("open() = %s, %v; want the reply opened under the key it names", plain, err) + } +} + +func TestCBCOpenClassifiesWhatItCannotUse(t *testing.T) { + c := testCBC(t) + wrongKey, _ := cbcEncrypt("ffffffffffffffffffffffffffffffff", []byte(cbcReplyPlain)) + for _, tc := range []struct { + name, body, code string + status int + }{ + {"not JSON", `down`, "NET_DOWNSTREAM_UNAVAILABLE", http.StatusBadGateway}, + {"no envelope", `{"d":{}}`, "NET_DOWNSTREAM_UNAVAILABLE", http.StatusBadGateway}, + {"plain text where the ciphertext belongs", `{"d":{"output":"Invalid Token"}}`, "NET_DOWNSTREAM_UNAVAILABLE", http.StatusBadGateway}, + {"sealed under another key", `{"d":{"output":"` + wrongKey + `"}}`, "NET_INTERNAL_ERROR", http.StatusInternalServerError}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := c.open(context.Background(), []byte(tc.body), cbcKeyString) + if err == nil { + t.Fatal("open() succeeded, want a refusal") + } + if code, status := codeOf(t, err); code != tc.code || status != tc.status { + t.Errorf("got %s/%d, want %s/%d", code, status, tc.code, tc.status) + } + }) + } +} + +func TestCBCSealFailsClosedWithoutItsToken(t *testing.T) { + t.Setenv(testOTPTokenEnv, "") + if _, _, err := testCBC(t).seal(context.Background(), []byte(`{}`)); err == nil { + t.Fatal("seal() succeeded with the token unset; it must never send without it") + } +} + +func TestNewCBCCodecRefusesIncompleteSettings(t *testing.T) { + for name, tweak := range map[string]func(*realmSettings){ + "unknown kind": func(s *realmSettings) { s.kind = "aesGcm" }, + "no request field": func(s *realmSettings) { s.requestField = "" }, + "no response path": func(s *realmSettings) { s.responsePaths = "" }, + "token with no field": func(s *realmSettings) { s.tokenField = "" }, + } { + t.Run(name, func(t *testing.T) { + s := otpRealm() + tweak(&s) + if _, err := newCBCCodec("pmkisan", s); err == nil { + t.Fatal("newCBCCodec() accepted settings that cannot work") + } + }) + } +} diff --git a/pkg/plugin/implementation/internal/common/envelope/envelope.go b/pkg/plugin/implementation/internal/common/envelope/envelope.go new file mode 100644 index 000000000..dd92fb8e4 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/envelope.go @@ -0,0 +1,382 @@ +// Package envelope encrypts a provider's bodies, verifies a challenge before +// the call and makes follow-up reads, so mappings stay plain JSON (ADR-0001). +package envelope + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "sort" + "strconv" + "strings" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +// Envelope kinds. See gcm.go and cbc.go. +const ( + envelopeAESGCM = "aesGcm" // fixed key and nonce + envelopeAESCBCKeyInBand = "aesCbcKeyInBand" // fresh key per request, sent as cipher@key +) + +// codeInternal is returned when the adapter itself is misconfigured. +const codeInternal = "NET_INTERNAL_ERROR" + +// envelopeFields maps each provider-block setting to its field. envelope* is the +// default codec, otp* a separate OTP service. +var envelopeFields = map[string]func(*envelopeSettings, string){ + "envelope": func(s *envelopeSettings, v string) { s.kind = v }, + "envelopeKeyEnv": func(s *envelopeSettings, v string) { s.keyEnv = v }, + "envelopeNonceEnv": func(s *envelopeSettings, v string) { s.nonceEnv = v }, + "envelopeTokenEnv": func(s *envelopeSettings, v string) { s.tokenEnv = v }, + "envelopeTokenField": func(s *envelopeSettings, v string) { s.tokenField = v }, + "envelopeRequestField": func(s *envelopeSettings, v string) { s.requestField = v }, + "envelopeResponsePaths": func(s *envelopeSettings, v string) { s.responsePaths = v }, + + "otpEnvelope": func(s *envelopeSettings, v string) { s.otp.kind = v }, + "otpBindingKey": func(s *envelopeSettings, v string) { s.otp.bindingKey = v }, + "otpTokenEnv": func(s *envelopeSettings, v string) { s.otp.tokenEnv = v }, + "otpTokenField": func(s *envelopeSettings, v string) { s.otp.tokenField = v }, + "otpRequestField": func(s *envelopeSettings, v string) { s.otp.requestField = v }, + "otpResponsePaths": func(s *envelopeSettings, v string) { s.otp.responsePaths = v }, + "otpActions": func(s *envelopeSettings, v string) { s.otpActions = v }, + "otpVerifyPath": func(s *envelopeSettings, v string) { s.verifyPath = v }, + "otpVerifyActions": func(s *envelopeSettings, v string) { s.verifyActions = v }, + "otpVerifyTimeoutMs": func(s *envelopeSettings, v string) { s.verifyTimeoutMs = v }, +} + +// envelopeSettings is one provider's envelope as written in config. +type envelopeSettings struct { + kind, keyEnv, nonceEnv, tokenEnv, tokenField, requestField, responsePaths string + + otp realmSettings + otpActions string + verifyPath string + verifyActions string + verifyTimeoutMs string +} + +// realmSettings configures a CBC codec. bindingKey names the registry binding +// whose participant is the OTP service, a host other than the provider's. +type realmSettings struct { + kind, bindingKey, tokenEnv, tokenField, requestField, responsePaths string +} + +// Parsed is each provider's envelope and the actions it sends elsewhere, for +// common.Config's EnvelopeByProvider and HostedElsewhere. +type Parsed struct { + Envelopes map[string]common.Envelope + HostedElsewhere map[string]map[string]string +} + +// Parse reads each provider's "setting-provider" envelope settings and returns +// the rest. +func Parse(config map[string]string, hooks Hooks) (Parsed, map[string]string, error) { + rest := make(map[string]string, len(config)) + settings := map[string]*envelopeSettings{} + for key, value := range config { + field, provider, dashed := strings.Cut(key, "-") + set, isEnvelope := envelopeFields[field] + if !dashed || !isEnvelope { + rest[key] = value + continue + } + if settings[provider] == nil { + settings[provider] = &envelopeSettings{} + } + set(settings[provider], value) + } + if len(settings) == 0 { + return Parsed{}, rest, nil + } + + parsed := Parsed{Envelopes: make(map[string]common.Envelope, len(settings))} + for provider, s := range settings { + e, err := newProviderEnvelope(provider, *s, hooks) + if err != nil { + return Parsed{}, nil, err + } + parsed.Envelopes[provider] = e + if hosted := e.hostedElsewhere(); len(hosted) > 0 { + if parsed.HostedElsewhere == nil { + parsed.HostedElsewhere = map[string]map[string]string{} + } + parsed.HostedElsewhere[provider] = hosted + } + } + return parsed, rest, nil +} + +// codec seals a request and returns the opener for its answer. +type codec interface { + seal(ctx context.Context, mapped []byte) ([]byte, common.Opener, error) +} + +// providerEnvelope picks a codec per action and runs the OTP verify and +// follow-up read where configured. A nil field means that feature is off. +type providerEnvelope struct { + provider string + base codec // nil: plain JSON + otp codec + otpBindingKey string + otpActions map[string]bool + verify *verify +} + +var _ common.Envelope = (*providerEnvelope)(nil) + +// hostedElsewhere sends the OTP actions to the OTP service's participant. +func (e *providerEnvelope) hostedElsewhere() map[string]string { + if e.otpBindingKey == "" { + return nil + } + hosted := make(map[string]string, len(e.otpActions)) + for action := range e.otpActions { + hosted[action] = e.otpBindingKey + } + return hosted +} + +// otpHost is the OTP service's base URL, read from the registry per call. +func (e *providerEnvelope) otpHost(ctx context.Context, exchange common.Exchange) (string, error) { + return common.HostOf(ctx, exchange.Registry, e.otpBindingKey) +} + +// Seal verifies the OTP if the action needs it, then seals the request. A wrong +// OTP stops the call. +func (e *providerEnvelope) Seal(ctx context.Context, exchange common.Exchange, mapped []byte) ([]byte, common.Opener, error) { + if e.verify != nil && e.verify.actions[exchange.Action] { + if err := e.verify.check(ctx, exchange); err != nil { + return nil, nil, err + } + } + c := e.base + if e.otpActions[exchange.Action] { + c = e.otp + } + if c == nil { + return mapped, func(_ context.Context, answer []byte) ([]byte, error) { return answer, nil }, nil + } + return c.seal(ctx, mapped) +} + +// newProviderEnvelope validates the settings. Secrets are read at call time. +func newProviderEnvelope(provider string, s envelopeSettings, hooks Hooks) (*providerEnvelope, error) { + if hooks.Succeeded == nil { + return nil, fmt.Errorf("%s: the envelope needs Hooks.Succeeded", provider) + } + e := &providerEnvelope{provider: provider, otpBindingKey: strings.TrimSpace(s.otp.bindingKey), + otpActions: listSet(s.otpActions)} + // The verify carries the farmer's identifier, so it refuses an off-host + // redirect as the main call does. + client := &http.Client{CheckRedirect: util.RefuseOffHostRedirect} + + switch s.kind { + case "": + // Without the kind the body would go out unsealed: refuse at startup. + if s.keyEnv != "" || s.nonceEnv != "" || s.tokenEnv != "" || s.tokenField != "" || + s.requestField != "" || s.responsePaths != "" { + return nil, fmt.Errorf("%s: envelope settings are present but envelope is not set", provider) + } + default: + base, err := newGCMCodec(provider, s) + if err != nil { + return nil, err + } + e.base = base + } + + if s.otp.kind != "" { + otp, err := newCBCCodec(provider, s.otp) + if err != nil { + return nil, err + } + if len(e.otpActions) == 0 { + return nil, fmt.Errorf("%s: otpEnvelope needs otpActions, the actions sent through it", provider) + } + participant, capability, found := strings.Cut(e.otpBindingKey, "|") + if !found || participant == "" || capability == "" { + return nil, fmt.Errorf("%s: otpEnvelope needs otpBindingKey, the binding key (participant|capability) "+ + "whose participant is the OTP service; got %q", provider, e.otpBindingKey) + } + e.otp = otp + } else if len(e.otpActions) > 0 || e.otpBindingKey != "" || s.otp.tokenEnv != "" || s.otp.requestField != "" || s.otp.responsePaths != "" { + return nil, fmt.Errorf("%s: otp settings are present but otpEnvelope is not set", provider) + } + + verifyTimeoutMs, err := timeoutSetting(provider, "otpVerifyTimeoutMs", s.verifyTimeoutMs) + if err != nil { + return nil, err + } + + verifyActions := listSet(s.verifyActions) + switch { + case s.verifyPath == "" && len(verifyActions) == 0: + case s.verifyPath == "" || len(verifyActions) == 0: + return nil, fmt.Errorf("%s: otpVerifyPath and otpVerifyActions are set together or not at all", provider) + case hooks.Challenge == nil: + return nil, fmt.Errorf("%s: otpVerifyPath needs Hooks.Challenge, to read the OTP from a payload", provider) + case e.otp == nil: + return nil, fmt.Errorf("%s: otpVerifyPath needs otpEnvelope: the OTP is verified on the OTP service", provider) + default: + e.verify = &verify{provider: provider, path: s.verifyPath, actions: verifyActions, codec: e.otp.(*cbcCodec), + host: e.otpHost, hooks: hooks, client: client, budget: model.ActionPlan{TimeoutMs: verifyTimeoutMs}} + } + + if e.base == nil && e.otp == nil { + return nil, fmt.Errorf("%s: an envelope block needs envelope or otpEnvelope", provider) + } + return e, nil +} + + +// --- shared by the codecs ---------------------------------------------------- + +// withToken adds the service token to the plaintext. It is added here, not in +// the mapping, because mapping files are public. +func withToken(ctx context.Context, provider string, mapped []byte, tokenEnv, tokenField string) ([]byte, error) { + fields := map[string]any{} + if len(mapped) > 0 { + if err := json.Unmarshal(mapped, &fields); err != nil { + return nil, fmt.Errorf("the %s request mapping must produce a JSON object: %w", provider, err) + } + } + if tokenField != "" { + token := os.Getenv(tokenEnv) + if token == "" { + return nil, unconfigured(ctx, provider, tokenEnv+" is not set") + } + if _, present := fields[tokenField]; present { + // A public mapping must never carry the token. + return nil, fmt.Errorf("the %s request mapping sets %s, which the envelope supplies; "+ + "remove it from the mapping", provider, tokenField) + } + fields[tokenField] = token + } + plain, err := json.Marshal(fields) + if err != nil { + return nil, fmt.Errorf("the %s request could not be encoded: %w", provider, err) + } + return plain, nil +} + +// checkToken requires tokenEnv and tokenField together or not at all. +func checkToken(tokenEnv, tokenField string, missing []string, prefix string) []string { + if (tokenEnv == "") != (tokenField == "") { + return append(missing, "both "+prefix+"TokenEnv and "+prefix+"TokenField, or neither") + } + return missing +} + +// parsePaths reads a comma-separated list of dotted paths. +func parsePaths(provider, setting, raw string) ([][]string, error) { + var paths [][]string + for _, path := range strings.Split(raw, ",") { + path = strings.TrimSpace(path) + if path == "" { + continue + } + segments := strings.Split(path, ".") + for _, segment := range segments { + if strings.TrimSpace(segment) == "" { + return nil, fmt.Errorf("%s: %s %q has a blank segment", provider, setting, path) + } + } + paths = append(paths, segments) + } + return paths, nil +} + +// sealedAnswer finds the sealed string at the first path that holds one. +func sealedAnswer(wire any, paths [][]string) (string, bool) { + for _, path := range paths { + node := wire + for _, key := range path { + fields, ok := node.(map[string]any) + if !ok { + node = nil + break + } + node = fields[key] + } + if value, ok := node.(string); ok { + return value, true + } + } + return "", false +} + +// unconfigured returns a 500; the detail goes to the log, not the wire. +func unconfigured(ctx context.Context, provider, detail string) error { + err := fmt.Errorf("the envelope for %s is not configured on this adapter", provider) + log.Errorf(ctx, err, "%s", detail) + return model.NewCodedErr(http.StatusInternalServerError, codeInternal, err) +} + +// redactToken removes a service token from text about to be logged. +func redactToken(text, tokenEnv string) string { + if tokenEnv == "" { + return text + } + if token := os.Getenv(tokenEnv); token != "" { + text = strings.ReplaceAll(text, token, util.RedactedMarker) + } + return text +} + +// refused is a 502: the provider's answer is unusable. +func refused(err error) error { + return model.NewCodedErr(http.StatusBadGateway, util.CodeUpstreamUnavailable, err) +} + +// decodeHex decodes hex, ignoring whitespace. +func decodeHex(raw string) ([]byte, error) { + cleaned := strings.Join(strings.Fields(raw), "") + if cleaned == "" { + return nil, errors.New("empty") + } + return hex.DecodeString(cleaned) +} + +// listSet reads a comma-separated list into a set. +func listSet(raw string) map[string]bool { + set := map[string]bool{} + for _, part := range strings.Split(raw, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + set[trimmed] = true + } + } + return set +} + +// missingSettings lists empty settings, sorted. +func missingSettings(settings map[string]string) []string { + var missing []string + for setting, value := range settings { + if strings.TrimSpace(value) == "" { + missing = append(missing, setting) + } + } + sort.Strings(missing) + return missing +} + +// timeoutSetting reads a call's own budget in milliseconds. Unset is 0, which +// util.Budget takes as its default. +func timeoutSetting(provider, name, value string) (int, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + ms, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || ms <= 0 { + return 0, fmt.Errorf("%s: %s must be a positive number of milliseconds, got %q", provider, name, value) + } + return ms, nil +} diff --git a/pkg/plugin/implementation/internal/common/envelope/envelope_test.go b/pkg/plugin/implementation/internal/common/envelope/envelope_test.go new file mode 100644 index 000000000..53e477f3a --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/envelope_test.go @@ -0,0 +1,402 @@ +package envelope + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" +) + +// Test material only. Made up for these tests; it is not, and must never be, +// the portal's. +const ( + testKeyHex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + testNonceHex = "a0a1a2a3a4a5a6a7a8a9aaab" + testToken = "test-service-token" + + testKeyEnv = "TEST_PMKISAN_KEY" + testNonceEnv = "TEST_PMKISAN_NONCE" + testTokenEnv = "TEST_PMKISAN_TOKEN" +) + +// legacyVectors were produced by Node's crypto with the same calls the legacy +// PM-KISAN client makes -- createCipheriv("aes-256-gcm", key, iv), then +// base64(ciphertext || authTag) -- over vectorPlaintext. Matching them proves +// this envelope speaks the portal's format byte for byte, not merely that it +// round-trips with itself. +// +// Two nonce lengths, because the portal's is not documented: 12 bytes is GCM's +// standard path, and anything else takes the hashed-nonce path. +const vectorPlaintext = `{"Responce":"True","message":"Grievance lodged"}` + +var legacyVectors = map[string]string{ + "a0a1a2a3a4a5a6a7a8a9aaab": "nTouSDa7bdEBAKXpJS6yqxWOdTL/0jEf/WlDpEWJMnO7EzGewUE2HTPzYK9sHqGEK+AlFb2oL9FNTItRmM+LMQ==", + "b0b1b2b3b4b5b6b7b8b9babbbcbdbebf": "yi/WF5kjet5MxolK5bGSb88/hP6Daw1FPfZdP2PB+fdYV84BOj0bFh4+sLqc4SkuJLa8xsCECoAGHG2F/cfnRg==", +} + +func setTestSecrets(t *testing.T) { + t.Helper() + t.Setenv(testKeyEnv, testKeyHex) + t.Setenv(testNonceEnv, testNonceHex) + t.Setenv(testTokenEnv, testToken) +} + +// pmkisanSettings is PM-KISAN's envelope as config/provider-adapter.yaml +// declares it. +func pmkisanSettings() envelopeSettings { + return envelopeSettings{ + kind: envelopeAESGCM, keyEnv: testKeyEnv, nonceEnv: testNonceEnv, + tokenEnv: testTokenEnv, tokenField: "TokenNo", + requestField: "EncryptedRequest", responsePaths: "d.output,output", + } +} + +func testEnvelope(t *testing.T) *gcmCodec { + t.Helper() + e, err := newGCMCodec("pmkisan", pmkisanSettings()) + if err != nil { + t.Fatalf("newGCMCodec() = %v", err) + } + return e +} + +// codeOf returns the taxonomy code and status of a coded step error. +func codeOf(t *testing.T, err error) (string, int) { + t.Helper() + var coded *model.CodedErr + if !errors.As(err, &coded) { + t.Fatalf("error %v (%T) is not a coded error", err, err) + } + return coded.BecknError().Code, coded.HTTPStatus() +} + +func TestOpenReadsWhatTheLegacyClientWrites(t *testing.T) { + for nonce, sealed := range legacyVectors { + t.Run(nonce, func(t *testing.T) { + setTestSecrets(t) + t.Setenv(testNonceEnv, nonce) + + plain, err := testEnvelope(t).Open(context.Background(), + []byte(`{"d":{"__type":"x","output":"`+sealed+`"}}`)) + if err != nil { + t.Fatalf("Open() = %v", err) + } + if string(plain) != vectorPlaintext { + t.Errorf("Open() = %s, want %s", plain, vectorPlaintext) + } + }) + } +} + +func TestSealWritesWhatTheLegacyClientWrites(t *testing.T) { + // The fixed nonce makes the output deterministic, so a known plaintext + // must seal to the known ciphertext. TokenNo is added by Seal, so the + // vector is checked through the cipher directly, then Seal's own output is + // checked to decrypt to the mapped body plus the token. + setTestSecrets(t) + e := testEnvelope(t) + + gcm, nonce, err := e.cipher(context.Background()) + if err != nil { + t.Fatalf("cipher() = %v", err) + } + got := base64.StdEncoding.EncodeToString(gcm.Seal(nil, nonce, []byte(vectorPlaintext), nil)) + if want := legacyVectors[testNonceHex]; got != want { + t.Errorf("sealed %s, want the legacy client's %s", got, want) + } + + wire, err := e.Seal(context.Background(), []byte(`{"Type":"Reg_No_Status","IdentityNo":"UP12345678A"}`)) + if err != nil { + t.Fatalf("Seal() = %v", err) + } + var body map[string]string + if err := json.Unmarshal(wire, &body); err != nil || len(body) != 1 || body["EncryptedRequest"] == "" { + t.Fatalf("Seal() = %s, want exactly {\"EncryptedRequest\": ...}", wire) + } + if strings.Contains(string(wire), testToken) || strings.Contains(string(wire), "UP12345678A") { + t.Errorf("sealed body %s carries plaintext", wire) + } + raw, _ := base64.StdEncoding.DecodeString(body["EncryptedRequest"]) + plain, err := gcm.Open(nil, nonce, raw, nil) + if err != nil { + t.Fatalf("Seal()'s output does not decrypt: %v", err) + } + var sent map[string]string + _ = json.Unmarshal(plain, &sent) + if sent["TokenNo"] != testToken || sent["IdentityNo"] != "UP12345678A" || sent["Type"] != "Reg_No_Status" { + t.Errorf("plaintext = %s, want the mapped fields plus TokenNo", plain) + } +} + +func TestSealRefusesAMappingThatSetsTheToken(t *testing.T) { + setTestSecrets(t) + if _, err := testEnvelope(t).Seal(context.Background(), []byte(`{"TokenNo":"x"}`)); err == nil { + t.Fatal("Seal() accepted a mapping carrying TokenNo, want a refusal") + } +} + +func TestSealFailsClosedWithoutItsSecrets(t *testing.T) { + for _, unset := range []string{testKeyEnv, testNonceEnv, testTokenEnv} { + t.Run(unset, func(t *testing.T) { + setTestSecrets(t) + t.Setenv(unset, "") + + _, err := testEnvelope(t).Seal(context.Background(), []byte(`{}`)) + if err == nil { + t.Fatal("Seal() succeeded with a secret unset; it must never send plaintext") + } + code, status := codeOf(t, err) + if code != "NET_INTERNAL_ERROR" || status != http.StatusInternalServerError { + t.Errorf("got %s/%d, want NET_INTERNAL_ERROR/500", code, status) + } + if strings.Contains(err.Error(), unset) { + t.Errorf("error %q names the variable; that belongs in the log, not on the wire", err) + } + }) + } +} + +// The key is hex. Any AES length is taken; anything else is refused. +func TestSealTakesAnyAESKeyLength(t *testing.T) { + for hexChars, ok := range map[int]bool{32: true, 48: true, 64: true, 20: false, 40: false} { + t.Run(fmt.Sprintf("%d hex characters", hexChars), func(t *testing.T) { + setTestSecrets(t) + t.Setenv(testKeyEnv, strings.Repeat("ab", hexChars/2)) + _, err := testEnvelope(t).Seal(context.Background(), []byte(`{}`)) + if ok && err != nil { + t.Fatalf("Seal() = %v, want a %d-byte key accepted", err, hexChars/2) + } + if !ok && err == nil { + t.Fatalf("Seal() accepted a %d-byte key, want it refused", hexChars/2) + } + }) + } +} + +// A 16-byte key seals with AES-128-GCM, readable with the same hex key and +// nonce by any standard implementation. +func TestSealWithASixteenByteKeyIsAES128GCM(t *testing.T) { + setTestSecrets(t) + key := testKeyHex[:32] + t.Setenv(testKeyEnv, key) + wire, err := testEnvelope(t).Seal(context.Background(), []byte(`{"Type":"Reg_No_Status"}`)) + if err != nil { + t.Fatalf("Seal() = %v", err) + } + var body map[string]string + if err := json.Unmarshal(wire, &body); err != nil { + t.Fatalf("sealed body %s: %v", wire, err) + } + sealed, _ := base64.StdEncoding.DecodeString(body["EncryptedRequest"]) + rawKey, _ := hex.DecodeString(key) + nonce, _ := hex.DecodeString(testNonceHex) + block, _ := aes.NewCipher(rawKey) + gcm, _ := cipher.NewGCMWithNonceSize(block, len(nonce)) + plain, err := gcm.Open(nil, nonce, sealed, nil) + if err != nil { + t.Fatalf("AES-128-GCM could not open what Seal() sent: %v", err) + } + if !strings.Contains(string(plain), `"Type":"Reg_No_Status"`) || !strings.Contains(string(plain), testToken) { + t.Errorf("opened %s, want the request with the service token", plain) + } +} + +func TestOpenClassifiesWhatItCannotUse(t *testing.T) { + setTestSecrets(t) + e := testEnvelope(t) + + tampered := []byte(legacyVectors[testNonceHex]) + tampered[3] ^= 0x01 + + for _, tc := range []struct { + name, body, code string + status int + }{ + {"not JSON", `Service Unavailable`, "NET_DOWNSTREAM_UNAVAILABLE", 502}, + {"no envelope", `{"d":{}}`, "NET_DOWNSTREAM_UNAVAILABLE", 502}, + {"plain text where the ciphertext belongs", `{"d":{"output":"Invalid Token No."}}`, "NET_DOWNSTREAM_UNAVAILABLE", 502}, + {"an envelope that will not open", `{"d":{"output":"` + string(tampered) + `"}}`, "NET_INTERNAL_ERROR", 500}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := e.Open(context.Background(), []byte(tc.body)) + if err == nil { + t.Fatal("Open() succeeded, want a refusal") + } + if code, status := codeOf(t, err); code != tc.code || status != tc.status { + t.Errorf("got %s/%d, want %s/%d", code, status, tc.code, tc.status) + } + if strings.Contains(err.Error(), "Invalid Token") { + t.Errorf("error %q passes the portal's message through", err) + } + }) + } +} + +func TestOpenAcceptsABareOutput(t *testing.T) { + setTestSecrets(t) + plain, err := testEnvelope(t).Open(context.Background(), + []byte(`{"output":"`+legacyVectors[testNonceHex]+`"}`)) + if err != nil || string(plain) != vectorPlaintext { + t.Fatalf("Open() = %s, %v; want the plaintext from a bare output", plain, err) + } +} + +func TestNewEnvelopeRefusesIncompleteSettings(t *testing.T) { + for name, tweak := range map[string]func(*envelopeSettings){ + "unknown kind": func(s *envelopeSettings) { s.kind = "aesCbc" }, + "no key variable": func(s *envelopeSettings) { s.keyEnv = "" }, + "no nonce variable": func(s *envelopeSettings) { s.nonceEnv = "" }, + "no request field": func(s *envelopeSettings) { s.requestField = "" }, + "no response path": func(s *envelopeSettings) { s.responsePaths = "" }, + "token with no field": func(s *envelopeSettings) { s.tokenField = "" }, + "field with no token": func(s *envelopeSettings) { s.tokenEnv = "" }, + "blank path segment": func(s *envelopeSettings) { s.responsePaths = "d..output" }, + } { + t.Run(name, func(t *testing.T) { + settings := pmkisanSettings() + tweak(&settings) + if _, err := newGCMCodec("pmkisan", settings); err == nil { + t.Fatal("newGCMCodec() accepted settings that cannot work") + } + }) + } +} + +func TestNewEnvelopeWithoutATokenSendsNone(t *testing.T) { + setTestSecrets(t) + settings := pmkisanSettings() + settings.tokenEnv, settings.tokenField = "", "" + e, err := newGCMCodec("pmkisan", settings) + if err != nil { + t.Fatalf("newGCMCodec() = %v", err) + } + wire, err := e.Seal(context.Background(), []byte(`{"a":1}`)) + if err != nil { + t.Fatalf("Seal() = %v", err) + } + var body map[string]string + _ = json.Unmarshal(wire, &body) + raw, _ := base64.StdEncoding.DecodeString(body["EncryptedRequest"]) + gcm, nonce, _ := e.cipher(context.Background()) + plain, _ := gcm.Open(nil, nonce, raw, nil) + if string(plain) != `{"a":1}` { + t.Errorf("plaintext = %s, want the mapped body with no token added", plain) + } +} + +func TestParseTakesTheEnvelopeOutOfTheProviderBlock(t *testing.T) { + parsed, rest, err := Parse(map[string]string{ + "bindingKeys": "pmkisan|openagrinet:Grievance", + "authScheme-pmkisan": "none", + "envelope-pmkisan": "aesGcm", + "envelopeKeyEnv-pmkisan": testKeyEnv, + "envelopeNonceEnv-pmkisan": testNonceEnv, + "envelopeTokenEnv-pmkisan": testTokenEnv, + "envelopeTokenField-pmkisan": "TokenNo", + "envelopeRequestField-pmkisan": "EncryptedRequest", + "envelopeResponsePaths-pmkisan": "d.output,output", + }, testHooks) + if err != nil { + t.Fatalf("Parse() = %v", err) + } + if _, ok := parsed.Envelopes["pmkisan"]; !ok || len(parsed.Envelopes) != 1 { + t.Errorf("envelopes = %v, want pmkisan's alone", parsed.Envelopes) + } + if parsed.HostedElsewhere != nil { + t.Errorf("HostedElsewhere = %v, want none: pmkisan names no OTP service here", parsed.HostedElsewhere) + } + for key := range rest { + if strings.HasPrefix(key, "envelope") { + t.Errorf("%s was left for the credential parser, which refuses it", key) + } + } + if rest["authScheme-pmkisan"] != "none" || rest["bindingKeys"] == "" { + t.Errorf("rest = %v, want everything but the envelope kept", rest) + } +} + +func TestParseWithNoneDeclaredReturnsNone(t *testing.T) { + parsed, rest, err := Parse(map[string]string{"authScheme-pmfby": "none"}, testHooks) + if err != nil || parsed.Envelopes != nil || parsed.HostedElsewhere != nil || rest["authScheme-pmfby"] != "none" { + t.Fatalf("Parse() = %v, %v, %v; want nothing parsed and the config back", parsed, rest, err) + } +} + +// The OTP actions go to the OTP service's participant, named by binding key. +func TestParseNamesTheOTPServicesParticipantForTheOTPActions(t *testing.T) { + parsed, _, err := Parse(map[string]string{ + "envelope-pmkisan": "aesGcm", + "envelopeKeyEnv-pmkisan": testKeyEnv, + "envelopeNonceEnv-pmkisan": testNonceEnv, + "envelopeRequestField-pmkisan": "EncryptedRequest", + "envelopeResponsePaths-pmkisan": "d.output,output", + "otpEnvelope-pmkisan": "aesCbcKeyInBand", + "otpBindingKey-pmkisan": testOTPBindingKey, + "otpRequestField-pmkisan": "EncryptedRequest", + "otpResponsePaths-pmkisan": "d.output,output", + "otpActions-pmkisan": "init", + }, testHooks) + if err != nil { + t.Fatalf("Parse() = %v", err) + } + if got := parsed.HostedElsewhere["pmkisan"]; len(got) != 1 || got["init"] != testOTPBindingKey { + t.Errorf("HostedElsewhere = %v, want pmkisan's init on %s", parsed.HostedElsewhere, testOTPBindingKey) + } +} + +func TestNewEnvelopeNeedsTheHooksItUses(t *testing.T) { + if _, err := newProviderEnvelope("pmkisan", pmkisanSettings(), Hooks{}); err == nil { + t.Error("newProviderEnvelope() took no Succeeded") + } + s := pmkisanSettings() + s.otp, s.otpActions, s.verifyPath, s.verifyActions = otpRealm(), "init", "/v", "support" + if _, err := newProviderEnvelope("pmkisan", s, Hooks{Succeeded: testHooks.Succeeded}); err == nil { + t.Error("newProviderEnvelope() took a verify with no Challenge") + } +} + +// Envelope settings without the kind would send the body unsealed. +func TestNewEnvelopeRefusesSettingsWithoutAKind(t *testing.T) { + s := pmkisanSettings() + s.kind = "" + _, err := newProviderEnvelope("pmkisan", s, testHooks) + if err == nil || !strings.Contains(err.Error(), "envelope is not set") { + t.Errorf("newProviderEnvelope() = %v, want a refusal naming the missing envelope", err) + } +} + +// A fixed GCM nonce is already a weakness; a short one is refused. +func TestSealRefusesANonceShorterThanGCMs(t *testing.T) { + setTestSecrets(t) + t.Setenv(testNonceEnv, "a0a1a2a3") + _, err := testEnvelope(t).Seal(context.Background(), []byte(`{}`)) + if err == nil { + t.Fatal("Seal() took a 4-byte nonce") + } + if code, _ := codeOf(t, err); code != "NET_INTERNAL_ERROR" { + t.Errorf("got %s, want NET_INTERNAL_ERROR", code) + } +} + +func TestNewEnvelopeRefusesABadTimeout(t *testing.T) { + for name, set := range map[string]func(*envelopeSettings){ + "otpVerifyTimeoutMs": func(s *envelopeSettings) { s.verifyTimeoutMs = "soon" }, + } { + t.Run(name, func(t *testing.T) { + s := pmkisanSettings() + set(&s) + if _, err := newProviderEnvelope("pmkisan", s, testHooks); err == nil || !strings.Contains(err.Error(), name) { + t.Errorf("newProviderEnvelope() = %v, want a refusal naming %s", err, name) + } + }) + } +} diff --git a/pkg/plugin/implementation/internal/common/envelope/gcm.go b/pkg/plugin/implementation/internal/common/envelope/gcm.go new file mode 100644 index 000000000..10d911fc9 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/gcm.go @@ -0,0 +1,158 @@ +package envelope + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +// keySizes are the AES key lengths in bytes, decoded from hex. +var keySizes = map[int]bool{16: true, 24: true, 32: true} + +// minNonceSize is GCM's standard nonce length. A shorter one is refused; it +// only makes the fixed-nonce break below easier. +const minNonceSize = 12 + +// gcmCodec is AES-GCM under a fixed key and nonce: base64(ciphertext||tag). +// A fixed nonce breaks GCM; TLS is the only protection (ADR-0001). +type gcmCodec struct { + provider string + keyEnv, nonceEnv, tokenEnv string + tokenField string + requestField string + responsePaths [][]string +} + +// newGCMCodec validates the settings. +func newGCMCodec(provider string, s envelopeSettings) (*gcmCodec, error) { + if s.kind != envelopeAESGCM { + return nil, fmt.Errorf("%s: envelope %q is not one this plugin knows; want %s", + provider, s.kind, envelopeAESGCM) + } + missing := missingSettings(map[string]string{ + "envelopeKeyEnv": s.keyEnv, + "envelopeNonceEnv": s.nonceEnv, + "envelopeRequestField": s.requestField, + "envelopeResponsePaths": s.responsePaths, + }) + missing = checkToken(s.tokenEnv, s.tokenField, missing, "envelope") + if len(missing) > 0 { + return nil, fmt.Errorf("%s: envelope %s needs %s", provider, envelopeAESGCM, strings.Join(missing, ", ")) + } + paths, err := parsePaths(provider, "envelopeResponsePaths", s.responsePaths) + if err != nil { + return nil, err + } + return &gcmCodec{provider: provider, keyEnv: s.keyEnv, nonceEnv: s.nonceEnv, + tokenEnv: s.tokenEnv, tokenField: s.tokenField, requestField: s.requestField, + responsePaths: paths}, nil +} + +func (c *gcmCodec) seal(ctx context.Context, mapped []byte) ([]byte, common.Opener, error) { + sealed, err := c.Seal(ctx, mapped) + if err != nil { + return nil, nil, err + } + return sealed, c.Open, nil +} + +// Seal adds the service token to the mapped request and encrypts it. +func (c *gcmCodec) Seal(ctx context.Context, mapped []byte) ([]byte, error) { + plain, err := withToken(ctx, c.provider, mapped, c.tokenEnv, c.tokenField) + if err != nil { + return nil, err + } + gcm, nonce, err := c.cipher(ctx) + if err != nil { + return nil, err + } + // Go appends the 16-byte tag, the layout the provider expects. + sealed := base64.StdEncoding.EncodeToString(gcm.Seal(nil, nonce, plain, nil)) + return json.Marshal(map[string]string{c.requestField: sealed}) +} + +// Open decrypts the answer. Missing or plain-text envelope: 502. Will not +// decrypt (our key is wrong): 500. Not JSON: 502. Neither text is logged. +func (c *gcmCodec) Open(ctx context.Context, answer []byte) ([]byte, error) { + var wire any + if err := json.Unmarshal(answer, &wire); err != nil { + log.Warnf(ctx, "%s answered with a body that is not JSON (%d bytes)", c.provider, len(answer)) + return nil, refused(fmt.Errorf("%s answered without an envelope", c.provider)) + } + sealed, found := sealedAnswer(wire, c.responsePaths) + if !found { + log.Warnf(ctx, "%s answered with no envelope at any configured path", c.provider) + return nil, refused(fmt.Errorf("%s answered without an envelope", c.provider)) + } + + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sealed)) + if err != nil { + // A plain-text rejection: log it redacted, never return it. + log.Warnf(ctx, "%s answered with plain text where its envelope belongs: %s", + c.provider, redactToken(util.Explain([]byte(sealed)), c.tokenEnv)) + return nil, refused(fmt.Errorf("%s rejected the request", c.provider)) + } + + gcm, nonce, err := c.cipher(ctx) + if err != nil { + return nil, err + } + plain, err := gcm.Open(nil, nonce, raw, nil) + if err != nil { + log.Errorf(ctx, err, "%s's answer will not decrypt; check %s and %s against the values it issued", + c.provider, c.keyEnv, c.nonceEnv) + return nil, model.NewCodedErr(http.StatusInternalServerError, codeInternal, + fmt.Errorf("the answer from %s could not be decrypted", c.provider)) + } + if !json.Valid(plain) { + log.Warnf(ctx, "%s sealed something other than JSON (%d bytes)", c.provider, len(plain)) + return nil, refused(fmt.Errorf("%s answered with something that is not JSON", c.provider)) + } + return plain, nil +} + +// cipher builds the AEAD from the environment. +func (c *gcmCodec) cipher(ctx context.Context) (cipher.AEAD, []byte, error) { + rawKey, rawNonce := os.Getenv(c.keyEnv), os.Getenv(c.nonceEnv) + if rawKey == "" || rawNonce == "" { + return nil, nil, unconfigured(ctx, c.provider, c.keyEnv+" and "+c.nonceEnv+" must both be set") + } + key, err := decodeHex(rawKey) + if err != nil { + return nil, nil, unconfigured(ctx, c.provider, c.keyEnv+" is not hex") + } + if !keySizes[len(key)] { + return nil, nil, unconfigured(ctx, c.provider, fmt.Sprintf("%s decodes to %d bytes; AES needs 16, 24 or 32", + c.keyEnv, len(key))) + } + nonce, err := decodeHex(rawNonce) + if err != nil { + return nil, nil, unconfigured(ctx, c.provider, c.nonceEnv+" is not hex") + } + if len(nonce) < minNonceSize { + return nil, nil, unconfigured(ctx, c.provider, fmt.Sprintf("%s decodes to %d bytes; GCM needs at least %d", + c.nonceEnv, len(nonce), minNonceSize)) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, nil, unconfigured(ctx, c.provider, "the AES key was refused: "+err.Error()) + } + // Any nonce length the provider issued, from minNonceSize up. + gcm, err := cipher.NewGCMWithNonceSize(block, len(nonce)) + if err != nil { + return nil, nil, unconfigured(ctx, c.provider, "GCM could not be built: "+err.Error()) + } + return gcm, nonce, nil +} diff --git a/pkg/plugin/implementation/internal/common/envelope/hooks_test.go b/pkg/plugin/implementation/internal/common/envelope/hooks_test.go new file mode 100644 index 000000000..dcddf4eb1 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/hooks_test.go @@ -0,0 +1,66 @@ +package envelope + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +// testHooks stand in for a capability's: the challenge sits where the +// grievance packs put it, and "yes" is a "True" flag. Enough for this +// package's tests; the real ones are the Grievance package's. +var testHooks = Hooks{ + Challenge: func(exchange common.Exchange) (map[string]any, error) { + holder := testHolder(exchange.Beckn) + kind, id := "", "" + if v, _ := holder["enrolmentId"].(string); v != "" { + kind, id = "Ben_id", v + } else if applicant, ok := holder["applicant"].(map[string]any); ok { + if v, _ := applicant["mobile"].(string); v != "" { + kind, id = "Mobile", v + } + } + challenge, _ := holder["challenge"].(map[string]any) + response, _ := challenge["response"].(map[string]any) + otp, _ := response["value"].(string) + if kind == "" || otp == "" { + return nil, model.NewBadReqErr("SCH_REQUIRED_FIELD_MISSING", errors.New("no identifier or OTP")) + } + return map[string]any{"Types": kind, "Values": id, "OTP": otp}, nil + }, + Succeeded: func(plain []byte) bool { + var answer map[string]any + if json.Unmarshal(plain, &answer) != nil { + return false + } + for _, key := range []string{"Rsponce", "Responce", "Status", "status"} { + if value, ok := answer[key].(string); ok { + return strings.EqualFold(strings.TrimSpace(value), "true") + } + } + return false + }, +} + +// testHolder is support's channel or the commitment's attributes. +func testHolder(beckn any) map[string]any { + payload, _ := beckn.(map[string]any) + message, _ := payload["message"].(map[string]any) + if support, ok := message["support"].(map[string]any); ok { + if channels, ok := support["channels"].([]any); ok && len(channels) > 0 { + holder, _ := channels[0].(map[string]any) + return holder + } + return nil + } + contract, _ := message["contract"].(map[string]any) + if commitments, ok := contract["commitments"].([]any); ok && len(commitments) > 0 { + commitment, _ := commitments[0].(map[string]any) + holder, _ := commitment["commitmentAttributes"].(map[string]any) + return holder + } + return nil +} diff --git a/pkg/plugin/implementation/internal/common/envelope/verify.go b/pkg/plugin/implementation/internal/common/envelope/verify.go new file mode 100644 index 000000000..58b845139 --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/verify.go @@ -0,0 +1,75 @@ +package envelope + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/beckn-one/beckn-onix/pkg/log" + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +// Hooks are what the capability knows and this package does not: where its +// payload carries the challenge, and what its provider's "yes" looks like. +type Hooks struct { + // Challenge builds the verify request from the payload, or a coded + // refusal. Needed only by a provider that verifies. + Challenge func(exchange common.Exchange) (map[string]any, error) + + // Succeeded reports whether an opened answer says yes. It must fail + // closed: anything it cannot read is a no. + Succeeded func(plain []byte) bool +} + +// verify checks the caller's challenge before the guarded call. The challenge +// goes only here, never to the call, the log or the reply. +type verify struct { + provider string + path string + actions map[string]bool + codec *cbcCodec + host func(context.Context, common.Exchange) (string, error) + hooks Hooks + client *http.Client + // budget is the verify's own time budget, not the guarded action's. + budget model.ActionPlan +} + +// check verifies the challenge. Unreachable: 502. Not a yes: 400 +// BIZ_GENERIC_ERROR (401 means a bad Beckn signature). +func (v *verify) check(ctx context.Context, exchange common.Exchange) error { + fields, err := v.hooks.Challenge(exchange) + if err != nil { + return err + } + base, err := v.host(ctx, exchange) + if err != nil { + return err + } + body, err := json.Marshal(fields) + if err != nil { + return err + } + sealed, open, err := v.codec.seal(ctx, body) + if err != nil { + return err + } + + answer, err := postSealed(ctx, v.client, v.provider, "OTP verification", base, v.path, v.budget, sealed) + if err != nil { + return err + } + plain, err := open(ctx, answer) + if err != nil { + return err + } + if !v.hooks.Succeeded(plain) { + // The provider's message is never logged or returned. + log.Warnf(ctx, "%s refused the OTP", v.provider) + return model.NewCodedErr(http.StatusBadRequest, "BIZ_GENERIC_ERROR", + errors.New("the OTP is wrong or has expired")) + } + return nil +} diff --git a/pkg/plugin/implementation/internal/common/envelope/verify_test.go b/pkg/plugin/implementation/internal/common/envelope/verify_test.go new file mode 100644 index 000000000..05687f54a --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope/verify_test.go @@ -0,0 +1,329 @@ +package envelope + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common" +) + +const validOTP = "4821" + +// otpService stands in for the OTP realm's verify call: it opens the +// key-in-band request, checks the OTP and token, and answers sealed. +type otpService struct { + t *testing.T + calls int + path string + sent map[string]string + status int // non-zero answers with this status and no body +} + +func (s *otpService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.calls++ + s.path = r.URL.Path + if s.status != 0 { + w.WriteHeader(s.status) + return + } + raw, _ := io.ReadAll(r.Body) + var envelope map[string]string + _ = json.Unmarshal(raw, &envelope) + sealed, key, _ := strings.Cut(envelope["EncryptedRequest"], "@") + cipherText, _ := base64.StdEncoding.DecodeString(sealed) + plain, err := cbcDecrypt(key, cipherText) + if err != nil { + s.t.Errorf("the OTP service could not open the request: %v", err) + return + } + s.sent = map[string]string{} + _ = json.Unmarshal(plain, &s.sent) + + answer := `{"Rsponce":"False","Message":"Invalid OTP"}` + if s.sent["OTP"] == validOTP && s.sent["Token"] == testOTPToken { + answer = `{"Rsponce":"True","Message":"OTP Verified"}` + } + out, _ := cbcEncrypt(key, []byte(answer)) + fmt.Fprintf(w, `{"d":{"output":%q}}`, out) +} + +// pmkisanEnvelopeSettings is PM-KISAN's block as config/provider-adapter.yaml +// declares it: GCM by default, CBC for init, OTP verified before support and +// status. +func pmkisanEnvelopeSettings() envelopeSettings { + s := pmkisanSettings() + s.otp = otpRealm() + s.otpActions = "init" + s.verifyPath = "/ChatbotOTPVerified" + s.verifyActions = "support,status" + return s +} + +func pmkisanEnvelope(t *testing.T) *providerEnvelope { + t.Helper() + setTestSecrets(t) + t.Setenv(testOTPTokenEnv, testOTPToken) + e, err := newProviderEnvelope("pmkisan", pmkisanEnvelopeSettings(), testHooks) + if err != nil { + t.Fatalf("newProviderEnvelope() = %v", err) + } + return e +} + +// otpAt is a registry publishing the OTP service at url. +func otpAt(url string) registry { + return registry{testOTPBindingKey: {BindingKey: testOTPBindingKey, BaseURL: url}} +} + +// registry answers each binding key with its own record. +type registry map[string]*model.ProviderRecord + +func (r registry) ProviderRecord(_ context.Context, key string) (*model.ProviderRecord, error) { + if record, ok := r[key]; ok { + return record, nil + } + return nil, definition.ErrProviderRecordNotFound +} + +// planOn is the grievance service's call plan, whose participant host is not +// the OTP service's. The OTP service is wherever the exchange's registry says. +func planOn(t *testing.T, url string) *model.ProviderRecord { + t.Helper() + return &model.ProviderRecord{ + BaseURL: "http://grievance.invalid", + Actions: map[string]model.ActionPlan{ + "init": {Method: http.MethodPost, Path: "/ChatbotOTP", TimeoutMs: 5000}, + "support": {Method: http.MethodPost, Path: "/LodgeGrievance"}, + "status": {Method: http.MethodPost, Path: "/GrievanceStatusCheck"}, + }, + } +} + +func supportPayload(otp string) map[string]any { + channel := map[string]any{"provider": map[string]any{"id": "pmkisan"}, "enrolmentId": "UP12345678A"} + if otp != "" { + channel["challenge"] = map[string]any{"response": map[string]any{"method": "SMS_OTP", "value": otp}} + } + return map[string]any{"message": map[string]any{"support": map[string]any{ + "channels": []any{channel}}}} +} + +func statusPayload(otp string) map[string]any { + attributes := map[string]any{"enrolmentId": "UP12345678A"} + if otp != "" { + attributes["challenge"] = map[string]any{"response": map[string]any{"method": "SMS_OTP", "value": otp}} + } + return map[string]any{"message": map[string]any{"contract": map[string]any{ + "commitments": []any{map[string]any{"commitmentAttributes": attributes}}}}} +} + +func TestInitGoesThroughTheOTPRealm(t *testing.T) { + e := pmkisanEnvelope(t) + wire, _, err := e.Seal(context.Background(), common.Exchange{Action: "init", Plan: planOn(t, "http://otp.invalid"), Registry: otpAt("http://otp.invalid")}, + []byte(`{"Types":"Ben_id","Values":"UP12345678A"}`)) + if err != nil { + t.Fatalf("Seal() = %v", err) + } + var body map[string]string + _ = json.Unmarshal(wire, &body) + if !strings.Contains(body["EncryptedRequest"], "@") { + t.Errorf("init sealed %q, want the OTP realm's cipher@key", body["EncryptedRequest"]) + } +} + +func TestInitIsHostedByTheOTPServicesParticipant(t *testing.T) { + e := pmkisanEnvelope(t) + hosted := e.hostedElsewhere() + if len(hosted) != 1 || hosted["init"] != testOTPBindingKey { + t.Errorf("hostedElsewhere() = %v, want init on %s and nothing else", hosted, testOTPBindingKey) + } +} + +func TestAnUnpublishedOTPServiceStopsTheVerify(t *testing.T) { + e := pmkisanEnvelope(t) + _, _, err := e.Seal(context.Background(), common.Exchange{Action: "support", Beckn: supportPayload(validOTP), + Plan: &model.ProviderRecord{BaseURL: "http://grievance.invalid"}, Registry: registry{}}, []byte(`{}`)) + if code, status := codeOf(t, err); code != "NET_DOWNSTREAM_UNAVAILABLE" || status != http.StatusBadGateway { + t.Errorf("verify with the OTP service unpublished: got %s/%d, want NET_DOWNSTREAM_UNAVAILABLE/502", code, status) + } +} + +func TestSupportAndStatusVerifyTheOTPThenGoThroughTheGrievanceRealm(t *testing.T) { + for action, payload := range map[string]map[string]any{ + "support": supportPayload(validOTP), + "status": statusPayload(validOTP), + } { + t.Run(action, func(t *testing.T) { + e := pmkisanEnvelope(t) + otp := &otpService{t: t} + server := httptest.NewServer(otp) + defer server.Close() + + wire, _, err := e.Seal(context.Background(), + common.Exchange{Action: action, Beckn: payload, Plan: planOn(t, server.URL), Registry: otpAt(server.URL)}, []byte(`{"Type":"x"}`)) + if err != nil { + t.Fatalf("Seal() = %v", err) + } + if otp.calls != 1 || otp.path != "/ChatbotOTPVerified" { + t.Fatalf("the OTP service saw %d call(s) at %q, want one at /ChatbotOTPVerified", otp.calls, otp.path) + } + for field, want := range map[string]string{"Types": "Ben_id", "Values": "UP12345678A", "OTP": validOTP, "Token": testOTPToken} { + if otp.sent[field] != want { + t.Errorf("verify sent %s = %q, want %q", field, otp.sent[field], want) + } + } + var body map[string]string + _ = json.Unmarshal(wire, &body) + if body["EncryptedRequest"] == "" || strings.Contains(body["EncryptedRequest"], "@") { + t.Errorf("%s sealed %q, want the grievance realm's GCM payload", action, body["EncryptedRequest"]) + } + }) + } +} + +func TestAWrongOTPStopsTheCallItGuards(t *testing.T) { + e := pmkisanEnvelope(t) + otp := &otpService{t: t} + server := httptest.NewServer(otp) + defer server.Close() + + _, _, err := e.Seal(context.Background(), + common.Exchange{Action: "support", Beckn: supportPayload("0000"), Plan: planOn(t, server.URL), Registry: otpAt(server.URL)}, []byte(`{}`)) + if err == nil { + t.Fatal("Seal() succeeded on a wrong OTP") + } + if code, status := codeOf(t, err); code != "BIZ_GENERIC_ERROR" || status != http.StatusBadRequest { + t.Errorf("got %s/%d, want BIZ_GENERIC_ERROR/400 -- a wrong OTP is the farmer's to fix, and not a 401", code, status) + } + if strings.Contains(err.Error(), "Invalid OTP") { + t.Errorf("error %q passes the portal's text through", err) + } +} + +func TestAMissingChallengeIsRefusedWithoutCallingTheOTPService(t *testing.T) { + e := pmkisanEnvelope(t) + otp := &otpService{t: t} + server := httptest.NewServer(otp) + defer server.Close() + + _, _, err := e.Seal(context.Background(), + common.Exchange{Action: "status", Beckn: statusPayload(""), Plan: planOn(t, server.URL), Registry: otpAt(server.URL)}, []byte(`{}`)) + if code, status := codeOf(t, err); code != "SCH_REQUIRED_FIELD_MISSING" || status != http.StatusBadRequest { + t.Errorf("got %s/%d, want SCH_REQUIRED_FIELD_MISSING/400", code, status) + } + if otp.calls != 0 { + t.Errorf("the OTP service was called %d time(s) without an OTP to verify", otp.calls) + } +} + +func TestAnUnreachableOTPServiceIsTheUpstreamFailing(t *testing.T) { + e := pmkisanEnvelope(t) + otp := &otpService{t: t, status: http.StatusServiceUnavailable} + server := httptest.NewServer(otp) + defer server.Close() + + _, _, err := e.Seal(context.Background(), + common.Exchange{Action: "support", Beckn: supportPayload(validOTP), Plan: planOn(t, server.URL), Registry: otpAt(server.URL)}, []byte(`{}`)) + if code, status := codeOf(t, err); code != "NET_DOWNSTREAM_UNAVAILABLE" || status != http.StatusBadGateway { + t.Errorf("got %s/%d, want NET_DOWNSTREAM_UNAVAILABLE/502", code, status) + } +} + +func TestNewProviderEnvelopeRefusesInconsistentOTPSettings(t *testing.T) { + for name, tweak := range map[string]func(*envelopeSettings){ + "otp envelope with no actions": func(s *envelopeSettings) { s.otpActions = "" }, + "verify path with no actions": func(s *envelopeSettings) { s.verifyActions = "" }, + "verify actions with no path": func(s *envelopeSettings) { s.verifyPath = "" }, + "otp settings with no envelope": func(s *envelopeSettings) { s.otp.kind = "" }, + "otp envelope with no host": func(s *envelopeSettings) { s.otp.bindingKey = "" }, + "a host that is no binding key": func(s *envelopeSettings) { s.otp.bindingKey = "pmkisan-chatbot" }, + "neither envelope nor otp": func(s *envelopeSettings) { *s = envelopeSettings{} }, + } { + t.Run(name, func(t *testing.T) { + s := pmkisanEnvelopeSettings() + tweak(&s) + if _, err := newProviderEnvelope("pmkisan", s, testHooks); err == nil { + t.Fatal("newProviderEnvelope() accepted settings that cannot work") + } + }) + } +} + +func TestAProviderWithOnlyAnOTPRealmSendsItsOtherActionsPlain(t *testing.T) { + t.Setenv(testOTPTokenEnv, testOTPToken) + s := envelopeSettings{otp: otpRealm(), otpActions: "init"} + e, err := newProviderEnvelope("pmkisan", s, testHooks) + if err != nil { + t.Fatalf("newProviderEnvelope() = %v", err) + } + wire, open, err := e.Seal(context.Background(), common.Exchange{Action: "status"}, []byte(`{"a":1}`)) + if err != nil || string(wire) != `{"a":1}` { + t.Fatalf("Seal() = %s, %v; want the body untouched", wire, err) + } + if got, _ := open(context.Background(), []byte(`{"b":2}`)); string(got) != `{"b":2}` { + t.Errorf("open() = %s, want the answer untouched", got) + } +} + +// The verify carries the farmer's identifier and OTP, so a redirect to another +// host is refused rather than followed. +func TestTheVerifyRefusesAnOffHostRedirect(t *testing.T) { + elsewhere := &otpService{t: t} + other := httptest.NewServer(elsewhere) + defer other.Close() + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL+r.URL.Path, http.StatusTemporaryRedirect) + })) + defer redirector.Close() + + e := pmkisanEnvelope(t) + _, _, err := e.Seal(context.Background(), + common.Exchange{Action: "support", Beckn: supportPayload(validOTP), Plan: planOn(t, redirector.URL), Registry: otpAt(redirector.URL)}, []byte(`{}`)) + if code, status := codeOf(t, err); code != "NET_DOWNSTREAM_UNAVAILABLE" || status != http.StatusBadGateway { + t.Errorf("got %s/%d, want NET_DOWNSTREAM_UNAVAILABLE/502", code, status) + } + if elsewhere.calls != 0 { + t.Errorf("the redirect was followed: the other host got %d call(s)", elsewhere.calls) + } +} + +// The verify runs on its own budget, not the guarded action's. +func TestTheVerifyKeepsToItsOwnBudget(t *testing.T) { + release := make(chan struct{}) + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + })) + defer slow.Close() + defer close(release) + + setTestSecrets(t) + t.Setenv(testOTPTokenEnv, testOTPToken) + s := pmkisanEnvelopeSettings() + s.verifyTimeoutMs = "50" + e, err := newProviderEnvelope("pmkisan", s, testHooks) + if err != nil { + t.Fatalf("newProviderEnvelope() = %v", err) + } + plan := planOn(t, slow.URL) + plan.Actions["support"] = model.ActionPlan{Method: http.MethodPost, Path: "/LodgeGrievance", TimeoutMs: 30000} + + start := time.Now() + _, _, err = e.Seal(context.Background(), + common.Exchange{Action: "support", Beckn: supportPayload(validOTP), Plan: plan, Registry: otpAt(slow.URL)}, []byte(`{}`)) + if err == nil { + t.Fatal("Seal() succeeded against a service that never answered") + } + if took := time.Since(start); took > 5*time.Second { + t.Errorf("the verify took %v; it should stop at its own 50ms budget, not support's 30s", took) + } +} diff --git a/pkg/plugin/implementation/internal/common/envelope_test.go b/pkg/plugin/implementation/internal/common/envelope_test.go new file mode 100644 index 000000000..a9dd3ec4e --- /dev/null +++ b/pkg/plugin/implementation/internal/common/envelope_test.go @@ -0,0 +1,269 @@ +// The Envelope hook: what a capability needs when its provider's bodies are not +// plain JSON on the wire. +// +// A separate file from common_test.go, which is already past two thousand +// lines. +package common + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +func planWithAction(baseURL, action string) *model.ProviderRecord { + plan := testPlan(baseURL, http.MethodPost) + plan.Actions = map[string]model.ActionPlan{ + action: {Method: http.MethodPost, Path: "/lodge", Mappings: testMappingRef, TimeoutMs: 2000}, + } + return plan +} + +// recordingUpstream answers with answer and remembers the body it was sent. +func recordingUpstream(t *testing.T, answer string, sent *string) string { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + *sent = string(body) + fmt.Fprint(w, answer) + })) + t.Cleanup(server.Close) + return server.URL +} + +// --- the envelope ------------------------------------------------------------- + +// reversingEnvelope stands in for a real codec: reversible, and obviously not +// the plaintext, so a test can tell which side of it a body was seen on. +type reversingEnvelope struct { + sealErr, openErr error + sealed, opened int + exchange Exchange +} + +func reverse(b []byte) []byte { + out := make([]byte, len(b)) + for i := range b { + out[len(b)-1-i] = b[i] + } + return out +} + +func (e *reversingEnvelope) Seal(_ context.Context, exchange Exchange, mapped []byte) ([]byte, Opener, error) { + e.sealed++ + e.exchange = exchange + if e.sealErr != nil { + return nil, nil, e.sealErr + } + return reverse(mapped), func(_ context.Context, answer []byte) ([]byte, error) { + e.opened++ + if e.openErr != nil { + return nil, e.openErr + } + return reverse(answer), nil + }, nil +} + +func envelopedStep(t *testing.T, url string, mapper definition.Mapper, envelope Envelope) *Step { + t.Helper() + return newStep(t, &stubRegistry{plan: planWithAction(url, "select")}, mapper, func(c *Config) { + c.EnvelopeByProvider = map[string]Envelope{testProvider: envelope} + }) +} + +func TestRunSealsTheRequestAndOpensTheAnswer(t *testing.T) { + var sent string + url := recordingUpstream(t, string(reverse([]byte(`{"answer":42}`))), &sent) + mapper := &stubMapper{requestResult: []byte(`{"ask":1}`), responseResult: []byte(`{"ok":true}`)} + envelope := &reversingEnvelope{} + + if _, err := runStep(t, envelopedStep(t, url, mapper, envelope), selectBody); err != nil { + t.Fatalf("Run() = %v", err) + } + if sent != string(reverse([]byte(`{"ask":1}`))) { + t.Errorf("upstream received %q, want the sealed request", sent) + } + response, _ := mapper.responseInput.(map[string]any) + if fmt.Sprint(response["response"]) != "map[answer:42]" { + t.Errorf("the response half saw %v, want the opened answer", response["response"]) + } + if envelope.sealed != 1 || envelope.opened != 1 { + t.Errorf("sealed %d, opened %d; want one of each", envelope.sealed, envelope.opened) + } +} + +func TestRunStopsWhenTheEnvelopeRefuses(t *testing.T) { + refusal := model.NewCodedErr(http.StatusInternalServerError, "NET_INTERNAL_ERROR", errors.New("no key")) + + t.Run("seal", func(t *testing.T) { + var sent string + url := recordingUpstream(t, `{}`, &sent) + step := envelopedStep(t, url, &stubMapper{requestResult: []byte(`{}`)}, + &reversingEnvelope{sealErr: refusal}) + if _, err := runStep(t, step, selectBody); !errors.Is(err, refusal) { + t.Fatalf("Run() = %v, want the envelope's own error", err) + } + if sent != "" { + t.Errorf("upstream was called with %q after the seal failed; it must never see plaintext", sent) + } + }) + + t.Run("open", func(t *testing.T) { + var sent string + url := recordingUpstream(t, `{}`, &sent) + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + step := envelopedStep(t, url, mapper, &reversingEnvelope{openErr: refusal}) + if _, err := runStep(t, step, selectBody); !errors.Is(err, refusal) { + t.Fatalf("Run() = %v, want the envelope's own error", err) + } + if mapper.responseInput != nil { + t.Error("the response half ran on an answer the envelope refused") + } + }) +} + +func TestNewRefusesAnEnvelopeForAProviderItDoesNotServe(t *testing.T) { + cfg := &Config{BindingKeys: []string{testBindingKey}} + cfg.setProviderAuth(AuthProfile{Scheme: util.AuthSchemeNone}) + cfg.EnvelopeByProvider = map[string]Envelope{"elsewhere": &reversingEnvelope{}} + _, _, err := New(context.Background(), &stubRegistry{}, &stubMapper{}, nil, cfg) + if err == nil || !strings.Contains(err.Error(), "elsewhere") { + t.Fatalf("New() = %v, want the stray provider refused", err) + } +} + +func TestRunSendsPlainJSONToAProviderWithNoEnvelope(t *testing.T) { + var sent string + url := recordingUpstream(t, `{"ok":true}`, &sent) + mapper := &stubMapper{requestResult: []byte(`{"ask":1}`), responseResult: []byte(`{"a":1}`)} + step := newStep(t, &stubRegistry{plan: planWithAction(url, "select")}, mapper) + + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() = %v", err) + } + if sent != `{"ask":1}` { + t.Errorf("upstream received %q, want the mapped request untouched", sent) + } +} + +func TestRunHandsTheEnvelopeTheExchange(t *testing.T) { + var sent string + url := recordingUpstream(t, string(reverse([]byte(`{}`))), &sent) + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"ok":true}`)} + envelope := &reversingEnvelope{} + + if _, err := runStep(t, envelopedStep(t, url, mapper, envelope), selectBody); err != nil { + t.Fatalf("Run() = %v", err) + } + if envelope.exchange.Action != "select" || envelope.exchange.Plan == nil || envelope.exchange.Beckn == nil { + t.Errorf("exchange = %+v, want the action, the plan and the inbound payload", envelope.exchange) + } +} + +// keyedRegistry answers each binding key with its own record. +type keyedRegistry map[string]*model.ProviderRecord + +func (r keyedRegistry) ProviderRecord(_ context.Context, key string) (*model.ProviderRecord, error) { + if record, ok := r[key]; ok { + return record, nil + } + return nil, definition.ErrProviderRecordNotFound +} + +const otherBindingKey = "other-host|openagrinet:Other" + +// hostedStep serves testBindingKey on participant, with select hosted by +// otherBindingKey's participant at elsewhere ("" leaves it unpublished). +func hostedStep(t *testing.T, participant, elsewhere string, mapper definition.Mapper) *Step { + t.Helper() + registry := keyedRegistry{testBindingKey: planWithAction(participant, "select")} + if elsewhere != "" { + registry[otherBindingKey] = &model.ProviderRecord{BindingKey: otherBindingKey, BaseURL: elsewhere} + } + return newStep(t, registry, mapper, func(c *Config) { + c.HostedElsewhere = map[string]map[string]string{testProvider: {"select": otherBindingKey}} + }) +} + +func TestRunCallsTheHostOfTheParticipantNamedForTheAction(t *testing.T) { + var onElsewhere, onParticipant string + elsewhere := recordingUpstream(t, `{"ok":true}`, &onElsewhere) + participant := recordingUpstream(t, `{"ok":true}`, &onParticipant) + + mapper := &stubMapper{requestResult: []byte(`{"ask":1}`), responseResult: []byte(`{"a":1}`)} + if _, err := runStep(t, hostedStep(t, participant, elsewhere, mapper), selectBody); err != nil { + t.Fatalf("Run() = %v", err) + } + if onElsewhere == "" || onParticipant != "" { + t.Errorf("the named host got %q, the participant got %q; want the named host called", onElsewhere, onParticipant) + } +} + +func TestRunCallsTheParticipantForAnActionHostedNowhereElse(t *testing.T) { + var sent string + participant := recordingUpstream(t, `{"ok":true}`, &sent) + + mapper := &stubMapper{requestResult: []byte(`{"ask":1}`), responseResult: []byte(`{"a":1}`)} + step := newStep(t, keyedRegistry{testBindingKey: planWithAction(participant, "select")}, mapper, func(c *Config) { + c.HostedElsewhere = map[string]map[string]string{testProvider: {"init": otherBindingKey}} + }) + if _, err := runStep(t, step, selectBody); err != nil { + t.Fatalf("Run() = %v", err) + } + if sent == "" { + t.Error("the participant's host was not called") + } +} + +func TestRunIsAnUpstreamFailureWhenTheNamedHostIsUnpublished(t *testing.T) { + var sent string + participant := recordingUpstream(t, `{}`, &sent) + + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)} + _, err := runStep(t, hostedStep(t, participant, "", mapper), selectBody) + var coded *model.CodedErr + if !errors.As(err, &coded) || coded.HTTPStatus() != http.StatusBadGateway { + t.Fatalf("Run() = %v, want a 502", err) + } + if sent != "" { + t.Errorf("the participant was called with %q instead", sent) + } +} + +func TestNewRefusesAHostForAProviderItDoesNotServeOrABadKey(t *testing.T) { + for name, hosts := range map[string]map[string]map[string]string{ + "an unserved provider": {"someone-else": {"init": otherBindingKey}}, + "a key with no |": {testProvider: {"init": "other-host"}}, + "an empty half": {testProvider: {"init": "|openagrinet:Other"}}, + } { + t.Run(name, func(t *testing.T) { + cfg := &Config{BindingKeys: []string{testBindingKey}, HostedElsewhere: hosts} + cfg.setProviderAuth(AuthProfile{Scheme: util.AuthSchemeNone}) + if _, _, err := New(context.Background(), &stubRegistry{}, &stubMapper{}, nil, cfg); err == nil { + t.Error("New() accepted it") + } + }) + } +} + +func TestRunHandsTheEnvelopeTheRegistry(t *testing.T) { + var sent string + url := recordingUpstream(t, string(reverse([]byte(`{}`))), &sent) + mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{"ok":true}`)} + envelope := &reversingEnvelope{} + if _, err := runStep(t, envelopedStep(t, url, mapper, envelope), selectBody); err != nil { + t.Fatalf("Run() = %v", err) + } + if envelope.exchange.Registry == nil { + t.Error("the envelope was not handed the registry") + } +} diff --git a/pkg/plugin/implementation/internal/common/hosts.go b/pkg/plugin/implementation/internal/common/hosts.go new file mode 100644 index 000000000..e83c018fc --- /dev/null +++ b/pkg/plugin/implementation/internal/common/hosts.go @@ -0,0 +1,33 @@ +package common + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/beckn-one/beckn-onix/pkg/model" + "github.com/beckn-one/beckn-onix/pkg/plugin/definition" + "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/common/util" +) + +// HostOf returns the registry's base URL for bindingKey's participant; an +// unpublished binding is a 502 (ADR-0002). +func HostOf(ctx context.Context, registry definition.ProviderRecordLookup, bindingKey string) (string, error) { + if registry == nil { + return "", fmt.Errorf("no registry to find %s's host in", bindingKey) + } + record, err := registry.ProviderRecord(ctx, bindingKey) + if err != nil { + if errors.Is(err, definition.ErrProviderRecordNotFound) { + return "", model.NewCodedErr(http.StatusBadGateway, util.CodeUpstreamUnavailable, + fmt.Errorf("the registry publishes no active binding for %s: %w", bindingKey, err)) + } + return "", fmt.Errorf("no host for %s: %w", bindingKey, err) + } + if record == nil || record.BaseURL == "" { + return "", model.NewCodedErr(http.StatusBadGateway, util.CodeUpstreamUnavailable, + fmt.Errorf("the registry has no base URL for %s", bindingKey)) + } + return record.BaseURL, nil +} diff --git a/pkg/plugin/implementation/internal/common/serve.go b/pkg/plugin/implementation/internal/common/serve.go index 1671df2c0..2ef7593a4 100644 --- a/pkg/plugin/implementation/internal/common/serve.go +++ b/pkg/plugin/implementation/internal/common/serve.go @@ -151,16 +151,42 @@ func (s *Step) serve(ctx *model.StepContext, plan *model.ProviderRecord) error { // Resolved from the binding key, so a step serving several providers // authenticates each as itself. Startup guarantees a profile per served // provider; this guards a record arriving for an undeclared key. - auth, configured := s.auth[providerIDFrom(plan.BindingKey)] + provider := providerIDFrom(plan.BindingKey) + auth, configured := s.auth[provider] if !configured { return fmt.Errorf("no credential is configured for %s", plan.BindingKey) } - upstreamResponse, err := s.call(ctx, auth, plan.BaseURL, call, upstreamRequest) + // The participant's host, unless another participant serves this action. + base := plan.BaseURL + if key := s.config.HostedElsewhere[provider][action]; key != "" { + if base, err = HostOf(ctx, s.registry, key); err != nil { + return err + } + } + + // The provider's envelope, if any: seal once before the retry loop, open + // once after it. + envelope, wrapped := s.config.EnvelopeByProvider[provider] + var open Opener + if wrapped { + exchange := Exchange{Action: action, Beckn: beckn, Plan: plan, Registry: s.registry} + if upstreamRequest, open, err = envelope.Seal(ctx, exchange, upstreamRequest); err != nil { + return err + } + } + + upstreamResponse, err := s.call(ctx, auth, base, call, upstreamRequest) if err != nil { return err } + if wrapped && open != nil { + if upstreamResponse, err = open(ctx, upstreamResponse); err != nil { + return err + } + } + answer, err := decodeBody(upstreamResponse) if err != nil { return fmt.Errorf("provider answered with something that is not JSON: %w", err) diff --git a/pkg/plugin/implementation/schemav2validator/cmd/plugin.go b/pkg/plugin/implementation/schemav2validator/cmd/plugin.go index 9db7f49db..8a9ffa1a8 100644 --- a/pkg/plugin/implementation/schemav2validator/cmd/plugin.go +++ b/pkg/plugin/implementation/schemav2validator/cmd/plugin.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "strconv" "strings" @@ -104,6 +105,19 @@ func (vp schemav2ValidatorProvider) New(ctx context.Context, config map[string]s if v, ok := config["extendedSchema_localSchemaPath"]; ok && v != "" { cfg.ExtendedSchemaConfig.LocalSchemaPath = v } + // "from|to" pairs, comma-separated: fetch an @context starting with + // from from to instead. "|" cannot appear unescaped in a URL. + if v, ok := config["extendedSchema_contextRewrite"]; ok && strings.TrimSpace(v) != "" { + for _, pair := range strings.Split(v, ",") { + from, to, found := strings.Cut(strings.TrimSpace(pair), "|") + from, to = strings.TrimSpace(from), strings.TrimSpace(to) + if !found || from == "" || to == "" { + return nil, nil, fmt.Errorf("extendedSchema_contextRewrite: %q is not a from|to pair", pair) + } + cfg.ExtendedSchemaConfig.ContextRewrites = append(cfg.ExtendedSchemaConfig.ContextRewrites, + schemav2validator.ContextRewrite{From: from, To: to}) + } + } } diff --git a/pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go b/pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go index 96bb3a56a..01d461a01 100644 --- a/pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go +++ b/pkg/plugin/implementation/schemav2validator/cmd/plugin_test.go @@ -180,9 +180,9 @@ func TestProvider_AuxiliaryConfig(t *testing.T) { { name: "auxiliary collides with primary", config: map[string]string{ - "type": "url", - "location": primary.URL, - "auxiliaryTypes": "url", + "type": "url", + "location": primary.URL, + "auxiliaryTypes": "url", "auxiliaryLocations": aux.URL, }, // aux and primary serve the same spec — collision on "test" action. @@ -296,3 +296,36 @@ func contains(s, substr string) bool { } return false } + +func TestProvider_ContextRewriteConfig(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(testSpec)) + })) + defer server.Close() + + for name, tc := range map[string]struct { + rewrite string + wantErr bool + }{ + "one pair": {"https://published.example/|https://raw.example/branch/", false}, + "two pairs, spaced": {" https://a.example/ | https://b.example/ , https://c.example/|https://d.example/ ", false}, + "empty is no rewrite": {"", false}, + "no separator": {"https://published.example/", true}, + "an empty side": {"https://published.example/|", true}, + } { + t.Run(name, func(t *testing.T) { + _, _, err := Provider.New(context.Background(), map[string]string{ + "type": "url", + "location": server.URL, + "extendedSchema_enabled": "true", + "extendedSchema_contextRewrite": tc.rewrite, + }) + if (err != nil) != tc.wantErr { + t.Errorf("New() error = %v, wantErr %v", err, tc.wantErr) + } + if err != nil && !contains(err.Error(), "extendedSchema_contextRewrite") { + t.Errorf("error %q does not name the setting", err) + } + }) + } +} diff --git a/pkg/plugin/implementation/schemav2validator/extended_schema.go b/pkg/plugin/implementation/schemav2validator/extended_schema.go index 1c3d7169f..12f057253 100644 --- a/pkg/plugin/implementation/schemav2validator/extended_schema.go +++ b/pkg/plugin/implementation/schemav2validator/extended_schema.go @@ -72,6 +72,26 @@ type ExtendedSchemaConfig struct { DownloadTimeout int // seconds, default 30 AllowedDomains []string // whitelist (empty = all allowed) LocalSchemaPath string // when non-empty, preload schemas from this directory; network is fallback + // ContextRewrites change where a pack is fetched from, never the + // payload's @context; the first matching prefix wins (ADR-0003). + ContextRewrites []ContextRewrite +} + +// ContextRewrite is one @context prefix and the prefix to fetch it from. +type ContextRewrite struct { + From string + To string +} + +// rewriteContext returns where to fetch contextURL from. The allowlist is +// checked on the result, the host actually read. +func rewriteContext(contextURL string, rewrites []ContextRewrite) string { + for _, r := range rewrites { + if r.From != "" && strings.HasPrefix(contextURL, r.From) { + return r.To + strings.TrimPrefix(contextURL, r.From) + } + } + return contextURL } // referencedObject represents a domain-specific object with @context. @@ -209,6 +229,7 @@ func (v *schemav2Validator) validateExtendedSchemas(ctx context.Context, body in // Validate each object for _, obj := range objects { + obj.Context = rewriteContext(obj.Context, refConfig.ContextRewrites) log.Debugf(ctx, "Validating object at path: %s, @context: %s, @type: %s", obj.Path, obj.Context, obj.Type) @@ -797,7 +818,7 @@ func (c *schemaCache) validateReferencedObject( openapi3.EnableFormatValidation(), } if err := schema.Value.VisitJSON(domainData, opts...); err != nil { - log.Debugf(ctx, "Validation failed for @type: %s at path: %s: %v", obj.Type, obj.Path, err) + log.Debugf(ctx, "Validation failed for @type: %s at path: %s: %s", obj.Type, obj.Path, withoutDetails(err.Error())) return err } diff --git a/pkg/plugin/implementation/schemav2validator/extended_schema_test.go b/pkg/plugin/implementation/schemav2validator/extended_schema_test.go index 9facca442..bb77ebec8 100644 --- a/pkg/plugin/implementation/schemav2validator/extended_schema_test.go +++ b/pkg/plugin/implementation/schemav2validator/extended_schema_test.go @@ -1987,3 +1987,175 @@ func TestPayloadDirectedReader(t *testing.T) { }) } } + +func TestRewriteContext(t *testing.T) { + rewrites := []ContextRewrite{ + {From: "https://published.example/specs/", To: "https://raw.example/specs/branch/"}, + {From: "https://published.example/", To: "https://other.example/"}, + } + for name, tc := range map[string]struct{ in, want string }{ + "the first matching prefix wins": {"https://published.example/specs/X/v0.1/context.jsonld", "https://raw.example/specs/branch/X/v0.1/context.jsonld"}, + "a later prefix still matches": {"https://published.example/other/context.jsonld", "https://other.example/other/context.jsonld"}, + "no match is left as is": {"https://elsewhere.example/X/context.jsonld", "https://elsewhere.example/X/context.jsonld"}, + } { + t.Run(name, func(t *testing.T) { + if got := rewriteContext(tc.in, rewrites); got != tc.want { + t.Errorf("rewriteContext(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } + if got := rewriteContext("https://a.example/x", nil); got != "https://a.example/x" { + t.Errorf("no rewrites changed the URL to %q", got) + } +} + +// A pack that pins @context to its published URL, served only from the +// rewrite target: the payload keeps the published URL, the fetch goes to the +// target, and the allowlist is checked on the target's host. +func TestValidateExtendedSchemas_ContextRewriteFetchesFromTheTarget(t *testing.T) { + const published = "https://published.invalid/specs/" + schemaContent := `openapi: 3.1.0 +info: + title: Test Schema + version: 1.0.0 +components: + schemas: + TestType: + type: object + x-jsonld: + "@context": ./context.jsonld + "@type": TestType + required: ["@context", field1] + properties: + "@context": + const: ` + published + `TestType/v0.1/context.jsonld + field1: + type: string + pattern: "^[0-9]{4}$"` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/branch/TestType/v0.1/attributes.yaml" { + http.NotFound(w, r) + return + } + w.Write([]byte(schemaContent)) + })) + defer server.Close() + serverURL, _ := url.Parse(server.URL) + + validator := func(rewrites []ContextRewrite) *schemav2Validator { + return &schemav2Validator{ + config: &Config{ + EnableExtendedSchema: true, + ExtendedSchemaConfig: ExtendedSchemaConfig{ + AllowedDomains: []string{serverURL.Host}, + ContextRewrites: rewrites, + }, + }, + schemaCache: newSchemaCache(10), + } + } + body := func(field1 string) map[string]interface{} { + return map[string]interface{}{"message": map[string]interface{}{"order": map[string]interface{}{ + "@context": published + "TestType/v0.1/context.jsonld", + "@type": "TestType", + "field1": field1, + }}} + } + rewrite := []ContextRewrite{{From: published, To: server.URL + "/branch/"}} + ctx := context.Background() + + if err := validator(rewrite).validateExtendedSchemas(ctx, body("4821")); err != nil { + t.Errorf("a valid object behind a rewritten @context: %v", err) + } + + err := validator(rewrite).validateExtendedSchemas(ctx, body("48210")) + if err == nil { + t.Error("an invalid object behind a rewritten @context passed") + } else if !strings.Contains(err.Error(), "field1") { + t.Errorf("error %v does not name the failing field", err) + } + + // Without the rewrite the published host is fetched, and it is not allowed. + err = validator(nil).validateExtendedSchemas(ctx, body("4821")) + var schemaErr *model.SchemaValidationErr + if !errors.As(err, &schemaErr) || schemaErr.Errors[0].Code != "SCH_INVALID_JSONLD_CONTEXT" { + t.Errorf("without a rewrite: %v, want SCH_INVALID_JSONLD_CONTEXT", err) + } + + // A rewrite to a host off the allowlist is refused, not fetched. + offList := []ContextRewrite{{From: published, To: "https://not-allowed.invalid/"}} + err = validator(offList).validateExtendedSchemas(ctx, body("4821")) + if !errors.As(err, &schemaErr) || schemaErr.Errors[0].Code != "SCH_INVALID_JSONLD_CONTEXT" { + t.Errorf("a rewrite off the allowlist: %v, want SCH_INVALID_JSONLD_CONTEXT", err) + } +} + +// A refusal names the rule and the path, never the value: a pack can mark the +// field no-echo, and kin-openapi appends the value and the schema by default. +func TestValidateExtendedSchemas_ErrorDoesNotEchoTheValue(t *testing.T) { + schemaContent := `openapi: 3.1.0 +info: + title: Test Schema + version: 1.0.0 +components: + schemas: + TestType: + type: object + x-jsonld: + "@context": ./context.jsonld + "@type": TestType + properties: + applicant: + type: object + properties: + mobile: + type: string + pattern: "^[6-9][0-9]{9}$" + x-oan-pii: + class: contact + handling: [no-echo]` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(schemaContent)) + })) + defer server.Close() + + v := &schemav2Validator{ + config: &Config{EnableExtendedSchema: true, ExtendedSchemaConfig: ExtendedSchemaConfig{}}, + schemaCache: newSchemaCache(10), + } + const secret = "5812345670" + body := map[string]interface{}{"message": map[string]interface{}{"order": map[string]interface{}{ + "@context": server.URL + "/TestType/v0.1/context.jsonld", + "@type": "TestType", + "applicant": map[string]interface{}{"mobile": secret}, + }}} + + err := v.validateExtendedSchemas(context.Background(), body) + var schemaErr *model.SchemaValidationErr + if !errors.As(err, &schemaErr) || len(schemaErr.Errors) == 0 { + t.Fatalf("validateExtendedSchemas() = %v, want a schema refusal", err) + } + got := schemaErr.BecknError() + for _, leak := range []string{secret, "Value:", "Schema:", "x-oan-pii"} { + if strings.Contains(got.Message, leak) { + t.Errorf("message %q carries %q", got.Message, leak) + } + } + if !strings.Contains(got.Message, "regular expression") { + t.Errorf("message %q does not say which rule failed", got.Message) + } + if got.Details == nil || !strings.Contains(got.Details.Path, "mobile") { + t.Errorf("details = %+v, want the path to the field", got.Details) + } +} + +func TestWithoutDetails(t *testing.T) { + in := "string doesn't match the regular expression \"^[0-9]{4}$\"\nSchema:\n {\"pattern\": \"^[0-9]{4}$\"}\n\nValue:\n \"48210\"\n" + if got := withoutDetails(in); got != "string doesn't match the regular expression \"^[0-9]{4}$\"" { + t.Errorf("withoutDetails() = %q", got) + } + if got := withoutDetails(" plain reason "); got != "plain reason" { + t.Errorf("withoutDetails() = %q, want the reason trimmed", got) + } +} diff --git a/pkg/plugin/implementation/schemav2validator/schemav2validator.go b/pkg/plugin/implementation/schemav2validator/schemav2validator.go index d66cf23c7..c0a6b3f6f 100644 --- a/pkg/plugin/implementation/schemav2validator/schemav2validator.go +++ b/pkg/plugin/implementation/schemav2validator/schemav2validator.go @@ -203,8 +203,9 @@ func (v *schemav2Validator) Validate(ctx context.Context, reqURL *url.URL, data openapi3.EnableFormatValidation(), } if err := schema.Value.VisitJSON(jsonData, opts...); err != nil { - log.Debugf(ctx, "Schema validation failed: %v", err) - return v.formatValidationError(err) + formatted := v.formatValidationError(err) + log.Debugf(ctx, "Schema validation failed: %v", formatted) + return formatted } log.Debugf(ctx, "base schema validation passed for action: %s", action) @@ -588,7 +589,7 @@ func (v *schemav2Validator) extractSchemaErrors(err error, schemaErrors *[]model } } else { // Generic error — no schema keyword to classify against. - *schemaErrors = append(*schemaErrors, *model.NewCodedError("SCH_SCHEMA_VALIDATION_FAILED", err.Error())) + *schemaErrors = append(*schemaErrors, *model.NewCodedError("SCH_SCHEMA_VALIDATION_FAILED", withoutDetails(err.Error()))) } } @@ -718,6 +719,17 @@ func schemaErrorLeaves(err error) []*openapi3.SchemaError { return nil } +// withoutDetails keeps the reason and drops the schema and the caller's value +// that kin-openapi appends (ADR-0003). +func withoutDetails(message string) string { + for _, marker := range []string{"\nSchema:", "\nValue:"} { + if i := strings.Index(message, marker); i >= 0 { + message = message[:i] + } + } + return strings.TrimSpace(message) +} + // buildActionIndex builds two indexes from the loaded OpenAPI spec: // - actionSchemas: body-bearing operations (POST/PUT/PATCH) keyed by context.action value // - bodylessActions: GET/DELETE operations without a requestBody, keyed by path without leading slash