From ad9d688875e22b7e94504570e50b0ac57bee55ae Mon Sep 17 00:00:00 2001 From: noxlesh Date: Fri, 4 Sep 2026 11:53:12 +0300 Subject: [PATCH 1/2] feat(sdk): enforce WellKnownManifest.ver in the endpoint resolvers, and give the manifest version its own constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest's ver has stated a consumer-side rule since the manifest was unified, and no SDK applied it: the Go, Python and TypeScript endpoint resolvers decoded the endpoint and dropped ver unread, so a manifest at any version, or with no version at all, was accepted. The reference implementation's schema validator was the only enforcement anywhere, and it enforced the wrong shape — an exact match on "1.0", which refuses the additive minor revisions the rule exists to admit. The rule is now stated in full on the field and enforced where the document is read. A consumer reads ver before any other member, accepts a recognised MAJOR whatever the MINOR, and refuses an unrecognised MAJOR, a value that is not MAJOR.MINOR, and an absent ver. Absent is refused rather than tolerated: the field is required by the wire shape, the document sits at a fixed unversioned path, and it is read before any signature is checked, so a layout the reader cannot classify must not supply the endpoint a signed call is then sent to. The gate runs on the endpoint face only — the well-known key resolver reads a JWKS document that carries no manifest version — and a refusal is a verdict: never cached, and classified not-sent by the client tier like the other resolver verdicts. The value gets one owner. helpers.WellKnownManifestVersion (WellKnownManifestVersion in Python and TypeScript) is pinned to the wire-constants corpus beside ProtocolVersion and deliberately not derived from it. The two are separate namespaces and the coupling runs one way: a manifest layout change bumps both numbers, a protocol change that leaves the manifest untouched bumps only ProtocolVersion, so a reader that parses only manifests upgrades when the manifest changes and at no other time. The proto header and the manifest field now say so. Three guards hold the three halves together. A new corpus, manifest-version-vectors.json, is emitted from the real Go rule and replayed by all three languages, through the pure check and through the real endpoint resolver against a stub origin. A new conformance guard pins the field comment to every clause the corpus exercises, in both directions. And the existing ver contract guard now reads the manifest's expected value from the WellKnownManifestVersion vector entry rather than from ProtocolVersion, so the guard itself no longer couples the two namespaces. Every fixture that serves a manifest to the real resolver now carries ver, which is the compatibility note for the change: a manifest served without ver was accepted before and is refused now. A conforming producer already stamps it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UUwf5FonFW8pAjkyD5raNu --- conformance/manifest_version_rule_test.go | 182 ++++++++++++++++++ conformance/ver_field_contract_test.go | 84 ++++++-- docs/sdk-parity-matrix.md | 7 +- gen/descriptor.binpb | Bin 618838 -> 619875 bytes gen/go/ramp/v1/ramp.pb.go | 18 +- gen/python/wire/models.py | 2 +- gen/ts/wire/schemas.ts | 2 +- proto/CHANGELOG.md | 23 +++ proto/ramp/v1/ramp.proto | 27 ++- sdk/go/connect/route.go | 11 +- sdk/go/connect/taxonomy_test.go | 32 ++- sdk/go/connect/testsupport_test.go | 3 +- sdk/go/connect/verbs_test.go | 2 +- sdk/go/helpers/constants.go | 13 +- .../gen_manifest_version_vectors_test.go | 82 ++++++++ sdk/go/helpers/gen_util_vectors_test.go | 3 +- sdk/go/helpers/manifestversion.go | 82 ++++++++ .../helpers/manifestversion_vectors_test.go | 55 ++++++ sdk/go/helpers/protocol_version_ssot_test.go | 10 +- .../testdata/manifest-version-vectors.json | 94 +++++++++ .../testdata/wire-constants-vectors.json | 4 + sdk/go/resolvers/endpointresolver.go | 24 ++- sdk/go/resolvers/endpointresolver_test.go | 4 +- .../endpointresolver_version_test.go | 122 ++++++++++++ .../resolvers/library_adoption_guard_test.go | 6 +- sdk/parity/symbol-map.json | 20 ++ sdk/python/ramp_sdk/__init__.py | 6 + sdk/python/ramp_sdk/client/route.py | 16 +- sdk/python/ramp_sdk/resolvers/__init__.py | 2 + sdk/python/ramp_sdk/resolvers/errors.py | 14 ++ sdk/python/ramp_sdk/resolvers/wellknown.py | 28 ++- sdk/python/ramp_sdk/wire.py | 66 ++++++- sdk/python/tests/resolvers_harness.py | 4 +- .../test_endpoint_refused_public_home.py | 1 + sdk/python/tests/test_endpoint_vet_parity.py | 6 +- .../tests/test_hosts_credential_redaction.py | 10 +- .../tests/test_manifest_version_parity.py | 118 ++++++++++++ .../tests/test_parity_corpora_nonempty.py | 5 + sdk/python/tests/test_wire_parity.py | 1 + sdk/ts/client/route.ts | 19 +- sdk/ts/resolvers/errors.ts | 17 ++ sdk/ts/resolvers/index.ts | 1 + sdk/ts/resolvers/wellknown.ts | 14 +- sdk/ts/src/wire.ts | 70 ++++++- sdk/ts/tests/endpoint-vet.parity.test.ts | 4 +- .../tests/hosts-credential-redaction.test.ts | 4 +- sdk/ts/tests/manifest-version.parity.test.ts | 113 +++++++++++ sdk/ts/tests/resolvers-harness.ts | 3 +- sdk/ts/tests/wire.parity.test.ts | 1 + .../docs/protocol/exchange-manifest.mdx | 2 +- .../src/content/docs/reference/changelog.mdx | 29 +++ .../src/content/docs/reference/proto-ramp.mdx | 2 +- .../docs/reference/ramp-json-example.mdx | 2 +- 53 files changed, 1387 insertions(+), 83 deletions(-) create mode 100644 conformance/manifest_version_rule_test.go create mode 100644 sdk/go/helpers/gen_manifest_version_vectors_test.go create mode 100644 sdk/go/helpers/manifestversion.go create mode 100644 sdk/go/helpers/manifestversion_vectors_test.go create mode 100644 sdk/go/helpers/testdata/manifest-version-vectors.json create mode 100644 sdk/go/resolvers/endpointresolver_version_test.go create mode 100644 sdk/python/tests/test_manifest_version_parity.py create mode 100644 sdk/ts/tests/manifest-version.parity.test.ts diff --git a/conformance/manifest_version_rule_test.go b/conformance/manifest_version_rule_test.go new file mode 100644 index 00000000..cb12e359 --- /dev/null +++ b/conformance/manifest_version_rule_test.go @@ -0,0 +1,182 @@ +package conformance + +// Drift guard: the manifest-version rule the SDKs enforce IS the rule the contract +// states. +// +// WellKnownManifest.ver carries a normative receive-side rule — accept a recognised +// MAJOR, reject an unrecognised MAJOR, a malformed value, and an absent field — and +// it deliberately carries no protovalidate rule: a pattern would make the field +// structurally required on every message that embeds the manifest, and an exact +// match would refuse the additive minor revisions the rule exists to admit. So the +// contract's whole statement of the rule is prose in the field's comment, and the +// enforcement is code in three SDK endpoint resolvers. +// +// Between them sits the shared corpus, which pins the three implementations to each +// other. This file pins both to the prose, the same construction as +// endpoint_rule_test.go and for the same reason: both halves are DATA READS, and +// nothing here imports sdk/go. + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// manifestVersionVectors is the SDK's committed verdict set for the rule, read as data. +const manifestVersionVectors = "../sdk/go/helpers/testdata/manifest-version-vectors.json" + +type manifestVersionCase struct { + Name string `json:"name"` + Ver string `json:"ver"` + Present bool `json:"present"` + Accepted bool `json:"accepted"` +} + +var errNoManifestVersionVectors = errors.New( + "the manifest-version corpus is missing or empty — regenerate it with " + + "RAMP_UPDATE_VECTORS=1 go test ./sdk/go/helpers/ -run TestGenerateManifestVersionVectors") + +var sdkManifestVersionCases = sync.OnceValues(func() ([]manifestVersionCase, error) { + b, err := os.ReadFile(manifestVersionVectors) + if err != nil { + return nil, err + } + var doc struct { + Cases []manifestVersionCase `json:"manifest_version"` + } + if err := json.Unmarshal(b, &doc); err != nil { + return nil, err + } + if len(doc.Cases) == 0 { + return nil, errNoManifestVersionVectors + } + return doc.Cases, nil +}) + +func mustManifestVersionCases(t *testing.T) []manifestVersionCase { + t.Helper() + cases, err := sdkManifestVersionCases() + if err != nil { + t.Fatalf("read %s: %v", manifestVersionVectors, err) + } + return cases +} + +// manifestVerComment is the comment block above WellKnownManifest.ver specifically. +// `string ver = 1` appears on every message, so the shared fieldComment extractor +// (first match in file order) would return some other message's envelope comment; +// this walk is message-scoped, the same way ver_field_contract_test.go attributes +// each `ver` to its enclosing message. +func manifestVerComment(t *testing.T) string { + t.Helper() + for _, f := range collectVerFields(t) { + if f.message == "WellKnownManifest" { + // collectVerFields joins the raw lines, `//` markers included, so a + // clause that wraps across two source lines carries a marker in the + // middle. Strip them so phrases are matched against prose. + return strings.TrimPrefix(strings.ReplaceAll(f.comment, " // ", " "), "// ") + } + } + t.Fatal("WellKnownManifest.ver not found in the contract protos") + return "" +} + +// TestManifestVersionCommentStatesEveryClauseTheSDKEnforces reads the rule out of +// the contract and holds it to the clauses the SDK actually applies. +func TestManifestVersionCommentStatesEveryClauseTheSDKEnforces(t *testing.T) { + comment := manifestVerComment(t) + + // Guard the guard: pin the extraction to the block it is supposed to have read. + const opening = "Version of THIS MANIFEST DOCUMENT's layout" + if !strings.HasPrefix(comment, opening) { + t.Fatalf("the extracted comment does not begin with the manifest ver's own first line "+ + "(%q) — this guard is reading the wrong block and would assert nothing.\n read: %.120s…", + opening, comment) + } + + for _, clause := range []struct { + name string + phrase string + why string + }{ + {"separate namespace", "separate from the RPC envelope", + "the manifest version is not the protocol version and is never stamped from it"}, + {"own constant", "WellKnownManifestVersion", + "the value has one owner, and it is not ProtocolVersion"}, + {"coupling direction", "bumps both numbers", + "a manifest change moves the protocol version; the reverse does not hold"}, + {"read first", "before any other member", + "the gate runs before the endpoint is so much as looked at"}, + {"recognised major accepted", "ACCEPT a recognised MAJOR version whatever the MINOR", + "a minor revision of the manifest is additive"}, + {"unrecognised major rejected", "REJECT an unrecognised MAJOR", + "a layout the reader cannot classify must not supply the endpoint"}, + {"malformed rejected", "not MAJOR.MINOR", + "a value the rule cannot parse is not a version it recognises"}, + {"absent rejected", "an ABSENT `ver`", + "a document with no version is one whose layout the reader cannot classify"}, + } { + if !strings.Contains(comment, clause.phrase) { + t.Errorf("the WellKnownManifest.ver comment no longer states %q (%s).\n"+ + "The SDKs still enforce it and the corpus still pins it, so the contract and the "+ + "implementations have drifted — restore the clause or change all four together.", + clause.name, clause.why) + } + } +} + +// TestManifestVersionCorpusCoversEveryStatedClause holds the other direction: each +// clause the contract states must be exercised by at least one vector, so three +// SDKs cannot quietly stop enforcing it while the comment still reads correctly. +func TestManifestVersionCorpusCoversEveryStatedClause(t *testing.T) { + cases := mustManifestVersionCases(t) + var sameMajorHigherMinor, unrecognisedMajor, malformed, absent bool + for _, c := range cases { + switch { + case !c.Present: + absent = absent || !c.Accepted + case c.Accepted && strings.HasPrefix(c.Ver, "1.") && c.Ver != "1.0": + sameMajorHigherMinor = true + case !c.Accepted && isMajorMinor(c.Ver) && !strings.HasPrefix(c.Ver, "1."): + unrecognisedMajor = true + case !c.Accepted && !isMajorMinor(c.Ver): + malformed = true + } + } + for name, ok := range map[string]bool{ + "a same-major higher-minor version accepted": sameMajorHigherMinor, + "an unrecognised major refused": unrecognisedMajor, + "a value that is not MAJOR.MINOR refused": malformed, + "an absent ver refused": absent, + } { + if !ok { + t.Errorf("the corpus at %s has no vector for %s — the contract states the clause, so the SDKs must be pinned to it", + filepath.Base(manifestVersionVectors), name) + } + } +} + +// isMajorMinor is the corpus-side classifier used only to sort vectors into the +// clauses above. It is deliberately a loose re-statement, not the SDK's parser: +// the SDK's verdict is what the corpus records, and this only decides which +// clause a recorded verdict exercises. +func isMajorMinor(ver string) bool { + major, minor, found := strings.Cut(ver, ".") + return found && digitsOnly(major) && digitsOnly(minor) +} + +func digitsOnly(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} diff --git a/conformance/ver_field_contract_test.go b/conformance/ver_field_contract_test.go index 7dc6d4a4..5db6b5a9 100644 --- a/conformance/ver_field_contract_test.go +++ b/conformance/ver_field_contract_test.go @@ -59,8 +59,9 @@ var ( // stale, and an entry whose comment has since adopted the standard advisory // wording fails as a no-longer-needed exemption. var verExemptMessages = map[string]string{ - "WellKnownManifest": "the /.well-known/ramp.json document's own schema version — a separate " + - "namespace from the RPC envelope, stated MUST-equal rather than advisory", + "WellKnownManifest": "the /.well-known/ramp.json document's own layout version — a separate " + + "namespace from the RPC envelope, gated on its MAJOR by consumers rather than advisory; " + + "its expected value is the SDK's WellKnownManifestVersion, not ProtocolVersion", } // wireConstantsVectors is the committed cross-language oracle for the SDK's wire @@ -94,8 +95,38 @@ var protocolVersion = sync.OnceValues(func() (string, error) { return "", errNoProtocolVersionVector }) -var errNoProtocolVersionVector = errors.New( - wireConstantsVectors + " carries no ProtocolVersion entry — this guard reads the expected `ver` value from there") +// manifestVersion reads the SDK's WellKnownManifestVersion the same way. It is a +// separate read on purpose: the two constants are separate namespaces, and a +// guard that checked the manifest comment against ProtocolVersion would be the +// coupling the contract forbids, written into the thing that guards it. +var manifestVersion = sync.OnceValues(func() (string, error) { + b, err := os.ReadFile(wireConstantsVectors) + if err != nil { + return "", err + } + var doc struct { + Vectors []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"vectors"` + } + if err := json.Unmarshal(b, &doc); err != nil { + return "", err + } + for _, v := range doc.Vectors { + if v.Name == "WellKnownManifestVersion" { + return v.Value, nil + } + } + return "", errNoManifestVersionVector +}) + +var ( + errNoProtocolVersionVector = errors.New( + wireConstantsVectors + " carries no ProtocolVersion entry — this guard reads the expected `ver` value from there") + errNoManifestVersionVector = errors.New( + wireConstantsVectors + " carries no WellKnownManifestVersion entry — this guard reads the manifest's expected `ver` value from there") +) // expectedVersionToken is the quoted form a `ver` comment must contain, e.g. `"1.0"`. func expectedVersionToken(t *testing.T) string { @@ -107,6 +138,18 @@ func expectedVersionToken(t *testing.T) string { return strconv.Quote(v) } +// expectedManifestVersionToken is the quoted form the MANIFEST's `ver` comment must +// contain. Read from its own constant, so a bump of either namespace alone goes +// red on exactly the comment it invalidates. +func expectedManifestVersionToken(t *testing.T) string { + t.Helper() + v, err := manifestVersion() + if err != nil { + t.Fatalf("read the SDK manifest version: %v", err) + } + return strconv.Quote(v) +} + // statesExpectedValue is the obligation on EVERY `ver` comment: an integrator // reading the field must learn what to stamp. Required of exempt fields too — // the exemption is from the advisory rule, not from naming the value. `token` is @@ -176,17 +219,23 @@ func TestVerFieldsDocumentTheirContract(t *testing.T) { } token := expectedVersionToken(t) + manifestToken := expectedManifestVersionToken(t) for _, f := range fields { if f.comment == "" { t.Errorf("%s:%d %s.ver carries no doc comment — every `ver` must state the expected value and its receive-side rule", f.file, f.line, f.message) continue } - if !statesExpectedValue(f.comment, token) { - t.Errorf("%s:%d %s.ver does not state the expected value — the comment must name %s (the SDK's ProtocolVersion, per %s) so an integrator learns what to stamp", - f.file, f.line, f.message, token, wireConstantsVectors) + _, exempt := verExemptMessages[f.message] + want, owner := token, "ProtocolVersion" + if exempt { + want, owner = manifestToken, "WellKnownManifestVersion" + } + if !statesExpectedValue(f.comment, want) { + t.Errorf("%s:%d %s.ver does not state the expected value — the comment must name %s (the SDK's %s, per %s) so an integrator learns what to stamp", + f.file, f.line, f.message, want, owner, wireConstantsVectors) } - if _, exempt := verExemptMessages[f.message]; exempt { + if exempt { continue } if !statesAdvisoryContract(f.comment) { @@ -234,12 +283,23 @@ func TestVerContractDetectors_MetaPositive(t *testing.T) { if !statesAdvisoryContract(std) { t.Error("detector missed the advisory rule in the standard comment") } - manifest := "// RAMP protocol version of THIS MANIFEST DOCUMENT's schema. MUST equal " + token + ";" - if !statesExpectedValue(manifest, token) { + mtoken := expectedManifestVersionToken(t) + manifest := "// Version of THIS MANIFEST DOCUMENT's layout — " + mtoken + ", stamped from WellKnownManifestVersion." + if !statesExpectedValue(manifest, mtoken) { t.Error("detector missed the expected value in the manifest comment") } } +// TestManifestVersionTokenIsReadSeparately pins that the manifest's expected +// value has its own source. The two read the same today, so a guard that quietly +// used ProtocolVersion for both would pass — this test fails the day the vector +// entry goes missing, which is the day the guard would silently start coupling. +func TestManifestVersionTokenIsReadSeparately(t *testing.T) { + if _, err := manifestVersion(); err != nil { + t.Fatalf("the manifest version must be readable from its own vector entry: %v", err) + } +} + // TestVerExpectedValueComesFromTheSDK pins the anchor itself: the token is READ // from the wire-constants vector, not restated here. A comment naming some other // version must fail — which is what makes a half-finished bump (constant moved, @@ -274,8 +334,8 @@ func TestVerContractDetectors_MetaNegative(t *testing.T) { } // The manifest comment states a value but NOT the advisory rule — that // asymmetry is exactly what makes its exemption necessary. - if statesAdvisoryContract("// MUST equal " + token + "; consumers REJECT unrecognised major versions.") { - t.Error("detector treated the MUST-equal manifest rule as the advisory contract") + if statesAdvisoryContract("// They REJECT an unrecognised MAJOR, a value that is not MAJOR.MINOR, and an ABSENT `ver`.") { + t.Error("detector treated the manifest's major-version gate as the advisory contract") } } diff --git a/docs/sdk-parity-matrix.md b/docs/sdk-parity-matrix.md index 109ea63b..7e607813 100644 --- a/docs/sdk-parity-matrix.md +++ b/docs/sdk-parity-matrix.md @@ -12,7 +12,7 @@ Go is the oracle (`sdk/go/{helpers,resolvers,core,connect,connectserver}`); Python and TS mirror it. This document is **generated** from the same two artifacts CI already enforces against the code, so it cannot drift from the real surface — a mismatch fails the API-surface gate or the corpus-completeness gate before it can reach this file. -**At a glance:** 134 symbols at cross-language parity · 16 documented divergences · 183 Go-idiomatic exclusions · 35 conformance corpora, each tri-replayed. +**At a glance:** 138 symbols at cross-language parity · 16 documented divergences · 183 Go-idiomatic exclusions · 36 conformance corpora, each tri-replayed. Layering (L1 pure trust core vs L2 I/O resolvers), the SSRF transport-wiring invariant, and naming conventions are recorded in [`design-history.md`](./design-history.md). @@ -38,6 +38,7 @@ Legend: a name = the public face in that language · `—` = intentionally none | `CatalogRejectionDetail` | `catalog_rejection_detail` | `catalogRejectionDetail` | | `CheckAudience` | `check_audience` | `checkAudience` | | `CheckRegistrationData` | `check_registration_data` | `checkRegistrationData` | +| `CheckWellKnownManifestVersion` | `manifest_version_refusal` | `manifestVersionRefusal` | | `CompileRegistrationSchema` | `compile_registration_schema` | `compileRegistrationSchema` | | `ConnectProtocolVersion` | `ConnectProtocolVersion` | `ConnectProtocolVersion` | | `ConnectProtocolVersionHeader` | `ConnectProtocolVersionHeader` | `ConnectProtocolVersionHeader` | @@ -47,6 +48,7 @@ Legend: a name = the public face in that language · `—` = intentionally none | `DisputeFailureDetail` | `dispute_failure_detail` | `disputeFailureDetail` | | `DomainVerificationFailureDetail` | `domain_verification_failure_detail` | `domainVerificationFailureDetail` | | `EntryVerdict` | `EntryVerdict` | `EntryVerdict` | +| `ErrManifestVersionRefused` | `ManifestVersionRefusedError` | `ManifestVersionRefused` | | `ErrUnknownKey` | `UnknownKeyError` | `UnknownKey` | | `FormatMoney` | `format_money` | `formatMoney` | | `HashURL` | `hash_url` | `hashUrl` | @@ -111,6 +113,7 @@ Legend: a name = the public face in that language · `—` = intentionally none | `VerifyRequest` | `verify_request` | `verifyRequestServer` | | `VerifyRequestAcceptance` | `verify_request_acceptance_jcs` | `verifyRequestAcceptance` | | `VerifyURLEd25519` | `verify_ed25519_signed_url` | `verifyEd25519SignedUrl` | +| `WellKnownManifestVersion` | `WellKnownManifestVersion` | `WellKnownManifestVersion` | ### resolvers — L2 I/O (key/endpoint resolution, active-key, SSRF-guarded fetch) @@ -128,6 +131,7 @@ Legend: a name = the public face in that language · `—` = intentionally none | `ErrEndpointRefused` | `EndpointRefusedError` | `EndpointRefused` | | `ErrKeyExpired` | `KeyExpiredError` | `KeyExpired` | | `ErrKeyRevoked` | `KeyRevokedError` | `KeyRevoked` | +| `ErrManifestVersionRefused` | `ManifestVersionRefusedError` | `ManifestVersionRefused` | | `ErrNoEndpoint` | `NoEndpointError` | `NoEndpoint` | | `ErrRevocationUnevaluated` | `RevocationUnevaluatedError` | `RevocationUnevaluated` | | `ErrUnknownKey` | `UnknownKeyError` | `UnknownKey` | @@ -411,6 +415,7 @@ Go emits each `*-vectors.json` oracle; Python and TS replay it. The completeness | `helpers/testdata/host-rule-vectors.json` | ✅ | ✅ | ✅ | | `helpers/testdata/idempotency-validate-vectors.json` | ✅ | ✅ | ✅ | | `helpers/testdata/licenseterm-vectors.json` | ✅ | ✅ | ✅ | +| `helpers/testdata/manifest-version-vectors.json` | ✅ | ✅ | ✅ | | `helpers/testdata/money-vectors.json` | ✅ | ✅ | ✅ | | `helpers/testdata/multisig-chain-vectors.json` | ✅ | ✅ | ✅ | | `helpers/testdata/offer-verify-vectors.json` | ✅ | ✅ | ✅ | diff --git a/gen/descriptor.binpb b/gen/descriptor.binpb index 4fc4136a08325138e1ee19fc5de92c27d562eb2a..c21256ef8763f47ed148bd730d4657fda47b981a 100644 GIT binary patch delta 44254 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%0AWy^HSh_ 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

w$!h*`T~XQo z*Y5gUW`$-u(6f9YY&(unGs_^ONUv~jqE={MZ3NN@`{Is}^l61=@38Cxt&kkU=rKZ7 zRjtZt5O}Q8KJy}jhE>`>m_VXomFB_ZjB+YSIqNllgQ*&+PA6w^77Y;a=XOXs1noj39}-*CYaX-ZuO6;0^>+KoJ!*7j zyAIq=J7FTTU9;0}n3I9pVaML9Mr3yA;8p=3GGfymnrW=8j8aruRwLKks|IIw>Y%@u zk(I`sdSJ0^W{#_~ zUiN?vTxbawsRNp_p8ux01~jqte%)jDtMNhh{al}9)%Shr(*uN1Wjb|4Cr;US9#r?I zk7%ZkvYb^Z5p+xkAKHNrsft+~Mezt9pB&R&qpu?&vEDJw8moW=5|A9zJwF5_vc*;y zE`pB75#%}a;CLKCo+S}K zfuv83n$`N(1#46ZKdTe1+IAnO{*pec^D_=?ods;4;DiWlkSKw69~$LD(#222;IhO{ z@ot%_b{VfI6sQBY&0mUoqc|`TiUX9TU7admxy;GeK$oxjz>POVH@EaZ6;WZ01ztJV7 z?f-~VPz5%xY1{KrRo&xSt~&zGYto$w0IDjTdfp^nHF<@jtscj~_r;^Cyy$tuZf}|M zRWawAXM&eaQhuU(9dtD36JFgzkE!3~4ViC}!ySC0b0~kwB&UCe!|g6GO!{Q#suH9w zHo;Qc_LLej3z9p5j;d)vVvEJ*=4jZ1r0rtEdw#(yBzRqHdMz~(UWMWCy2SX5?>Y2g ziSZfVvjne840v_VOjRHxRc!*soNYfRwJ|Qy&;tQ$wITSU z4#}jy+6=OENCq6v)1fq%UvBu@IHmG~E&-+MHUsqgvw^P=~oYsx_?7MQUXad95iJ>-@)AQ7fFAq9R?M!yVR| zLGiVn5ZA6XmG`!9kSL~H`x=vYgPYz+{o2Vh?BOTW<_>F&-Yo$!`2cNM;;PKej#DSR@x6t;=d{S*+?>(y*Zz6y0_=2Z0R`F-hTd!+S}m+lb?0&EEIPh zw!5Ciorg`}{;`q|lZzZS{H1_TognG=5&QiqsyuTfCnN4WA~M~b4AfCOX^P5bj+$Vw zGch&gg=_8{>Seg$WA?8ibIkbJU7>U5F~jVxr_)1>9k-{Zs9TDT$1X2)?mR9omwgbf zO#DZjRIkiQ6S(T-bVlZ9C(V#MybRN`M*Aa?Y0TL{ zW@n9J2krE*9p#jLb(-ptIc0)qz-MDDGN%mN{i8m!<>a(II*mS`Hi7#22NK9$FugzXkQIi5?58ngJ%=8B8bj8z z1hStBWLF4eDf<4UN&HVByAa53-JV})-@02}Z~yU}n%3dV7_Hp|t<8eg-n}@cZ_P0t zA-PJk;8DhfZi3mbOi;p~yOb${jOYlMkO+Sz4q;-9Fx1z^?);^y%6=V>5O5uv5rSQ| z2yyQhjm^$bgR&Q6h`>eYqCjMr69T@MjD2o~8lJrr`=p!0?FBbt@6AvH zvzK$16VP6k0qH&*H!7XFY7(=pnW@GWTorf86n>Pr>zWDLjGZ%6mCxcZ@)Rey!E3Rn zAnEP3*i(>r>YC}@<~=10=c(4%Q=UT?T4PUnmUyZaPyIZ_f2U_o8}#0#@AYL# '9' { + return false + } + } + return true +} diff --git a/sdk/go/helpers/manifestversion_vectors_test.go b/sdk/go/helpers/manifestversion_vectors_test.go new file mode 100644 index 00000000..8c3092ba --- /dev/null +++ b/sdk/go/helpers/manifestversion_vectors_test.go @@ -0,0 +1,55 @@ +package helpers + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +// TestManifestVersionCorpusReplays drives every row of the shared corpus through +// CheckWellKnownManifestVersion and asserts the committed verdict. The emitter +// proves the file matches the oracle at emit time; this replay proves the oracle +// still agrees with the file, which is what the Python and TypeScript replays +// assert against their own ports. +func TestManifestVersionCorpusReplays(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "manifest-version-vectors.json")) + if err != nil { + t.Fatalf("read corpus: %v", err) + } + var doc struct { + Cases []manifestVersionVector `json:"manifest_version"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("decode corpus: %v", err) + } + if len(doc.Cases) == 0 { + t.Fatal("corpus is empty; the replay would be vacuous") + } + var accepted, refused, absent int + for _, c := range doc.Cases { + ver := c.Ver + if !c.Present { + ver = "" // an absent member arrives as the zero value in Go + absent++ + } + err := CheckWellKnownManifestVersion(ver) + if c.Accepted { + accepted++ + if err != nil { + t.Errorf("%s: ver=%q present=%v: refused (%v), corpus says accepted", c.Name, c.Ver, c.Present, err) + } + continue + } + refused++ + if err == nil { + t.Errorf("%s: ver=%q present=%v: accepted, corpus says refused", c.Name, c.Ver, c.Present) + } else if !errors.Is(err, ErrManifestVersionRefused) { + t.Errorf("%s: error %v does not carry ErrManifestVersionRefused", c.Name, err) + } + } + if accepted == 0 || refused == 0 || absent == 0 { + t.Fatalf("corpus must carry accepted, refused and absent rows; got %d/%d/%d", accepted, refused, absent) + } +} diff --git a/sdk/go/helpers/protocol_version_ssot_test.go b/sdk/go/helpers/protocol_version_ssot_test.go index bc1e374e..0115003e 100644 --- a/sdk/go/helpers/protocol_version_ssot_test.go +++ b/sdk/go/helpers/protocol_version_ssot_test.go @@ -41,11 +41,11 @@ import ( // `Ver: req.GetVer()` and the constant form `Ver: helpers.ProtocolVersion` do not. // // The struct-literal form is the whole scope, and widening it is not free: a -// matcher that also read `x.Ver = "…"` would flag WellKnownManifest.Ver, whose -// quoted literal is CORRECT — that field versions the /.well-known/ramp.json -// document schema, a separate namespace stated MUST-equal and deliberately not -// stamped from ProtocolVersion. Widening therefore needs a message-level -// carve-out, not a looser regex. +// matcher that also read `x.Ver = "…"` would flag WellKnownManifest.Ver, which +// is stamped from a DIFFERENT constant — WellKnownManifestVersion, the version +// of the /.well-known/ramp.json document layout, a separate namespace never +// derived from ProtocolVersion. Widening therefore needs a message-level +// carve-out that knows which constant each message stamps, not a looser regex. var bareVerLiteralRe = regexp.MustCompile(`\bVer:\s*"[^"]*"`) // stampsBareVerLiteral is the pure predicate, extracted so the meta-tests can diff --git a/sdk/go/helpers/testdata/manifest-version-vectors.json b/sdk/go/helpers/testdata/manifest-version-vectors.json new file mode 100644 index 00000000..5f0da7ea --- /dev/null +++ b/sdk/go/helpers/testdata/manifest-version-vectors.json @@ -0,0 +1,94 @@ +{ + "manifest_version": [ + { + "name": "current", + "ver": "1.0", + "present": true, + "accepted": true + }, + { + "name": "same_major_next_minor", + "ver": "1.1", + "present": true, + "accepted": true + }, + { + "name": "same_major_large_minor", + "ver": "1.99", + "present": true, + "accepted": true + }, + { + "name": "next_major", + "ver": "2.0", + "present": true, + "accepted": false + }, + { + "name": "prerelease_major", + "ver": "0.3", + "present": true, + "accepted": false + }, + { + "name": "two_digit_major", + "ver": "10.0", + "present": true, + "accepted": false + }, + { + "name": "absent", + "ver": "", + "present": false, + "accepted": false + }, + { + "name": "empty", + "ver": "", + "present": true, + "accepted": false + }, + { + "name": "major_only", + "ver": "1", + "present": true, + "accepted": false + }, + { + "name": "patch_component", + "ver": "1.0.0", + "present": true, + "accepted": false + }, + { + "name": "leading_v", + "ver": "v1.0", + "present": true, + "accepted": false + }, + { + "name": "non_digit_minor", + "ver": "1.a", + "present": true, + "accepted": false + }, + { + "name": "leading_space", + "ver": " 1.0", + "present": true, + "accepted": false + }, + { + "name": "trailing_dot", + "ver": "1.", + "present": true, + "accepted": false + }, + { + "name": "leading_zero_major", + "ver": "01.0", + "present": true, + "accepted": false + } + ] +} diff --git a/sdk/go/helpers/testdata/wire-constants-vectors.json b/sdk/go/helpers/testdata/wire-constants-vectors.json index 51479686..0059b690 100644 --- a/sdk/go/helpers/testdata/wire-constants-vectors.json +++ b/sdk/go/helpers/testdata/wire-constants-vectors.json @@ -20,6 +20,10 @@ "name": "ProtocolVersion", "value": "1.0" }, + { + "name": "WellKnownManifestVersion", + "value": "1.0" + }, { "name": "RequestIDHeader", "value": "X-Request-ID" diff --git a/sdk/go/resolvers/endpointresolver.go b/sdk/go/resolvers/endpointresolver.go index 50fa95dc..d8c90c03 100644 --- a/sdk/go/resolvers/endpointresolver.go +++ b/sdk/go/resolvers/endpointresolver.go @@ -41,9 +41,22 @@ var ErrNoEndpoint = errors.New("resolvers: well-known manifest has no endpoint") // again in a moment. var ErrEndpointRefused = errors.New("resolvers: well-known manifest advertises an unusable endpoint") +// ErrManifestVersionRefused signals that a /.well-known/ramp.json was fetched and +// parsed but carries a WellKnownManifest.ver this resolver does not accept: an +// unrecognised major version, a value that is not MAJOR.MINOR, or no version at +// all. The rule is helpers.CheckWellKnownManifestVersion; the error carries that +// package's sentinel too, so either matches. +// +// Checked BEFORE any other member of the document is read. The manifest sits at +// a fixed, unversioned path and is read before any signature is checked, so a +// layout the reader cannot classify must not supply the endpoint a signed call +// is then sent to. Like ErrEndpointRefused it is a VERDICT — final, not a +// transport failure to retry — and it is never cached. +var ErrManifestVersionRefused = errors.New("resolvers: well-known manifest version not accepted") + // wellKnownDoc is the JSON projection of the subset of WellKnownManifest the SDK -// resolvers read: the RFC 7517 key set (field 5) and the self-advertised -// ExchangeService endpoint (field 12). One fetch decodes the whole document so a +// resolvers read: the document version (field 1), the RFC 7517 key set (field 5) +// and the self-advertised ExchangeService endpoint (field 12). One fetch decodes the whole document so a // single body serves both the key face (WellKnownKeyResolver) and the endpoint // face (WellKnownEndpointResolver). // @@ -54,6 +67,7 @@ var ErrEndpointRefused = errors.New("resolvers: well-known manifest advertises a // the promoted-alongside Endpoint field from the same body. type wellKnownDoc struct { jose.JSONWebKeySet + Ver string `json:"ver"` Endpoint string `json:"endpoint"` } @@ -239,6 +253,12 @@ func (r *WellKnownEndpointResolver) ResolveEndpoint(ctx context.Context, host st if ferr != nil { return "", ferr } + // The document version gate runs before the endpoint is so much as looked + // at, and only on this face: the key face reads a JWKS document that + // carries no manifest version. + if verr := helpers.CheckWellKnownManifestVersion(doc.Ver); verr != nil { + return "", fmt.Errorf("%w: %w", ErrManifestVersionRefused, verr) + } if doc.Endpoint == "" { return "", fmt.Errorf("%w: host=%q", ErrNoEndpoint, host) } diff --git a/sdk/go/resolvers/endpointresolver_test.go b/sdk/go/resolvers/endpointresolver_test.go index 58e6311c..3d7184cf 100644 --- a/sdk/go/resolvers/endpointresolver_test.go +++ b/sdk/go/resolvers/endpointresolver_test.go @@ -25,7 +25,7 @@ func manifestHandler(endpoint *string, hits *int) http.Handler { if hits != nil { *hits++ } - doc := map[string]any{"role": "ROLE_EXCHANGE"} + doc := map[string]any{"ver": helpers.WellKnownManifestVersion, "role": "ROLE_EXCHANGE"} if endpoint != nil { doc["endpoint"] = *endpoint } @@ -375,7 +375,7 @@ func TestWellKnownEndpointResolver_leaderCancellationDoesNotPoisonWaiters(t *tes srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { atomic.AddInt32(&hits, 1) <-gate // hold the fetch open until both callers are queued - _ = json.NewEncoder(w).Encode(map[string]any{"endpoint": ep}) + _ = json.NewEncoder(w).Encode(map[string]any{"ver": helpers.WellKnownManifestVersion, "endpoint": ep}) })) defer srv.Close() ep = srv.URL + "/ramp.v1.ExchangeService" diff --git a/sdk/go/resolvers/endpointresolver_version_test.go b/sdk/go/resolvers/endpointresolver_version_test.go new file mode 100644 index 00000000..0b25f2d7 --- /dev/null +++ b/sdk/go/resolvers/endpointresolver_version_test.go @@ -0,0 +1,122 @@ +package resolvers_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/RAMP-Protocol/protocol/sdk/go/helpers" + "github.com/RAMP-Protocol/protocol/sdk/go/resolvers" +) + +// versionedManifestHandler serves a manifest with a caller-chosen `ver`. A nil +// ver omits the member entirely, modeling a manifest with no version at all. +func versionedManifestHandler(ver *string, endpoint string, hits *int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if hits != nil { + *hits++ + } + doc := map[string]any{"role": "ROLE_EXCHANGE", "endpoint": endpoint} + if ver != nil { + doc["ver"] = *ver + } + _ = json.NewEncoder(w).Encode(doc) + }) +} + +func newVersionTestResolver() *resolvers.WellKnownEndpointResolver { + return resolvers.NewWellKnownEndpointResolver(resolvers.WellKnownOptions{ + TTL: time.Hour, Scheme: "http", HTTP: http.DefaultClient, + }) +} + +// A manifest at the same major and a higher minor is accepted: a minor revision +// of the manifest is additive, so a reader keeps reading. +func TestWellKnownEndpointResolver_acceptsASameMajorHigherMinor(t *testing.T) { + ver := "1.1" + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + versionedManifestHandler(&ver, srv.URL+"/ramp.v1.ExchangeService", nil).ServeHTTP(w, r) + })) + defer srv.Close() + got, err := newVersionTestResolver().ResolveEndpoint(context.Background(), hostOf(t, srv)) + if err != nil { + t.Fatalf("resolve failed: %v; a same-major manifest must be accepted", err) + } + if want := srv.URL + "/ramp.v1.ExchangeService"; got != want { + t.Errorf("resolve = %q, want %q", got, want) + } +} + +// An unrecognised major is refused with the version verdict, and the refusal is +// not cached: once the origin serves an acceptable document, the next resolve +// succeeds without waiting out a TTL. +func TestWellKnownEndpointResolver_refusesAnUnrecognisedMajorAndDoesNotCacheIt(t *testing.T) { + ver := "2.0" + var hits int + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + versionedManifestHandler(&ver, srv.URL+"/ramp.v1.ExchangeService", &hits).ServeHTTP(w, r) + })) + defer srv.Close() + r := newVersionTestResolver() + host := hostOf(t, srv) + + got, err := r.ResolveEndpoint(context.Background(), host) + if err == nil { + t.Fatalf("resolve returned %q; a 2.0 manifest must be refused", got) + } + if !errors.Is(err, resolvers.ErrManifestVersionRefused) { + t.Errorf("error = %v, want it to carry resolvers.ErrManifestVersionRefused", err) + } + if !errors.Is(err, helpers.ErrManifestVersionRefused) { + t.Errorf("error = %v, want it to carry helpers.ErrManifestVersionRefused too", err) + } + + ver = helpers.WellKnownManifestVersion + if _, err := r.ResolveEndpoint(context.Background(), host); err != nil { + t.Fatalf("resolve after the origin recovered failed: %v; a refusal must not be cached", err) + } + if hits != 2 { + t.Errorf("origin hits = %d, want 2 (the refusal was fetched again, not served from cache)", hits) + } +} + +// A manifest with no `ver` at all is refused before its endpoint is read. The +// endpoint is a perfectly usable one, so the refusal can only be the version gate. +func TestWellKnownEndpointResolver_refusesAManifestWithNoVersion(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + versionedManifestHandler(nil, srv.URL+"/ramp.v1.ExchangeService", nil).ServeHTTP(w, r) + })) + defer srv.Close() + got, err := newVersionTestResolver().ResolveEndpoint(context.Background(), hostOf(t, srv)) + if err == nil { + t.Fatalf("resolve returned %q; a manifest with no ver must be refused", got) + } + if !errors.Is(err, resolvers.ErrManifestVersionRefused) { + t.Errorf("error = %v, want it to carry ErrManifestVersionRefused", err) + } + if errors.Is(err, resolvers.ErrNoEndpoint) || errors.Is(err, resolvers.ErrEndpointRefused) { + t.Errorf("error = %v; the version gate must fire before the endpoint is examined", err) + } +} + +// The version gate precedes the endpoint gate: a wrong-version manifest that ALSO +// advertises no endpoint reports the version, because no other member is read. +func TestWellKnownEndpointResolver_versionGatePrecedesEndpointGate(t *testing.T) { + ver := "2.0" + srv := httptest.NewServer(versionedManifestHandler(&ver, "", nil)) + defer srv.Close() + _, err := newVersionTestResolver().ResolveEndpoint(context.Background(), hostOf(t, srv)) + if !errors.Is(err, resolvers.ErrManifestVersionRefused) { + t.Fatalf("error = %v, want ErrManifestVersionRefused", err) + } + if errors.Is(err, resolvers.ErrNoEndpoint) { + t.Errorf("error = %v; ErrNoEndpoint must not be reached when the version is refused", err) + } +} diff --git a/sdk/go/resolvers/library_adoption_guard_test.go b/sdk/go/resolvers/library_adoption_guard_test.go index f66186c9..7b9b5407 100644 --- a/sdk/go/resolvers/library_adoption_guard_test.go +++ b/sdk/go/resolvers/library_adoption_guard_test.go @@ -37,8 +37,8 @@ func TestLibraryAdoptionGuard_KeyResolverUsesGoJoseJWKS(t *testing.T) { } // The hand-rolled JWK struct the key face decodes lives in the shared - // wellKnownDoc.Keys in endpointresolver.go — it must keep only the Endpoint - // field and promote the go-jose key set. + // wellKnownDoc.Keys in endpointresolver.go — it must keep only the scalar + // manifest members (Ver, Endpoint) and promote the go-jose key set. epSrc := readResolverSource(t, "endpointresolver.go") for _, forbidden := range []string{ "Kty string `json:\"kty\"`", @@ -47,7 +47,7 @@ func TestLibraryAdoptionGuard_KeyResolverUsesGoJoseJWKS(t *testing.T) { } { if strings.Contains(epSrc, forbidden) { t.Errorf("endpointresolver.go still declares the hand-rolled JWK struct field %q; "+ - "the key face must decode via go-jose jose.JSONWebKeySet, keeping only the Endpoint field", forbidden) + "the key face must decode via go-jose jose.JSONWebKeySet, keeping only the scalar manifest members", forbidden) } } } diff --git a/sdk/parity/symbol-map.json b/sdk/parity/symbol-map.json index c452a43c..561130d8 100644 --- a/sdk/parity/symbol-map.json +++ b/sdk/parity/symbol-map.json @@ -599,11 +599,26 @@ "python": "parse_money", "ts": "parseMoney" }, + "helpers.CheckWellKnownManifestVersion": { + "allowlist_reason": null, + "python": "manifest_version_refusal", + "ts": "manifestVersionRefusal" + }, + "helpers.ErrManifestVersionRefused": { + "allowlist_reason": null, + "python": "ManifestVersionRefusedError", + "ts": "ManifestVersionRefused" + }, "helpers.ProtocolVersion": { "allowlist_reason": null, "python": "ProtocolVersion", "ts": "ProtocolVersion" }, + "helpers.WellKnownManifestVersion": { + "allowlist_reason": null, + "python": "WellKnownManifestVersion", + "ts": "WellKnownManifestVersion" + }, "helpers.Reason": { "allowlist_reason": null, "python": "reason", @@ -849,6 +864,11 @@ "python": "KeyRevokedError", "ts": "KeyRevoked" }, + "resolvers.ErrManifestVersionRefused": { + "allowlist_reason": null, + "python": "ManifestVersionRefusedError", + "ts": "ManifestVersionRefused" + }, "resolvers.ErrNoEndpoint": { "allowlist_reason": null, "python": "NoEndpointError", diff --git a/sdk/python/ramp_sdk/__init__.py b/sdk/python/ramp_sdk/__init__.py index e59522a9..80f5aa06 100644 --- a/sdk/python/ramp_sdk/__init__.py +++ b/sdk/python/ramp_sdk/__init__.py @@ -138,6 +138,7 @@ EndpointRefusedError, KeyExpiredError, KeyRevokedError, + ManifestVersionRefusedError, NoEndpointError, ResolverError, UnknownKeyError, @@ -159,6 +160,8 @@ ProtocolVersion, RequestIDHeader, SignatureAgentHeader, + WellKnownManifestVersion, + manifest_version_refusal, ) __all__ = [ @@ -197,6 +200,7 @@ "MAX_REGISTRATION_SCHEMA_DEPTH", "MAX_REGISTRATION_SCHEMA_EVALUATIONS", "MAX_REGISTRATION_SCHEMA_REF_HOPS", + "ManifestVersionRefusedError", "Mode", "MultisigVerdict", "NoEndpointError", @@ -232,6 +236,7 @@ "Verifier", "WBAKeyResolver", "WBA_DIRECTORY_PATH", + "WellKnownManifestVersion", "WellKnownEndpointResolver", "WellKnownKeyResolver", "Window", @@ -261,6 +266,7 @@ "is_bare_domain", "is_bare_host", "is_safe_schema_pattern", + "manifest_version_refusal", "jcs_acceptance_payload", "jcs_request_acceptance_payload", "known_restriction_token", diff --git a/sdk/python/ramp_sdk/client/route.py b/sdk/python/ramp_sdk/client/route.py index eb69a577..1d991f31 100644 --- a/sdk/python/ramp_sdk/client/route.py +++ b/sdk/python/ramp_sdk/client/route.py @@ -18,7 +18,11 @@ from ramp_sdk._endpoint_rule import endpoint_refusal from ramp_sdk._hostref import _redact_userinfo as redact_userinfo from ramp_sdk.hosts import is_bare_host -from ramp_sdk.resolvers.errors import EndpointRefusedError, NoEndpointError +from ramp_sdk.resolvers.errors import ( + EndpointRefusedError, + ManifestVersionRefusedError, + NoEndpointError, +) from .errors import CallError, CallErrorKind, not_sent @@ -33,8 +37,9 @@ class EndpointResolver(Protocol): An implementation's FAILURE decides how a caller is told to react, so it is part of the contract rather than an implementation detail. A failure that is a VERDICT — the host - is unusable, the host is not allowed, the manifest advertises no endpoint, or it - advertises one that must not be used — MUST raise + is unusable, the host is not allowed, the manifest carries a version this reader does + not accept, advertises no endpoint, or advertises one that must not be used — MUST + raise :class:`~ramp_sdk.resolvers.errors.ManifestVersionRefusedError`, :class:`~ramp_sdk.resolvers.errors.NoEndpointError`, :class:`~ramp_sdk.resolvers.errors.EndpointRefusedError`, or the ``ValueError`` ``is_bare_host`` raises; those surface as ``NOT_SENT``, which tells the caller not to @@ -98,7 +103,10 @@ def vet_exchange_endpoint( # way — an injected one can. kind = ( CallErrorKind.NOT_SENT - if isinstance(exc, NoEndpointError | EndpointRefusedError | ValueError) + if isinstance( + exc, + ManifestVersionRefusedError | NoEndpointError | EndpointRefusedError | ValueError, + ) else CallErrorKind.UNREACHABLE ) raise CallError( diff --git a/sdk/python/ramp_sdk/resolvers/__init__.py b/sdk/python/ramp_sdk/resolvers/__init__.py index 08c61dab..72d26460 100644 --- a/sdk/python/ramp_sdk/resolvers/__init__.py +++ b/sdk/python/ramp_sdk/resolvers/__init__.py @@ -23,6 +23,7 @@ EndpointRefusedError, KeyExpiredError, KeyRevokedError, + ManifestVersionRefusedError, NoEndpointError, ResolverError, RevocationUnevaluatedError, @@ -48,6 +49,7 @@ "EndpointRefusedError", "KeyExpiredError", "KeyRevokedError", + "ManifestVersionRefusedError", "NoEndpointError", "ResolverError", "RevocationUnevaluatedError", diff --git a/sdk/python/ramp_sdk/resolvers/errors.py b/sdk/python/ramp_sdk/resolvers/errors.py index af1e02e5..0b535307 100644 --- a/sdk/python/ramp_sdk/resolvers/errors.py +++ b/sdk/python/ramp_sdk/resolvers/errors.py @@ -64,3 +64,17 @@ class EndpointRefusedError(ResolverError): answered, and the answer is not usable. A caller that classifies retryability reads this as final rather than as something to try again in a moment. """ + + +class ManifestVersionRefusedError(ResolverError): + """A ``/.well-known/ramp.json`` was fetched and parsed but carries a + ``WellKnownManifest.ver`` this resolver does not accept: an unrecognised major + version, a value that is not ``MAJOR.MINOR``, or no version at all. The rule is + :func:`ramp_sdk.wire.manifest_version_refusal`. + + Checked BEFORE any other member of the document is read. The manifest sits at + a fixed, unversioned path and is read before any signature is checked, so a + layout the reader cannot classify must not supply the endpoint a signed call + is then sent to. Like :class:`EndpointRefusedError` it is a VERDICT — final, + not a transport failure to retry — and it is never cached. + """ diff --git a/sdk/python/ramp_sdk/resolvers/wellknown.py b/sdk/python/ramp_sdk/resolvers/wellknown.py index 958ef4d5..60d7537c 100644 --- a/sdk/python/ramp_sdk/resolvers/wellknown.py +++ b/sdk/python/ramp_sdk/resolvers/wellknown.py @@ -8,8 +8,11 @@ wrong-length ``x`` is skipped and one bad key never fails the whole set. ``WellKnownEndpointResolver`` is host-keyed: it fetches -``{scheme}://{host}/.well-known/ramp.json`` and reads the ``endpoint`` field -(ignoring unknown fields). Both port the Go oracle's lazy-fetch + TTL cache + +``{scheme}://{host}/.well-known/ramp.json``, refuses the document unless its +``ver`` carries a recognised major (``ManifestVersionRefusedError``), then reads +the ``endpoint`` field (ignoring unknown fields). The version gate is this +face's alone — the key face reads a JWKS document that carries no manifest +version. Both port the Go oracle's lazy-fetch + TTL cache + single-flight coalescing and its fail-closed taxonomy. """ @@ -29,8 +32,10 @@ from ramp_sdk.resolvers.errors import ( DirectoryUnavailableError, EndpointRefusedError, + ManifestVersionRefusedError, NoEndpointError, ) +from ramp_sdk.wire import manifest_version_refusal if TYPE_CHECKING: import httpx @@ -42,7 +47,6 @@ AllowFn = Callable[[str], bool] - def _now_utc() -> datetime: return datetime.now(UTC) @@ -162,13 +166,14 @@ def __init__( def resolve_endpoint(self, host: str) -> str: """Return the endpoint ``host`` advertises in its well-known manifest. - Four exits, and a caller classifying retryability needs all four. Three are + Five exits, and a caller classifying retryability needs all five. Four are verdicts and FINAL: ``ValueError`` when ``host`` is not a bare host, raised - before anything is fetched; ``NoEndpointError`` when the manifest is - reachable but advertises nothing; ``EndpointRefusedError`` when it advertises - an endpoint the rule will not hand back. The fourth, - ``DirectoryUnavailableError``, is a transport failure — unreachable or - undecodable — and is the only one worth retrying. + before anything is fetched; ``ManifestVersionRefusedError`` when the manifest + carries a ``ver`` this reader does not accept, or none at all; + ``NoEndpointError`` when the manifest is reachable but advertises nothing; + ``EndpointRefusedError`` when it advertises an endpoint the rule will not + hand back. The fifth, ``DirectoryUnavailableError``, is a transport failure + — unreachable or undecodable — and is the only one worth retrying. """ # Checked BEFORE the allow overlay and before the cache. The fetch URL is # built by concatenation, so a value carrying a path or a query would @@ -206,6 +211,11 @@ def _fetch_endpoint(self, host: str) -> str: doc = json.loads(body) except ValueError as exc: raise DirectoryUnavailableError(f"manifest decode {url}") from exc + # The document version gate runs before the endpoint is so much as looked + # at, and never caches: the next resolve fetches again. + refusal = manifest_version_refusal(doc.get("ver") if isinstance(doc, dict) else None) + if refusal is not None: + raise ManifestVersionRefusedError(refusal) endpoint = doc.get("endpoint") if isinstance(doc, dict) else None if not isinstance(endpoint, str) or endpoint == "": raise NoEndpointError(f"host={host}") diff --git a/sdk/python/ramp_sdk/wire.py b/sdk/python/ramp_sdk/wire.py index 0488838f..4334f12f 100644 --- a/sdk/python/ramp_sdk/wire.py +++ b/sdk/python/ramp_sdk/wire.py @@ -3,7 +3,10 @@ Content-Type (ADR-020): application/proto for binary, application/json for canonical proto-JSON. The Go layer splits RequestIDHeader across helpers/constants.go and core/requestid.go; the single Python module exposes all -seven values once. Pinned to wire-constants-vectors.json. +eight values once. Pinned to wire-constants-vectors.json. + +Also home to the pure receive-side rule for ``WellKnownManifest.ver``, so the +constant and the check that reads it sit together. """ from __future__ import annotations @@ -18,9 +21,68 @@ ConnectProtocolVersion = "1" #: The RAMP protocol version stamped on the ``ver`` field of every RAMP message #: — NOT the Connect transport version above. Senders stamp it from here so a -#: protocol bump is a single edit; receivers treat ``ver`` as advisory. +#: protocol bump is a single edit; receivers treat ``ver`` as advisory. The +#: ``/.well-known/ramp.json`` document carries its own version in a separate +#: namespace, which this constant does NOT supply — that is +#: :data:`WellKnownManifestVersion`. ProtocolVersion = "1.0" +#: The version of the ``/.well-known/ramp.json`` DOCUMENT layout, stamped on +#: ``WellKnownManifest.ver`` by every party that serves one. A namespace separate +#: from :data:`ProtocolVersion` and never derived from it: a change to the +#: manifest layout bumps both numbers, a protocol change that leaves the manifest +#: untouched bumps only ``ProtocolVersion``. The two read the same today by +#: coincidence, not by rule. The receive-side check a manifest reader applies is +#: :func:`check_well_known_manifest_version`. +WellKnownManifestVersion = "1.0" #: Header correlating a request across services and the edge. RequestIDHeader = "X-Request-ID" #: Header carrying the signer's Web Bot Auth key-directory URL. SignatureAgentHeader = "Signature-Agent" + + +def _parse_major(ver: str) -> str | None: + """The MAJOR run of a MAJOR.MINOR string, or None when ``ver`` is not one. + + Both runs must be non-empty ASCII digits joined by exactly one dot; a missing + minor, a patch component, a leading ``v``, surrounding whitespace or a + non-digit is not a version this rule recognises. + """ + major, dot, minor = ver.partition(".") + if not dot or not major.isascii() or not major.isdigit(): + return None + if not minor.isascii() or not minor.isdigit(): + return None + return major + + +def manifest_version_refusal(ver: object) -> str | None: + """The receive-side rule for ``WellKnownManifest.ver``, as a pure verdict. + + Returns ``None`` when the document is accepted — its MAJOR equals the major of + :data:`WellKnownManifestVersion`, whatever the MINOR, because a minor revision + of the manifest is additive by definition and a reader ignores members it does + not know. Otherwise returns the reason: an unrecognised major, a value that is + not ``MAJOR.MINOR``, or an absent member (``None``, or any non-string, is how + a missing ``ver`` arrives from ``json.loads``). Absent is refused rather than + tolerated because the field is required by the wire shape and a document with + no version is one whose layout the reader cannot classify; the manifest sits + at a fixed, unversioned path and is read before any signature is checked, so + the gate fails closed. + + The message names the value found so an operator can tell a version mismatch + from a network failure. The three SDK languages pin this verdict to a shared + corpus. The exception a resolver raises for a refusal is + :class:`ramp_sdk.resolvers.errors.ManifestVersionRefusedError`. + """ + accept_major = _parse_major(WellKnownManifestVersion) + if accept_major is None: # pragma: no cover - a malformed constant is a bug + msg = f"WellKnownManifestVersion is not MAJOR.MINOR: {WellKnownManifestVersion!r}" + raise RuntimeError(msg) + if not isinstance(ver, str) or ver == "": + return f"ver is absent, accept major {accept_major}" + major = _parse_major(ver) + if major is None: + return f"ver {ver!r} is not MAJOR.MINOR, accept major {accept_major}" + if major != accept_major: + return f"ver {ver!r} has major {major}, accept major {accept_major}" + return None diff --git a/sdk/python/tests/resolvers_harness.py b/sdk/python/tests/resolvers_harness.py index d1954dd2..47265446 100644 --- a/sdk/python/tests/resolvers_harness.py +++ b/sdk/python/tests/resolvers_harness.py @@ -28,6 +28,8 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from ramp_sdk.wire import WellKnownManifestVersion + from ramp_sdk.b64 import b64url_nopad from ramp_sdk.thumbprint import thumbprint @@ -124,7 +126,7 @@ def jwks_key_doc_json(entries: list[dict[str, Any]]) -> str: def manifest_json(endpoint: str | None = None) -> str: """Serialize a WellKnownManifest projection ({role, endpoint}); omit endpoint to model a valid-but-inert manifest.""" - doc: dict[str, Any] = {"role": "ROLE_EXCHANGE"} + doc: dict[str, Any] = {"ver": WellKnownManifestVersion, "role": "ROLE_EXCHANGE"} if endpoint is not None: doc["endpoint"] = endpoint return json.dumps(doc) diff --git a/sdk/python/tests/test_endpoint_refused_public_home.py b/sdk/python/tests/test_endpoint_refused_public_home.py index fd08bfe1..2034c475 100644 --- a/sdk/python/tests/test_endpoint_refused_public_home.py +++ b/sdk/python/tests/test_endpoint_refused_public_home.py @@ -27,6 +27,7 @@ def test_it_sits_beside_its_siblings() -> None: for sibling in ( "ResolverError", "NoEndpointError", + "ManifestVersionRefusedError", "DirectoryUnavailableError", "UnknownKeyError", "KeyRevokedError", diff --git a/sdk/python/tests/test_endpoint_vet_parity.py b/sdk/python/tests/test_endpoint_vet_parity.py index 61cd8bd3..e20095b9 100644 --- a/sdk/python/tests/test_endpoint_vet_parity.py +++ b/sdk/python/tests/test_endpoint_vet_parity.py @@ -26,6 +26,7 @@ from conftest import GO_RESOLVERS_TESTDATA, load_json from ramp_sdk.hosts import is_bare_host from ramp_sdk.resolvers import EndpointRefusedError, WellKnownEndpointResolver +from ramp_sdk.wire import WellKnownManifestVersion _DOC = load_json(GO_RESOLVERS_TESTDATA / "endpoint-vet-vectors.json") _REFUSED = [v for v in _DOC["endpoint_vet"] if v["refused"]] @@ -51,7 +52,10 @@ def _serving_manifest(endpoint: str) -> httpx.Client: """A manifest server that always answers with the endpoint under test.""" def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"role": "ROLE_EXCHANGE", "endpoint": endpoint}) + return httpx.Response( + 200, + json={"ver": WellKnownManifestVersion, "role": "ROLE_EXCHANGE", "endpoint": endpoint}, + ) return httpx.Client(transport=httpx.MockTransport(handler)) diff --git a/sdk/python/tests/test_hosts_credential_redaction.py b/sdk/python/tests/test_hosts_credential_redaction.py index 76d50040..f2be81c7 100644 --- a/sdk/python/tests/test_hosts_credential_redaction.py +++ b/sdk/python/tests/test_hosts_credential_redaction.py @@ -25,6 +25,7 @@ from ramp_sdk.hosts import host_anchored, host_of from ramp_sdk.resolvers import EndpointRefusedError, WellKnownEndpointResolver +from ramp_sdk.wire import WellKnownManifestVersion _SECRET = "s3cr3t" # noqa: S105 - a test fixture, not a credential @@ -58,7 +59,10 @@ def _serving(endpoint: str) -> httpx.Client: """A manifest server that always answers with the endpoint under test.""" def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"role": "ROLE_EXCHANGE", "endpoint": endpoint}) + return httpx.Response( + 200, + json={"ver": WellKnownManifestVersion, "role": "ROLE_EXCHANGE", "endpoint": endpoint}, + ) return httpx.Client(transport=httpx.MockTransport(handler)) @@ -96,9 +100,7 @@ def test_a_well_formed_credential_still_gets_the_rules_own_message() -> None: r = WellKnownEndpointResolver(http=_serving(f"https://u:{_SECRET}@exchange.example/v1")) with pytest.raises(EndpointRefusedError) as caught: r.resolve_endpoint("exchange.example") - assert str(caught.value) == ( - "host='exchange.example' advertises an endpoint carrying userinfo" - ) + assert str(caught.value) == ("host='exchange.example' advertises an endpoint carrying userinfo") # The SERVING HOST, not the advertised endpoint. Every case above feeds the diff --git a/sdk/python/tests/test_manifest_version_parity.py b/sdk/python/tests/test_manifest_version_parity.py new file mode 100644 index 00000000..fe6d89e1 --- /dev/null +++ b/sdk/python/tests/test_manifest_version_parity.py @@ -0,0 +1,118 @@ +"""Replay of the shared manifest-version corpus, driven two ways. + +The pure rule (``manifest_version_refusal``) is replayed row by row against the +Go emitter's verdicts. Then the same rows are driven through the REAL endpoint +resolver against a mock manifest server, so the test proves the resolver applies +the rule before it reads the endpoint — a refused document's endpoint is a +perfectly usable one, so the refusal can only be the version gate. + +``present`` is its own column because an absent member is not a string: the +resolver reads ``doc.get("ver")`` and gets ``None``, which is the path the +``absent`` row must exercise. Pinned to manifest-version-vectors.json. +""" + +from __future__ import annotations + +from datetime import timedelta + +import httpx +import pytest + +from conftest import GO_TESTDATA, load_json +from ramp_sdk.resolvers import ( + EndpointRefusedError, + ManifestVersionRefusedError, + NoEndpointError, + WellKnownEndpointResolver, +) +from ramp_sdk.wire import WellKnownManifestVersion, manifest_version_refusal + +_VECTORS = load_json(GO_TESTDATA / "manifest-version-vectors.json")["manifest_version"] +_ACCEPTED = [v for v in _VECTORS if v["accepted"]] +_REFUSED = [v for v in _VECTORS if not v["accepted"]] +_ABSENT = [v for v in _VECTORS if not v["present"]] + +_HOST = "exchange.example" +_ENDPOINT = f"https://{_HOST}/ramp.v1.ExchangeService" + + +def _ver_of(vector: dict) -> str | None: + """The value the resolver sees: the string when present, None when absent.""" + return vector["ver"] if vector["present"] else None + + +def _serving(vector: dict) -> httpx.Client: + """A manifest server that answers with the vector's ver and a usable endpoint.""" + doc: dict[str, object] = {"role": "ROLE_EXCHANGE", "endpoint": _ENDPOINT} + if vector["present"]: + doc["ver"] = vector["ver"] + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=doc) + + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def _resolver(client: httpx.Client) -> WellKnownEndpointResolver: + return WellKnownEndpointResolver(http=client, ttl=timedelta(hours=1), scheme="https") + + +def test_corpus_partitions_are_nonempty() -> None: + assert len(_ACCEPTED) > 0 + assert len(_REFUSED) > 0 + assert len(_ABSENT) > 0 + + +def test_the_current_constant_is_in_the_accepted_partition() -> None: + assert any(v["ver"] == WellKnownManifestVersion and v["present"] for v in _ACCEPTED) + + +@pytest.mark.parametrize("vector", _VECTORS, ids=[v["name"] for v in _VECTORS]) +def test_pure_rule_matches_go_oracle(vector: dict) -> None: + refusal = manifest_version_refusal(_ver_of(vector)) + assert (refusal is None) == vector["accepted"], refusal + + +@pytest.mark.parametrize("vector", _ACCEPTED, ids=[v["name"] for v in _ACCEPTED]) +def test_resolver_accepts_and_reads_the_endpoint(vector: dict) -> None: + assert _resolver(_serving(vector)).resolve_endpoint(_HOST) == _ENDPOINT + + +@pytest.mark.parametrize("vector", _REFUSED, ids=[v["name"] for v in _REFUSED]) +def test_resolver_refuses_with_the_version_verdict(vector: dict) -> None: + with pytest.raises(ManifestVersionRefusedError) as excinfo: + _resolver(_serving(vector)).resolve_endpoint(_HOST) + # A refused document's endpoint is usable, so the refusal is the version gate + # and no other verdict class. + assert not isinstance(excinfo.value, NoEndpointError | EndpointRefusedError) + + +def test_version_gate_precedes_the_endpoint_gate() -> None: + """A wrong-version manifest that ALSO advertises nothing reports the version.""" + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"ver": "2.0", "role": "ROLE_EXCHANGE"}) + + r = _resolver(httpx.Client(transport=httpx.MockTransport(handler))) + with pytest.raises(ManifestVersionRefusedError): + r.resolve_endpoint(_HOST) + + +def test_a_refusal_is_not_cached() -> None: + """Once the origin serves an acceptable document the next resolve succeeds.""" + served: list[str] = ["2.0"] + hits = 0 + + def handler(_request: httpx.Request) -> httpx.Response: + nonlocal hits + hits += 1 + return httpx.Response( + 200, json={"ver": served[0], "role": "ROLE_EXCHANGE", "endpoint": _ENDPOINT} + ) + + r = _resolver(httpx.Client(transport=httpx.MockTransport(handler))) + with pytest.raises(ManifestVersionRefusedError): + r.resolve_endpoint(_HOST) + served[0] = WellKnownManifestVersion + assert r.resolve_endpoint(_HOST) == _ENDPOINT + assert hits == 2 diff --git a/sdk/python/tests/test_parity_corpora_nonempty.py b/sdk/python/tests/test_parity_corpora_nonempty.py index 31802942..df8d7333 100644 --- a/sdk/python/tests/test_parity_corpora_nonempty.py +++ b/sdk/python/tests/test_parity_corpora_nonempty.py @@ -89,6 +89,11 @@ def _whole(doc: Any) -> Any: (GO_TESTDATA / "host-rule-vectors.json", lambda d: d["host_of"], "host-of"), (GO_TESTDATA / "host-rule-vectors.json", lambda d: d["is_bare_host"], "is-bare-host"), (GO_TESTDATA / "host-rule-vectors.json", lambda d: d["host_anchored"], "host-anchored"), + ( + GO_TESTDATA / "manifest-version-vectors.json", + lambda d: d["manifest_version"], + "manifest-version", + ), ( GO_RESOLVERS_TESTDATA / "endpoint-vet-vectors.json", lambda d: d["endpoint_vet"], diff --git a/sdk/python/tests/test_wire_parity.py b/sdk/python/tests/test_wire_parity.py index c8a96c6c..0717e9df 100644 --- a/sdk/python/tests/test_wire_parity.py +++ b/sdk/python/tests/test_wire_parity.py @@ -30,6 +30,7 @@ "ConnectProtocolVersionHeader": "ConnectProtocolVersionHeader", "ConnectProtocolVersion": "ConnectProtocolVersion", "ProtocolVersion": "ProtocolVersion", + "WellKnownManifestVersion": "WellKnownManifestVersion", "RequestIDHeader": "RequestIDHeader", "SignatureAgentHeader": "SignatureAgentHeader", } diff --git a/sdk/ts/client/route.ts b/sdk/ts/client/route.ts index 364dd8ae..7067d75b 100644 --- a/sdk/ts/client/route.ts +++ b/sdk/ts/client/route.ts @@ -11,7 +11,7 @@ // own manifest and then checked, rather than taken on trust or, worse, read from // configuration. -import { EndpointRefused, NoEndpoint } from "./../resolvers/errors.ts"; +import { EndpointRefused, ManifestVersionRefused, NoEndpoint } from "./../resolvers/errors.ts"; import { endpointRefusal } from "../src/endpoint-rule.ts"; import { isBareHost } from "../src/hosts.ts"; import { redactUserinfo } from "../src/host-ref.ts"; @@ -25,10 +25,11 @@ import { RampCallError, notSent } from "./errors.ts"; * * An implementation's FAILURE decides how a caller is told to react, so it is part of the * contract rather than an implementation detail. A failure that is a VERDICT — the host - * is unusable, the host is not allowed, the manifest advertises no endpoint, or it - * advertises one that must not be used — MUST throw the resolver tier's NoEndpoint or - * EndpointRefused, or the invalid-host error isBareHost raises; those surface as - * `not_sent`, which tells the caller not to retry. Anything else is read as a transport + * is unusable, the host is not allowed, the manifest carries a version this reader does + * not accept, advertises no endpoint, or advertises one that must not be used — MUST + * throw the resolver tier's ManifestVersionRefused, NoEndpoint or EndpointRefused, or + * the invalid-host error isBareHost raises; those surface as `not_sent`, which tells + * the caller not to retry. Anything else is read as a transport * failure and reported as `unreachable`, i.e. worth retrying. An implementation that * throws a bare error for a refusal therefore has its final answer retried indefinitely. */ @@ -137,7 +138,13 @@ export async function vetExchangeEndpoint( // is not a host will not become one on a later attempt. This module checks it before // resolving, so the SDK's own resolver never reaches here that way — an injected one can. function isVerdict(cause: unknown): boolean { - if (cause instanceof NoEndpoint || cause instanceof EndpointRefused) return true; + if ( + cause instanceof ManifestVersionRefused || + cause instanceof NoEndpoint || + cause instanceof EndpointRefused + ) { + return true; + } // The invalid-host refusal is matched on its wording rather than its type. The host // helpers are L1 and deliberately raise a bare error for it — a typed InvalidHost // would cross the helpers-versus-resolvers sentinel split the SDK documents — so the diff --git a/sdk/ts/resolvers/errors.ts b/sdk/ts/resolvers/errors.ts index 92a9b03e..c1db62f9 100644 --- a/sdk/ts/resolvers/errors.ts +++ b/sdk/ts/resolvers/errors.ts @@ -86,3 +86,20 @@ export class EndpointRefused extends ResolverError { this.name = "EndpointRefused"; } } + +/** A /.well-known/ramp.json was fetched and parsed but carries a + * WellKnownManifest.ver this resolver does not accept: an unrecognised major + * version, a value that is not MAJOR.MINOR, or no version at all. The rule is + * manifestVersionRefusal in src/wire.ts. + * + * Checked BEFORE any other member of the document is read. The manifest sits at + * a fixed, unversioned path and is read before any signature is checked, so a + * layout the reader cannot classify must not supply the endpoint a signed call + * is then sent to. Like EndpointRefused it is a VERDICT — final, not a transport + * failure to retry — and it is never cached. */ +export class ManifestVersionRefused extends ResolverError { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "ManifestVersionRefused"; + } +} diff --git a/sdk/ts/resolvers/index.ts b/sdk/ts/resolvers/index.ts index 282e513c..b7c58eb1 100644 --- a/sdk/ts/resolvers/index.ts +++ b/sdk/ts/resolvers/index.ts @@ -11,6 +11,7 @@ export { EndpointRefused, KeyExpired, KeyRevoked, + ManifestVersionRefused, NoEndpoint, ResolverError, RevocationUnevaluated, diff --git a/sdk/ts/resolvers/wellknown.ts b/sdk/ts/resolvers/wellknown.ts index bb9be4dc..df29dffe 100644 --- a/sdk/ts/resolvers/wellknown.ts +++ b/sdk/ts/resolvers/wellknown.ts @@ -2,12 +2,16 @@ // kid-keyed) and WellKnownEndpointResolver (host-keyed ramp.json → endpoint). // Both port the Go oracle's lazy-fetch + TTL-cache + single-flight coalescing and // its fail-closed taxonomy: a fetch/decode failure throws DirectoryUnavailable; -// an unknown kid is `undefined`; a manifest with no endpoint throws NoEndpoint. +// an unknown kid is `undefined`; a manifest whose `ver` is not a recognised major +// (or is absent) throws ManifestVersionRefused before anything else is read — on +// the endpoint face only, the key face reads a JWKS document that carries no +// manifest version; a manifest with no endpoint throws NoEndpoint. import { endpointRefusal } from "../src/endpoint-rule.ts"; import { invalidHost } from "../src/host-ref.ts"; import { isBareHost } from "../src/hosts.ts"; -import { DirectoryUnavailable, EndpointRefused, NoEndpoint } from "./errors.ts"; +import { manifestVersionRefusal } from "../src/wire.ts"; +import { DirectoryUnavailable, EndpointRefused, ManifestVersionRefused, NoEndpoint } from "./errors.ts"; import { type FetchLike, defaultFetch, fetchStrict } from "./http.ts"; import { ed25519KeysFromJwks } from "./jwks.ts"; @@ -183,6 +187,12 @@ class EndpointResolverImpl implements WellKnownEndpointResolver { } catch (err) { throw new DirectoryUnavailable(`manifest decode ${url}`, { cause: err }); } + // The document version gate runs before the endpoint is so much as looked + // at, and never caches: the next resolve fetches again. + const versionRefusal = manifestVersionRefusal((doc as { ver?: unknown } | null)?.ver); + if (versionRefusal !== undefined) { + throw new ManifestVersionRefused(versionRefusal); + } const endpoint = (doc as { endpoint?: unknown }).endpoint; if (typeof endpoint !== "string" || endpoint === "") { throw new NoEndpoint(`host=${host}`); diff --git a/sdk/ts/src/wire.ts b/sdk/ts/src/wire.ts index 4f26a71a..760d22e1 100644 --- a/sdk/ts/src/wire.ts +++ b/sdk/ts/src/wire.ts @@ -3,7 +3,10 @@ // Content-Type (ADR-020): application/proto for binary, application/json for // canonical proto-JSON. The Go layer splits RequestIDHeader across // helpers/constants.go and core/requestid.go; the single TS module exposes all -// seven values once. Pinned to wire-constants-vectors.json. +// eight values once. Pinned to wire-constants-vectors.json. +// +// Also home to the pure receive-side rule for WellKnownManifest.ver, so the +// constant and the check that reads it sit together. /** ContentTypeProto is the Content-Type for binary protobuf bodies. */ export const ContentTypeProto = "application/proto"; @@ -17,10 +20,73 @@ export const ConnectProtocolVersion = "1"; * ProtocolVersion is the RAMP protocol version stamped on the `ver` field of * every RAMP message — NOT the Connect transport version above. Senders stamp * it from here so a protocol bump is a single edit; receivers treat `ver` as - * advisory. + * advisory. The /.well-known/ramp.json document carries its own version in a + * separate namespace, which this constant does NOT supply — that is + * WellKnownManifestVersion. */ export const ProtocolVersion = "1.0"; +/** + * WellKnownManifestVersion is the version of the /.well-known/ramp.json DOCUMENT + * layout, stamped on WellKnownManifest.ver by every party that serves one. A + * namespace separate from ProtocolVersion and never derived from it: a change + * to the manifest layout bumps both numbers, a protocol change that leaves the + * manifest untouched bumps only ProtocolVersion. The two read the same today by + * coincidence, not by rule. The receive-side check a manifest reader applies is + * manifestVersionRefusal. + */ +export const WellKnownManifestVersion = "1.0"; /** RequestIDHeader correlates a request across services and the edge. */ export const RequestIDHeader = "X-Request-ID"; /** SignatureAgentHeader carries the signer's Web Bot Auth key-directory URL. */ export const SignatureAgentHeader = "Signature-Agent"; + +const ASCII_DIGITS = /^[0-9]+$/; + +/** The MAJOR run of a MAJOR.MINOR string, or undefined when `ver` is not one. + * Both runs must be non-empty ASCII digits joined by exactly one dot; a missing + * minor, a patch component, a leading "v", surrounding whitespace or a non-digit + * is not a version this rule recognises. */ +function parseMajor(ver: string): string | undefined { + const dot = ver.indexOf("."); + if (dot < 0) return undefined; + const major = ver.slice(0, dot); + const minor = ver.slice(dot + 1); + if (!ASCII_DIGITS.test(major) || !ASCII_DIGITS.test(minor)) return undefined; + return major; +} + +/** + * The receive-side rule for WellKnownManifest.ver, as a pure verdict. + * + * Returns undefined when the document is accepted — its MAJOR equals the major + * of WellKnownManifestVersion, whatever the MINOR, because a minor revision of + * the manifest is additive by definition and a reader ignores members it does + * not know. Otherwise returns the reason: an unrecognised major, a value that is + * not MAJOR.MINOR, or an absent member (undefined, null, or any non-string is + * how a missing `ver` arrives from JSON.parse). Absent is refused rather than + * tolerated because the field is required by the wire shape and a document with + * no version is one whose layout the reader cannot classify; the manifest sits + * at a fixed, unversioned path and is read before any signature is checked, so + * the gate fails closed. + * + * The message names the value found so an operator can tell a version mismatch + * from a network failure. The three SDK languages pin this verdict to a shared + * corpus. The error a resolver throws for a refusal is ManifestVersionRefused. + */ +export function manifestVersionRefusal(ver: unknown): string | undefined { + const acceptMajor = parseMajor(WellKnownManifestVersion); + if (acceptMajor === undefined) { + throw new Error(`WellKnownManifestVersion is not MAJOR.MINOR: ${WellKnownManifestVersion}`); + } + if (typeof ver !== "string" || ver === "") { + return `ver is absent, accept major ${acceptMajor}`; + } + const major = parseMajor(ver); + if (major === undefined) { + return `ver ${JSON.stringify(ver)} is not MAJOR.MINOR, accept major ${acceptMajor}`; + } + if (major !== acceptMajor) { + return `ver ${JSON.stringify(ver)} has major ${major}, accept major ${acceptMajor}`; + } + return undefined; +} diff --git a/sdk/ts/tests/endpoint-vet.parity.test.ts b/sdk/ts/tests/endpoint-vet.parity.test.ts index 7aad2388..a8462018 100644 --- a/sdk/ts/tests/endpoint-vet.parity.test.ts +++ b/sdk/ts/tests/endpoint-vet.parity.test.ts @@ -21,6 +21,7 @@ import { createWellKnownEndpointResolver, EndpointRefused } from "../resolvers/i import { isBareHost } from "../src/hosts.ts"; import type { FetchLike } from "../resolvers/http.ts"; import vectorsFile from "../../go/resolvers/testdata/endpoint-vet-vectors.json"; +import { WellKnownManifestVersion } from "../src/wire.ts"; type EndpointVetVector = { name: string; host: string; endpoint: string; refused: boolean }; type EndpointVetVectorsFile = { endpoint_vet: EndpointVetVector[] }; @@ -31,7 +32,8 @@ const doc = vectorsFile as EndpointVetVectorsFile; function servingManifest(endpoint: string): FetchLike { return async () => ({ status: 200, - text: async () => JSON.stringify({ role: "ROLE_EXCHANGE", endpoint }), + text: async () => + JSON.stringify({ ver: WellKnownManifestVersion, role: "ROLE_EXCHANGE", endpoint }), }); } diff --git a/sdk/ts/tests/hosts-credential-redaction.test.ts b/sdk/ts/tests/hosts-credential-redaction.test.ts index fdd4efcd..85cc8367 100644 --- a/sdk/ts/tests/hosts-credential-redaction.test.ts +++ b/sdk/ts/tests/hosts-credential-redaction.test.ts @@ -20,6 +20,7 @@ import { describe, expect, it } from "vitest"; import { hostAnchored, hostOf } from "../src/hosts.ts"; import { createWellKnownEndpointResolver } from "../resolvers/index.ts"; import type { FetchLike } from "../resolvers/http.ts"; +import { WellKnownManifestVersion } from "../src/wire.ts"; const SECRET = "s3cr3t"; @@ -50,7 +51,8 @@ const credentialRefs = [ const serving = (endpoint: string): FetchLike => async () => ({ status: 200, - text: async () => JSON.stringify({ role: "ROLE_EXCHANGE", endpoint }), + text: async () => + JSON.stringify({ ver: WellKnownManifestVersion, role: "ROLE_EXCHANGE", endpoint }), }); /** The message of whatever `call` throws, or undefined when it does not. */ diff --git a/sdk/ts/tests/manifest-version.parity.test.ts b/sdk/ts/tests/manifest-version.parity.test.ts new file mode 100644 index 00000000..014068fd --- /dev/null +++ b/sdk/ts/tests/manifest-version.parity.test.ts @@ -0,0 +1,113 @@ +// Replay of the shared manifest-version corpus, driven two ways. +// +// The pure rule (manifestVersionRefusal) is replayed row by row against the Go +// emitter's verdicts. Then the same rows are driven through the REAL endpoint +// resolver against a stub manifest server, so the test proves the resolver +// applies the rule before it reads the endpoint — a refused document's endpoint +// is a perfectly usable one, so the refusal can only be the version gate. +// +// `present` is its own column because an absent member is not a string: the +// resolver reads `doc.ver` and gets undefined, which is the path the `absent` +// row must exercise. Pinned to manifest-version-vectors.json. + +import { describe, expect, it } from "vitest"; +import vectorsFile from "../../go/helpers/testdata/manifest-version-vectors.json"; +import { + EndpointRefused, + ManifestVersionRefused, + NoEndpoint, + createWellKnownEndpointResolver, +} from "../resolvers/index.ts"; +import type { FetchLike } from "../resolvers/http.ts"; +import { WellKnownManifestVersion, manifestVersionRefusal } from "../src/wire.ts"; + +type ManifestVersionVector = { name: string; ver: string; present: boolean; accepted: boolean }; +type ManifestVersionVectorsFile = { manifest_version: ManifestVersionVector[] }; + +const doc = vectorsFile as ManifestVersionVectorsFile; +const vectors = doc.manifest_version; +const accepted = vectors.filter((v) => v.accepted); +const refused = vectors.filter((v) => !v.accepted); +const absent = vectors.filter((v) => !v.present); + +const HOST = "exchange.example"; +const ENDPOINT = `https://${HOST}/ramp.v1.ExchangeService`; + +/** The value the resolver sees: the string when present, undefined when absent. */ +const verOf = (v: ManifestVersionVector): string | undefined => (v.present ? v.ver : undefined); + +/** A manifest server that answers with the vector's ver and a usable endpoint. */ +function serving(body: Record): FetchLike { + return async () => ({ status: 200, text: async () => JSON.stringify(body) }); +} + +function manifestFor(v: ManifestVersionVector): Record { + const body: Record = { role: "ROLE_EXCHANGE", endpoint: ENDPOINT }; + if (v.present) body.ver = v.ver; + return body; +} + +const resolverOver = (fetchFn: FetchLike) => + createWellKnownEndpointResolver({ fetch: fetchFn, ttlMs: 3_600_000, scheme: "https" }); + +describe("manifest-version corpus", () => { + it("every partition is non-empty", () => { + expect(accepted.length).toBeGreaterThan(0); + expect(refused.length).toBeGreaterThan(0); + expect(absent.length).toBeGreaterThan(0); + }); + + it("the current constant is in the accepted partition", () => { + expect(accepted.some((v) => v.present && v.ver === WellKnownManifestVersion)).toBe(true); + }); + + for (const v of vectors) { + it(`pure rule: ${v.name} → accepted=${v.accepted}`, () => { + const refusal = manifestVersionRefusal(verOf(v)); + expect(refusal === undefined, refusal).toBe(v.accepted); + }); + } + + for (const v of accepted) { + it(`resolver accepts and reads the endpoint: ${v.name}`, async () => { + await expect(resolverOver(serving(manifestFor(v))).resolveEndpoint(HOST)).resolves.toBe( + ENDPOINT, + ); + }); + } + + for (const v of refused) { + it(`resolver refuses with the version verdict: ${v.name}`, async () => { + const err = await resolverOver(serving(manifestFor(v))) + .resolveEndpoint(HOST) + .then(() => undefined, (e: unknown) => e); + expect(err).toBeInstanceOf(ManifestVersionRefused); + // A refused document's endpoint is usable, so the refusal is the version + // gate and no other verdict class. + expect(err).not.toBeInstanceOf(NoEndpoint); + expect(err).not.toBeInstanceOf(EndpointRefused); + }); + } + + it("the version gate precedes the endpoint gate", async () => { + const r = resolverOver(serving({ ver: "2.0", role: "ROLE_EXCHANGE" })); + await expect(r.resolveEndpoint(HOST)).rejects.toBeInstanceOf(ManifestVersionRefused); + }); + + it("a refusal is not cached", async () => { + let served = "2.0"; + let hits = 0; + const fetchFn: FetchLike = async () => { + hits += 1; + return { + status: 200, + text: async () => JSON.stringify({ ver: served, role: "ROLE_EXCHANGE", endpoint: ENDPOINT }), + }; + }; + const r = resolverOver(fetchFn); + await expect(r.resolveEndpoint(HOST)).rejects.toBeInstanceOf(ManifestVersionRefused); + served = WellKnownManifestVersion; + await expect(r.resolveEndpoint(HOST)).resolves.toBe(ENDPOINT); + expect(hits).toBe(2); + }); +}); diff --git a/sdk/ts/tests/resolvers-harness.ts b/sdk/ts/tests/resolvers-harness.ts index 61c2f06b..de1d8d65 100644 --- a/sdk/ts/tests/resolvers-harness.ts +++ b/sdk/ts/tests/resolvers-harness.ts @@ -17,6 +17,7 @@ import type { AddressInfo } from "node:net"; import { WBAFileSchema } from "../../../gen/ts/wire/schemas.ts"; import { decodeBase64Url } from "../src/base64url.ts"; import { thumbprint } from "../src/thumbprint.ts"; +import { WellKnownManifestVersion } from "../src/wire.ts"; // Well-known paths the origin serves. The WBA directory path is the fixed Web // Bot Auth path; the JWKS key doc and the endpoint manifest sit on distinct @@ -92,7 +93,7 @@ export function jwksEntry(kid: string, x: string): Record { /** Serialize a WellKnownManifest projection ({role, endpoint}); omit endpoint to * model a valid-but-inert manifest. */ export function manifestJson(endpoint?: string): string { - const doc: Record = { role: "ROLE_EXCHANGE" }; + const doc: Record = { ver: WellKnownManifestVersion, role: "ROLE_EXCHANGE" }; if (endpoint !== undefined) doc.endpoint = endpoint; return JSON.stringify(doc); } diff --git a/sdk/ts/tests/wire.parity.test.ts b/sdk/ts/tests/wire.parity.test.ts index 31fc2f90..e209f6e3 100644 --- a/sdk/ts/tests/wire.parity.test.ts +++ b/sdk/ts/tests/wire.parity.test.ts @@ -28,6 +28,7 @@ const exportFor: Record = { ConnectProtocolVersionHeader: "ConnectProtocolVersionHeader", ConnectProtocolVersion: "ConnectProtocolVersion", ProtocolVersion: "ProtocolVersion", + WellKnownManifestVersion: "WellKnownManifestVersion", RequestIDHeader: "RequestIDHeader", SignatureAgentHeader: "SignatureAgentHeader", }; diff --git a/website/src/content/docs/protocol/exchange-manifest.mdx b/website/src/content/docs/protocol/exchange-manifest.mdx index 4df0be0e..4a6d3f3c 100644 --- a/website/src/content/docs/protocol/exchange-manifest.mdx +++ b/website/src/content/docs/protocol/exchange-manifest.mdx @@ -103,7 +103,7 @@ The Exchange's offer-signing keys are **not** carried in `ramp.json`. They live | Field | Type | Required | Description | |---|---|---|---| -| `ver` | string | Yes | Schema version of this manifest **document** — a namespace separate from the RPC envelope `ver`, not coupled to it. MUST equal `"1.0"`; consumers reject unrecognised major versions | +| `ver` | string | Yes | Version of this manifest **document**'s layout — `"1.0"`, stamped from the SDK's `WellKnownManifestVersion`, a namespace separate from the RPC envelope `ver` and never derived from it. A manifest layout change bumps both numbers; a protocol change that leaves the manifest untouched bumps only the envelope's. Consumers read it before any other member: a recognised major is accepted whatever the minor, an unrecognised major, a malformed value, or an absent `ver` is refused | | `role` | string | Yes | `ROLE_EXCHANGE` for an Exchange manifest | | `domain` | string | Yes | Canonical domain of this Exchange | | `name` | string | No | Human-readable Exchange name | diff --git a/website/src/content/docs/reference/changelog.mdx b/website/src/content/docs/reference/changelog.mdx index 9de8a564..a4d04298 100644 --- a/website/src/content/docs/reference/changelog.mdx +++ b/website/src/content/docs/reference/changelog.mdx @@ -8,6 +8,35 @@ and protocol history, see [`proto/CHANGELOG.md`](https://github.com/RAMP-Protoco ## Unreleased +**SDK endpoint resolvers enforce `WellKnownManifest.ver`, and the manifest version +gets its own constant (SDK behaviour change; no wire change).** The +`/.well-known/ramp.json` document's `ver` has stated a consumer-side rule since the +manifest was unified, and no SDK applied it: the Go, Python and TypeScript endpoint +resolvers decoded the endpoint and dropped `ver` unread, so a manifest at any +version, or with no version at all, was accepted. The rule is now stated in full and +enforced. A consumer reads `ver` before any other member of the document, accepts a +recognised **major** version whatever the minor (a minor revision of the manifest is +additive, and a reader ignores members it does not know), and refuses an +unrecognised major, a value that is not `MAJOR.MINOR`, or an absent `ver` — the +document sits at a fixed, unversioned path and is read before any signature is +checked, so a layout the reader cannot classify must not supply the endpoint a +signed call is then sent to. The refusal is a verdict, surfaced as +`resolvers.ErrManifestVersionRefused` / `ManifestVersionRefusedError` / +`ManifestVersionRefused`, classified `not sent` by the client tier like the other +resolver verdicts, and never cached. It applies to the endpoint face only: the +well-known key resolver reads a JWKS document that carries no manifest version. +The value has one owner, `helpers.WellKnownManifestVersion` (`WellKnownManifestVersion` +in Python and TypeScript), pinned to the wire-constants corpus beside +`ProtocolVersion` and deliberately not derived from it — the two are separate +namespaces, and the coupling runs one way: a manifest layout change bumps both +numbers, a protocol change that leaves the manifest untouched bumps only +`ProtocolVersion`. A new shared corpus, `manifest-version-vectors.json`, pins the +verdict across the three languages, and a conformance guard holds the proto +comment to every clause the corpus exercises. **Compatibility:** a manifest served +without `ver` was accepted before this change and is refused after it. The field +has been required by the wire shape since the unified manifest was introduced, so +a conforming producer already stamps it. + **`TransactionRequest.agent_request_acceptance` adds an agent-signed proof of the complete ordered request set (additive wire change).** The agent signs the ordered list of `(offer signature, exchange)` references it committed to, plus diff --git a/website/src/content/docs/reference/proto-ramp.mdx b/website/src/content/docs/reference/proto-ramp.mdx index 85c99a6e..8af5aa4e 100644 --- a/website/src/content/docs/reference/proto-ramp.mdx +++ b/website/src/content/docs/reference/proto-ramp.mdx @@ -7,7 +7,7 @@ description: "RAMP v1.0 protobuf reference -- ExchangeService, CatalogService, a Source: [`proto/ramp/v1/ramp.proto`](https://github.com/RAMP-Protocol/protocol/blob/main/proto/ramp/v1/ramp.proto) :::note[Envelope and correlation] -Every RPC request/response carries `ver` (field 1, RAMP protocol version). Its value is `"1.0"`, and senders MUST stamp it — from the SDK's `ProtocolVersion` constant rather than a literal, so a protocol bump is a single edit. On receive `ver` is **advisory**: it is not an authenticity or authorization control and MUST NOT be used as one, a receiver is not required to check it, and one that does check it MAY reject an unrecognised **major** version but MUST NOT reject an unrecognised **minor** version. Version negotiation, where it is needed, happens out of band — an Exchange publishes `WellKnownManifest.protocol_versions_supported` and a Broker filters on it before sending — which is why the in-band field need not be a rejection gate. It carries no protovalidate rule. `WellKnownManifest.ver` is a separate namespace: it versions the `/.well-known/ramp.json` document schema, is stated MUST-equal, and is not coupled to the envelope value. The external-contract messages (domain verification, catalog push) also carry `ext` (field 15) and `ext_critical` (field 90, COSE `crit`, RFC 9052). +Every RPC request/response carries `ver` (field 1, RAMP protocol version). Its value is `"1.0"`, and senders MUST stamp it — from the SDK's `ProtocolVersion` constant rather than a literal, so a protocol bump is a single edit. On receive `ver` is **advisory**: it is not an authenticity or authorization control and MUST NOT be used as one, a receiver is not required to check it, and one that does check it MAY reject an unrecognised **major** version but MUST NOT reject an unrecognised **minor** version. Version negotiation, where it is needed, happens out of band — an Exchange publishes `WellKnownManifest.protocol_versions_supported` and a Broker filters on it before sending — which is why the in-band field need not be a rejection gate. It carries no protovalidate rule. `WellKnownManifest.ver` is a separate namespace: it versions the `/.well-known/ramp.json` document's layout, is stamped from the SDK's `WellKnownManifestVersion` rather than `ProtocolVersion`, and is gated by consumers on its major version — a recognised major is accepted whatever the minor, and an unrecognised major, a malformed value or an absent `ver` is refused. The coupling runs one way: a manifest layout change bumps both numbers, a protocol change that leaves the manifest untouched bumps only the envelope's. The external-contract messages (domain verification, catalog push) also carry `ext` (field 15) and `ext_critical` (field 90, COSE `crit`, RFC 9052). Request correlation is **not** carried in the proto body. It rides on the `X-Request-ID` HTTP header, and cross-system tracing uses W3C Trace Context (`traceparent` / `tracestate`). The former `request_id` fields have been removed from every message. ::: diff --git a/website/src/content/docs/reference/ramp-json-example.mdx b/website/src/content/docs/reference/ramp-json-example.mdx index 677ea5d8..8e0d3892 100644 --- a/website/src/content/docs/reference/ramp-json-example.mdx +++ b/website/src/content/docs/reference/ramp-json-example.mdx @@ -77,7 +77,7 @@ Signing keys are **not** carried in `ramp.json`. Each participant publishes its | Field | Type | Required | Description | |---|---|---|---| -| `ver` | string | Yes | Schema version of this manifest **document** — a namespace separate from the RPC envelope `ver`, not coupled to it. MUST equal `"1.0"`; consumers reject unrecognised major versions | +| `ver` | string | Yes | Version of this manifest **document**'s layout — `"1.0"`, stamped from the SDK's `WellKnownManifestVersion`, a namespace separate from the RPC envelope `ver` and never derived from it. A manifest layout change bumps both numbers; a protocol change that leaves the manifest untouched bumps only the envelope's. Consumers read it before any other member: a recognised major is accepted whatever the minor, an unrecognised major, a malformed value, or an absent `ver` is refused | | `role` | string | Yes | Participant role — `ROLE_PUBLISHER` for a provider's manifest | | `domain` | string | Yes | Canonical domain serving this manifest | | `contact` | string | No | Contact email for licensing inquiries | From b741fe081bfc03951c9407579ac7f01c60dd10fa Mon Sep 17 00:00:00 2001 From: noxlesh Date: Fri, 4 Sep 2026 15:21:58 +0300 Subject: [PATCH 2/2] fix(sdk): one manifest-version sentinel, a non-string ver refused as a verdict, and the key-face exemption pinned by a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review rounds of the manifest-version gate found the same shape three times: a decision recorded as prose where a test should have held it. The key-face exemption was one. The endpoint resolvers gate on ver and the well-known key resolvers do not, because a key document is a plain JWK Set that carries no manifest version — but no test in any language served a key document with a ver the endpoint face would refuse and checked that keys still resolve, so moving the gate into the shared fetch passed every test. Each language now has that test. The comments that still described the pre-branch shape are corrected with it: wellKnownDoc no longer claims a key set at proto field 5 (there is none) or one body serving both faces, and the key resolver no longer says it reads ramp.json keys. A non-string ver was the second. Go decoded ver into a string, so a document carrying `"ver": 1` failed the decode and surfaced as a transport error the client tier retried; Python and TypeScript treat any non-string as absent and return the final verdict. Go now holds ver raw and reads it through manifestVer, which yields the absent value for anything that is not a JSON string, so the three languages return the same verdict and the client tier classifies it not-sent everywhere. A resolver-level test in each language serves a number, a null and an object and asserts the verdict — and, in Go, that it is reported as absent rather than spelled from the raw bytes. The not-sent classification in Python and TypeScript was the third: Go had a taxonomy test for the new verdict and the two ports did not, so reverting either arm left every test green. Both now have one, in the shape of the existing injected-resolver tests beside them. Go declared the sentinel twice — helpers.ErrManifestVersionRefused and a separate resolvers.ErrManifestVersionRefused, joined by a double wrap — and the client route matched only the second, so an injected resolver wrapping the helper sentinel was retried forever. The resolvers name is now a re-export of the helper sentinel, the shape ErrUnknownKey already uses, and the wrap is single. Both symbol-map rows stay, as they do for ErrUnknownKey. Smaller things the same rounds caught. The refused ver is clipped to 64 characters before it is echoed into the error message, in all three languages: the body is read up to 1 MiB and a refusal is never cached, so an unclipped echo let a hostile origin size every error. The rationale paragraph that had been copied into seven source sites now lives on WellKnownManifest.ver in the proto, and each SDK site says only what it does and points there. The "read the same by coincidence, not by rule" sentence beside the one-way coupling rule is reworded so the two no longer read as a contradiction. A stale Python cross-reference, a "two sentinels" count in the Go README, and "seven wire constants" in two replay tests are corrected. The conformance ver-contract guard reads the wire-constants vector once through one parameterised lookup instead of two copies of the reader, the Python and TypeScript manifest builders take a ver parameter so the parity tests no longer hand-build the document, and a tautological isinstance assertion after a pytest.raises is dropped. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Mc78kH8LccesLjakv77nmK --- conformance/ver_field_contract_test.go | 71 +++++++++---------- proto/CHANGELOG.md | 7 +- sdk/go/README.md | 13 ++-- sdk/go/helpers/constants.go | 7 +- sdk/go/helpers/manifestversion.go | 34 ++++++--- sdk/go/resolvers/endpointresolver.go | 64 +++++++++++------ .../endpointresolver_version_test.go | 34 +++++++++ sdk/go/resolvers/wellknownkeyresolver.go | 8 ++- sdk/go/resolvers/wellknownkeyresolver_test.go | 27 +++++++ sdk/python/ramp_sdk/resolvers/errors.py | 9 ++- sdk/python/ramp_sdk/wire.py | 41 ++++++----- sdk/python/tests/resolvers_harness.py | 13 ++-- sdk/python/tests/test_client.py | 24 ++++++- .../tests/test_manifest_version_parity.py | 29 +++----- .../test_resolvers_wellknown_integration.py | 34 +++++++++ sdk/python/tests/test_wire_parity.py | 4 +- sdk/ts/resolvers/errors.ts | 9 ++- sdk/ts/src/wire.ts | 41 ++++++----- sdk/ts/tests/client.test.ts | 24 +++++++ sdk/ts/tests/manifest-version.parity.test.ts | 42 ++++------- sdk/ts/tests/resolvers-harness.ts | 10 +-- .../resolvers-wellknown.integration.test.ts | 33 +++++++++ sdk/ts/tests/wire.parity.test.ts | 6 +- .../src/content/docs/reference/changelog.mdx | 6 +- 24 files changed, 403 insertions(+), 187 deletions(-) diff --git a/conformance/ver_field_contract_test.go b/conformance/ver_field_contract_test.go index 5db6b5a9..bb8cd01b 100644 --- a/conformance/ver_field_contract_test.go +++ b/conformance/ver_field_contract_test.go @@ -37,7 +37,7 @@ package conformance import ( "encoding/json" - "errors" + "fmt" "os" "path/filepath" "regexp" @@ -69,14 +69,13 @@ var verExemptMessages = map[string]string{ // generator. Read as data (see the file header on why this is not an import). const wireConstantsVectors = "../sdk/go/helpers/testdata/wire-constants-vectors.json" -// protocolVersion returns the RAMP protocol version the SDK exports, read from the -// committed vector so this guard never restates the literal. Errors are fatal -// rather than skipped: a missing file or entry means the guard has lost its -// anchor, and silently passing would be worse than failing. -var protocolVersion = sync.OnceValues(func() (string, error) { +// wireVectors loads the committed vector once. Errors are fatal rather than +// skipped: a missing file means the guard has lost its anchor, and silently +// passing would be worse than failing. +var wireVectors = sync.OnceValues(func() (map[string]string, error) { b, err := os.ReadFile(wireConstantsVectors) if err != nil { - return "", err + return nil, err } var doc struct { Vectors []struct { @@ -85,47 +84,41 @@ var protocolVersion = sync.OnceValues(func() (string, error) { } `json:"vectors"` } if err := json.Unmarshal(b, &doc); err != nil { - return "", err + return nil, err } + byName := make(map[string]string, len(doc.Vectors)) for _, v := range doc.Vectors { - if v.Name == "ProtocolVersion" { - return v.Value, nil - } + byName[v.Name] = v.Value } - return "", errNoProtocolVersionVector + return byName, nil }) -// manifestVersion reads the SDK's WellKnownManifestVersion the same way. It is a -// separate read on purpose: the two constants are separate namespaces, and a -// guard that checked the manifest comment against ProtocolVersion would be the -// coupling the contract forbids, written into the thing that guards it. -var manifestVersion = sync.OnceValues(func() (string, error) { - b, err := os.ReadFile(wireConstantsVectors) - if err != nil { - return "", err - } - var doc struct { - Vectors []struct { - Name string `json:"name"` - Value string `json:"value"` - } `json:"vectors"` - } - if err := json.Unmarshal(b, &doc); err != nil { - return "", err - } - for _, v := range doc.Vectors { - if v.Name == "WellKnownManifestVersion" { - return v.Value, nil +// wireConstant returns a reader for one named entry of the vector, so this guard +// never restates a literal. A missing entry is an error for the same reason a +// missing file is. +func wireConstant(name string) func() (string, error) { + return func() (string, error) { + byName, err := wireVectors() + if err != nil { + return "", err } + v, ok := byName[name] + if !ok { + return "", fmt.Errorf("%s carries no %s entry — this guard reads the expected `ver` value from there", + wireConstantsVectors, name) + } + return v, nil } - return "", errNoManifestVersionVector -}) +} var ( - errNoProtocolVersionVector = errors.New( - wireConstantsVectors + " carries no ProtocolVersion entry — this guard reads the expected `ver` value from there") - errNoManifestVersionVector = errors.New( - wireConstantsVectors + " carries no WellKnownManifestVersion entry — this guard reads the manifest's expected `ver` value from there") + // protocolVersion is the RAMP protocol version the SDK exports. + protocolVersion = wireConstant("ProtocolVersion") + // manifestVersion is the SDK's WellKnownManifestVersion. It is a separate + // lookup on purpose: the two constants are separate namespaces, and a guard + // that checked the manifest comment against ProtocolVersion would be the + // coupling the contract forbids, written into the thing that guards it. + manifestVersion = wireConstant("WellKnownManifestVersion") ) // expectedVersionToken is the quoted form a `ver` comment must contain, e.g. `"1.0"`. diff --git a/proto/CHANGELOG.md b/proto/CHANGELOG.md index 55cf27d5..d4ff66be 100644 --- a/proto/CHANGELOG.md +++ b/proto/CHANGELOG.md @@ -13,8 +13,11 @@ bumps only `ProtocolVersion`). Go `helpers.CheckWellKnownManifestVersion` / `ManifestVersionRefusedError`, TS `manifestVersionRefusal` / `ManifestVersionRefused` carry the pure rule; the endpoint resolvers apply it on the endpoint face only and wrap the refusal as `resolvers.ErrManifestVersionRefused` (same name in each -port), which the client tier classifies as not-sent. The key resolvers are -unchanged — they read JWKS documents, not manifests. New corpus +port), which the client tier classifies as not-sent. A `ver` that is not a JSON +string is refused as absent — a verdict, not a decode failure to retry — and the +value a refusal echoes is clipped to 64 characters, in all three languages. The +key resolvers are unchanged — they read JWK Set documents, not manifests — and a +test in each language pins that a key document carrying a `ver` still resolves. New corpus `sdk/go/helpers/testdata/manifest-version-vectors.json` (`manifest_version` list; columns `name`, `ver`, `present`, `accepted`), replayed in all three languages and registered in the parity matrix. `conformance/manifest_version_rule_test.go` diff --git a/sdk/go/README.md b/sdk/go/README.md index c1cddc18..c816cb31 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -220,12 +220,13 @@ import "github.com/RAMP-Protocol/protocol/sdk/go/resolvers" `NewWBAKeyResolver` (WBA directory, revocation/expiry-aware, with a `Run` poller). - **Endpoint resolver** — `NewWellKnownEndpointResolver` discovers an Exchange's own service endpoint (`WellKnownManifest.endpoint`) from `/.well-known/ramp.json`, - host-keyed and cached per host. Two sentinels, and the difference decides whether - a caller should retry: `ErrNoEndpoint` when the manifest was read and advertises - none, `ErrEndpointRefused` when it advertises one this resolver will not hand back - — on a host unrelated to the one that served the manifest, or carrying userinfo. - Both are verdicts, so both are final; anything else is a transport failure and - worth retrying. The key is an offer-supplied host, so the cache evicts + host-keyed and cached per host. Three sentinels, and the difference decides whether + a caller should retry: `ErrManifestVersionRefused` when the manifest was read and + carries a `ver` this reader does not accept (or none at all), `ErrNoEndpoint` when + it advertises no endpoint, `ErrEndpointRefused` when it advertises one this resolver + will not hand back — on a host unrelated to the one that served the manifest, or + carrying userinfo. All three are verdicts, so all three are final; anything else is + a transport failure and worth retrying. The key is an offer-supplied host, so the cache evicts least-recently-used at a fixed cap and concurrent lookups for one host coalesce to a single fetch. - **Active-key selection** — `ActiveEd25519Key` / `ActiveEd25519KeyWithExpiry` pick diff --git a/sdk/go/helpers/constants.go b/sdk/go/helpers/constants.go index a7cb3957..d882aefd 100644 --- a/sdk/go/helpers/constants.go +++ b/sdk/go/helpers/constants.go @@ -26,9 +26,10 @@ const ( // DOCUMENT layout, stamped on WellKnownManifest.ver by every party that serves // one. It is a namespace separate from ProtocolVersion and is never derived // from it: a change to the manifest layout bumps both numbers, a protocol - // change that leaves the manifest untouched bumps only ProtocolVersion. The - // two read the same today by coincidence, not by rule. The receive-side - // check a manifest reader applies is CheckWellKnownManifestVersion. + // change that leaves the manifest untouched bumps only ProtocolVersion. Both + // read "1.0" today because neither has moved yet; neither is derived from + // the other. The receive-side check a manifest reader applies is + // CheckWellKnownManifestVersion. WellKnownManifestVersion = "1.0" // RequestIDHeader correlates a request across services and the edge. RequestIDHeader = "X-Request-ID" diff --git a/sdk/go/helpers/manifestversion.go b/sdk/go/helpers/manifestversion.go index 061fc41d..0f016ebe 100644 --- a/sdk/go/helpers/manifestversion.go +++ b/sdk/go/helpers/manifestversion.go @@ -23,15 +23,18 @@ var ErrManifestVersionRefused = errors.New("helpers: well-known manifest version // MAJOR.MINOR (two runs of ASCII digits joined by one dot), and the empty string, // which is how an absent field arrives. // -// Absent is refused rather than tolerated because the field is required by the -// wire shape and a document with no version is one whose layout the reader cannot -// classify. The manifest sits at a fixed, unversioned path and is read before any -// signature is checked, so the gate fails closed. +// Absent is refused rather than tolerated: the field is required by the wire +// shape, and a document with no version is one whose layout the reader cannot +// classify. Why the gate runs before any other member is read, and fails closed, +// is stated once on WellKnownManifest.ver in the proto. // // The returned error wraps ErrManifestVersionRefused and names the value found, -// so an operator can tell a version mismatch from a network failure. The -// function is pure: the same input always yields the same verdict, and the three -// SDK languages pin that verdict to a shared corpus. +// so an operator can tell a version mismatch from a network failure. The echo is +// clipped to maxEchoedVer bytes: the document body is read up to 1 MiB and a +// refusal is never cached, so an unclipped echo would let a hostile origin size +// every error a resolve produces. The function is pure: the same input always +// yields the same verdict, and the three SDK languages pin that verdict to a +// shared corpus. func CheckWellKnownManifestVersion(ver string) error { acceptMajor := majorOf(WellKnownManifestVersion) if ver == "" { @@ -39,14 +42,27 @@ func CheckWellKnownManifestVersion(ver string) error { } major, ok := parseMajor(ver) if !ok { - return fmt.Errorf("%w: ver %q is not MAJOR.MINOR, accept major %s", ErrManifestVersionRefused, ver, acceptMajor) + return fmt.Errorf("%w: ver %q is not MAJOR.MINOR, accept major %s", ErrManifestVersionRefused, echoVer(ver), acceptMajor) } if major != acceptMajor { - return fmt.Errorf("%w: ver %q has major %s, accept major %s", ErrManifestVersionRefused, ver, major, acceptMajor) + return fmt.Errorf("%w: ver %q has major %s, accept major %s", ErrManifestVersionRefused, echoVer(ver), major, acceptMajor) } return nil } +// maxEchoedVer bounds how much of a refused `ver` the error message repeats. A +// real version is a few bytes; anything longer is only ever going to be shown. +const maxEchoedVer = 64 + +// echoVer is ver clipped for an error message. Python and TS clip at the same +// length, so an operator reading three SDKs' logs sees the same prefix. +func echoVer(ver string) string { + if len(ver) <= maxEchoedVer { + return ver + } + return ver[:maxEchoedVer] + "..." +} + // majorOf is parseMajor over a value this package owns, so a malformed constant // is a programming error rather than a runtime verdict. func majorOf(ver string) string { diff --git a/sdk/go/resolvers/endpointresolver.go b/sdk/go/resolvers/endpointresolver.go index d8c90c03..a81a7731 100644 --- a/sdk/go/resolvers/endpointresolver.go +++ b/sdk/go/resolvers/endpointresolver.go @@ -41,34 +41,54 @@ var ErrNoEndpoint = errors.New("resolvers: well-known manifest has no endpoint") // again in a moment. var ErrEndpointRefused = errors.New("resolvers: well-known manifest advertises an unusable endpoint") -// ErrManifestVersionRefused signals that a /.well-known/ramp.json was fetched and -// parsed but carries a WellKnownManifest.ver this resolver does not accept: an -// unrecognised major version, a value that is not MAJOR.MINOR, or no version at -// all. The rule is helpers.CheckWellKnownManifestVersion; the error carries that -// package's sentinel too, so either matches. +// ErrManifestVersionRefused re-exports helpers.ErrManifestVersionRefused — the +// verdict CheckWellKnownManifestVersion returns for a /.well-known/ramp.json +// whose WellKnownManifest.ver this reader does not accept — so the endpoint +// resolver's refusal is checkable from this package beside ErrNoEndpoint and +// ErrEndpointRefused without a caller importing helpers directly. One sentinel +// under two names: an injected resolver that wraps either is classified the +// same way by the client tier. // -// Checked BEFORE any other member of the document is read. The manifest sits at -// a fixed, unversioned path and is read before any signature is checked, so a -// layout the reader cannot classify must not supply the endpoint a signed call -// is then sent to. Like ErrEndpointRefused it is a VERDICT — final, not a -// transport failure to retry — and it is never cached. -var ErrManifestVersionRefused = errors.New("resolvers: well-known manifest version not accepted") +// Like ErrEndpointRefused it is a VERDICT — final, not a transport failure to +// retry — and it is never cached. The rule, and why the gate runs before any +// other member is read, are stated once on WellKnownManifest.ver in the proto. +var ErrManifestVersionRefused = helpers.ErrManifestVersionRefused -// wellKnownDoc is the JSON projection of the subset of WellKnownManifest the SDK -// resolvers read: the document version (field 1), the RFC 7517 key set (field 5) -// and the self-advertised ExchangeService endpoint (field 12). One fetch decodes the whole document so a -// single body serves both the key face (WellKnownKeyResolver) and the endpoint -// face (WellKnownEndpointResolver). +// wellKnownDoc is the JSON projection the two well-known resolvers decode +// through: one decoder for two document shapes, each face reading only its own +// members. The key face (WellKnownKeyResolver) fetches a plain RFC 7517 JWK Set +// from an operator-chosen URL and reads `keys`. The endpoint face +// (WellKnownEndpointResolver) fetches /.well-known/ramp.json and reads the +// WellKnownManifest projection — the document version (field 1) and the +// self-advertised ExchangeService endpoint (field 12). A JWK Set carries no +// manifest version, which is why the version gate is the endpoint face's alone. +// +// Ver is held raw rather than as a string so a document whose `ver` is not a +// JSON string still decodes: the gate then refuses it as a verdict — the answer +// Python and TS give — instead of the decode failing as a transport error the +// client would retry. See manifestVer. // // The embedded jose.JSONWebKeySet promotes the "keys" member, so go-jose (the // canonical JOSE library) parses each JWK — decoding the OKP/Ed25519 `x` member // into an ed25519.PublicKey on JSONWebKey.Key — rather than the SDK hand-rolling -// the kty/crv match and the base64url `x` decode. The endpoint face still reads -// the promoted-alongside Endpoint field from the same body. +// the kty/crv match and the base64url `x` decode. type wellKnownDoc struct { jose.JSONWebKeySet - Ver string `json:"ver"` - Endpoint string `json:"endpoint"` + Ver json.RawMessage `json:"ver"` + Endpoint string `json:"endpoint"` +} + +// manifestVer is the string the version rule reads out of the raw `ver` member. +// A member that is absent, null, or not a JSON string yields "" — the value an +// absent field arrives as — so the rule refuses it as absent. Python and TS make +// the same choice (any non-string is absent), so the three languages return the +// same verdict for the same document. +func manifestVer(raw json.RawMessage) string { + var s string + if len(raw) == 0 || json.Unmarshal(raw, &s) != nil { + return "" + } + return s } // fetchWellKnownDoc GETs url and decodes the well-known manifest. It is the one @@ -256,8 +276,8 @@ func (r *WellKnownEndpointResolver) ResolveEndpoint(ctx context.Context, host st // The document version gate runs before the endpoint is so much as looked // at, and only on this face: the key face reads a JWKS document that // carries no manifest version. - if verr := helpers.CheckWellKnownManifestVersion(doc.Ver); verr != nil { - return "", fmt.Errorf("%w: %w", ErrManifestVersionRefused, verr) + if verr := helpers.CheckWellKnownManifestVersion(manifestVer(doc.Ver)); verr != nil { + return "", fmt.Errorf("resolvers: %w", verr) } if doc.Endpoint == "" { return "", fmt.Errorf("%w: host=%q", ErrNoEndpoint, host) diff --git a/sdk/go/resolvers/endpointresolver_version_test.go b/sdk/go/resolvers/endpointresolver_version_test.go index 0b25f2d7..ab3b8c43 100644 --- a/sdk/go/resolvers/endpointresolver_version_test.go +++ b/sdk/go/resolvers/endpointresolver_version_test.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -120,3 +122,35 @@ func TestWellKnownEndpointResolver_versionGatePrecedesEndpointGate(t *testing.T) t.Errorf("error = %v; ErrNoEndpoint must not be reached when the version is refused", err) } } + +// A `ver` that is not a JSON string is refused as absent — a VERDICT, the answer +// Python and TS give — rather than failing the decode, which the client tier +// would classify as unreachable and retry forever against a document that will +// not change. The refusal is one sentinel under two names, so either matches. +func TestWellKnownEndpointResolver_refusesANonStringVersionAsAVerdict(t *testing.T) { + for _, tc := range []struct{ name, doc string }{ + {"number", `{"ver":1,"role":"ROLE_EXCHANGE","endpoint":%q}`}, + {"null", `{"ver":null,"role":"ROLE_EXCHANGE","endpoint":%q}`}, + {"object", `{"ver":{"major":1},"role":"ROLE_EXCHANGE","endpoint":%q}`}, + } { + t.Run(tc.name, func(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, tc.doc, srv.URL+"/ramp.v1.ExchangeService") + })) + defer srv.Close() + _, err := newVersionTestResolver().ResolveEndpoint(context.Background(), hostOf(t, srv)) + if !errors.Is(err, resolvers.ErrManifestVersionRefused) { + t.Fatalf("err = %v, want the version verdict: a non-string ver is absent, not a decode failure", err) + } + if !errors.Is(err, helpers.ErrManifestVersionRefused) { + t.Error("the helpers sentinel must match too: it is the same sentinel under two names") + } + // Refused AS ABSENT, the wording Python and TS produce for the same + // document — not as a malformed string spelled from the raw bytes. + if !strings.Contains(err.Error(), "ver is absent") { + t.Errorf("err = %v, want a non-string ver reported as absent", err) + } + }) + } +} diff --git a/sdk/go/resolvers/wellknownkeyresolver.go b/sdk/go/resolvers/wellknownkeyresolver.go index 385a14e3..7012eeb9 100644 --- a/sdk/go/resolvers/wellknownkeyresolver.go +++ b/sdk/go/resolvers/wellknownkeyresolver.go @@ -13,9 +13,11 @@ import ( "github.com/RAMP-Protocol/protocol/sdk/go/helpers" ) -// WellKnownKeyResolver fetches a JWKS-shaped document from a URL (the publisher's -// /.well-known/ramp.json keys) and caches resolved keys with a TTL. The wire form -// is the RFC 7517 subset: +// WellKnownKeyResolver fetches a JWKS-shaped document from one fixed, +// operator-chosen URL and caches resolved keys with a TTL. The document is a +// plain RFC 7517 JWK Set, not a WellKnownManifest — a manifest carries no keys +// member, and a JWK Set carries no manifest version, so the endpoint face's +// version gate does not apply here. The wire form is the RFC 7517 subset: // // {"keys":[{"kid":"agent1.v1","kty":"OKP","crv":"Ed25519","x":""}]} // diff --git a/sdk/go/resolvers/wellknownkeyresolver_test.go b/sdk/go/resolvers/wellknownkeyresolver_test.go index 897d906a..96493770 100644 --- a/sdk/go/resolvers/wellknownkeyresolver_test.go +++ b/sdk/go/resolvers/wellknownkeyresolver_test.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "testing" @@ -100,3 +101,29 @@ func TestWellKnownKeyResolver_allowlist(t *testing.T) { t.Errorf("disallowed key err = %v, want ErrUnknownKey", err) } } + +// The manifest version gate is the endpoint face's alone. A key document is a +// plain JWK Set, not a manifest: it carries no WellKnownManifest.ver, and one that +// happens to carry a `ver` the endpoint face would refuse still resolves keys. +// This pins that decision — moving the gate into the shared fetch fails here. +func TestWellKnownKeyResolver_isNotGatedOnAManifestVersion(t *testing.T) { + pub, _, _ := ed25519.GenerateKey(nil) + x := base64.RawURLEncoding.EncodeToString(pub) + for _, tc := range []struct{ name, verMember string }{ + {"no ver", ""}, + {"unrecognised major", `"ver":"2.0",`}, + {"non-string ver", `"ver":1,`}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, `{%s"keys":[{"kid":"ex.v1","kty":"OKP","crv":"Ed25519","x":%q}]}`, tc.verMember, x) + })) + defer srv.Close() + r := resolvers.NewWellKnownKeyResolver(srv.URL, resolvers.WellKnownOptions{TTL: time.Hour}) + got, err := r.Resolve(context.Background(), "ex.v1") + if err != nil || !got.Equal(pub) { + t.Fatalf("resolve = %v, %v; a key document is not gated on a manifest version", got, err) + } + }) + } +} diff --git a/sdk/python/ramp_sdk/resolvers/errors.py b/sdk/python/ramp_sdk/resolvers/errors.py index 0b535307..8d6a00bf 100644 --- a/sdk/python/ramp_sdk/resolvers/errors.py +++ b/sdk/python/ramp_sdk/resolvers/errors.py @@ -72,9 +72,8 @@ class ManifestVersionRefusedError(ResolverError): version, a value that is not ``MAJOR.MINOR``, or no version at all. The rule is :func:`ramp_sdk.wire.manifest_version_refusal`. - Checked BEFORE any other member of the document is read. The manifest sits at - a fixed, unversioned path and is read before any signature is checked, so a - layout the reader cannot classify must not supply the endpoint a signed call - is then sent to. Like :class:`EndpointRefusedError` it is a VERDICT — final, - not a transport failure to retry — and it is never cached. + Like :class:`EndpointRefusedError` it is a VERDICT — final, not a transport + failure to retry — and it is never cached. The gate runs before any other + member of the document is read, for the reason stated once on + ``WellKnownManifest.ver`` in the proto. """ diff --git a/sdk/python/ramp_sdk/wire.py b/sdk/python/ramp_sdk/wire.py index 4334f12f..22f2fc85 100644 --- a/sdk/python/ramp_sdk/wire.py +++ b/sdk/python/ramp_sdk/wire.py @@ -30,9 +30,9 @@ #: ``WellKnownManifest.ver`` by every party that serves one. A namespace separate #: from :data:`ProtocolVersion` and never derived from it: a change to the #: manifest layout bumps both numbers, a protocol change that leaves the manifest -#: untouched bumps only ``ProtocolVersion``. The two read the same today by -#: coincidence, not by rule. The receive-side check a manifest reader applies is -#: :func:`check_well_known_manifest_version`. +#: untouched bumps only ``ProtocolVersion``. Both read ``"1.0"`` today because +#: neither has moved yet; neither is derived from the other. The receive-side +#: check a manifest reader applies is :func:`manifest_version_refusal`. WellKnownManifestVersion = "1.0" #: Header correlating a request across services and the edge. RequestIDHeader = "X-Request-ID" @@ -59,19 +59,19 @@ def manifest_version_refusal(ver: object) -> str | None: """The receive-side rule for ``WellKnownManifest.ver``, as a pure verdict. Returns ``None`` when the document is accepted — its MAJOR equals the major of - :data:`WellKnownManifestVersion`, whatever the MINOR, because a minor revision - of the manifest is additive by definition and a reader ignores members it does - not know. Otherwise returns the reason: an unrecognised major, a value that is - not ``MAJOR.MINOR``, or an absent member (``None``, or any non-string, is how - a missing ``ver`` arrives from ``json.loads``). Absent is refused rather than - tolerated because the field is required by the wire shape and a document with - no version is one whose layout the reader cannot classify; the manifest sits - at a fixed, unversioned path and is read before any signature is checked, so - the gate fails closed. + :data:`WellKnownManifestVersion`, whatever the MINOR. Otherwise returns the + reason: an unrecognised major, a value that is not ``MAJOR.MINOR``, or an + absent member (``None``, or any non-string, is how a missing ``ver`` arrives + from ``json.loads``). Absent is refused because a document with no version is + one whose layout the reader cannot classify. Why a minor is accepted, and why + the gate runs before any other member is read and fails closed, is stated once + on ``WellKnownManifest.ver`` in the proto. The message names the value found so an operator can tell a version mismatch - from a network failure. The three SDK languages pin this verdict to a shared - corpus. The exception a resolver raises for a refusal is + from a network failure, clipped to :data:`_MAX_ECHOED_VER` characters: the body + is read up to 1 MiB and a refusal is never cached, so an unclipped echo would + let a hostile origin size every error. The three SDK languages pin this verdict + to a shared corpus. The exception a resolver raises for a refusal is :class:`ramp_sdk.resolvers.errors.ManifestVersionRefusedError`. """ accept_major = _parse_major(WellKnownManifestVersion) @@ -82,7 +82,16 @@ def manifest_version_refusal(ver: object) -> str | None: return f"ver is absent, accept major {accept_major}" major = _parse_major(ver) if major is None: - return f"ver {ver!r} is not MAJOR.MINOR, accept major {accept_major}" + return f"ver {_echo_ver(ver)!r} is not MAJOR.MINOR, accept major {accept_major}" if major != accept_major: - return f"ver {ver!r} has major {major}, accept major {accept_major}" + return f"ver {_echo_ver(ver)!r} has major {major}, accept major {accept_major}" return None + + +#: How much of a refused ``ver`` an error message repeats. Go and TS clip at the +#: same length, so an operator reading three SDKs' logs sees the same prefix. +_MAX_ECHOED_VER = 64 + + +def _echo_ver(ver: str) -> str: + return ver if len(ver) <= _MAX_ECHOED_VER else ver[:_MAX_ECHOED_VER] + "..." diff --git a/sdk/python/tests/resolvers_harness.py b/sdk/python/tests/resolvers_harness.py index 47265446..0e3c6d1b 100644 --- a/sdk/python/tests/resolvers_harness.py +++ b/sdk/python/tests/resolvers_harness.py @@ -123,10 +123,15 @@ def jwks_key_doc_json(entries: list[dict[str, Any]]) -> str: return json.dumps({"keys": entries}) -def manifest_json(endpoint: str | None = None) -> str: - """Serialize a WellKnownManifest projection ({role, endpoint}); omit endpoint - to model a valid-but-inert manifest.""" - doc: dict[str, Any] = {"ver": WellKnownManifestVersion, "role": "ROLE_EXCHANGE"} +def manifest_json( + endpoint: str | None = None, *, ver: str | None = WellKnownManifestVersion +) -> str: + """Serialize a WellKnownManifest projection ({ver, role, endpoint}); omit + endpoint to model a valid-but-inert manifest, and pass ``ver=None`` to omit + the version member and model a manifest with no version at all.""" + doc: dict[str, Any] = {"role": "ROLE_EXCHANGE"} + if ver is not None: + doc["ver"] = ver if endpoint is not None: doc["endpoint"] = endpoint return json.dumps(doc) diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index f53babf3..0890dd18 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -25,7 +25,7 @@ from ramp_sdk.client import BrokerClient from ramp_sdk.client.errors import CallError, CallErrorKind from ramp_sdk.core import Mode, StaticOfferKeyResolver, Verifier, sign_offer_jcs -from ramp_sdk.resolvers.errors import NoEndpointError +from ramp_sdk.resolvers.errors import ManifestVersionRefusedError, NoEndpointError from ramp_sdk.signing_transport import SigningTransport REQUESTER = {"id": "agent-1", "domain": "agent.test", "type": "REQUESTER_TYPE_AGENT"} @@ -570,6 +570,28 @@ def resolve_endpoint(self, host: str) -> str: # noqa: ARG002 assert flaky.value.kind is CallErrorKind.UNREACHABLE +@pytest.mark.parametrize("face", FACES, ids=_IDS) +def test_an_unaccepted_manifest_version_is_not_sent(face: Face) -> None: + """A manifest whose version the reader refuses is a VERDICT: the manifest was + fetched and parsed, and nothing about a retry changes the version served. + Mirrors the Go taxonomy test for the same refusal.""" + + class Refusing: + def resolve_endpoint(self, host: str) -> str: # noqa: ARG002 + raise ManifestVersionRefusedError("ver '2.0' has major 2, accept major 1") + + rec = Recorder({}) + with pytest.raises(CallError) as excinfo: + face.run( + face.client(_config(endpoint_resolver=Refusing()), rec).report_usage( + {"exchange": "issuer.test"} + ) + ) + assert excinfo.value.kind is CallErrorKind.NOT_SENT + assert isinstance(excinfo.value.__cause__, ManifestVersionRefusedError) + assert rec.seen == [] + + # --------------------------------------------------------------------------- # the broker face, outbound validation, fetch # --------------------------------------------------------------------------- diff --git a/sdk/python/tests/test_manifest_version_parity.py b/sdk/python/tests/test_manifest_version_parity.py index fe6d89e1..4dee57ee 100644 --- a/sdk/python/tests/test_manifest_version_parity.py +++ b/sdk/python/tests/test_manifest_version_parity.py @@ -19,12 +19,9 @@ import pytest from conftest import GO_TESTDATA, load_json -from ramp_sdk.resolvers import ( - EndpointRefusedError, - ManifestVersionRefusedError, - NoEndpointError, - WellKnownEndpointResolver, -) +from resolvers_harness import manifest_json + +from ramp_sdk.resolvers import ManifestVersionRefusedError, WellKnownEndpointResolver from ramp_sdk.wire import WellKnownManifestVersion, manifest_version_refusal _VECTORS = load_json(GO_TESTDATA / "manifest-version-vectors.json")["manifest_version"] @@ -43,12 +40,10 @@ def _ver_of(vector: dict) -> str | None: def _serving(vector: dict) -> httpx.Client: """A manifest server that answers with the vector's ver and a usable endpoint.""" - doc: dict[str, object] = {"role": "ROLE_EXCHANGE", "endpoint": _ENDPOINT} - if vector["present"]: - doc["ver"] = vector["ver"] + body = manifest_json(_ENDPOINT, ver=_ver_of(vector)) def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json=doc) + return httpx.Response(200, text=body) return httpx.Client(transport=httpx.MockTransport(handler)) @@ -80,18 +75,18 @@ def test_resolver_accepts_and_reads_the_endpoint(vector: dict) -> None: @pytest.mark.parametrize("vector", _REFUSED, ids=[v["name"] for v in _REFUSED]) def test_resolver_refuses_with_the_version_verdict(vector: dict) -> None: - with pytest.raises(ManifestVersionRefusedError) as excinfo: + # A refused document's endpoint is usable, so the refusal can only be the + # version gate — and ManifestVersionRefusedError is its own class, not a + # subclass of any other verdict, so the raises() is the whole assertion. + with pytest.raises(ManifestVersionRefusedError): _resolver(_serving(vector)).resolve_endpoint(_HOST) - # A refused document's endpoint is usable, so the refusal is the version gate - # and no other verdict class. - assert not isinstance(excinfo.value, NoEndpointError | EndpointRefusedError) def test_version_gate_precedes_the_endpoint_gate() -> None: """A wrong-version manifest that ALSO advertises nothing reports the version.""" def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"ver": "2.0", "role": "ROLE_EXCHANGE"}) + return httpx.Response(200, text=manifest_json(None, ver="2.0")) r = _resolver(httpx.Client(transport=httpx.MockTransport(handler))) with pytest.raises(ManifestVersionRefusedError): @@ -106,9 +101,7 @@ def test_a_refusal_is_not_cached() -> None: def handler(_request: httpx.Request) -> httpx.Response: nonlocal hits hits += 1 - return httpx.Response( - 200, json={"ver": served[0], "role": "ROLE_EXCHANGE", "endpoint": _ENDPOINT} - ) + return httpx.Response(200, text=manifest_json(_ENDPOINT, ver=served[0])) r = _resolver(httpx.Client(transport=httpx.MockTransport(handler))) with pytest.raises(ManifestVersionRefusedError): diff --git a/sdk/python/tests/test_resolvers_wellknown_integration.py b/sdk/python/tests/test_resolvers_wellknown_integration.py index 16a38c36..a4857230 100644 --- a/sdk/python/tests/test_resolvers_wellknown_integration.py +++ b/sdk/python/tests/test_resolvers_wellknown_integration.py @@ -16,6 +16,7 @@ from __future__ import annotations +import json import queue import threading from datetime import timedelta @@ -44,6 +45,7 @@ from ramp_sdk.resolvers import ( # type: ignore[import-not-found] DirectoryUnavailableError, EndpointRefusedError, + ManifestVersionRefusedError, NoEndpointError, WellKnownEndpointResolver, WellKnownKeyResolver, @@ -210,6 +212,38 @@ def test_endpoint_decode_failure_raises_directory_unavailable() -> None: origin.close() +def test_wellknown_key_is_not_gated_on_a_manifest_version() -> None: + """The manifest version gate is the endpoint face's alone. A key document is a + plain JWK Set, not a manifest: it carries no ``WellKnownManifest.ver``, and one + that happens to carry a ``ver`` the endpoint face would refuse still resolves + keys. Pins that decision — gating the key face fails here.""" + k = make_key() + for ver_member in ("", '"ver":"2.0",', '"ver":1,'): + origin = Origin() + entry = json.dumps(jwks_entry("ex.v1", k.x)) + origin.set_jwks(f'{{{ver_member}"keys":[{entry}]}}') + try: + r = WellKnownKeyResolver(f"{origin.url}/keys.json", ttl=HOUR) + assert r.resolve("ex.v1") == k.raw_pub, ver_member + finally: + origin.close() + + +def test_endpoint_non_string_version_is_refused_as_a_verdict() -> None: + """A ``ver`` that is not a JSON string is refused as absent — a VERDICT, the + answer Go and TS give — not a decode failure the client would retry.""" + for doc in ('{"ver":1,%s}', '{"ver":null,%s}', '{"ver":{"major":1},%s}'): + origin = Origin() + ep = f"http://{origin.host}/ramp.v1.ExchangeService" + origin.set_manifest(doc % f'"role":"ROLE_EXCHANGE","endpoint":"{ep}"') + try: + r = WellKnownEndpointResolver(scheme="http") + with pytest.raises(ManifestVersionRefusedError): + r.resolve_endpoint(origin.host) + finally: + origin.close() + + def test_endpoint_missing_field_raises_no_endpoint() -> None: origin = Origin() origin.set_manifest(manifest_json(None)) # valid manifest, no endpoint diff --git a/sdk/python/tests/test_wire_parity.py b/sdk/python/tests/test_wire_parity.py index 0717e9df..aa7b7fd8 100644 --- a/sdk/python/tests/test_wire_parity.py +++ b/sdk/python/tests/test_wire_parity.py @@ -2,12 +2,12 @@ Mirrors the sdk/ts sibling sdk/ts/tests/wire.parity.test.ts. -``ramp_sdk.wire`` MUST expose the seven wire constants with the EXACT values the +``ramp_sdk.wire`` MUST expose the eight wire constants with the EXACT values the sdk/go oracle carries. The shared vectors at sdk/go/helpers/testdata/wire-constants-vectors.json carry {name, value}, referenced from the real Go exported constants (never hand-typed). The Go layer splits RequestIDHeader across helpers/constants.go and core/requestid.go; the -single Python wire module exposes all seven once. +single Python wire module exposes all eight once. RED now purely because ``ramp_sdk.wire`` does not exist yet. """ diff --git a/sdk/ts/resolvers/errors.ts b/sdk/ts/resolvers/errors.ts index c1db62f9..d11a9af8 100644 --- a/sdk/ts/resolvers/errors.ts +++ b/sdk/ts/resolvers/errors.ts @@ -92,11 +92,10 @@ export class EndpointRefused extends ResolverError { * version, a value that is not MAJOR.MINOR, or no version at all. The rule is * manifestVersionRefusal in src/wire.ts. * - * Checked BEFORE any other member of the document is read. The manifest sits at - * a fixed, unversioned path and is read before any signature is checked, so a - * layout the reader cannot classify must not supply the endpoint a signed call - * is then sent to. Like EndpointRefused it is a VERDICT — final, not a transport - * failure to retry — and it is never cached. */ + * Like EndpointRefused it is a VERDICT — final, not a transport failure to + * retry — and it is never cached. The gate runs before any other member of the + * document is read, for the reason stated once on WellKnownManifest.ver in the + * proto. */ export class ManifestVersionRefused extends ResolverError { constructor(message: string, options?: { cause?: unknown }) { super(message, options); diff --git a/sdk/ts/src/wire.ts b/sdk/ts/src/wire.ts index 760d22e1..097832bf 100644 --- a/sdk/ts/src/wire.ts +++ b/sdk/ts/src/wire.ts @@ -30,9 +30,9 @@ export const ProtocolVersion = "1.0"; * layout, stamped on WellKnownManifest.ver by every party that serves one. A * namespace separate from ProtocolVersion and never derived from it: a change * to the manifest layout bumps both numbers, a protocol change that leaves the - * manifest untouched bumps only ProtocolVersion. The two read the same today by - * coincidence, not by rule. The receive-side check a manifest reader applies is - * manifestVersionRefusal. + * manifest untouched bumps only ProtocolVersion. Both read "1.0" today because + * neither has moved yet; neither is derived from the other. The receive-side + * check a manifest reader applies is manifestVersionRefusal. */ export const WellKnownManifestVersion = "1.0"; /** RequestIDHeader correlates a request across services and the edge. */ @@ -59,19 +59,20 @@ function parseMajor(ver: string): string | undefined { * The receive-side rule for WellKnownManifest.ver, as a pure verdict. * * Returns undefined when the document is accepted — its MAJOR equals the major - * of WellKnownManifestVersion, whatever the MINOR, because a minor revision of - * the manifest is additive by definition and a reader ignores members it does - * not know. Otherwise returns the reason: an unrecognised major, a value that is - * not MAJOR.MINOR, or an absent member (undefined, null, or any non-string is - * how a missing `ver` arrives from JSON.parse). Absent is refused rather than - * tolerated because the field is required by the wire shape and a document with - * no version is one whose layout the reader cannot classify; the manifest sits - * at a fixed, unversioned path and is read before any signature is checked, so - * the gate fails closed. + * of WellKnownManifestVersion, whatever the MINOR. Otherwise returns the reason: + * an unrecognised major, a value that is not MAJOR.MINOR, or an absent member + * (undefined, null, or any non-string is how a missing `ver` arrives from + * JSON.parse). Absent is refused because a document with no version is one whose + * layout the reader cannot classify. Why a minor is accepted, and why the gate + * runs before any other member is read and fails closed, is stated once on + * WellKnownManifest.ver in the proto. * * The message names the value found so an operator can tell a version mismatch - * from a network failure. The three SDK languages pin this verdict to a shared - * corpus. The error a resolver throws for a refusal is ManifestVersionRefused. + * from a network failure, clipped to MAX_ECHOED_VER characters: the body is read + * up to 1 MiB and a refusal is never cached, so an unclipped echo would let a + * hostile origin size every error. The three SDK languages pin this verdict to a + * shared corpus. The error a resolver throws for a refusal is + * ManifestVersionRefused. */ export function manifestVersionRefusal(ver: unknown): string | undefined { const acceptMajor = parseMajor(WellKnownManifestVersion); @@ -83,10 +84,18 @@ export function manifestVersionRefusal(ver: unknown): string | undefined { } const major = parseMajor(ver); if (major === undefined) { - return `ver ${JSON.stringify(ver)} is not MAJOR.MINOR, accept major ${acceptMajor}`; + return `ver ${JSON.stringify(echoVer(ver))} is not MAJOR.MINOR, accept major ${acceptMajor}`; } if (major !== acceptMajor) { - return `ver ${JSON.stringify(ver)} has major ${major}, accept major ${acceptMajor}`; + return `ver ${JSON.stringify(echoVer(ver))} has major ${major}, accept major ${acceptMajor}`; } return undefined; } + +/** How much of a refused `ver` an error message repeats. Go and Python clip at + * the same length, so an operator reading three SDKs' logs sees the same prefix. */ +const MAX_ECHOED_VER = 64; + +function echoVer(ver: string): string { + return ver.length <= MAX_ECHOED_VER ? ver : `${ver.slice(0, MAX_ECHOED_VER)}...`; +} diff --git a/sdk/ts/tests/client.test.ts b/sdk/ts/tests/client.test.ts index 01a2ded0..e464c7d5 100644 --- a/sdk/ts/tests/client.test.ts +++ b/sdk/ts/tests/client.test.ts @@ -588,6 +588,30 @@ describe("the offer-derived leg", () => { flakyClient.reportUsage({ exchange: "issuer.test" }), ).rejects.toMatchObject({ kind: "unreachable" }); }); + + it("does not send when the manifest's version is refused", async () => { + // A manifest whose version the reader refuses is a VERDICT: the manifest was + // fetched and parsed, and nothing about a retry changes the version served. + // Mirrors the Go taxonomy test for the same refusal. + const { ManifestVersionRefused } = await import("../resolvers/errors.ts"); + const { send, seen } = recordingSend({}); + const client = createClient("https://home.test", { + endpointResolver: { + resolveEndpoint: async () => { + throw new ManifestVersionRefused('ver "2.0" has major 2, accept major 1'); + }, + }, + guardedSend: send, + }); + const err = (await client + .reportUsage({ exchange: "issuer.test" }) + .catch((e: unknown) => e)) as RampCallError; + expect(err.kind).toBe("not_sent"); + // The route flattens the resolver's error into the cause message; the + // refusal's wording must survive so an operator can tell it from an outage. + expect((err.cause as Error).message).toContain("accept major 1"); + expect(seen).toHaveLength(0); + }); }); describe("the broker face", () => { diff --git a/sdk/ts/tests/manifest-version.parity.test.ts b/sdk/ts/tests/manifest-version.parity.test.ts index 014068fd..cb446e1f 100644 --- a/sdk/ts/tests/manifest-version.parity.test.ts +++ b/sdk/ts/tests/manifest-version.parity.test.ts @@ -12,14 +12,10 @@ import { describe, expect, it } from "vitest"; import vectorsFile from "../../go/helpers/testdata/manifest-version-vectors.json"; -import { - EndpointRefused, - ManifestVersionRefused, - NoEndpoint, - createWellKnownEndpointResolver, -} from "../resolvers/index.ts"; +import { ManifestVersionRefused, createWellKnownEndpointResolver } from "../resolvers/index.ts"; import type { FetchLike } from "../resolvers/http.ts"; import { WellKnownManifestVersion, manifestVersionRefusal } from "../src/wire.ts"; +import { manifestJson } from "./resolvers-harness.ts"; type ManifestVersionVector = { name: string; ver: string; present: boolean; accepted: boolean }; type ManifestVersionVectorsFile = { manifest_version: ManifestVersionVector[] }; @@ -36,16 +32,13 @@ const ENDPOINT = `https://${HOST}/ramp.v1.ExchangeService`; /** The value the resolver sees: the string when present, undefined when absent. */ const verOf = (v: ManifestVersionVector): string | undefined => (v.present ? v.ver : undefined); -/** A manifest server that answers with the vector's ver and a usable endpoint. */ -function serving(body: Record): FetchLike { - return async () => ({ status: 200, text: async () => JSON.stringify(body) }); +/** A manifest server that answers with a fixed body. */ +function serving(body: string): FetchLike { + return async () => ({ status: 200, text: async () => body }); } -function manifestFor(v: ManifestVersionVector): Record { - const body: Record = { role: "ROLE_EXCHANGE", endpoint: ENDPOINT }; - if (v.present) body.ver = v.ver; - return body; -} +/** The vector's manifest with a usable endpoint; an absent row omits `ver`. */ +const manifestFor = (v: ManifestVersionVector): string => manifestJson(ENDPOINT, verOf(v) ?? null); const resolverOver = (fetchFn: FetchLike) => createWellKnownEndpointResolver({ fetch: fetchFn, ttlMs: 3_600_000, scheme: "https" }); @@ -78,19 +71,17 @@ describe("manifest-version corpus", () => { for (const v of refused) { it(`resolver refuses with the version verdict: ${v.name}`, async () => { - const err = await resolverOver(serving(manifestFor(v))) - .resolveEndpoint(HOST) - .then(() => undefined, (e: unknown) => e); - expect(err).toBeInstanceOf(ManifestVersionRefused); - // A refused document's endpoint is usable, so the refusal is the version - // gate and no other verdict class. - expect(err).not.toBeInstanceOf(NoEndpoint); - expect(err).not.toBeInstanceOf(EndpointRefused); + // A refused document's endpoint is usable, so the refusal can only be the + // version gate — and ManifestVersionRefused is its own class, not a + // subclass of any other verdict, so the instance check is the whole assertion. + await expect(resolverOver(serving(manifestFor(v))).resolveEndpoint(HOST)).rejects.toBeInstanceOf( + ManifestVersionRefused, + ); }); } it("the version gate precedes the endpoint gate", async () => { - const r = resolverOver(serving({ ver: "2.0", role: "ROLE_EXCHANGE" })); + const r = resolverOver(serving(manifestJson(undefined, "2.0"))); await expect(r.resolveEndpoint(HOST)).rejects.toBeInstanceOf(ManifestVersionRefused); }); @@ -99,10 +90,7 @@ describe("manifest-version corpus", () => { let hits = 0; const fetchFn: FetchLike = async () => { hits += 1; - return { - status: 200, - text: async () => JSON.stringify({ ver: served, role: "ROLE_EXCHANGE", endpoint: ENDPOINT }), - }; + return { status: 200, text: async () => manifestJson(ENDPOINT, served) }; }; const r = resolverOver(fetchFn); await expect(r.resolveEndpoint(HOST)).rejects.toBeInstanceOf(ManifestVersionRefused); diff --git a/sdk/ts/tests/resolvers-harness.ts b/sdk/ts/tests/resolvers-harness.ts index de1d8d65..2488358f 100644 --- a/sdk/ts/tests/resolvers-harness.ts +++ b/sdk/ts/tests/resolvers-harness.ts @@ -90,10 +90,12 @@ export function jwksEntry(kid: string, x: string): Record { return { kid, kty: "OKP", crv: "Ed25519", x }; } -/** Serialize a WellKnownManifest projection ({role, endpoint}); omit endpoint to - * model a valid-but-inert manifest. */ -export function manifestJson(endpoint?: string): string { - const doc: Record = { ver: WellKnownManifestVersion, role: "ROLE_EXCHANGE" }; +/** Serialize a WellKnownManifest projection ({ver, role, endpoint}); omit + * endpoint to model a valid-but-inert manifest, and pass `ver: null` to omit the + * version member and model a manifest with no version at all. */ +export function manifestJson(endpoint?: string, ver: string | null = WellKnownManifestVersion): string { + const doc: Record = { role: "ROLE_EXCHANGE" }; + if (ver !== null) doc.ver = ver; if (endpoint !== undefined) doc.endpoint = endpoint; return JSON.stringify(doc); } diff --git a/sdk/ts/tests/resolvers-wellknown.integration.test.ts b/sdk/ts/tests/resolvers-wellknown.integration.test.ts index 143c42b3..77a8158e 100644 --- a/sdk/ts/tests/resolvers-wellknown.integration.test.ts +++ b/sdk/ts/tests/resolvers-wellknown.integration.test.ts @@ -30,6 +30,7 @@ import { import { DirectoryUnavailable, EndpointRefused, + ManifestVersionRefused, NoEndpoint, createStaticKeyResolver, createWellKnownEndpointResolver, @@ -128,6 +129,22 @@ describe("createWellKnownKeyResolver", () => { // JWKS extraction is skip-not-fail — one malformed key must not kill the // whole publisher key set; survivors still resolve, bad entries become // unknown (undefined). + it("is not gated on a manifest version", async () => { + // The manifest version gate is the endpoint face's alone. A key document is + // a plain JWK Set, not a manifest: it carries no WellKnownManifest.ver, and + // one that happens to carry a `ver` the endpoint face would refuse still + // resolves keys. Pins that decision — gating the key face fails here. + const k = await makeKey(); + for (const verMember of ["", '"ver":"2.0",', '"ver":1,']) { + origin = await startOrigin(); + origin.setJwks(`{${verMember}"keys":[${JSON.stringify(jwksEntry("ex.v1", k.x))}]}`); + const r = createWellKnownKeyResolver(`${origin.url}/keys.json`, { ttlMs: HOUR_MS, fetch: loopbackFetch }); + expect(await r.resolve("ex.v1"), verMember).toEqual(k.rawPub); + await origin.close(); + origin = undefined; + } + }); + it("skips malformed JWKS entries (missing kid / non-Ed25519 / bad-length x) and resolves survivors", async () => { const good = await makeKey(); const nonEd = await makeKey(); @@ -236,6 +253,22 @@ describe("createWellKnownEndpointResolver", () => { } }); + it("refuses a non-string ver as a verdict, not a decode failure", async () => { + // A `ver` that is not a JSON string is refused as absent — a VERDICT, the + // answer Go and Python give — not a DirectoryUnavailable the client would retry. + for (const ver of ["1", "null", '{"major":1}']) { + const origin = await startOrigin(); + const ep = `http://${origin.host}/ramp.v1.ExchangeService`; + origin.setManifest(`{"ver":${ver},"role":"ROLE_EXCHANGE","endpoint":"${ep}"}`); + try { + const r = createWellKnownEndpointResolver({ scheme: "http", fetch: loopbackFetch }); + await expect(r.resolveEndpoint(origin.host), ver).rejects.toBeInstanceOf(ManifestVersionRefused); + } finally { + await origin.close(); + } + } + }); + it("throws NoEndpoint when a valid manifest omits the endpoint field", async () => { const origin = await startOrigin(); origin.setManifest(manifestJson(undefined)); // valid manifest, no endpoint diff --git a/sdk/ts/tests/wire.parity.test.ts b/sdk/ts/tests/wire.parity.test.ts index e209f6e3..eea187ab 100644 --- a/sdk/ts/tests/wire.parity.test.ts +++ b/sdk/ts/tests/wire.parity.test.ts @@ -1,12 +1,12 @@ -// Wire-constants parity (TypeScript side): wire.ts exposes the seven wire +// Wire-constants parity (TypeScript side): wire.ts exposes the eight wire // constants with the exact values the Go oracle carries. // -// sdk/ts `wire.ts` MUST expose the seven wire constants with the EXACT values the +// sdk/ts `wire.ts` MUST expose the eight wire constants with the EXACT values the // sdk/go oracle carries. The shared vectors at // sdk/go/helpers/testdata/wire-constants-vectors.json carry {name, value}, // referenced from the real Go exported constants (never hand-typed). The Go // layer splits RequestIDHeader across helpers/constants.go and core/requestid.go; -// the single TS wire module exposes all seven once. +// the single TS wire module exposes all eight once. // // RED now purely because sdk/ts/src/wire.ts does not exist yet. import { describe, it, expect } from "vitest"; diff --git a/website/src/content/docs/reference/changelog.mdx b/website/src/content/docs/reference/changelog.mdx index a4d04298..c34be853 100644 --- a/website/src/content/docs/reference/changelog.mdx +++ b/website/src/content/docs/reference/changelog.mdx @@ -17,14 +17,16 @@ version, or with no version at all, was accepted. The rule is now stated in full enforced. A consumer reads `ver` before any other member of the document, accepts a recognised **major** version whatever the minor (a minor revision of the manifest is additive, and a reader ignores members it does not know), and refuses an -unrecognised major, a value that is not `MAJOR.MINOR`, or an absent `ver` — the +unrecognised major, a value that is not `MAJOR.MINOR`, or an absent `ver` (a `ver` +that is not a JSON string is refused as absent, in all three languages) — the document sits at a fixed, unversioned path and is read before any signature is checked, so a layout the reader cannot classify must not supply the endpoint a signed call is then sent to. The refusal is a verdict, surfaced as `resolvers.ErrManifestVersionRefused` / `ManifestVersionRefusedError` / `ManifestVersionRefused`, classified `not sent` by the client tier like the other resolver verdicts, and never cached. It applies to the endpoint face only: the -well-known key resolver reads a JWKS document that carries no manifest version. +well-known key resolver reads a JWK Set document that carries no manifest version, +and a test in each language pins that a key document carrying a `ver` still resolves. The value has one owner, `helpers.WellKnownManifestVersion` (`WellKnownManifestVersion` in Python and TypeScript), pinned to the wire-constants corpus beside `ProtocolVersion` and deliberately not derived from it — the two are separate