dexter has uploaded this change for review. ( https://gerrit.osmocom.org/c/onomondo-eim/+/43175?usp=email )
Change subject: esipa_json_handler: Add ESipa JSON bindings ......................................................................
esipa_json_handler: Add ESipa JSON bindings
Since SGP.32, version 1.2, the JSON ESipa bindings are mandatory for the eIM (for the IPAd they are still optional). With this patch we add support for the JSON ESipa bindings.
Change-Id: Id73ae78a976608fe6f367f8c3b92d8b7b4e195d4 Related: SYS#8100 --- M src/esipa_asn1_http_handler.erl M src/esipa_json_handler.erl A src/esipa_json_http_handler.erl M src/onomondo_eim_app.erl 4 files changed, 479 insertions(+), 41 deletions(-)
git pull ssh://gerrit.osmocom.org:29418/onomondo-eim refs/changes/75/43175/1
diff --git a/src/esipa_asn1_http_handler.erl b/src/esipa_asn1_http_handler.erl index 5f6ff9d..3b03826 100644 --- a/src/esipa_asn1_http_handler.erl +++ b/src/esipa_asn1_http_handler.erl @@ -25,7 +25,7 @@ {ok, IpaToEim} = esipa_asn1_codec:decode_ipa_to_eim(Data), {EsipaMsgType, _} = IpaToEim, logger:info( - "Handling incoming IPAd request: ~p,~nPeer=~p, Pid=~p~n", + "Handling incoming IPAd request (ASN.1): ~p,~nPeer=~p, Pid=~p~n", [EsipaMsgType, maps:get(peer, Req0), maps:get(pid, Req0)] ), logger:debug( @@ -64,7 +64,7 @@ maps:get(pid, Req0), [{[{procedureError, abortedOrder}]}], Reason ), logger:info( - "Handling of IPAd request terminated unexpectedly, Reason=~p Pid=~p~n", + "Handling of IPAd request (ASN.1) terminated unexpectedly, Reason=~p Pid=~p~n", [Reason, maps:get(pid, Req0)] ), cowboy_req:reply(500, ?RESPONSE_HEADERS, <<"Internal Server Error">>, Req0) diff --git a/src/esipa_json_handler.erl b/src/esipa_json_handler.erl index 62b4396..c5d8368 100644 --- a/src/esipa_json_handler.erl +++ b/src/esipa_json_handler.erl @@ -2,47 +2,419 @@ % % SPDX-License-Identifier: AGPL-3.0-only % +% Author: Philipp Maier pmaier@sysmocom.de / sysmocom - s.f.m.c. GmbH + -module(esipa_json_handler). --behavior(cowboy_handler).
--export([init/2]). +-export([handle_json/2]).
-init(Req0, State) -> - Path = cowboy_req:path(Req0), - Req = - case is_esipa_endpoint(Path) of +% Decode hex-string to binary +to_binary(absent) -> + absent; +to_binary(HexStr) -> + utils:hex_to_binary(HexStr). + +% Encode binary to hex-string +to_hex(absent) -> + absent; +to_hex(Binary) -> + utils:binary_to_hex(Binary). + +% Decode binary from base64 +from_base64(absent) -> + absent; +from_base64(ValueBase64) -> + base64:decode(ValueBase64). + +% Encode binary to base64 +to_base64(absent) -> + absent; +to_base64(ValueBinary) -> + base64:encode(ValueBinary). + +% Decode base64 encoded string to map (ASN.1) +from_base64asn1(_, _, absent) -> + absent; +from_base64asn1(Asn1SpecName, Asn1TypeName, Asn1ValueBase64) -> + Asn1ValueBin = base64:decode(Asn1ValueBase64), + {ok, Asn1ValueMap} = Asn1SpecName:decode(Asn1TypeName, Asn1ValueBin), + Asn1ValueMap. + +% Encode map (ASN.1) to base64 encoded string +to_base64asn1(_, _, absent) -> + absent; +to_base64asn1(Asn1SpecName, Asn1TypeName, Asn1ValueMap) -> + {ok, Asn1ValueBin} = Asn1SpecName:encode(Asn1TypeName, Asn1ValueMap), + base64:encode(Asn1ValueBin). + +% Join a list of tuples into a map +join_loop(Map, []) -> + Map; +join_loop(Map, [TuplesHead | TuplesTail]) -> + {Key, Value} = TuplesHead, + case Value of + absent -> + join_loop(Map, TuplesTail); + _ -> + join_loop(maps:merge(Map, #{Key => Value}), TuplesTail) + end. +join(Tuples) -> + join_loop(#{}, Tuples). + +% Chose the first (there should only be exactly one) non-absent member from the given tuple list +choose([]) -> + throw("missing required JSON element (choice, oneOf)"); +choose([TuplesHead | TuplesTail]) -> + {_, Value} = TuplesHead, + case Value of + absent -> + choose(TuplesTail); + _ -> + TuplesHead + end. + +%GSMA SGP.32, section 6.4.1.1 +handle_json(Pid, {<<"/gsma/rsp2/esipa/initiateAuthentication">>, EsipaReq}) -> + Req = join([ + {euiccChallenge, from_base64(maps:get(<<"euiccChallenge">>, EsipaReq, absent))}, + {euiccInfo1, + from_base64asn1( + 'RSPDefinitions', 'EUICCInfo1', maps:get(<<"euiccInfo1">>, EsipaReq, absent) + )}, + {smdpAddress, maps:get(<<"smdpAddress">>, EsipaReq, absent)}, + {eimTransactionId, to_binary(maps:get(<<"eimTransactionId">>, EsipaReq, absent))} + ]), + EsipaReqAsn = {initiateAuthenticationRequestEsipa, Req}, + + EimToIpaAsn = esipa_asn1_handler:handle_asn1(Pid, EsipaReqAsn), + + case EimToIpaAsn of + {initiateAuthenticationResponseEsipa, + {initiateAuthenticationOkEsipa, InitiateAuthenticationOkEsipa}} -> + join([ + {header, #{ + <<"functionExecutionStatus">> => #{<<"status">> => <<"Executed-Success">>} + }}, + {transactionId, + to_hex(maps:get(transactionId, InitiateAuthenticationOkEsipa, absent))}, + {serverSigned1, + to_base64asn1( + 'RSPDefinitions', + 'ServerSigned1', + maps:get(serverSigned1, InitiateAuthenticationOkEsipa) + )}, + {serverSignature1, + to_base64(maps:get(serverSignature1, InitiateAuthenticationOkEsipa))}, + {euiccCiPKIdentifierToBeUsed, + to_base64( + maps:get(euiccCiPKIdentifierToBeUsed, InitiateAuthenticationOkEsipa) + )}, + {serverCertificate, + to_base64asn1( + 'PKIX1Explicit88', + 'Certificate', + maps:get(serverCertificate, InitiateAuthenticationOkEsipa) + )}, + {matchingId, maps:get(matchingId, InitiateAuthenticationOkEsipa, absent)}, + {ctxParams1, + to_base64asn1( + 'RSPDefinitions', + 'CtxParams1', + maps:get(ctxParams1, InitiateAuthenticationOkEsipa, absent) + )} + ]); + _ -> + join([{header, #{<<"functionExecutionStatus">> => #{<<"status">> => <<"Failed">>}}}]) + end; +%GSMA SGP.32, section 6.4.1.2 +handle_json(Pid, {<<"/gsma/rsp2/esipa/authenticateClient">>, EsipaReq}) -> + Req = join([ + {transactionId, to_binary(maps:get(<<"transactionId">>, EsipaReq))}, + {authenticateServerResponse, + from_base64asn1( + 'SGP32Definitions', + 'SGP32-AuthenticateServerResponse', + maps:get(<<"authenticateServerResponse">>, EsipaReq) + )} + ]), + EsipaReqAsn = {authenticateClientRequestEsipa, Req}, + + EimToIpaAsn = esipa_asn1_handler:handle_asn1(Pid, EsipaReqAsn), + + case EimToIpaAsn of + {authenticateClientResponseEsipa, + {authenticateClientOkDPEsipa, AuthenticateClientOkDPEsipa}} -> + join([ + {header, #{ + <<"functionExecutionStatus">> => #{<<"status">> => <<"Executed-Success">>} + }}, + {transactionId, + to_hex(maps:get(transactionId, AuthenticateClientOkDPEsipa, absent))}, + % (The profileMetadata field has different spellings, JSON:"profileMetadata", ASN.1:"profileMetaData") + {profileMetadata, + to_base64asn1( + 'SGP32Definitions', + 'SGP32-StoreMetadataRequest', + maps:get(profileMetaData, AuthenticateClientOkDPEsipa, absent) + )}, + {smdpSigned2, + to_base64asn1( + 'RSPDefinitions', + 'SmdpSigned2', + maps:get(smdpSigned2, AuthenticateClientOkDPEsipa) + )}, + {smdpSignature2, + to_base64( + maps:get(smdpSignature2, AuthenticateClientOkDPEsipa) + )}, + {smdpCertificate, + to_base64asn1( + 'PKIX1Explicit88', + 'Certificate', + maps:get(smdpCertificate, AuthenticateClientOkDPEsipa) + )}, + {hashCc, + to_base64( + maps:get(hashCc, AuthenticateClientOkDPEsipa, absent) + )} + ]); + _ -> + join([{header, #{<<"functionExecutionStatus">> => #{<<"status">> => <<"Failed">>}}}]) + end; +%GSMA SGP.32, section 6.4.1.3 +handle_json(Pid, {<<"/gsma/rsp2/esipa/getBoundProfilePackage">>, EsipaReq}) -> + Req = join([ + {transactionId, to_binary(maps:get(<<"transactionId">>, EsipaReq))}, + {prepareDownloadResponse, + from_base64asn1( + 'SGP32Definitions', + 'SGP32-PrepareDownloadResponse', + maps:get(<<"prepareDownloadResponse">>, EsipaReq) + )} + ]), + EsipaReqAsn = {getBoundProfilePackageRequestEsipa, Req}, + + EimToIpaAsn = esipa_asn1_handler:handle_asn1(Pid, EsipaReqAsn), + + case EimToIpaAsn of + {getBoundProfilePackageResponseEsipa, + {getBoundProfilePackageOkEsipa, GetBoundProfilePackageOkEsipa}} -> + join([ + {header, #{ + <<"functionExecutionStatus">> => #{<<"status">> => <<"Executed-Success">>} + }}, + {transactionId, + to_hex(maps:get(transactionId, GetBoundProfilePackageOkEsipa, absent))}, + {boundProfilePackage, + to_base64asn1( + 'RSPDefinitions', + 'BoundProfilePackage', + maps:get(boundProfilePackage, GetBoundProfilePackageOkEsipa) + )} + ]); + {getBoundProfilePackageResponseEsipa, + {getBoundProfilePackageErrorEsipa, _GetBoundProfilePackageErrorEsipa}} -> + join([{header, #{<<"functionExecutionStatus">> => #{<<"status">> => <<"Failed">>}}}]); + _ -> + join([{header, #{<<"functionExecutionStatus">> => #{<<"status">> => <<"Failed">>}}}]) + end; +%GSMA SGP.32, section 6.4.1.5 +handle_json(Pid, {<<"/gsma/rsp2/esipa/getEimPackage">>, EsipaReq}) -> + NotifyStateChange = + case maps:get(<<"notifyStateChange">>, EsipaReq, false) of true -> - cowboy_req:reply( - 501, - #{ - <<"content-type">> => <<"application/json">> - }, - <<"{}">>, - Req0 - ); - false -> - cowboy_req:reply( - 404, - #{ - <<"content-type">> => <<"text/plain">> - }, - <<"Not Found">>, - Req0 - ) + {notifyStateChange, 'NULL'}; + _ -> + {notifyStateChange, absent} end, - {ok, Req, State}. + StateChangeCause = + case maps:get(<<"stateChangeCause">>, EsipaReq, absent) of + absent -> + {stateChangeCause, absent}; + 0 -> + {stateChangeCause, otherEim}; + 1 -> + {stateChangeCause, fallback}; + 2 -> + {stateChangeCause, emergencyProfile}; + 3 -> + {stateChangeCause, local}; + 4 -> + {stateChangeCause, reset}; + 5 -> + {stateChangeCause, immediateEnableProfile}; + 6 -> + {stateChangeCause, deviceChange}; + 7 -> + {stateChangeCause, undefined}; + StateChangeCauseInt -> + % TODO: print warning + {stateChangeCause, StateChangeCauseInt} + end, + Req = join([ + {eidValue, to_binary(maps:get(<<"eidValue">>, EsipaReq))}, + NotifyStateChange, + StateChangeCause, + {rPLMN, to_binary(maps:get(<<"rPlmn">>, EsipaReq, absent))} + ]), + EsipaReqAsn = {getEimPackageRequest, Req},
-is_esipa_endpoint(Path) -> - lists:member( - Path, - [ - <<"/gsma/rsp2/esipa/initiateAuthentication">>, - <<"/gsma/rsp2/esipa/authenticateClient">>, - <<"/gsma/rsp2/esipa/getBoundProfilePackage">>, - <<"/gsma/rsp2/esipa/transferEimPackage">>, - <<"/gsma/rsp2/esipa/getEimPackage">>, - <<"/gsma/rsp2/esipa/provideEimPackageResult">>, - <<"/gsma/rsp2/esipa/handleNotification">>, - <<"/gsma/rsp2/esipa/cancelSession">> - ] - ). + EimToIpaAsn = esipa_asn1_handler:handle_asn1(Pid, EsipaReqAsn), + + case EimToIpaAsn of + {getEimPackageResponse, {euiccPackageRequest, EuiccPackageRequest}} -> + join([ + {header, #{ + <<"functionExecutionStatus">> => #{<<"status">> => <<"Executed-Success">>} + }}, + {euiccPackageRequest, + to_base64asn1( + 'SGP32Definitions', 'EuiccPackageRequest', EuiccPackageRequest + )} + ]); + {getEimPackageResponse, {ipaEuiccDataRequest, IpaEuiccDataRequest}} -> + join([ + {header, #{ + <<"functionExecutionStatus">> => #{<<"status">> => <<"Executed-Success">>} + }}, + {ipaEuiccDataRequest, + to_base64asn1( + 'SGP32Definitions', 'IpaEuiccDataRequest', IpaEuiccDataRequest + )} + ]); + {getEimPackageResponse, {profileDownloadTriggerRequest, ProfileDownloadTriggerRequest}} -> + join([ + {header, #{ + <<"functionExecutionStatus">> => #{<<"status">> => <<"Executed-Success">>} + }}, + {profileDownloadTriggerRequest, + to_base64asn1( + 'SGP32Definitions', + 'ProfileDownloadTriggerRequest', + ProfileDownloadTriggerRequest + )} + ]); + {getEimPackageResponse, {eimPackageError, EimPackageError}} -> + EimPackageErrorInt = + case EimPackageError of + noEimPackageAvailable -> + 1; + eidNotFound -> + 2; + invalidEid -> + 3; + missingEid -> + 4; + _ -> + 127 + end, + join([ + {header, #{ + <<"functionExecutionStatus">> => #{<<"status">> => <<"Executed-Success">>} + }}, + {eimPackageError, EimPackageErrorInt} + ]); + _ -> + join([{header, #{<<"functionExecutionStatus">> => #{<<"status">> => <<"Failed">>}}}]) + end; +%GSMA SGP.32, section 6.4.1.6 +handle_json(Pid, {<<"/gsma/rsp2/esipa/provideEimPackageResult">>, EsipaReq}) -> + Req = join([ + {eidValue, to_binary(maps:get(<<"eidValue">>, EsipaReq, absent))}, + {eimPackageResult, + from_base64asn1( + 'SGP32Definitions', 'EimPackageResult', maps:get(<<"eimPackageResult">>, EsipaReq) + )} + ]), + EsipaReqAsn = {provideEimPackageResult, Req}, + + EimToIpaAsn = esipa_asn1_handler:handle_asn1(Pid, EsipaReqAsn), + + case EimToIpaAsn of + {provideEimPackageResultResponse, {eimAcknowledgements, EimAcknowledgements}} -> + join([ + {header, #{ + <<"functionExecutionStatus">> => #{<<"status">> => <<"Executed-Success">>} + }}, + {eimAcknowledgements, + to_base64asn1( + 'SGP32Definitions', 'EimAcknowledgements', EimAcknowledgements + )} + ]); + {provideEimPackageResultResponse, {emptyResponse, #{}}} -> + join([ + {header, #{ + <<"functionExecutionStatus">> => #{<<"status">> => <<"Executed-Success">>} + }} + ]); + {provideEimPackageResultError, + {provideEimPackageResultError, _ProvideEimPackageResultError}} -> + join([{header, #{<<"functionExecutionStatus">> => #{<<"status">> => <<"Failed">>}}}]); + _ -> + join([{header, #{<<"functionExecutionStatus">> => #{<<"status">> => <<"Failed">>}}}]) + end; +%GSMA SGP.32, section 6.4.1.7 +handle_json(Pid, {<<"/gsma/rsp2/esipa/handleNotification">>, EsipaReq}) -> + Req = choose([ + {pendingNotification, + from_base64asn1( + 'SGP32Definitions', + 'SGP32-PendingNotification', + maps:get(<<"pendingNotification">>, EsipaReq, absent) + )}, + {provideEimPackageResult, + from_base64asn1( + 'SGP32Definitions', + 'ProvideEimPackageResult', + maps:get(<<"provideEimPackageResult">>, EsipaReq, absent) + )} + ]), + EsipaReqAsn = {handleNotificationEsipa, Req}, + + EimToIpaAsn = esipa_asn1_handler:handle_asn1(Pid, EsipaReqAsn), + + case EimToIpaAsn of + emptyResponse -> + join([ + {header, #{ + <<"functionExecutionStatus">> => #{<<"status">> => <<"Executed-Success">>} + }} + ]); + _ -> + join([{header, #{<<"functionExecutionStatus">> => #{<<"status">> => <<"Failed">>}}}]) + end; +%GSMA SGP.32, section 6.4.1.8 +handle_json(Pid, {<<"/gsma/rsp2/esipa/cancelSession">>, EsipaReq}) -> + Req = join([ + {transactionId, to_binary(maps:get(<<"transactionId">>, EsipaReq))}, + {cancelSessionResponse, + from_base64asn1( + 'SGP32Definitions', + 'SGP32-CancelSessionResponse', + maps:get(<<"cancelSessionResponse">>, EsipaReq) + )} + ]), + EsipaReqAsn = {cancelSessionRequestEsipa, Req}, + + EimToIpaAsn = esipa_asn1_handler:handle_asn1(Pid, EsipaReqAsn), + + case EimToIpaAsn of + {cancelSessionResponseEsipa, {cancelSessionOk, _CancelSessionOk}} -> + join([ + {header, #{ + <<"functionExecutionStatus">> => #{<<"status">> => <<"Executed-Success">>} + }} + ]); + {cancelSessionResponseEsipa, {cancelSessionError, _CancelSessionError}} -> + join([{header, #{<<"functionExecutionStatus">> => #{<<"status">> => <<"Failed">>}}}]); + _ -> + join([{header, #{<<"functionExecutionStatus">> => #{<<"status">> => <<"Failed">>}}}]) + end; +%Unsupported request +handle_json(Pid, Request) -> + mnesia_db_work:finish(Pid, [{[{procedureError, abortedOrder}]}], unsupported), + logger:info( + "Handling of IPAd request failed, the request type is unsupported,~nRequest=~p,~nPid=~p~n", + [Request, Pid] + ), + {error, unsupported_request}. diff --git a/src/esipa_json_http_handler.erl b/src/esipa_json_http_handler.erl new file mode 100644 index 0000000..7d2de97 --- /dev/null +++ b/src/esipa_json_http_handler.erl @@ -0,0 +1,66 @@ +% Copyright (c) 2025 Onomondo ApS & sysmocom - s.f.m.c. GmbH. All rights reserved. +% +% SPDX-License-Identifier: AGPL-3.0-only +% +-module(esipa_json_http_handler). +-behavior(cowboy_handler). + +-define(RESPONSE_HEADERS, #{ + <<"content-type">> => <<"application/json;charset=UTF-8">>, + <<"x-admin-protocol">> => <<"gsma/rsp/v2.1.0">> +}). + +-export([init/2, terminate/3]). + +init(Req0, State) -> + Req = + case cowboy_req:header(<<"content-type">>, Req0) of + <<"application/json;charset=UTF-8">> -> + {ok, Data, _Req1} = cowboy_req:read_body(Req0), + IpaToEim = jiffy:decode(Data, [return_maps]), + Path = cowboy_req:path(Req0), + logger:info( + "Handling incoming IPAd request (JSON): ~p,~nPeer=~p, Pid=~p~n", + [Path, maps:get(peer, Req0), maps:get(pid, Req0)] + ), + logger:debug( + "Rx ESipa JSON,~nPeer=~p, Pid=~p,~nIpaToEim=~p~n", + [maps:get(peer, Req0), maps:get(pid, Req0), IpaToEim] + ), + EimToIpa = esipa_json_handler:handle_json(maps:get(pid, Req0), {Path, IpaToEim}), + logger:debug( + "Tx ESipa JSON,~nPeer=~p,Pid=~p,~nEimToIpa=~p~n", + [maps:get(peer, Req0), maps:get(pid, Req0), EimToIpa] + ), + case EimToIpa of + {error, unsupported_request} -> + cowboy_req:reply( + 400, + ?RESPONSE_HEADERS, + <<"Unsupported Request">>, + Req0 + ); + _ -> + EncodedRespBody = jiffy:encode(EimToIpa, [force_utf8]), + cowboy_req:reply(200, ?RESPONSE_HEADERS, EncodedRespBody, Req0) + end; + _ -> + cowboy_req:reply(415, ?RESPONSE_HEADERS, <<"Unsupported content-type">>, Req0) + end, + {ok, Req, State}. + +% Handle termination of HTTP requests +terminate(Reason, Req0, _State) -> + case Reason of + normal -> + ok; + _ -> + mnesia_db_work:finish( + maps:get(pid, Req0), [{[{procedureError, abortedOrder}]}], Reason + ), + logger:info( + "Handling of IPAd request (JSON) terminated unexpectedly, Reason=~p Pid=~p~n", + [Reason, maps:get(pid, Req0)] + ), + cowboy_req:reply(500, ?RESPONSE_HEADERS, <<"Internal Server Error">>, Req0) + end. diff --git a/src/onomondo_eim_app.erl b/src/onomondo_eim_app.erl index 89ffd4a..e796c96 100644 --- a/src/onomondo_eim_app.erl +++ b/src/onomondo_eim_app.erl @@ -51,7 +51,7 @@ cowboy_router:compile([ {'_', [ % SGP.32 Section 6.4.1 - {"/gsma/rsp2/esipa/[...]", esipa_json_handler, []}, + {"/gsma/rsp2/esipa/[...]", esipa_json_http_handler, []}, % SGP.32 Section 6.1.1: Any function execution request using ASN.1 binding SHALL be sent to the generic % HTTP path 'gsma/rsp2/asn1' {"/gsma/rsp2/asn1", esipa_asn1_http_handler, []},