Skip to content

Commit 5fbd4c9

Browse files
committed
fix: MCP admin handler bugs and startup ordering
Fix three issues discovered during live MCP tool testing: - Fix MCP endpoint not starting: start_mcp/1 public API added to cryptic_server so the MCP listener can be started after app boot, avoiding sys.config overriding application:set_env calls. start-server.sh updated to call cryptic_server:start_mcp(Port) after ensure_all_started. - Fix list_users crash (badarg): find_user_connection/1 used hardcoded 'user_connections' ETS table name instead of ?CONNECTION_TABLE macro (cryptic_connections). - Fix username resolution in encode_user: replace cert-CN-only lookup with GPG UID extraction (via erl_gpg_api:get_key_info) with cert CN fallback, matching cryptic_admin:get_username_for_gpg/2 behavior. - Fix online status always false: cert CN is the full GPG fingerprint domain (e.g. "33F8ED...gpg.cryptic.local"), not the chat username. Added get_chat_username_from_cert/2 which extracts the username from the cert SAN (Subject Alternative Name) first, then falls back to CN, matching how cryptic_ws_handler authenticates connections. - Fix extract_common_name returning binary instead of string: the {utf8String, CN} value was returned raw, but CONNECTION_TABLE keys are strings. Now calls binary_to_list(CN) matching cryptic_admin.
1 parent 01e24b3 commit 5fbd4c9

3 files changed

Lines changed: 182 additions & 55 deletions

File tree

scripts/start-server.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ ERL_OPTS="-sname $NODE_NAME -pa $PROJECT_ROOT/_build/default/lib/*/ebin"
168168

169169
# Build MCP config snippet for Erlang
170170
if [ "$MCP_ENABLED" = true ]; then
171-
MCP_ERL_CONFIG="application:set_env(cryptic, mcp_tcp_enabled, true), application:set_env(cryptic, mcp_tcp_port, $CRYPTIC_MCP_PORT),"
171+
MCP_ERL_CONFIG="cryptic_server:start_mcp($CRYPTIC_MCP_PORT),"
172172
MCP_STATUS_LINE="io:format(\" MCP Admin: http://127.0.0.1:$CRYPTIC_MCP_PORT/mcp/v1/admin~n\"),"
173173
else
174174
MCP_ERL_CONFIG=""
@@ -177,9 +177,9 @@ fi
177177

178178
# Erlang code to run
179179
ERL_CODE="
180-
$MCP_ERL_CONFIG
181180
application:ensure_all_started(cryptic),
182181
timer:sleep(1000),
182+
$MCP_ERL_CONFIG
183183
inet:i(),
184184
io:format(\"~nServer running!~n\"),
185185
io:format(\" WebSocket mTLS: wss://$CRYPTIC_SERVER_HOST:$CRYPTIC_SERVER_PORT/ws~n\"),

src/cryptic_mcp_admin_handler.erl

Lines changed: 171 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -683,82 +683,201 @@ encode_user(DbRef, #gpg_identity{
683683
last_seen = LastSeen,
684684
metadata = Meta
685685
}) ->
686-
Username = case get_username_from_gpg_fp(DbRef, Fp) of
687-
{ok, Name} -> list_to_binary(Name);
688-
{error, _Reason} -> <<"unknown">>
686+
DisplayName = case get_username_for_gpg(DbRef, Fp) of
687+
{ok, Name} -> to_binary(Name);
688+
undefined -> <<"unknown">>
689+
end,
690+
%% Online check: extract the chat username from the cert (SAN then CN)
691+
%% which matches the key stored in the CONNECTION_TABLE.
692+
ChatUsername = get_chat_username_from_cert(DbRef, Fp),
693+
Online = case ChatUsername of
694+
undefined -> false;
695+
U -> is_online(U)
689696
end,
690697
UserMap = #{
691698
gpg_fp => Fp,
692-
username => Username,
699+
username => DisplayName,
693700
status => Status,
694701
registered_by => RegBy,
695702
registered_at => RegAt,
696703
last_seen => LastSeen,
697-
online => is_online(Username)
704+
online => Online
698705
},
699706
maybe_attach_metadata(UserMap, Meta).
700707

701-
maybe_attach_metadata(UserMap, undefined) ->
702-
UserMap;
703-
maybe_attach_metadata(UserMap, Meta) ->
704-
try
705-
MetaMap = jsx:decode(Meta, [return_maps]),
706-
UserMap#{metadata => MetaMap}
707-
catch
708-
_:_ -> UserMap
708+
%% @private Resolve a display name for a GPG fingerprint.
709+
%% Tries the GPG key's user ID name first, then falls back to certificate CN.
710+
get_username_for_gpg(DbRef, GpgFp) ->
711+
case get_name_from_gpg_key(DbRef, GpgFp) of
712+
{ok, _} = Ok -> Ok;
713+
undefined ->
714+
case get_cn_from_cert(DbRef, GpgFp) of
715+
undefined -> undefined;
716+
CN -> {ok, CN}
717+
end
718+
end.
719+
720+
%% @private Extract the human name from the GPG key's user ID.
721+
get_name_from_gpg_key(DbRef, GpgFp) ->
722+
case cryptic_ca_store:get_gpg_identity(DbRef, GpgFp) of
723+
{ok, #gpg_identity{gpg_pub_armor = PubArmor}}
724+
when is_binary(PubArmor), byte_size(PubArmor) > 0 ->
725+
try
726+
case erl_gpg_api:get_key_info(PubArmor, "") of
727+
{ok, KeyInfo} ->
728+
UIDs = maps:get(user_ids, KeyInfo, []),
729+
extract_name_from_uids(UIDs);
730+
_ ->
731+
undefined
732+
end
733+
catch
734+
_:_ -> undefined
735+
end;
736+
_ ->
737+
undefined
738+
end.
739+
740+
extract_name_from_uids([]) -> undefined;
741+
extract_name_from_uids([UID | Rest]) ->
742+
case extract_name_from_uid(UID) of
743+
{ok, _} = Ok -> Ok;
744+
undefined -> extract_name_from_uids(Rest)
709745
end.
710746

711-
get_username_from_gpg_fp(DbRef, GpgFp) ->
747+
extract_name_from_uid(UID) when is_binary(UID) ->
748+
S = unicode:characters_to_list(UID),
749+
case string:split(S, "<") of
750+
[S] ->
751+
case string:trim(S) of
752+
[] -> undefined;
753+
Trimmed -> {ok, Trimmed}
754+
end;
755+
[Before, _] ->
756+
case string:trim(Before) of
757+
[] -> undefined;
758+
Name -> {ok, Name}
759+
end
760+
end;
761+
extract_name_from_uid(_) -> undefined.
762+
763+
%% @private Extract the chat username from a stored certificate PEM.
764+
%% Mirrors cryptic_ws_handler: tries SAN (otherName, rfc822Name, dNSName) first,
765+
%% then falls back to CN. Returns the string that matches the CONNECTION_TABLE key.
766+
get_chat_username_from_cert(DbRef, GpgFp) ->
712767
case cryptic_ca_store:list_certificates_by_user(DbRef, GpgFp) of
713-
{ok, []} ->
714-
{error, not_found};
715-
{ok, [LatestCert | _]} ->
716-
extract_username_from_cert_pem(LatestCert#certificate.cert_pem);
717-
{error, Reason} ->
718-
{error, Reason}
768+
{ok, [#certificate{cert_pem = CertPem} | _]} ->
769+
try
770+
[{'Certificate', CertDER, not_encrypted}] =
771+
public_key:pem_decode(CertPem),
772+
Cert = public_key:pkix_decode_cert(CertDER, otp),
773+
TBSCert = Cert#'OTPCertificate'.tbsCertificate,
774+
case extract_username_from_san(TBSCert) of
775+
{ok, Username} -> Username;
776+
not_found ->
777+
Subject = TBSCert#'OTPTBSCertificate'.subject,
778+
extract_common_name(Subject)
779+
end
780+
catch
781+
_:_ -> undefined
782+
end;
783+
_ ->
784+
undefined
719785
end.
720786

721-
extract_username_from_cert_pem(CertPem) ->
722-
try
723-
[DecodedEntry | _] = public_key:pem_decode(CertPem),
724-
CertDer = public_key:pem_entry_decode(DecodedEntry),
725-
Cert = public_key:pkix_decode_cert(CertDer, otp),
726-
TBSCert = Cert#'OTPCertificate'.tbsCertificate,
727-
Subject = TBSCert#'OTPTBSCertificate'.subject,
728-
case extract_common_name(Subject) of
729-
{ok, CN} -> {ok, CN};
730-
_ -> {error, no_cn}
731-
end
732-
catch
733-
_:_ ->
734-
{error, cert_decode_failed}
787+
%% @private Extract username from cert SAN extension.
788+
extract_username_from_san(TBSCert) ->
789+
case TBSCert#'OTPTBSCertificate'.extensions of
790+
asn1_NOVALUE -> not_found;
791+
Extensions ->
792+
case lists:keyfind(?'id-ce-subjectAltName',
793+
#'Extension'.extnID, Extensions) of
794+
false -> not_found;
795+
#'Extension'{extnValue = GeneralNames}
796+
when is_list(GeneralNames) ->
797+
extract_username_from_san_names(GeneralNames);
798+
_ -> not_found
799+
end
800+
end.
801+
802+
extract_username_from_san_names([]) -> not_found;
803+
extract_username_from_san_names([{otherName, {{1,3,6,1,4,1,99999,1}, Value}} | _]) ->
804+
case Value of
805+
{utf8String, U} when is_binary(U) -> {ok, binary_to_list(U)};
806+
{utf8String, U} when is_list(U) -> {ok, U};
807+
_ -> not_found
808+
end;
809+
extract_username_from_san_names([{rfc822Name, Email} | Rest]) ->
810+
EmailStr = if is_binary(Email) -> binary_to_list(Email);
811+
is_list(Email) -> Email end,
812+
case string:split(EmailStr, "@") of
813+
[Local, _] -> {ok, Local};
814+
_ -> extract_username_from_san_names(Rest)
815+
end;
816+
extract_username_from_san_names([{dNSName, Name} | Rest]) ->
817+
NameStr = if is_binary(Name) -> binary_to_list(Name);
818+
is_list(Name) -> Name end,
819+
case string:find(NameStr, ".") of
820+
nomatch -> {ok, NameStr};
821+
_ -> extract_username_from_san_names(Rest)
822+
end;
823+
extract_username_from_san_names([_ | Rest]) ->
824+
extract_username_from_san_names(Rest).
825+
826+
%% @private Extract CN from the user's certificate.
827+
get_cn_from_cert(DbRef, GpgFp) ->
828+
case cryptic_ca_store:list_certificates_by_user(DbRef, GpgFp) of
829+
{ok, [#certificate{cert_pem = CertPem} | _]} ->
830+
try
831+
[{'Certificate', CertDER, not_encrypted}] =
832+
public_key:pem_decode(CertPem),
833+
Cert = public_key:pkix_decode_cert(CertDER, otp),
834+
TBSCert = Cert#'OTPCertificate'.tbsCertificate,
835+
Subject = TBSCert#'OTPTBSCertificate'.subject,
836+
extract_common_name(Subject)
837+
catch
838+
_:_ -> undefined
839+
end;
840+
_ ->
841+
undefined
735842
end.
736843

737-
extract_common_name({rdnSequence, RDNs}) ->
738-
extract_cn_from_rdns(RDNs).
844+
to_binary(V) when is_binary(V) -> V;
845+
to_binary(V) when is_list(V) -> list_to_binary(V).
739846

740-
extract_cn_from_rdns([]) ->
741-
{error, no_cn_found};
742-
extract_cn_from_rdns([RDN | Rest]) ->
743-
case extract_cn_from_rdn(RDN) of
744-
{ok, CN} -> {ok, CN};
745-
not_found -> extract_cn_from_rdns(Rest)
847+
maybe_attach_metadata(UserMap, undefined) ->
848+
UserMap;
849+
maybe_attach_metadata(UserMap, Meta) ->
850+
try
851+
MetaMap = jsx:decode(Meta, [return_maps]),
852+
UserMap#{metadata => MetaMap}
853+
catch
854+
_:_ -> UserMap
746855
end.
747856

748-
extract_cn_from_rdn([]) ->
749-
not_found;
750-
extract_cn_from_rdn([#'AttributeTypeAndValue'{
751-
type = {2, 5, 4, 3},
752-
value = {_Type, Value}
753-
} | _]) ->
754-
{ok, Value};
755-
extract_cn_from_rdn([_ | Rest]) ->
756-
extract_cn_from_rdn(Rest).
857+
extract_common_name({rdnSequence, RDNSeq}) ->
858+
lists:foldl(fun
859+
(_, Found) when is_list(Found) -> Found;
860+
(RDNSet, undefined) ->
861+
lists:foldl(fun
862+
(_, Found) when is_list(Found) -> Found;
863+
(#'AttributeTypeAndValue'{
864+
type = ?'id-at-commonName',
865+
value = {utf8String, CN}}, _) ->
866+
binary_to_list(CN);
867+
(#'AttributeTypeAndValue'{
868+
type = ?'id-at-commonName',
869+
value = CN}, _) when is_list(CN) ->
870+
CN;
871+
(_, Acc) -> Acc
872+
end, undefined, RDNSet)
873+
end, undefined, RDNSeq);
874+
extract_common_name(_) ->
875+
undefined.
757876

758877
find_user_connection(User) when is_binary(User) ->
759878
find_user_connection(binary_to_list(User));
760879
find_user_connection(User) when is_list(User) ->
761-
case ets:lookup(user_connections, User) of
880+
case ets:lookup(?CONNECTION_TABLE, User) of
762881
[{User, Pid}] when is_pid(Pid) ->
763882
case is_process_alive(Pid) of
764883
true -> {ok, Pid};

src/cryptic_server.erl

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@
264264
-behaviour(gen_server).
265265

266266
%% API
267-
-export([start_link/0]).
267+
-export([start_link/0, start_mcp/1]).
268268

269269
%% gen_server callbacks
270270
-export([
@@ -297,6 +297,14 @@
297297
start_link() ->
298298
gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
299299

300+
%% @doc Start the MCP admin HTTP endpoint on localhost.
301+
%% Can be called after the server has booted, e.g. from the shell or a startup script.
302+
-spec start_mcp(non_neg_integer()) -> {ok, started} | {error, term()}.
303+
start_mcp(Port) when is_integer(Port) ->
304+
application:set_env(cryptic, mcp_tcp_enabled, true),
305+
application:set_env(cryptic, mcp_tcp_port, Port),
306+
start_mcp_localhost_tcp(#{port => Port}).
307+
300308
%%%===================================================================
301309
%%% gen_server callbacks
302310
%%%===================================================================

0 commit comments

Comments
 (0)