From d080271ceb4f056a40bee880f6560319079affcd Mon Sep 17 00:00:00 2001 From: Simon Dick Date: Tue, 28 Jul 2026 10:06:00 +0100 Subject: [PATCH] Add ARexx port for automation (#46) A genuine public AMIAUTH. RexxMsg port on AmiAuthGUI, separate from the private CLI-forwarding port: GETCODE, TIMELEFT, LIST, STATUS, LOCK, UNLOCK (always interactive - the passphrase never crosses the port), SHOW/HIDE (gated on running as a registered commodity), and QUIT. Standard RC convention (0/5/10/20) and a per-vault arexxgetcode setting (default on) to restrict the port to control-only commands. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K5bAsAyoYLpkX3rWwrTBPh --- Makefile | 26 ++- docs/SECURITY.md | 37 ++-- mkdocs.yml | 1 + src/amiga/arexx.c | 100 +++++++++++ src/amiga/arexx.h | 53 ++++++ src/core/arexx_cmd.c | 107 ++++++++++++ src/core/arexx_cmd.h | 68 ++++++++ src/gui/main.c | 260 ++++++++++++++++++++++++---- tests/copperline/arexx-probe.rexx | 31 ++++ tests/copperline/arexxtest.c | 56 ++++++ tests/gui/arexx-onhw.sh | 130 ++++++++++++++ tests/test_arexx_cmd.c | 61 +++++++ tests/test_main.c | 2 + tools/docs2guide.py | 1 + userdocs/ARexx-Port.md | 93 ++++++++++ userdocs/Commodity-and-Tooltypes.md | 5 +- userdocs/Security-Model.md | 9 +- userdocs/Settings-Reference.md | 1 + userdocs/Troubleshooting-and-FAQ.md | 9 +- 19 files changed, 986 insertions(+), 64 deletions(-) create mode 100644 src/amiga/arexx.c create mode 100644 src/amiga/arexx.h create mode 100644 src/core/arexx_cmd.c create mode 100644 src/core/arexx_cmd.h create mode 100644 tests/copperline/arexx-probe.rexx create mode 100644 tests/copperline/arexxtest.c create mode 100755 tests/gui/arexx-onhw.sh create mode 100644 tests/test_arexx_cmd.c create mode 100644 userdocs/ARexx-Port.md diff --git a/Makefile b/Makefile index 85bd6e4..f2bc34b 100644 --- a/Makefile +++ b/Makefile @@ -70,8 +70,10 @@ QR_CPPFLAGS := -Isrc/qr -DQUIRC_FLOAT_TYPE=float -DQUIRC_USE_TGMATH QRENC_WRAP := src/qr/qrencode.c DIFF_SRCS := tests/diff/diff_main.c # AmigaOS-only front-end glue (bsdsocket SNTP, ...); m68k build only. qrimage.c -# is GUI-only (datatypes.library) so it's excluded here and added to GUI_SRCS. -AMIGA_SRCS := $(filter-out src/amiga/qrimage.c,$(wildcard src/amiga/*.c)) +# and arexx.c are GUI-only (datatypes.library / the resident ARexx port - +# the CLI is a one-shot process, nothing to serve) so both are excluded here +# and added to GUI_SRCS instead. +AMIGA_SRCS := $(filter-out src/amiga/qrimage.c src/amiga/arexx.c,$(wildcard src/amiga/*.c)) # OpenSSL flags for the differential harness (pkg-config, with a plain fallback). OPENSSL_CFLAGS ?= $(shell pkg-config --cflags libcrypto 2>/dev/null) @@ -90,7 +92,7 @@ QUIRC_M68K_OBJS := $(patsubst src/qr/%.c,$(BUILD)/qr-m68k/%.o,$(QUIRC_SRCS)) QRCODEGEN_HOST_OBJ := $(BUILD)/qr-host/qrcodegen.o QRCODEGEN_M68K_OBJ := $(BUILD)/qr-m68k/qrcodegen.o -.PHONY: all test cli smoke diff m68k m68k-docker gui gui-docker gui-smoke qr-onhw qr-onhw-docker qr-onhw-smoke serialtest-m68k serialtest-m68k-docker copperline-smoke pbkdf2-bench asm-bench amissl-bench clean +.PHONY: all test cli smoke diff m68k m68k-docker gui gui-docker gui-smoke qr-onhw qr-onhw-docker qr-onhw-smoke arexx-onhw arexx-onhw-docker arexx-onhw-smoke serialtest-m68k serialtest-m68k-docker copperline-smoke pbkdf2-bench asm-bench amissl-bench clean all: test cli @@ -141,7 +143,8 @@ m68k: $(QRCODEGEN_M68K_OBJ) | $(BUILD) # --- m68k: ReAction GUI binary (Amiga only; needs intuition + ReAction classes) --- # Includes the QR decoder: qrimage.c (datatypes glue) + our qr.c wrapper + the # vendored quirc objects (built -w for m68k). QUIRC_FLOAT_TYPE=float: no FPU. -GUI_SRCS := src/gui/main.c src/amiga/qrimage.c +# arexx.c (#46) is the ARexx port's RexxMsg glue - GUI-only, see AMIGA_SRCS. +GUI_SRCS := src/gui/main.c src/amiga/qrimage.c src/amiga/arexx.c # Vendored quirc objects — m68k toolchain, warnings suppressed (third-party). $(BUILD)/qr-m68k/%.o: src/qr/%.c | $(BUILD) @@ -222,6 +225,21 @@ serialtest-m68k-docker: copperline-smoke: serialtest-m68k-docker sh tests/copperline/run.sh +# --- Headless on-target ARexx port test: boot WB 3.2, launch AmiAuthGUI +# resident, run a real ARexx script (tests/copperline/arexx-probe.rexx) via +# the WB image's resident RexxMast (`rx`) against AMIAUTH.1, then relay its +# redirected output back over serial with this small m68k program (arexxtest). +# See tests/gui/arexx-onhw.sh. +arexx-onhw: | $(BUILD) + $(M68K_CC) $(M68K_CFLAGS) tests/copperline/arexxtest.c -o $(BUILD)/arexxtest + +arexx-onhw-docker: + $(DOCKER) run --rm --platform linux/amd64 $(DOCKER_USER) -v "$(CURDIR)":/work -w /work \ + $(AMIGA_GCC_IMAGE) sh -lc 'PATH=/opt/amiga/bin:$$PATH make arexx-onhw' + +arexx-onhw-smoke: + sh tests/gui/arexx-onhw.sh + # Measure PBKDF2 throughput on a stock 68000 (informs the KDF policy). Dev-only: # needs a Kickstart ROM (timer.device EClock isn't available under AROS). pbkdf2-bench: diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fde9c53..36af202 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -102,7 +102,7 @@ would be dishonest. Specifically: The passphrase is optional at vault creation. Without one, the vault is stored **unencrypted** (same file format, cipher marked `none`) and every entry point — -GUI, hotkey popup, CLI, and (v2) ARexx — works with zero prompts. +GUI, hotkey popup, CLI, and ARexx — works with zero prompts. This is the right trade for a single-user machine at home, a dedicated emulator instance, or scripted/headless use where the CLI must run non-interactively. @@ -112,18 +112,29 @@ plaintext exports anyway. State it plainly: **in this mode there is no at-rest protection — anyone with the file has the secrets.** It is a deliberate opt-out, never the default. It is convertible in both directions from settings (add/change/remove passphrase, -re-encrypting or decrypting the vault on disk). Auto-lock and (v2) ARexx -`LOCK`/`UNLOCK` become no-ops; `STATUS` reports the mode explicitly. - -## ARexx port (v2) - -If/when an ARexx port ships, one hard rule governs it: **the port never carries -the passphrase.** Unlocking is exclusively interactive (GUI requester); scripts -operate against a vault the user has already unlocked. A per-vault "allow ARexx -`GETCODE`" setting (default on) lets cautious users restrict the port to control -commands only. The security note will state plainly that any running program can -drive the port while unlocked — not materially worse than the no-memory-protection -baseline, but worth saying. +re-encrypting or decrypting the vault on disk). Auto-lock and ARexx +`LOCK`/`UNLOCK` are no-ops (RC 0, `RESULT "always-unlocked"`) for it; +`STATUS` reports the mode explicitly. + +## ARexx port + +AmiAuth exposes a public `AMIAUTH.` ARexx port (GUI only; see +[ARexx Port](../userdocs/ARexx-Port.md) for the full command reference). One +hard rule governs it: **the port never carries the passphrase.** `UNLOCK` is +exclusively interactive — it reuses the same GUI passphrase requester as the +window/hotkey/commodity paths, never accepting one over the port. Scripts +otherwise operate against a vault the user has already unlocked. + +The `ENVARC:AmiAuth/arexxgetcode` setting (default on; `off` disables) lets +cautious users restrict the port to control commands (`STATUS`, `LOCK`, +`UNLOCK`, `SHOW`, `HIDE`, `QUIT`) and deny `GETCODE`/`TIMELEFT`. `GETCODE` +returns the same RC (20, failure) whether the vault is locked or the pref is +off — deliberately indistinguishable, so a script can't use the RC alone to +probe *why* it was refused. + +Any running program on the system can drive the port while the vault is +unlocked — not materially worse than the no-memory-protection baseline +above, but worth saying plainly. ## Scope discipline diff --git a/mkdocs.yml b/mkdocs.yml index 1fff57f..cb5437b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ nav: - CLI Reference: CLI-Reference.md - GUI Guide: GUI-Guide.md - Commodity and Tooltypes: Commodity-and-Tooltypes.md + - ARexx Port: ARexx-Port.md - Time and Clock Sync: Time-and-Clock-Sync.md - Under the hood: - Vault and Passphrases: Vault-and-Passphrases.md diff --git a/src/amiga/arexx.c b/src/amiga/arexx.c new file mode 100644 index 0000000..4b5373f --- /dev/null +++ b/src/amiga/arexx.c @@ -0,0 +1,100 @@ +/* arexx.c -- see arexx.h. AmigaOS only (never built into the CLI or host). */ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "arexx.h" + +/* Defined here (the one file that actually calls rexxsyslib functions); + * 's inline stubs reference this exact global by name, + * same convention as SysBase/IntuitionBase/GfxBase elsewhere in this + * project - it must NOT be static. gui/main.c's open_libs()/close_libs() + * own its OpenLibrary()/CloseLibrary() lifecycle, matching every other + * library base in this app. */ +struct RxsLib *RexxSysBase = NULL; + +/* Try slots 1..99 (a generous, arbitrary cap - realistically 1-2 instances + * ever run) under one Forbid() so two AmiAuth processes launched at once + * can't race onto the same name. */ +#define AREXX_MAX_SLOT 99 + +struct MsgPort *arexx_open(const char *portname_override, + char *out_name, size_t out_name_cap) +{ + static char name[32]; /* "AMIAUTH.NN" - static: AddPort() keeps a + * reference, must outlive the port itself */ + struct MsgPort *port = NULL; + + if (!RexxSysBase) return NULL; + + Forbid(); + if (portname_override && portname_override[0]) { + strncpy(name, portname_override, sizeof name - 1); + name[sizeof name - 1] = '\0'; + if (!FindPort((CONST_STRPTR)name)) { + port = CreateMsgPort(); + if (port) { port->mp_Node.ln_Name = name; AddPort(port); } + } + } else { + int n; + for (n = 1; n <= AREXX_MAX_SLOT; n++) { + sprintf(name, "AMIAUTH.%d", n); + if (!FindPort((CONST_STRPTR)name)) { + port = CreateMsgPort(); + if (port) { port->mp_Node.ln_Name = name; AddPort(port); } + break; + } + } + } + Permit(); + + if (port && out_name && out_name_cap) { + strncpy(out_name, name, out_name_cap - 1); + out_name[out_name_cap - 1] = '\0'; + } + return port; +} + +void arexx_close(struct MsgPort *port) +{ + struct RexxMsg *msg; + if (!port) return; + Forbid(); + RemPort(port); + Permit(); + while ((msg = (struct RexxMsg *)GetMsg(port)) != NULL) + arexx_reply(msg, AREXX_RC_FAIL, NULL); + DeleteMsgPort(port); +} + +void *arexx_receive(struct MsgPort *port, arexx_parsed *out) +{ + struct RexxMsg *msg; + while ((msg = (struct RexxMsg *)GetMsg(port)) != NULL) { + if (!IsRexxMsg(msg)) continue; /* not ours; shouldn't happen, drop it */ + if (arexx_parse((const char *)ARG0(msg), out) != 0) + out->type = AREXX_CMD_UNKNOWN; /* still reply - RC 10, see caller */ + return (void *)msg; + } + return NULL; +} + +void arexx_reply(void *handle, int rc, const char *result) +{ + struct RexxMsg *msg = (struct RexxMsg *)handle; + if (!msg) return; + msg->rm_Result1 = rc; + msg->rm_Result2 = 0; + if ((msg->rm_Action & RXFF_RESULT) && result && result[0]) + msg->rm_Result2 = (LONG)CreateArgstring((UBYTE *)result, (ULONG)strlen(result)); + ReplyMsg((struct Message *)msg); + /* Do not DeleteArgstring(rm_Result2) here - ARexx frees it after + * consuming the reply. See arexx.h's note on this. */ +} diff --git a/src/amiga/arexx.h b/src/amiga/arexx.h new file mode 100644 index 0000000..cc76432 --- /dev/null +++ b/src/amiga/arexx.h @@ -0,0 +1,53 @@ +/* arexx.h -- ARexx port for AmiAuth automation (#46), GUI-only. + * + * A genuine public RexxMsg port (AMIAUTH.), separate from the private + * CLI-forwarding port in guiport.h - two ports, two purposes, coexisting. + * Port creation/teardown and RexxMsg mechanics live here; the actual + * per-command work (touching the vault/window) stays in src/gui/main.c's + * event loop, same split guiport.h/main.c already uses for AAP_*. Command + * parsing itself is portable (src/core/arexx_cmd.h), so only this file's + * RexxMsg glue is Amiga-only. + * + * The passphrase never crosses this port - see docs/SECURITY.md. + */ +#ifndef AMIAUTH_AREXX_H +#define AMIAUTH_AREXX_H + +#include + +#include "arexx_cmd.h" + +/* Open the port. `portname_override` (NULL/empty for the default) is the + * PORTNAME tooltype/arg; otherwise derives "AMIAUTH." (uppercase, + * lowest free slot, the standard . convention). Copies + * the actual name used into out_name (out_name_cap bytes, for display - + * e.g. the window title) if out_name is non-NULL. Returns the port, or + * NULL if rexxsyslib.library isn't open (the caller's own open_libs() + * must set RexxSysBase first) or no port could be created - absence just + * means no ARexx port, not a fatal error, matching this project's other + * optional-library features. */ +struct MsgPort *arexx_open(const char *portname_override, + char *out_name, size_t out_name_cap); + +/* Reply any still-queued message with AREXX_RC_FAIL (mirrors pubport's own + * teardown in main.c), then RemPort + DeleteMsgPort. */ +void arexx_close(struct MsgPort *port); + +/* Pull the next genuine ARexx message off `port` (validated via + * IsRexxMsg(); anything else is silently dropped - nothing but the ARexx + * interpreter should ever PutMsg() to a public ARexx-named port, but this + * mirrors the standard caution) and parse it into `out` via arexx_parse(). + * Returns an opaque handle for arexx_reply(), or NULL once the port is + * drained for this signal. */ +void *arexx_receive(struct MsgPort *port, arexx_parsed *out); + +/* Reply to the message `handle` identifies (from arexx_receive). Always + * sets the RC; only builds a RESULT argstring if the caller actually asked + * for one (RXFB_RESULT) - `result` may be NULL/empty either way. The + * argstring (if any) is never freed here: ReplyMsg() hands ownership to + * the ARexx interpreter, which frees it after consuming the reply + * (confirmed against a canonical reference host implementation - do not + * "fix" this into a leak by adding a DeleteArgstring call here). */ +void arexx_reply(void *handle, int rc, const char *result); + +#endif /* AMIAUTH_AREXX_H */ diff --git a/src/core/arexx_cmd.c b/src/core/arexx_cmd.c new file mode 100644 index 0000000..63a8629 --- /dev/null +++ b/src/core/arexx_cmd.c @@ -0,0 +1,107 @@ +/* arexx_cmd.c -- see arexx_cmd.h. */ +#include + +#include "arexx_cmd.h" +#include "otp.h" + +/* ASCII case-insensitive full-string compare, same shape as the CLI's own + * ci_streq (src/cli/main.c) - kept as a separate copy since this file must + * stay a portable core/ module with no dependency on the CLI front-end. */ +static int ci_streq(const char *a, const char *b) +{ + for (; *a && *b; a++, b++) { + int ca = *a, cb = *b; + if (ca >= 'A' && ca <= 'Z') ca += 32; + if (cb >= 'A' && cb <= 'Z') cb += 32; + if (ca != cb) return 0; + } + return *a == '\0' && *b == '\0'; +} + +static const char *skip_ws(const char *p) +{ + while (*p == ' ' || *p == '\t') p++; + return p; +} + +/* Read one token starting at p. A leading '"' reads a quoted token up to + * the closing '"' (no embedded-quote escaping - not needed for this app's + * simple templates); otherwise reads up to the next whitespace. Copies + * into dst (cap bytes, NUL-terminated, silently truncates if needed) and + * returns a pointer just past the token. */ +static const char *read_token(const char *p, char *dst, size_t cap) +{ + size_t n = 0; + if (*p == '"') { + p++; + while (*p && *p != '"') { + if (n + 1 < cap) dst[n++] = *p; + p++; + } + if (*p == '"') p++; + } else { + while (*p && *p != ' ' && *p != '\t') { + if (n + 1 < cap) dst[n++] = *p; + p++; + } + } + dst[n] = '\0'; + return p; +} + +int arexx_parse(const char *cmdline, arexx_parsed *out) +{ + char kw[16]; + const char *p; + + if (!cmdline || !out) return -1; + memset(out, 0, sizeof *out); + out->type = AREXX_CMD_UNKNOWN; + + p = skip_ws(cmdline); + p = read_token(p, kw, sizeof kw); + + if (ci_streq(kw, "GETCODE")) out->type = AREXX_CMD_GETCODE; + else if (ci_streq(kw, "TIMELEFT")) out->type = AREXX_CMD_TIMELEFT; + else if (ci_streq(kw, "LIST")) out->type = AREXX_CMD_LIST; + else if (ci_streq(kw, "STATUS")) out->type = AREXX_CMD_STATUS; + else if (ci_streq(kw, "LOCK")) out->type = AREXX_CMD_LOCK; + else if (ci_streq(kw, "UNLOCK")) out->type = AREXX_CMD_UNLOCK; + else if (ci_streq(kw, "SHOW")) out->type = AREXX_CMD_SHOW; + else if (ci_streq(kw, "HIDE")) out->type = AREXX_CMD_HIDE; + else if (ci_streq(kw, "QUIT")) out->type = AREXX_CMD_QUIT; + else { out->type = AREXX_CMD_UNKNOWN; return -1; } + + switch (out->type) { + case AREXX_CMD_GETCODE: + case AREXX_CMD_TIMELEFT: { + char acct[AREXX_MAX_ACCOUNT]; + p = skip_ws(p); + if (!*p) { out->type = AREXX_CMD_UNKNOWN; return -1; } /* ACCOUNT/A missing */ + read_token(p, acct, sizeof acct); + strcpy(out->account, acct); + break; + } + case AREXX_CMD_QUIT: { + char sw[16]; + p = skip_ws(p); + if (*p) { + read_token(p, sw, sizeof sw); + if (ci_streq(sw, "FORCE")) out->force = 1; + /* Anything else trailing FORCE is ignored, matching the + * general leniency of not needing exact ReadArgs parity + * for a single optional switch. */ + } + break; + } + default: + break; /* LIST/STATUS/LOCK/UNLOCK/SHOW/HIDE take no arguments */ + } + return 0; +} + +long arexx_timeleft(int is_hotp, uint64_t now, uint64_t t0, uint32_t period) +{ + if (is_hotp) return -1; + return (long)totp_seconds_remaining(now, t0, period); +} diff --git a/src/core/arexx_cmd.h b/src/core/arexx_cmd.h new file mode 100644 index 0000000..fda1505 --- /dev/null +++ b/src/core/arexx_cmd.h @@ -0,0 +1,68 @@ +/* arexx_cmd.h -- portable ARexx command-line parsing + RC policy for the + * ARexx port (#46). Pure C, no Amiga types: this is the part of arexx.c + * that can be host-tested. The Amiga-only glue (RexxMsg handling, port + * creation) lives in src/amiga/arexx.c; the actual vault/window work each + * command does lives in src/gui/main.c, same split as guiport.h/main.c. + * + * VAR/STEM note: earlier design notes on this issue suggested a VAR/STEM + * keyword on LIST's own template, but VAR/STEM aren't a language-level + * clause a host command string carries -- a script gets the same + * stem-like result already, entirely on its own, by PARSEing LIST's + * plain multi-line RESULT (one "issuer:label" per line). True automatic + * STEM-variable population would need rexxsyslib.library's separate + * variable-pool API (SetRexxVarFromMsg) for no capability gain over that, + * so LIST's template stays argument-free. */ +#ifndef AMIAUTH_AREXX_CMD_H +#define AMIAUTH_AREXX_CMD_H + +#include +#include + +/* The 9 commands (#46). */ +typedef enum { + AREXX_CMD_UNKNOWN = 0, + AREXX_CMD_GETCODE, + AREXX_CMD_TIMELEFT, + AREXX_CMD_LIST, + AREXX_CMD_STATUS, + AREXX_CMD_LOCK, + AREXX_CMD_UNLOCK, + AREXX_CMD_SHOW, + AREXX_CMD_HIDE, + AREXX_CMD_QUIT +} arexx_cmd_type; + +/* ARexx RC convention (see the issue's review comment / userdocs): + * 0 success, 5 warning (user cancelled interactively), 10 error (bad + * argument / unknown command / account not found), 20 failure (couldn't + * reach the vault - locked, gated off, or a save failed). */ +enum { + AREXX_RC_OK = 0, + AREXX_RC_WARN = 5, + AREXX_RC_ERROR = 10, + AREXX_RC_FAIL = 20 +}; + +#define AREXX_MAX_ACCOUNT 192 /* generous; matches OTP_MAX_ISSUER+LABEL headroom */ + +typedef struct { + arexx_cmd_type type; + char account[AREXX_MAX_ACCOUNT]; /* GETCODE/TIMELEFT; empty otherwise */ + int force; /* QUIT FORCE/S */ +} arexx_parsed; + +/* Parse one ARexx command line (the raw text a script sends, e.g. + * "GETCODE github" or "GETCODE \"My Account\"" or "QUIT FORCE") into + * `out`. Case-insensitive command keyword; the account argument accepts + * a double-quoted form for names containing spaces. Returns 0 on success, + * -1 on an unknown command or a missing required argument (map to + * AREXX_RC_ERROR) -- `out->type` is AREXX_CMD_UNKNOWN on failure. */ +int arexx_parse(const char *cmdline, arexx_parsed *out); + +/* TOTP: seconds remaining in the current period (wraps + * totp_seconds_remaining, otp.h). HOTP has no time concept: returns -1, + * a well-defined "not applicable" answer, not an error (RC stays 0 either + * way) -- so scripts can branch on RESULT < 0 without special-casing RC. */ +long arexx_timeleft(int is_hotp, uint64_t now, uint64_t t0, uint32_t period); + +#endif /* AMIAUTH_AREXX_CMD_H */ diff --git a/src/gui/main.c b/src/gui/main.c index f59d1a3..4dd55d7 100644 --- a/src/gui/main.c +++ b/src/gui/main.c @@ -51,6 +51,8 @@ #include /* AddAppWindowA/RemoveAppWindow (QR drag-and-drop); * note: workbench.library's proto header is * "wb.h", not "workbench.h" */ +#include /* RexxSysBase (#46, ARexx port) */ +#include /* struct RxsLib (RexxSysBase's real type) */ #include #include @@ -72,6 +74,7 @@ AMIAUTH_VERSTAG("AmiAuthGUI") #include "qrimage.h" /* qrimage_load_gray (datatypes glue) */ #include "guiport.h" /* CLI->GUI IPC (Stage 3b public port) */ #include "crypto_select.h" /* select crypto hot-loop impl, #47 */ +#include "arexx.h" /* ARexx automation port, #46 */ /* Request a larger stack (libnix): the QR decoder still needs more than the * few KB a shell hands a Run/WBench program. qr_decode_gray keeps its big @@ -126,6 +129,10 @@ static char g_pubscreen[MAXPUBSCREENNAME + 1] = ""; * startup; same precedence and session-only scope as the CLI's VAULT/K. */ static char g_vault_arg[256] = ""; +/* ARexx port name override (PORTNAME tooltype/arg, #46); empty = the default + * slot-numbered "AMIAUTH." derived by arexx_open(). */ +static char g_portname_arg[32] = ""; + /* clock-status LED: red/amber/green pens indexed by clock_state (-1 = none), * drawn in a recessed bevel (shadow/shine pens from the screen's DrawInfo). */ static LONG g_ledpen[3] = { -1, -1, -1 }; @@ -170,16 +177,24 @@ enum { CMD_ADD_CLIP = 1, CMD_ADD_TYPE, CMD_ADD_QR, CMD_EDIT, CMD_COPY, CMD_SHOW_ static char g_code[VAULT_MAX_ACCOUNTS][12]; static char g_left[VAULT_MAX_ACCOUNTS][8]; +/* ARexx LIST reply scratch (#46): sized for the worst case, every account's + * "issuer:label\n" line at once; off the stack like everything else here. */ +static char g_rexx_listbuf[VAULT_MAX_ACCOUNTS * (OTP_MAX_ISSUER + OTP_MAX_LABEL + 3)]; + /* The main window's title, including the version/build hash (set at window * creation, win_show below). Single source of truth so the QR-decode busy * title (do_add_qr, "AmiAuth - Decoding QR image...") restores the real * title afterward instead of a separate hardcoded "AmiAuth" that drops the - * version/hash. */ + * version/hash. Mutable (not const): main() appends " [AMIAUTH.]" once + * the ARexx port opens (#46), so the port name is discoverable without a + * separate About window. */ #ifdef AMIAUTH_BUILD_HASH -static const char g_main_title[] = "AmiAuth " AMIAUTH_VERSION " (" AMIAUTH_BUILD_HASH ")"; +#define AMIAUTH_TITLE_BASE "AmiAuth " AMIAUTH_VERSION " (" AMIAUTH_BUILD_HASH ")" #else -static const char g_main_title[] = "AmiAuth " AMIAUTH_VERSION; +#define AMIAUTH_TITLE_BASE "AmiAuth " AMIAUTH_VERSION #endif +static char g_main_title[sizeof(AMIAUTH_TITLE_BASE) + 40 /* " [" + rexxportname(31) + "]" */] + = AMIAUTH_TITLE_BASE; static struct ColumnInfo g_columns[] = { { 50, (STRPTR)"Account", CIF_WEIGHTED }, { 34, (STRPTR)"Code", CIF_WEIGHTED }, @@ -225,6 +240,7 @@ static struct NewMenu g_menu[] = { static void close_libs(void) { + if (RexxSysBase) CloseLibrary((struct Library *)RexxSysBase); if (CxBase) CloseLibrary(CxBase); if (WorkbenchBase) CloseLibrary(WorkbenchBase); if (AslBase) CloseLibrary(AslBase); @@ -262,6 +278,7 @@ static const char *open_libs(void) AslBase = OpenLibrary((STRPTR)"asl.library", 37); WorkbenchBase = OpenLibrary((STRPTR)"workbench.library", 37); CxBase = OpenLibrary((STRPTR)"commodities.library", 37); /* optional: commodity */ + RexxSysBase = (struct RxsLib *)OpenLibrary((STRPTR)"rexxsyslib.library", 0); /* optional: ARexx port, #46 */ if (!IntuitionBase || !UtilityBase) return "needs intuition.library / utility.library v37+ (OS 2.04)"; if (!WindowBase || !LayoutBase || !ListBrowserBase || !FuelGaugeBase || !ButtonBase) @@ -1746,6 +1763,73 @@ static void win_hide(struct gui_widgets *gw, struct Window *w) memset(gw, 0, sizeof *gw); } +/* --- shared command bodies (#46): both the guiport (CLI-forward) switch and + * the ARexx command switch dispatch into these, rather than each having + * their own copy of the same vault/window logic. --- */ + +/* Show the window, unlocking on demand if this is a deferred (hidden) start + * - same behaviour as the hotkey/AppMenu "appear" path. Returns 1 on + * success (win, deferred and winsig outputs updated), 0 if declined (still + * locked; nothing changed). */ +static int gui_do_show(struct gui_widgets *gw, struct List *lblist, vault *v, + int have_clip, const clock_ctx *clk, char *statbuf, + char *vpath, size_t vpath_cap, int *encrypted, + size_t *naccounts, struct Window **win, int *deferred, + ULONG *winsig) +{ + if (*win) { WindowToFront(*win); ActivateWindow(*win); return 1; } + if (!*deferred || deferred_open(v, vpath, vpath_cap, encrypted, lblist, + gw->listobj, naccounts)) { + *deferred = 0; + *win = win_show(gw, lblist, v, have_clip, clk, statbuf, winsig, clk->state); + return 1; + } + return 0; +} + +/* "issuer:label\n" per account into rb (bounded by rbcap). Caller checks + * v->unlocked first - both callers do, with their own not-unlocked result. */ +static void gui_do_list(const vault *v, char *rb, size_t rbcap) +{ + size_t k; + if (!rb || !rbcap) return; + rb[0] = '\0'; + for (k = 0; k < v->count; k++) { + const otp_account *a = &v->accounts[k]; + char line[OTP_MAX_ISSUER + OTP_MAX_LABEL + 3]; + if (a->issuer[0]) { strcpy(line, a->issuer); strcat(line, ":"); strcat(line, a->label); } + else strcpy(line, a->label); + strcat(line, "\n"); + if (strlen(rb) + strlen(line) < rbcap) strcat(rb, line); + } +} + +/* Render `arg`'s current code into rb (bounded by rbcap, "\n"-terminated). + * HOTP advances + persists the counter, same as always. Returns 0 on + * success, -1 if no account matches. *save_failed (if non-NULL) is set on a + * HOTP persist failure - callers decide what that means for their own + * result code. Caller checks v->unlocked (and any ARexx-specific gating) + * first. */ +static int gui_do_get(vault *v, const char *path, const clock_ctx *clk, + const char *arg, char *rb, size_t rbcap, int *save_failed) +{ + int idx = gui_find_account(v, arg); + otp_account *a; + char code[OTP_CODE_BUF]; + if (idx < 0) return -1; + a = &v->accounts[idx]; + if (save_failed) *save_failed = 0; + if (strcmp(a->type, "hotp") == 0) { + otp_render(a, 0, code); + a->counter++; /* stateful: persist */ + if (gui_save(v, path) != VAULT_OK && save_failed) *save_failed = 1; + } else { + otp_render(a, clock_now_utc(clk), code); + } + if (rb && rbcap > strlen(code) + 1) { strcpy(rb, code); strcat(rb, "\n"); } + return 0; +} + /* ------------------------------------------------------------------ */ int main(int argc, char **argv) @@ -1775,6 +1859,12 @@ int main(int argc, char **argv) * open the vault a second time. Created once we hold the (unlocked) vault. */ struct MsgPort *pubport = NULL; ULONG pubsig = 0; + /* ARexx automation port (#46): a genuine public RexxMsg port, separate + * from pubport above (that's the private CLI-forwarding protocol). See + * arexx.h for why the two don't share one port. */ + struct MsgPort *rexxport = NULL; + ULONG rexxsig = 0; + static char rexxportname[32]; curcode[0] = '\0'; crypto_select_init(); @@ -1813,6 +1903,16 @@ int main(int argc, char **argv) } } + /* PORTNAME=: override the default "AMIAUTH." ARexx port name + * (#46), same tooltype/arg convention as VAULT/PUBSCREEN above. */ + { + STRPTR pn = ArgString((CONST_STRPTR *)tt, (CONST_STRPTR)"PORTNAME", NULL); + if (pn && pn[0]) { + strncpy(g_portname_arg, (const char *)pn, sizeof g_portname_arg - 1); + g_portname_arg[sizeof g_portname_arg - 1] = '\0'; + } + } + /* Register the commodity broker BEFORE unlocking: a second launch must * detect the running instance and exit without prompting for a passphrase. */ if (CxBase && (cxport = CreateMsgPort()) != NULL) { @@ -1917,6 +2017,18 @@ int main(int argc, char **argv) appsig = g_appport ? (1UL << g_appport->mp_SigBit) : 0; if (have_timer) timer_arm(1); + /* ARexx automation port (#46): open before the window so its name (if + * any) is already in g_main_title at window-creation time. Absence + * (no rexxsyslib.library, or all AMIAUTH.1..99 slots taken) just means + * no ARexx port - not fatal, same as the other optional libraries. */ + rexxport = arexx_open(g_portname_arg, rexxportname, sizeof rexxportname); + if (rexxport) { + rexxsig = 1UL << rexxport->mp_SigBit; + strcat(g_main_title, " ["); + strcat(g_main_title, rexxportname); + strcat(g_main_title, "]"); + } + /* Open the window now, unless started as a hidden commodity (CX_POPUP=no): * then it stays dormant until the hotkey / Exchange "Show". win_show sets * winsig; it stays 0 while hidden so the event loop drops it from the mask. */ @@ -1977,7 +2089,7 @@ int main(int argc, char **argv) while (running) { ULONG sigs = Wait((win ? winsig : 0) | timersig | (win ? appsig : 0) | - cxsig | pubsig | SIGBREAKF_CTRL_C); + cxsig | pubsig | rexxsig | SIGBREAKF_CTRL_C); changed = 0; /* set by any add/remove/edit this pass */ if (sigs & SIGBREAKF_CTRL_C) running = 0; @@ -2032,47 +2144,22 @@ int main(int argc, char **argv) req->aar_Result = AAR_OK; switch (req->aar_Cmd) { case AAP_SHOW: - if (win) { WindowToFront(win); ActivateWindow(win); } - else if (!deferred || - deferred_open(&v, vpath, sizeof vpath, &encrypted, - &lblist, gw.listobj, &naccounts)) { - deferred = 0; - win = win_show(&gw, &lblist, &v, have_clip, &clk, statbuf, &winsig, clk.state); - } else { + if (!gui_do_show(&gw, &lblist, &v, have_clip, &clk, statbuf, + vpath, sizeof vpath, &encrypted, &naccounts, + &win, &deferred, &winsig)) req->aar_Result = AAR_LOCKED; /* declined: still locked */ - } break; case AAP_LIST: if (!v.unlocked) { req->aar_Result = AAR_LOCKED; break; } - if (rb && rbcap) { - size_t k; rb[0] = '\0'; - for (k = 0; k < v.count; k++) { - const otp_account *a = &v.accounts[k]; - char line[OTP_MAX_ISSUER + OTP_MAX_LABEL + 3]; - if (a->issuer[0]) { strcpy(line, a->issuer); strcat(line, ":"); strcat(line, a->label); } - else strcpy(line, a->label); - strcat(line, "\n"); - if (strlen(rb) + strlen(line) < rbcap) strcat(rb, line); - } - } + gui_do_list(&v, rb, rbcap); break; case AAP_GET: { - int idx; + int save_failed = 0; if (!v.unlocked) { req->aar_Result = AAR_LOCKED; break; } - idx = gui_find_account(&v, arg); - if (idx < 0) { req->aar_Result = AAR_NOTFOUND; break; } - { - otp_account *a = &v.accounts[idx]; - char code[OTP_CODE_BUF]; - if (strcmp(a->type, "hotp") == 0) { - otp_render(a, 0, code); - a->counter++; /* stateful: persist */ - if (gui_save(&v, path) != VAULT_OK) req->aar_Result = AAR_SAVEFAIL; - } else { - otp_render(a, clock_now_utc(&clk), code); - } - if (rb && rbcap > strlen(code) + 1) { strcpy(rb, code); strcat(rb, "\n"); } - } + if (gui_do_get(&v, path, &clk, arg, rb, rbcap, &save_failed) < 0) + req->aar_Result = AAR_NOTFOUND; + else if (save_failed) + req->aar_Result = AAR_SAVEFAIL; break; } case AAP_QR: { @@ -2148,6 +2235,102 @@ int main(int argc, char **argv) } } + /* --- ARexx automation (#46): a genuine public RexxMsg port, separate + * from pubport above. See arexx.h for the two-port rationale, and + * docs/SECURITY.md for the RC scheme - the passphrase never crosses + * this port; UNLOCK reuses the same interactive prompt as the GUI. */ + if (rexxsig && (sigs & rexxsig)) { + void *rmsg; + arexx_parsed rp; + while ((rmsg = arexx_receive(rexxport, &rp)) != NULL) { + int rrc = AREXX_RC_OK; + char rbuf[64]; + const char *result = rbuf; + rbuf[0] = '\0'; + switch (rp.type) { + case AREXX_CMD_GETCODE: { + int idx, save_failed = 0; + char prefbuf[8]; + /* Locked and "gated off" share one RC on purpose: a + * script must not be able to probe *why* GETCODE + * failed, only that it did. */ + if (!v.unlocked) { rrc = AREXX_RC_FAIL; break; } + if (prefs_get("arexxgetcode", prefbuf, sizeof prefbuf) == 0 && + Stricmp((STRPTR)prefbuf, (STRPTR)"off") == 0) + { rrc = AREXX_RC_FAIL; break; } + idx = gui_find_account(&v, rp.account); + if (idx < 0) { rrc = AREXX_RC_ERROR; break; } + gui_do_get(&v, path, &clk, rp.account, rbuf, sizeof rbuf, &save_failed); + if (save_failed) rrc = AREXX_RC_FAIL; + break; + } + case AREXX_CMD_TIMELEFT: { + int idx; + if (!v.unlocked) { rrc = AREXX_RC_FAIL; break; } + idx = gui_find_account(&v, rp.account); + if (idx < 0) { rrc = AREXX_RC_ERROR; break; } + sprintf(rbuf, "%ld", + arexx_timeleft(strcmp(v.accounts[idx].type, "hotp") == 0, + clock_now_utc(&clk), 0, v.accounts[idx].period)); + break; + } + case AREXX_CMD_LIST: + if (!v.unlocked) { rrc = AREXX_RC_FAIL; break; } + gui_do_list(&v, g_rexx_listbuf, sizeof g_rexx_listbuf); + result = g_rexx_listbuf; + break; + case AREXX_CMD_STATUS: + sprintf(rbuf, "%s %lu", + !encrypted ? "always-unlocked" : v.unlocked ? "unlocked" : "locked", + (unsigned long)v.count); + break; + case AREXX_CMD_LOCK: + if (!encrypted) { strcpy(rbuf, "always-unlocked"); break; } + vault_lock(&v); + if (win) { win_hide(&gw, win); win = NULL; } + deferred = 1; + strcpy(rbuf, "locked"); + break; + case AREXX_CMD_UNLOCK: + if (!encrypted) strcpy(rbuf, "always-unlocked"); + else if (v.unlocked) strcpy(rbuf, "already-unlocked"); + else if (deferred_open(&v, vpath, sizeof vpath, &encrypted, + &lblist, gw.listobj, &naccounts)) { + deferred = 0; + strcpy(rbuf, "unlocked"); + } else { + rrc = AREXX_RC_WARN; + strcpy(rbuf, "cancelled"); + } + break; + case AREXX_CMD_SHOW: + /* Only meaningful as a registered commodity - mirrors the + * close-gadget's own broker check (#46 plan feedback). */ + if (!broker) { rrc = AREXX_RC_FAIL; break; } + if (!gui_do_show(&gw, &lblist, &v, have_clip, &clk, statbuf, + vpath, sizeof vpath, &encrypted, &naccounts, + &win, &deferred, &winsig)) + rrc = AREXX_RC_WARN; /* user cancelled the unlock prompt */ + break; + case AREXX_CMD_HIDE: + if (!broker) { rrc = AREXX_RC_FAIL; break; } + if (win) { win_hide(&gw, win); win = NULL; } + break; + case AREXX_CMD_QUIT: + /* rp.force (FORCE/S) is accepted but a documented no-op: + * every vault mutation already saves immediately, so + * there's no unsaved-changes confirm to suppress. */ + running = 0; + break; + case AREXX_CMD_UNKNOWN: + default: + rrc = AREXX_RC_ERROR; + break; + } + arexx_reply(rmsg, rrc, result); + } + } + if (have_timer && (sigs & timersig)) { WaitIO((struct IORequest *)g_treq); /* consume the request */ if (win && copied > 0 && --copied == 0) /* revert the "Copied" flash */ @@ -2443,6 +2626,7 @@ int main(int argc, char **argv) } cleanup: + arexx_close(rexxport); /* drains + replies AREXX_RC_FAIL, RemPort, Delete */ if (pubport) { /* stop new forwards, drain pending */ struct AmiAuthReq *req; RemPort(pubport); diff --git a/tests/copperline/arexx-probe.rexx b/tests/copperline/arexx-probe.rexx new file mode 100644 index 0000000..407b4c9 --- /dev/null +++ b/tests/copperline/arexx-probe.rexx @@ -0,0 +1,31 @@ +/* arexx-probe.rexx — drives AmiAuthGUI's ARexx port (#46) for the on-target + * smoke test (tests/gui/arexx-onhw.sh). Run via `rx` under a real resident + * RexxMast so RC/RESULT populate exactly as a real user's script would see. + * + * OPTIONS RESULTS is required — without it ARexx never sets RXFF_RESULT on + * the outgoing RexxMsg, so the host never gets asked for a RESULT string at + * all (confirmed empirically: rm_Action came back plain RXCOMM, no result + * bit, until this was added). Separately, ARexx 'drops' (uninitializes) + * RESULT when a host command doesn't supply one; referencing a dropped + * variable yields its own name ("RESULT") per REXX semantics. SYMBOL() + * below turns that into a clean empty string so every line has a + * predictable "RESULT=" shape. */ +OPTIONS RESULTS +ADDRESS AMIAUTH.1 +CALL PROBE 'STATUS', 'STATUS' +CALL PROBE 'LIST', 'LIST' +CALL PROBE 'GETCODE smoke', 'GETCODE' +CALL PROBE 'TIMELEFT smoke', 'TIMELEFT' +CALL PROBE 'GETCODE nosuchaccount', 'NOTFOUND' +CALL PROBE 'BOGUSCOMMAND', 'UNKNOWN' +CALL PROBE 'QUIT', 'QUIT' +EXIT + +PROBE: PROCEDURE + cmd = ARG(1) + tag = ARG(2) + cmd + if symbol('RESULT') = 'VAR' then r = RESULT + else r = '' + SAY tag' RC='RC' RESULT='r + RETURN diff --git a/tests/copperline/arexxtest.c b/tests/copperline/arexxtest.c new file mode 100644 index 0000000..ab012d0 --- /dev/null +++ b/tests/copperline/arexxtest.c @@ -0,0 +1,56 @@ +/* arexxtest.c — on-target (m68k/AmigaOS) relay for the ARexx port test (#46). + * + * The actual probe of AmiAuthGUI's AMIAUTH. port is a real ARexx script + * (arexx-probe.rexx) run by the resident RexxMast under `rx`, redirected to + * a RAM: file (see tests/gui/arexx-onhw.sh) — a genuine ARexx interpreter + * task is what's needed here, not a hand-rolled one: rexxsyslib.library's + * IsRexxMsg()/CHECKREXXMSG() validate rm_TaskBlock, which only a message + * built from within a live ARexx task's own context ever has populated + * (confirmed empirically — CreateRexxMsg() called from a plain external C + * program, even with every documented field set correctly, produces a + * message IsRexxMsg() rejects; this is by design, not a bug in arexx.c). + * + * Copperline's [ide] host-directory mount is an in-memory snapshot (guest + * writes never reach the host, see the copperline-testing skill), so the + * RAM: result file must be relayed out over serial *before* the emulator + * exits. This program does exactly that: read the file, emit its bytes via + * exec/RawPutChar (the same ROM debug path serialtest.c uses — no + * serial.device handler or Mount needed), wrapped in BEGIN/END markers. + */ + +#include +#include +#include + +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++); } + +int main(int argc, char **argv) +{ + const char *path = argc > 1 ? argv[1] : "RAM:arexx-result.txt"; + BPTR fh; + char buf[256]; + LONG n; + + raw_str("BEGIN\r\n"); + + fh = Open((STRPTR)path, MODE_OLDFILE); + if (!fh) { raw_str("NOFILE\r\n"); raw_str("END\r\n"); return 1; } + + while ((n = Read(fh, buf, sizeof buf)) > 0) { + LONG i; + for (i = 0; i < n; i++) raw_put(buf[i]); + } + Close(fh); + + raw_str("END\r\n"); + return 0; +} diff --git a/tests/gui/arexx-onhw.sh b/tests/gui/arexx-onhw.sh new file mode 100755 index 0000000..d79385e --- /dev/null +++ b/tests/gui/arexx-onhw.sh @@ -0,0 +1,130 @@ +#!/bin/sh +# arexx-onhw.sh — headless on-target test of AmiAuthGUI's ARexx port (#46). +# +# Boots Workbench 3.2 + ReAction under Copperline (A1200/AGA/Kickstart 3.2, +# same profile as gui-smoke.sh), launches AmiAuthGUI resident with a seeded +# always-unlocked vault, then — in the same boot — runs a real ARexx script +# (tests/copperline/arexx-probe.rexx) via the WB image's resident RexxMast +# (`rx`), redirected to a RAM: file. A genuine ARexx interpreter task is +# required here: rexxsyslib.library's IsRexxMsg() only validates messages +# whose rm_TaskBlock was populated by a live ARexx task's own context, which +# a hand-rolled RexxMsg from a plain external C program never has (confirmed +# empirically — see arexxtest.c's header comment) — so this exercises the +# real dispatch code in src/gui/main.c/src/amiga/arexx.c exactly as a real +# user's script would, via the actual interpreter, not a simulation of one. +# +# Since Copperline's [ide] host-directory mount is an in-memory snapshot +# (guest writes never reach the host), the RAM: result file is relayed back +# over serial by tests/copperline/arexxtest (a tiny file-to-RawPutChar +# relay) before the emulator exits. +# +# Non-mutating: the Workbench install is a copy-on-write clone; source is +# never touched. Paths come from tests/gui/.env (shared with gui-smoke.sh). +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) # tests/gui +ROOT=$(cd "$HERE/../.." && pwd) + +[ -f "$HERE/.env" ] && . "$HERE/.env" +COPPERLINE=${COPPERLINE:-copperline} +KICK=${KICK:-${AMIAUTH_ROM:-}} +WB=${WB:-${AMIAUTH_WB_HDD:-}} +GUI=${GUI:-$ROOT/build/AmiAuthGUI} +CLI=${CLI:-$ROOT/build/amiauth-host} +AREXXTEST=${AREXXTEST:-$ROOT/build/arexxtest} +PROBE=${PROBE:-$ROOT/tests/copperline/arexx-probe.rexx} +SECS=${SECS:-60} + +# INIT --open (no passphrase given) makes an always-unlocked test vault, +# same as gui-smoke.sh's own seeding. +EXPECT_STATUS='STATUS RC=0 RESULT=always-unlocked 1' + +OUTDIR=$ROOT/build/arexx-onhw-run +BOOT=$OUTDIR/boot +LOG=$OUTDIR/serial.log +VAULT=$OUTDIR/AmiAuth.vault + +fail() { echo "AREXX-ONHW FAIL: $1" >&2; exit 1; } + +# --- preflight --------------------------------------------------------------- +command -v "$COPPERLINE" >/dev/null 2>&1 || fail "copperline not found (set COPPERLINE=, or brew install copperline)" +[ -n "$KICK" ] && [ -e "$KICK" ] || fail "Kickstart 3.2 ROM missing (KICK= / AMIAUTH_ROM in tests/gui/.env): '$KICK'" +[ -n "$WB" ] && [ -d "$WB" ] || fail "Workbench 3.2 dir missing (WB= / AMIAUTH_WB_HDD): '$WB'" +[ -e "$WB/Libs/rexxsyslib.library" ] || fail "WB dir has no rexxsyslib.library: '$WB'" +[ -e "$WB/System/RexxMast" ] || fail "WB dir has no System/RexxMast (resident ARexx interpreter): '$WB'" +[ -x "$GUI" ] || fail "$GUI missing — build it first: make gui-docker" +[ -x "$CLI" ] || fail "$CLI missing — build it first: make cli" +[ -x "$AREXXTEST" ] || fail "$AREXXTEST missing — build it first: make arexx-onhw-docker" +[ -e "$PROBE" ] || fail "$PROBE missing" + +rm -rf "$OUTDIR"; mkdir -p "$OUTDIR" +cleanup() { rm -rf "$BOOT"; } +trap cleanup EXIT INT TERM + +# --- fresh always-unlocked test vault, one known account --------------------- +SECRET=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ +export AMIAUTH_PREFS_DIR="$OUTDIR/prefs" +"$CLI" -v "$VAULT" INIT --open >/dev/null || fail "vault INIT" +"$CLI" -v "$VAULT" ADD "otpauth://totp/GitHub:smoke?secret=$SECRET&digits=6&period=30" \ + >/dev/null || fail "vault ADD" + +# --- clone the WB (copy-on-write) and stage binaries + script + vault ------- +cp -Rc "$WB" "$BOOT" 2>/dev/null || { rm -rf "$BOOT"; cp -R "$WB" "$BOOT"; } +cp "$GUI" "$BOOT/AmiAuthGUI" +cp "$AREXXTEST" "$BOOT/arexxtest" +cp "$PROBE" "$BOOT/arexx-probe.rexx" +cp "$VAULT" "$BOOT/AmiAuth.vault" + +# Launch AmiAuthGUI resident (backgrounded) after LoadWB, give it a few +# seconds to open its window and the ARexx port, run the probe script via +# the resident RexxMast (rx), redirecting SAY output to a RAM: file, then +# relay that file out over serial. All synchronous within one Startup-Sequence. +SEQ="$BOOT/S/Startup-Sequence" +[ -f "$SEQ" ] || fail "clone missing S/Startup-Sequence" +awk '{ print } + /^LoadWB/ { print "SetEnv AmiAuth/vault SYS:Decoy-does-not-exist.vault" + print "Run >NIL: SYS:AmiAuthGUI VAULT=SYS:AmiAuth.vault" + print "Wait 3" + print "rx SYS:arexx-probe.rexx >RAM:arexx-result.txt" + print "SYS:arexxtest RAM:arexx-result.txt" }' "$SEQ" > "$SEQ.new" && mv "$SEQ.new" "$SEQ" +grep -q 'SYS:arexxtest' "$SEQ" || fail "could not patch Startup-Sequence (no LoadWB line?)" + +# --- config: A1200 / AGA / KS 3.2, boot from the clone ----------------------- +CFG="$OUTDIR/copperline.toml" +cat > "$CFG" < our stdout, exit at SECS ----------------------- +echo "AREXX-ONHW: booting A1200/OS 3.2 under Copperline, probing AmiAuthGUI's ARexx port..." +"$COPPERLINE" --config "$CFG" --noaudio --serial stdout --benchmark-until "$SECS" "$KICK" \ + >"$LOG" 2>&1 || { tail -20 "$LOG" >&2; fail "copperline exited non-zero"; } + +tr -d '\r' <"$LOG" >"$LOG.n" && mv "$LOG.n" "$LOG" # serial sends CRLF; drop CR +echo "----- serial capture -----"; cat "$LOG"; echo "--------------------------" + +grep -q '^BEGIN$' "$LOG" || fail "no BEGIN marker — arexxtest didn't run (raise SECS?)" +grep -q '^NOFILE$' "$LOG" && fail "RAM:arexx-result.txt missing — rx probe script didn't run/produce output" +grep -q '^END$' "$LOG" || fail "no END marker (raise SECS?)" + +fails=0 +assert_line() { + grep -qxF "$1" "$LOG" || { echo "FAIL: expected line '$1' not found" >&2; fails=$((fails + 1)); } +} +assert_line "$EXPECT_STATUS" +grep -q '^LIST RC=0 RESULT=GitHub:smoke$' "$LOG" || { echo "FAIL: LIST result wrong" >&2; fails=$((fails + 1)); } +grep -q '^GETCODE RC=0 RESULT=[0-9]\{6\}$' "$LOG" || { echo "FAIL: GETCODE didn't return a 6-digit code" >&2; fails=$((fails + 1)); } +grep -q '^TIMELEFT RC=0 RESULT=[0-9][0-9]\{0,1\}$' "$LOG" || { echo "FAIL: TIMELEFT result wrong" >&2; fails=$((fails + 1)); } +assert_line "NOTFOUND RC=10 RESULT=" +assert_line "UNKNOWN RC=10 RESULT=" +assert_line "QUIT RC=0 RESULT=" + +[ "$fails" -eq 0 ] || exit 1 +echo "AREXX-ONHW PASS: AMIAUTH.1 answered STATUS/LIST/GETCODE/TIMELEFT/QUIT correctly on real 68k (via a real rx script)." diff --git a/tests/test_arexx_cmd.c b/tests/test_arexx_cmd.c new file mode 100644 index 0000000..41500c9 --- /dev/null +++ b/tests/test_arexx_cmd.c @@ -0,0 +1,61 @@ +/* test_arexx_cmd.c — ARexx command-line parsing + RC policy (#46). */ +#include + +#include "test.h" +#include "arexx_cmd.h" + +void run_arexx_cmd_tests(void) +{ + arexx_parsed p; + + /* --- command keyword recognition, case-insensitive --- */ + TEST_CHECK(arexx_parse("STATUS", &p) == 0 && p.type == AREXX_CMD_STATUS); + TEST_CHECK(arexx_parse("status", &p) == 0 && p.type == AREXX_CMD_STATUS); + TEST_CHECK(arexx_parse("StAtUs", &p) == 0 && p.type == AREXX_CMD_STATUS); + TEST_CHECK(arexx_parse("LOCK", &p) == 0 && p.type == AREXX_CMD_LOCK); + TEST_CHECK(arexx_parse("UNLOCK", &p) == 0 && p.type == AREXX_CMD_UNLOCK); + TEST_CHECK(arexx_parse("SHOW", &p) == 0 && p.type == AREXX_CMD_SHOW); + TEST_CHECK(arexx_parse("HIDE", &p) == 0 && p.type == AREXX_CMD_HIDE); + + /* --- GETCODE/TIMELEFT: required ACCOUNT argument --- */ + TEST_CHECK(arexx_parse("GETCODE github", &p) == 0); + TEST_CHECK(p.type == AREXX_CMD_GETCODE && strcmp(p.account, "github") == 0); + TEST_CHECK(arexx_parse("TIMELEFT github", &p) == 0); + TEST_CHECK(p.type == AREXX_CMD_TIMELEFT && strcmp(p.account, "github") == 0); + + /* Quoted account name (spaces). */ + TEST_CHECK(arexx_parse("GETCODE \"My Account\"", &p) == 0); + TEST_CHECK(strcmp(p.account, "My Account") == 0); + + /* Missing required ACCOUNT is a parse error. */ + TEST_CHECK(arexx_parse("GETCODE", &p) == -1 && p.type == AREXX_CMD_UNKNOWN); + TEST_CHECK(arexx_parse("GETCODE ", &p) == -1 && p.type == AREXX_CMD_UNKNOWN); + TEST_CHECK(arexx_parse("TIMELEFT", &p) == -1); + + /* --- QUIT: optional FORCE/S --- */ + TEST_CHECK(arexx_parse("QUIT", &p) == 0 && p.type == AREXX_CMD_QUIT && p.force == 0); + TEST_CHECK(arexx_parse("QUIT FORCE", &p) == 0 && p.force == 1); + TEST_CHECK(arexx_parse("QUIT force", &p) == 0 && p.force == 1); /* case-insensitive */ + + /* --- unknown command --- */ + TEST_CHECK(arexx_parse("BOGUS", &p) == -1 && p.type == AREXX_CMD_UNKNOWN); + TEST_CHECK(arexx_parse("", &p) == -1); + TEST_CHECK(arexx_parse(" ", &p) == -1); + + /* --- argument guards --- */ + TEST_CHECK(arexx_parse(NULL, &p) == -1); + TEST_CHECK(arexx_parse("STATUS", NULL) == -1); + + /* --- leading/trailing whitespace tolerance --- */ + TEST_CHECK(arexx_parse(" STATUS ", &p) == 0 && p.type == AREXX_CMD_STATUS); + TEST_CHECK(arexx_parse(" GETCODE github ", &p) == 0 && + strcmp(p.account, "github") == 0); + + /* --- arexx_timeleft: TOTP delegates to totp_seconds_remaining, HOTP is + * always -1 regardless of period/time --- */ + TEST_CHECK(arexx_timeleft(0, 30, 0, 30) == 30); /* fresh period: full time left */ + TEST_CHECK(arexx_timeleft(0, 29, 0, 30) == 1); /* about to roll over */ + TEST_CHECK(arexx_timeleft(0, 15, 0, 30) == 15); + TEST_CHECK(arexx_timeleft(1, 15, 0, 30) == -1); /* HOTP: no time concept */ + TEST_CHECK(arexx_timeleft(1, 0, 0, 0) == -1); +} diff --git a/tests/test_main.c b/tests/test_main.c index 19eb709..76696ca 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -23,6 +23,7 @@ void run_clock_tests(void); void run_prefs_tests(void); void run_qr_tests(void); void run_qr_encode_tests(void); +void run_arexx_cmd_tests(void); int main(void) { @@ -46,6 +47,7 @@ int main(void) run_prefs_tests(); run_qr_tests(); run_qr_encode_tests(); + run_arexx_cmd_tests(); printf("\n%d passed, %d failed, %d pending\n", g_test.passed, g_test.failed, g_test.pending); diff --git a/tools/docs2guide.py b/tools/docs2guide.py index 8d1f1b4..ffd3088 100644 --- a/tools/docs2guide.py +++ b/tools/docs2guide.py @@ -31,6 +31,7 @@ ('CLI-Reference', 'CLI Reference'), ('GUI-Guide', 'GUI Guide'), ('Commodity-and-Tooltypes', 'Commodity and Tooltypes'), + ('ARexx-Port', 'ARexx Port'), ('Time-and-Clock-Sync', 'Time and Clock Sync'), ('Vault-and-Passphrases', 'Vault and Passphrases'), ('Settings-Reference', 'Settings Reference'), diff --git a/userdocs/ARexx-Port.md b/userdocs/ARexx-Port.md new file mode 100644 index 0000000..41d4808 --- /dev/null +++ b/userdocs/ARexx-Port.md @@ -0,0 +1,93 @@ +# ARexx Port + +`AmiAuthGUI`, while resident, opens a public ARexx port so any ARexx script +(or other program that speaks the RexxMsg protocol) can drive it — get a +code, check status, lock/unlock, show/hide, quit — the same way Exchange or +the hotkey do. This is separate from the CLI's own forwarding (see +[CLI Reference](CLI-Reference.md)); it's for scripts, not the `AmiAuth` +Shell command. + +**The passphrase never crosses this port.** `UNLOCK` is always interactive — +it opens the same GUI requester as the window/hotkey/commodity paths. A +script can ask AmiAuth to unlock, but it can't hand it a passphrase to do so +unattended. See [Security Model](Security-Model.md). + +## Port name + +The port is named `AMIAUTH.` (uppercase), where `` is the lowest free +slot — normally `AMIAUTH.1`. It's shown in the main window's title bar once +open, e.g. `AmiAuth 1.0 (abc1234) [AMIAUTH.1]`. Override it with the +`PORTNAME` tooltype/argument, the same way as `VAULT`/`PUBSCREEN` (see +[Commodity and Tooltypes](Commodity-and-Tooltypes.md)): + + Run >NIL: AmiAuthGUI PORTNAME=MYAUTH.1 + +If `rexxsyslib.library` isn't available, or every slot up to `AMIAUTH.99` is +already taken, AmiAuth simply runs without an ARexx port — not a fatal error. + +## Commands + +Address the port (`ADDRESS AMIAUTH.1`), then issue commands as quoted host +command strings — quoting keeps ARexx from evaluating a bare word like +`GETCODE GitHub` as a REXX expression (string concatenation, which +upper-cases and can mangle it) rather than sending it verbatim. Put +`OPTIONS RESULTS` near the top of the script: without it, ARexx never asks +the host for a `RESULT` string at all, so `RESULT` stays undefined even on +success. Every command still sets `RC` either way. + +`RC` follows the standard ARexx convention: **0** success, **5** the user +cancelled an interactive prompt, **10** a bad or missing argument, **20** the +command couldn't be carried out (locked, gated off, or not registered as a +commodity). + +| Command | Argument | RESULT | Notes | +|---------|----------|--------|-------| +| `GETCODE` | account name (required) | the current code | RC 20 if the vault is locked *or* if [`arexxgetcode`](Settings-Reference.md) is off — deliberately the same RC either way, so a script can't tell which. RC 10 if no account matches. | +| `TIMELEFT` | account name (required) | seconds remaining, or `-1` for HOTP (no time concept — not an error) | Same locked/gated/not-found RCs as `GETCODE`. | +| `LIST` | — | one `issuer:label` (or just `label`) per line | RC 20 if locked. | +| `STATUS` | — | `" "`, e.g. `unlocked 7`, `locked 0`, `always-unlocked 7` | Always answers, even locked — this is how a script decides whether to `UNLOCK` at all. | +| `LOCK` | — | `locked`, or `always-unlocked` | A no-op (RC 0) for an always-unlocked vault. | +| `UNLOCK` | — | `unlocked`, `already-unlocked`, `always-unlocked`, or `cancelled` | **Interactive** — opens the same passphrase requester as the GUI. RC 5 if the user cancels it. | +| `SHOW` | — | — | Only meaningful while running as a registered commodity (RC 20 otherwise) — mirrors the window's own close-gadget behaviour, which hides rather than quits only when a commodity broker is present. | +| `HIDE` | — | — | Same commodity-only restriction as `SHOW`. | +| `QUIT` | `FORCE` (optional switch) | — | Quits the resident instance. `FORCE` is accepted (the reserved ARexx convention) but is a documented no-op: every vault change already saves immediately, so there's never an unsaved-changes prompt to suppress. | + +Unknown commands, or a required argument left out (e.g. `GETCODE` with no +account), return RC 10. + +## The `arexxgetcode` setting + +`ENVARC:AmiAuth/arexxgetcode` (default: allowed; set to `off` to disable) +restricts the port to control commands — `STATUS`, `LOCK`, `UNLOCK`, `SHOW`, +`HIDE`, `QUIT` — and refuses `GETCODE`/`TIMELEFT` (RC 20, same as a locked +vault). See [Settings Reference](Settings-Reference.md). + + SetEnv SAVE AmiAuth/arexxgetcode off + +## Example + +``` +/* getcode.rexx — print a TOTP code via AmiAuth's ARexx port */ +OPTIONS RESULTS +ADDRESS AMIAUTH.1 +'GETCODE GitHub' +IF RC = 0 THEN SAY RESULT +ELSE SAY 'AmiAuth: could not get a code (RC' RC')' +``` + +``` +/* status.rexx — unlock AmiAuth if it's locked, then list accounts */ +OPTIONS RESULTS +ADDRESS AMIAUTH.1 +'STATUS' +PARSE VAR RESULT mode count +IF mode = 'locked' THEN DO + 'UNLOCK' + IF RC = 5 THEN EXIT /* user cancelled the prompt */ +END +'LIST' +SAY RESULT +``` + +Quoted account names with spaces work as you'd expect: +`'GETCODE "My Account"'`. diff --git a/userdocs/Commodity-and-Tooltypes.md b/userdocs/Commodity-and-Tooltypes.md index d4aa0c4..e6d8566 100644 --- a/userdocs/Commodity-and-Tooltypes.md +++ b/userdocs/Commodity-and-Tooltypes.md @@ -46,10 +46,11 @@ set. | `TIMESERVER` | *(saved `server` pref, else `pool.ntp.org`)* | SNTP server for the automatic time sync the GUI performs at startup. On success the measured offset (and this server) are saved; offline the sync fails quietly. See [Time and Clock Sync](Time-and-Clock-Sync.md). | | `PUBSCREEN` | *(the default public screen)* | Open on the named public screen instead — e.g. a custom screen another program opened. Falls back to the default public screen automatically if the named one isn't open when AmiAuth starts. | | `VAULT` | *(see [Vault and Passphrases](Vault-and-Passphrases.md))* | Use this vault file for this launch, e.g. `VAULT=Work:Secrets/Test.vault`. Same precedence as the CLI's `VAULT` keyword: it beats `AMIAUTH_VAULT` and the saved path, and — like those overrides — is never recorded as the sticky vault location. | +| `PORTNAME` | *(the lowest free `AMIAUTH.` slot)* | Override the [ARexx port](ARexx-Port.md)'s name, e.g. `PORTNAME=MYAUTH.1`. | | `DONOTWAIT` | — | Not read by AmiAuth itself: tells **Workbench** not to wait for the program to exit. Set it on any WBStartup icon. | -The `CX_*` names, and `TIMESERVER`/`PUBSCREEN`/`VAULT`, also work as Shell -arguments when starting the GUI from a script +The `CX_*` names, and `TIMESERVER`/`PUBSCREEN`/`VAULT`/`PORTNAME`, also work +as Shell arguments when starting the GUI from a script (`Run >NIL: AmiAuthGUI CX_POPUP=no`). ## A WBStartup icon that works diff --git a/userdocs/Security-Model.md b/userdocs/Security-Model.md index a16cf7c..dde8fc8 100644 --- a/userdocs/Security-Model.md +++ b/userdocs/Security-Model.md @@ -94,9 +94,12 @@ is re-encrypted or decrypted on disk). The vault passphrase is only ever entered **interactively** — at a Shell prompt (RAW mode, no echo) or in a GUI requester. There is deliberately no way to supply it on the command line, in an environment variable, in a script, or over -the commodity's message port. Scripted/headless use is served by always-unlocked -vaults instead. When the CLI forwards commands to a resident GUI, the GUI is -the one that holds the unlocked vault — the passphrase never crosses the port. +the commodity's message port or the [ARexx port](ARexx-Port.md). Scripted/headless +use is served by always-unlocked vaults instead. When the CLI forwards commands +to a resident GUI, the GUI is the one that holds the unlocked vault — the +passphrase never crosses the port. The ARexx `UNLOCK` command is likewise +always interactive: it opens the same GUI requester, never accepting a +passphrase as an argument. ## Randomness diff --git a/userdocs/Settings-Reference.md b/userdocs/Settings-Reference.md index 853ca3f..ea9f147 100644 --- a/userdocs/Settings-Reference.md +++ b/userdocs/Settings-Reference.md @@ -25,6 +25,7 @@ RAM-backed and would be lost on reboot. | `AmiAuth/rekey` | `off` | you, or "Never here"/`ne(v)er` in the re-key prompt | When set to `off`, suppresses the adaptive re-key offers on this machine entirely. Delete the variable to get them back. | | `AmiAuth/idlelock` | seconds | you | GUI idle auto-lock timeout for encrypted vaults. Default when unset: **120**. `0` disables auto-lock. Only an *open* window ticks the idle timer. | | `AmiAuth/cryptoasm` | `off` | you | AmiAuth uses hand-written 68000 assembly for its SHA-1 inner loop by default (safe on every supported CPU, including a plain 68000 — it's not an 020+ fast path). Set to `off` to force the portable C implementation instead, e.g. if you suspect the assembly on your particular setup. Delete the variable to get the assembly back. | +| `AmiAuth/arexxgetcode` | `off` | you | Restricts the [ARexx port](ARexx-Port.md) to control commands (`STATUS`/`LOCK`/`UNLOCK`/`SHOW`/`HIDE`/`QUIT`), refusing `GETCODE`/`TIMELEFT`, when set to `off`. Delete the variable to allow them again (the default). | Additionally the **`AMIAUTH_VAULT`** environment variable (a conventional variable read via `getenv`, so `ENV:AMIAUTH_VAULT` on AmigaOS) overrides the diff --git a/userdocs/Troubleshooting-and-FAQ.md b/userdocs/Troubleshooting-and-FAQ.md index 95f3deb..d96af1e 100644 --- a/userdocs/Troubleshooting-and-FAQ.md +++ b/userdocs/Troubleshooting-and-FAQ.md @@ -146,10 +146,11 @@ The data is unrecoverable — that is the point of the encryption. Re-enrol each account using the services' recovery codes or a second enrolled device. **Exporting QR codes?** -On the v2 candidate list, along with an ARexx port and translations. See the -[v2 milestone](https://github.com/sidick/amiauth/milestone/2). (SHA-256/512 -TOTP and Steam Guard are already supported — see -[Managing Accounts](Managing-Accounts.md).) +Supported — *Account → Show QR code…* in the GUI, or `AmiAuth QR ` +from the CLI. See [Managing Accounts](Managing-Accounts.md). (SHA-256/512 +TOTP and Steam Guard are supported too, and there's an [ARexx port](ARexx-Port.md) +for scripting.) Translations remain on the +[v1.1 milestone](https://github.com/sidick/amiauth/milestone/2). **Why SHA-1? Isn't that broken?** SHA-1's collision attacks do not affect HMAC-SHA1 as used by TOTP/HOTP (it