Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
37 changes: 24 additions & 13 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.<n>` 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

Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions src/amiga/arexx.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/* arexx.c -- see arexx.h. AmigaOS only (never built into the CLI or host). */
#include <exec/types.h>
#include <exec/nodes.h>
#include <exec/ports.h>
#include <proto/exec.h>
#include <proto/rexxsyslib.h>
#include <rexx/storage.h>
#include <rexx/rxslib.h>

#include <stdio.h>
#include <string.h>

#include "arexx.h"

/* Defined here (the one file that actually calls rexxsyslib functions);
* <proto/rexxsyslib.h>'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. */
}
53 changes: 53 additions & 0 deletions src/amiga/arexx.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/* arexx.h -- ARexx port for AmiAuth automation (#46), GUI-only.
*
* A genuine public RexxMsg port (AMIAUTH.<n>), 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 <exec/ports.h>

#include "arexx_cmd.h"

/* Open the port. `portname_override` (NULL/empty for the default) is the
* PORTNAME tooltype/arg; otherwise derives "AMIAUTH.<n>" (uppercase,
* lowest free slot, the standard <BASENAME>.<slot#> 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 */
107 changes: 107 additions & 0 deletions src/core/arexx_cmd.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/* arexx_cmd.c -- see arexx_cmd.h. */
#include <string.h>

#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);
}
Loading