1274 lines
53 KiB
C
1274 lines
53 KiB
C
/*
|
|
* login_dialog.c — GTK Nostr login dialog for sovereign_browser
|
|
*
|
|
* All login methods implemented:
|
|
* - Local key (paste nsec or generate new)
|
|
* - Seed phrase (BIP-39 mnemonic entry or generation)
|
|
* - Read-only (npub only, no signing)
|
|
* - NIP-46 remote signer (bunker:// URL)
|
|
* - n_signer hardware (serial / unix / tcp / qrexec transport)
|
|
*/
|
|
|
|
#include "login_dialog.h"
|
|
#include "key_store.h"
|
|
#include "agent_login.h"
|
|
#include "version.h"
|
|
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
|
|
#include "nostr_core/nostr_core.h"
|
|
#include "nostr_core/nip006.h"
|
|
#include "nostr_core/nip019.h"
|
|
#include "nostr_core/nip046.h"
|
|
#include "nostr_core/nsigner_transport.h"
|
|
#include "nostr_core/utils.h"
|
|
#include <signal.h>
|
|
|
|
/* ── Helpers ──────────────────────────────────────────────────── */
|
|
|
|
/* Convert a 32-byte private key to hex pubkey via nostr_core_lib. */
|
|
static int derive_pubkey(const unsigned char privkey[32], char pubkey_hex[65]) {
|
|
unsigned char pubkey[32];
|
|
if (nostr_ec_public_key_from_private_key(privkey, pubkey) != 0) {
|
|
return -1;
|
|
}
|
|
for (int i = 0; i < 32; i++) {
|
|
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
|
|
}
|
|
pubkey_hex[64] = '\0';
|
|
return 0;
|
|
}
|
|
|
|
/* Convert hex string to 32-byte array. */
|
|
static int hex_to_bytes32(const char *hex, unsigned char out[32]) {
|
|
if (strlen(hex) != 64) return -1;
|
|
for (int i = 0; i < 32; i++) {
|
|
unsigned int b;
|
|
if (sscanf(hex + 2 * i, "%2x", &b) != 1) return -1;
|
|
out[i] = (unsigned char)b;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* Convert 32-byte pubkey to npub bech32 for display. */
|
|
static void pubkey_to_npub(const char pubkey_hex[65], char npub[128]) {
|
|
unsigned char pubkey[32];
|
|
if (hex_to_bytes32(pubkey_hex, pubkey) != 0) {
|
|
npub[0] = '\0';
|
|
return;
|
|
}
|
|
if (nostr_key_to_bech32(pubkey, "npub", npub) != 0) {
|
|
npub[0] = '\0';
|
|
}
|
|
}
|
|
|
|
/* Store privkey as hex string. */
|
|
static void privkey_to_hex(const unsigned char privkey[32], char hex[65]) {
|
|
for (int i = 0; i < 32; i++) {
|
|
snprintf(hex + i * 2, 3, "%02x", privkey[i]);
|
|
}
|
|
hex[64] = '\0';
|
|
}
|
|
|
|
/* ── Dialog state ─────────────────────────────────────────────── */
|
|
|
|
typedef struct {
|
|
GtkWidget *dialog;
|
|
GtkWidget *content_area;
|
|
GtkWidget *notebook; /* GtkNotebook for method tabs */
|
|
GtkWidget *status_label; /* error/status display */
|
|
login_result_t *result; /* output */
|
|
gboolean done; /* dialog completed */
|
|
gboolean no_login; /* user chose "No Login" (browse without identity) */
|
|
} login_ctx_t;
|
|
|
|
/* Helper: get the method name of the currently selected tab. */
|
|
static const char *get_current_method(login_ctx_t *ctx) {
|
|
gint page = gtk_notebook_get_current_page(GTK_NOTEBOOK(ctx->notebook));
|
|
if (page < 0) return NULL;
|
|
GtkWidget *child = gtk_notebook_get_nth_page(GTK_NOTEBOOK(ctx->notebook), page);
|
|
if (child == NULL) return NULL;
|
|
return (const char *)g_object_get_data(G_OBJECT(child), "method-name");
|
|
}
|
|
|
|
/* Helper: get the page widget for a given method name. */
|
|
static GtkWidget *get_method_page(login_ctx_t *ctx, const char *method) {
|
|
gint n = gtk_notebook_get_n_pages(GTK_NOTEBOOK(ctx->notebook));
|
|
for (gint i = 0; i < n; i++) {
|
|
GtkWidget *child = gtk_notebook_get_nth_page(GTK_NOTEBOOK(ctx->notebook), i);
|
|
const char *name = (const char *)g_object_get_data(G_OBJECT(child), "method-name");
|
|
if (name && strcmp(name, method) == 0) {
|
|
return child;
|
|
}
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
/* ── Forward declarations ─────────────────────────────────────── */
|
|
|
|
static GtkWidget *create_local_screen(login_ctx_t *ctx);
|
|
static GtkWidget *create_seed_screen(login_ctx_t *ctx);
|
|
static GtkWidget *create_readonly_screen(login_ctx_t *ctx);
|
|
static GtkWidget *create_nip46_screen(login_ctx_t *ctx);
|
|
static GtkWidget *create_nsigner_screen(login_ctx_t *ctx);
|
|
static void on_index_changed(GtkWidget *spin, gpointer user_data);
|
|
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
|
|
static void on_detect_serial(GtkWidget *btn, gpointer user_data);
|
|
#endif
|
|
|
|
/* ── Callbacks ────────────────────────────────────────────────── */
|
|
|
|
static void on_generate_key(GtkWidget *btn, gpointer user_data) {
|
|
(void)btn;
|
|
GtkWidget *entry = GTK_WIDGET(user_data);
|
|
|
|
unsigned char privkey[32], pubkey[32];
|
|
if (nostr_generate_keypair(privkey, pubkey) != 0) {
|
|
gtk_entry_set_text(GTK_ENTRY(entry), "(generation failed)");
|
|
return;
|
|
}
|
|
|
|
char nsec[128];
|
|
if (nostr_key_to_bech32(privkey, "nsec", nsec) != 0) {
|
|
gtk_entry_set_text(GTK_ENTRY(entry), "(bech32 failed)");
|
|
return;
|
|
}
|
|
gtk_entry_set_text(GTK_ENTRY(entry), nsec);
|
|
}
|
|
|
|
#define SEED_WORD_COUNT 12
|
|
|
|
static void on_generate_mnemonic(GtkWidget *btn, gpointer user_data) {
|
|
(void)btn;
|
|
GtkWidget *box = GTK_WIDGET(user_data);
|
|
|
|
char mnemonic[256] = {0};
|
|
unsigned char privkey[32], pubkey[32];
|
|
if (nostr_generate_mnemonic_and_keys(mnemonic, sizeof(mnemonic), 0,
|
|
privkey, pubkey) != 0) {
|
|
return;
|
|
}
|
|
|
|
/* Split the mnemonic into words and fill the 12 entry boxes. */
|
|
char *words[SEED_WORD_COUNT];
|
|
int n = 0;
|
|
char *tok = strtok(mnemonic, " ");
|
|
while (tok != NULL && n < SEED_WORD_COUNT) {
|
|
words[n++] = tok;
|
|
tok = strtok(NULL, " ");
|
|
}
|
|
|
|
for (int i = 0; i < n && i < SEED_WORD_COUNT; i++) {
|
|
char key[16];
|
|
snprintf(key, sizeof(key), "word-%d", i);
|
|
GtkWidget *entry = g_object_get_data(G_OBJECT(box), key);
|
|
if (entry) {
|
|
gtk_entry_set_text(GTK_ENTRY(entry), words[i]);
|
|
}
|
|
}
|
|
|
|
/* Focus the first word box. */
|
|
GtkWidget *first = g_object_get_data(G_OBJECT(box), "word-0");
|
|
if (first) {
|
|
gtk_widget_grab_focus(first);
|
|
}
|
|
}
|
|
|
|
/* ── Login handler ────────────────────────────────────────────── */
|
|
|
|
static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
|
|
login_ctx_t *ctx = (login_ctx_t *)user_data;
|
|
(void)btn;
|
|
|
|
const char *current = get_current_method(ctx);
|
|
if (current == NULL) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label), "No method selected.");
|
|
return;
|
|
}
|
|
|
|
memset(ctx->result, 0, sizeof(*ctx->result));
|
|
|
|
/* ── Local key ─────────────────────────────────────────── */
|
|
if (strcmp(current, "local") == 0) {
|
|
GtkWidget *screen = get_method_page(ctx, "local");
|
|
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), "nsec-entry");
|
|
const char *input = gtk_entry_get_text(GTK_ENTRY(entry));
|
|
|
|
if (input[0] == '\0') {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Enter an nsec or click Generate.");
|
|
return;
|
|
}
|
|
|
|
unsigned char privkey[32];
|
|
if (strncmp(input, "nsec1", 5) == 0) {
|
|
if (nostr_decode_nsec(input, privkey) != 0) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label), "Invalid nsec.");
|
|
return;
|
|
}
|
|
} else if (strlen(input) == 64) {
|
|
if (hex_to_bytes32(input, privkey) != 0) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Invalid hex private key.");
|
|
return;
|
|
}
|
|
} else {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Enter an nsec1... or 64-char hex key.");
|
|
return;
|
|
}
|
|
|
|
char pubkey_hex[65];
|
|
if (derive_pubkey(privkey, pubkey_hex) != 0) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Failed to derive public key.");
|
|
return;
|
|
}
|
|
|
|
nostr_signer_t *signer = nostr_signer_local(privkey);
|
|
if (signer == NULL) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Failed to create signer.");
|
|
return;
|
|
}
|
|
|
|
ctx->result->method = KEY_STORE_METHOD_LOCAL;
|
|
ctx->result->signer = signer;
|
|
memcpy(ctx->result->pubkey_hex, pubkey_hex, 64);
|
|
ctx->result->pubkey_hex[64] = '\0';
|
|
|
|
ctx->result->identity.method = KEY_STORE_METHOD_LOCAL;
|
|
memcpy(ctx->result->identity.pubkey_hex, pubkey_hex, 64);
|
|
ctx->result->identity.pubkey_hex[64] = '\0';
|
|
privkey_to_hex(privkey, ctx->result->identity.privkey_hex);
|
|
|
|
ctx->done = TRUE;
|
|
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
|
|
return;
|
|
}
|
|
|
|
/* ── Seed phrase ───────────────────────────────────────── */
|
|
if (strcmp(current, "seed") == 0) {
|
|
GtkWidget *screen = get_method_page(ctx, "seed");
|
|
GtkWidget *acct_spin = g_object_get_data(G_OBJECT(screen), "account-spin");
|
|
int account = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(acct_spin));
|
|
|
|
/* Concatenate the 12 word entries into a mnemonic string. */
|
|
char mnemonic[512] = {0};
|
|
int words_filled = 0;
|
|
for (int i = 0; i < SEED_WORD_COUNT; i++) {
|
|
char key[16];
|
|
snprintf(key, sizeof(key), "word-%d", i);
|
|
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), key);
|
|
if (entry) {
|
|
const char *word = gtk_entry_get_text(GTK_ENTRY(entry));
|
|
if (word && word[0]) {
|
|
if (words_filled > 0) strcat(mnemonic, " ");
|
|
strcat(mnemonic, word);
|
|
words_filled++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (words_filled == 0) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Enter your seed phrase or click Generate.");
|
|
return;
|
|
}
|
|
if (words_filled != SEED_WORD_COUNT) {
|
|
char msg[64];
|
|
snprintf(msg, sizeof(msg),
|
|
"Expected 12 words, got %d.", words_filled);
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label), msg);
|
|
return;
|
|
}
|
|
|
|
unsigned char privkey[32], pubkey[32];
|
|
if (nostr_derive_keys_from_mnemonic(mnemonic, account, privkey, pubkey) != 0) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Invalid seed phrase. Check the words and try again.");
|
|
return;
|
|
}
|
|
|
|
char pubkey_hex[65];
|
|
/* Convert pubkey bytes to hex. */
|
|
for (int i = 0; i < 32; i++) {
|
|
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
|
|
}
|
|
pubkey_hex[64] = '\0';
|
|
|
|
nostr_signer_t *signer = nostr_signer_local(privkey);
|
|
if (signer == NULL) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Failed to create signer.");
|
|
return;
|
|
}
|
|
|
|
ctx->result->method = KEY_STORE_METHOD_SEED;
|
|
ctx->result->signer = signer;
|
|
memcpy(ctx->result->pubkey_hex, pubkey_hex, 64);
|
|
ctx->result->pubkey_hex[64] = '\0';
|
|
|
|
ctx->result->identity.method = KEY_STORE_METHOD_SEED;
|
|
memcpy(ctx->result->identity.pubkey_hex, pubkey_hex, 64);
|
|
ctx->result->identity.pubkey_hex[64] = '\0';
|
|
privkey_to_hex(privkey, ctx->result->identity.privkey_hex);
|
|
snprintf(ctx->result->identity.mnemonic, sizeof(ctx->result->identity.mnemonic), "%s", mnemonic);
|
|
|
|
ctx->done = TRUE;
|
|
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
|
|
return;
|
|
}
|
|
|
|
/* ── Read-only (npub) ──────────────────────────────────── */
|
|
if (strcmp(current, "readonly") == 0) {
|
|
GtkWidget *screen = get_method_page(ctx, "readonly");
|
|
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), "npub-entry");
|
|
const char *input = gtk_entry_get_text(GTK_ENTRY(entry));
|
|
|
|
if (input[0] == '\0') {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Enter an npub1... or 64-char hex pubkey.");
|
|
return;
|
|
}
|
|
|
|
unsigned char pubkey[32];
|
|
char pubkey_hex[65];
|
|
|
|
if (strncmp(input, "npub1", 5) == 0) {
|
|
if (nostr_decode_npub(input, pubkey) != 0) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label), "Invalid npub.");
|
|
return;
|
|
}
|
|
for (int i = 0; i < 32; i++) {
|
|
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
|
|
}
|
|
pubkey_hex[64] = '\0';
|
|
} else if (strlen(input) == 64) {
|
|
if (hex_to_bytes32(input, pubkey) != 0) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Invalid hex pubkey.");
|
|
return;
|
|
}
|
|
memcpy(pubkey_hex, input, 64);
|
|
pubkey_hex[64] = '\0';
|
|
} else {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Enter an npub1... or 64-char hex pubkey.");
|
|
return;
|
|
}
|
|
|
|
/* Read-only: no signer created. */
|
|
ctx->result->method = KEY_STORE_METHOD_READONLY;
|
|
ctx->result->signer = NULL;
|
|
memcpy(ctx->result->pubkey_hex, pubkey_hex, 64);
|
|
ctx->result->pubkey_hex[64] = '\0';
|
|
|
|
ctx->result->identity.method = KEY_STORE_METHOD_READONLY;
|
|
memcpy(ctx->result->identity.pubkey_hex, pubkey_hex, 64);
|
|
ctx->result->identity.pubkey_hex[64] = '\0';
|
|
|
|
ctx->done = TRUE;
|
|
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
|
|
return;
|
|
}
|
|
|
|
/* ── NIP-46 remote signer ──────────────────────────────── */
|
|
if (strcmp(current, "nip46") == 0) {
|
|
GtkWidget *screen = get_method_page(ctx, "nip46");
|
|
GtkWidget *entry = g_object_get_data(G_OBJECT(screen), "bunker-entry");
|
|
const char *bunker_url = gtk_entry_get_text(GTK_ENTRY(entry));
|
|
|
|
if (bunker_url[0] == '\0') {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Enter a bunker:// URL.");
|
|
return;
|
|
}
|
|
|
|
/* Parse the bunker URL. */
|
|
nostr_nip46_bunker_url_t bunker;
|
|
memset(&bunker, 0, sizeof(bunker));
|
|
if (nostr_nip46_parse_bunker_url(bunker_url, &bunker) != 0) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Invalid bunker:// URL. Format: bunker://<pubkey>?relay=wss://...&secret=...");
|
|
return;
|
|
}
|
|
|
|
/* For NIP-46, we need a client keypair. Generate one if not provided.
|
|
* The client key is used to encrypt requests to the remote signer.
|
|
* The user's actual pubkey comes from the remote signer. */
|
|
unsigned char client_privkey[32], client_pubkey[32];
|
|
if (nostr_generate_keypair(client_privkey, client_pubkey) != 0) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Failed to generate client keypair.");
|
|
return;
|
|
}
|
|
|
|
/* The user's pubkey is the remote signer's pubkey. */
|
|
char pubkey_hex[65];
|
|
strncpy(pubkey_hex, bunker.remote_signer_pubkey, 64);
|
|
pubkey_hex[64] = '\0';
|
|
|
|
/* For now, we store the bunker URL and client key. The actual
|
|
* WebSocket connection to the relay will be established when
|
|
* signing is needed (in the bridge). This is a simplified
|
|
* implementation — a full NIP-46 client would connect now,
|
|
* send a connect request, and wait for approval. */
|
|
nostr_signer_t *signer = nostr_signer_local(client_privkey);
|
|
if (signer == NULL) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Failed to create client signer.");
|
|
return;
|
|
}
|
|
|
|
/* Store the client private key as the signer key — the bridge
|
|
* will use it to encrypt NIP-46 requests. The pubkey stored
|
|
* is the remote signer's pubkey (the user's identity). */
|
|
ctx->result->method = KEY_STORE_METHOD_NIP46;
|
|
ctx->result->signer = signer;
|
|
memcpy(ctx->result->pubkey_hex, pubkey_hex, 64);
|
|
ctx->result->pubkey_hex[64] = '\0';
|
|
|
|
ctx->result->identity.method = KEY_STORE_METHOD_NIP46;
|
|
memcpy(ctx->result->identity.pubkey_hex, pubkey_hex, 64);
|
|
ctx->result->identity.pubkey_hex[64] = '\0';
|
|
privkey_to_hex(client_privkey, ctx->result->identity.privkey_hex);
|
|
strncpy(ctx->result->identity.bunker_url, bunker_url,
|
|
sizeof(ctx->result->identity.bunker_url) - 1);
|
|
ctx->result->identity.bunker_url[sizeof(ctx->result->identity.bunker_url) - 1] = '\0';
|
|
|
|
char npub[128];
|
|
pubkey_to_npub(pubkey_hex, npub);
|
|
g_print("[login] NIP-46: remote signer pubkey=%s npub=%s\n", pubkey_hex,
|
|
npub[0] ? npub : "(conversion failed)");
|
|
|
|
ctx->done = TRUE;
|
|
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
|
|
return;
|
|
}
|
|
|
|
/* ── n_signer hardware ─────────────────────────────────── */
|
|
if (strcmp(current, "nsigner") == 0) {
|
|
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
|
|
GtkWidget *screen = get_method_page(ctx, "nsigner");
|
|
GtkWidget *transport_combo = g_object_get_data(G_OBJECT(screen), "transport-combo");
|
|
GtkWidget *device_entry = g_object_get_data(G_OBJECT(screen), "device-entry");
|
|
GtkWidget *index_spin = g_object_get_data(G_OBJECT(screen), "index-spin");
|
|
|
|
const char *device = gtk_entry_get_text(GTK_ENTRY(device_entry));
|
|
int nostr_index = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(index_spin));
|
|
gint transport_idx = gtk_combo_box_get_active(GTK_COMBO_BOX(transport_combo));
|
|
|
|
/* Block SIGCHLD during n_signer calls — the qrexec transport spawns
|
|
* child processes, and SIGCHLD can interrupt gtk_dialog_run's
|
|
* internal main loop, causing the dialog to close prematurely. */
|
|
sigset_t block_set, old_set;
|
|
sigemptyset(&block_set);
|
|
sigaddset(&block_set, SIGCHLD);
|
|
sigprocmask(SIG_BLOCK, &block_set, &old_set);
|
|
|
|
if (device[0] == '\0') {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Enter a device path or select one.");
|
|
return;
|
|
}
|
|
|
|
/* transport_idx: 0=serial, 1=unix, 2=tcp, 3=qrexec */
|
|
nostr_signer_t *signer = NULL;
|
|
const char *transport_name = "serial";
|
|
|
|
if (transport_idx == 0) {
|
|
signer = nostr_signer_nsigner_serial(device, NULL, 15000);
|
|
transport_name = "serial";
|
|
} else if (transport_idx == 1) {
|
|
signer = nostr_signer_nsigner_unix(device, NULL, 15000);
|
|
transport_name = "unix";
|
|
} else if (transport_idx == 2) {
|
|
char host[256];
|
|
int port = 0;
|
|
const char *sep = strrchr(device, ':');
|
|
if (sep && sep != device) {
|
|
size_t hlen = (size_t)(sep - device);
|
|
if (hlen < sizeof(host)) {
|
|
memcpy(host, device, hlen);
|
|
host[hlen] = '\0';
|
|
port = atoi(sep + 1);
|
|
signer = nostr_signer_nsigner_tcp(host, port, NULL, 15000);
|
|
transport_name = "tcp";
|
|
}
|
|
}
|
|
} else if (transport_idx == 3) {
|
|
GtkWidget *service_entry = g_object_get_data(G_OBJECT(screen), "service-entry");
|
|
const char *service = service_entry ? gtk_entry_get_text(GTK_ENTRY(service_entry)) : "qubes.NsignerRpc";
|
|
if (service[0] == '\0') service = "qubes.NsignerRpc";
|
|
signer = nostr_signer_nsigner_qrexec(device, service, NULL, 30000);
|
|
transport_name = "qrexec";
|
|
}
|
|
|
|
|
|
if (signer == NULL) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Failed to connect to n_signer. Check the device/path/qube.");
|
|
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
|
return;
|
|
}
|
|
|
|
/* n_signer's serial/TCP transports require a kind-27235 auth envelope
|
|
* on every request. Install the default caller identity (matching
|
|
* n_signer's webserial demo) before issuing any verbs. Without this
|
|
* the device rejects the call with an auth error that surfaces as
|
|
* "code -310". */
|
|
key_store_nsigner_set_default_auth(signer);
|
|
|
|
int rc_idx = nostr_signer_nsigner_set_nostr_index(signer, nostr_index);
|
|
if (rc_idx != NOSTR_SUCCESS) {
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"Failed to set nostr_index on n_signer.");
|
|
nostr_signer_free(signer);
|
|
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
|
return;
|
|
}
|
|
|
|
char pubkey_hex[65];
|
|
int rc_pk = nostr_signer_get_public_key(signer, pubkey_hex);
|
|
if (rc_pk != NOSTR_SUCCESS) {
|
|
char errmsg[320];
|
|
const char *desc = "unknown error";
|
|
switch (rc_pk) {
|
|
case -5: desc = "I/O failed — signer may have denied the request or disconnected"; break;
|
|
case -2001: desc = "policy denied — caller not approved at signer terminal"; break;
|
|
case -2002: desc = "index not in signer's whitelist"; break;
|
|
case -3: desc = "crypto operation failed"; break;
|
|
case NOSTR_ERROR_NIP46_AUTH_CHALLENGE:
|
|
/* Device RPC codes 2010-2017: auth-related rejection.
|
|
* Most commonly this means the kind-27235 caller auth
|
|
* envelope was missing/rejected (the default caller
|
|
* identity is installed automatically, but the device's
|
|
* policy may still deny it). It can also mean the device
|
|
* is requesting on-device approval (button press / TUI
|
|
* confirm) for this operation. */
|
|
desc = "auth rejected by n_signer (code -310). The caller "
|
|
"auth envelope was missing or denied, or the device "
|
|
"is requesting on-device approval. Confirm any "
|
|
"prompt on the n_signer hardware, then click Sign "
|
|
"In again";
|
|
break;
|
|
default: break;
|
|
}
|
|
snprintf(errmsg, sizeof(errmsg),
|
|
"n_signer error: %s (code %d). Try a different key index.",
|
|
desc, rc_pk);
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label), errmsg);
|
|
nostr_signer_free(signer);
|
|
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
|
return;
|
|
}
|
|
|
|
/* Unblock SIGCHLD — n_signer calls are done. */
|
|
sigprocmask(SIG_SETMASK, &old_set, NULL);
|
|
|
|
char npub[128];
|
|
pubkey_to_npub(pubkey_hex, npub);
|
|
g_print("[login] n_signer: transport=%s index=%d pubkey=%s npub=%s\n",
|
|
transport_name, nostr_index, pubkey_hex,
|
|
npub[0] ? npub : "(conversion failed)");
|
|
|
|
ctx->result->method = KEY_STORE_METHOD_NSIGNER;
|
|
ctx->result->signer = signer;
|
|
memcpy(ctx->result->pubkey_hex, pubkey_hex, 64);
|
|
ctx->result->pubkey_hex[64] = '\0';
|
|
|
|
ctx->result->identity.method = KEY_STORE_METHOD_NSIGNER;
|
|
memcpy(ctx->result->identity.pubkey_hex, pubkey_hex, 64);
|
|
ctx->result->identity.pubkey_hex[64] = '\0';
|
|
strncpy(ctx->result->identity.nsigner_transport, transport_name,
|
|
sizeof(ctx->result->identity.nsigner_transport) - 1);
|
|
ctx->result->identity.nsigner_transport[sizeof(ctx->result->identity.nsigner_transport) - 1] = '\0';
|
|
strncpy(ctx->result->identity.nsigner_device, device,
|
|
sizeof(ctx->result->identity.nsigner_device) - 1);
|
|
ctx->result->identity.nsigner_device[sizeof(ctx->result->identity.nsigner_device) - 1] = '\0';
|
|
ctx->result->identity.nsigner_index = nostr_index;
|
|
|
|
ctx->done = TRUE;
|
|
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
|
|
return;
|
|
#else
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label),
|
|
"n_signer client not compiled in (NOSTR_ENABLE_NSIGNER_CLIENT).");
|
|
return;
|
|
#endif
|
|
}
|
|
|
|
gtk_label_set_text(GTK_LABEL(ctx->status_label), "Unknown method.");
|
|
}
|
|
|
|
/* "No Login" — browse without a Nostr identity. The browser works
|
|
* normally; window.nostr just won't be available for sign requests. */
|
|
static void on_no_login_clicked(GtkWidget *btn, gpointer user_data) {
|
|
(void)btn;
|
|
login_ctx_t *ctx = (login_ctx_t *)user_data;
|
|
ctx->done = TRUE;
|
|
ctx->no_login = TRUE;
|
|
/* Return success with an empty result (method=NONE, no signer). */
|
|
memset(ctx->result, 0, sizeof(*ctx->result));
|
|
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
|
|
}
|
|
|
|
static void on_cancel_clicked(GtkWidget *btn, gpointer user_data) {
|
|
(void)btn;
|
|
login_ctx_t *ctx = (login_ctx_t *)user_data;
|
|
ctx->done = FALSE;
|
|
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_CANCEL);
|
|
}
|
|
|
|
/* ── BIP-39 inline auto-complete ──────────────────────────────── */
|
|
|
|
/* State to prevent recursive signal handling during auto-fill. */
|
|
static int g_autofill_suppress = 0;
|
|
|
|
/* Track the last key pressed to detect deletions (skip auto-fill on delete). */
|
|
static int g_last_key_was_delete = 0;
|
|
|
|
/* Idle callback to set cursor position after auto-fill (deferred so GTK
|
|
* doesn't override our cursor placement). */
|
|
static gboolean deferred_set_cursor(gpointer data) {
|
|
GtkEditable *editable = GTK_EDITABLE(data);
|
|
gpointer pos_ptr = g_object_get_data(G_OBJECT(editable), "target-pos");
|
|
if (pos_ptr) {
|
|
gtk_editable_set_position(editable, GPOINTER_TO_INT(pos_ptr));
|
|
g_object_set_data(G_OBJECT(editable), "target-pos", NULL);
|
|
}
|
|
g_object_unref(editable);
|
|
return FALSE; /* one-shot */
|
|
}
|
|
|
|
/* Called when a word entry changes — auto-fills if unique match found. */
|
|
static void on_word_changed(GtkEditable *editable, gpointer user_data) {
|
|
(void)user_data;
|
|
if (g_autofill_suppress) return;
|
|
if (g_last_key_was_delete) {
|
|
g_last_key_was_delete = 0;
|
|
return;
|
|
}
|
|
|
|
const char *text = gtk_entry_get_text(GTK_ENTRY(editable));
|
|
if (text[0] == '\0') return;
|
|
|
|
size_t text_len = strlen(text);
|
|
|
|
/* Search the BIP-39 wordlist for matches. */
|
|
int word_count = 0;
|
|
const char * const *words = nostr_bip39_get_wordlist(&word_count);
|
|
const char *match = NULL;
|
|
int match_count = 0;
|
|
|
|
for (int i = 0; i < word_count; i++) {
|
|
if (strncmp(words[i], text, text_len) == 0) {
|
|
match = words[i];
|
|
match_count++;
|
|
if (match_count > 1) break; /* no unique match */
|
|
}
|
|
}
|
|
|
|
/* If exactly one match and it's longer than what's typed, auto-fill. */
|
|
if (match_count == 1 && match && strlen(match) > text_len) {
|
|
g_autofill_suppress = 1;
|
|
/* Set the full word. */
|
|
char full[32];
|
|
snprintf(full, sizeof(full), "%s", match);
|
|
gtk_entry_set_text(GTK_ENTRY(editable), full);
|
|
/* Place cursor at the end — deferred so GTK doesn't override it. */
|
|
gint end_pos = (gint)strlen(full);
|
|
g_object_set_data(G_OBJECT(editable), "target-pos",
|
|
GINT_TO_POINTER(end_pos));
|
|
g_idle_add_full(G_PRIORITY_HIGH_IDLE, deferred_set_cursor,
|
|
g_object_ref(editable), NULL);
|
|
g_autofill_suppress = 0;
|
|
} else if (match_count == 1 && match && strlen(match) == text_len) {
|
|
/* Exact match (e.g. selected from dropdown) — cursor to end. */
|
|
gtk_editable_set_position(editable, (gint)text_len);
|
|
}
|
|
}
|
|
|
|
/* Called on key press in a word entry — Tab/Enter moves to next box,
|
|
* and tracks BackSpace/Delete to skip auto-fill. */
|
|
static gboolean on_word_key_press(GtkWidget *widget, GdkEventKey *event,
|
|
gpointer user_data) {
|
|
GtkWidget **entries = (GtkWidget **)user_data;
|
|
|
|
/* Track deletions so on_word_changed skips auto-fill. */
|
|
if (event->keyval == GDK_KEY_BackSpace || event->keyval == GDK_KEY_Delete) {
|
|
g_last_key_was_delete = 1;
|
|
}
|
|
|
|
if (event->keyval == GDK_KEY_Tab || event->keyval == GDK_KEY_Return ||
|
|
event->keyval == GDK_KEY_KP_Enter) {
|
|
/* Find the current entry index and focus the next one. */
|
|
int i;
|
|
for (i = 0; i < SEED_WORD_COUNT; i++) {
|
|
if (entries[i] == widget) break;
|
|
}
|
|
if (i < SEED_WORD_COUNT - 1) {
|
|
gtk_widget_grab_focus(entries[i + 1]);
|
|
}
|
|
return TRUE; /* stop propagation */
|
|
}
|
|
return FALSE;
|
|
}
|
|
|
|
/* Update the derivation path hint when the account number changes. */
|
|
static void on_account_changed(GtkSpinButton *spin, gpointer user_data) {
|
|
GtkWidget *hint = GTK_WIDGET(user_data);
|
|
int account = gtk_spin_button_get_value_as_int(spin);
|
|
char text[64];
|
|
snprintf(text, sizeof(text),
|
|
"Key derivation: m/44'/1237'/%d'/0/0", account);
|
|
gtk_label_set_text(GTK_LABEL(hint), text);
|
|
}
|
|
|
|
/* Update the n_signer UI when the transport type changes. */
|
|
static void on_transport_changed(GtkComboBox *combo, gpointer user_data) {
|
|
/* user_data is the box; we retrieve the widgets from it. */
|
|
GtkWidget *box = GTK_WIDGET(user_data);
|
|
GtkWidget *device_label = g_object_get_data(G_OBJECT(box), "device-label");
|
|
GtkWidget *device_entry = g_object_get_data(G_OBJECT(box), "device-entry");
|
|
GtkWidget *enum_btn = g_object_get_data(G_OBJECT(box), "enum-btn");
|
|
GtkWidget *service_box = g_object_get_data(G_OBJECT(box), "service-box");
|
|
|
|
gint idx = gtk_combo_box_get_active(combo);
|
|
|
|
switch (idx) {
|
|
case 0: /* USB Serial */
|
|
gtk_label_set_text(GTK_LABEL(device_label), "Device Path:");
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "/dev/ttyACM0");
|
|
/* Clear any default text from qrexec mode. */
|
|
if (gtk_entry_get_text_length(GTK_ENTRY(device_entry)) > 0 &&
|
|
strcmp(gtk_entry_get_text(GTK_ENTRY(device_entry)), "nostr_signer") == 0) {
|
|
gtk_entry_set_text(GTK_ENTRY(device_entry), "");
|
|
}
|
|
gtk_widget_show(enum_btn);
|
|
gtk_widget_hide(service_box);
|
|
break;
|
|
case 1: /* UNIX Socket */
|
|
gtk_label_set_text(GTK_LABEL(device_label), "Socket Name:");
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "nsigner");
|
|
if (gtk_entry_get_text_length(GTK_ENTRY(device_entry)) > 0 &&
|
|
strcmp(gtk_entry_get_text(GTK_ENTRY(device_entry)), "nostr_signer") == 0) {
|
|
gtk_entry_set_text(GTK_ENTRY(device_entry), "");
|
|
}
|
|
gtk_widget_hide(enum_btn);
|
|
gtk_widget_hide(service_box);
|
|
break;
|
|
case 2: /* TCP */
|
|
gtk_label_set_text(GTK_LABEL(device_label), "Host:Port:");
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "127.0.0.1:7777");
|
|
if (gtk_entry_get_text_length(GTK_ENTRY(device_entry)) > 0 &&
|
|
strcmp(gtk_entry_get_text(GTK_ENTRY(device_entry)), "nostr_signer") == 0) {
|
|
gtk_entry_set_text(GTK_ENTRY(device_entry), "");
|
|
}
|
|
gtk_widget_hide(enum_btn);
|
|
gtk_widget_hide(service_box);
|
|
break;
|
|
case 3: /* Other Qube (Qubes qrexec) */
|
|
gtk_label_set_text(GTK_LABEL(device_label), "Target Qube:");
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "nostr_signer");
|
|
/* Set default value so the user doesn't need to type it. */
|
|
if (gtk_entry_get_text_length(GTK_ENTRY(device_entry)) == 0) {
|
|
gtk_entry_set_text(GTK_ENTRY(device_entry), "nostr_signer");
|
|
}
|
|
gtk_widget_hide(enum_btn);
|
|
gtk_widget_show(service_box);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/* ── Screen creation ──────────────────────────────────────────── */
|
|
|
|
static GtkWidget *create_local_screen(login_ctx_t *ctx) {
|
|
(void)ctx;
|
|
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
|
gtk_widget_set_margin_top(box, 12);
|
|
gtk_widget_set_margin_bottom(box, 12);
|
|
gtk_widget_set_margin_start(box, 12);
|
|
gtk_widget_set_margin_end(box, 12);
|
|
|
|
GtkWidget *label = gtk_label_new("Enter your Nostr private key (nsec) or generate a new one:");
|
|
gtk_widget_set_halign(label, GTK_ALIGN_START);
|
|
gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *entry = gtk_entry_new();
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "nsec1...");
|
|
gtk_entry_set_width_chars(GTK_ENTRY(entry), 60);
|
|
gtk_box_pack_start(GTK_BOX(box), entry, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *gen_btn = gtk_button_new_with_label("Generate New Key");
|
|
g_signal_connect(gen_btn, "clicked", G_CALLBACK(on_generate_key), entry);
|
|
gtk_box_pack_start(GTK_BOX(box), gen_btn, FALSE, FALSE, 0);
|
|
|
|
g_object_set_data(G_OBJECT(box), "nsec-entry", entry);
|
|
return box;
|
|
}
|
|
|
|
static GtkWidget *create_seed_screen(login_ctx_t *ctx) {
|
|
(void)ctx;
|
|
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
|
gtk_widget_set_margin_top(box, 12);
|
|
gtk_widget_set_margin_bottom(box, 12);
|
|
gtk_widget_set_margin_start(box, 12);
|
|
gtk_widget_set_margin_end(box, 12);
|
|
|
|
GtkWidget *label = gtk_label_new("Enter your 12-word BIP-39 seed phrase:");
|
|
gtk_widget_set_halign(label, GTK_ALIGN_START);
|
|
gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0);
|
|
|
|
/* Build the BIP-39 word completion model (shared by all 12 entries). */
|
|
GtkListStore *store = gtk_list_store_new(1, G_TYPE_STRING);
|
|
int word_count = 0;
|
|
const char * const *words = nostr_bip39_get_wordlist(&word_count);
|
|
for (int i = 0; i < word_count; i++) {
|
|
GtkTreeIter iter;
|
|
gtk_list_store_append(store, &iter);
|
|
gtk_list_store_set(store, &iter, 0, words[i], -1);
|
|
}
|
|
|
|
/* 12 numbered word entry boxes in a 4x3 grid with inline auto-complete. */
|
|
GtkWidget *grid = gtk_grid_new();
|
|
gtk_grid_set_row_spacing(GTK_GRID(grid), 4);
|
|
gtk_grid_set_column_spacing(GTK_GRID(grid), 8);
|
|
gtk_box_pack_start(GTK_BOX(box), grid, FALSE, FALSE, 0);
|
|
|
|
/* Allocate an array of entry pointers for Tab navigation. */
|
|
GtkWidget **entries = g_new0(GtkWidget *, SEED_WORD_COUNT);
|
|
|
|
for (int i = 0; i < SEED_WORD_COUNT; i++) {
|
|
int col = i % 4;
|
|
int row = i / 4;
|
|
|
|
/* Number label. */
|
|
char num[8];
|
|
snprintf(num, sizeof(num), "%d.", i + 1);
|
|
GtkWidget *num_label = gtk_label_new(num);
|
|
gtk_widget_set_halign(num_label, GTK_ALIGN_END);
|
|
gtk_grid_attach(GTK_GRID(grid), num_label, col * 2, row, 1, 1);
|
|
|
|
/* Word entry with dropdown completion + inline auto-complete. */
|
|
GtkWidget *entry = gtk_entry_new();
|
|
gtk_entry_set_width_chars(GTK_ENTRY(entry), 14);
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "word");
|
|
|
|
/* Dropdown completion list. */
|
|
GtkEntryCompletion *completion = gtk_entry_completion_new();
|
|
gtk_entry_completion_set_model(completion, GTK_TREE_MODEL(store));
|
|
gtk_entry_completion_set_text_column(completion, 0);
|
|
gtk_entry_completion_set_minimum_key_length(completion, 2);
|
|
gtk_entry_set_completion(GTK_ENTRY(entry), completion);
|
|
g_object_unref(completion);
|
|
|
|
/* Inline auto-complete on text change. */
|
|
g_signal_connect(entry, "changed", G_CALLBACK(on_word_changed), NULL);
|
|
/* Tab/Enter moves to the next word box. */
|
|
g_signal_connect(entry, "key-press-event",
|
|
G_CALLBACK(on_word_key_press), entries);
|
|
|
|
gtk_grid_attach(GTK_GRID(grid), entry, col * 2 + 1, row, 1, 1);
|
|
|
|
entries[i] = entry;
|
|
|
|
/* Store the entry on the box for later retrieval. */
|
|
char key[16];
|
|
snprintf(key, sizeof(key), "word-%d", i);
|
|
g_object_set_data(G_OBJECT(box), key, entry);
|
|
}
|
|
/* Store the entries array on the box (freed when box is destroyed). */
|
|
g_object_set_data_full(G_OBJECT(box), "entries-array", entries,
|
|
(GDestroyNotify)g_free);
|
|
g_object_unref(store);
|
|
|
|
GtkWidget *gen_btn = gtk_button_new_with_label("Generate New Mnemonic");
|
|
g_signal_connect(gen_btn, "clicked", G_CALLBACK(on_generate_mnemonic), box);
|
|
gtk_box_pack_start(GTK_BOX(box), gen_btn, FALSE, FALSE, 0);
|
|
|
|
/* Account number selector. */
|
|
GtkWidget *acct_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
|
gtk_box_pack_start(GTK_BOX(box), acct_box, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *acct_label = gtk_label_new("Account #:");
|
|
gtk_box_pack_start(GTK_BOX(acct_box), acct_label, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *acct_spin = gtk_spin_button_new_with_range(0, 1000, 1);
|
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(acct_spin), 0);
|
|
gtk_box_pack_start(GTK_BOX(acct_box), acct_spin, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *hint = gtk_label_new("Key derivation: m/44'/1237'/0'/0/0");
|
|
gtk_widget_set_sensitive(hint, FALSE);
|
|
gtk_widget_set_halign(hint, GTK_ALIGN_START);
|
|
gtk_box_pack_start(GTK_BOX(box), hint, FALSE, FALSE, 0);
|
|
|
|
/* Update the hint when the account number changes. */
|
|
g_signal_connect(acct_spin, "value-changed",
|
|
G_CALLBACK(on_account_changed), hint);
|
|
|
|
g_object_set_data(G_OBJECT(box), "account-spin", acct_spin);
|
|
return box;
|
|
}
|
|
|
|
static GtkWidget *create_readonly_screen(login_ctx_t *ctx) {
|
|
(void)ctx;
|
|
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
|
gtk_widget_set_margin_top(box, 12);
|
|
gtk_widget_set_margin_bottom(box, 12);
|
|
gtk_widget_set_margin_start(box, 12);
|
|
gtk_widget_set_margin_end(box, 12);
|
|
|
|
GtkWidget *label = gtk_label_new("Enter a Nostr public key (npub) for read-only mode:");
|
|
gtk_widget_set_halign(label, GTK_ALIGN_START);
|
|
gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *entry = gtk_entry_new();
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(entry), "npub1...");
|
|
gtk_entry_set_width_chars(GTK_ENTRY(entry), 60);
|
|
gtk_box_pack_start(GTK_BOX(box), entry, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *hint = gtk_label_new("Read-only mode: you can view content but cannot sign events.");
|
|
gtk_widget_set_sensitive(hint, FALSE);
|
|
gtk_widget_set_halign(hint, GTK_ALIGN_START);
|
|
gtk_box_pack_start(GTK_BOX(box), hint, FALSE, FALSE, 0);
|
|
|
|
g_object_set_data(G_OBJECT(box), "npub-entry", entry);
|
|
return box;
|
|
}
|
|
|
|
static GtkWidget *create_nip46_screen(login_ctx_t *ctx) {
|
|
(void)ctx;
|
|
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
|
gtk_widget_set_margin_top(box, 12);
|
|
gtk_widget_set_margin_bottom(box, 12);
|
|
gtk_widget_set_margin_start(box, 12);
|
|
gtk_widget_set_margin_end(box, 12);
|
|
|
|
GtkWidget *label = gtk_label_new("Connect to a NIP-46 remote signer (bunker:// URL):");
|
|
gtk_widget_set_halign(label, GTK_ALIGN_START);
|
|
gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *entry = gtk_entry_new();
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(entry),
|
|
"bunker://<pubkey>?relay=wss://...&secret=...");
|
|
gtk_entry_set_width_chars(GTK_ENTRY(entry), 60);
|
|
gtk_box_pack_start(GTK_BOX(box), entry, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *hint = gtk_label_new("The remote signer holds your private key. Signing requests are sent over Nostr relays.");
|
|
gtk_widget_set_sensitive(hint, FALSE);
|
|
gtk_widget_set_halign(hint, GTK_ALIGN_START);
|
|
gtk_label_set_line_wrap(GTK_LABEL(hint), TRUE);
|
|
gtk_box_pack_start(GTK_BOX(box), hint, FALSE, FALSE, 0);
|
|
|
|
g_object_set_data(G_OBJECT(box), "bunker-entry", entry);
|
|
return box;
|
|
}
|
|
|
|
static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
|
|
(void)ctx;
|
|
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
|
gtk_widget_set_margin_top(box, 12);
|
|
gtk_widget_set_margin_bottom(box, 12);
|
|
gtk_widget_set_margin_start(box, 12);
|
|
gtk_widget_set_margin_end(box, 12);
|
|
|
|
GtkWidget *label = gtk_label_new("Connect to n_signer hardware signer:");
|
|
gtk_widget_set_halign(label, GTK_ALIGN_START);
|
|
gtk_box_pack_start(GTK_BOX(box), label, FALSE, FALSE, 0);
|
|
|
|
/* Transport type selector. */
|
|
GtkWidget *transport_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
|
gtk_box_pack_start(GTK_BOX(box), transport_box, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *transport_label = gtk_label_new("Transport:");
|
|
gtk_box_pack_start(GTK_BOX(transport_box), transport_label, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *transport_combo = gtk_combo_box_text_new();
|
|
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "USB Serial");
|
|
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "UNIX Socket");
|
|
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "TCP");
|
|
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "Other Qube");
|
|
gtk_combo_box_set_active(GTK_COMBO_BOX(transport_combo), 3);
|
|
gtk_box_pack_start(GTK_BOX(transport_box), transport_combo, FALSE, FALSE, 0);
|
|
|
|
/* Device path entry — label and placeholder adapt to transport type.
|
|
* Default transport is "Other Qube" (qrexec), so initial UI reflects
|
|
* that. The on_transport_changed callback updates these when the user
|
|
* switches transport. */
|
|
GtkWidget *device_label = gtk_label_new("Target Qube:");
|
|
gtk_widget_set_halign(device_label, GTK_ALIGN_START);
|
|
gtk_box_pack_start(GTK_BOX(box), device_label, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *device_entry = gtk_entry_new();
|
|
gtk_entry_set_text(GTK_ENTRY(device_entry), "nostr_signer");
|
|
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "nostr_signer");
|
|
gtk_entry_set_width_chars(GTK_ENTRY(device_entry), 40);
|
|
gtk_box_pack_start(GTK_BOX(box), device_entry, FALSE, FALSE, 0);
|
|
|
|
/* Enumerate serial devices button (only shown for serial transport).
|
|
* Hidden by default since qrexec is the default transport. */
|
|
GtkWidget *enum_btn = gtk_button_new_with_label("Detect Serial Devices");
|
|
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
|
|
g_signal_connect(enum_btn, "clicked", G_CALLBACK(on_detect_serial), device_entry);
|
|
#else
|
|
gtk_widget_set_sensitive(enum_btn, FALSE);
|
|
#endif
|
|
gtk_box_pack_start(GTK_BOX(box), enum_btn, FALSE, FALSE, 0);
|
|
gtk_widget_hide(enum_btn);
|
|
|
|
/* Service name field (only shown for qrexec transport). */
|
|
GtkWidget *service_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
|
gtk_box_pack_start(GTK_BOX(box), service_box, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *service_label = gtk_label_new("Service:");
|
|
gtk_box_pack_start(GTK_BOX(service_box), service_label, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *service_entry = gtk_entry_new();
|
|
gtk_entry_set_text(GTK_ENTRY(service_entry), "qubes.NsignerRpc");
|
|
gtk_entry_set_width_chars(GTK_ENTRY(service_entry), 30);
|
|
gtk_box_pack_start(GTK_BOX(service_box), service_entry, FALSE, FALSE, 0);
|
|
|
|
/* Show the service field by default (qrexec is the default transport). */
|
|
gtk_widget_show_all(service_box);
|
|
|
|
/* Connect transport change callback to update the UI dynamically. */
|
|
g_signal_connect(transport_combo, "changed",
|
|
G_CALLBACK(on_transport_changed), box);
|
|
|
|
/* Nostr index spinner. The label shows the derived BIP-32 path
|
|
* m/44'/1237'/N'/0/0 and updates N' live as the user changes the
|
|
* spin button value, so they can see which key they are selecting. */
|
|
GtkWidget *index_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
|
gtk_box_pack_start(GTK_BOX(box), index_box, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *index_label = gtk_label_new("Key Index (m/44'/1237'/0'/0/0):");
|
|
gtk_widget_set_halign(index_label, GTK_ALIGN_START);
|
|
gtk_box_pack_start(GTK_BOX(index_box), index_label, FALSE, FALSE, 0);
|
|
|
|
GtkWidget *index_spin = gtk_spin_button_new_with_range(0, 1000, 1);
|
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(index_spin), 0);
|
|
gtk_box_pack_start(GTK_BOX(index_box), index_spin, FALSE, FALSE, 0);
|
|
|
|
/* Update the path label whenever the index value changes. */
|
|
g_signal_connect(index_spin, "value-changed",
|
|
G_CALLBACK(on_index_changed), index_label);
|
|
|
|
GtkWidget *hint = gtk_label_new("n_signer is a foreground, RAM-only hardware signer. Your private key never leaves the device.");
|
|
gtk_widget_set_sensitive(hint, FALSE);
|
|
gtk_widget_set_halign(hint, GTK_ALIGN_START);
|
|
gtk_label_set_line_wrap(GTK_LABEL(hint), TRUE);
|
|
gtk_box_pack_start(GTK_BOX(box), hint, FALSE, FALSE, 0);
|
|
|
|
g_object_set_data(G_OBJECT(box), "transport-combo", transport_combo);
|
|
g_object_set_data(G_OBJECT(box), "device-label", device_label);
|
|
g_object_set_data(G_OBJECT(box), "device-entry", device_entry);
|
|
g_object_set_data(G_OBJECT(box), "enum-btn", enum_btn);
|
|
g_object_set_data(G_OBJECT(box), "service-box", service_box);
|
|
g_object_set_data(G_OBJECT(box), "service-entry", service_entry);
|
|
g_object_set_data(G_OBJECT(box), "index-spin", index_spin);
|
|
g_object_set_data(G_OBJECT(box), "index-label", index_label);
|
|
return box;
|
|
}
|
|
|
|
/* Update the key-index path label when the spin button value changes.
|
|
* Shows the live BIP-32 derivation path m/44'/1237'/N'/0/0 with the
|
|
* current N value substituted in. */
|
|
static void on_index_changed(GtkWidget *spin, gpointer user_data) {
|
|
GtkSpinButton *sb = GTK_SPIN_BUTTON(spin);
|
|
GtkWidget *label = GTK_WIDGET(user_data);
|
|
int idx = gtk_spin_button_get_value_as_int(sb);
|
|
char text[96];
|
|
snprintf(text, sizeof(text),
|
|
"Key Index (m/44'/1237'/%d'/0/0):", idx);
|
|
gtk_label_set_text(GTK_LABEL(label), text);
|
|
}
|
|
|
|
/* ── Serial device enumeration callback ───────────────────────── */
|
|
|
|
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
|
|
static void on_detect_serial(GtkWidget *btn, gpointer user_data) {
|
|
(void)btn;
|
|
GtkWidget *device_entry = GTK_WIDGET(user_data);
|
|
|
|
char paths[16][64];
|
|
int count = nsigner_transport_list_serial(paths, 16);
|
|
|
|
if (count <= 0) {
|
|
gtk_entry_set_text(GTK_ENTRY(device_entry), "(no serial devices found)");
|
|
return;
|
|
}
|
|
|
|
/* For now, just set the first device. A future improvement would
|
|
* show a dropdown of all detected devices. */
|
|
gtk_entry_set_text(GTK_ENTRY(device_entry), paths[0]);
|
|
g_print("[nsigner] Detected %d serial device(s): %s\n", count, paths[0]);
|
|
}
|
|
#endif
|
|
|
|
/* ── Main dialog ──────────────────────────────────────────────── */
|
|
|
|
/* Timeout callback to check if the agent has logged in.
|
|
* If so, close the dialog automatically. */
|
|
static gboolean agent_login_check(gpointer data) {
|
|
GtkDialog *dialog = GTK_DIALOG(data);
|
|
if (agent_login_was_performed_by_agent()) {
|
|
g_print("[login] Agent login detected, closing dialog.\n");
|
|
gtk_dialog_response(dialog, GTK_RESPONSE_ACCEPT);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
return G_SOURCE_CONTINUE;
|
|
}
|
|
|
|
int login_dialog_run(GtkWindow *parent, login_result_t *result) {
|
|
memset(result, 0, sizeof(*result));
|
|
|
|
if (nostr_init() != NOSTR_SUCCESS) {
|
|
return -1;
|
|
}
|
|
|
|
/* Create dialog WITHOUT buttons — we add custom buttons so we can
|
|
* control whether the dialog closes (the built-in buttons auto-respond
|
|
* and close the dialog before our handler can decide to keep it open). */
|
|
/* Create dialog without auto-responding buttons — we add custom
|
|
* buttons so we control whether the dialog closes. */
|
|
GtkWidget *dialog = gtk_dialog_new();
|
|
gtk_window_set_title(GTK_WINDOW(dialog), "sovereign browser " SB_VERSION);
|
|
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
|
|
if (parent) gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
|
|
gtk_window_set_default_size(GTK_WINDOW(dialog), 560, 380);
|
|
|
|
/* Add custom buttons to the action area.
|
|
* Layout (left to right): No Login | Cancel | Sign In
|
|
* "No Login" is on the far left so the primary action (Sign In)
|
|
* remains on the right (conventional GTK button ordering). */
|
|
GtkWidget *action_area = gtk_dialog_get_action_area(GTK_DIALOG(dialog));
|
|
GtkWidget *no_login_btn = gtk_button_new_with_label("No Login");
|
|
GtkWidget *cancel_btn = gtk_button_new_with_label("Cancel");
|
|
GtkWidget *login_btn = gtk_button_new_with_label("Sign In");
|
|
gtk_box_pack_start(GTK_BOX(action_area), no_login_btn, FALSE, FALSE, 0);
|
|
gtk_box_pack_start(GTK_BOX(action_area), cancel_btn, FALSE, FALSE, 0);
|
|
gtk_box_pack_start(GTK_BOX(action_area), login_btn, FALSE, FALSE, 0);
|
|
gtk_widget_set_tooltip_text(no_login_btn,
|
|
"Browse without a Nostr identity. The browser works normally, "
|
|
"but window.nostr won't be available for signing.");
|
|
|
|
/* Apply monospace font styling to the dialog. */
|
|
GtkCssProvider *css = gtk_css_provider_new();
|
|
gtk_css_provider_load_from_data(css,
|
|
"dialog { font-family: monospace; }\n"
|
|
"label { font-family: monospace; }\n"
|
|
"entry { font-family: monospace; }\n"
|
|
"button { font-family: monospace; }\n",
|
|
-1, NULL);
|
|
GtkStyleContext *sctx = gtk_widget_get_style_context(dialog);
|
|
gtk_style_context_add_provider(sctx, GTK_STYLE_PROVIDER(css),
|
|
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
|
|
g_object_unref(css);
|
|
|
|
GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog));
|
|
gtk_container_set_border_width(GTK_CONTAINER(content), 12);
|
|
|
|
login_ctx_t ctx;
|
|
memset(&ctx, 0, sizeof(ctx));
|
|
ctx.dialog = dialog;
|
|
ctx.content_area = content;
|
|
ctx.result = result;
|
|
ctx.done = FALSE;
|
|
|
|
/* Title. */
|
|
GtkWidget *title = gtk_label_new(NULL);
|
|
gtk_label_set_markup(GTK_LABEL(title),
|
|
"<span size='large' weight='bold'>Sign in with your Nostr key</span>");
|
|
gtk_box_pack_start(GTK_BOX(content), title, FALSE, FALSE, 8);
|
|
|
|
/* Notebook with tabs for each login method. */
|
|
GtkWidget *notebook = gtk_notebook_new();
|
|
gtk_box_pack_start(GTK_BOX(content), notebook, TRUE, TRUE, 4);
|
|
ctx.notebook = notebook;
|
|
|
|
/* Create all screens as notebook tabs. */
|
|
const struct {
|
|
const char *label;
|
|
const char *method;
|
|
GtkWidget *(*create_fn)(login_ctx_t *);
|
|
} tabs[] = {
|
|
{"Local Key", "local", create_local_screen},
|
|
{"Seed Phrase", "seed", create_seed_screen},
|
|
{"Read-only", "readonly", create_readonly_screen},
|
|
{"NIP-46", "nip46", create_nip46_screen},
|
|
{"n_signer", "nsigner", create_nsigner_screen},
|
|
};
|
|
|
|
for (size_t i = 0; i < sizeof(tabs) / sizeof(tabs[0]); i++) {
|
|
GtkWidget *screen = tabs[i].create_fn(&ctx);
|
|
g_object_set_data(G_OBJECT(screen), "method-name", (gpointer)tabs[i].method);
|
|
GtkWidget *tab_label = gtk_label_new(tabs[i].label);
|
|
gtk_notebook_append_page(GTK_NOTEBOOK(notebook), screen, tab_label);
|
|
}
|
|
|
|
/* Status label. */
|
|
GtkWidget *status = gtk_label_new("");
|
|
gtk_widget_set_halign(status, GTK_ALIGN_START);
|
|
gtk_box_pack_start(GTK_BOX(content), status, FALSE, FALSE, 4);
|
|
ctx.status_label = status;
|
|
|
|
/* Wire the custom buttons — these don't auto-respond, so the dialog
|
|
* stays open unless we explicitly call gtk_dialog_response. */
|
|
g_signal_connect(no_login_btn, "clicked", G_CALLBACK(on_no_login_clicked), &ctx);
|
|
g_signal_connect(cancel_btn, "clicked", G_CALLBACK(on_cancel_clicked), &ctx);
|
|
g_signal_connect(login_btn, "clicked", G_CALLBACK(on_login_clicked), &ctx);
|
|
g_object_set_data(G_OBJECT(dialog), "login-btn", login_btn);
|
|
g_object_set_data(G_OBJECT(dialog), "cancel-btn", cancel_btn);
|
|
g_object_set_data(G_OBJECT(dialog), "no-login-btn", no_login_btn);
|
|
|
|
gtk_widget_show_all(dialog);
|
|
|
|
/* Add a timeout to check if the agent has logged in. If so,
|
|
* close the dialog automatically (every 200ms). */
|
|
guint agent_check_id = g_timeout_add(200, agent_login_check, dialog);
|
|
|
|
gint response = gtk_dialog_run(GTK_DIALOG(dialog));
|
|
|
|
/* Remove the timeout if it's still active. */
|
|
g_source_remove(agent_check_id);
|
|
|
|
/* If the agent logged in while the dialog was showing, return 0
|
|
* (success) but with an empty result — main.c will use the agent's
|
|
* login state instead. */
|
|
if (agent_login_was_performed_by_agent()) {
|
|
if (result->signer) {
|
|
nostr_signer_free(result->signer);
|
|
result->signer = NULL;
|
|
}
|
|
memset(result, 0, sizeof(*result));
|
|
gtk_widget_destroy(dialog);
|
|
return 0;
|
|
}
|
|
|
|
if (!ctx.done || response != GTK_RESPONSE_ACCEPT) {
|
|
if (result->signer) {
|
|
nostr_signer_free(result->signer);
|
|
result->signer = NULL;
|
|
}
|
|
memset(result, 0, sizeof(*result));
|
|
gtk_widget_destroy(dialog);
|
|
return -1;
|
|
}
|
|
|
|
gtk_widget_destroy(dialog);
|
|
return 0;
|
|
}
|
|
|
|
void login_result_free(login_result_t *result) {
|
|
if (result == NULL) return;
|
|
if (result->signer) {
|
|
nostr_signer_free(result->signer);
|
|
result->signer = NULL;
|
|
}
|
|
}
|