From e0cfcaf674ebe32d28ecab04eeca41cc7b23df88 Mon Sep 17 00:00:00 2001 From: Ian Chin Wang Date: Mon, 29 Jun 2026 11:33:25 -0400 Subject: [PATCH 1/2] Return URL-safe nonce in challenge sessions Signed-off-by: Ian Chin Wang --- integration-tests/utils/generators.py | 16 ++++-- verification/api/challengeresponsesession.go | 58 ++++++++++++++++++++ verification/api/handler_test.go | 26 +++++++-- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/integration-tests/utils/generators.py b/integration-tests/utils/generators.py index 513f06ef..6ec930e6 100644 --- a/integration-tests/utils/generators.py +++ b/integration-tests/utils/generators.py @@ -91,6 +91,10 @@ def generate_artefacts_from_response(response, scheme, evidence, signing, keys, generate_expected_result_from_response(response, scheme, expected) +def base64url_to_base64(value): + return value.replace('-', '+').replace('_', '/') + + def generate_expected_result_from_response(response, scheme, expected): os.makedirs(f'{GENDIR}/expected', exist_ok=True) @@ -99,15 +103,17 @@ def generate_expected_result_from_response(response, scheme, expected): nonce = response.json()["nonce"] if scheme == 'psa' and nonce: + translated_nonce = base64url_to_base64(nonce) update_json( infile, - {"PSA_IOT": {'ear.veraison.annotated-evidence': {f'psa-nonce': nonce}}}, + {"PSA_IOT": {'ear.veraison.annotated-evidence': {f'psa-nonce': translated_nonce}}}, outfile, ) elif scheme == 'cca' and nonce: + translated_nonce = base64url_to_base64(nonce) update_json( infile, - {"CCA_REALM": {'ear.veraison.annotated-evidence': {f'cca-realm-challenge': nonce}}}, + {"CCA_REALM": {'ear.veraison.annotated-evidence': {f'cca-realm-challenge': translated_nonce}}}, outfile, ) else: @@ -138,15 +144,16 @@ def generate_evidence(scheme, evidence, nonce, signing, outname): if scheme == 'psa' and nonce: claims_file = f'{GENDIR}/claims/{scheme}.{evidence}.json' + translated_nonce = base64url_to_base64(nonce) update_json( f'data/claims/{scheme}.{evidence}.json', - {f'{scheme}-nonce': nonce}, + {f'{scheme}-nonce': translated_nonce}, claims_file, ) elif scheme == 'cca' and nonce: claims_file = f'{GENDIR}/claims/{scheme}.{evidence}.json' # convert nonce from base64url to base64 - translated_nonce = nonce.replace('-', '+').replace('_', '/') + translated_nonce = base64url_to_base64(nonce) update_json( f'data/claims/{scheme}.{evidence}.json', {'cca-realm-delegated-token': {f'cca-realm-challenge': translated_nonce}}, @@ -306,4 +313,3 @@ def substitute_random_corim_id(path): with tempfile.NamedTemporaryFile(delete=False, mode='w') as tf: json.dump(data, tf) return tf.name - diff --git a/verification/api/challengeresponsesession.go b/verification/api/challengeresponsesession.go index 33e7035e..a8e0000c 100644 --- a/verification/api/challengeresponsesession.go +++ b/verification/api/challengeresponsesession.go @@ -6,6 +6,7 @@ package api import ( + "encoding/base64" "encoding/json" "fmt" "time" @@ -79,6 +80,63 @@ type ChallengeResponseSession struct { Result *string `json:"result,omitempty"` } +type challengeResponseSessionJSON struct { + Status Status `json:"status"` + Nonce string `json:"nonce"` + Expiry time.Time `json:"expiry"` + Accept []string `json:"accept"` + Evidence *EvidenceBlob `json:"evidence,omitempty"` + Result *string `json:"result,omitempty"` +} + +func (o ChallengeResponseSession) MarshalJSON() ([]byte, error) { + return json.Marshal(challengeResponseSessionJSON{ + Status: o.Status, + Nonce: base64.URLEncoding.EncodeToString(o.Nonce), + Expiry: o.Expiry, + Accept: o.Accept, + Evidence: o.Evidence, + Result: o.Result, + }) +} + +func decodeSessionNonce(v string) ([]byte, error) { + nonce, err := base64.URLEncoding.DecodeString(v) + if err == nil { + return nonce, nil + } + + // Keep reading sessions created before the nonce wire format switched + // from standard base64 to base64url. + if nonce, stdErr := base64.StdEncoding.DecodeString(v); stdErr == nil { + return nonce, nil + } + + return nil, err +} + +func (o *ChallengeResponseSession) UnmarshalJSON(b []byte) error { + var session challengeResponseSessionJSON + + if err := json.Unmarshal(b, &session); err != nil { + return err + } + + nonce, err := decodeSessionNonce(session.Nonce) + if err != nil { + return fmt.Errorf("nonce must be valid base64url: %w", err) + } + + o.Status = session.Status + o.Nonce = nonce + o.Expiry = session.Expiry + o.Accept = session.Accept + o.Evidence = session.Evidence + o.Result = session.Result + + return nil +} + func (o *ChallengeResponseSession) SetEvidence(mt string, evidence []byte) { o.Evidence = &EvidenceBlob{Type: mt, Value: evidence} } diff --git a/verification/api/handler_test.go b/verification/api/handler_test.go index f2b44447..8f26a275 100644 --- a/verification/api/handler_test.go +++ b/verification/api/handler_test.go @@ -4,6 +4,7 @@ package api import ( "bytes" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -45,7 +46,7 @@ var ( testJSONBody = `{ "k": "v" }` testSession = `{ "status": "waiting", - "nonce": "mVubqtg3Wa5GSrx3L/2B99cQU2bMQFVYUI9aTmDYi64=", + "nonce": "mVubqtg3Wa5GSrx3L_2B99cQU2bMQFVYUI9aTmDYi64=", "expiry": "2022-07-13T13:50:24.520525+01:00", "accept": [ "application/eat_cwt;profile=\"http://arm.com/psa/2.0.0\"", @@ -61,7 +62,7 @@ var ( }` testProcessingSession = `{ "status": "processing", - "nonce": "mVubqtg3Wa5GSrx3L/2B99cQU2bMQFVYUI9aTmDYi64=", + "nonce": "mVubqtg3Wa5GSrx3L_2B99cQU2bMQFVYUI9aTmDYi64=", "expiry": "2022-07-13T13:50:24.520525+01:00", "accept": [ "application/eat_cwt;profile=\"http://arm.com/psa/2.0.0\"", @@ -75,7 +76,7 @@ var ( }` testCompleteSession = `{ "status": "complete", - "nonce": "mVubqtg3Wa5GSrx3L/2B99cQU2bMQFVYUI9aTmDYi64=", + "nonce": "mVubqtg3Wa5GSrx3L_2B99cQU2bMQFVYUI9aTmDYi64=", "expiry": "2022-07-13T13:50:24.520525+01:00", "accept": [ "application/eat_cwt;profile=\"http://arm.com/psa/2.0.0\"", @@ -117,6 +118,17 @@ var ( } ) +func responseNonce(t *testing.T, response []byte) string { + t.Helper() + + var body struct { + Nonce string `json:"nonce"` + } + require.NoError(t, json.Unmarshal(response, &body)) + + return body.Nonce +} + func TestHandler_NewChallengeResponse_UnsupportedAccept(t *testing.T) { h := &Handler{} @@ -269,11 +281,11 @@ func TestHandler_NewChallengeResponse_NonceParameter(t *testing.T) { expectedType := ChallengeResponseSessionMediaType expectedLocationRE := sessionURIRegexp expectedSessionStatus := StatusWaiting - expectedNonce := []byte("nonce-value") + expectedNonce := testNonce + expectedNonceURLSafe := base64.URLEncoding.EncodeToString(expectedNonce) qParams := url.Values{} - // b64("nonce-value") => "bm9uY2UtdmFsdWU=" - qParams.Add("nonce", "bm9uY2UtdmFsdWU=") + qParams.Add("nonce", expectedNonceURLSafe) w := httptest.NewRecorder() @@ -289,6 +301,7 @@ func TestHandler_NewChallengeResponse_NonceParameter(t *testing.T) { assert.Equal(t, expectedCode, w.Code) assert.Equal(t, expectedType, w.Result().Header.Get("Content-Type")) assert.Regexp(t, expectedLocationRE, w.Result().Header.Get("Location")) + assert.Equal(t, expectedNonceURLSafe, responseNonce(t, w.Body.Bytes())) assert.Equal(t, expectedNonce, body.Nonce) assert.Nil(t, body.Evidence) assert.Nil(t, body.Result) @@ -334,6 +347,7 @@ func TestHandler_NewChallengeResponse_NonceSizeParameter(t *testing.T) { assert.Equal(t, expectedCode, w.Code) assert.Equal(t, expectedType, w.Result().Header.Get("Content-Type")) assert.Regexp(t, expectedLocationRE, w.Result().Header.Get("Location")) + assert.Regexp(t, `^[A-Za-z0-9_-]+={0,2}$`, responseNonce(t, w.Body.Bytes())) assert.Len(t, body.Nonce, expectedNonceSize) assert.Nil(t, body.Evidence) assert.Nil(t, body.Result) From a4a410ea03e1bab978453494c30032701b3a52f0 Mon Sep 17 00:00:00 2001 From: Ian Chin Wang Date: Mon, 29 Jun 2026 13:38:37 -0400 Subject: [PATCH 2/2] Use decoder for nonce test conversion Signed-off-by: Ian Chin Wang --- integration-tests/utils/generators.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/integration-tests/utils/generators.py b/integration-tests/utils/generators.py index 6ec930e6..e96560be 100644 --- a/integration-tests/utils/generators.py +++ b/integration-tests/utils/generators.py @@ -1,6 +1,7 @@ # Copyright 2023-2026 Contributors to the Veraison project. # SPDX-License-Identifier: Apache-2.0 import ast +import base64 import json import os import shutil @@ -92,7 +93,9 @@ def generate_artefacts_from_response(response, scheme, evidence, signing, keys, def base64url_to_base64(value): - return value.replace('-', '+').replace('_', '/') + # evcli claim JSON uses standard base64, while session nonces are base64url. + decoded = base64.urlsafe_b64decode(value) + return base64.b64encode(decoded).decode('ascii') def generate_expected_result_from_response(response, scheme, expected):