From e1caef5f404c567ed95c5d177a004a6421e9161e Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Sun, 30 Aug 2026 11:25:43 +0100 Subject: [PATCH 1/2] Update release docs: TLS has shipped, document AmiSSL install step midge.readme and userdocs/index.md still said TLS was "planned for future releases" - stale since the AmiSSL transport landed and shipped in mqtt.library/mqtt_pub/mqtt_sub. Both now describe it as available (mco_TLS/TLS-TLSINSECORE, AmiSSL required separately, CPU-speed caveat). Installation.md gets a new "Installing AmiSSL (needed for TLS)" section alongside the existing mqtt.library install step - the missing piece a release user would actually need to discover TLS is usable at all. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QWq1CRLCQy9AaD9UtLRSM9 --- midge.readme | 23 +++++++++++++++-------- userdocs/Installation.md | 24 ++++++++++++++++++++++++ userdocs/index.md | 6 ++++-- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/midge.readme b/midge.readme index 917815b..74ad5fb 100644 --- a/midge.readme +++ b/midge.readme @@ -30,21 +30,28 @@ This release ships: mqtt_pub/mqtt_sub and their -static counterparts all follow the same AmigaDOS conventions (ReadArgs templates, Ctrl-C to stop) and speak plain MQTT 3.1.1 over TCP. mqtt_pub/mqtt_sub are backed by mqtt.library and -support QoS 0 and QoS 1 publish; mqtt_pub-static/mqtt_sub-static talk to -bsdsocket.library directly with no library install required, but -mqtt_pub-static only supports QoS 0 publish. mqtt.library itself also -exposes an optional auto-reconnect mode to programs written against it -directly. See https://sidick.github.io/midge/mqtt-library/ for the full -mqtt.library guide. +support QoS 0 and QoS 1 publish, plus TLS via AmiSSL (TLS/TLSINSECURE - +needs AmiSSL installed separately, see Requirements below); +mqtt_pub-static/mqtt_sub-static talk to bsdsocket.library directly with no +library install required, but mqtt_pub-static only supports QoS 0 publish +and neither -static tool supports TLS. mqtt.library itself also exposes an +optional auto-reconnect mode to programs written against it directly. See +https://sidick.github.io/midge/mqtt-library/ for the full mqtt.library +guide. -Planned for future releases: TLS via AmiSSL, and a ReAction dashboard -application with Home Assistant MQTT discovery. +Planned for future releases: a ReAction dashboard application with Home +Assistant MQTT discovery. Requirements ------------ - AmigaOS 3.1+ (3.2 is the reference platform), 68020 or better. - A TCP/IP stack providing bsdsocket.library (Roadshow, AmiTCP, Miami, or an emulator-provided stack). + - For TLS (mqtt_pub/mqtt_sub's TLS/TLSINSECURE switches): AmiSSL 5.x + (https://github.com/jens-maus/amissl), installed separately - not + bundled with this archive. Software TLS is CPU-intensive; a genuinely + stock, unaccelerated 68020 may see intermittent failures under it - + see https://sidick.github.io/midge/CLI-Reference/ for details. Quick start ----------- diff --git a/userdocs/Installation.md b/userdocs/Installation.md index 5962072..9c548e4 100644 --- a/userdocs/Installation.md +++ b/userdocs/Installation.md @@ -47,6 +47,30 @@ If you'd rather not install anything, use `mqtt_pub-static`/ [Two build flavours](CLI-Reference.md#two-build-flavours) for exactly what that trade-off costs you (QoS 1 publish support). +## Installing AmiSSL (needed for TLS) + +`mqtt_pub`/`mqtt_sub`'s `TLS`/`TLSINSECURE` switches (see +[CLI Reference](CLI-Reference.md)) need +[AmiSSL](https://github.com/jens-maus/amissl) 5.x installed - it is not +bundled with this archive. Download the OS3 package from AmiSSL's own +releases and run its installer; that sets up `LIBS:amisslmaster.library`, +the CPU-tier `LIBS:AmiSSL/` library, and the `AmiSSL:` assign the TLS +certificate store is read through. All three matter - a missing `AmiSSL:` +assign in particular can make AmigaOS pop up a "please insert volume" +requester instead of a clean connect failure. + +Without AmiSSL installed, `TLS`/`TLSINSECURE` fail with a connect error; +everything else in this release works exactly the same either way - TLS is +entirely opt-in. + +Software TLS is CPU-intensive: a genuinely stock, unaccelerated 68020 has +been found to intermittently fail under it, while any real accelerator (or +a 68030 or better) is reliable - see +[CLI Reference](CLI-Reference.md#a-note-on-tls-and-cpu-speed). + +`mqtt_pub-static`/`mqtt_sub-static` have no AmiSSL support at all and +reject `TLS` outright. + ## Verifying it worked ``` diff --git a/userdocs/index.md b/userdocs/index.md index 52f1d32..0ba53f8 100644 --- a/userdocs/index.md +++ b/userdocs/index.md @@ -6,14 +6,16 @@ as both a display/control surface and a publisher of its own telemetry. This release ships the command-line tools `mqtt_pub` and `mqtt_sub`, and `mqtt.library`, a shared library exposing the same MQTT client to other -AmigaOS programs. TLS support and a ReAction dashboard application are -planned for future releases. +AmigaOS programs, with optional TLS via AmiSSL. A ReAction dashboard +application is planned for a future release. ## Requirements - AmigaOS 3.1+ (3.2 is the reference platform), 68020 or better. - A TCP/IP stack providing `bsdsocket.library` (Roadshow, AmiTCP, Miami, or an emulator-provided stack). +- For TLS: [AmiSSL](https://github.com/jens-maus/amissl) 5.x, installed + separately - see [Installation](Installation.md#installing-amissl-needed-for-tls). See [Installation](Installation.md) for unpacking the archive and the `mqtt.library` install step the default tools need. From f3e02b36493979387ed45ca57ef2a7155538a50f Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Sun, 30 Aug 2026 12:09:00 +0100 Subject: [PATCH 2/2] Add CAFILE/-c: TLS trust anchor override for private CAs (issue #13) Threads a new ca_file/mco_CAFile/-c/CAFILE option end-to-end through both TLS transports and both CLI flavors: - src/tools/tool_opts.h: new ca_file field, parsed by both host getopt (-c) and Amiga ReadArgs (CAFILE/K). - src/host/transport_openssl.c and src/amiga/transport_amissl.c: an extra SSL_CTX_load_verify_locations() call when ca_file is set, additive to the existing default trust store/AmiSSL cert store (not a replacement), so brokers behind a public CA keep verifying too. - mqtt.library: new mco_CAFile field on MqttConnectOpts, deep-copied at MQTT_CreateClient() time like the other string options, threaded through child_connect() into the AmiSSL branch. Host/library-linked Amiga CLIs pass their new -c/CAFILE value straight through; the static Amiga CLIs already reject TLS outright so need no change. - mqtt.doc/mqtt-library-reference.md and CLI-Reference.md updated (including host `-c` and Amiga `CAFILE`). Verified end-to-end on real m68k/AmiSSL under Copperline: a broker cert signed by a private CA correctly fails without the CA file and succeeds with it (both the host CLI and the real mqtt.library path). Along the way, found and documented a real gotcha for anyone doing certificate verification on Amiga: the guest clock matters a lot (a wrong or unseeded clock makes a good certificate look not-yet-valid/expired) - new "A note on TLS and the system clock" in CLI-Reference.md. Adds tests/library/libcafile.c + cafile-run.sh (make library-cafile-smoke), same local-only/amibake-image shape as the existing library-tls-smoke. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QWq1CRLCQy9AaD9UtLRSM9 --- Makefile | 11 ++ src/amiga/args.c | 11 +- src/amiga/pub_main_lib.c | 1 + src/amiga/sub_main_lib.c | 1 + src/amiga/transport_amissl.c | 9 +- src/amiga/transport_amissl.h | 9 +- src/host/args.c | 10 +- src/host/pub_main.c | 3 +- src/host/sub_main.c | 3 +- src/host/transport_openssl.c | 13 ++- src/host/transport_openssl.h | 12 ++- src/library/include/libraries/mqtt.h | 8 ++ src/library/mqtt.doc | 15 ++- src/library/mqtt_funcs.c | 13 ++- src/library/mqtt_priv.h | 1 + src/tools/tool_opts.h | 5 + tests/library/cafile-run.sh | 156 +++++++++++++++++++++++++++ tests/library/libcafile.c | 113 +++++++++++++++++++ userdocs/CLI-Reference.md | 24 ++++- userdocs/mqtt-library-reference.md | 15 ++- 20 files changed, 407 insertions(+), 26 deletions(-) create mode 100755 tests/library/cafile-run.sh create mode 100644 tests/library/libcafile.c diff --git a/Makefile b/Makefile index ac819df..f16f089 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ # make library-reconnect-smoke on-target mco_AutoReconnect test (broker restart mid-run) # make fetch-amissl-sdk fetch the AmiSSL v5 SDK (needed for Amiga-side TLS support) # make library-tls-smoke on-target mco_TLS test - local-only, needs an amibake image +# make library-cafile-smoke on-target mco_CAFile test - local-only, needs an amibake image # make clean # # The core is portable C99, so `test` and `cli` build with any host compiler. @@ -407,6 +408,16 @@ libtls-m68k: library-headers | $(BUILD)/.dir library-tls-smoke: library libtls-m68k cli sh tests/library/tls-run.sh +# --- m68k: on-target mco_CAFile end-to-end smoke test (issue #13) --- +# Same shape/local-only rationale as libtls-m68k/library-tls-smoke - see +# tests/library/libcafile.c and cafile-run.sh's own banners (the latter +# also explains why this one seeds Copperline's RTC). +libcafile-m68k: library-headers | $(BUILD)/.dir + $(M68K_CC) $(M68K_CFLAGS) -I$(LIB_INCDIR) tests/library/libcafile.c -o $(BUILD)/libcafile + +library-cafile-smoke: library libcafile-m68k + sh tests/library/cafile-run.sh + # --- m68k: on-target check that examples/pubexample.c actually works --- # Real Copperline boot (CI/release gate): stages build/pubexample and # build/mqtt.library into a throwaway boot volume with Copperline's diff --git a/src/amiga/args.c b/src/amiga/args.c index fb1e9a0..9e1aebe 100644 --- a/src/amiga/args.c +++ b/src/amiga/args.c @@ -8,20 +8,21 @@ #define PUB_TEMPLATE \ "HOST/A,PORT/N/K,TOPIC/A,MESSAGE/K,FILE/K,QOS/N/K,CLIENTID/K,USER/K," \ - "PASSWORD/K,KEEPALIVE/N/K,RETAIN/S,VERBOSE/S,TLS/S,TLSINSECURE/S" + "PASSWORD/K,KEEPALIVE/N/K,RETAIN/S,VERBOSE/S,TLS/S,TLSINSECURE/S," \ + "CAFILE/K" #define SUB_TEMPLATE \ "HOST/A,PORT/N/K,TOPIC/A,QOS/N/K,CLIENTID/K,USER/K,PASSWORD/K," \ - "KEEPALIVE/N/K,COUNT/N/K,VERBOSE/S,TLS/S,TLSINSECURE/S" + "KEEPALIVE/N/K,COUNT/N/K,VERBOSE/S,TLS/S,TLSINSECURE/S,CAFILE/K" enum { PUB_HOST, PUB_PORT, PUB_TOPIC, PUB_MESSAGE, PUB_FILE, PUB_QOS, PUB_CLIENTID, PUB_USER, PUB_PASSWORD, PUB_KEEPALIVE, PUB_RETAIN, - PUB_VERBOSE, PUB_TLS, PUB_TLSINSECURE, PUB_NARGS + PUB_VERBOSE, PUB_TLS, PUB_TLSINSECURE, PUB_CAFILE, PUB_NARGS }; enum { SUB_HOST, SUB_PORT, SUB_TOPIC, SUB_QOS, SUB_CLIENTID, SUB_USER, SUB_PASSWORD, SUB_KEEPALIVE, SUB_COUNT, SUB_VERBOSE, SUB_TLS, - SUB_TLSINSECURE, SUB_NARGS + SUB_TLSINSECURE, SUB_CAFILE, SUB_NARGS }; static struct RDArgs *g_rdargs; @@ -67,6 +68,7 @@ int amiga_parse_args(int is_pub, tool_opts *opts) opts->tls_insecure = args[PUB_TLSINSECURE] ? 1 : 0; if (opts->tls_insecure) opts->tls = 1; + opts->ca_file = (const char *)args[PUB_CAFILE]; if (!opts->message && !opts->file) { fprintf(stderr, "mqtt_pub: MESSAGE or FILE is required\n"); @@ -98,6 +100,7 @@ int amiga_parse_args(int is_pub, tool_opts *opts) opts->tls_insecure = args[SUB_TLSINSECURE] ? 1 : 0; if (opts->tls_insecure) opts->tls = 1; + opts->ca_file = (const char *)args[SUB_CAFILE]; } if (opts->port == 0) diff --git a/src/amiga/pub_main_lib.c b/src/amiga/pub_main_lib.c index 07316ca..933e48f 100644 --- a/src/amiga/pub_main_lib.c +++ b/src/amiga/pub_main_lib.c @@ -132,6 +132,7 @@ int main(void) co.mco_AutoReconnect = FALSE; co.mco_TLS = opts.tls ? TRUE : FALSE; co.mco_TLSInsecure = opts.tls_insecure ? TRUE : FALSE; + co.mco_CAFile = (STRPTR)opts.ca_file; if (opts.verbose) printf("mqtt_pub: connecting to %s:%u\n", opts.host, diff --git a/src/amiga/sub_main_lib.c b/src/amiga/sub_main_lib.c index 596f8e0..803e823 100644 --- a/src/amiga/sub_main_lib.c +++ b/src/amiga/sub_main_lib.c @@ -73,6 +73,7 @@ int main(void) co.mco_AutoReconnect = FALSE; co.mco_TLS = opts.tls ? TRUE : FALSE; co.mco_TLSInsecure = opts.tls_insecure ? TRUE : FALSE; + co.mco_CAFile = (STRPTR)opts.ca_file; if (opts.verbose) printf("mqtt_sub: connecting to %s:%u\n", opts.host, diff --git a/src/amiga/transport_amissl.c b/src/amiga/transport_amissl.c index 7ad7206..80bd3de 100644 --- a/src/amiga/transport_amissl.c +++ b/src/amiga/transport_amissl.c @@ -201,7 +201,8 @@ static void amissl_close(void *vctx) int transport_amissl_connect(mqtt_transport *out, amissl_ctx *ctx, const char *host, uint16_t port, - int insecure_skip_verify) + int insecure_skip_verify, + const char *ca_file) { struct Library *SocketBase; struct Library *AmiSSLMasterBase; @@ -290,6 +291,12 @@ int transport_amissl_connect(mqtt_transport *out, amissl_ctx *ctx, NULL); if (!SSL_CTX_set_default_verify_paths(ctx->ssl_ctx)) goto fail_ctx; + /* Extra trust anchor for a private CA (issue #13) - added on top + * of the AmiSSL: cert store above, not instead of it, so a broker + * behind a normal public CA still verifies too. */ + if (ca_file != NULL && + !SSL_CTX_load_verify_locations(ctx->ssl_ctx, ca_file, NULL)) + goto fail_ctx; } ctx->ssl = SSL_new(ctx->ssl_ctx); diff --git a/src/amiga/transport_amissl.h b/src/amiga/transport_amissl.h index 9666333..d28f939 100644 --- a/src/amiga/transport_amissl.h +++ b/src/amiga/transport_amissl.h @@ -45,7 +45,11 @@ typedef struct { * mask, same discipline as transport_bsdsocket_connect). Certificate and * hostname verification are on by default; pass insecure_skip_verify * nonzero to disable both (SSL_VERIFY_NONE) - for testing against - * self-signed brokers only, never for production use. + * self-signed brokers only, never for production use. `ca_file`, if + * non-NULL, additionally loads a PEM file as an extra trust anchor + * (SSL_CTX_load_verify_locations()) - for a private CA not in AmiSSL's + * bundled trust store (issue #13); ignored when insecure_skip_verify is + * set (nothing to verify against then). * * Requires an AmiSSL: assign (see amissl.library's install docs - the * amissl package assigns it to SYS:Devs/AmiSSL) for InitAmiSSL()'s cert @@ -57,6 +61,7 @@ typedef struct { * handshake failure) - no resources leaked on any error path. */ int transport_amissl_connect(mqtt_transport *out, amissl_ctx *ctx_storage, const char *host, uint16_t port, - int insecure_skip_verify); + int insecure_skip_verify, + const char *ca_file); #endif diff --git a/src/host/args.c b/src/host/args.c index 710a350..d07092c 100644 --- a/src/host/args.c +++ b/src/host/args.c @@ -14,8 +14,8 @@ int host_parse_args(int argc, char **argv, int is_pub, tool_opts *opts) { int ch; - const char *optstring = is_pub ? "h:p:t:m:f:q:i:u:P:k:rvsS" - : "h:p:t:q:i:u:P:k:C:vsS"; + const char *optstring = is_pub ? "h:p:t:m:f:q:i:u:P:k:rvsSc:" + : "h:p:t:q:i:u:P:k:C:vsSc:"; memset(opts, 0, sizeof(*opts)); /* opts->port stays 0 (memset) until resolved after the option loop, @@ -40,15 +40,17 @@ int host_parse_args(int argc, char **argv, int is_pub, tool_opts *opts) case 'v': opts->verbose = 1; break; case 's': opts->tls = 1; break; case 'S': opts->tls = 1; opts->tls_insecure = 1; break; + case 'c': opts->ca_file = optarg; break; default: fprintf(stderr, is_pub ? "usage: %s -h host [-p port] -t topic " "(-m message | -f file) [-q qos] [-i id] " "[-u user] [-P pass] [-k keepalive] [-r] [-s] " - "[-S] [-v]\n" + "[-S] [-c cafile] [-v]\n" : "usage: %s -h host [-p port] -t topic " "[-q qos] [-i id] [-u user] [-P pass] " - "[-k keepalive] [-C count] [-s] [-S] [-v]\n", + "[-k keepalive] [-C count] [-s] [-S] " + "[-c cafile] [-v]\n", argv[0]); return -1; } diff --git a/src/host/pub_main.c b/src/host/pub_main.c index 5a5ee56..68b913f 100644 --- a/src/host/pub_main.c +++ b/src/host/pub_main.c @@ -31,7 +31,8 @@ int main(int argc, char **argv) if (opts.tls) { connected = transport_openssl_connect(&transport, &ossl_ctx, opts.host, opts.port, - opts.tls_insecure) == 0; + opts.tls_insecure, + opts.ca_file) == 0; } else { connected = transport_bsd_connect(&transport, &ctx, opts.host, opts.port) == 0; diff --git a/src/host/sub_main.c b/src/host/sub_main.c index b9b58e8..dcc3f16 100644 --- a/src/host/sub_main.c +++ b/src/host/sub_main.c @@ -28,7 +28,8 @@ int main(int argc, char **argv) if (opts.tls) { connected = transport_openssl_connect(&transport, &ossl_ctx, opts.host, opts.port, - opts.tls_insecure) == 0; + opts.tls_insecure, + opts.ca_file) == 0; } else { connected = transport_bsd_connect(&transport, &ctx, opts.host, opts.port) == 0; diff --git a/src/host/transport_openssl.c b/src/host/transport_openssl.c index 2f997d2..ee5b959 100644 --- a/src/host/transport_openssl.c +++ b/src/host/transport_openssl.c @@ -118,7 +118,8 @@ static int tcp_connect(const char *host, uint16_t port) int transport_openssl_connect(mqtt_transport *out, openssl_ctx *ctx, const char *host, uint16_t port, - int insecure_skip_verify) + int insecure_skip_verify, + const char *ca_file) { int fd; SSL_CTX *ssl_ctx; @@ -151,6 +152,16 @@ int transport_openssl_connect(mqtt_transport *out, openssl_ctx *ctx, close(fd); return -1; } + /* Extra trust anchor for a private CA (issue #13) - added on top + * of the system trust store above, not instead of it, so a broker + * behind a normal public CA still verifies too. */ + if (ca_file != NULL && + !SSL_CTX_load_verify_locations(ssl_ctx, ca_file, NULL)) { + fprintf(stderr, "mqtt: failed to load CA file %s\n", ca_file); + SSL_CTX_free(ssl_ctx); + close(fd); + return -1; + } } ssl = SSL_new(ssl_ctx); diff --git a/src/host/transport_openssl.h b/src/host/transport_openssl.h index 69a5c91..35979cf 100644 --- a/src/host/transport_openssl.h +++ b/src/host/transport_openssl.h @@ -19,11 +19,15 @@ typedef struct { * and hostname verification are on by default (system trust store via * SSL_CTX_set_default_verify_paths()); pass insecure_skip_verify nonzero to * disable both (SSL_VERIFY_NONE) - for testing against self-signed brokers - * only, never for production use. Wires `out` up via `ctx` (caller-owned, no - * allocation). Returns 0 on success, -1 on failure (no resources leaked on - * any error path). */ + * only, never for production use. `ca_file`, if non-NULL, additionally loads + * a PEM file as an extra trust anchor (SSL_CTX_load_verify_locations()) - + * for a private CA not in the system trust store (issue #13); ignored when + * insecure_skip_verify is set (nothing to verify against then). Wires `out` + * up via `ctx` (caller-owned, no allocation). Returns 0 on success, -1 on + * failure (no resources leaked on any error path). */ int transport_openssl_connect(mqtt_transport *out, openssl_ctx *ctx, const char *host, uint16_t port, - int insecure_skip_verify); + int insecure_skip_verify, + const char *ca_file); #endif diff --git a/src/library/include/libraries/mqtt.h b/src/library/include/libraries/mqtt.h index e3adb4b..bb4659d 100644 --- a/src/library/include/libraries/mqtt.h +++ b/src/library/include/libraries/mqtt.h @@ -58,6 +58,14 @@ struct MqttConnectOpts { unless mco_TLS is also TRUE. For testing against self-signed or otherwise untrusted brokers only - never for production use. */ + STRPTR mco_CAFile; /* NUL-terminated path to a PEM file, loaded + as an extra trust anchor alongside + AmiSSL's bundled trust store - for a + broker behind a private CA that isn't in + it (issue #13). Ignored unless mco_TLS is + TRUE, and ignored if mco_TLSInsecure is + also TRUE (nothing to verify against + then). NULL = no extra trust anchor. */ BOOL mco_AutoReconnect; /* FALSE (default/zeroed struct) = today's behaviour: an unexpected connection drop (transport error, keepalive timeout) leaves diff --git a/src/library/mqtt.doc b/src/library/mqtt.doc index eabe0e1..5f98a42 100644 --- a/src/library/mqtt.doc +++ b/src/library/mqtt.doc @@ -186,8 +186,8 @@ mqtt.library/MQTT_CreateClient mqtt.library/MQTT_CreateClient port). opts - connect options (client id, credentials, keepalive, clean-session, mco_AutoReconnect, mco_TLS, - mco_TLSInsecure); see struct MqttConnectOpts in - . May be NULL, which behaves like a + mco_TLSInsecure, mco_CAFile); see struct MqttConnectOpts + in . May be NULL, which behaves like a zeroed struct (no client id, no credentials, no keepalive, clean session, no auto-reconnect, no TLS). @@ -214,6 +214,17 @@ mqtt.library/MQTT_CreateClient mqtt.library/MQTT_CreateClient self-signed or otherwise untrusted brokers only, never for production use. + mco_CAFile (issue #13) additionally trusts a PEM file's CA + alongside AmiSSL's bundled trust store - for a broker behind a + private CA that isn't in it. Certificate verification checks the + broker cert's validity dates against the Amiga's own system + clock, same as any TLS client - a system clock that's wrong + (common on real hardware with a dead or unset battery-backed + RTC) will make a perfectly good certificate look not-yet-valid + or expired and fail the handshake. Set the clock (SetClock, + IControl, or NTP via a suitable client) before relying on + certificate verification. + SEE ALSO MQTT_Connect, MQTT_DeleteClient diff --git a/src/library/mqtt_funcs.c b/src/library/mqtt_funcs.c index 752100d..3bf1957 100644 --- a/src/library/mqtt_funcs.c +++ b/src/library/mqtt_funcs.c @@ -285,7 +285,8 @@ static int child_connect(MqttClientHandle *h, struct MsgPort *cmd_port, #ifdef MIDGE_AMIGA_TLS if (transport_amissl_connect(transport, &cctx->tls, (const char *)h->ch_Host, h->ch_Port, - h->ch_Opts.mco_TLSInsecure) != 0) + h->ch_Opts.mco_TLSInsecure, + (const char *)h->ch_Opts.mco_CAFile) != 0) return MQTTERR_NOTCONNECTED; /* Extra wakeup source for recv()'s WaitSelect poll, so a * PUBLISH/SUBSCRIBE/DISCONNECT/QUIT arriving on cmd_port while @@ -927,6 +928,11 @@ APTR MQTT_CreateClient(struct Library *_base, STRPTR host, UWORD port, if (!h->ch_Password) goto fail_host; } + if (opts->mco_CAFile) { + h->ch_CAFile = dupstr((const char *)opts->mco_CAFile); + if (!h->ch_CAFile) + goto fail_host; + } h->ch_Opts.mco_KeepAlive = opts->mco_KeepAlive; h->ch_Opts.mco_CleanSession = opts->mco_CleanSession; h->ch_Opts.mco_AutoReconnect = opts->mco_AutoReconnect; @@ -936,6 +942,7 @@ APTR MQTT_CreateClient(struct Library *_base, STRPTR host, UWORD port, h->ch_Opts.mco_ClientID = h->ch_ClientID; h->ch_Opts.mco_Username = h->ch_Username; h->ch_Opts.mco_Password = h->ch_Password; + h->ch_Opts.mco_CAFile = h->ch_CAFile; h->ch_MsgPort = CreateMsgPort(); if (!h->ch_MsgPort) @@ -984,6 +991,8 @@ APTR MQTT_CreateClient(struct Library *_base, STRPTR host, UWORD port, fail_msgport: DeleteMsgPort(h->ch_MsgPort); fail_host: + if (h->ch_CAFile) + FreeVec(h->ch_CAFile); if (h->ch_Password) FreeVec(h->ch_Password); if (h->ch_Username) @@ -1033,6 +1042,8 @@ VOID MQTT_DeleteClient(struct Library *_base, APTR client) FreeVec(msg); DeleteMsgPort(h->ch_MsgPort); + if (h->ch_CAFile) + FreeVec(h->ch_CAFile); if (h->ch_Password) FreeVec(h->ch_Password); if (h->ch_Username) diff --git a/src/library/mqtt_priv.h b/src/library/mqtt_priv.h index 1ccb12c..bfb5159 100644 --- a/src/library/mqtt_priv.h +++ b/src/library/mqtt_priv.h @@ -93,6 +93,7 @@ typedef struct MqttClientHandle { STRPTR ch_ClientID; STRPTR ch_Username; STRPTR ch_Password; + STRPTR ch_CAFile; /* --- QoS 1 publish / SUBACK-wait scratch state ------------------------- * Written by deliver_cb() (called from inside the CHILD subprocess's own diff --git a/src/tools/tool_opts.h b/src/tools/tool_opts.h index 781fdb1..5ed5edf 100644 --- a/src/tools/tool_opts.h +++ b/src/tools/tool_opts.h @@ -30,6 +30,11 @@ typedef struct { int tls; /* opt-in, never default-on (see issue #3) */ int tls_insecure; /* skip certificate verification; ignored without tls */ + const char *ca_file; /* extra trust anchor for a private CA (issue + #13); NULL uses only the system/AmiSSL + default trust store. Ignored without tls, + and ignored if tls_insecure is also set + (nothing to verify against then). */ } tool_opts; /* Connects (assumes `transport` is already connected to the broker), diff --git a/tests/library/cafile-run.sh b/tests/library/cafile-run.sh new file mode 100755 index 0000000..6da7519 --- /dev/null +++ b/tests/library/cafile-run.sh @@ -0,0 +1,156 @@ +#!/bin/sh +# cafile-run.sh — on-target Copperline end-to-end test for mqtt.library's +# mco_CAFile (issue #13). Same local-only/amibake-image requirement as +# tls-run.sh (see tests/library/README.md's "TLS smoke test" section) plus +# one more: it seeds Copperline's RTC to the host's real wall-clock time +# via --rtc-time. +# +# THAT MATTERS: Copperline's guest clock otherwise starts unseeded, and +# certificate-date verification (mco_CAFile, unlike mco_TLSInsecure, does +# real X.509 chain verification including validity dates) checks the +# broker cert's notBefore/notAfter against the Amiga's own idea of "now" - +# an unseeded/wrong clock makes a perfectly good just-issued certificate +# look not-yet-valid (SSL_get_verify_result() == X509_V_ERR_CERT_NOT_YET_ +# VALID == 9) and the handshake fails for a reason that has nothing to do +# with mco_CAFile itself. This is also true on real hardware with a dead +# or unset battery-backed RTC - see userdocs/CLI-Reference.md's "A note on +# TLS and the system clock". +# +# Even seeded, a few hours of skew (timezone/DST handling somewhere in the +# AmigaOS/AmiSSL boot-to-clock path - not pinned down, not worth chasing +# further for a test harness) was enough to make a cert issued moments +# before the guest even booted look not-yet-valid. The certs below are +# backdated a day for comfortable margin against that, rather than issued +# at literally "now". +# +# Sequence: +# 1. generate a private CA + a broker cert signed by it (openssl) +# 2. start a scratch Mosquitto using that cert +# 3. boot Copperline (RTC seeded to `date +%s`) running C:libcafile, +# staged into the amibake image alongside a fresh S:User-Startup +# that does the AmiSSL: assign and copies the CA cert into +# Devs:AmiSSL/Certs/ - libcafile.c connects twice: once with no CA +# file (must fail - untrusted issuer) and once with mco_CAFile +# pointing at it (must succeed) +# 4. assert the guest's serial PASS/FAIL/RESULT/END markers +# +# Usage: sh tests/library/cafile-run.sh (invoked via +# `make library-cafile-smoke`, which cross-builds libcafile + mqtt.library +# - with AmiSSL support, see `make fetch-amissl-sdk` - first) +# +# Environment: same as tls-run.sh (MIDGE_TLS_AMIGA_IMAGE, MIDGE_TLS_KICKSTART). +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "$HERE/../.." && pwd) + +COPPERLINE=${COPPERLINE:-copperline} +BIN=${LIBCAFILE_M68K:-$ROOT/build/libcafile} +LIB=${MQTT_LIBRARY_M68K:-$ROOT/build/mqtt.library} +IMAGE=${MIDGE_TLS_AMIGA_IMAGE:-$HOME/src/amibake/manifests/AmiSSLSpike} +ROM=${MIDGE_TLS_KICKSTART:-$HOME/src/amibake/assets/roms/kickstart-47.7.rom} +PORT=18886 # must match libcafile.c's TEST_PORT +CA_FILENAME=midge-test-ca.pem # must match libcafile.c's CA_FILE basename +BENCH=${BENCH:-300} + +command -v "$COPPERLINE" >/dev/null || { echo "FAIL: $COPPERLINE not found" >&2; exit 2; } +command -v mosquitto >/dev/null || { echo "FAIL: mosquitto not on PATH" >&2; exit 2; } +command -v openssl >/dev/null || { echo "FAIL: openssl not on PATH" >&2; exit 2; } +[ -e "$BIN" ] || { echo "FAIL: missing $BIN - run 'make libcafile-m68k' first" >&2; exit 2; } +[ -e "$LIB" ] || { echo "FAIL: missing $LIB - run 'make fetch-amissl-sdk && make library' first" >&2; exit 2; } +[ -d "$IMAGE" ] || { + echo "FAIL: MIDGE_TLS_AMIGA_IMAGE=$IMAGE not found - see" >&2 + echo " tests/library/README.md's \"TLS smoke test\" section to build one" >&2 + exit 2 +} +[ -f "$ROM" ] || { echo "FAIL: MIDGE_TLS_KICKSTART=$ROM not found" >&2; exit 2; } + +OUTDIR=$(mktemp -d) +MACHINE_TOML="$OUTDIR/cafile-machine.toml" +OUT=$(mktemp) +MOSQ_PID= +trap 'kill "$MOSQ_PID" 2>/dev/null || true; rm -f "$OUT"; rm -rf "$OUTDIR"' EXIT + +# Backdated notBefore, generous notAfter - see the clock-skew note above. +# BSD date (macOS) uses -v-1d; GNU date (Linux/CI) uses -d '-1 day'. +NOT_BEFORE=$(date -u -v-1d +%y%m%d%H%M%SZ 2>/dev/null || date -u -d '-1 day' +%y%m%d%H%M%SZ) +NOT_AFTER=$(date -u -v+30d +%y%m%d%H%M%SZ 2>/dev/null || date -u -d '+30 days' +%y%m%d%H%M%SZ) + +openssl genrsa -out "$OUTDIR/ca.key" 2048 2>/dev/null +openssl req -x509 -new -nodes -key "$OUTDIR/ca.key" -sha256 \ + -not_before "$NOT_BEFORE" -not_after "$NOT_AFTER" \ + -out "$OUTDIR/ca.crt" -subj "/CN=midge test CA" 2>/dev/null +openssl genrsa -out "$OUTDIR/server.key" 2048 2>/dev/null +openssl req -new -key "$OUTDIR/server.key" -out "$OUTDIR/server.csr" \ + -subj "/CN=localhost" 2>/dev/null +echo "subjectAltName=DNS:localhost,IP:127.0.0.1" > "$OUTDIR/ext.cnf" +openssl x509 -req -in "$OUTDIR/server.csr" -CA "$OUTDIR/ca.crt" \ + -CAkey "$OUTDIR/ca.key" -CAcreateserial -out "$OUTDIR/server.crt" \ + -not_before "$NOT_BEFORE" -not_after "$NOT_AFTER" \ + -sha256 -extfile "$OUTDIR/ext.cnf" 2>/dev/null + +cat > "$OUTDIR/mosquitto.conf" < "$OUTDIR/mosquitto.log" 2>&1 & +MOSQ_PID=$! + +i=0 +while ! grep -q "mosquitto version .* running" "$OUTDIR/mosquitto.log" 2>/dev/null; do + i=$((i + 1)) + [ "$i" -ge 50 ] && { echo "FAIL: mosquitto did not start" >&2; cat "$OUTDIR/mosquitto.log" >&2; exit 1; } + sleep 0.1 +done + +cp "$BIN" "$IMAGE/C/libcafile" +cp "$LIB" "$IMAGE/Libs/mqtt.library" +cp "$OUTDIR/ca.crt" "$IMAGE/Devs/AmiSSL/Certs/$CA_FILENAME" +cat > "$IMAGE/S/User-Startup" <<'EOF' +; Written by tests/library/cafile-run.sh on every run - see that script. +Assign AmiSSL: SYS:Devs/AmiSSL +libcafile +echo "LIBCAFILE: ALL-DONE" +EOF + +cat > "$MACHINE_TOML" <"$OUT" 2>/dev/null \ + || { echo "FAIL: $COPPERLINE exited non-zero" >&2; cat "$OUT" >&2; exit 3; } + +tr -d '\r' <"$OUT" >"$OUT.n" && mv "$OUT.n" "$OUT" +echo "----- serial capture -----"; cat "$OUT"; echo "--------------------------" + +grep -q '^END' "$OUT" 2>/dev/null || { echo "FAIL: no END marker (raise BENCH?)" >&2; exit 1; } +grep -q '^FAIL ' "$OUT" && { echo "FAIL: one or more mqtt.library CA-file checks failed on-target" >&2; exit 1; } +grep -q '^RESULT=OK$' "$OUT" || { echo "FAIL: no RESULT=OK marker" >&2; exit 1; } + +echo "PASS: mqtt.library's mco_CAFile works end-to-end on 68020 (private CA trusted, untrusted CA rejected)" diff --git a/tests/library/libcafile.c b/tests/library/libcafile.c new file mode 100644 index 0000000..9e589f4 --- /dev/null +++ b/tests/library/libcafile.c @@ -0,0 +1,113 @@ +/* libcafile.c — on-target end-to-end mco_CAFile test (issue #13): proves + * that a broker behind a private CA (a) fails certificate verification + * without the extra trust anchor, and (b) succeeds with mco_CAFile + * pointing at the CA's certificate. Same shape/serial contract as + * libtls.c - see that file's banner and tests/library/README.md's "TLS + * smoke test" section (this test shares its amibake-image/local-only + * requirement, plus a seeded Kickstart RTC - see cafile-run.sh's banner + * for why that matters). + */ + +#include +#include +#include +#include + +#include +#include + +#include + +struct Library *MqttBase; + +static void raw_put(char c) +{ + void *SysBase = *(void **)4UL; + register long d0 __asm__("d0") = (unsigned char)c; + register void *a6 __asm__("a6") = SysBase; + __asm__ volatile("jsr -516(%%a6)" : : "r"(d0), "r"(a6) + : "d1", "a0", "a1", "cc", "memory"); +} + +static void raw_str(const char *s) +{ + while (*s) + raw_put(*s++); +} + +static int g_fails; + +static void check(int cond, const char *name) +{ + if (cond) { + raw_str("PASS "); + } else { + raw_str("FAIL "); + g_fails++; + } + raw_str(name); + raw_str("\r\n"); +} + +/* Must match cafile-run.sh's own PORT/CA-file path exactly. */ +#define TEST_HOST "127.0.0.1" +#define TEST_PORT 18886 +#define CA_FILE "AmiSSL:Certs/midge-test-ca.pem" + +static APTR connect_with(STRPTR ca_file) +{ + struct MqttConnectOpts opts; + APTR client; + + memset(&opts, 0, sizeof(opts)); + opts.mco_ClientID = (STRPTR) "midge-libcafile"; + opts.mco_KeepAlive = 30; + opts.mco_CleanSession = TRUE; + opts.mco_TLS = TRUE; + opts.mco_CAFile = ca_file; + + client = MQTT_CreateClient((STRPTR) TEST_HOST, TEST_PORT, &opts); + if (!client) + return NULL; + if (MQTT_Connect(client) != 0) { + MQTT_DeleteClient(client); + return NULL; + } + return client; +} + +int main(void) +{ + APTR client; + + raw_str("BEGIN\r\n"); + + MqttBase = OpenLibrary((STRPTR) "mqtt.library", 0); + check(MqttBase != NULL, "openlibrary-nonnull"); + if (!MqttBase) + goto done; + + /* Without the CA file: the broker's cert is signed by a CA AmiSSL's + * bundled trust store has never heard of - verification MUST fail. */ + client = connect_with(NULL); + check(client == NULL, "no-cafile-connect-fails"); + if (client) { + MQTT_Disconnect(client); + MQTT_DeleteClient(client); + } + + /* With it: same broker, same cert - now trusted. */ + client = connect_with((STRPTR) CA_FILE); + check(client != NULL, "with-cafile-connect-ok"); + if (client) { + MQTT_Disconnect(client); + MQTT_DeleteClient(client); + } + + CloseLibrary(MqttBase); + +done: + raw_str(g_fails == 0 ? "RESULT=OK\r\n" : "RESULT=FAIL\r\n"); + raw_str("END\r\n"); + return g_fails; +} diff --git a/userdocs/CLI-Reference.md b/userdocs/CLI-Reference.md index 3d18700..4633377 100644 --- a/userdocs/CLI-Reference.md +++ b/userdocs/CLI-Reference.md @@ -37,6 +37,7 @@ mqtt_pub HOST/A,PORT/N/K,TOPIC/A,MESSAGE/K,FILE/K,QOS/N/K,CLIENTID/K,USER/K, | `VERBOSE` | Print connection and protocol detail to stdout. | | `TLS` | Connect over TLS via AmiSSL, with certificate and hostname verification on. Default build only - see [TLS on the Amiga](#tls-on-the-amiga). When no `PORT` is given, the default becomes 8883 instead of 1883. | | `TLSINSECURE` | Connect over TLS but skip certificate verification (implies `TLS`). For testing against self-signed or otherwise untrusted brokers only. | +| `CAFILE` | Trust an additional CA certificate (PEM file), alongside AmiSSL's bundled trust store - for a broker behind a private CA. Ignored without `TLS`, and ignored if `TLSINSECURE` is also given. | Example: @@ -67,6 +68,7 @@ mqtt_sub HOST/A,PORT/N/K,TOPIC/A,QOS/N/K,CLIENTID/K,USER/K,PASSWORD/K, | `VERBOSE` | Print connection and protocol detail to stdout. | | `TLS` | Connect over TLS via AmiSSL, with certificate and hostname verification on. Default build only - see [TLS on the Amiga](#tls-on-the-amiga). When no `PORT` is given, the default becomes 8883 instead of 1883. | | `TLSINSECURE` | Connect over TLS but skip certificate verification (implies `TLS`). For testing against self-signed or otherwise untrusted brokers only. | +| `CAFILE` | Trust an additional CA certificate (PEM file), alongside AmiSSL's bundled trust store - for a broker behind a private CA. Ignored without `TLS`, and ignored if `TLSINSECURE` is also given. | Example: @@ -86,6 +88,7 @@ host builds so far: |---|---| | `-s` | Enable TLS, with certificate and hostname verification on, checked against the system trust store. When no `-p` is given, the default port becomes 8883 instead of 1883. | | `-S` | Enable TLS but skip certificate verification. Intended only for testing against self-signed or otherwise untrusted brokers. | +| `-c FILE` | Trust an additional CA certificate (PEM file), alongside the system trust store - for a broker behind a private CA. Ignored without `-s`/`-S`, and ignored together with `-S` (nothing to verify against then). | TLS is opt-in and off by default everywhere in midge. @@ -108,6 +111,22 @@ The statically linked `mqtt_pub-static`/`mqtt_sub-static` have no AmiSSL support and reject `TLS` outright rather than silently connecting in plaintext. +`CAFILE`/`-c` trust an additional certificate authority (a PEM file) +alongside the bundled trust store, for a broker behind a private CA - +without it, `TLS` (not `TLSINSECURE`) fails against a broker whose +certificate isn't signed by a CA already in that store. + +### A note on TLS and the system clock + +Certificate verification (`TLS` without `TLSINSECURE`) checks the +broker's certificate validity dates against the Amiga's own system +clock. A wrong clock - common on real hardware with a dead or unset +battery-backed RTC, and easy to overlook under an emulator too - makes a +perfectly good certificate look not-yet-valid or expired, and the +handshake fails with no indication the actual problem is the clock, not +the certificate. Set the clock (`SetClock`, `IControl`, or an NTP client) +before relying on certificate verification. + ### A note on TLS and CPU speed Software TLS is CPU-intensive, and de-risking work for the Amiga-side @@ -119,6 +138,5 @@ bump - any real accelerator, or a 68030 or better - clears this reliably. This isn't specific to midge: [AmiSSL's own maintainer has reached the same conclusion](https://github.com/jens-maus/amissl/issues/111) for other software - "the Amiga can't keep up with modern SSL" at stock -clock speeds. Once TLS ships on the Amiga side, expect it to work best on -an accelerated machine, and to occasionally need a retry on genuinely -stock hardware. +clock speeds. Expect TLS to work best on an accelerated machine, and to +occasionally need a retry on genuinely stock hardware. diff --git a/userdocs/mqtt-library-reference.md b/userdocs/mqtt-library-reference.md index d076683..a487ff5 100644 --- a/userdocs/mqtt-library-reference.md +++ b/userdocs/mqtt-library-reference.md @@ -195,8 +195,8 @@ port - broker TCP port (1883 is the conventional unencrypted MQTT port). opts - connect options (client id, credentials, keepalive, clean-session, mco_AutoReconnect, mco_TLS, - mco_TLSInsecure); see struct MqttConnectOpts in - . May be NULL, which behaves like a + mco_TLSInsecure, mco_CAFile); see struct MqttConnectOpts + in . May be NULL, which behaves like a zeroed struct (no client id, no credentials, no keepalive, clean session, no auto-reconnect, no TLS). @@ -225,6 +225,17 @@ and is ignored unless mco_TLS is also set - for testing against self-signed or otherwise untrusted brokers only, never for production use. +mco_CAFile (issue #13) additionally trusts a PEM file's CA +alongside AmiSSL's bundled trust store - for a broker behind a +private CA that isn't in it. Certificate verification checks the +broker cert's validity dates against the Amiga's own system +clock, same as any TLS client - a system clock that's wrong +(common on real hardware with a dead or unset battery-backed +RTC) will make a perfectly good certificate look not-yet-valid +or expired and fail the handshake. Set the clock (SetClock, +IControl, or NTP via a suitable client) before relying on +certificate verification. + ### See Also MQTT_Connect, MQTT_DeleteClient