From 4cc1ad9586324126aa49584570251de8f570de80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:36:09 +0000 Subject: [PATCH 1/6] Add localhost MCP admin TCP endpoint and REST-like handler Agent-Logs-Url: https://github.com/etnt/cryptic/sessions/72a58939-24af-4b65-ab35-ea8bca6477ed Co-authored-by: etnt <5860+etnt@users.noreply.github.com> --- config/sys.config | 4 + src/cryptic.app.src | 4 + src/cryptic_mcp_admin_handler.erl | 534 ++++++++++++++++++++++++++++++ src/cryptic_server.erl | 72 ++++ 4 files changed, 614 insertions(+) create mode 100644 src/cryptic_mcp_admin_handler.erl diff --git a/config/sys.config b/config/sys.config index 20e771a..93bb348 100644 --- a/config/sys.config +++ b/config/sys.config @@ -16,6 +16,10 @@ {server_cert_file, "priv/ssl/server.crt"}, {server_key_file, "priv/ssl/server.key"}, + %% Localhost-only MCP admin HTTP endpoint (plain TCP) + {mcp_tcp_enabled, true}, + {mcp_tcp_port, 8081}, + %% Directory holding GPG bootstrap keys {bootstrap_dir, "priv/ca/bootstrap"}, diff --git a/src/cryptic.app.src b/src/cryptic.app.src index 395a7f4..01d3a89 100644 --- a/src/cryptic.app.src +++ b/src/cryptic.app.src @@ -26,6 +26,10 @@ {websocket_mtls_enabled, true}, {websocket_mtls_port, 8443}, + % Localhost-only MCP admin HTTP endpoint (plain TCP) + {mcp_tcp_enabled, true}, + {mcp_tcp_port, 8081}, + % Poll interval (seconds) {poll_interval, 2}, diff --git a/src/cryptic_mcp_admin_handler.erl b/src/cryptic_mcp_admin_handler.erl new file mode 100644 index 0000000..e9d2f19 --- /dev/null +++ b/src/cryptic_mcp_admin_handler.erl @@ -0,0 +1,534 @@ +-module(cryptic_mcp_admin_handler). + +-export([init/2]). + +-include("cryptic.hrl"). +-include("cryptic_ca.hrl"). +-include_lib("public_key/include/public_key.hrl"). + +init(Req0, State) -> + Method = cowboy_req:method(Req0), + Response = + try + handle_request(Method, Req0) + catch + _:Reason:Stack -> + ?error("MCP admin handler crashed: ~p~nStack: ~p", [Reason, Stack]), + {500, #{ + type => <<"error">>, + status => <<"error">>, + message => <<"internal_server_error">> + }, Req0} + end, + reply_json(Response, State). + +reply_json({StatusCode, BodyMap, Req}, State) -> + Body = jsx:encode(BodyMap), + Req2 = cowboy_req:reply( + StatusCode, + #{<<"content-type">> => <<"application/json">>}, + Body, + Req + ), + {ok, Req2, State}. + +handle_request(<<"GET">>, Req0) -> + Operation = cowboy_req:binding(operation, Req0), + case Operation of + <<"list_users">> -> + with_admin( + Req0, + fun(AdminFp, DbRef, Req1) -> + Filter = query_value(<<"filter">>, Req1), + list_users(DbRef, AdminFp, Filter, Req1) + end + ); + <<"get_user_info">> -> + with_admin( + Req0, + fun(_AdminFp, DbRef, Req1) -> + case cowboy_req:binding(gpg_fp, Req1) of + undefined -> + bad_request(<<"missing_gpg_fp">>, Req1); + GpgFp -> + get_user_info(DbRef, GpgFp, Req1) + end + end + ); + <<"list_certificates">> -> + with_admin( + Req0, + fun(_AdminFp, DbRef, Req1) -> + case cowboy_req:binding(gpg_fp, Req1) of + undefined -> + bad_request(<<"missing_gpg_fp">>, Req1); + GpgFp -> + list_certificates(DbRef, GpgFp, Req1) + end + end + ); + _ -> + {404, #{ + type => <<"error">>, + status => <<"error">>, + message => <<"not_found">> + }, Req0} + end; +handle_request(<<"POST">>, Req0) -> + {ok, RawBody, Req1} = cowboy_req:read_body(Req0), + case decode_json_body(RawBody) of + {error, Reason} -> + bad_request(Reason, Req1); + {ok, BodyMap} -> + Operation = cowboy_req:binding(operation, Req1), + with_admin( + Req1, + BodyMap, + fun(AdminFp, DbRef, Req2) -> + handle_post_operation(Operation, BodyMap, AdminFp, DbRef, Req2) + end + ) + end; +handle_request(_, Req0) -> + {405, #{ + type => <<"error">>, + status => <<"error">>, + message => <<"method_not_allowed">> + }, Req0}. + +handle_post_operation(<<"register_user">>, BodyMap, AdminFp, DbRef, Req) -> + with_required_fields( + BodyMap, + [<<"gpg_fp">>, <<"gpg_pub">>], + fun() -> + GpgFp = maps:get(<<"gpg_fp">>, BodyMap), + GpgPub = maps:get(<<"gpg_pub">>, BodyMap), + Metadata = maps:get(<<"metadata">>, BodyMap, null), + case cryptic_ca_store:register_user(DbRef, GpgFp, GpgPub, AdminFp, Metadata) of + ok -> + {200, #{ + type => <<"user_registered">>, + status => <<"success">>, + gpg_fp => GpgFp, + registered_by => AdminFp + }, Req}; + {error, Reason} -> + error_response(400, Reason, Req) + end + end, + Req + ); +handle_post_operation(<<"suspend_user">>, BodyMap, AdminFp, DbRef, Req) -> + with_required_fields( + BodyMap, + [<<"gpg_fp">>], + fun() -> + GpgFp = maps:get(<<"gpg_fp">>, BodyMap), + Reason = maps:get(<<"reason">>, BodyMap, <<"No reason provided">>), + case cryptic_ca_store:update_user_status(DbRef, GpgFp, <<"suspended">>) of + ok -> + Now = erlang:system_time(second), + log_audit(DbRef, <<"user_suspended">>, GpgFp, #{ + suspended_by => AdminFp, + reason => Reason + }), + {200, #{ + type => <<"suspend_user_response">>, + status => <<"success">>, + gpg_fp => GpgFp, + new_status => <<"suspended">>, + suspended_by => AdminFp, + suspended_at => Now + }, Req}; + {error, Reason2} -> + error_response(400, Reason2, Req) + end + end, + Req + ); +handle_post_operation(<<"revoke_user">>, BodyMap, AdminFp, DbRef, Req) -> + with_required_fields( + BodyMap, + [<<"gpg_fp">>], + fun() -> + GpgFp = maps:get(<<"gpg_fp">>, BodyMap), + Reason = maps:get(<<"reason">>, BodyMap, <<"No reason provided">>), + case cryptic_ca_store:update_user_status(DbRef, GpgFp, <<"revoked">>) of + ok -> + Now = erlang:system_time(second), + log_audit(DbRef, <<"user_revoked">>, GpgFp, #{ + revoked_by => AdminFp, + reason => Reason + }), + {200, #{ + type => <<"revoke_user_response">>, + status => <<"success">>, + gpg_fp => GpgFp, + new_status => <<"revoked">>, + revoked_by => AdminFp, + revoked_at => Now + }, Req}; + {error, Reason2} -> + error_response(400, Reason2, Req) + end + end, + Req + ); +handle_post_operation(<<"reactivate_user">>, BodyMap, AdminFp, DbRef, Req) -> + with_required_fields( + BodyMap, + [<<"gpg_fp">>], + fun() -> + GpgFp = maps:get(<<"gpg_fp">>, BodyMap), + case cryptic_ca_store:get_gpg_identity(DbRef, GpgFp) of + {ok, #gpg_identity{status = <<"revoked">>}} -> + {400, #{ + type => <<"reactivate_user_response">>, + status => <<"error">>, + error => <<"cannot_reactivate_revoked">>, + message => <<"Revoked users cannot be reactivated">> + }, Req}; + {ok, #gpg_identity{status = <<"active">>}} -> + {200, #{ + type => <<"reactivate_user_response">>, + status => <<"success">>, + gpg_fp => GpgFp, + message => <<"User already active">> + }, Req}; + {ok, _Identity} -> + case cryptic_ca_store:update_user_status(DbRef, GpgFp, <<"active">>) of + ok -> + Now = erlang:system_time(second), + log_audit(DbRef, <<"user_reactivated">>, GpgFp, #{ + reactivated_by => AdminFp + }), + {200, #{ + type => <<"reactivate_user_response">>, + status => <<"success">>, + gpg_fp => GpgFp, + new_status => <<"active">>, + reactivated_by => AdminFp, + reactivated_at => Now + }, Req}; + {error, Reason} -> + error_response(400, Reason, Req) + end; + {error, Reason2} -> + error_response(404, Reason2, Req) + end + end, + Req + ); +handle_post_operation(<<"revoke_certificate">>, BodyMap, AdminFp, DbRef, Req) -> + with_required_fields( + BodyMap, + [<<"serial">>, <<"reason">>], + fun() -> + Serial = maps:get(<<"serial">>, BodyMap), + Reason = maps:get(<<"reason">>, BodyMap), + case cryptic_ca_store:revoke_certificate(DbRef, Serial, AdminFp, Reason) of + ok -> + {200, #{ + type => <<"revoke_certificate_response">>, + status => <<"success">>, + serial => Serial, + reason => Reason + }, Req}; + {error, Reason2} -> + error_response(400, Reason2, Req) + end + end, + Req + ); +handle_post_operation(_Unknown, _BodyMap, _AdminFp, _DbRef, Req) -> + {404, #{ + type => <<"error">>, + status => <<"error">>, + message => <<"not_found">> + }, Req}. + +with_required_fields(Map, Keys, Fun, Req) -> + Missing = [K || K <- Keys, not maps:is_key(K, Map)], + case Missing of + [] -> Fun(); + _ -> + {400, #{ + type => <<"error">>, + status => <<"error">>, + message => <<"missing_required_fields">>, + missing => Missing + }, Req} + end. + +decode_json_body(<<>>) -> + {ok, #{}}; +decode_json_body(RawBody) -> + try + {ok, jsx:decode(RawBody, [return_maps])} + catch + _:_ -> + {error, <<"invalid_json">>} + end. + +query_value(Key, Req) -> + proplists:get_value(Key, cowboy_req:parse_qs(Req)). + +with_admin(Req, Fun) -> + with_admin(Req, #{}, Fun). + +with_admin(Req, BodyMap, Fun) -> + case get_db_ref() of + {error, Reason} -> + {500, #{ + type => <<"error">>, + status => <<"error">>, + message => iolist_to_binary(io_lib:format("~p", [Reason])) + }, Req}; + {ok, DbRef} -> + AdminFp = get_admin_fingerprint(Req, BodyMap), + case is_admin(AdminFp, DbRef) of + true -> + Fun(AdminFp, DbRef, Req); + false -> + {403, #{ + type => <<"error">>, + status => <<"error">>, + message => <<"admin_privileges_required">> + }, Req} + end + end. + +get_db_ref() -> + case application:get_env(cryptic, ca_db_ref) of + {ok, DbRef} -> + {ok, DbRef}; + _ -> + {error, ca_db_ref_not_configured} + end. + +get_admin_fingerprint(Req, BodyMap) -> + case cowboy_req:header(<<"x-admin-gpg-fp">>, Req) of + undefined -> + case maps:get(<<"admin_gpg_fp">>, BodyMap, undefined) of + undefined -> + query_value(<<"admin_gpg_fp">>, Req); + Fp -> + Fp + end; + Fp -> + Fp + end. + +is_admin(undefined, _DbRef) -> + false; +is_admin(GpgFp, DbRef) -> + case cryptic_ca_store:get_gpg_identity(DbRef, GpgFp) of + {ok, #gpg_identity{registered_by = undefined}} -> true; + _ -> false + end. + +list_users(DbRef, _AdminFp, Filter, Req) -> + case cryptic_ca_store:list_gpg_identities(DbRef) of + {ok, Identities} -> + FilteredIdentities = + case Filter of + undefined -> Identities; + <<>> -> Identities; + FilterStatus -> + lists:filter( + fun(#gpg_identity{status = S}) -> + S =:= FilterStatus + end, + Identities + ) + end, + Users = [ + encode_user(DbRef, Identity) + || Identity <- FilteredIdentities + ], + {200, #{ + type => <<"list_users_response">>, + status => <<"success">>, + count => length(Users), + users => Users + }, Req}; + {error, Reason} -> + error_response(500, Reason, Req) + end. + +get_user_info(DbRef, GpgFp, Req) -> + case cryptic_ca_store:get_gpg_identity(DbRef, GpgFp) of + {ok, #gpg_identity{ + status = Status, + registered_by = RegBy, + registered_at = RegAt, + last_seen = LastSeen, + metadata = Meta + }} -> + UserInfo = maybe_attach_metadata(#{ + gpg_fp => GpgFp, + status => Status, + registered_by => RegBy, + registered_at => RegAt, + last_seen => LastSeen + }, Meta), + {200, #{ + type => <<"get_user_info_response">>, + status => <<"success">>, + user => UserInfo + }, Req}; + {error, Reason} -> + error_response(404, Reason, Req) + end. + +list_certificates(DbRef, GpgFp, Req) -> + case cryptic_ca_store:list_certificates_by_user(DbRef, GpgFp) of + {ok, Certs} -> + CertList = lists:map( + fun(Cert) -> + #{ + serial => Cert#certificate.serial, + issued_at => Cert#certificate.issued_at, + expires_at => Cert#certificate.expires_at, + status => Cert#certificate.status, + revoked_at => Cert#certificate.revoked_at, + revoked_by => Cert#certificate.revoked_by, + revoked_reason => Cert#certificate.revoked_reason + } + end, + Certs + ), + {200, #{ + type => <<"list_certificates_response">>, + status => <<"success">>, + gpg_fp => GpgFp, + certificates => CertList, + count => length(Certs) + }, Req}; + {error, Reason} -> + error_response(404, Reason, Req) + end. + +encode_user(DbRef, #gpg_identity{ + gpg_fp = Fp, + status = Status, + registered_by = RegBy, + registered_at = RegAt, + last_seen = LastSeen, + metadata = Meta +}) -> + Username = case get_username_from_gpg_fp(DbRef, Fp) of + {ok, Name} -> list_to_binary(Name); + {error, not_found} -> <<"unknown">> + end, + UserMap = #{ + gpg_fp => Fp, + username => Username, + status => Status, + registered_by => RegBy, + registered_at => RegAt, + last_seen => LastSeen, + online => is_online(Username) + }, + maybe_attach_metadata(UserMap, Meta). + +maybe_attach_metadata(UserMap, undefined) -> + UserMap; +maybe_attach_metadata(UserMap, Meta) -> + try + MetaMap = jsx:decode(Meta, [return_maps]), + UserMap#{metadata => MetaMap} + catch + _:_ -> UserMap + end. + +get_username_from_gpg_fp(DbRef, GpgFp) -> + case cryptic_ca_store:list_certificates_by_user(DbRef, GpgFp) of + {ok, []} -> + {error, not_found}; + {ok, [LatestCert | _]} -> + extract_username_from_cert_pem(LatestCert#certificate.cert_pem); + {error, Reason} -> + {error, Reason} + end. + +extract_username_from_cert_pem(CertPem) -> + try + [DecodedEntry | _] = public_key:pem_decode(CertPem), + CertDer = public_key:pem_entry_decode(DecodedEntry), + Cert = public_key:pkix_decode_cert(CertDer, otp), + TBSCert = Cert#'OTPCertificate'.tbsCertificate, + Subject = TBSCert#'OTPTBSCertificate'.subject, + case extract_common_name(Subject) of + {ok, CN} -> {ok, CN}; + _ -> {error, no_cn} + end + catch + _:_ -> + {error, cert_decode_failed} + end. + +extract_common_name({rdnSequence, RDNs}) -> + extract_cn_from_rdns(RDNs). + +extract_cn_from_rdns([]) -> + {error, no_cn_found}; +extract_cn_from_rdns([RDN | Rest]) -> + case extract_cn_from_rdn(RDN) of + {ok, CN} -> {ok, CN}; + not_found -> extract_cn_from_rdns(Rest) + end. + +extract_cn_from_rdn([]) -> + not_found; +extract_cn_from_rdn([#'AttributeTypeAndValue'{ + type = {2, 5, 4, 3}, + value = {_Type, Value} +} | _]) -> + {ok, Value}; +extract_cn_from_rdn([_ | Rest]) -> + extract_cn_from_rdn(Rest). + +find_user_connection(User) when is_binary(User) -> + find_user_connection(binary_to_list(User)); +find_user_connection(User) when is_list(User) -> + case ets:lookup(user_connections, User) of + [{User, Pid}] when is_pid(Pid) -> + case is_process_alive(Pid) of + true -> {ok, Pid}; + false -> not_found + end; + _ -> + not_found + end. + +is_online(User) -> + case find_user_connection(User) of + {ok, _Pid} -> true; + not_found -> false + end. + +log_audit(DbRef, EventType, GpgFp, DetailsMap) -> + AuditLog = #audit_log{ + timestamp = erlang:system_time(second), + event_type = EventType, + gpg_fp = GpgFp, + invite_id = undefined, + details = jsx:encode(DetailsMap), + ip_address = <<"127.0.0.1">> + }, + cryptic_ca_store:insert_audit_log(DbRef, AuditLog). + +bad_request(Reason, Req) -> + {400, #{ + type => <<"error">>, + status => <<"error">>, + message => Reason + }, Req}. + +error_response(StatusCode, Reason, Req) -> + {StatusCode, #{ + type => <<"error">>, + status => <<"error">>, + message => iolist_to_binary(io_lib:format("~p", [Reason])) + }, Req}. diff --git a/src/cryptic_server.erl b/src/cryptic_server.erl index 7ab0b0f..1e12d85 100644 --- a/src/cryptic_server.erl +++ b/src/cryptic_server.erl @@ -385,6 +385,25 @@ continue(CfgMap) -> ?info("WebSocket mTLS server disabled~n", []) end, + %% Optional localhost-only MCP admin HTTP endpoint (plain TCP) + MCPEnabled = + case application:get_env(cryptic, mcp_tcp_enabled) of + {ok, true} -> true; + undefined -> true; + _ -> false + end, + case MCPEnabled of + true -> + MCPPort = + case application:get_env(cryptic, mcp_tcp_port) of + {ok, Port} -> Port; + undefined -> 8081 + end, + start_mcp_localhost_tcp(#{port => MCPPort}); + false -> + ?info("MCP localhost TCP endpoint disabled~n", []) + end, + {noreply, CfgMap}. %% @doc Returns the list of ETS table names to create @@ -463,6 +482,7 @@ handle_info(_Info, State) -> terminate(_Reason, _State) -> %% Stop WebSocket mTLS server (if it was started) catch cowboy:stop_listener(cryptic_ws_listener), + catch cowboy:stop_listener(cryptic_mcp_listener), %% Clean up ETS tables [ets:delete(Table) || Table <- ets_tables()], @@ -622,6 +642,58 @@ start_websocket_mtls(Config) -> ?info("Cryptic WebSocket server with mTLS started on port ~p~n", [Port]), {ok, started}. +start_mcp_localhost_tcp(Config) -> + application:ensure_all_started(cowboy), + + Port = + case os:getenv("CRYPTIC_MCP_PORT") of + false -> + maps:get(port, Config, 8081); + PortStr -> + list_to_integer(PortStr) + end, + + Dispatch = cowboy_router:compile([ + {'_', [ + {"/mcp/v1/admin/list_users", cryptic_mcp_admin_handler, #{ + operation => <<"list_users">> + }}, + {"/mcp/v1/admin/user/:gpg_fp", cryptic_mcp_admin_handler, #{ + operation => <<"get_user_info">> + }}, + {"/mcp/v1/admin/user/:gpg_fp/certificates", cryptic_mcp_admin_handler, #{ + operation => <<"list_certificates">> + }}, + {"/mcp/v1/admin/register_user", cryptic_mcp_admin_handler, #{ + operation => <<"register_user">> + }}, + {"/mcp/v1/admin/suspend_user", cryptic_mcp_admin_handler, #{ + operation => <<"suspend_user">> + }}, + {"/mcp/v1/admin/revoke_user", cryptic_mcp_admin_handler, #{ + operation => <<"revoke_user">> + }}, + {"/mcp/v1/admin/reactivate_user", cryptic_mcp_admin_handler, #{ + operation => <<"reactivate_user">> + }}, + {"/mcp/v1/admin/revoke_certificate", cryptic_mcp_admin_handler, #{ + operation => <<"revoke_certificate">> + }} + ]} + ]), + + ?info("Starting MCP localhost TCP endpoint on 127.0.0.1:~p~n", [Port]), + {ok, _} = cowboy:start_clear( + cryptic_mcp_listener, + [ + {ip, {127, 0, 0, 1}}, + {port, Port} + ], + #{env => #{dispatch => Dispatch}} + ), + ?info("MCP localhost TCP endpoint started on 127.0.0.1:~p~n", [Port]), + {ok, started}. + verify_peer(_OtpCert, _DerCert, {bad_cert, _} = Reason, _UserState) -> ?debug("VERIFY_PEER: bad_cert - ~p", [Reason]), From 508c094d75107f0517a9bda23f6c66b911eea9a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:38:03 +0000 Subject: [PATCH 2/6] Fix MCP handler operation routing and audit robustness Agent-Logs-Url: https://github.com/etnt/cryptic/sessions/72a58939-24af-4b65-ab35-ea8bca6477ed Co-authored-by: etnt <5860+etnt@users.noreply.github.com> --- src/cryptic_mcp_admin_handler.erl | 32 +++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/cryptic_mcp_admin_handler.erl b/src/cryptic_mcp_admin_handler.erl index e9d2f19..e1ab955 100644 --- a/src/cryptic_mcp_admin_handler.erl +++ b/src/cryptic_mcp_admin_handler.erl @@ -10,7 +10,7 @@ init(Req0, State) -> Method = cowboy_req:method(Req0), Response = try - handle_request(Method, Req0) + handle_request(Method, Req0, State) catch _:Reason:Stack -> ?error("MCP admin handler crashed: ~p~nStack: ~p", [Reason, Stack]), @@ -32,8 +32,8 @@ reply_json({StatusCode, BodyMap, Req}, State) -> ), {ok, Req2, State}. -handle_request(<<"GET">>, Req0) -> - Operation = cowboy_req:binding(operation, Req0), +handle_request(<<"GET">>, Req0, State) -> + Operation = maps:get(operation, State, undefined), case Operation of <<"list_users">> -> with_admin( @@ -74,13 +74,13 @@ handle_request(<<"GET">>, Req0) -> message => <<"not_found">> }, Req0} end; -handle_request(<<"POST">>, Req0) -> +handle_request(<<"POST">>, Req0, State) -> {ok, RawBody, Req1} = cowboy_req:read_body(Req0), case decode_json_body(RawBody) of {error, Reason} -> bad_request(Reason, Req1); {ok, BodyMap} -> - Operation = cowboy_req:binding(operation, Req1), + Operation = maps:get(operation, State, undefined), with_admin( Req1, BodyMap, @@ -89,7 +89,7 @@ handle_request(<<"POST">>, Req0) -> end ) end; -handle_request(_, Req0) -> +handle_request(_, Req0, _State) -> {405, #{ type => <<"error">>, status => <<"error">>, @@ -128,10 +128,11 @@ handle_post_operation(<<"suspend_user">>, BodyMap, AdminFp, DbRef, Req) -> case cryptic_ca_store:update_user_status(DbRef, GpgFp, <<"suspended">>) of ok -> Now = erlang:system_time(second), - log_audit(DbRef, <<"user_suspended">>, GpgFp, #{ + AuditResult = log_audit(DbRef, <<"user_suspended">>, GpgFp, #{ suspended_by => AdminFp, reason => Reason }), + log_audit_result(AuditResult, <<"user_suspended">>, GpgFp), {200, #{ type => <<"suspend_user_response">>, status => <<"success">>, @@ -156,10 +157,11 @@ handle_post_operation(<<"revoke_user">>, BodyMap, AdminFp, DbRef, Req) -> case cryptic_ca_store:update_user_status(DbRef, GpgFp, <<"revoked">>) of ok -> Now = erlang:system_time(second), - log_audit(DbRef, <<"user_revoked">>, GpgFp, #{ + AuditResult = log_audit(DbRef, <<"user_revoked">>, GpgFp, #{ revoked_by => AdminFp, reason => Reason }), + log_audit_result(AuditResult, <<"user_revoked">>, GpgFp), {200, #{ type => <<"revoke_user_response">>, status => <<"success">>, @@ -199,9 +201,10 @@ handle_post_operation(<<"reactivate_user">>, BodyMap, AdminFp, DbRef, Req) -> case cryptic_ca_store:update_user_status(DbRef, GpgFp, <<"active">>) of ok -> Now = erlang:system_time(second), - log_audit(DbRef, <<"user_reactivated">>, GpgFp, #{ + AuditResult = log_audit(DbRef, <<"user_reactivated">>, GpgFp, #{ reactivated_by => AdminFp }), + log_audit_result(AuditResult, <<"user_reactivated">>, GpgFp), {200, #{ type => <<"reactivate_user_response">>, status => <<"success">>, @@ -419,7 +422,7 @@ encode_user(DbRef, #gpg_identity{ }) -> Username = case get_username_from_gpg_fp(DbRef, Fp) of {ok, Name} -> list_to_binary(Name); - {error, not_found} -> <<"unknown">> + {error, _Reason} -> <<"unknown">> end, UserMap = #{ gpg_fp => Fp, @@ -519,6 +522,15 @@ log_audit(DbRef, EventType, GpgFp, DetailsMap) -> }, cryptic_ca_store:insert_audit_log(DbRef, AuditLog). +log_audit_result(ok, _EventType, _GpgFp) -> + ok; +log_audit_result({error, Reason}, EventType, GpgFp) -> + ?warning("Failed to write audit log for ~s on ~s: ~p", [EventType, GpgFp, Reason]), + ok; +log_audit_result(Other, EventType, GpgFp) -> + ?warning("Unexpected audit log result for ~s on ~s: ~p", [EventType, GpgFp, Other]), + ok. + bad_request(Reason, Req) -> {400, #{ type => <<"error">>, From 184903ef042b150090a27f801181aeeb9f0ee0b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:38:59 +0000 Subject: [PATCH 3/6] Capture real peer IP in MCP admin audit logs Agent-Logs-Url: https://github.com/etnt/cryptic/sessions/72a58939-24af-4b65-ab35-ea8bca6477ed Co-authored-by: etnt <5860+etnt@users.noreply.github.com> --- src/cryptic_mcp_admin_handler.erl | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/cryptic_mcp_admin_handler.erl b/src/cryptic_mcp_admin_handler.erl index e1ab955..c08100d 100644 --- a/src/cryptic_mcp_admin_handler.erl +++ b/src/cryptic_mcp_admin_handler.erl @@ -131,7 +131,7 @@ handle_post_operation(<<"suspend_user">>, BodyMap, AdminFp, DbRef, Req) -> AuditResult = log_audit(DbRef, <<"user_suspended">>, GpgFp, #{ suspended_by => AdminFp, reason => Reason - }), + }, Req), log_audit_result(AuditResult, <<"user_suspended">>, GpgFp), {200, #{ type => <<"suspend_user_response">>, @@ -160,7 +160,7 @@ handle_post_operation(<<"revoke_user">>, BodyMap, AdminFp, DbRef, Req) -> AuditResult = log_audit(DbRef, <<"user_revoked">>, GpgFp, #{ revoked_by => AdminFp, reason => Reason - }), + }, Req), log_audit_result(AuditResult, <<"user_revoked">>, GpgFp), {200, #{ type => <<"revoke_user_response">>, @@ -203,7 +203,7 @@ handle_post_operation(<<"reactivate_user">>, BodyMap, AdminFp, DbRef, Req) -> Now = erlang:system_time(second), AuditResult = log_audit(DbRef, <<"user_reactivated">>, GpgFp, #{ reactivated_by => AdminFp - }), + }, Req), log_audit_result(AuditResult, <<"user_reactivated">>, GpgFp), {200, #{ type => <<"reactivate_user_response">>, @@ -511,17 +511,28 @@ is_online(User) -> not_found -> false end. -log_audit(DbRef, EventType, GpgFp, DetailsMap) -> +log_audit(DbRef, EventType, GpgFp, DetailsMap, Req) -> + IpAddress = peer_ip(Req), AuditLog = #audit_log{ timestamp = erlang:system_time(second), event_type = EventType, gpg_fp = GpgFp, invite_id = undefined, details = jsx:encode(DetailsMap), - ip_address = <<"127.0.0.1">> + ip_address = IpAddress }, cryptic_ca_store:insert_audit_log(DbRef, AuditLog). +peer_ip(Req) -> + case cowboy_req:peer(Req) of + {{A, B, C, D}, _Port} -> + iolist_to_binary(io_lib:format("~b.~b.~b.~b", [A, B, C, D])); + {{_, _, _, _, _, _, _, _} = IPv6, _Port} -> + iolist_to_binary(inet:ntoa(IPv6)); + _ -> + <<"unknown">> + end. + log_audit_result(ok, _EventType, _GpgFp) -> ok; log_audit_result({error, Reason}, EventType, GpgFp) -> From fc814bf39db99b95be79b2003b1918b35ecd9051 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:39:36 +0000 Subject: [PATCH 4/6] Harden MCP peer IP parsing and document admin check Agent-Logs-Url: https://github.com/etnt/cryptic/sessions/72a58939-24af-4b65-ab35-ea8bca6477ed Co-authored-by: etnt <5860+etnt@users.noreply.github.com> --- src/cryptic_mcp_admin_handler.erl | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/cryptic_mcp_admin_handler.erl b/src/cryptic_mcp_admin_handler.erl index c08100d..bc08539 100644 --- a/src/cryptic_mcp_admin_handler.erl +++ b/src/cryptic_mcp_admin_handler.erl @@ -325,6 +325,7 @@ get_admin_fingerprint(Req, BodyMap) -> is_admin(undefined, _DbRef) -> false; is_admin(GpgFp, DbRef) -> + %% Bootstrap/root admins are identities with no registered_by owner. case cryptic_ca_store:get_gpg_identity(DbRef, GpgFp) of {ok, #gpg_identity{registered_by = undefined}} -> true; _ -> false @@ -527,8 +528,13 @@ peer_ip(Req) -> case cowboy_req:peer(Req) of {{A, B, C, D}, _Port} -> iolist_to_binary(io_lib:format("~b.~b.~b.~b", [A, B, C, D])); - {{_, _, _, _, _, _, _, _} = IPv6, _Port} -> - iolist_to_binary(inet:ntoa(IPv6)); + {Addr, _Port} when is_tuple(Addr) -> + case inet:ntoa(Addr) of + Ip when is_list(Ip) -> + iolist_to_binary(Ip); + _ -> + <<"unknown">> + end; _ -> <<"unknown">> end. From a4f84c6e0b6f434ee03dba094d292859b512a372 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:40:52 +0000 Subject: [PATCH 5/6] Harden MCP defaults and align audit timestamps Agent-Logs-Url: https://github.com/etnt/cryptic/sessions/72a58939-24af-4b65-ab35-ea8bca6477ed Co-authored-by: etnt <5860+etnt@users.noreply.github.com> --- src/cryptic_mcp_admin_handler.erl | 11 ++++++----- src/cryptic_server.erl | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/cryptic_mcp_admin_handler.erl b/src/cryptic_mcp_admin_handler.erl index bc08539..895ceed 100644 --- a/src/cryptic_mcp_admin_handler.erl +++ b/src/cryptic_mcp_admin_handler.erl @@ -131,7 +131,7 @@ handle_post_operation(<<"suspend_user">>, BodyMap, AdminFp, DbRef, Req) -> AuditResult = log_audit(DbRef, <<"user_suspended">>, GpgFp, #{ suspended_by => AdminFp, reason => Reason - }, Req), + }, Req, Now), log_audit_result(AuditResult, <<"user_suspended">>, GpgFp), {200, #{ type => <<"suspend_user_response">>, @@ -160,7 +160,7 @@ handle_post_operation(<<"revoke_user">>, BodyMap, AdminFp, DbRef, Req) -> AuditResult = log_audit(DbRef, <<"user_revoked">>, GpgFp, #{ revoked_by => AdminFp, reason => Reason - }, Req), + }, Req, Now), log_audit_result(AuditResult, <<"user_revoked">>, GpgFp), {200, #{ type => <<"revoke_user_response">>, @@ -203,7 +203,7 @@ handle_post_operation(<<"reactivate_user">>, BodyMap, AdminFp, DbRef, Req) -> Now = erlang:system_time(second), AuditResult = log_audit(DbRef, <<"user_reactivated">>, GpgFp, #{ reactivated_by => AdminFp - }, Req), + }, Req, Now), log_audit_result(AuditResult, <<"user_reactivated">>, GpgFp), {200, #{ type => <<"reactivate_user_response">>, @@ -310,6 +310,7 @@ get_db_ref() -> end. get_admin_fingerprint(Req, BodyMap) -> + %% Priority order: explicit header, then JSON body, then query string. case cowboy_req:header(<<"x-admin-gpg-fp">>, Req) of undefined -> case maps:get(<<"admin_gpg_fp">>, BodyMap, undefined) of @@ -512,10 +513,10 @@ is_online(User) -> not_found -> false end. -log_audit(DbRef, EventType, GpgFp, DetailsMap, Req) -> +log_audit(DbRef, EventType, GpgFp, DetailsMap, Req, Timestamp) -> IpAddress = peer_ip(Req), AuditLog = #audit_log{ - timestamp = erlang:system_time(second), + timestamp = Timestamp, event_type = EventType, gpg_fp = GpgFp, invite_id = undefined, diff --git a/src/cryptic_server.erl b/src/cryptic_server.erl index 1e12d85..3cd684a 100644 --- a/src/cryptic_server.erl +++ b/src/cryptic_server.erl @@ -389,7 +389,7 @@ continue(CfgMap) -> MCPEnabled = case application:get_env(cryptic, mcp_tcp_enabled) of {ok, true} -> true; - undefined -> true; + undefined -> false; _ -> false end, case MCPEnabled of @@ -686,6 +686,7 @@ start_mcp_localhost_tcp(Config) -> {ok, _} = cowboy:start_clear( cryptic_mcp_listener, [ + %% Security boundary: bind admin MCP endpoint to localhost only. {ip, {127, 0, 0, 1}}, {port, Port} ], From f2e520a48c46063ab3b9c822f3faf6bf47f974c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 16:42:08 +0000 Subject: [PATCH 6/6] Tighten MCP defaults and sanitize error exposure Agent-Logs-Url: https://github.com/etnt/cryptic/sessions/72a58939-24af-4b65-ab35-ea8bca6477ed Co-authored-by: etnt <5860+etnt@users.noreply.github.com> --- config/sys.config | 2 +- src/cryptic.app.src | 2 +- src/cryptic_mcp_admin_handler.erl | 14 ++++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/config/sys.config b/config/sys.config index 93bb348..de1f41c 100644 --- a/config/sys.config +++ b/config/sys.config @@ -17,7 +17,7 @@ {server_key_file, "priv/ssl/server.key"}, %% Localhost-only MCP admin HTTP endpoint (plain TCP) - {mcp_tcp_enabled, true}, + {mcp_tcp_enabled, false}, {mcp_tcp_port, 8081}, %% Directory holding GPG bootstrap keys diff --git a/src/cryptic.app.src b/src/cryptic.app.src index 01d3a89..0bfa25f 100644 --- a/src/cryptic.app.src +++ b/src/cryptic.app.src @@ -27,7 +27,7 @@ {websocket_mtls_port, 8443}, % Localhost-only MCP admin HTTP endpoint (plain TCP) - {mcp_tcp_enabled, true}, + {mcp_tcp_enabled, false}, {mcp_tcp_port, 8081}, % Poll interval (seconds) diff --git a/src/cryptic_mcp_admin_handler.erl b/src/cryptic_mcp_admin_handler.erl index 895ceed..ccf1035 100644 --- a/src/cryptic_mcp_admin_handler.erl +++ b/src/cryptic_mcp_admin_handler.erl @@ -12,8 +12,8 @@ init(Req0, State) -> try handle_request(Method, Req0, State) catch - _:Reason:Stack -> - ?error("MCP admin handler crashed: ~p~nStack: ~p", [Reason, Stack]), + _:Reason -> + ?error("MCP admin handler crashed: ~p", [Reason]), {500, #{ type => <<"error">>, status => <<"error">>, @@ -282,10 +282,11 @@ with_admin(Req, Fun) -> with_admin(Req, BodyMap, Fun) -> case get_db_ref() of {error, Reason} -> + ?error("MCP admin handler missing DB ref: ~p", [Reason]), {500, #{ type => <<"error">>, status => <<"error">>, - message => iolist_to_binary(io_lib:format("~p", [Reason])) + message => <<"internal_server_error">> }, Req}; {ok, DbRef} -> AdminFp = get_admin_fingerprint(Req, BodyMap), @@ -310,12 +311,12 @@ get_db_ref() -> end. get_admin_fingerprint(Req, BodyMap) -> - %% Priority order: explicit header, then JSON body, then query string. + %% Priority order: explicit header, then JSON body. case cowboy_req:header(<<"x-admin-gpg-fp">>, Req) of undefined -> case maps:get(<<"admin_gpg_fp">>, BodyMap, undefined) of undefined -> - query_value(<<"admin_gpg_fp">>, Req); + undefined; Fp -> Fp end; @@ -557,8 +558,9 @@ bad_request(Reason, Req) -> }, Req}. error_response(StatusCode, Reason, Req) -> + ?error("MCP admin operation failed: ~p", [Reason]), {StatusCode, #{ type => <<"error">>, status => <<"error">>, - message => iolist_to_binary(io_lib:format("~p", [Reason])) + message => <<"operation_failed">> }, Req}.