From b363d21c124518909ed6b897b6dd29d624842e90 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 14 Oct 2026 15:19:11 +0800 Subject: [PATCH 1/6] build: map the Nix flake in MAINTAINERS --- MAINTAINERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MAINTAINERS b/MAINTAINERS index 456c204..475639c 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -90,6 +90,8 @@ F: tests/test_gateway_e2e.sh PACKAGING AND RELEASE S: Maintained +F: flake.lock +F: flake.nix F: packaging/** F: scripts/healthcheck.sh F: scripts/package_*.sh From 991d00f9a31abe477626178ee6f30c78032670aa Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 14 Oct 2026 15:19:33 +0800 Subject: [PATCH 2/6] docs: spell the Chinese system sender literally in tnt-module-protocol(7) --- tnt-module-protocol.7 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tnt-module-protocol.7 b/tnt-module-protocol.7 index 97ab7f1..b9b2307 100644 --- a/tnt-module-protocol.7 +++ b/tnt-module-protocol.7 @@ -159,7 +159,7 @@ This covers chat messages, actions (sender system notices (sender .B system or -.BR \(co\(fc\(cy ), +.BR 系统 ), and replies from other modules (sender .BR module:NAME ). A module never receives the messages it created or posted itself. From 7720dcfb65a44cfe30e0ea3f1f741901ff89ca14 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 14 Oct 2026 15:20:44 +0800 Subject: [PATCH 3/6] feat: let m1ngsama ID members sign in and reserve their handles Add :login and :logout. An identity:write module signs a session in through identity.requested, identity.notice, identity.verified, and identity.reserve records, so the core makes no network requests. Signed-in handles are reserved in $TNT_STATE_DIR/identities with the SSH key fingerprints bound to them. A bound key signs the session in on connect, exec post under a reserved name requires that key, and message.created metadata marks verified senders. The server now refuses the SSH none method so that clients offer their public key. Keyless clients get in through keyboard-interactive with no prompts; keyless clients running with BatchMode can no longer connect. --- .gitignore | 1 + include/chat_room.h | 11 +- include/command_catalog.h | 2 + include/commands.h | 9 + include/i18n.h | 9 + include/identity.h | 62 +++++ include/input.h | 5 + include/module_protocol.h | 26 +- include/module_runtime.h | 9 +- include/ssh_server.h | 12 +- src/bootstrap.c | 53 ++++- src/chat_room.c | 12 +- src/command_catalog.c | 16 ++ src/commands.c | 147 ++++++++---- src/exec.c | 19 +- src/i18n_text.c | 42 ++++ src/identity.c | 380 ++++++++++++++++++++++++++++++ src/input.c | 188 ++++++++++++++- src/module_protocol.c | 64 ++++- src/module_runtime.c | 172 +++++++++++--- tests/run_suites.sh | 3 +- tests/test_identity.sh | 334 ++++++++++++++++++++++++++ tests/test_module_privileged.sh | 2 +- tests/unit/Makefile | 17 +- tests/unit/test_identity.c | 233 ++++++++++++++++++ tests/unit/test_module_protocol.c | 76 +++++- tests/unit/test_module_runtime.c | 14 +- tnt-chat.7 | 54 ++++- tnt-exec.7 | 14 +- tnt-module-protocol.7 | 119 +++++++++- tnt.8 | 37 ++- 31 files changed, 2016 insertions(+), 126 deletions(-) create mode 100644 include/identity.h create mode 100644 src/identity.c create mode 100755 tests/test_identity.sh create mode 100644 tests/unit/test_identity.c diff --git a/.gitignore b/.gitignore index 50a70a8..9234561 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ tests/unit/test_richtext tests/unit/test_editor tests/unit/test_keymap tests/unit/test_message_log +tests/unit/test_identity perf-results/ __pycache__/ *.pyc diff --git a/include/chat_room.h b/include/chat_room.h index 79c12e4..9ba947b 100644 --- a/include/chat_room.h +++ b/include/chat_room.h @@ -12,6 +12,9 @@ struct client; * lifetime, so it must never block or call back into chat_room APIs. */ typedef void (*room_client_notifier_fn)(struct client *client); typedef const char *(*room_client_name_fn)(const struct client *client); +/* True when the client holds its name as a signed-in handle, which several + * sessions may share. */ +typedef bool (*room_client_owns_name_fn)(const struct client *client); /* Runs with the room lock held, so it must not call back into chat_room. */ typedef void (*room_presence_fn)(const char *nickname, bool joined); @@ -34,6 +37,7 @@ typedef struct { uint64_t update_seq; room_client_notifier_fn client_notifier; room_client_name_fn client_name; + room_client_owns_name_fn client_owns_name; room_presence_fn presence_notifier; } chat_room_t; @@ -47,7 +51,8 @@ chat_room_t* room_create(void); void room_destroy(chat_room_t *room); /* Add client to room atomically. Returns 0 on success, -1 when full, and -2 - * when another connected client already owns the same display name. */ + * when another connected client already owns the same display name, unless + * the client owns its name. */ int room_add_client(chat_room_t *room, struct client *client); /* Remove client from room */ @@ -65,6 +70,10 @@ void room_set_client_notifier(chat_room_t *room, void room_set_client_name_accessor(chat_room_t *room, room_client_name_fn accessor); +/* Install the accessor that exempts a client from the duplicate-name check. */ +void room_set_client_name_owner(chat_room_t *room, + room_client_owns_name_fn accessor); + /* Install the presence hook called when a client joins or leaves. */ void room_set_presence_notifier(chat_room_t *room, room_presence_fn notifier); diff --git a/include/command_catalog.h b/include/command_catalog.h index 3ccca48..54690da 100644 --- a/include/command_catalog.h +++ b/include/command_catalog.h @@ -11,6 +11,8 @@ typedef enum { TNT_COMMAND_REPLY, TNT_COMMAND_INBOX, TNT_COMMAND_NICK, + TNT_COMMAND_LOGIN, + TNT_COMMAND_LOGOUT, TNT_COMMAND_LAST, TNT_COMMAND_SEARCH, TNT_COMMAND_MUTE_JOINS, diff --git a/include/commands.h b/include/commands.h index 8d60eca..2851644 100644 --- a/include/commands.h +++ b/include/commands.h @@ -19,6 +19,15 @@ * path; callers must not hold client->io_lock before dispatching. */ void commands_dispatch(client_t *client); +/* Rename the session and announce it to the room. A verified rename takes a + * signed-in handle and skips the duplicate-name check; an unverified one + * clears the session's verified state. Returns 0 when renamed, 1 when the + * name is unchanged, and -1 when another session holds the name. */ +int commands_rename(client_t *client, const char *new_name, bool verified); + +/* Rename to "anonymous", or "anonymousN" when that is taken. */ +void commands_become_anonymous(client_t *client); + /* Rebuild the currently visible command output when it is backed by live * client state, such as :inbox. Returns true if output changed and the caller * should render it again. */ diff --git a/include/i18n.h b/include/i18n.h index ff1d578..ee5a41c 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -60,6 +60,14 @@ typedef enum { I18N_NICK_TAKEN_FORMAT, I18N_NICK_UNCHANGED, I18N_NICK_CHANGED_FORMAT, + I18N_NICK_RESERVED_FORMAT, + I18N_NICK_SIGNED_IN_FORMAT, + I18N_LOGIN_UNAVAILABLE, + I18N_LOGIN_OPENING, + I18N_LOGIN_ALREADY_FORMAT, + I18N_SIGNED_IN_FORMAT, + I18N_LOGOUT_NOT_SIGNED_IN, + I18N_LOGOUT_DONE_FORMAT, I18N_LAST_HEADER_FORMAT, I18N_LAST_EMPTY, I18N_SEARCH_HEADER_FORMAT, @@ -82,6 +90,7 @@ typedef enum { I18N_EXEC_POST_ASCII_CONTROL, I18N_EXEC_POST_TOO_LONG, I18N_EXEC_POST_PERSIST_FAILED, + I18N_EXEC_POST_RESERVED_FORMAT, I18N_EXEC_COMMAND_TOO_LONG, I18N_EXEC_UNKNOWN_COMMAND_FORMAT, I18N_TEXT_COUNT diff --git a/include/identity.h b/include/identity.h new file mode 100644 index 0000000..bf0fdf5 --- /dev/null +++ b/include/identity.h @@ -0,0 +1,62 @@ +#ifndef IDENTITY_H +#define IDENTITY_H + +#include "common.h" +#include + +#define TNT_IDENTITY_FILE "identities" +#define TNT_IDENTITY_HANDLE_MAX 20 +/* "SHA256:", 43 unpadded base64 characters, and the NUL byte. */ +#define TNT_IDENTITY_FINGERPRINT_SIZE 51 +#define TNT_IDENTITY_MAX_KEYS 16 + +typedef struct { + char handle[TNT_IDENTITY_HANDLE_MAX + 1]; + int key_count; + char keys[TNT_IDENTITY_MAX_KEYS][TNT_IDENTITY_FINGERPRINT_SIZE]; +} identity_entry_t; + +/* The identities file: one handle per line followed by zero or more SSH key + * fingerprints, all separated by spaces. */ +typedef struct { + identity_entry_t *entries; + size_t count; +} identity_table_t; + +bool identity_handle_valid(const char *handle); +bool identity_fingerprint_valid(const char *fingerprint); + +/* Format `key` the way `ssh-keygen -l` does: "SHA256:" and unpadded base64. */ +int identity_fingerprint_from_key(ssh_key key, char *out, size_t out_size); + +/* A missing file loads as an empty table. Invalid lines and fingerprints are + * skipped. */ +int identity_table_load(identity_table_t *table, const char *path); + +/* Replace `path` atomically with a mode 0600 file. */ +int identity_table_save(const identity_table_t *table, const char *path); + +void identity_table_free(identity_table_t *table); + +/* Match `name` against handles without regard to ASCII letter case. */ +const identity_entry_t *identity_table_find(const identity_table_t *table, + const char *name); +const char *identity_table_handle_for_key(const identity_table_t *table, + const char *fingerprint); + +/* Add `handle` if absent. A non-NULL `fingerprint` moves to this handle from + * any other one; past TNT_IDENTITY_MAX_KEYS the oldest key is dropped. */ +int identity_table_add(identity_table_t *table, const char *handle, + const char *fingerprint); +bool identity_table_forget_key(identity_table_t *table, + const char *fingerprint); + +/* The process-wide identities file in the state directory. Each call + * reloads the file when it changed on disk. */ +bool identity_is_reserved(const char *name); +bool identity_handle_for_key(const char *fingerprint, char *handle, + size_t handle_size); +int identity_reserve(const char *handle, const char *fingerprint); +int identity_forget_key(const char *fingerprint); + +#endif /* IDENTITY_H */ diff --git a/include/input.h b/include/input.h index 4ee8e6c..19fc986 100644 --- a/include/input.h +++ b/include/input.h @@ -28,4 +28,9 @@ void input_run_session(client_t *client); * inside input_run_session and by exec_command_post. */ void notify_mentions(const char *content, const client_t *sender); +/* Module-facing identity hooks, called from the identity module's worker. + * Each returns false when no interactive session has that id. */ +bool input_identity_notice(const char *session, const char *plain_text); +bool input_identity_verified(const char *session, const char *handle); + #endif /* INPUT_H */ diff --git a/include/module_protocol.h b/include/module_protocol.h index ca8e181..a5e9442 100644 --- a/include/module_protocol.h +++ b/include/module_protocol.h @@ -1,6 +1,7 @@ #ifndef MODULE_PROTOCOL_H #define MODULE_PROTOCOL_H +#include "identity.h" #include "message.h" #define TNT_MODULE_PROTOCOL_VERSION "tnt.module.v1" @@ -10,6 +11,9 @@ #define TNT_MODULE_EVENT_PRESENCE_JOINED "presence.joined" #define TNT_MODULE_EVENT_PRESENCE_LEFT "presence.left" #define TNT_MODULE_EVENT_PRESENCE_SNAPSHOT "presence.snapshot" +#define TNT_MODULE_EVENT_IDENTITY_REQUESTED "identity.requested" +#define TNT_MODULE_RECORD_IDENTITY_PREFIX "identity." +#define TNT_MODULE_SESSION_ID_SIZE 32 typedef struct { char plain_text[MAX_MESSAGE_LEN]; @@ -20,12 +24,25 @@ typedef struct { char plain_text[MAX_MESSAGE_LEN]; } tnt_module_message_post_t; +typedef enum { + TNT_MODULE_IDENTITY_NOTICE, + TNT_MODULE_IDENTITY_VERIFIED, + TNT_MODULE_IDENTITY_RESERVE +} tnt_module_identity_kind_t; + +typedef struct { + tnt_module_identity_kind_t kind; + char session[TNT_MODULE_SESSION_ID_SIZE]; + char handle[TNT_IDENTITY_HANDLE_MAX + 1]; + char plain_text[MAX_MESSAGE_LEN]; +} tnt_module_identity_record_t; + int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos, const char *server_version); int tnt_module_append_message_created(char *buffer, size_t buf_size, size_t *pos, const char *message_id, - const message_t *msg); + const message_t *msg, bool verified); int tnt_module_append_presence(char *buffer, size_t buf_size, size_t *pos, const char *type, const char *nickname, @@ -36,10 +53,17 @@ int tnt_module_append_presence_snapshot(char *buffer, size_t buf_size, const char *const *nicknames, size_t count); +int tnt_module_append_identity_requested(char *buffer, size_t buf_size, + size_t *pos, const char *session, + const char *nickname); + bool tnt_module_parse_message_create(const char *line, tnt_module_message_create_t *out); bool tnt_module_parse_message_post(const char *line, tnt_module_message_post_t *out); +bool tnt_module_parse_identity_record(const char *line, + tnt_module_identity_record_t *out); + #endif /* MODULE_PROTOCOL_H */ diff --git a/include/module_runtime.h b/include/module_runtime.h index 2582caa..0ee62ec 100644 --- a/include/module_runtime.h +++ b/include/module_runtime.h @@ -17,6 +17,7 @@ typedef struct { bool can_create_messages; bool can_post_messages; bool can_read_presence; + bool can_write_identity; } tnt_module_manifest_t; int tnt_module_manifest_load(const char *module_dir, @@ -27,12 +28,18 @@ void tnt_module_runtime_shutdown(void); /* Queue a user/core-created public message for enabled modules. This is * intentionally fire-and-forget so basic chat never depends on module health. */ -void tnt_module_runtime_publish_message_created(const message_t *msg); +void tnt_module_runtime_publish_message_created(const message_t *msg, + bool verified); /* Track an interactive user joining or leaving and notify presence:read * modules. Safe to call with the room lock held. */ void tnt_module_runtime_publish_presence(const char *nickname, bool joined); +/* Ask the identity:write module to sign in an interactive session. Returns + * false when no such module can take the request. */ +bool tnt_module_runtime_request_identity(const char *session, + const char *nickname); + #ifdef TNT_TESTING /* Pipe-level test seams for deterministic backpressure/deadline coverage. */ int tnt_module_runtime_test_write_fd(int fd, const char *data, size_t len, diff --git a/include/ssh_server.h b/include/ssh_server.h index ec49e0e..8551dfd 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -2,6 +2,7 @@ #define SSH_SERVER_H #include "common.h" +#include "identity.h" #include "keymap.h" #include "chat_room.h" #include @@ -59,7 +60,10 @@ typedef struct client { bool show_motd; /* command_output holds MOTD text */ char exec_command[MAX_EXEC_COMMAND_LEN]; bool exec_command_too_long; + bool verified; /* Name is a signed-in handle; room lock */ char ssh_login[MAX_USERNAME_LEN]; + char session_id[32]; /* Opaque id an identity module sees */ + char key_fingerprint[TNT_IDENTITY_FINGERPRINT_SIZE]; /* Signed SSH key */ time_t connect_time; time_t last_active; atomic_bool redraw_pending; @@ -80,13 +84,19 @@ typedef struct client { whisper_t whisper_inbox[WHISPER_INBOX_SIZE]; int whisper_inbox_count; bool mute_joins; + /* Identity mailbox filled by the module worker under whisper_lock and + * applied by the session's own thread, which alone renames it. */ + char identity_notice[MAX_COMMAND_OUTPUT_LEN / 2]; + char identity_handle[TNT_IDENTITY_HANDLE_MAX + 1]; + bool identity_evict; + atomic_bool identity_pending; pthread_t thread; atomic_bool connected; atomic_bool channel_closed; /* Peer CHANNEL_CLOSE callback observed */ int ref_count; /* Reference count for safe cleanup */ pthread_mutex_t ref_lock; /* Lock for ref_count */ pthread_mutex_t io_lock; /* Serialize SSH channel writes */ - pthread_mutex_t whisper_lock; /* Serialize whisper inbox access */ + pthread_mutex_t whisper_lock; /* Serialize whisper inbox and identity mailbox */ atomic_bool wake_ready; /* Session thread accepts directed wake signals */ atomic_bool wake_pending; /* State check closes unblock/poll wake races */ /* Server-worker lifetime hook. Called after channel teardown and diff --git a/src/bootstrap.c b/src/bootstrap.c index a7486cc..6e56347 100644 --- a/src/bootstrap.c +++ b/src/bootstrap.c @@ -1,6 +1,7 @@ #include "bootstrap.h" #include "client.h" #include "common.h" +#include "identity.h" #include "input.h" #include "ratelimit.h" #include "theme.h" @@ -25,6 +26,7 @@ typedef struct { char client_ip[INET6_ADDRSTRLEN]; char requested_user[MAX_USERNAME_LEN]; + char key_fingerprint[TNT_IDENTITY_FINGERPRINT_SIZE]; int pty_width; int pty_height; char exec_command[MAX_EXEC_COMMAND_LEN]; @@ -238,15 +240,36 @@ static int auth_none(ssh_session session, const char *user, void *userdata) { ctx->requested_user[sizeof(ctx->requested_user) - 1] = '\0'; } - /* If access token is configured, reject passwordless */ - if (g_access_token[0] != '\0') { - return SSH_AUTH_DENIED; - } else { - /* No token configured, allow passwordless */ - ctx->auth_success = true; - ratelimit_record_auth_success(ctx->client_ip); - return SSH_AUTH_SUCCESS; + /* Accepting "none" would end authentication before the client offers + * its public key, and a signed key is what an identity is bound to. + * Clients without a key fall through to keyboard-interactive, which + * asks nothing. */ + return SSH_AUTH_DENIED; +} + +/* Keyboard-interactive with zero prompts keeps keyless access passwordless. */ +static int auth_message(ssh_session session, ssh_message msg, void *userdata) { + session_context_t *ctx = (session_context_t *)userdata; + (void)session; + + if (g_access_token[0] != '\0' || + ssh_message_type(msg) != SSH_REQUEST_AUTH || + ssh_message_subtype(msg) != SSH_AUTH_METHOD_INTERACTIVE) { + return 1; + } + + if (ssh_message_auth_user(msg) && ssh_message_auth_user(msg)[0] != '\0') { + snprintf(ctx->requested_user, sizeof(ctx->requested_user), "%s", + ssh_message_auth_user(msg)); } + if (!ssh_message_auth_kbdint_is_response(msg)) { + return ssh_message_auth_interactive_request(msg, "", "", 0, NULL, + NULL) == SSH_OK ? 0 : 1; + } + + ctx->auth_success = true; + ratelimit_record_auth_success(ctx->client_ip); + return ssh_message_auth_reply_success(msg, 0) == SSH_OK ? 0 : 1; } /* Public key authentication callback */ @@ -254,7 +277,6 @@ static int auth_pubkey(ssh_session session, const char *user, struct ssh_key_struct *pubkey, char signature_state, void *userdata) { (void)session; - (void)pubkey; session_context_t *ctx = (session_context_t *)userdata; if (user && user[0] != '\0') { @@ -274,6 +296,10 @@ static int auth_pubkey(ssh_session session, const char *user, return SSH_AUTH_SUCCESS; } + if (identity_fingerprint_from_key(pubkey, ctx->key_fingerprint, + sizeof(ctx->key_fingerprint)) < 0) { + ctx->key_fingerprint[0] = '\0'; + } ctx->auth_success = true; ratelimit_record_auth_success(ctx->client_ip); return SSH_AUTH_SUCCESS; @@ -521,6 +547,12 @@ void *bootstrap_run(void *arg) { server_cb.auth_pubkey_function = auth_pubkey; server_cb.channel_open_request_session_function = channel_open_request_session; ssh_set_server_callbacks(session, &server_cb); + ssh_set_message_callback(session, auth_message, ctx); + if (g_access_token[0] == '\0') { + ssh_set_auth_methods(session, SSH_AUTH_METHOD_PUBLICKEY | + SSH_AUTH_METHOD_INTERACTIVE | + SSH_AUTH_METHOD_PASSWORD); + } setup_deadline_ms = bootstrap_monotonic_millis() + BOOTSTRAP_SETUP_TIMEOUT_MS; @@ -604,6 +636,7 @@ void *bootstrap_run(void *arg) { atomic_init(&client->wake_ready, false); atomic_init(&client->wake_pending, false); atomic_init(&client->channel_closed, false); + atomic_init(&client->identity_pending, false); client->session = session; client->channel = channel; @@ -638,6 +671,8 @@ void *bootstrap_run(void *arg) { snprintf(client->client_ip, sizeof(client->client_ip), "%s", ctx->client_ip); } + snprintf(client->key_fingerprint, sizeof(client->key_fingerprint), "%s", + ctx->key_fingerprint); if (ctx->exec_command[0] != '\0') { strncpy(client->exec_command, ctx->exec_command, sizeof(client->exec_command) - 1); diff --git a/src/chat_room.c b/src/chat_room.c index 9b69db8..c6a207e 100644 --- a/src/chat_room.c +++ b/src/chat_room.c @@ -68,7 +68,8 @@ int room_add_client(chat_room_t *room, struct client *client) { return -1; } - if (room->client_name) { + if (room->client_name && + !(room->client_owns_name && room->client_owns_name(client))) { const char *candidate_name = room->client_name(client); for (int i = 0; i < room->client_count; i++) { const char *existing_name = room->client_name(room->clients[i]); @@ -167,6 +168,15 @@ void room_set_client_name_accessor(chat_room_t *room, pthread_rwlock_unlock(&room->lock); } +void room_set_client_name_owner(chat_room_t *room, + room_client_owns_name_fn accessor) { + if (!room) return; + + pthread_rwlock_wrlock(&room->lock); + room->client_owns_name = accessor; + pthread_rwlock_unlock(&room->lock); +} + void room_set_presence_notifier(chat_room_t *room, room_presence_fn notifier) { if (!room) return; diff --git a/src/command_catalog.c b/src/command_catalog.c index f5338c9..74561b1 100644 --- a/src/command_catalog.c +++ b/src/command_catalog.c @@ -66,6 +66,22 @@ static const command_catalog_entry_t entries[] = { I18N_STRING("Usage: nick \n", "用法: nick \n"), 2, false, true }, + { + {TNT_COMMAND_LOGIN, "login", {"login", NULL}}, + I18N_STRING(":login", ":login"), + I18N_STRING("Sign in with m1ngsama ID", "用 m1ngsama ID 登录"), + I18N_STRING(":login", ":login"), + I18N_STRING("Usage: login\n", "用法: login\n"), + 2, true, false + }, + { + {TNT_COMMAND_LOGOUT, "logout", {"logout", NULL}}, + I18N_STRING(":logout", ":logout"), + I18N_STRING("Sign out and become anonymous", "退出登录,改回匿名"), + I18N_STRING(":logout", ":logout"), + I18N_STRING("Usage: logout\n", "用法: logout\n"), + 2, true, false + }, { {TNT_COMMAND_LAST, "last", {"last", NULL}}, I18N_STRING(":last [N]", ":last [N]"), diff --git a/src/commands.c b/src/commands.c index 04c357c..68d3693 100644 --- a/src/commands.c +++ b/src/commands.c @@ -10,6 +10,7 @@ #include "command_catalog.h" #include "common.h" #include "i18n.h" +#include "identity.h" #include "keymap.h" #include "manual.h" #include "message.h" @@ -231,6 +232,57 @@ static void clear_inbox(client_t *client) { pthread_mutex_unlock(&client->whisper_lock); } +int commands_rename(client_t *client, const char *new_name, bool verified) { + char old_name[MAX_USERNAME_LEN]; + bool taken = false; + bool renamed; + + pthread_rwlock_wrlock(&g_room->lock); + snprintf(old_name, sizeof(old_name), "%s", client->username); + renamed = strcmp(new_name, old_name) != 0; + if (renamed && !verified) { + for (int i = 0; i < g_room->client_count; i++) { + if (g_room->clients[i] != client && + strcasecmp(g_room->clients[i]->username, new_name) == 0) { + taken = true; + break; + } + } + } + if (!taken) { + client->verified = verified; + snprintf(client->username, MAX_USERNAME_LEN, "%s", new_name); + if (g_room->presence_notifier && renamed) { + g_room->presence_notifier(old_name, false); + g_room->presence_notifier(client->username, true); + } + } + pthread_rwlock_unlock(&g_room->lock); + + if (taken) { + return -1; + } + if (!renamed) { + return 1; + } + + message_t nick_msg; + system_message_make_nick(&nick_msg, old_name, client->username, + client->ui_lang); + room_broadcast(g_room, &nick_msg); + message_save(&nick_msg); + tnt_module_runtime_publish_message_created(&nick_msg, false); + return 0; +} + +void commands_become_anonymous(client_t *client) { + char name[MAX_USERNAME_LEN] = "anonymous"; + + for (int n = 2; commands_rename(client, name, false) < 0 && n < 1000; n++) { + snprintf(name, sizeof(name), "anonymous%d", n); + } +} + bool commands_refresh_active_output(client_t *client) { char output[MAX_COMMAND_OUTPUT_LEN] = {0}; size_t pos = 0; @@ -440,6 +492,11 @@ void commands_dispatch(client_t *client) { if (new_name[0] == '\0') { append_command_usage(output, sizeof(output), &pos, TNT_COMMAND_NICK, client->ui_lang); + } else if (client->verified) { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, + I18N_NICK_SIGNED_IN_FORMAT), + client->username); } else if (!is_valid_username(new_name)) { buffer_appendf(output, sizeof(output), &pos, "%s", i18n_text(client->ui_lang, I18N_NICK_INVALID)); @@ -450,55 +507,65 @@ void commands_dispatch(client_t *client) { utf8_truncate(validated_name, 20); } - /* Reject collisions with active room members. Held under - * wrlock so the username swap below races neither read nor - * concurrent :nick from another client. */ char old_name[MAX_USERNAME_LEN]; - bool taken = false; - pthread_rwlock_wrlock(&g_room->lock); snprintf(old_name, sizeof(old_name), "%s", client->username); - if (strcmp(validated_name, old_name) != 0) { - for (int i = 0; i < g_room->client_count; i++) { - if (g_room->clients[i] == client) continue; - if (strcasecmp(g_room->clients[i]->username, - validated_name) == 0) { - taken = true; - break; - } - } - } - if (!taken) { - snprintf(client->username, MAX_USERNAME_LEN, "%s", validated_name); - if (g_room->presence_notifier && - strcmp(client->username, old_name) != 0) { - g_room->presence_notifier(old_name, false); - g_room->presence_notifier(client->username, true); - } - } - pthread_rwlock_unlock(&g_room->lock); - if (taken) { + if (identity_is_reserved(validated_name)) { buffer_appendf(output, sizeof(output), &pos, i18n_text(client->ui_lang, - I18N_NICK_TAKEN_FORMAT), + I18N_NICK_RESERVED_FORMAT), validated_name); - } else if (strcmp(validated_name, old_name) == 0) { - buffer_appendf(output, sizeof(output), &pos, "%s", - i18n_text(client->ui_lang, - I18N_NICK_UNCHANGED)); } else { - message_t nick_msg; - system_message_make_nick(&nick_msg, old_name, - client->username, client->ui_lang); - room_broadcast(g_room, &nick_msg); - message_save(&nick_msg); - tnt_module_runtime_publish_message_created(&nick_msg); + int rc = commands_rename(client, validated_name, false); + + if (rc < 0) { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, + I18N_NICK_TAKEN_FORMAT), + validated_name); + } else if (rc > 0) { + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, + I18N_NICK_UNCHANGED)); + } else { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, + I18N_NICK_CHANGED_FORMAT), + old_name, client->username); + } + } + } - buffer_appendf(output, sizeof(output), &pos, - i18n_text(client->ui_lang, - I18N_NICK_CHANGED_FORMAT), - old_name, client->username); + } else if (command_id == TNT_COMMAND_LOGIN) { + if (client->verified) { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, + I18N_LOGIN_ALREADY_FORMAT), + client->username); + } else if (tnt_module_runtime_request_identity(client->session_id, + client->username)) { + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, I18N_LOGIN_OPENING)); + } else { + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, + I18N_LOGIN_UNAVAILABLE)); + } + + } else if (command_id == TNT_COMMAND_LOGOUT) { + if (!client->verified) { + buffer_appendf(output, sizeof(output), &pos, "%s", + i18n_text(client->ui_lang, + I18N_LOGOUT_NOT_SIGNED_IN)); + } else { + if (client->key_fingerprint[0] != '\0') { + (void)identity_forget_key(client->key_fingerprint); } + commands_become_anonymous(client); + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, + I18N_LOGOUT_DONE_FORMAT), + client->username); } } else if (command_id == TNT_COMMAND_LAST) { diff --git a/src/exec.c b/src/exec.c index 763b3b0..6bac005 100644 --- a/src/exec.c +++ b/src/exec.c @@ -4,6 +4,7 @@ #include "common.h" #include "exec_catalog.h" #include "i18n.h" +#include "identity.h" #include "input.h" #include "json_text.h" #include "message.h" @@ -14,6 +15,7 @@ #include #include #include +#include #include #define TNT_DUMP_DEFAULT_RECORDS 100 @@ -369,6 +371,8 @@ static int exec_command_dump(client_t *client, const char *args) { static int exec_command_post(client_t *client, const char *args) { char content[MAX_MESSAGE_LEN]; char username[MAX_USERNAME_LEN]; + char handle[TNT_IDENTITY_HANDLE_MAX + 1]; + bool verified = false; message_t msg = { .timestamp = time(NULL), }; @@ -408,6 +412,19 @@ static int exec_command_post(client_t *client, const char *args) { } resolve_exec_username(client, username, sizeof(username)); + if (identity_is_reserved(username)) { + if (!identity_handle_for_key(client->key_fingerprint, handle, + sizeof(handle)) || + strcasecmp(handle, username) != 0) { + client_printf(client, + i18n_text(client->ui_lang, + I18N_EXEC_POST_RESERVED_FORMAT), + username); + return TNT_EXIT_ERROR; + } + snprintf(username, sizeof(username), "%s", handle); + verified = true; + } if (strncmp(content, "/me ", 4) == 0 && content[4] != '\0') { size_t username_len = strlen(username); @@ -442,7 +459,7 @@ static int exec_command_post(client_t *client, const char *args) { room_broadcast(g_room, &msg); notify_mentions(msg.content, client); - tnt_module_runtime_publish_message_created(&msg); + tnt_module_runtime_publish_message_created(&msg, verified); if (client_send(client, "posted\n", 7) != 0) { return TNT_EXIT_ERROR; diff --git a/src/i18n_text.c b/src/i18n_text.c index 74ef4b7..9217f5e 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -181,6 +181,42 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "Nickname changed: %s -> %s\n", "昵称已修改: %s -> %s\n" ), + [I18N_NICK_RESERVED_FORMAT] = I18N_STRING( + "@%s belongs to a signed-in member.\n" + "Use :login if it is you, or pick another name.\n", + "@%s 属于一位已登录的成员。\n" + "如果是你本人,请用 :login 登录,否则请换一个名字。\n" + ), + [I18N_NICK_SIGNED_IN_FORMAT] = I18N_STRING( + "You are signed in as @%s.\n" + "Use :logout before changing your name.\n", + "你已登录为 @%s。\n" + "要改名,请先用 :logout 退出登录。\n" + ), + [I18N_LOGIN_UNAVAILABLE] = I18N_STRING( + "Sign-in is not available on this server.\n", + "这台服务器没有开放登录。\n" + ), + [I18N_LOGIN_OPENING] = I18N_STRING( + "Opening sign-in…\n", + "正在打开登录…\n" + ), + [I18N_LOGIN_ALREADY_FORMAT] = I18N_STRING( + "You are already signed in as @%s.\n", + "你已经登录为 @%s。\n" + ), + [I18N_SIGNED_IN_FORMAT] = I18N_STRING( + "Signed in as @%s.\n", + "已登录为 @%s。\n" + ), + [I18N_LOGOUT_NOT_SIGNED_IN] = I18N_STRING( + "You are not signed in.\n", + "你还没有登录。\n" + ), + [I18N_LOGOUT_DONE_FORMAT] = I18N_STRING( + "Signed out. Your name is now %s.\n", + "已退出登录,你现在的名字是 %s。\n" + ), [I18N_LAST_HEADER_FORMAT] = I18N_STRING( "--- Last %d message(s) ---\n", "--- 最近 %d 条消息 ---\n" @@ -279,6 +315,12 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "post: failed to persist message\n", "post: 消息持久化失败\n" ), + [I18N_EXEC_POST_RESERVED_FORMAT] = I18N_STRING( + "post: %s belongs to a signed-in member; " + "connect with that member's SSH key to post as it\n", + "post: %s 属于一位已登录的成员," + "只有用这位成员的 SSH 密钥连接才能以这个名字发帖\n" + ), [I18N_EXEC_COMMAND_TOO_LONG] = I18N_STRING( "exec: command too long\n", "exec: 命令过长\n" diff --git a/src/identity.c b/src/identity.c new file mode 100644 index 0000000..9a7a849 --- /dev/null +++ b/src/identity.c @@ -0,0 +1,380 @@ +#include "identity.h" + +#include +#include +#include +#include + +#define FINGERPRINT_PREFIX "SHA256:" +#define FINGERPRINT_PREFIX_LEN 7 +#define FINGERPRINT_BASE64_LEN 43 + +static pthread_mutex_t g_identity_lock = PTHREAD_MUTEX_INITIALIZER; +static identity_table_t g_identities; +static bool g_identities_loaded = false; +static struct stat g_identities_stat; +static bool g_identities_exist = false; + +bool identity_handle_valid(const char *handle) { + size_t len; + + if (!handle) { + return false; + } + len = strlen(handle); + if (len == 0 || len > TNT_IDENTITY_HANDLE_MAX) { + return false; + } + for (const char *p = handle; *p; p++) { + if (!((*p >= 'a' && *p <= 'z') || (*p >= '0' && *p <= '9') || + *p == '_')) { + return false; + } + } + return true; +} + +bool identity_fingerprint_valid(const char *fingerprint) { + if (!fingerprint || + strncmp(fingerprint, FINGERPRINT_PREFIX, FINGERPRINT_PREFIX_LEN) != 0 || + strlen(fingerprint) != + FINGERPRINT_PREFIX_LEN + FINGERPRINT_BASE64_LEN) { + return false; + } + for (const char *p = fingerprint + FINGERPRINT_PREFIX_LEN; *p; p++) { + if (!((*p >= 'A' && *p <= 'Z') || (*p >= 'a' && *p <= 'z') || + (*p >= '0' && *p <= '9') || *p == '+' || *p == '/')) { + return false; + } + } + return true; +} + +int identity_fingerprint_from_key(ssh_key key, char *out, size_t out_size) { + unsigned char *hash = NULL; + size_t hash_len = 0; + char *fingerprint; + int rc = -1; + + if (!key || !out || out_size == 0) { + return -1; + } + out[0] = '\0'; + if (ssh_get_publickey_hash(key, SSH_PUBLICKEY_HASH_SHA256, &hash, + &hash_len) != 0) { + return -1; + } + fingerprint = ssh_get_fingerprint_hash(SSH_PUBLICKEY_HASH_SHA256, hash, + hash_len); + ssh_clean_pubkey_hash(&hash); + if (fingerprint && identity_fingerprint_valid(fingerprint) && + strlen(fingerprint) < out_size) { + snprintf(out, out_size, "%s", fingerprint); + rc = 0; + } + ssh_string_free_char(fingerprint); + return rc; +} + +void identity_table_free(identity_table_t *table) { + if (!table) { + return; + } + free(table->entries); + table->entries = NULL; + table->count = 0; +} + +const identity_entry_t *identity_table_find(const identity_table_t *table, + const char *name) { + if (!table || !name) { + return NULL; + } + for (size_t i = 0; i < table->count; i++) { + if (strcasecmp(table->entries[i].handle, name) == 0) { + return &table->entries[i]; + } + } + return NULL; +} + +const char *identity_table_handle_for_key(const identity_table_t *table, + const char *fingerprint) { + if (!table || !fingerprint || fingerprint[0] == '\0') { + return NULL; + } + for (size_t i = 0; i < table->count; i++) { + for (int k = 0; k < table->entries[i].key_count; k++) { + if (strcmp(table->entries[i].keys[k], fingerprint) == 0) { + return table->entries[i].handle; + } + } + } + return NULL; +} + +static void entry_remove_key(identity_entry_t *entry, int index) { + memmove(entry->keys[index], entry->keys[index + 1], + (size_t)(entry->key_count - index - 1) * sizeof(entry->keys[0])); + entry->key_count--; +} + +bool identity_table_forget_key(identity_table_t *table, + const char *fingerprint) { + bool removed = false; + + if (!table || !fingerprint) { + return false; + } + for (size_t i = 0; i < table->count; i++) { + identity_entry_t *entry = &table->entries[i]; + for (int k = entry->key_count - 1; k >= 0; k--) { + if (strcmp(entry->keys[k], fingerprint) == 0) { + entry_remove_key(entry, k); + removed = true; + } + } + } + return removed; +} + +int identity_table_add(identity_table_t *table, const char *handle, + const char *fingerprint) { + identity_entry_t *entry; + + if (!table || !identity_handle_valid(handle) || + (fingerprint && !identity_fingerprint_valid(fingerprint))) { + return -1; + } + + entry = (identity_entry_t *)identity_table_find(table, handle); + if (!entry) { + identity_entry_t *grown = realloc( + table->entries, (table->count + 1) * sizeof(*table->entries)); + if (!grown) { + return -1; + } + table->entries = grown; + entry = &table->entries[table->count++]; + memset(entry, 0, sizeof(*entry)); + snprintf(entry->handle, sizeof(entry->handle), "%s", handle); + } + + if (fingerprint) { + (void)identity_table_forget_key(table, fingerprint); + if (entry->key_count == TNT_IDENTITY_MAX_KEYS) { + entry_remove_key(entry, 0); + } + snprintf(entry->keys[entry->key_count++], sizeof(entry->keys[0]), + "%s", fingerprint); + } + return 0; +} + +static int parse_line(identity_table_t *table, char *line) { + char *saveptr = NULL; + char *token = strtok_r(line, " \t\r\n", &saveptr); + + if (!token || !identity_handle_valid(token) || + identity_table_find(table, token)) { + return 0; + } + if (identity_table_add(table, token, NULL) < 0) { + return -1; + } + char handle[TNT_IDENTITY_HANDLE_MAX + 1]; + snprintf(handle, sizeof(handle), "%s", token); + while ((token = strtok_r(NULL, " \t\r\n", &saveptr)) != NULL) { + if (identity_fingerprint_valid(token) && + !identity_table_handle_for_key(table, token) && + identity_table_add(table, handle, token) < 0) { + return -1; + } + } + return 0; +} + +int identity_table_load(identity_table_t *table, const char *path) { + FILE *fp; + char *line = NULL; + size_t line_size = 0; + int rc = 0; + + if (!table || !path) { + return -1; + } + table->entries = NULL; + table->count = 0; + + fp = fopen(path, "r"); + if (!fp) { + return errno == ENOENT ? 0 : -1; + } + while (getline(&line, &line_size, fp) >= 0) { + if (parse_line(table, line) < 0) { + rc = -1; + break; + } + } + if (ferror(fp)) { + rc = -1; + } + free(line); + fclose(fp); + if (rc < 0) { + identity_table_free(table); + } + return rc; +} + +int identity_table_save(const identity_table_t *table, const char *path) { + char temp_path[PATH_MAX]; + FILE *fp; + int fd; + bool ok = true; + + if (!table || !path || + snprintf(temp_path, sizeof(temp_path), "%s.XXXXXX", path) >= + (int)sizeof(temp_path)) { + return -1; + } + + fd = mkstemp(temp_path); + if (fd < 0) { + return -1; + } + fp = fdopen(fd, "w"); + if (!fp) { + close(fd); + unlink(temp_path); + return -1; + } + + for (size_t i = 0; i < table->count && ok; i++) { + const identity_entry_t *entry = &table->entries[i]; + + ok = fputs(entry->handle, fp) >= 0; + for (int k = 0; k < entry->key_count && ok; k++) { + ok = fprintf(fp, " %s", entry->keys[k]) >= 0; + } + ok = ok && fputc('\n', fp) != EOF; + } + ok = ok && fchmod(fd, 0600) == 0 && fflush(fp) == 0 && fsync(fd) == 0; + if (fclose(fp) != 0) { + ok = false; + } + if (!ok || rename(temp_path, path) != 0) { + unlink(temp_path); + return -1; + } + return 0; +} + +static bool same_file_state(const struct stat *a, const struct stat *b) { + return a->st_ino == b->st_ino && a->st_size == b->st_size && + a->st_mtime == b->st_mtime; +} + +static int identities_path(char *path, size_t path_size) { + return tnt_state_path(path, path_size, TNT_IDENTITY_FILE); +} + +/* Caller holds g_identity_lock. */ +static int refresh_locked(const char *path) { + struct stat st; + bool exists = stat(path, &st) == 0; + identity_table_t fresh; + + if (g_identities_loaded && exists == g_identities_exist && + (!exists || same_file_state(&st, &g_identities_stat))) { + return 0; + } + if (identity_table_load(&fresh, path) < 0) { + fprintf(stderr, "identity: failed to read %s\n", path); + return -1; + } + identity_table_free(&g_identities); + g_identities = fresh; + g_identities_loaded = true; + g_identities_exist = exists; + if (exists) { + g_identities_stat = st; + } + return 0; +} + +static int save_locked(const char *path) { + if (identity_table_save(&g_identities, path) < 0) { + fprintf(stderr, "identity: failed to write %s\n", path); + g_identities_loaded = false; + return -1; + } + g_identities_exist = stat(path, &g_identities_stat) == 0; + return 0; +} + +bool identity_is_reserved(const char *name) { + char path[PATH_MAX]; + bool reserved = false; + + if (!name || name[0] == '\0' || identities_path(path, sizeof(path)) < 0) { + return false; + } + pthread_mutex_lock(&g_identity_lock); + (void)refresh_locked(path); + reserved = identity_table_find(&g_identities, name) != NULL; + pthread_mutex_unlock(&g_identity_lock); + return reserved; +} + +bool identity_handle_for_key(const char *fingerprint, char *handle, + size_t handle_size) { + char path[PATH_MAX]; + const char *found; + + if (!fingerprint || fingerprint[0] == '\0' || !handle || + handle_size == 0 || identities_path(path, sizeof(path)) < 0) { + return false; + } + pthread_mutex_lock(&g_identity_lock); + (void)refresh_locked(path); + found = identity_table_handle_for_key(&g_identities, fingerprint); + if (found) { + snprintf(handle, handle_size, "%s", found); + } + pthread_mutex_unlock(&g_identity_lock); + return found != NULL; +} + +int identity_reserve(const char *handle, const char *fingerprint) { + char path[PATH_MAX]; + int rc = -1; + + if (identities_path(path, sizeof(path)) < 0) { + return -1; + } + pthread_mutex_lock(&g_identity_lock); + if (refresh_locked(path) == 0 && + identity_table_add(&g_identities, handle, fingerprint) == 0) { + rc = save_locked(path); + } + pthread_mutex_unlock(&g_identity_lock); + return rc; +} + +int identity_forget_key(const char *fingerprint) { + char path[PATH_MAX]; + int rc = -1; + + if (!fingerprint || identities_path(path, sizeof(path)) < 0) { + return -1; + } + pthread_mutex_lock(&g_identity_lock); + if (refresh_locked(path) == 0) { + rc = identity_table_forget_key(&g_identities, fingerprint) + ? save_locked(path) + : 0; + } + pthread_mutex_unlock(&g_identity_lock); + return rc; +} diff --git a/src/input.c b/src/input.c index 1f4e499..6ea8dd9 100644 --- a/src/input.c +++ b/src/input.c @@ -13,6 +13,7 @@ #include "exec.h" #include "history_view.h" #include "i18n.h" +#include "identity.h" #include "keymap.h" #include "input_buffer.h" #include "message.h" @@ -49,12 +50,18 @@ static tnt_keymap_t g_default_keymap = TNT_KEYMAP_DEFAULT; #define ROOM_REDRAW_MIN_INTERVAL_MS 8 #define USERNAME_TIMEOUT_MS 60000 +static atomic_ullong g_next_session_id = 0; + static int64_t input_monotonic_millis(void); static const char *input_client_name(const struct client *client) { return client ? ((const client_t *)client)->username : NULL; } +static bool input_client_owns_name(const struct client *client) { + return client && ((const client_t *)client)->verified; +} + void input_init(void) { g_idle_timeout = tnt_config_env_int(&TNT_CONFIG_IDLE_TIMEOUT); g_default_ui_lang = i18n_default_ui_lang(); @@ -62,6 +69,7 @@ void input_init(void) { g_default_keymap); room_set_client_notifier(g_room, client_wake); room_set_client_name_accessor(g_room, input_client_name); + room_set_client_name_owner(g_room, input_client_owns_name); room_set_presence_notifier(g_room, tnt_module_runtime_publish_presence); } @@ -242,6 +250,140 @@ void notify_mentions(const char *content, const client_t *sender) { free(targets); } +static void input_post_identity(client_t *client, const char *notice, + const char *handle, bool evict) { + pthread_mutex_lock(&client->whisper_lock); + if (notice) { + size_t len = strlen(client->identity_notice); + snprintf(client->identity_notice + len, + sizeof(client->identity_notice) - len, "%s%s", + len > 0 ? "\n" : "", notice); + } + if (handle) { + snprintf(client->identity_handle, sizeof(client->identity_handle), + "%s", handle); + } + if (evict) { + client->identity_evict = true; + } + pthread_mutex_unlock(&client->whisper_lock); + atomic_store(&client->identity_pending, true); + client_wake(client); +} + +static client_t *input_find_session(const char *session) { + client_t *found = NULL; + + pthread_rwlock_rdlock(&g_room->lock); + for (int i = 0; i < g_room->client_count; i++) { + if (strcmp(g_room->clients[i]->session_id, session) == 0) { + found = g_room->clients[i]; + client_addref(found); + break; + } + } + pthread_rwlock_unlock(&g_room->lock); + return found; +} + +bool input_identity_notice(const char *session, const char *plain_text) { + client_t *client = input_find_session(session); + + if (!client) { + return false; + } + input_post_identity(client, plain_text, NULL, false); + client_release(client); + return true; +} + +bool input_identity_verified(const char *session, const char *handle) { + client_t *client = input_find_session(session); + + if (!client) { + return false; + } + (void)identity_reserve(handle, client->key_fingerprint[0] != '\0' + ? client->key_fingerprint + : NULL); + input_post_identity(client, NULL, handle, false); + client_release(client); + return true; +} + +/* Sessions rename only themselves, so an unverified holder of a handle that + * just signed in is asked to step aside rather than renamed from here. */ +static void evict_unverified_holders(const client_t *self, const char *handle) { + client_t **targets = NULL; + int target_count = 0; + + pthread_rwlock_rdlock(&g_room->lock); + if (g_room->client_count > 0) { + targets = calloc((size_t)g_room->client_count, sizeof(*targets)); + } + for (int i = 0; targets && i < g_room->client_count; i++) { + client_t *c = g_room->clients[i]; + if (c != self && !c->verified && + strcasecmp(c->username, handle) == 0) { + client_addref(c); + targets[target_count++] = c; + } + } + pthread_rwlock_unlock(&g_room->lock); + + for (int i = 0; i < target_count; i++) { + input_post_identity(targets[i], NULL, NULL, true); + client_release(targets[i]); + } + free(targets); +} + +static void show_session_notice(client_t *client, const char *text) { + snprintf(client->command_output, sizeof(client->command_output), "%s", + text); + client->command_output_scroll = 0; + client->command_output_kind = TNT_COMMAND_OUTPUT_GENERIC; + client->show_motd = false; + client->redraw_pending = true; +} + +static void apply_identity_mailbox(client_t *client) { + char notice[sizeof(client->identity_notice)]; + char handle[sizeof(client->identity_handle)]; + char output[MAX_COMMAND_OUTPUT_LEN] = ""; + size_t pos = 0; + bool evict; + + pthread_mutex_lock(&client->whisper_lock); + memcpy(notice, client->identity_notice, sizeof(notice)); + memcpy(handle, client->identity_handle, sizeof(handle)); + evict = client->identity_evict; + client->identity_notice[0] = '\0'; + client->identity_handle[0] = '\0'; + client->identity_evict = false; + pthread_mutex_unlock(&client->whisper_lock); + + if (notice[0] != '\0') { + buffer_appendf(output, sizeof(output), &pos, "%s\n", notice); + } + if (handle[0] != '\0') { + (void)commands_rename(client, handle, true); + evict_unverified_holders(client, handle); + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, I18N_SIGNED_IN_FORMAT), + handle); + } else if (evict && !client->verified && + identity_is_reserved(client->username)) { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, I18N_NICK_RESERVED_FORMAT), + client->username); + commands_become_anonymous(client); + } + if (output[0] != '\0') { + show_session_notice(client, output); + } +} + static int read_channel_exact(client_t *client, char *buf, size_t len, int timeout_ms) { size_t got = 0; @@ -1015,7 +1157,8 @@ static bool handle_key(client_t *client, unsigned char key, editor_t *ed, if (message_save(&msg) == 0) { room_broadcast(g_room, &msg); notify_mentions(msg.content, client); - tnt_module_runtime_publish_message_created(&msg); + tnt_module_runtime_publish_message_created( + &msg, client->verified); } else { fprintf(stderr, "interactive: failed to persist message\n"); } @@ -1393,6 +1536,8 @@ void input_run_session(client_t *client) { int64_t last_keepalive_ms = input_monotonic_millis(); int64_t last_activity_ms = last_keepalive_ms; int64_t last_room_render_ms = 0; + char entry_notice[512] = ""; + char handle[TNT_IDENTITY_HANDLE_MAX + 1]; /* Terminal size already set from PTY request */ client->mode = MODE_INSERT; @@ -1417,10 +1562,27 @@ void input_run_session(client_t *client) { goto cleanup; } - /* Read username */ - if (read_username(client) < 0) { - goto cleanup; + if (client->key_fingerprint[0] != '\0' && + identity_handle_for_key(client->key_fingerprint, handle, + sizeof(handle))) { + snprintf(client->username, sizeof(client->username), "%s", handle); + client->verified = true; + snprintf(entry_notice, sizeof(entry_notice), + i18n_text(client->ui_lang, I18N_SIGNED_IN_FORMAT), handle); + } else { + if (read_username(client) < 0) { + goto cleanup; + } + if (identity_is_reserved(client->username)) { + snprintf(entry_notice, sizeof(entry_notice), + i18n_text(client->ui_lang, I18N_NICK_RESERVED_FORMAT), + client->username); + snprintf(client->username, sizeof(client->username), + "anonymous"); + } } + snprintf(client->session_id, sizeof(client->session_id), "s-%llu", + atomic_fetch_add(&g_next_session_id, 1) + 1); /* Add to room */ int join_rc = room_add_client(g_room, client); @@ -1437,6 +1599,9 @@ void input_run_session(client_t *client) { goto cleanup; } joined_room = true; + if (client->verified) { + evict_unverified_holders(client, client->username); + } /* Enable xterm bracketed-paste mode only for interactive chat, so * multi-line pastes arrive framed by ESC[200~...ESC[201~ instead of @@ -1449,7 +1614,7 @@ void input_run_session(client_t *client) { system_message_make_join(&join_msg, client->username, client->ui_lang); room_broadcast(g_room, &join_msg); message_save(&join_msg); - tnt_module_runtime_publish_message_created(&join_msg); + tnt_module_runtime_publish_message_created(&join_msg, false); /* Show MOTD if motd.txt exists in state directory */ { @@ -1464,7 +1629,9 @@ void input_run_session(client_t *client) { motd_buf[motd_len] = '\0'; snprintf(client->command_output, sizeof(client->command_output), - "%s", motd_buf); + "%s%s%s", entry_notice, + entry_notice[0] != '\0' ? "\n" : "", + motd_buf); client->command_output_scroll = 0; client->command_output_kind = TNT_COMMAND_OUTPUT_NONE; client->show_motd = true; @@ -1480,6 +1647,9 @@ void input_run_session(client_t *client) { seen_update_seq = room_get_update_seq(g_room); tui_render_screen(client); last_room_render_ms = input_monotonic_millis(); + if (entry_notice[0] != '\0') { + show_session_notice(client, entry_notice); + } main_loop: @@ -1503,6 +1673,10 @@ void input_run_session(client_t *client) { break; } + if (atomic_exchange(&client->identity_pending, false)) { + apply_identity_mailbox(client); + } + if (current_update_seq != seen_update_seq) { room_updated = true; } @@ -1839,7 +2013,7 @@ void input_run_session(client_t *client) { room_remove_client(g_room, client); room_broadcast(g_room, &leave_msg); message_save(&leave_msg); - tnt_module_runtime_publish_message_created(&leave_msg); + tnt_module_runtime_publish_message_created(&leave_msg, false); } /* Interactive shells otherwise look like a transport reset to OpenSSH diff --git a/src/module_protocol.c b/src/module_protocol.c index e688b6e..3e96602 100644 --- a/src/module_protocol.c +++ b/src/module_protocol.c @@ -40,7 +40,7 @@ int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos, int tnt_module_append_message_created(char *buffer, size_t buf_size, size_t *pos, const char *message_id, - const message_t *msg) { + const message_t *msg, bool verified) { char timestamp[64]; size_t before; @@ -64,7 +64,8 @@ int tnt_module_append_message_created(char *buffer, size_t buf_size, buffer_appendf(buffer, buf_size, pos, ",\"kind\":\"text\"," "\"plain_text\":"); tnt_json_append_string(buffer, buf_size, pos, msg->content); - buffer_appendf(buffer, buf_size, pos, ",\"metadata\":{}}}\n"); + buffer_appendf(buffer, buf_size, pos, ",\"metadata\":{%s}}}\n", + verified ? "\"verified\":true" : ""); return append_result(before, *pos, buf_size); } @@ -117,6 +118,27 @@ int tnt_module_append_presence_snapshot(char *buffer, size_t buf_size, return append_result(before, *pos, buf_size); } +int tnt_module_append_identity_requested(char *buffer, size_t buf_size, + size_t *pos, const char *session, + const char *nickname) { + size_t before; + + if (!buffer || !pos || buf_size == 0 || !session || session[0] == '\0' || + !nickname || !utf8_is_valid_string(nickname)) { + return -1; + } + + before = *pos; + buffer_appendf(buffer, buf_size, pos, "{\"type\":\"%s\",\"session\":", + TNT_MODULE_EVENT_IDENTITY_REQUESTED); + tnt_json_append_string(buffer, buf_size, pos, session); + buffer_appendf(buffer, buf_size, pos, ",\"nickname\":"); + tnt_json_append_string(buffer, buf_size, pos, nickname); + buffer_appendf(buffer, buf_size, pos, "}\n"); + + return append_result(before, *pos, buf_size); +} + bool tnt_module_parse_message_create(const char *line, tnt_module_message_create_t *out) { char type[64]; @@ -163,3 +185,41 @@ bool tnt_module_parse_message_post(const char *line, sizeof(out->plain_text)) && is_valid_plain_text(out->plain_text); } + +bool tnt_module_parse_identity_record(const char *line, + tnt_module_identity_record_t *out) { + char type[64]; + + if (!line || !out) { + return false; + } + + memset(out, 0, sizeof(*out)); + if (!tnt_json_get_string_field(line, "type", type, sizeof(type))) { + return false; + } + if (strcmp(type, "identity.notice") == 0) { + out->kind = TNT_MODULE_IDENTITY_NOTICE; + return tnt_json_get_string_field(line, "session", out->session, + sizeof(out->session)) && + out->session[0] != '\0' && + tnt_json_get_string_field(line, "plain_text", out->plain_text, + sizeof(out->plain_text)) && + is_valid_plain_text(out->plain_text); + } + if (strcmp(type, "identity.verified") == 0) { + out->kind = TNT_MODULE_IDENTITY_VERIFIED; + if (!tnt_json_get_string_field(line, "session", out->session, + sizeof(out->session)) || + out->session[0] == '\0') { + return false; + } + } else if (strcmp(type, "identity.reserve") == 0) { + out->kind = TNT_MODULE_IDENTITY_RESERVE; + } else { + return false; + } + return tnt_json_get_string_field(line, "handle", out->handle, + sizeof(out->handle)) && + identity_handle_valid(out->handle); +} diff --git a/src/module_runtime.c b/src/module_runtime.c index 12b183d..99879d1 100644 --- a/src/module_runtime.c +++ b/src/module_runtime.c @@ -3,6 +3,7 @@ #include "chat_room.h" #include "common.h" #include "config_defaults.h" +#include "identity.h" #include "json_text.h" #include "module_protocol.h" #include "utf8.h" @@ -33,18 +34,24 @@ struct client; void notify_mentions(const char *content, const struct client *sender); +bool input_identity_notice(const char *session, const char *plain_text); +bool input_identity_verified(const char *session, const char *handle); typedef enum module_event_kind { MODULE_EVENT_MESSAGE_CREATED, MODULE_EVENT_PRESENCE_JOINED, MODULE_EVENT_PRESENCE_LEFT, - MODULE_EVENT_PRESENCE_SNAPSHOT + MODULE_EVENT_PRESENCE_SNAPSHOT, + MODULE_EVENT_IDENTITY_REQUESTED } module_event_kind_t; +/* An identity request carries the nickname in msg.username and the session + * id in msg.content. */ typedef struct module_event { module_event_kind_t kind; message_t msg; uint64_t event_id; + bool verified; } module_event_t; typedef struct module_read_buffer { @@ -113,6 +120,7 @@ static uint64_t g_next_event_id = 0; static atomic_bool g_stop_requested = ATOMIC_VAR_INIT(true); static pthread_mutex_t g_dispatch_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_module_publish_lock = PTHREAD_MUTEX_INITIALIZER; +static module_process_t *g_identity_module = NULL; static char g_online[TNT_MAX_CONFIGURED_CLIENTS][MAX_USERNAME_LEN]; static int g_online_count = 0; @@ -261,6 +269,8 @@ int tnt_module_manifest_load(const char *module_dir, manifest, "permissions", "message:post"); out->can_read_presence = json_array_contains_string( manifest, "permissions", "presence:read"); + out->can_write_identity = json_array_contains_string( + manifest, "permissions", "identity:write"); if (!out->wants_message_created || !out->can_read_messages || !out->can_create_messages) { @@ -791,7 +801,8 @@ static module_queue_push_result_t module_queue_push(module_process_t *module, active = atomic_load_explicit(&module->active, memory_order_acquire); if (!module->queue_running || queue_full || !active) { if (module->queue_running && queue_full && active && - event->kind != MODULE_EVENT_MESSAGE_CREATED) { + event->kind != MODULE_EVENT_MESSAGE_CREATED && + event->kind != MODULE_EVENT_IDENTITY_REQUESTED) { module->dropped_presence++; } else if (module->queue_running && queue_full && active && !module->saturation_reported) { @@ -901,10 +912,11 @@ static bool online_update_locked(const char *nickname, bool joined) { } static int queue_event_locked(module_event_kind_t kind, const message_t *msg, - const module_process_t *origin, + bool verified, const module_process_t *origin, char saturated[][TNT_MODULE_NAME_MAX + 1]) { module_event_t event = { .kind = kind, + .verified = verified, }; int saturated_count = 0; @@ -949,7 +961,7 @@ static void report_saturated(char saturated[][TNT_MODULE_NAME_MAX + 1], } static void dispatch_event(module_event_kind_t kind, const message_t *msg, - const module_process_t *origin) { + bool verified, const module_process_t *origin) { char saturated[TNT_MAX_MODULES][TNT_MODULE_NAME_MAX + 1]; int saturated_count = 0; @@ -960,7 +972,8 @@ static void dispatch_event(module_event_kind_t kind, const message_t *msg, pthread_mutex_lock(&g_dispatch_lock); if (g_accepting && !atomic_load_explicit(&g_stop_requested, memory_order_acquire)) { - saturated_count = queue_event_locked(kind, msg, origin, saturated); + saturated_count = queue_event_locked(kind, msg, verified, origin, + saturated); } pthread_mutex_unlock(&g_dispatch_lock); report_saturated(saturated, saturated_count); @@ -1014,7 +1027,7 @@ static void publish_module_message(const module_process_t *module, TNT_MODULE_NAME_MAX, module->manifest.name); snprintf(msg.content, sizeof(msg.content), "%s", plain_text); if (publish_message(&msg)) { - dispatch_event(MODULE_EVENT_MESSAGE_CREATED, &msg, module); + dispatch_event(MODULE_EVENT_MESSAGE_CREATED, &msg, false, module); } } @@ -1033,21 +1046,59 @@ static void publish_posted_message(const module_process_t *module, snprintf(msg.content, sizeof(msg.content), "%s", post->plain_text); } if (publish_message(&msg)) { - dispatch_event(MODULE_EVENT_MESSAGE_CREATED, &msg, module); + dispatch_event(MODULE_EVENT_MESSAGE_CREATED, &msg, + module->manifest.can_write_identity, module); } } -static module_response_action_t handle_module_post(module_process_t *module, - const char *line) { - tnt_module_message_post_t post; +static void apply_identity_record(const tnt_module_identity_record_t *record) { + switch (record->kind) { + case TNT_MODULE_IDENTITY_NOTICE: + (void)input_identity_notice(record->session, record->plain_text); + break; + case TNT_MODULE_IDENTITY_VERIFIED: + (void)input_identity_verified(record->session, record->handle); + break; + case TNT_MODULE_IDENTITY_RESERVE: + (void)identity_reserve(record->handle, NULL); + break; + } +} + +/* Records a granted module may write at any time, outside the response + * window of an event. */ +static bool is_unsolicited_record(const module_process_t *module, + const char *line) { + char type[64]; + + if (!tnt_json_get_string_field(line, "type", type, sizeof(type))) { + return false; + } + return (module->manifest.can_post_messages && + strcmp(type, TNT_MODULE_RECORD_MESSAGE_POST) == 0) || + (module->manifest.can_write_identity && + strncmp(type, TNT_MODULE_RECORD_IDENTITY_PREFIX, + strlen(TNT_MODULE_RECORD_IDENTITY_PREFIX)) == 0); +} - if (!module->manifest.can_post_messages || - !tnt_module_parse_message_post(line, &post)) { +static module_response_action_t handle_module_record(module_process_t *module, + const char *line) { + tnt_module_message_post_t post; + tnt_module_identity_record_t identity; + + if (module->manifest.can_post_messages && + tnt_module_parse_message_post(line, &post) && + (module->manifest.can_write_identity || + !identity_is_reserved(post.sender))) { + publish_posted_message(module, &post); + } else if (module->manifest.can_write_identity && + tnt_module_parse_identity_record(line, &identity)) { + apply_identity_record(&identity); + } else { fprintf(stderr, "module runtime: ignored invalid response from %s\n", module->manifest.name); return MODULE_RESPONSE_INVALID; } - publish_posted_message(module, &post); module->invalid_responses = 0; return MODULE_RESPONSE_CONTINUE; } @@ -1091,7 +1142,7 @@ static bool write_module_record(module_process_t *module, const char *record) { static void deliver_message_to_module(module_process_t *module, const message_t *msg, - uint64_t event_id) { + uint64_t event_id, bool verified) { char event[TNT_MODULE_LINE_MAX] = ""; char line[TNT_MODULE_LINE_MAX]; char message_id[64]; @@ -1102,7 +1153,7 @@ static void deliver_message_to_module(module_process_t *module, snprintf(message_id, sizeof(message_id), "local-%llu", (unsigned long long)event_id); if (tnt_module_append_message_created(event, sizeof(event), &pos, - message_id, msg) < 0) { + message_id, msg, verified) < 0) { disable_module(module, "event encoding failure"); return; } @@ -1135,11 +1186,11 @@ static void deliver_message_to_module(module_process_t *module, disable_module(module, "read failure"); return; } - if (module->manifest.can_post_messages && - record_has_type(line, TNT_MODULE_RECORD_MESSAGE_POST)) { + if (is_unsolicited_record(module, line)) { int64_t started = monotonic_millis(); - if (handle_module_post(module, line) == MODULE_RESPONSE_INVALID && + if (handle_module_record(module, line) == + MODULE_RESPONSE_INVALID && !note_invalid_response(module)) { return; } @@ -1181,6 +1232,20 @@ static void deliver_presence_to_module(module_process_t *module, (void)write_module_record(module, record); } +static void deliver_identity_request_to_module(module_process_t *module, + const module_event_t *event) { + char record[TNT_MODULE_LINE_MAX] = ""; + size_t pos = 0; + + if (tnt_module_append_identity_requested(record, sizeof(record), &pos, + event->msg.content, + event->msg.username) < 0) { + disable_module(module, "event encoding failure"); + return; + } + (void)write_module_record(module, record); +} + static void deliver_snapshot_to_module(module_process_t *module) { char (*online)[MAX_USERNAME_LEN] = malloc(sizeof(g_online)); int count; @@ -1231,7 +1296,11 @@ static void deliver_event_to_module(module_process_t *module, switch (event->kind) { case MODULE_EVENT_MESSAGE_CREATED: - deliver_message_to_module(module, &event->msg, event->event_id); + deliver_message_to_module(module, &event->msg, event->event_id, + event->verified); + break; + case MODULE_EVENT_IDENTITY_REQUESTED: + deliver_identity_request_to_module(module, event); break; case MODULE_EVENT_PRESENCE_SNAPSHOT: deliver_snapshot_to_module(module); @@ -1270,7 +1339,7 @@ static void read_unsolicited_record(module_process_t *module, int wait_ms) { disable_module(module, "read failure"); return; } - if (handle_module_post(module, line) == MODULE_RESPONSE_INVALID) { + if (handle_module_record(module, line) == MODULE_RESPONSE_INVALID) { (void)note_invalid_response(module); } } @@ -1325,16 +1394,24 @@ static bool module_is_granted(const char *name) { static bool module_permissions_allowed(module_process_t *module) { const tnt_module_manifest_t *manifest = &module->manifest; - module->privileged = - manifest->can_post_messages || manifest->can_read_presence; - if (!module->privileged || module_is_granted(manifest->name)) { - return true; + module->privileged = manifest->can_post_messages || + manifest->can_read_presence || + manifest->can_write_identity; + if (module->privileged && !module_is_granted(manifest->name)) { + fprintf(stderr, + "module runtime: %s requests message:post, presence:read or " + "identity:write but is not listed in TNT_MODULE_GRANTS\n", + manifest->name); + return false; } - fprintf(stderr, - "module runtime: %s requests message:post or presence:read but " - "is not listed in TNT_MODULE_GRANTS\n", - manifest->name); - return false; + if (manifest->can_write_identity && g_identity_module) { + fprintf(stderr, + "module runtime: %s requests identity:write, which %s " + "already holds\n", + manifest->name, g_identity_module->manifest.name); + return false; + } + return true; } static int load_modules_from_env(void) { @@ -1367,6 +1444,9 @@ static int load_modules_from_env(void) { start_module_process(token, module) == 0) { fprintf(stderr, "module runtime: enabled %s\n", module->manifest.name); + if (module->manifest.can_write_identity) { + g_identity_module = module; + } g_module_count++; } else { fprintf(stderr, "module runtime: failed to enable module at %s\n", @@ -1478,7 +1558,8 @@ int tnt_module_runtime_init(void) { pthread_mutex_lock(&g_dispatch_lock); g_accepting = true; - (void)queue_event_locked(MODULE_EVENT_PRESENCE_SNAPSHOT, NULL, NULL, NULL); + (void)queue_event_locked(MODULE_EVENT_PRESENCE_SNAPSHOT, NULL, false, NULL, + NULL); pthread_mutex_unlock(&g_dispatch_lock); pthread_mutex_unlock(&g_lifecycle_lock); return 0; @@ -1490,6 +1571,7 @@ int tnt_module_runtime_init(void) { close_module_processes(count); pthread_mutex_lock(&g_dispatch_lock); g_module_count = 0; + g_identity_module = NULL; pthread_mutex_unlock(&g_dispatch_lock); pthread_mutex_unlock(&g_lifecycle_lock); return -1; @@ -1510,14 +1592,16 @@ void tnt_module_runtime_shutdown(void) { pthread_mutex_lock(&g_dispatch_lock); g_module_count = 0; + g_identity_module = NULL; g_next_event_id = 0; pthread_mutex_unlock(&g_dispatch_lock); pthread_mutex_unlock(&g_lifecycle_lock); } -void tnt_module_runtime_publish_message_created(const message_t *msg) { +void tnt_module_runtime_publish_message_created(const message_t *msg, + bool verified) { if (msg) { - dispatch_event(MODULE_EVENT_MESSAGE_CREATED, msg, NULL); + dispatch_event(MODULE_EVENT_MESSAGE_CREATED, msg, verified, NULL); } } @@ -1536,9 +1620,31 @@ void tnt_module_runtime_publish_presence(const char *nickname, bool joined) { !atomic_load_explicit(&g_stop_requested, memory_order_acquire)) { (void)queue_event_locked( joined ? MODULE_EVENT_PRESENCE_JOINED : MODULE_EVENT_PRESENCE_LEFT, - &msg, NULL, NULL); + &msg, false, NULL, NULL); + } + pthread_mutex_unlock(&g_dispatch_lock); +} + +bool tnt_module_runtime_request_identity(const char *session, + const char *nickname) { + module_event_t event = { + .kind = MODULE_EVENT_IDENTITY_REQUESTED, + }; + module_queue_push_result_t pushed = MODULE_QUEUE_DROPPED; + + if (!session || session[0] == '\0' || !nickname) { + return false; + } + snprintf(event.msg.username, sizeof(event.msg.username), "%s", nickname); + snprintf(event.msg.content, sizeof(event.msg.content), "%s", session); + + pthread_mutex_lock(&g_dispatch_lock); + if (g_accepting && g_identity_module && + !atomic_load_explicit(&g_stop_requested, memory_order_acquire)) { + pushed = module_queue_push(g_identity_module, &event); } pthread_mutex_unlock(&g_dispatch_lock); + return pushed == MODULE_QUEUE_PUSHED; } #ifdef TNT_TESTING diff --git a/tests/run_suites.sh b/tests/run_suites.sh index 1091ed2..e192724 100755 --- a/tests/run_suites.sh +++ b/tests/run_suites.sh @@ -21,7 +21,8 @@ multiline_input 14 cursor_editing 10 vim_keymap 12 wide_text_view 9 -module_privileged 16" +module_privileged 16 +identity 17" WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-suites.XXXXXX") trap 'rm -rf "$WORK_DIR"' EXIT diff --git a/tests/test_identity.sh b/tests/test_identity.sh new file mode 100755 index 0000000..058eeca --- /dev/null +++ b/tests/test_identity.sh @@ -0,0 +1,334 @@ +#!/bin/sh +# Verified identities: :login through an identity:write module, reserved +# names, :logout, SSH key sign-in, verified metadata, and exec post. + +. ./lib.sh + +PORT=${PORT:-12369} +trap tnt_cleanup EXIT + +tnt_skip_without_expect "identity test" +tnt_require_binary +tnt_state_dir identity-test + +echo "=== TNT Identity Tests ===" + +MODULE_DIR="$STATE_DIR/ident" +IDENTITIES="$STATE_DIR/identities" +LOG="$STATE_DIR/messages.log" +TNT_IDENT_EVENTS="$STATE_DIR/ident.events" +KEY="$STATE_DIR/key" +OTHER_KEY="$STATE_DIR/other" +export TNT_IDENT_EVENTS + +ssh-keygen -q -t ed25519 -N "" -C "" -f "$KEY" +ssh-keygen -q -t ed25519 -N "" -C "" -f "$OTHER_KEY" +FINGERPRINT=$(ssh-keygen -lf "$KEY.pub" | cut -d' ' -f2) + +SSH_KEYLESS="$(tnt_ssh_opts "$PORT") -o PubkeyAuthentication=no" +SSH_KEY="$(tnt_ssh_opts "$PORT") -o IdentitiesOnly=yes -i $KEY" +SSH_EXEC="ssh -n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ +-o BatchMode=yes -o LogLevel=ERROR -o IdentitiesOnly=yes -p $PORT" + +# Run expect body $2 as session $1. $3 holds the ssh options; the body +# starts right after the connection opens. +run_session() { + _script="$STATE_DIR/$1.expect" + cat >"$_script" <"$STATE_DIR/$1.session.log" 2>&1 +} + +# --- Without an identity module ------------------------------------------ + +tnt_start_server "$PORT" "$STATE_DIR" + +if run_session nomodule ' +expect "): " +send -- "bob\r" +expect "/help" +send -- "/login\r" +expect { + "Sign-in is not available on this server." {} + timeout { exit 1 } +} +' "$SSH_KEYLESS"; then + pass ":login without an identity module says sign-in is unavailable" +else + fail ":login without a module" + tnt_dump_session nomodule +fi + +kill "$SERVER_PID" 2>/dev/null +wait "$SERVER_PID" 2>/dev/null +SERVER_PID="" + +# --- With a fake identity module ----------------------------------------- + +mkdir -p "$MODULE_DIR" +cat >"$MODULE_DIR/tnt-module.json" <<'JSON' +{ + "protocol": "tnt.module.v1", + "name": "ident", + "entrypoint": "./ident.sh", + "permissions": ["message:read", "message:create", "message:post", "identity:write"], + "events": ["message.created"] +} +JSON +cat >"$MODULE_DIR/ident.sh" <<'SH' +#!/bin/sh +while IFS= read -r line; do + printf '%s\n' "$line" >>"$TNT_IDENT_EVENTS" + case "$line" in + *'"type":"handshake"'*) + printf '{"type":"handshake.ok","protocol":"tnt.module.v1"}\n' + printf '{"type":"identity.reserve","handle":"webby"}\n' ;; + *'"type":"identity.requested"'*) + session=$(printf '%s' "$line" | sed -n 's/.*"session":"\([^"]*\)".*/\1/p') + printf '{"type":"identity.notice","session":"%s","plain_text":"Enter code TEST-CODE"}\n' "$session" + printf '{"type":"identity.verified","session":"%s","handle":"alice"}\n' "$session" ;; + *'"type":"message.created"'*'"plain_text":"web hello"'*) + printf '{"type":"event.ok"}\n' ;; + *'"type":"message.created"'*) + printf '{"type":"event.ok"}\n' ;; + esac +done +SH +chmod +x "$MODULE_DIR/ident.sh" + +TNT_MODULE_PATHS="$MODULE_DIR" +export TNT_MODULE_PATHS + +tnt_start_server "$PORT" "$STATE_DIR" +if grep -q "ident requests message:post, presence:read or identity:write" \ + "$STATE_DIR/server.log"; then + pass "an ungranted identity:write module is refused" +else + fail "an ungranted identity:write module was not refused" + sed -n '1,40p' "$STATE_DIR/server.log" +fi +kill "$SERVER_PID" 2>/dev/null +wait "$SERVER_PID" 2>/dev/null +SERVER_PID="" + +SECOND_DIR="$STATE_DIR/second" +mkdir -p "$SECOND_DIR" +sed 's/"name": "ident"/"name": "second"/' "$MODULE_DIR/tnt-module.json" \ + >"$SECOND_DIR/tnt-module.json" +cp "$MODULE_DIR/ident.sh" "$SECOND_DIR/ident.sh" +TNT_MODULE_PATHS="$MODULE_DIR:$SECOND_DIR" +TNT_MODULE_GRANTS="ident:second" +export TNT_MODULE_PATHS TNT_MODULE_GRANTS +tnt_start_server "$PORT" "$STATE_DIR" + +if grep -q "second requests identity:write, which ident already holds" \ + "$STATE_DIR/server.log" && + grep -q "enabled ident" "$STATE_DIR/server.log" && + ! grep -q "enabled second" "$STATE_DIR/server.log"; then + pass "only the first identity:write module is enabled" +else + fail "a second identity:write module was enabled" + sed -n '1,40p' "$STATE_DIR/server.log" +fi + +if tnt_wait_for "$IDENTITIES" "^webby$"; then + pass "identity.reserve adds a handle without a key" +else + fail "identity.reserve did not reach the identities file" + cat "$IDENTITIES" 2>/dev/null +fi + +if [ "$(ls -l "$IDENTITIES" | cut -c1-10)" = "-rw-------" ]; then + pass "the identities file has mode 0600" +else + fail "the identities file mode is wrong" +fi + +# A holds "alice" before it is reserved; B signs in as alice and A steps aside. +cat >"$STATE_DIR/login.expect" <"$STATE_DIR/login.session.log" 2>&1; then + pass ":login shows the module notice and signs the session in" +else + fail ":login flow" + tnt_dump_session login +fi + +if tnt_wait_for_fixed "$LOG" "|system|alice renamed to anonymous" && + tnt_wait_for_fixed "$LOG" "|system|bob renamed to alice"; then + pass "the unverified holder of a handle is renamed when its owner signs in" +else + fail "renames missing" + cat "$LOG" +fi + +if tnt_wait_for_fixed "$TNT_IDENT_EVENTS" '"identity.requested","session":"s-' && + grep -qF '"nickname":"bob"}' "$TNT_IDENT_EVENTS"; then + pass ":login sends identity.requested with the session and nickname" +else + fail "identity.requested is missing" + cat "$TNT_IDENT_EVENTS" +fi + +if tnt_wait_for_fixed "$TNT_IDENT_EVENTS" \ + '"sender":"alice","kind":"text","plain_text":"hello verified","metadata":{"verified":true}'; then + pass "a verified session's messages carry metadata.verified" +else + fail "metadata.verified is missing" + cat "$TNT_IDENT_EVENTS" +fi + +if tnt_wait_for_fixed "$TNT_IDENT_EVENTS" '"plain_text":"after logout","metadata":{}'; then + pass ":logout drops metadata.verified" +else + fail "a signed-out session still looks verified" + cat "$TNT_IDENT_EVENTS" +fi + +if grep -q "^alice$" "$IDENTITIES"; then + pass "a keyless sign-in reserves the handle without a key" +else + fail "the identities file is wrong after a keyless sign-in" + cat "$IDENTITIES" +fi + +if run_session reserved " +expect \"): \" +send -- \"Alice\r\" +expect { + \"@Alice belongs to a signed-in member.\" {} + timeout { exit 1 } +} +send -- \"q\" +sleep 0.3 +send -- \"/nick webby\r\" +expect { + \"@webby belongs to a signed-in member.\" {} + timeout { exit 1 } +} +" "$SSH_KEYLESS"; then + pass "reserved names are refused at the prompt and by :nick" +else + fail "a reserved name was accepted" + tnt_dump_session reserved +fi + +if run_session bindkey ' +expect "): " +send -- "carol\r" +expect "/help" +send -- "/login\r" +expect { + "Signed in as @alice." {} + timeout { exit 1 } +} +' "$SSH_KEY" && + tnt_wait_for_fixed "$IDENTITIES" "alice $FINGERPRINT"; then + pass "signing in over a public key binds the key to the handle" +else + fail "the key was not bound" + tnt_dump_session bindkey + cat "$IDENTITIES" +fi + +POST_OUTPUT=$($SSH_EXEC -i "$KEY" alice@127.0.0.1 post "from my key" 2>&1) +POST_STATUS=$? +if [ "$POST_STATUS" -eq 0 ] && [ "$POST_OUTPUT" = "posted" ] && + tnt_wait_for_fixed "$TNT_IDENT_EVENTS" \ + '"plain_text":"from my key","metadata":{"verified":true}'; then + pass "exec post under a reserved name works with its key and is verified" +else + fail "exec post with the bound key: $POST_STATUS $POST_OUTPUT" +fi + +POST_OUTPUT=$($SSH_EXEC -i "$OTHER_KEY" Alice@127.0.0.1 post "forged" 2>&1) +POST_STATUS=$? +if [ "$POST_STATUS" -eq 1 ] && + printf '%s' "$POST_OUTPUT" | grep -q "belongs to a signed-in member" && + ! grep -q "forged" "$LOG"; then + pass "exec post under a reserved name is refused without its key" +else + fail "exec post was not refused: $POST_STATUS $POST_OUTPUT" +fi + +if run_session autologin ' +expect { + "): " { exit 1 } + "Signed in as @alice." {} + timeout { exit 1 } +} +send -- "q" +sleep 0.3 +send -- "/logout\r" +expect { + "Signed out. Your name is now" {} + timeout { exit 1 } +} +' "$SSH_KEY"; then + pass "a bound key signs in on reconnect without the prompt" +else + fail "automatic sign-in by key" + tnt_dump_session autologin +fi + +if tnt_poll_until 5 sh -c "! grep -qF '$FINGERPRINT' '$IDENTITIES'" && + grep -q "^alice$" "$IDENTITIES"; then + pass ":logout removes the session's key and keeps the handle reserved" +else + fail ":logout left the key bound" + cat "$IDENTITIES" +fi + +if ! grep -q "disabling ident" "$STATE_DIR/server.log"; then + pass "the identity module stays enabled" +else + fail "the identity module was disabled" + sed -n '1,80p' "$STATE_DIR/server.log" +fi + +tnt_check_server_alive "server survived the identity session" +tnt_summary diff --git a/tests/test_module_privileged.sh b/tests/test_module_privileged.sh index f2c081e..0bd9d09 100755 --- a/tests/test_module_privileged.sh +++ b/tests/test_module_privileged.sh @@ -76,7 +76,7 @@ TNT_MODULE_PATHS="$POSTER_DIR:$ECHO_DIR" export TNT_MODULE_PATHS tnt_start_server "$PORT" "$STATE_DIR" -if grep -q "poster requests message:post or presence:read" "$STATE_DIR/server.log" && +if grep -q "poster requests message:post, presence:read or identity:write" "$STATE_DIR/server.log" && ! grep -q "enabled poster" "$STATE_DIR/server.log"; then pass "an ungranted module requesting message:post is refused" else diff --git a/tests/unit/Makefile b/tests/unit/Makefile index e997e0d..3471aa7 100644 --- a/tests/unit/Makefile +++ b/tests/unit/Makefile @@ -46,8 +46,9 @@ THEME_SRC = ../../src/theme.c RICHTEXT_SRC = ../../src/richtext.c EDITOR_SRC = ../../src/editor.c KEYMAP_SRC = ../../src/keymap.c +IDENTITY_SRC = ../../src/identity.c -TESTS = test_utf8 test_input_buffer test_json_text test_module_protocol test_module_runtime test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_tntctl_text test_ratelimit test_common test_client_outbox test_config_defaults test_theme test_richtext test_editor test_keymap test_message_log +TESTS = test_utf8 test_input_buffer test_json_text test_module_protocol test_module_runtime test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_tntctl_text test_ratelimit test_common test_client_outbox test_config_defaults test_theme test_richtext test_editor test_keymap test_message_log test_identity .PHONY: all clean run @@ -74,11 +75,14 @@ test_input_buffer: test_input_buffer.c $(INPUT_BUFFER_SRC) $(UTF8_SRC) test_json_text: test_json_text.c $(JSON_TEXT_SRC) $(COMMON_SRC) $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) $(LDLIBS) -test_module_protocol: test_module_protocol.c $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) - $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) $(LDLIBS) +test_module_protocol: test_module_protocol.c $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(IDENTITY_SRC) + $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(LDFLAGS) $(PROJECT_LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) -lssh $(LDLIBS) -test_module_runtime: test_module_runtime.c $(MODULE_RUNTIME_SRC) $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CONFIG_DEFAULTS_SRC) - $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) -DTNT_TESTING $(LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) $(LDLIBS) +test_identity: test_identity.c $(IDENTITY_SRC) $(COMMON_SRC) + $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(LDFLAGS) $(PROJECT_LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) -lssh $(LDLIBS) + +test_module_runtime: test_module_runtime.c $(MODULE_RUNTIME_SRC) $(MODULE_PROTOCOL_SRC) $(JSON_TEXT_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CONFIG_DEFAULTS_SRC) $(IDENTITY_SRC) + $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) -DTNT_TESTING $(LDFLAGS) $(PROJECT_LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) -lssh $(LDLIBS) test_message: test_message.c $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CC) $(CPPFLAGS) $(PROJECT_CPPFLAGS) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(PROJECT_LDLIBS) $(LDLIBS) @@ -154,6 +158,9 @@ run: all @echo "=== Running Module Protocol Tests ===" ./test_module_protocol @echo "" + @echo "=== Running Identity Tests ===" + ./test_identity + @echo "" @echo "=== Running Module Runtime Tests ===" ./test_module_runtime @echo "" diff --git a/tests/unit/test_identity.c b/tests/unit/test_identity.c new file mode 100644 index 0000000..8bc0705 --- /dev/null +++ b/tests/unit/test_identity.c @@ -0,0 +1,233 @@ +#include "../../include/identity.h" + +#include +#include +#include +#include +#include +#include + +#define TEST(name) static void test_##name(void) +#define RUN_TEST(name) do { \ + printf("Running %s... ", #name); \ + test_##name(); \ + printf("ok\n"); \ + tests_passed++; \ +} while (0) + +/* Generated with `ssh-keygen -t ed25519`; `ssh-keygen -lf` prints KEY_SHA256. */ +#define KEY_BASE64 \ + "AAAAC3NzaC1lZDI1NTE5AAAAIH8bmE4NEMDW8dBlYJ2ADZ1zmtMmJpNfWMd1yKnpNjtC" +#define KEY_SHA256 "SHA256:UMSdY3XS0sbuWW4MC+rV6SEa0OanWxNxnFV35VM5Sng" +#define OTHER_SHA256 "SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU" + +static int tests_passed = 0; +static char state_dir[PATH_MAX]; + +static void write_text(const char *path, const char *text) { + FILE *fp = fopen(path, "w"); + + assert(fp); + assert(fputs(text, fp) >= 0); + assert(fclose(fp) == 0); +} + +static void read_text(const char *path, char *out, size_t out_size) { + FILE *fp = fopen(path, "r"); + size_t n; + + assert(fp); + n = fread(out, 1, out_size - 1, fp); + out[n] = '\0'; + fclose(fp); +} + +static void state_path(char *out, size_t out_size, const char *name) { + snprintf(out, out_size, "%s/%s", state_dir, name); +} + +TEST(validates_handles) { + assert(identity_handle_valid("alice")); + assert(identity_handle_valid("a_1")); + assert(identity_handle_valid("abcdefghijklmnopqrst")); + assert(!identity_handle_valid("")); + assert(!identity_handle_valid("abcdefghijklmnopqrstu")); + assert(!identity_handle_valid("Alice")); + assert(!identity_handle_valid("al-ice")); + assert(!identity_handle_valid("al ice")); + assert(!identity_handle_valid("用户")); +} + +TEST(validates_fingerprints) { + assert(identity_fingerprint_valid(KEY_SHA256)); + assert(!identity_fingerprint_valid("SHA256:")); + assert(!identity_fingerprint_valid( + "MD5:UMSdY3XS0sbuWW4MC+rV6SEa0OanWxNxnFV35VM5Sng")); + assert(!identity_fingerprint_valid(KEY_SHA256 "=")); + assert(!identity_fingerprint_valid( + "SHA256:UMSdY3XS0sbuWW4MC+rV6SEa0OanWxNxnFV35VM5Sn")); + assert(!identity_fingerprint_valid( + "SHA256:UMSdY3XS0sbuWW4MC-rV6SEa0OanWxNxnFV35VM5Sng")); +} + +TEST(formats_fingerprints_like_ssh_keygen) { + ssh_key key = NULL; + char fingerprint[TNT_IDENTITY_FINGERPRINT_SIZE]; + + assert(ssh_pki_import_pubkey_base64(KEY_BASE64, SSH_KEYTYPE_ED25519, + &key) == SSH_OK); + assert(identity_fingerprint_from_key(key, fingerprint, + sizeof(fingerprint)) == 0); + assert(strcmp(fingerprint, KEY_SHA256) == 0); + assert(identity_fingerprint_from_key(key, fingerprint, 20) < 0); + ssh_key_free(key); +} + +TEST(parses_and_skips_invalid_lines) { + char path[PATH_MAX]; + identity_table_t table; + + state_path(path, sizeof(path), "parse"); + write_text(path, + "alice " KEY_SHA256 " not-a-key\n" + "\n" + "# comment\n" + "Bob\n" + "toolongtoolongtoolong1\n" + "carol\t" OTHER_SHA256 "\n" + "dave " KEY_SHA256 "\n" + "alice " OTHER_SHA256 "\n"); + assert(identity_table_load(&table, path) == 0); + assert(table.count == 3); + assert(strcmp(table.entries[0].handle, "alice") == 0); + assert(table.entries[0].key_count == 1); + assert(strcmp(table.entries[1].handle, "carol") == 0); + assert(table.entries[1].key_count == 1); + assert(strcmp(table.entries[2].handle, "dave") == 0); + assert(table.entries[2].key_count == 0); + assert(identity_table_find(&table, "ALICE") == &table.entries[0]); + assert(identity_table_find(&table, "bob") == NULL); + assert(strcmp(identity_table_handle_for_key(&table, KEY_SHA256), + "alice") == 0); + identity_table_free(&table); +} + +TEST(missing_file_is_empty) { + char path[PATH_MAX]; + identity_table_t table; + + state_path(path, sizeof(path), "missing"); + assert(identity_table_load(&table, path) == 0); + assert(table.count == 0); + identity_table_free(&table); +} + +TEST(adds_moves_and_forgets_keys) { + identity_table_t table = {0}; + + assert(identity_table_add(&table, "alice", KEY_SHA256) == 0); + assert(identity_table_add(&table, "bob", NULL) == 0); + assert(identity_table_add(&table, "Bad", NULL) < 0); + assert(identity_table_add(&table, "bob", "SHA256:x") < 0); + assert(identity_table_add(&table, "bob", KEY_SHA256) == 0); + assert(table.count == 2); + assert(table.entries[0].key_count == 0); + assert(strcmp(identity_table_handle_for_key(&table, KEY_SHA256), + "bob") == 0); + assert(identity_table_forget_key(&table, KEY_SHA256)); + assert(!identity_table_forget_key(&table, KEY_SHA256)); + assert(identity_table_handle_for_key(&table, KEY_SHA256) == NULL); + assert(identity_table_find(&table, "bob") != NULL); + identity_table_free(&table); +} + +TEST(keeps_the_newest_keys) { + identity_table_t table = {0}; + char key[TNT_IDENTITY_FINGERPRINT_SIZE]; + + for (int i = 0; i <= TNT_IDENTITY_MAX_KEYS; i++) { + snprintf(key, sizeof(key), "SHA256:%043d", i); + assert(identity_table_add(&table, "alice", key) == 0); + } + assert(table.entries[0].key_count == TNT_IDENTITY_MAX_KEYS); + snprintf(key, sizeof(key), "SHA256:%043d", 0); + assert(identity_table_handle_for_key(&table, key) == NULL); + snprintf(key, sizeof(key), "SHA256:%043d", TNT_IDENTITY_MAX_KEYS); + assert(identity_table_handle_for_key(&table, key) != NULL); + identity_table_free(&table); +} + +TEST(saves_atomically_with_mode_0600) { + char path[PATH_MAX]; + char text[512]; + identity_table_t table = {0}; + struct stat st; + + state_path(path, sizeof(path), "saved"); + assert(identity_table_add(&table, "alice", KEY_SHA256) == 0); + assert(identity_table_add(&table, "alice", OTHER_SHA256) == 0); + assert(identity_table_add(&table, "bob", NULL) == 0); + assert(identity_table_save(&table, path) == 0); + identity_table_free(&table); + + assert(stat(path, &st) == 0); + assert((st.st_mode & 0777) == 0600); + read_text(path, text, sizeof(text)); + assert(strcmp(text, "alice " KEY_SHA256 " " OTHER_SHA256 "\nbob\n") == 0); +} + +TEST(store_reserves_and_reloads_on_change) { + char path[PATH_MAX]; + char temp[PATH_MAX + 8]; + char handle[TNT_IDENTITY_HANDLE_MAX + 1]; + + state_path(path, sizeof(path), TNT_IDENTITY_FILE); + assert(!identity_is_reserved("alice")); + assert(identity_reserve("alice", KEY_SHA256) == 0); + assert(identity_is_reserved("Alice")); + assert(identity_handle_for_key(KEY_SHA256, handle, sizeof(handle))); + assert(strcmp(handle, "alice") == 0); + + assert(identity_forget_key(KEY_SHA256) == 0); + assert(!identity_handle_for_key(KEY_SHA256, handle, sizeof(handle))); + assert(identity_is_reserved("alice")); + + /* Another process rewrites the file the same way TNT does. */ + snprintf(temp, sizeof(temp), "%s.new", path); + write_text(temp, "carol " OTHER_SHA256 "\n"); + assert(rename(temp, path) == 0); + assert(!identity_is_reserved("alice")); + assert(identity_is_reserved("carol")); + assert(identity_handle_for_key(OTHER_SHA256, handle, sizeof(handle))); +} + +int main(void) { + char template[] = "/tmp/tnt-identity-test.XXXXXX"; + + printf("Running identity unit tests...\n\n"); + assert(mkdtemp(template)); + snprintf(state_dir, sizeof(state_dir), "%s", template); + setenv("TNT_STATE_DIR", state_dir, 1); + + RUN_TEST(validates_handles); + RUN_TEST(validates_fingerprints); + RUN_TEST(formats_fingerprints_like_ssh_keygen); + RUN_TEST(parses_and_skips_invalid_lines); + RUN_TEST(missing_file_is_empty); + RUN_TEST(adds_moves_and_forgets_keys); + RUN_TEST(keeps_the_newest_keys); + RUN_TEST(saves_atomically_with_mode_0600); + RUN_TEST(store_reserves_and_reloads_on_change); + + for (size_t i = 0; i < 3; i++) { + static const char *const files[] = {"parse", "saved", + TNT_IDENTITY_FILE}; + char path[PATH_MAX]; + + state_path(path, sizeof(path), files[i]); + unlink(path); + } + rmdir(state_dir); + printf("\nAll %d identity tests passed.\n", tests_passed); + return 0; +} diff --git a/tests/unit/test_module_protocol.c b/tests/unit/test_module_protocol.c index 973bd5b..323a23a 100644 --- a/tests/unit/test_module_protocol.c +++ b/tests/unit/test_module_protocol.c @@ -36,16 +36,86 @@ TEST(appends_message_created_jsonl_with_escaping) { snprintf(msg.content, sizeof(msg.content), "%s", "hello \"core\""); assert(tnt_module_append_message_created(out, sizeof(out), &pos, - "local-1", &msg) == 0); + "local-1", &msg, false) == 0); assert(strstr(out, "\"type\":\"message.created\"") != NULL); assert(strstr(out, "\"id\":\"local-1\"") != NULL); assert(strstr(out, "\"timestamp\":\"1970-01-01T00:00:00Z\"") != NULL); assert(strstr(out, "\"sender\":\"alice\"") != NULL); assert(strstr(out, "\"kind\":\"text\"") != NULL); assert(strstr(out, "\"plain_text\":\"hello \\\"core\\\"\"") != NULL); + assert(strstr(out, "\"metadata\":{}}}\n") != NULL); assert(out[strlen(out) - 1] == '\n'); } +TEST(appends_verified_metadata) { + char out[512] = ""; + size_t pos = 0; + message_t msg = { + .timestamp = 0, + }; + + snprintf(msg.username, sizeof(msg.username), "%s", "alice"); + snprintf(msg.content, sizeof(msg.content), "%s", "hi"); + assert(tnt_module_append_message_created(out, sizeof(out), &pos, + "local-2", &msg, true) == 0); + assert(strstr(out, "\"metadata\":{\"verified\":true}}}\n") != NULL); +} + +TEST(appends_identity_requested_jsonl) { + char out[256] = ""; + size_t pos = 0; + + assert(tnt_module_append_identity_requested(out, sizeof(out), &pos, + "s-7", "al\"ice") == 0); + assert(strcmp(out, "{\"type\":\"identity.requested\",\"session\":\"s-7\"," + "\"nickname\":\"al\\\"ice\"}\n") == 0); +} + +TEST(parse_identity_records) { + tnt_module_identity_record_t record; + + assert(tnt_module_parse_identity_record( + "{\"type\":\"identity.notice\",\"session\":\"s-1\"," + "\"plain_text\":\"Visit https://auth.m1ng.space\\nCode: ABCD\"}", + &record)); + assert(record.kind == TNT_MODULE_IDENTITY_NOTICE); + assert(strcmp(record.session, "s-1") == 0); + assert(strcmp(record.plain_text, + "Visit https://auth.m1ng.space\nCode: ABCD") == 0); + + assert(tnt_module_parse_identity_record( + "{\"type\":\"identity.verified\",\"session\":\"s-1\"," + "\"handle\":\"alice_2\"}", &record)); + assert(record.kind == TNT_MODULE_IDENTITY_VERIFIED); + assert(strcmp(record.handle, "alice_2") == 0); + + assert(tnt_module_parse_identity_record( + "{\"type\":\"identity.reserve\",\"handle\":\"bob\"}", &record)); + assert(record.kind == TNT_MODULE_IDENTITY_RESERVE); + assert(strcmp(record.handle, "bob") == 0); +} + +TEST(parse_identity_records_rejects_invalid) { + tnt_module_identity_record_t record; + + assert(!tnt_module_parse_identity_record( + "{\"type\":\"identity.verified\",\"session\":\"s-1\"," + "\"handle\":\"Alice\"}", &record)); + assert(!tnt_module_parse_identity_record( + "{\"type\":\"identity.verified\",\"handle\":\"alice\"}", &record)); + assert(!tnt_module_parse_identity_record( + "{\"type\":\"identity.reserve\"," + "\"handle\":\"abcdefghijklmnopqrstu\"}", &record)); + assert(!tnt_module_parse_identity_record( + "{\"type\":\"identity.notice\",\"session\":\"s-1\"," + "\"plain_text\":\"\"}", &record)); + assert(!tnt_module_parse_identity_record( + "{\"type\":\"identity.notice\",\"session\":\"s-1\"," + "\"plain_text\":\"\\u001b[2J\"}", &record)); + assert(!tnt_module_parse_identity_record( + "{\"type\":\"identity.forget\",\"handle\":\"alice\"}", &record)); +} + TEST(parse_message_create_accepts_valid_plain_text) { tnt_module_message_create_t response; @@ -207,6 +277,10 @@ int main(void) { RUN_TEST(appends_handshake_jsonl); RUN_TEST(appends_message_created_jsonl_with_escaping); + RUN_TEST(appends_verified_metadata); + RUN_TEST(appends_identity_requested_jsonl); + RUN_TEST(parse_identity_records); + RUN_TEST(parse_identity_records_rejects_invalid); RUN_TEST(parse_message_create_accepts_valid_plain_text); RUN_TEST(parse_message_create_accepts_utf8_plain_text); RUN_TEST(parse_message_create_rejects_wrong_type); diff --git a/tests/unit/test_module_runtime.c b/tests/unit/test_module_runtime.c index 12a3f6d..ecbaf80 100644 --- a/tests/unit/test_module_runtime.c +++ b/tests/unit/test_module_runtime.c @@ -47,6 +47,18 @@ void notify_mentions(const char *content, const void *sender) { (void)sender; } +bool input_identity_notice(const char *session, const char *plain_text) { + (void)session; + (void)plain_text; + return false; +} + +bool input_identity_verified(const char *session, const char *handle) { + (void)session; + (void)handle; + return false; +} + static void cleanup_module_dir(void) { if (module_dir[0] == '\0') return; @@ -569,7 +581,7 @@ TEST(presence_saturation_logs_outside_the_notifier) { assert(tnt_module_runtime_init() == 0); assert(dup2(log_fd, STDERR_FILENO) >= 0); - tnt_module_runtime_publish_message_created(&msg); + tnt_module_runtime_publish_message_created(&msg, false); sleep_millis(200); for (int i = 0; i < TNT_MODULE_QUEUE_LIMIT + 72; i++) { snprintf(nickname, sizeof(nickname), "user%d", i); diff --git a/tnt-chat.7 b/tnt-chat.7 index 48b2117..f26df20 100644 --- a/tnt-chat.7 +++ b/tnt-chat.7 @@ -36,10 +36,47 @@ The names and the .B module: prefix are reserved for internal messages. +A handle of a signed-in member is reserved too; see +.BR "SIGNING IN" . .P If the administrator configured an access token, enter it as the SSH password. The token is shared and does not authenticate the nickname. +.SH SIGNING IN +The room stays anonymous: anyone can connect and pick any free name. +A member of m1ngsama ID can also prove who they are, and their handle then +belongs to them. +.P +.B :login +starts the sign-in when the server runs an identity module. +The module shows what to do next, usually a link and a code. +Once it confirms the member, the session is renamed to the handle and +shows +.BI "Signed in as @" handle . +A session that was using that name without signing in is renamed to +.BR anonymous . +Several sessions may be signed in to the same handle at once. +.P +When the session authenticated with an SSH public key, signing in also +binds that key to the handle. +A later connection with the same key is signed in at once, without the +nickname prompt. +.B :logout +unbinds the key, and the session becomes +.B anonymous +again. +.P +Nobody else can take a reserved handle, at the nickname prompt or with +.BR :nick , +without regard to ASCII letter case. +The prompt falls back to +.BR anonymous , +and both say: +.P +.EX +@alice belongs to a signed-in member. +Use :login if it is you, or pick another name. +.EE .SH KEYS Typing inserts at the caret. Nothing a user types moves the session into a different keyboard state. @@ -332,6 +369,18 @@ Clear the inbox and the current reply target. .TQ .BI ":name " name Change the current nickname. +A signed-in session must +.B :logout +first, and a handle another member signed in with is refused. +.TP +.B :login +Sign in with m1ngsama ID. +When the server has no identity module, it says that sign-in is not +available. +.TP +.B :logout +Sign out, unbind this session's SSH key, and become +.BR anonymous . .TP .BI ":last " [count] Display up to @@ -417,8 +466,9 @@ Private messages are not written to .IR messages.log , do not appear in public history or exec output, and are lost at disconnect. .P -Public nicknames are labels, not authenticated identities. -Another session may reuse a nickname after its owner disconnects. +A nickname that is not a signed-in handle is a label, not an identity. +Another session may reuse it after its owner disconnects. +A signed-in handle stays reserved after its owner disconnects or signs out. .P The terminal must support the ANSI control sequences used by the interface. .SH EXAMPLES diff --git a/tnt-exec.7 b/tnt-exec.7 index f655b93..010f80e 100644 --- a/tnt-exec.7 +++ b/tnt-exec.7 @@ -166,6 +166,15 @@ otherwise it is .BR anonymous . The displayed name is limited to 20 Unicode characters. .P +A login name that is the handle of a signed-in member, without regard to +ASCII letter case, may post only when the SSH key the connection +authenticated with is bound to that handle; see +.BR tnt\-chat (7). +The message is then posted under the handle. +Otherwise +.B post +fails with status 1 and posts nothing. +.P A message beginning with .B "/me " creates an action. @@ -183,7 +192,8 @@ The command succeeded. .B 1 A runtime, allocation, I/O, or persistence operation failed. .B post -also returns 1 for invalid UTF-8 or a prohibited control character. +also returns 1 for invalid UTF-8, a prohibited control character, or a +reserved login name without its key. .TP .B 64 The command name, argument shape, count, or message length was invalid. @@ -205,7 +215,7 @@ and error wording, is not stable. .SH CAVEATS In anonymous-access mode, the SSH login name used by .B post -is not authenticated. +is not authenticated unless it is a signed-in member's handle. A shared access token authenticates possession of the token. It does not establish ownership of the login name. diff --git a/tnt-module-protocol.7 b/tnt-module-protocol.7 index b9b2307..35dea32 100644 --- a/tnt-module-protocol.7 +++ b/tnt-module-protocol.7 @@ -9,8 +9,8 @@ The supported protocol identifier is .P Modules receive selected public message events and may create public plain-text messages. -Modules the operator grants may also post as a chat user and observe -presence. +Modules the operator grants may also post as a chat user, observe +presence, and sign sessions in. The core chat path continues when an individual module is invalid, slow, stopped, or saturated. .SH ACTIVATION @@ -28,13 +28,18 @@ directory. No command-line arguments are supplied. .P A module whose manifest requests -.B message:post +.BR message:post , +.BR presence:read , or -.B presence:read +.B identity:write is enabled only when its name appears in .BR TNT_MODULE_GRANTS , a colon-separated list of module names. Otherwise TNT reports the refusal on standard error and skips the module. +At most one enabled module holds +.BR identity:write ; +TNT skips every later module that requests it and says so on standard +error. .SH MANIFEST The manifest is a JSON object of at most 4094 bytes. The following fields are required: @@ -64,9 +69,10 @@ Array containing both and .BR message:create . It may also contain -.B message:post +.BR message:post , +.BR presence:read , and -.BR presence:read ; +.BR identity:write ; see .BR "GRANTED PERMISSIONS" . .TP @@ -186,7 +192,19 @@ String The public plain-text content. .TP .B metadata -An object, currently empty. +An object. +It holds +.B verified +set to +.B true +when the sender proved its identity: a signed-in interactive session, an +exec +.B post +under its handle, or a +.B message.post +from the module holding +.BR identity:write . +Otherwise it is empty. .P For example, the following is one JSON Lines record: .P @@ -273,6 +291,10 @@ It must also not be or its Chinese equivalent; the colon restriction already excludes the .BI module: name form TNT reserves for its own messages. +Unless the module also holds +.BR identity:write , +it must not be a reserved handle either; see +.BR identity:write . .B plain_text follows the .B message.create @@ -320,9 +342,9 @@ for the new one: .P Presence records are notifications and must not be answered. They open no response window. -For a module holding either permission, any record other than a valid +For a module holding any granted permission, any record other than a valid .B message.post -that arrives while no +or identity record that arrives while no .B message.created event is waiting is a protocol failure. Around startup a joined record can name a user the snapshot already listed, @@ -339,6 +361,77 @@ dropped on standard error: .EX module runtime: event queue full for NAME, dropped N presence events .EE +.SS identity:write +The module holding +.B identity:write +signs interactive sessions in to m1ngsama ID on TNT's behalf; TNT itself +makes no network requests. +A signed-in session's nickname is its handle, and handles are reserved +in the +.I identities +file described in +.BR tnt (8). +A handle is 1 through 20 ASCII lowercase letters, digits, and underscores. +.P +When a user runs +.BR :login , +TNT sends the module one notification: +.P +.EX +{"type":"identity.requested","session":"s-1","nickname":"bob"} +.EE +.P +.B session +is an opaque string, unique for the life of the server process, that +names the interactive session. +Like presence records, this notification opens no response window and must +not be answered with +.BR event.ok . +.P +The module may write these records at any time, as with +.BR message.post : +.TP +.B identity.notice +.EX +{"type":"identity.notice","session":"s-1",\c +"plain_text":"Open https://auth.m1ng.space/device"} +.EE +.IP +Show +.B plain_text +to that session alone. +It is not logged or broadcast. +.B plain_text +follows the +.B message.create +rules. +TNT ignores a session that is unknown or has closed. +.TP +.B identity.verified +.EX +{"type":"identity.verified","session":"s-1","handle":"alice"} +.EE +.IP +Sign the session in as +.BR handle . +TNT reserves the handle, binds the SSH key the session authenticated with, +if any, to it and to no other handle, renames the session, and announces +the rename to the room. +Another session using the name without signing in is renamed to +.BR anonymous . +TNT ignores a session that is unknown or has closed. +.TP +.B identity.reserve +.EX +{"type":"identity.reserve","handle":"alice"} +.EE +.IP +Reserve +.B handle +without binding a key, for a member who signed in elsewhere. +.P +An identity record with a missing or invalid member, or an invalid handle, +is a protocol failure like any other invalid response. .SH FAILURE HANDLING A handshake failure skips the affected module. After activation, a write failure, write timeout, read failure, response @@ -414,8 +507,12 @@ modules. .P A module holding .B message:post -can post under any valid nickname, including one an SSH user is using. -Grant it only to a module you trust as much as the server. +can post under any valid nickname, including one an SSH user is using, +except a reserved handle. +A module holding +.B identity:write +can post under, and sign any session in as, any handle. +Grant either only to a module you trust as much as the server. .P The child inherits most of the server environment. TNT removes diff --git a/tnt.8 b/tnt.8 index 2c02816..79c1c3e 100644 --- a/tnt.8 +++ b/tnt.8 @@ -208,9 +208,19 @@ The default is .TP .B TNT_ACCESS_TOKEN Require this shared token as the SSH password. -When it is unset or empty, passwordless, password, and signed public-key -authentication are accepted. -When it is nonempty, +When it is unset or empty, any signed public key, any password, and +keyboard-interactive authentication that asks no questions are accepted. +The +.B none +method is refused so that a client offers its public key, which can sign a +member in; see +.BR tnt\-chat (7). +A client without a key still gets in without typing anything, unless +.BR ssh (1) +runs with +.BR BatchMode , +which disables keyboard-interactive and password authentication. +When the token is nonempty, passwordless and public-key authentication are rejected. Only the first 255 bytes are effective. .TP @@ -262,9 +272,10 @@ See .TP .B TNT_MODULE_GRANTS Colon-separated module names allowed to use the -.B message:post +.BR message:post , +.BR presence:read , and -.B presence:read +.B identity:write permissions. A granted module can post under any valid nickname. See @@ -303,6 +314,22 @@ See .I messages.log.1 The previous log produced by automatic rotation. .TP +.I identities +Handles reserved by signed-in members, created with mode 0600 and replaced +atomically. +Each line holds one handle followed by zero or more space-separated SSH key +fingerprints in the form +.B ssh\-keygen \-l +prints: +.IP +.EX +alice SHA256:UMSdY3XS0sbuWW4MC+rV6SEa0OanWxNxnFV35VM5Sng +.EE +.IP +A handle is 1 through 20 ASCII lowercase letters, digits, and underscores; +invalid lines are skipped. +TNT rereads the file when it changes. +.TP .I motd.txt Optional text displayed before the chat screen for each new interactive session. From 0540f37e930476e6e379250d8d1d706ceff36e38 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 14 Oct 2026 15:20:44 +0800 Subject: [PATCH 4/6] chore(release): prepare v1.5.0 --- include/common.h | 2 +- packaging/arch/.SRCINFO | 6 +++--- packaging/arch/PKGBUILD | 4 ++-- packaging/debian/debian/changelog | 11 +++++++++++ packaging/homebrew/tnt-chat.rb | 4 ++-- tnt-chat.7 | 2 +- tnt-exec.7 | 2 +- tnt-message-log.5 | 2 +- tnt-module-protocol.7 | 2 +- tnt.8 | 2 +- tntctl.1 | 2 +- 11 files changed, 25 insertions(+), 14 deletions(-) diff --git a/include/common.h b/include/common.h index fbae1ab..b51f0ec 100644 --- a/include/common.h +++ b/include/common.h @@ -14,7 +14,7 @@ #include "config_defaults.h" /* Project Metadata */ -#define TNT_VERSION "1.4.0" +#define TNT_VERSION "1.5.0" /* Public process/exec exit statuses. TNT follows the common sysexits(3) * convention for usage errors while keeping runtime failures portable. */ diff --git a/packaging/arch/.SRCINFO b/packaging/arch/.SRCINFO index 6d84e26..b255c14 100644 --- a/packaging/arch/.SRCINFO +++ b/packaging/arch/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = tnt-chat pkgdesc = SSH-native terminal chat server with a Vim-style interface - pkgver = 1.4.0 + pkgver = 1.5.0 pkgrel = 1 url = https://github.com/m1ngsama/TNT arch = x86_64 @@ -10,9 +10,9 @@ pkgbase = tnt-chat makedepends = make depends = libssh depends = openssh - source = tnt-chat-v1.4.0-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.4.0/tnt-chat-v1.4.0-source.tar.gz + source = tnt-chat-v1.5.0-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.5.0/tnt-chat-v1.5.0-source.tar.gz source = tnt-chat.sysusers - sha256sums = 9724bf0ff4e22aab11dd12c46db52d14ea2ffe7bfbaa47091f5ed5e2f0c3aa5b + sha256sums = SKIP sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed pkgname = tnt-chat diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index abe88b1..9107d0d 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: M1ng pkgname=tnt-chat -pkgver=1.4.0 +pkgver=1.5.0 pkgrel=1 pkgdesc='SSH-native terminal chat server with a Vim-style interface' arch=('x86_64' 'aarch64') @@ -11,7 +11,7 @@ depends=('libssh' 'openssh') makedepends=('gcc' 'make') source=("${pkgname}-v${pkgver}-source.tar.gz::${url}/releases/download/v${pkgver}/${pkgname}-v${pkgver}-source.tar.gz" "${pkgname}.sysusers") -sha256sums=('9724bf0ff4e22aab11dd12c46db52d14ea2ffe7bfbaa47091f5ed5e2f0c3aa5b' +sha256sums=('SKIP' '8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed') build() { diff --git a/packaging/debian/debian/changelog b/packaging/debian/debian/changelog index df850bb..0b36c7c 100644 --- a/packaging/debian/debian/changelog +++ b/packaging/debian/debian/changelog @@ -1,3 +1,14 @@ +tnt-chat (1.5.0-1) unstable; urgency=medium + + * Let members of m1ngsama ID sign in with :login and :logout, reserve + their handles, and sign in again by SSH key. + * Add the identity:write module permission and mark verified senders in + message.created metadata. + * Refuse the SSH "none" method so clients offer their public key; + keyless clients sign in through keyboard-interactive with no prompts. + + -- M1ng Wed, 14 Oct 2026 00:00:00 +0800 + tnt-chat (1.4.0-1) unstable; urgency=medium * Add a WebSocket gateway module that lets signed-in clients read and diff --git a/packaging/homebrew/tnt-chat.rb b/packaging/homebrew/tnt-chat.rb index 0e0126b..d7ff877 100644 --- a/packaging/homebrew/tnt-chat.rb +++ b/packaging/homebrew/tnt-chat.rb @@ -1,8 +1,8 @@ class TntChat < Formula desc "SSH-native terminal chat server with a Vim-style interface" homepage "https://github.com/m1ngsama/TNT" - url "https://github.com/m1ngsama/TNT/releases/download/v1.4.0/tnt-chat-v1.4.0-source.tar.gz" - sha256 "9724bf0ff4e22aab11dd12c46db52d14ea2ffe7bfbaa47091f5ed5e2f0c3aa5b" + url "https://github.com/m1ngsama/TNT/releases/download/v1.5.0/tnt-chat-v1.5.0-source.tar.gz" + sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256" license "MIT" depends_on "libssh" diff --git a/tnt-chat.7 b/tnt-chat.7 index f26df20..b380f87 100644 --- a/tnt-chat.7 +++ b/tnt-chat.7 @@ -1,4 +1,4 @@ -.TH TNT-CHAT 7 "2026-09-22" "TNT 1.4.0" +.TH TNT-CHAT 7 "2026-10-14" "TNT 1.5.0" .SH NAME tnt-chat \- use the TNT interactive terminal interface .SH DESCRIPTION diff --git a/tnt-exec.7 b/tnt-exec.7 index 010f80e..6d7fc75 100644 --- a/tnt-exec.7 +++ b/tnt-exec.7 @@ -1,4 +1,4 @@ -.TH TNT-EXEC 7 "2026-09-22" "TNT 1.4.0" +.TH TNT-EXEC 7 "2026-10-14" "TNT 1.5.0" .SH NAME tnt-exec \- TNT SSH exec command and output protocol .SH DESCRIPTION diff --git a/tnt-message-log.5 b/tnt-message-log.5 index fd751af..d555a2e 100644 --- a/tnt-message-log.5 +++ b/tnt-message-log.5 @@ -1,4 +1,4 @@ -.TH TNT-MESSAGE-LOG 5 "2026-09-22" "TNT 1.4.0" +.TH TNT-MESSAGE-LOG 5 "2026-10-14" "TNT 1.5.0" .SH NAME tnt-message-log \- TNT public message log format .SH DESCRIPTION diff --git a/tnt-module-protocol.7 b/tnt-module-protocol.7 index 35dea32..2800d71 100644 --- a/tnt-module-protocol.7 +++ b/tnt-module-protocol.7 @@ -1,4 +1,4 @@ -.TH TNT-MODULE-PROTOCOL 7 "2026-09-22" "TNT 1.4.0" +.TH TNT-MODULE-PROTOCOL 7 "2026-10-14" "TNT 1.5.0" .SH NAME tnt-module-protocol \- implement an external TNT message module .SH DESCRIPTION diff --git a/tnt.8 b/tnt.8 index 79c1c3e..baae43d 100644 --- a/tnt.8 +++ b/tnt.8 @@ -1,4 +1,4 @@ -.TH TNT 8 "2026-09-22" "TNT 1.4.0" +.TH TNT 8 "2026-10-14" "TNT 1.5.0" .SH NAME tnt \- run the TNT SSH chat server .SH SYNOPSIS diff --git a/tntctl.1 b/tntctl.1 index cccd8f2..72e0d81 100644 --- a/tntctl.1 +++ b/tntctl.1 @@ -1,4 +1,4 @@ -.TH TNTCTL 1 "2026-09-22" "TNT 1.4.0" +.TH TNTCTL 1 "2026-10-14" "TNT 1.5.0" .SH NAME tntctl \- run TNT exec commands through SSH .SH SYNOPSIS From 0c92465f99e9e0b7dc3ef1cbc693722bab7815d0 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 14 Oct 2026 15:41:31 +0800 Subject: [PATCH 5/6] fix(auth): refuse the none method only for a reserved login name Accept passwordless SSH again unless the login name is a signed-in member's handle, so anonymous and keyless BatchMode clients connect as before. A member is recognised by key when connecting as ssh HANDLE@host, and a keyless sign-in now says how to bind a key. A blank, invalid, or reserved nickname at the prompt now joins as the first free anonymousN instead of disconnecting when anonymous is taken. --- include/i18n.h | 1 + include/ssh_server.h | 4 +++ packaging/debian/debian/changelog | 5 +-- src/bootstrap.c | 18 +++++++--- src/i18n_text.c | 8 +++++ src/input.c | 16 ++++++++- src/ssh_server.c | 16 ++++++--- tests/test_identity.sh | 56 ++++++++++++++++++++++++++++--- tnt-chat.7 | 31 +++++++++++++---- tnt.8 | 14 ++++---- 10 files changed, 138 insertions(+), 31 deletions(-) diff --git a/include/i18n.h b/include/i18n.h index ee5a41c..2ccb1c9 100644 --- a/include/i18n.h +++ b/include/i18n.h @@ -66,6 +66,7 @@ typedef enum { I18N_LOGIN_OPENING, I18N_LOGIN_ALREADY_FORMAT, I18N_SIGNED_IN_FORMAT, + I18N_SIGNED_IN_KEY_HINT_FORMAT, I18N_LOGOUT_NOT_SIGNED_IN, I18N_LOGOUT_DONE_FORMAT, I18N_LAST_HEADER_FORMAT, diff --git a/include/ssh_server.h b/include/ssh_server.h index 8551dfd..e6e2c44 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -121,4 +121,8 @@ int ssh_server_start(int shutdown_fd, /* Read-only accessor for the server start time (used by exec stats). */ time_t ssh_server_start_time(void); +/* The listening port and the host name shown to users in connection hints. */ +int ssh_server_port(void); +const char *ssh_server_public_host(void); + #endif /* SSH_SERVER_H */ diff --git a/packaging/debian/debian/changelog b/packaging/debian/debian/changelog index 0b36c7c..e4b5a19 100644 --- a/packaging/debian/debian/changelog +++ b/packaging/debian/debian/changelog @@ -4,8 +4,9 @@ tnt-chat (1.5.0-1) unstable; urgency=medium their handles, and sign in again by SSH key. * Add the identity:write module permission and mark verified senders in message.created metadata. - * Refuse the SSH "none" method so clients offer their public key; - keyless clients sign in through keyboard-interactive with no prompts. + * Recognise a member by SSH key when the login name is their handle. + * Join a blank or fallback nickname as the first free anonymousN instead + of disconnecting when anonymous is taken. -- M1ng Wed, 14 Oct 2026 00:00:00 +0800 diff --git a/src/bootstrap.c b/src/bootstrap.c index 6e56347..1f379cf 100644 --- a/src/bootstrap.c +++ b/src/bootstrap.c @@ -240,11 +240,19 @@ static int auth_none(ssh_session session, const char *user, void *userdata) { ctx->requested_user[sizeof(ctx->requested_user) - 1] = '\0'; } - /* Accepting "none" would end authentication before the client offers - * its public key, and a signed key is what an identity is bound to. - * Clients without a key fall through to keyboard-interactive, which - * asks nothing. */ - return SSH_AUTH_DENIED; + if (g_access_token[0] != '\0') { + return SSH_AUTH_DENIED; + } + /* Accepting "none" ends authentication before the client offers its + * public key, so a login name that is a reserved handle has to refuse + * it for the key to be seen. A keyless client still gets in through + * keyboard-interactive, which asks nothing. */ + if (identity_is_reserved(ctx->requested_user)) { + return SSH_AUTH_DENIED; + } + ctx->auth_success = true; + ratelimit_record_auth_success(ctx->client_ip); + return SSH_AUTH_SUCCESS; } /* Keyboard-interactive with zero prompts keeps keyless access passwordless. */ diff --git a/src/i18n_text.c b/src/i18n_text.c index 9217f5e..844b4ca 100644 --- a/src/i18n_text.c +++ b/src/i18n_text.c @@ -209,6 +209,14 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = { "Signed in as @%s.\n", "已登录为 @%s。\n" ), + [I18N_SIGNED_IN_KEY_HINT_FORMAT] = I18N_STRING( + "To be recognised by your SSH key next time, connect with\n" + " ssh %s@%s -p %d\n" + "and run :login once more.\n", + "想下次凭 SSH 密钥自动识别,请用你的密钥这样连接:\n" + " ssh %s@%s -p %d\n" + "然后再运行一次 :login。\n" + ), [I18N_LOGOUT_NOT_SIGNED_IN] = I18N_STRING( "You are not signed in.\n", "你还没有登录。\n" diff --git a/src/input.c b/src/input.c index 6ea8dd9..887c913 100644 --- a/src/input.c +++ b/src/input.c @@ -372,6 +372,13 @@ static void apply_identity_mailbox(client_t *client) { buffer_appendf(output, sizeof(output), &pos, i18n_text(client->ui_lang, I18N_SIGNED_IN_FORMAT), handle); + if (client->key_fingerprint[0] == '\0') { + buffer_appendf(output, sizeof(output), &pos, + i18n_text(client->ui_lang, + I18N_SIGNED_IN_KEY_HINT_FORMAT), + handle, ssh_server_public_host(), + ssh_server_port()); + } } else if (evict && !client->verified && identity_is_reserved(client->username)) { buffer_appendf(output, sizeof(output), &pos, @@ -1584,8 +1591,15 @@ void input_run_session(client_t *client) { snprintf(client->session_id, sizeof(client->session_id), "s-%llu", atomic_fetch_add(&g_next_session_id, 1) + 1); - /* Add to room */ + /* Add to room. Nobody asked for a particular anonymous name, so a + * taken one moves on to the next free anonymousN. */ + bool anonymous_name = strcmp(client->username, "anonymous") == 0; int join_rc = room_add_client(g_room, client); + for (int n = 2; join_rc == -2 && anonymous_name && n < 1000; n++) { + snprintf(client->username, sizeof(client->username), "anonymous%d", + n); + join_rc = room_add_client(g_room, client); + } if (join_rc < 0) { if (join_rc == -2) { client_printf(client, diff --git a/src/ssh_server.c b/src/ssh_server.c index f5d1885..bac709a 100644 --- a/src/ssh_server.c +++ b/src/ssh_server.c @@ -52,6 +52,16 @@ time_t ssh_server_start_time(void) { return g_server_start_time; } +int ssh_server_port(void) { + return g_listen_port; +} + +const char *ssh_server_public_host(void) { + const char *public_host = getenv("TNT_PUBLIC_HOST"); + + return public_host && public_host[0] != '\0' ? public_host : "localhost"; +} + static void session_worker_register(session_worker_t *worker) { pthread_mutex_lock(&g_session_lock); worker->next = g_session_workers; @@ -316,7 +326,7 @@ int ssh_server_init(int port) { /* Start SSH server (blocking) */ int ssh_server_start(int shutdown_fd, const volatile sig_atomic_t *shutdown_requested) { - const char *public_host = getenv("TNT_PUBLIC_HOST"); + const char *public_host = ssh_server_public_host(); pthread_attr_t attr; int result = 0; int listen_fd; @@ -331,10 +341,6 @@ int ssh_server_start(int shutdown_fd, return -1; } - if (!public_host || public_host[0] == '\0') { - public_host = "localhost"; - } - printf("TNT chat server listening on port %d (SSH)\n", g_listen_port); printf("Connect with: ssh -p %d %s\n", g_listen_port, public_host); fflush(stdout); diff --git a/tests/test_identity.sh b/tests/test_identity.sh index 058eeca..867405f 100755 --- a/tests/test_identity.sh +++ b/tests/test_identity.sh @@ -30,14 +30,14 @@ SSH_KEY="$(tnt_ssh_opts "$PORT") -o IdentitiesOnly=yes -i $KEY" SSH_EXEC="ssh -n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o BatchMode=yes -o LogLevel=ERROR -o IdentitiesOnly=yes -p $PORT" -# Run expect body $2 as session $1. $3 holds the ssh options; the body -# starts right after the connection opens. +# Run expect body $2 as session $1. $3 holds the ssh options and $4 the +# login name; the body starts right after the connection opens. run_session() { _script="$STATE_DIR/$1.expect" cat >"$_script" <&1)" = "ok" ]; then + pass "a keyless BatchMode client still connects" +else + fail "a keyless BatchMode client was refused" +fi + +cat >"$STATE_DIR/anon.expect" <"$STATE_DIR/anon.session.log" 2>&1 && + tnt_wait_for_fixed "$LOG" "|system|anonymous2 joined the room"; then + pass "a second blank nickname joins as anonymous2" +else + fail "a second blank nickname did not join" + tnt_dump_session anon +fi + kill "$SERVER_PID" 2>/dev/null wait "$SERVER_PID" 2>/dev/null SERVER_PID="" @@ -173,6 +205,7 @@ want \$b "/help" send -i \$b -- "/login\r" want \$b "TEST-CODE" want \$b "Signed in as @alice." +want \$b "ssh alice@localhost -p $PORT" want \$a "belongs to a signed-in member" @@ -266,7 +299,7 @@ expect { "Signed in as @alice." {} timeout { exit 1 } } -' "$SSH_KEY" && +' "$SSH_KEY" alice && tnt_wait_for_fixed "$IDENTITIES" "alice $FINGERPRINT"; then pass "signing in over a public key binds the key to the handle" else @@ -275,6 +308,19 @@ else cat "$IDENTITIES" fi +if run_session otherlogin ' +expect { + "): " {} + "Signed in as" { exit 1 } + timeout { exit 1 } +} +' "$SSH_KEY"; then + pass "a bound key is recognised only under the handle as login name" +else + fail "a bound key was recognised under another login name" + tnt_dump_session otherlogin +fi + POST_OUTPUT=$($SSH_EXEC -i "$KEY" alice@127.0.0.1 post "from my key" 2>&1) POST_STATUS=$? if [ "$POST_STATUS" -eq 0 ] && [ "$POST_OUTPUT" = "posted" ] && @@ -308,7 +354,7 @@ expect { "Signed out. Your name is now" {} timeout { exit 1 } } -' "$SSH_KEY"; then +' "$SSH_KEY" alice; then pass "a bound key signs in on reconnect without the prompt" else fail "automatic sign-in by key" diff --git a/tnt-chat.7 b/tnt-chat.7 index b380f87..7d33af0 100644 --- a/tnt-chat.7 +++ b/tnt-chat.7 @@ -25,7 +25,11 @@ It does not select the interactive nickname. TNT prompts for a nickname after login. An empty or invalid nickname becomes -.BR anonymous . +.BR anonymous , +or the first free +.BR anonymous2 , +.BR anonymous3 , +and so on when that is taken. Nicknames are limited to 20 Unicode characters. They must be unique among active sessions without regard to ASCII letter case. @@ -57,10 +61,22 @@ A session that was using that name without signing in is renamed to .BR anonymous . Several sessions may be signed in to the same handle at once. .P -When the session authenticated with an SSH public key, signing in also -binds that key to the handle. -A later connection with the same key is signed in at once, without the -nickname prompt. +TNT recognises a member by SSH key only when the SSH login name is their +handle: +.P +.EX +ssh \-p 2222 alice@chat.example.com +.EE +.P +Signing in from such a connection binds the key it authenticated with to +the handle. +From then on, connecting the same way with the same key signs the session +in at once, without the nickname prompt. +After signing in from a connection without a key, TNT says how to connect +next time; run +.B :login +once more from that connection to bind the key. +Under any other login name, nothing about the key is used. .B :logout unbinds the key, and the session becomes .B anonymous @@ -71,7 +87,10 @@ Nobody else can take a reserved handle, at the nickname prompt or with without regard to ASCII letter case. The prompt falls back to .BR anonymous , -and both say: +or to the first free +.BR anonymous2 , +.BR anonymous3 , +and so on, and both say: .P .EX @alice belongs to a signed-in member. diff --git a/tnt.8 b/tnt.8 index baae43d..fc4364f 100644 --- a/tnt.8 +++ b/tnt.8 @@ -208,18 +208,18 @@ The default is .TP .B TNT_ACCESS_TOKEN Require this shared token as the SSH password. -When it is unset or empty, any signed public key, any password, and +When it is unset or empty, passwordless, password, signed public-key, and keyboard-interactive authentication that asks no questions are accepted. -The -.B none -method is refused so that a client offers its public key, which can sign a -member in; see +When the SSH login name is a signed-in member's handle, without regard to +ASCII letter case, passwordless authentication is refused so that the client +offers its public key; see .BR tnt\-chat (7). -A client without a key still gets in without typing anything, unless +A client without a key then gets in through keyboard-interactive or +password authentication, unless .BR ssh (1) runs with .BR BatchMode , -which disables keyboard-interactive and password authentication. +which disables both. When the token is nonempty, passwordless and public-key authentication are rejected. Only the first 255 bytes are effective. From bea6e3f9c2447f5def81f5b32018bde3443aad8b Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Wed, 14 Oct 2026 16:54:25 +0800 Subject: [PATCH 6/6] fix(tui): let a notice close on the first key without eating it The sign-in notice opens on every recognised connection. As a pager it swallowed whatever the person started typing until they pressed q. --- include/ssh_server.h | 1 + src/input.c | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/include/ssh_server.h b/include/ssh_server.h index e6e2c44..c24d891 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -26,6 +26,7 @@ typedef struct { typedef enum { TNT_COMMAND_OUTPUT_NONE, TNT_COMMAND_OUTPUT_GENERIC, + TNT_COMMAND_OUTPUT_NOTICE, TNT_COMMAND_OUTPUT_INBOX } tnt_command_output_kind_t; diff --git a/src/input.c b/src/input.c index 887c913..3983ae1 100644 --- a/src/input.c +++ b/src/input.c @@ -342,7 +342,7 @@ static void show_session_notice(client_t *client, const char *text) { snprintf(client->command_output, sizeof(client->command_output), "%s", text); client->command_output_scroll = 0; - client->command_output_kind = TNT_COMMAND_OUTPUT_GENERIC; + client->command_output_kind = TNT_COMMAND_OUTPUT_NOTICE; client->show_motd = false; client->redraw_pending = true; } @@ -845,6 +845,20 @@ static bool handle_key(client_t *client, unsigned char key, editor_t *ed, /* Handle command output / MOTD display. MOTD remains a simple notice; * command output behaves like a small pager so long results can be read. */ + /* A notice can arrive at any moment, from a module or on entry, so it + * must not swallow what someone is already typing: any key but q or + * Esc closes it and then does its usual job. */ + if (client->command_output[0] != '\0' && + client->command_output_kind == TNT_COMMAND_OUTPUT_NOTICE && + !client->show_motd) { + client_mode_t mode = client->mode; + dismiss_command_output(client); + client->mode = mode; + if (key == 'q' || key == 27) { + return true; + } + } + if (client->command_output[0] != '\0') { pager_action_t action;