From 771855753032064f35a927be07bf3df84098917b Mon Sep 17 00:00:00 2001 From: Victor Ramperez Date: Mon, 17 Aug 2026 13:04:06 +0200 Subject: [PATCH 1/3] fix(verifier): accept padded base64 vp_token and stop panicking on malformed VPs decodeVpString only decoded raw(unpadded) base64url, so a client emitting padded base64 - java's Base64.getUrlEncoder() or python's b64encode() - made the decode fail and the raw, still-encoded string was passed on. That string matches no DCQL shape and reaches ParseWithSdJwt, where strings.Split(token, ".")[1] panics on a string without a '.', turning a malformed request into an empty HTTP 500 instead of a 400. Accept all four base64 variants in decodeVpString. Tokens that are not base64 at all(compact JWTs, SD-JWTs, plain json dcql responses) are unaffected, since '.', '~', '{', '"' and ':' are part of no base64 alphabet. ParseWithSdJwt now reuses the existing extractJWTPayload helper, which checks the segment count and no longer discards the decode error. Its three remaining unchecked type assertions - the holder, the verifiableCredential array and its entries - are guarded as well, since they panic on the same unauthenticated endpoint. Co-Authored-By: Claude Opus 5 (1M context) --- openapi/api_api.go | 15 ++++-- openapi/api_api_test.go | 50 ++++++++++++++++++++ verifier/presentation_parser.go | 33 ++++++++++--- verifier/presentation_parser_test.go | 70 ++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 12 deletions(-) diff --git a/openapi/api_api.go b/openapi/api_api.go index 94e5fb1d..2a754b28 100644 --- a/openapi/api_api.go +++ b/openapi/api_api.go @@ -750,13 +750,18 @@ func isSdJWT(c *gin.Context, vpToken string) (isSdJwt bool, presentation *common return true, presentation, nil } -// decodeVpString - In newer versions of OID4VP the token is not encoded as a whole but only its segments separately. This function covers the older and newer versions +// decodeVpString - In newer versions of OID4VP the token is not encoded as a whole but only its segments separately. This function covers the older and newer versions. +// While the spec asks for raw(unpadded) base64url, clients do emit padded and/or standard-alphabet base64 - e.g. javas Base64.getUrlEncoder() without withoutPadding() +// or pythons base64.b64encode() - so all four variants are accepted. A token that is not base64 at all(a compact JWT, an SD-JWT or a plain json dcql response) is +// returned unchanged, since '.', '~', '{', '"' and ':' are not part of any base64 alphabet and thus can never be decoded by accident. func decodeVpString(vpToken string) (tokenBytes []byte) { - tokenBytes, err := base64.RawURLEncoding.DecodeString(vpToken) - if err != nil { - return []byte(vpToken) + for _, encoding := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding} { + if decodedBytes, err := encoding.DecodeString(vpToken); err == nil { + return decodedBytes + } } - return tokenBytes + logging.Log().Debugf("The token is not base64 encoded, using it as is.") + return []byte(vpToken) } func handleAuthenticationResponse(c *gin.Context, state string, presentation *common.Presentation) { diff --git a/openapi/api_api_test.go b/openapi/api_api_test.go index 6fc6a1f3..14af666d 100644 --- a/openapi/api_api_test.go +++ b/openapi/api_api_test.go @@ -690,6 +690,10 @@ func TestGetPresentationFromQuery(t *testing.T) { if err != nil { t.Fatalf("Failed to marshal draft 25+ query map: %v", err) } + // clients that base64 encode the query map with a padding-emitting encoder(java, python) send a + // trailing '=', which used to be rejected and made the raw string fall through to the sd-jwt parser + paddedStringShapeMap := base64.URLEncoding.EncodeToString(withBase64Padding(stringShapeMap)) + paddedArrayShapeMap := base64.StdEncoding.EncodeToString(withBase64Padding(arrayShapeMap)) type test struct { testName string @@ -700,6 +704,8 @@ func TestGetPresentationFromQuery(t *testing.T) { tests := []test{ {"OID4VP drafts 22-24 single-string-shape query map should be parsed.", string(stringShapeMap), true}, {"OID4VP draft 25+ array-shape query map should be parsed.", string(arrayShapeMap), true}, + {"A padded base64url encoded query map should be parsed.", paddedStringShapeMap, true}, + {"A padded standard-base64 encoded query map should be parsed.", paddedArrayShapeMap, true}, {"A flat sd-jwt (no query map) should return nil so the caller falls through.", sdJwt, false}, {"A non-JSON token should return nil so the caller falls through.", "this-is-not-json", false}, } @@ -753,6 +759,50 @@ func TestBuildFrontendV2Address(t *testing.T) { } } +// withBase64Padding appends json-insignificant whitespace until the length is not a multiple of 3, so +// that encoding the result is guaranteed to emit '=' padding regardless of the fixture that is used. +func withBase64Padding(raw []byte) []byte { + for len(raw)%3 == 0 { + raw = append(raw, ' ') + } + return raw +} + +func TestDecodeVpString(t *testing.T) { + + logging.Configure(LOGGING_CONFIG) + + // 4 bytes, so the encodings emit padding, and chosen so that the standard alphabet uses '+' and '/' + // where the url-safe one uses '-' and '_' - the four encodings therefore produce four distinct strings + decoded := []byte{0xfb, 0xff, 0xbe, 0x2a} + + type test struct { + testName string + vpToken string + expected []byte + } + + tests := []test{ + {"Raw base64url should be decoded.", base64.RawURLEncoding.EncodeToString(decoded), decoded}, + {"Padded base64url should be decoded.", base64.URLEncoding.EncodeToString(decoded), decoded}, + {"Raw standard base64 should be decoded.", base64.RawStdEncoding.EncodeToString(decoded), decoded}, + {"Padded standard base64 should be decoded.", base64.StdEncoding.EncodeToString(decoded), decoded}, + {"A compact jwt should be returned unchanged.", "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtZSJ9.sig", []byte("eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtZSJ9.sig")}, + {"An sd-jwt should be returned unchanged.", "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtZSJ9.sig~disclosure~", []byte("eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtZSJ9.sig~disclosure~")}, + {"A plain json query map should be returned unchanged.", `{"lpc-query":"a.b.c"}`, []byte(`{"lpc-query":"a.b.c"}`)}, + {"A non-base64 string should be returned unchanged.", "this-is-not-base64!", []byte("this-is-not-base64!")}, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + actual := decodeVpString(tc.vpToken) + if !bytes.Equal(actual, tc.expected) { + t.Errorf("%s - Expected %s but was %s", tc.testName, tc.expected, actual) + } + }) + } +} + func getNoHolderVPToken() string { return "ewogICJAY29udGV4dCI6IFsKICAgICJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIKICBdLAogICJ0eXBlIjogWwogICAgIlZlcmlmaWFibGVQcmVzZW50YXRpb24iCiAgXSwKICAidmVyaWZpYWJsZUNyZWRlbnRpYWwiOiBbCiAgICB7CiAgICAgICJ0eXBlcyI6IFsKICAgICAgICAiUGFja2V0RGVsaXZlcnlTZXJ2aWNlIiwKICAgICAgICAiVmVyaWZpYWJsZUNyZWRlbnRpYWwiCiAgICAgIF0sCiAgICAgICJAY29udGV4dCI6IFsKICAgICAgICAiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLAogICAgICAgICJodHRwczovL3czaWQub3JnL3NlY3VyaXR5L3N1aXRlcy9qd3MtMjAyMC92MSIKICAgICAgXSwKICAgICAgImNyZWRlbnRpYWxzU3ViamVjdCI6IHt9LAogICAgICAiYWRkaXRpb25hbFByb3AxIjoge30KICAgIH0KICBdLAogICJpZCI6ICJlYmM2ZjFjMiIsCiAgImhvbGRlciI6IHsKICAgICJub3RhIjogImhvbGRlciIKICB9LAogICJwcm9vZiI6IHsKICAgICJ0eXBlIjogIkpzb25XZWJTaWduYXR1cmUyMDIwIiwKICAgICJjcmVhdG9yIjogImRpZDprZXk6ejZNa3M5bTlpZkx3eTNKV3FINGM1N0ViQlFWUzJTcFJDamZhNzl3SGI1dldNNnZoIiwKICAgICJjcmVhdGVkIjogIjIwMjMtMDEtMDZUMDc6NTE6MzZaIiwKICAgICJ2ZXJpZmljYXRpb25NZXRob2QiOiAiZGlkOmtleTp6Nk1rczltOWlmTHd5M0pXcUg0YzU3RWJCUVZTMlNwUkNqZmE3OXdIYjV2V002dmgjejZNa3M5bTlpZkx3eTNKV3FINGM1N0ViQlFWUzJTcFJDamZhNzl3SGI1dldNNnZoIiwKICAgICJqd3MiOiAiZXlKaU5qUWlPbVpoYkhObExDSmpjbWwwSWpwYkltSTJOQ0pkTENKaGJHY2lPaUpGWkVSVFFTSjkuLjZ4U3FvWmphME53akYwYWY5WmtucXgzQ2JoOUdFTnVuQmY5Qzh1TDJ1bEdmd3VzM1VGTV9abmhQald0SFBsLTcyRTlwM0JUNWYycHRab1lrdE1LcERBIgogIH0KfQ" } diff --git a/verifier/presentation_parser.go b/verifier/presentation_parser.go index 660786fc..53e3bddd 100644 --- a/verifier/presentation_parser.go +++ b/verifier/presentation_parser.go @@ -22,6 +22,7 @@ var ErrorNoValidationEndpoint = errors.New("no_validation_endpoint_configured") var ErrorNoValidationHost = errors.New("no_validation_host_configured") var ErrorInvalidSdJwt = errors.New("credential_is_not_sd_jwt") var ErrorPresentationNoCredentials = errors.New("presentation_not_contains_credentials") +var ErrorPresentationNoHolder = errors.New("presentation_not_contains_holder") var ErrorInvalidProof = errors.New("invalid_vp_proof") var ErrorVCNotArray = errors.New("verifiable_credential_not_array") var ErrorInvalidJWTFormat = errors.New("invalid_jwt_format") @@ -519,9 +520,11 @@ func (sjp *ConfigurableSdJwtParser) ClaimsToCredential(claims map[string]interfa func (sjp *ConfigurableSdJwtParser) ParseWithSdJwt(tokenBytes []byte) (presentation *common.Presentation, err error) { logging.Log().Debug("Parse with SD-Jwt") - tokenString := string(tokenBytes) - payloadString := strings.Split(tokenString, ".")[1] - payloadBytes, _ := base64.RawURLEncoding.DecodeString(payloadString) + payloadBytes, err := extractJWTPayload(tokenBytes) + if err != nil { + logging.Log().Warnf("Failed to extract the VP payload: %v", err) + return nil, err + } var vpMap map[string]interface{} if err := json.Unmarshal(payloadBytes, &vpMap); err != nil { @@ -546,12 +549,28 @@ func (sjp *ConfigurableSdJwtParser) ParseWithSdJwt(tokenBytes []byte) (presentat return nil, err } - presentation.Holder = vp[common.VPKeyHolder].(string) + holder, ok := vp[common.VPKeyHolder].(string) + if !ok { + logging.Log().Warn("VP does not contain a string holder") + return nil, ErrorPresentationNoHolder + } + presentation.Holder = holder + + vcArray, ok := vcs.([]interface{}) + if !ok { + logging.Log().Warn("The verifiableCredential entry is not an array") + return nil, ErrorVCNotArray + } // due to dcql, we only need to take care of presentations containing credentials of the same type. - for _, vc := range vcs.([]interface{}) { - logging.Log().Debugf("The vc %s", vc.(string)) - parsed, err := sjp.Parse(vc.(string)) + for _, vc := range vcArray { + vcString, ok := vc.(string) + if !ok { + logging.Log().Warn("The presentation contains a credential that is not an sd-jwt string") + return nil, ErrorInvalidSdJwt + } + logging.Log().Debugf("The vc %s", vcString) + parsed, err := sjp.Parse(vcString) if err != nil { logging.Log().Warnf("Failed to parse SD-JWT VC: %v", err) return nil, err diff --git a/verifier/presentation_parser_test.go b/verifier/presentation_parser_test.go index 19de4d8f..0c08f28a 100644 --- a/verifier/presentation_parser_test.go +++ b/verifier/presentation_parser_test.go @@ -276,6 +276,76 @@ func TestParseWithSdJwt_MalformedPayload(t *testing.T) { } } +// TestParseWithSdJwt_RejectsTokensWithoutPayloadSegment covers tokens that do not have a payload +// segment at all. Such a token reaches the parser whenever decodeVpString cannot decode the vp_token +// and hands the raw string over, e.g. for a base64 encoded dcql response - and must not panic. +func TestParseWithSdJwt_RejectsTokensWithoutPayloadSegment(t *testing.T) { + tests := []struct { + name string + token string + }{ + // base64 of {"mc-query":"a.b.c"}, the shape a padded dcql response degrades into + {"padded_base64_dcql_response", "eyJtYy1xdWVyeSI6ImEuYi5jIn0="}, + {"empty_token", ""}, + {"no_separator", "notajwt"}, + {"header_only", "eyJhbGciOiJFUzI1NiJ9"}, + } + + parser := &ConfigurableSdJwtParser{} + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := parser.ParseWithSdJwt([]byte(tc.token)) + if err != ErrorInvalidJWTFormat { + t.Errorf("Expected ErrorInvalidJWTFormat, got %v", err) + } + }) + } +} + +func TestParseWithSdJwt_MissingHolder(t *testing.T) { + parser := &ConfigurableSdJwtParser{} + token := buildFakeJWT(map[string]interface{}{ + "vp": map[string]interface{}{ + "verifiableCredential": []interface{}{"some-sd-jwt"}, + }, + }) + + _, err := parser.ParseWithSdJwt([]byte(token)) + if err != ErrorPresentationNoHolder { + t.Errorf("Expected ErrorPresentationNoHolder, got %v", err) + } +} + +func TestParseWithSdJwt_VerifiableCredentialNotAnArray(t *testing.T) { + parser := &ConfigurableSdJwtParser{} + token := buildFakeJWT(map[string]interface{}{ + "vp": map[string]interface{}{ + "holder": "did:web:holder", + "verifiableCredential": map[string]interface{}{"id": "urn:vc:1"}, + }, + }) + + _, err := parser.ParseWithSdJwt([]byte(token)) + if err != ErrorVCNotArray { + t.Errorf("Expected ErrorVCNotArray, got %v", err) + } +} + +func TestParseWithSdJwt_CredentialNotAString(t *testing.T) { + parser := &ConfigurableSdJwtParser{} + token := buildFakeJWT(map[string]interface{}{ + "vp": map[string]interface{}{ + "holder": "did:web:holder", + "verifiableCredential": []interface{}{map[string]interface{}{"id": "urn:vc:1"}}, + }, + }) + + _, err := parser.ParseWithSdJwt([]byte(token)) + if err != ErrorInvalidSdJwt { + t.Errorf("Expected ErrorInvalidSdJwt, got %v", err) + } +} + // --- Tests for VP signature verification --- // buildSignedJWT creates a properly signed JWT with the given payload using a random EC key. From 1f1e2f02b5ef41a595e989e26ddcae52e97b76e5 Mon Sep 17 00:00:00 2001 From: Victor Ramperez Date: Tue, 18 Aug 2026 12:58:57 +0200 Subject: [PATCH 2/3] fix PR comments --- verifier/presentation_parser.go | 10 ++++----- verifier/presentation_parser_test.go | 33 ++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/verifier/presentation_parser.go b/verifier/presentation_parser.go index 53e3bddd..23f9a201 100644 --- a/verifier/presentation_parser.go +++ b/verifier/presentation_parser.go @@ -22,7 +22,6 @@ var ErrorNoValidationEndpoint = errors.New("no_validation_endpoint_configured") var ErrorNoValidationHost = errors.New("no_validation_host_configured") var ErrorInvalidSdJwt = errors.New("credential_is_not_sd_jwt") var ErrorPresentationNoCredentials = errors.New("presentation_not_contains_credentials") -var ErrorPresentationNoHolder = errors.New("presentation_not_contains_holder") var ErrorInvalidProof = errors.New("invalid_vp_proof") var ErrorVCNotArray = errors.New("verifiable_credential_not_array") var ErrorInvalidJWTFormat = errors.New("invalid_jwt_format") @@ -549,12 +548,11 @@ func (sjp *ConfigurableSdJwtParser) ParseWithSdJwt(tokenBytes []byte) (presentat return nil, err } - holder, ok := vp[common.VPKeyHolder].(string) - if !ok { - logging.Log().Warn("VP does not contain a string holder") - return nil, ErrorPresentationNoHolder + // the holder is optional here, mirroring parseJSONLDPresentation and parseJWTPresentation. Holder binding + // for sd-jwts is done via the kb-jwt, not via this claim, so a missing one must not reject the presentation. + if holder, ok := vp[common.VPKeyHolder].(string); ok { + presentation.Holder = holder } - presentation.Holder = holder vcArray, ok := vcs.([]interface{}) if !ok { diff --git a/verifier/presentation_parser_test.go b/verifier/presentation_parser_test.go index 0c08f28a..c94c472d 100644 --- a/verifier/presentation_parser_test.go +++ b/verifier/presentation_parser_test.go @@ -302,17 +302,32 @@ func TestParseWithSdJwt_RejectsTokensWithoutPayloadSegment(t *testing.T) { } } -func TestParseWithSdJwt_MissingHolder(t *testing.T) { +// TestParseWithSdJwt_MissingHolderIsAccepted documents that the holder is optional, just like it is in +// parseJSONLDPresentation and parseJWTPresentation. Wallets do not have to send it for sd-jwt presentations, +// since holder binding is done via the kb-jwt. +func TestParseWithSdJwt_MissingHolderIsAccepted(t *testing.T) { + tests := []struct { + name string + vp map[string]interface{} + }{ + {"absent_holder", map[string]interface{}{"verifiableCredential": []interface{}{}}}, + {"non_string_holder", map[string]interface{}{"holder": map[string]interface{}{"not": "a string"}, "verifiableCredential": []interface{}{}}}, + } + parser := &ConfigurableSdJwtParser{} - token := buildFakeJWT(map[string]interface{}{ - "vp": map[string]interface{}{ - "verifiableCredential": []interface{}{"some-sd-jwt"}, - }, - }) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + token := buildFakeJWT(map[string]interface{}{"vp": tc.vp}) - _, err := parser.ParseWithSdJwt([]byte(token)) - if err != ErrorPresentationNoHolder { - t.Errorf("Expected ErrorPresentationNoHolder, got %v", err) + presentation, err := parser.ParseWithSdJwt([]byte(token)) + if err != nil { + t.Errorf("Expected no error, got %v", err) + return + } + if presentation.Holder != "" { + t.Errorf("Expected an empty holder, got %s", presentation.Holder) + } + }) } } From ef073cea35f9dbf3cdc115046adf0bd7d27a96af Mon Sep 17 00:00:00 2001 From: Victor Ramperez Date: Wed, 19 Aug 2026 11:46:37 +0200 Subject: [PATCH 3/3] revert to follow spec --- openapi/api_api.go | 38 +++++++++++++++++------- openapi/api_api_test.go | 65 +++++++++++++++++++++++++++-------------- 2 files changed, 71 insertions(+), 32 deletions(-) diff --git a/openapi/api_api.go b/openapi/api_api.go index 2a754b28..a24250a5 100644 --- a/openapi/api_api.go +++ b/openapi/api_api.go @@ -53,6 +53,7 @@ var ErrorMessageNoToken = ErrorMessage{"no_token_provided", "Authentication requ var ErrorMessageNoPresentationSubmission = ErrorMessage{"no_presentation_submission_provided", "Authentication requires a presentation submission provided as a form parameter."} var ErrorMessageNoCallback = ErrorMessage{"NoCallbackProvided", "A callback address has to be provided as query-parameter."} var ErrorMessageUnableToDecodeToken = ErrorMessage{"invalid_token", "Token could not be decoded."} +var ErrorMessageInvalidTokenEncoding = ErrorMessage{"invalid_token", "The vp_token has to be base64url encoded without padding."} var ErrorMessageUnableToDecodeCredential = ErrorMessage{"invalid_token", "Could not read the credential(s) inside the token."} var ErrorMessageUnableToDecodeHolder = ErrorMessage{"invalid_token", "Could not read the holder inside the token."} var ErrorMessageNoSuchSession = ErrorMessage{"no_session", "Session with the requested id is not available."} @@ -68,6 +69,8 @@ var ErrorMessageInvalidRequestedTokenType = ErrorMessage{"invalid_requested_toke var ErrorMessageNoRefreshToken = ErrorMessage{"no_refresh_token_provided", "Refresh token requests require a refresh_token."} var ErrorMessageInvalidRefreshToken = ErrorMessage{"invalid_refresh_token", "The provided refresh token is invalid or expired."} +var ErrorInvalidTokenEncoding = errors.New("invalid_token_encoding") + func getApiVerifier() verifier.Verifier { if apiVerifier == nil { apiVerifier = verifier.GetVerifier() @@ -633,7 +636,12 @@ func extractVpFromToken(c *gin.Context, vpToken string) (parsedPresentation *com } func tokenToPresentation(c *gin.Context, vpToken string) (parsedPresentation *common.Presentation, err error) { - tokenBytes := decodeVpString(vpToken) + tokenBytes, err := decodeVpString(vpToken) + if err != nil { + logging.Log().Infof("Was not able to decode the token %s. Err: %v", vpToken, err) + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorMessageInvalidTokenEncoding) + return nil, err + } isSdJWT, parsedPresentation, err := isSdJWT(c, vpToken) if isSdJWT && err != nil { @@ -670,7 +678,12 @@ func tokenToPresentation(c *gin.Context, vpToken string) (parsedPresentation *co } func getPresentationFromQuery(c *gin.Context, vpToken string) (parsedPresentation *common.Presentation, err error) { - tokenBytes := decodeVpString(vpToken) + tokenBytes, err := decodeVpString(vpToken) + if err != nil { + logging.Log().Infof("Was not able to decode the token %s. Err: %v", vpToken, err) + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorMessageInvalidTokenEncoding) + return nil, err + } // First, try the OID4VP drafts 22-24 shape: // {"": ""} @@ -751,17 +764,22 @@ func isSdJWT(c *gin.Context, vpToken string) (isSdJwt bool, presentation *common } // decodeVpString - In newer versions of OID4VP the token is not encoded as a whole but only its segments separately. This function covers the older and newer versions. -// While the spec asks for raw(unpadded) base64url, clients do emit padded and/or standard-alphabet base64 - e.g. javas Base64.getUrlEncoder() without withoutPadding() -// or pythons base64.b64encode() - so all four variants are accepted. A token that is not base64 at all(a compact JWT, an SD-JWT or a plain json dcql response) is -// returned unchanged, since '.', '~', '{', '"' and ':' are not part of any base64 alphabet and thus can never be decoded by accident. -func decodeVpString(vpToken string) (tokenBytes []byte) { - for _, encoding := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding} { - if decodedBytes, err := encoding.DecodeString(vpToken); err == nil { - return decodedBytes +// OID4VP requires the vp_token to be base64url encoded without padding, thus only base64.RawURLEncoding is accepted. A token that is not base64 at all(a compact JWT, +// an SD-JWT or a plain json dcql response) is returned unchanged, since '.', '~', '{', '"' and ':' are part of no base64 alphabet and thus can never be decoded by +// accident. A token that decodes only with padding or only with the standard alphabet is none of those either, so instead of handing the still-encoded string on as +// an opaque failure further down the parsers, it is reported as the malformed request that it is. +func decodeVpString(vpToken string) (tokenBytes []byte, err error) { + if decodedBytes, decodeErr := base64.RawURLEncoding.DecodeString(vpToken); decodeErr == nil { + return decodedBytes, nil + } + for _, encoding := range []*base64.Encoding{base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding} { + if _, decodeErr := encoding.DecodeString(vpToken); decodeErr == nil { + logging.Log().Warnf("The vp_token is base64 encoded, but not with the raw(unpadded) url-safe alphabet required by OID4VP.") + return nil, ErrorInvalidTokenEncoding } } logging.Log().Debugf("The token is not base64 encoded, using it as is.") - return []byte(vpToken) + return []byte(vpToken), nil } func handleAuthenticationResponse(c *gin.Context, state string, presentation *common.Presentation) { diff --git a/openapi/api_api_test.go b/openapi/api_api_test.go index 14af666d..2004e918 100644 --- a/openapi/api_api_test.go +++ b/openapi/api_api_test.go @@ -675,7 +675,9 @@ func buildSignedVPToken(t *testing.T) string { // - OID4VP draft 25+: {"": ["", ...]} // Both must be accepted (backward-compatible). Inputs that are neither a query // map nor parseable as a single token must return (nil, nil) so the caller -// falls through to the flat-string presentation parsing path. +// falls through to the flat-string presentation parsing path. A token that is +// base64, but not in the raw url-safe encoding OID4VP requires, must be rejected +// with a 400 instead of falling through as an undecoded string. func TestGetPresentationFromQuery(t *testing.T) { logging.Configure(LOGGING_CONFIG) @@ -690,24 +692,28 @@ func TestGetPresentationFromQuery(t *testing.T) { if err != nil { t.Fatalf("Failed to marshal draft 25+ query map: %v", err) } - // clients that base64 encode the query map with a padding-emitting encoder(java, python) send a - // trailing '=', which used to be rejected and made the raw string fall through to the sd-jwt parser + // a spec-conform client base64url encodes the query map without padding + encodedStringShapeMap := base64.RawURLEncoding.EncodeToString(stringShapeMap) + // clients that encode it with a padding-emitting encoder(java, python) send a trailing '=' instead, which + // used to make the raw string fall through to the sd-jwt parser. It has to be reported, not compensated. paddedStringShapeMap := base64.URLEncoding.EncodeToString(withBase64Padding(stringShapeMap)) - paddedArrayShapeMap := base64.StdEncoding.EncodeToString(withBase64Padding(arrayShapeMap)) + standardAlphabetArrayShapeMap := base64.StdEncoding.EncodeToString(withBase64Padding(arrayShapeMap)) type test struct { testName string vpToken string expectedNonNil bool + expectedErr error } tests := []test{ - {"OID4VP drafts 22-24 single-string-shape query map should be parsed.", string(stringShapeMap), true}, - {"OID4VP draft 25+ array-shape query map should be parsed.", string(arrayShapeMap), true}, - {"A padded base64url encoded query map should be parsed.", paddedStringShapeMap, true}, - {"A padded standard-base64 encoded query map should be parsed.", paddedArrayShapeMap, true}, - {"A flat sd-jwt (no query map) should return nil so the caller falls through.", sdJwt, false}, - {"A non-JSON token should return nil so the caller falls through.", "this-is-not-json", false}, + {"OID4VP drafts 22-24 single-string-shape query map should be parsed.", string(stringShapeMap), true, nil}, + {"OID4VP draft 25+ array-shape query map should be parsed.", string(arrayShapeMap), true, nil}, + {"A raw base64url encoded query map should be parsed.", encodedStringShapeMap, true, nil}, + {"A padded base64url encoded query map should be rejected.", paddedStringShapeMap, false, ErrorInvalidTokenEncoding}, + {"A standard-alphabet base64 encoded query map should be rejected.", standardAlphabetArrayShapeMap, false, ErrorInvalidTokenEncoding}, + {"A flat sd-jwt (no query map) should return nil so the caller falls through.", sdJwt, false, nil}, + {"A non-JSON token should return nil so the caller falls through.", "this-is-not-json", false, nil}, } for _, tc := range tests { @@ -724,6 +730,16 @@ func TestGetPresentationFromQuery(t *testing.T) { parsed, parseErr := getPresentationFromQuery(testContext, tc.vpToken) + if tc.expectedErr != nil { + if parseErr != tc.expectedErr { + t.Errorf("%s - Expected error %v but was %v", tc.testName, tc.expectedErr, parseErr) + return + } + if recorder.Code != http.StatusBadRequest { + t.Errorf("%s - Expected the request to be answered with a 400 but was %d.", tc.testName, recorder.Code) + } + return + } if parseErr != nil { t.Errorf("%s - Unexpected error: %v", tc.testName, parseErr) return @@ -777,25 +793,30 @@ func TestDecodeVpString(t *testing.T) { decoded := []byte{0xfb, 0xff, 0xbe, 0x2a} type test struct { - testName string - vpToken string - expected []byte + testName string + vpToken string + expected []byte + expectedErr error } tests := []test{ - {"Raw base64url should be decoded.", base64.RawURLEncoding.EncodeToString(decoded), decoded}, - {"Padded base64url should be decoded.", base64.URLEncoding.EncodeToString(decoded), decoded}, - {"Raw standard base64 should be decoded.", base64.RawStdEncoding.EncodeToString(decoded), decoded}, - {"Padded standard base64 should be decoded.", base64.StdEncoding.EncodeToString(decoded), decoded}, - {"A compact jwt should be returned unchanged.", "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtZSJ9.sig", []byte("eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtZSJ9.sig")}, - {"An sd-jwt should be returned unchanged.", "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtZSJ9.sig~disclosure~", []byte("eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtZSJ9.sig~disclosure~")}, - {"A plain json query map should be returned unchanged.", `{"lpc-query":"a.b.c"}`, []byte(`{"lpc-query":"a.b.c"}`)}, - {"A non-base64 string should be returned unchanged.", "this-is-not-base64!", []byte("this-is-not-base64!")}, + {"Raw base64url, the only encoding OID4VP allows, should be decoded.", base64.RawURLEncoding.EncodeToString(decoded), decoded, nil}, + {"Padded base64url should be rejected.", base64.URLEncoding.EncodeToString(decoded), nil, ErrorInvalidTokenEncoding}, + {"Raw standard base64 should be rejected.", base64.RawStdEncoding.EncodeToString(decoded), nil, ErrorInvalidTokenEncoding}, + {"Padded standard base64 should be rejected.", base64.StdEncoding.EncodeToString(decoded), nil, ErrorInvalidTokenEncoding}, + {"A compact jwt should be returned unchanged.", "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtZSJ9.sig", []byte("eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtZSJ9.sig"), nil}, + {"An sd-jwt should be returned unchanged.", "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtZSJ9.sig~disclosure~", []byte("eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJtZSJ9.sig~disclosure~"), nil}, + {"A plain json query map should be returned unchanged.", `{"lpc-query":"a.b.c"}`, []byte(`{"lpc-query":"a.b.c"}`), nil}, + {"A non-base64 string should be returned unchanged.", "this-is-not-base64!", []byte("this-is-not-base64!"), nil}, } for _, tc := range tests { t.Run(tc.testName, func(t *testing.T) { - actual := decodeVpString(tc.vpToken) + actual, err := decodeVpString(tc.vpToken) + if err != tc.expectedErr { + t.Errorf("%s - Expected error %v but was %v", tc.testName, tc.expectedErr, err) + return + } if !bytes.Equal(actual, tc.expected) { t.Errorf("%s - Expected %s but was %s", tc.testName, tc.expected, actual) }