commit 2cc182b977305800e90ff9500200d227b506f19b
Author: SeMi <sebastian.michalk@protonmail.com>
Date: Sat, 30 Nov 2024 22:12:28 +0100
initial commit
Diffstat:
A | LICENSE | | | 21 | +++++++++++++++++++++ |
A | Makefile | | | 38 | ++++++++++++++++++++++++++++++++++++++ |
A | config.def.h | | | 54 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | config.mk | | | 54 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | readme | | | 53 | +++++++++++++++++++++++++++++++++++++++++++++++++++++ |
A | spm.c | | | 1991 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
6 files changed, 2211 insertions(+), 0 deletions(-)
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 nyangkosense
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Makefile b/Makefile
@@ -0,0 +1,38 @@
+# spm - simple password management
+
+include config.mk
+
+SRC = spm.c
+OBJ = ${SRC:.c=.o}
+
+all: options spm
+
+options:
+ @echo slpass build options:
+ @echo "CFLAGS = ${CFLAGS}"
+ @echo "LDFLAGS = ${LDFLAGS}"
+ @echo "CC = ${CC}"
+
+.c.o:
+ ${CC} -c ${CFLAGS} $<
+
+${OBJ}: config.mk
+
+spm: ${OBJ}
+ ${CC} -o $@ ${OBJ} ${LDFLAGS}
+
+clean:
+ rm -f spm ${OBJ}
+
+install: all
+ mkdir -p ${DESTDIR}${PREFIX}/bin
+ cp -f spm ${DESTDIR}${PREFIX}/bin
+ chmod 755 ${DESTDIR}${PREFIX}/bin/spm
+# mkdir -p ${DESTDIR}${MANPREFIX}/man1
+# chmod 644 ${DESTDIR}${MANPREFIX}/man1/slpass.1
+
+uninstall:
+ rm -f ${DESTDIR}${PREFIX}/bin/spm
+ rm -f ${DESTDIR}${MANPREFIX}/man1/spm.1
+
+.PHONY: all options clean install uninstall
diff --git a/config.def.h b/config.def.h
@@ -0,0 +1,54 @@
+/* config.def.h */
+/* Color definitions */
+/* from curses.h */
+/* COLOR_BLACK 0
+ COLOR_RED 1
+ COLOR_GREEN 2
+ COLOR_YELLOW 3
+ COLOR_BLUE 4
+ COLOR_MAGENTA 5
+ COLOR_CYAN 6
+ COLOR_WHITE 7 */
+/*
+ * init_color(COLOR_RED, 700, 0, 0);
+ * param 1 : color name
+ * param 2, 3, 4 : rgb content min = 0, max = 1000 */
+
+static const int colors[][2] = {
+ /* fg bg */
+ { COLOR_MAGENTA, COLOR_BLACK }, /* [0] status bar */
+ { COLOR_BLACK, COLOR_GREEN }, /* [1] selected item */
+ { COLOR_CYAN, COLOR_BLACK }, /* [2] dialog headers */
+ { COLOR_MAGENTA, COLOR_BLACK }, /* [3] top bar headers */
+ { COLOR_WHITE, COLOR_RED }, /* [3] top bar headers */
+};
+
+/* Color pair indices */
+#define COLOR_STATUS_BAR 1
+#define COLOR_SELECTED 2
+#define COLOR_DIALOG_HEADER 3
+#define COLOR_TOP_BAR 4
+#define COLOR_UI_STATUS 5
+
+/* password gen */
+#define PW_LEN 32 /* default pw length */
+/* charset for password gen */
+const char charset[] = "abcdefghijklmnopqrstuvwxyz"
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "0123456789!@#$%^&*()_+-=[]{}";
+/* misc */
+#define clr_clipboard 15 /* timer to clear clipboard in seconds */
+#define KEY_ESC 27 /* define esc key */
+/* help */
+const char *help_text[] = {
+ "Keys:",
+ "q - quit",
+ "j/k - move up/down",
+ "a - add entry",
+ "d - delete entry",
+ "e - edit entry",
+ "y - copy password to clipboard",
+ "g - generate new password",
+ "/ - search",
+ "Tab - toggle view",
+ "? - show this help"};
diff --git a/config.mk b/config.mk
@@ -0,0 +1,54 @@
+# spm version
+VERSION = 0.1
+
+# paths
+PREFIX = /usr/local
+MANPREFIX = ${PREFIX}/share/man
+
+# Linux/BSD common flags
+CFLAGS = -std=c99 -pedantic -Wall -Wno-deprecated-declarations -Os
+LDFLAGS = -s
+LIBS = -lncurses -lsodium
+
+# OS-specific flags
+UNAME_S := $(shell uname -s)
+
+# Linux
+ifeq ($(UNAME_S),Linux)
+ # Void Linux specific paths (default)
+ INCS = -I/usr/include
+ LIBS += -L/usr/lib
+endif
+
+# OpenBSD
+ifeq ($(UNAME_S),OpenBSD)
+ INCS = -I${PREFIX}/include
+ LIBS += -L${PREFIX}/lib
+endif
+
+# FreeBSD
+ifeq ($(UNAME_S),FreeBSD)
+ INCS = -I${PREFIX}/include
+ LIBS += -L${PREFIX}/lib
+endif
+
+# NetBSD
+ifeq ($(UNAME_S),NetBSD)
+ INCS = -I${PREFIX}/include
+ LIBS += -L${PREFIX}/lib
+endif
+
+# Debug build
+# Uncomment these for debugging
+#CFLAGS = -std=c99 -pedantic -Wall -O0 -g
+#LDFLAGS = -g
+
+# compiler and linker
+CC = cc
+
+# Feature flags
+CPPFLAGS = -D_DEFAULT_SOURCE -D_BSD_SOURCE -D_POSIX_C_SOURCE=200809L -DVERSION=\"${VERSION}\"
+
+# Final flags
+CFLAGS += ${INCS} ${CPPFLAGS}
+LDFLAGS += ${LIBS}
diff --git a/readme b/readme
@@ -0,0 +1,53 @@
+spm - simple password manager
+================================
+
+A terminal-based password manager using libsodium encryption
+
+Description
+----------
+spm is a minimal password manager that:
+- Stores encrypted passwords in a local database
+- Uses ncurses for terminal UI
+- Supports password generation
+- Copies passwords to X11/Wayland clipboard
+- Has no GUI or browser dependencies
+
+Requirements
+-----------
+In order to build spm, you need:
+
+- libsodium (encryption)
+- ncurses (UI)
+- xclip (X11) or wl-clipboard (Wayland)
+
+e.g. with apt:
+ apt install ncurses-dev libsodium-dev xclip
+
+
+Installation
+-----------
+ make
+ sudo make install
+
+Configuration
+------------
+See config.def.h for configuration
+
+Usage
+-----
+Create database:
+ spm database
+
+Open existing:
+ spm database
+
+Keys:
+ j/k - navigate
+ a - add entry
+ d - delete entry
+ e - edit entry
+ y - copy password
+ g - generate password
+ / - search
+ Tab - toggle view
+ q - quit
diff --git a/spm.c b/spm.c
@@ -0,0 +1,1990 @@
+/* See LICENSE file for copyright and license details. */
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <ncurses.h>
+#include <sodium.h>
+#include <termios.h>
+#include <errno.h>
+#include <time.h>
+#include <sys/wait.h>
+#include <sys/ioctl.h>
+#include <sys/stat.h>
+#include <stdarg.h>
+
+#include "config.def.h"
+
+/* arbitrary sizes */
+#define MAX_PATH 256
+#define MAX_PASS 2048
+#define XOR_KEY 0x42
+
+/* macros */
+#define XSTR(x) STR(x)
+#define STR(x) #x
+#define LENGTH(X) (sizeof(X) / sizeof(X[0]))
+#define DB_MAGIC get_magic()
+#define VERIFY_STRING get_verify()
+#define SALT_LEN crypto_pwhash_SALTBYTES
+#define KEY_LEN crypto_secretbox_KEYBYTES
+#define NONCE_LEN crypto_secretbox_NONCEBYTES
+#define MAC_LEN crypto_secretbox_MACBYTES
+
+enum {
+ VIEW_LIST = 0,
+ VIEW_ENTRY,
+ VIEW_SEARCH,
+};
+
+/* types */
+typedef struct Entry {
+ char *path;
+ char *title;
+ char *user;
+ char *pass;
+ char *notes;
+ time_t modified;
+ struct Entry *next;
+} Entry;
+
+typedef struct {
+ Entry *entries;
+ char *dbpath;
+ unsigned char key[KEY_LEN];
+ unsigned char salt[SALT_LEN];
+ int view;
+ Entry *selected;
+ char search[MAX_PATH];
+} DB;
+
+typedef struct {
+ const char *cmd;
+ void (*func)(void);
+} Binding;
+
+/* globals */
+static DB db;
+static WINDOW *mainwin;
+static WINDOW *statuswin;
+
+static const unsigned char _magic[] = {
+ 'S' ^ XOR_KEY, 'L' ^ XOR_KEY, 'P' ^ XOR_KEY, 'A' ^ XOR_KEY,
+ 'S' ^ XOR_KEY, 'S' ^ XOR_KEY, '0' ^ XOR_KEY, '2' ^ XOR_KEY
+};
+
+static const unsigned char _verify[] = {
+ 'S' ^ XOR_KEY, 'L' ^ XOR_KEY, 'P' ^ XOR_KEY, 'A' ^ XOR_KEY,
+ 'S' ^ XOR_KEY, 'S' ^ XOR_KEY, 'O' ^ XOR_KEY, 'K' ^ XOR_KEY
+};
+
+/* function declarations */
+static void cleanup(void);
+static int createdb(const char *path, const char *masterpass);
+static const char *get_magic(void);
+static const char *get_verify(void);
+static int loaddb(const char *path, const char *masterpass);
+static int savedb(void);
+static Entry *addentry(const char *path, const char *title);
+static void delentry(Entry *e);
+static int encrypt(const char *plain, char **cipher);
+static int encrypt_data(const unsigned char *data, size_t len, unsigned char **encrypted, size_t *outlen);
+static int decrypt_data(const unsigned char *encrypted, size_t len, unsigned char **decrypted, size_t *outlen);
+static int write_encrypted_string(FILE *fp, const char *str);
+static int read_encrypted_string(FILE *fp, char **str);
+static void die(const char *fmt, ...);
+static void ui_init(void);
+static void ui_cleanup(void);
+static void ui_draw(void);
+static void ui_status(const char *fmt, ...);
+static void handle_resize(void);
+static WINDOW *create_dialog(int height, int width, const char *title);
+static void cmd_add(void);
+static void cmd_edit(void);
+static void cmd_copy(void);
+static void cmd_generate(void);
+static void cmd_search(void);
+static char *readpass(const char *prompt);
+static char *genpass(int len);
+static int copyto_clipboard(const char *text);
+
+/* implementation */
+static void
+delentry(Entry *e)
+{
+ Entry *prev;
+
+ if (!e)
+ return;
+
+ /* Find previous entry */
+ if (e == db.entries) {
+ db.entries = e->next;
+ } else {
+ for (prev = db.entries; prev && prev->next != e; prev = prev->next)
+ ;
+ if (prev)
+ prev->next = e->next;
+ }
+
+ /* Securely free all fields */
+ if (e->path) {
+ sodium_memzero(e->path, strlen(e->path));
+ free(e->path);
+ }
+
+ if (e->title) {
+ sodium_memzero(e->title, strlen(e->title));
+ free(e->title);
+ }
+
+ if (e->user) {
+ sodium_memzero(e->user, strlen(e->user));
+ free(e->user);
+ }
+
+ if (e->pass) {
+ sodium_memzero(e->pass, strlen(e->pass));
+ free(e->pass);
+ }
+
+ if (e->notes) {
+ sodium_memzero(e->notes, strlen(e->notes));
+ free(e->notes);
+ }
+
+ /* Zero out the entry structure before freeing */
+ sodium_memzero(e, sizeof(Entry));
+ free(e);
+}
+
+static int
+encrypt_data(const unsigned char *data, size_t len, unsigned char **encrypted, size_t *outlen)
+{
+ unsigned char nonce[NONCE_LEN];
+ size_t clen = MAC_LEN + len;
+ unsigned char *c;
+ char *b64;
+ size_t b64len;
+
+ /* Allocate buffer for encrypted data + nonce */
+ c = malloc(NONCE_LEN + clen);
+ if (!c) return -1;
+
+ /* Generate random nonce */
+ randombytes_buf(nonce, sizeof(nonce));
+ memcpy(c, nonce, NONCE_LEN);
+
+ /* Encrypt */
+ if (crypto_secretbox_easy(c + NONCE_LEN, data, len, nonce, db.key) != 0) {
+ free(c);
+ return -1;
+ }
+
+ /* Convert to base64 */
+ b64len = sodium_base64_encoded_len(NONCE_LEN + clen, sodium_base64_VARIANT_ORIGINAL);
+ b64 = malloc(b64len);
+ if (!b64) {
+ free(c);
+ return -1;
+ }
+
+ sodium_bin2base64(b64, b64len,
+ c, NONCE_LEN + clen,
+ sodium_base64_VARIANT_ORIGINAL);
+
+ *encrypted = (unsigned char *)b64;
+ *outlen = strlen(b64);
+
+ free(c);
+ return 0;
+}
+
+static int
+decrypt_data(const unsigned char *encrypted, size_t len, unsigned char **decrypted, size_t *outlen)
+{
+ unsigned char *bin;
+ size_t bin_len;
+ unsigned char *p;
+ int ret = -1;
+
+ /* Decode base64 */
+ bin = malloc(len); /* Will be smaller than base64 */
+ if (!bin) return -1;
+
+ if (sodium_base642bin(bin, len,
+ (const char *)encrypted, len,
+ NULL, &bin_len,
+ NULL,
+ sodium_base64_VARIANT_ORIGINAL) != 0) {
+ free(bin);
+ return -1;
+ }
+
+ if (bin_len <= NONCE_LEN + MAC_LEN) {
+ free(bin);
+ return -1;
+ }
+
+ /* Allocate output buffer */
+ *outlen = bin_len - NONCE_LEN - MAC_LEN;
+ p = malloc(*outlen + 1);
+ if (!p) {
+ free(bin);
+ return -1;
+ }
+
+ /* Decrypt */
+ if (crypto_secretbox_open_easy(p,
+ bin + NONCE_LEN,
+ bin_len - NONCE_LEN,
+ bin, /* nonce at start */
+ db.key) == 0) {
+ p[*outlen] = '\0';
+ *decrypted = p;
+ ret = 0;
+ } else {
+ free(p);
+ }
+
+ free(bin);
+ return ret;
+}
+
+static int
+write_encrypted_string(FILE *fp, const char *str)
+{
+ unsigned char *encrypted;
+ size_t enclen;
+ uint32_t len;
+
+ if (!str) {
+ len = 0;
+ fwrite(&len, sizeof(len), 1, fp);
+ return 0;
+ }
+
+ if (encrypt_data((unsigned char *)str, strlen(str), &encrypted, &enclen) != 0)
+ return -1;
+
+ len = enclen;
+ if (fwrite(&len, sizeof(len), 1, fp) != 1) {
+ free(encrypted);
+ return -1;
+ }
+
+ if (fwrite(encrypted, 1, enclen, fp) != enclen) {
+ free(encrypted);
+ return -1;
+ }
+
+ free(encrypted);
+ return 0;
+}
+
+static int
+read_encrypted_string(FILE *fp, char **str)
+{
+ uint32_t len;
+ unsigned char *encrypted;
+ unsigned char *decrypted;
+ size_t declen;
+
+ *str = NULL; /* Initialize to NULL */
+
+ if (fread(&len, sizeof(len), 1, fp) != 1) {
+ return -1;
+ }
+
+ if (len == 0) {
+ return 0;
+ }
+
+ encrypted = malloc(len);
+ if (!encrypted) {
+ return -1;
+ }
+
+ if (fread(encrypted, 1, len, fp) != len) {
+ free(encrypted);
+ return -1;
+ }
+
+ if (decrypt_data(encrypted, len, &decrypted, &declen) != 0) {
+ free(encrypted);
+ return -1;
+ }
+
+ free(encrypted);
+ *str = (char *)decrypted;
+ return 0;
+}
+
+static void
+die(const char *fmt, ...)
+{
+ va_list ap;
+
+ va_start(ap, fmt);
+ vfprintf(stderr, fmt, ap);
+ va_end(ap);
+ fprintf(stderr, "\n");
+ exit(1);
+}
+
+static void
+cleanup(void)
+{
+ Entry *e, *next;
+
+ for (e = db.entries; e != NULL; e = next) {
+ next = e->next;
+ if (e->path) free(e->path);
+ if (e->title) free(e->title);
+ if (e->user) free(e->user);
+ if (e->pass) {
+ sodium_memzero(e->pass, strlen(e->pass));
+ free(e->pass);
+ }
+ if (e->notes) free(e->notes);
+ free(e);
+ }
+
+ sodium_memzero(&db, sizeof(db));
+}
+
+static int
+savedb(void)
+{
+ FILE *fp;
+ Entry *e;
+ uint32_t count = 0;
+ uint32_t version = 2;
+ unsigned char *encrypted;
+ size_t enclen;
+
+ if (!(fp = fopen(db.dbpath, "wb")))
+ return -1;
+
+ /* Write magic and version */
+ fwrite(DB_MAGIC, 1, strlen(DB_MAGIC), fp);
+ fwrite(&version, sizeof(version), 1, fp);
+
+ /* Write salt */
+ fwrite(db.salt, 1, sizeof(db.salt), fp);
+
+ /* Create verification block with constant string */
+ if (encrypt_data((const unsigned char *)VERIFY_STRING,
+ strlen(VERIFY_STRING),
+ &encrypted, &enclen) != 0) {
+ fclose(fp);
+ return -1;
+ }
+
+ /* Write verification block */
+ fwrite(&enclen, sizeof(enclen), 1, fp);
+ fwrite(encrypted, 1, enclen, fp);
+ free(encrypted);
+
+ /* Count entries */
+ for (e = db.entries; e; e = e->next)
+ count++;
+
+ /* Write entry count */
+ fwrite(&count, sizeof(count), 1, fp);
+
+ /* Write each entry */
+ for (e = db.entries; e; e = e->next) {
+ if (write_encrypted_string(fp, e->path) != 0 ||
+ write_encrypted_string(fp, e->title) != 0 ||
+ write_encrypted_string(fp, e->user) != 0 ||
+ write_encrypted_string(fp, e->pass) != 0 ||
+ write_encrypted_string(fp, e->notes) != 0) {
+ fclose(fp);
+ return -1;
+ }
+ fwrite(&e->modified, sizeof(e->modified), 1, fp);
+ }
+
+ fclose(fp);
+ return 0;
+}
+
+static const char *
+get_magic(void)
+{
+ static char magic[sizeof(_magic) + 1];
+ for (size_t i = 0; i < sizeof(_magic); i++)
+ magic[i] = _magic[i] ^ XOR_KEY;
+ magic[sizeof(_magic)] = '\0';
+ return magic;
+}
+
+static const char *
+get_verify(void)
+{
+ static char verify[sizeof(_verify) + 1];
+ for (size_t i = 0; i < sizeof(_verify); i++)
+ verify[i] = _verify[i] ^ XOR_KEY;
+ verify[sizeof(_verify)] = '\0';
+ return verify;
+}
+
+static int
+createdb(const char *path, const char *masterpass)
+{
+ FILE *fp;
+ uint32_t version = 2;
+ unsigned char *encrypted;
+ size_t enclen;
+
+ if (access(path, F_OK) == 0)
+ return -1;
+
+ if (!(fp = fopen(path, "wb")))
+ return -1;
+
+ if (chmod(path, S_IRUSR|S_IWUSR) != 0){
+ fclose(fp);
+ unlink(path);
+ return -1;
+ }
+
+ /* Write magic and version */
+ fwrite(DB_MAGIC, 1, strlen(DB_MAGIC), fp);
+ fwrite(&version, sizeof(version), 1, fp);
+
+ /* Generate new salt and derive key */
+ randombytes_buf(db.salt, sizeof(db.salt));
+ if (crypto_pwhash(db.key, sizeof(db.key),
+ masterpass, strlen(masterpass),
+ db.salt,
+ crypto_pwhash_OPSLIMIT_INTERACTIVE,
+ crypto_pwhash_MEMLIMIT_INTERACTIVE,
+ crypto_pwhash_ALG_DEFAULT) != 0) {
+ fclose(fp);
+ return -1;
+ }
+
+ /* Write salt */
+ fwrite(db.salt, 1, sizeof(db.salt), fp);
+
+ if (encrypt_data((const unsigned char *)VERIFY_STRING,
+ strlen(VERIFY_STRING),
+ &encrypted, &enclen) != 0) {
+ fclose(fp);
+ return -1;
+ }
+
+ fwrite(&enclen, sizeof(enclen), 1, fp);
+ fwrite(encrypted, 1, enclen, fp);
+ free(encrypted);
+
+ uint32_t count = 0;
+ fwrite(&count, sizeof(count), 1, fp);
+
+ fclose(fp);
+ return 0;
+}
+
+static Entry *
+addentry(const char *path, const char *title)
+{
+ Entry *e;
+
+ e = calloc(1, sizeof(Entry));
+ if (!e)
+ return NULL;
+
+ e->path = strdup(path);
+ e->title = strdup(title);
+ e->next = db.entries;
+ db.entries = e;
+
+ return e;
+}
+
+static void
+cmd_edit(void)
+{
+ WINDOW *win;
+ char buf[MAX_PATH];
+ char *pass = NULL;
+ int y = 1;
+ int c;
+
+ if (!db.selected)
+ return;
+
+ /* Create centered dialog window */
+ win = create_dialog(14, COLS - 20, "Edit Entry");
+ if (!win) {
+ ui_status("Failed to create dialog window");
+ return;
+ }
+
+ /* Header with instructions */
+ wattron(win, A_BOLD);
+ mvwprintw(win, y++, 2, "Editing Entry");
+ wattroff(win, A_BOLD);
+ wattron(win, A_DIM);
+ mvwprintw(win, y++, 2, "(Press Enter to keep current value)");
+ wattroff(win, A_DIM);
+ y++; /* Add space */
+
+ keypad(win, TRUE); /* Enable special keys */
+
+ /* Path input */
+ wattron(win, A_BOLD);
+ mvwprintw(win, y, 2, "Path: ");
+ wattroff(win, A_BOLD);
+ mvwprintw(win, y, 8, "[%s]", db.selected->path ? db.selected->path : "");
+ wmove(win, y++, 8);
+ wclrtoeol(win);
+ nodelay(win, TRUE);
+ echo();
+
+ /* Check for ESC */
+ c = wgetch(win);
+ if (c == 27) {
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ ui_status("Edit cancelled");
+ return;
+ }
+ if (c != ERR) ungetch(c);
+ nodelay(win, FALSE);
+
+ wgetnstr(win, buf, sizeof(buf));
+ if (strlen(buf)) {
+ free(db.selected->path);
+ db.selected->path = strdup(buf);
+ }
+
+ /* Title input */
+ wattron(win, A_BOLD);
+ mvwprintw(win, y, 2, "Title: ");
+ wattroff(win, A_BOLD);
+ mvwprintw(win, y, 9, "[%s]", db.selected->title ? db.selected->title : "");
+ wmove(win, y++, 9);
+ wclrtoeol(win);
+ nodelay(win, TRUE);
+
+ c = wgetch(win);
+ if (c == 27) {
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ ui_status("Edit cancelled");
+ return;
+ }
+ if (c != ERR) ungetch(c);
+ nodelay(win, FALSE);
+
+ wgetnstr(win, buf, sizeof(buf));
+ if (strlen(buf)) {
+ free(db.selected->title);
+ db.selected->title = strdup(buf);
+ }
+
+ /* Username input */
+ wattron(win, A_BOLD);
+ mvwprintw(win, y, 2, "Username: ");
+ wattroff(win, A_BOLD);
+ mvwprintw(win, y, 12, "[%s]", db.selected->user ? db.selected->user : "");
+ wmove(win, y++, 12);
+ wclrtoeol(win);
+ nodelay(win, TRUE);
+
+ c = wgetch(win);
+ if (c == 27) {
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ ui_status("Edit cancelled");
+ return;
+ }
+ if (c != ERR) ungetch(c);
+ nodelay(win, FALSE);
+
+ wgetnstr(win, buf, sizeof(buf));
+ if (strlen(buf)) {
+ free(db.selected->user);
+ db.selected->user = strdup(buf);
+ }
+
+ /* Notes input */
+ wattron(win, A_BOLD);
+ mvwprintw(win, y, 2, "Notes: ");
+ wattroff(win, A_BOLD);
+ mvwprintw(win, y, 9, "[%s]", db.selected->notes ? db.selected->notes : "");
+ wmove(win, y++, 9);
+ wclrtoeol(win);
+ nodelay(win, TRUE);
+
+ c = wgetch(win);
+ if (c == 27) {
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ ui_status("Edit cancelled");
+ return;
+ }
+ if (c != ERR) ungetch(c);
+ nodelay(win, FALSE);
+
+ wgetnstr(win, buf, sizeof(buf));
+ if (strlen(buf)) {
+ free(db.selected->notes);
+ db.selected->notes = strdup(buf);
+ }
+
+ /* Password handling */
+ y++;
+ wattron(win, A_BOLD);
+ mvwprintw(win, y++, 2, "Change password? (y/n/ESC)");
+ wattroff(win, A_BOLD);
+ noecho();
+ wrefresh(win);
+ c = wgetch(win);
+
+ if (c == 27) { /* ESC */
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ ui_status("Edit cancelled");
+ return;
+ }
+
+ if (c == 'y' || c == 'Y') {
+ wattron(win, A_BOLD);
+ mvwprintw(win, y++, 2, "Generate new random password? (y/n/ESC)");
+ wattroff(win, A_BOLD);
+ wrefresh(win);
+ c = wgetch(win);
+
+ if (c == 27) { /* ESC */
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ ui_status("Edit cancelled");
+ return;
+ }
+
+ if (c == 'y' || c == 'Y') {
+ /* Generate new password */
+ pass = genpass(PW_LEN);
+ if (pass) {
+ wattron(win, A_BOLD);
+ mvwprintw(win, y++, 2, "New password generated and copied to clipboard");
+ wattroff(win, A_BOLD);
+ wrefresh(win);
+ if (copyto_clipboard(pass) == 0) {
+ ui_status("Generated password copied to clipboard");
+ }
+ }
+ } else {
+ /* Manual password entry */
+ wattron(win, A_BOLD);
+ mvwprintw(win, y++, 2, "Enter new password");
+ wattroff(win, A_BOLD);
+ wattron(win, A_DIM);
+ mvwprintw(win, y++, 2, "(Password will not be shown while typing)");
+ wattroff(win, A_DIM);
+ wrefresh(win);
+ pass = readpass("");
+
+ if (!pass) { /* ESC was pressed in readpass */
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ ui_status("Edit cancelled");
+ return;
+ }
+ }
+
+ /* Update password if provided */
+ if (pass && strlen(pass) > 0) {
+ unsigned char *encrypted;
+ size_t enclen;
+
+ /* Clean up old password */
+ if (db.selected->pass) {
+ sodium_memzero(db.selected->pass, strlen(db.selected->pass));
+ free(db.selected->pass);
+ db.selected->pass = NULL;
+ }
+
+ /* Encrypt and store new password */
+ if (encrypt_data((const unsigned char *)pass, strlen(pass),
+ &encrypted, &enclen) != 0) {
+ ui_status("Failed to encrypt password");
+ sodium_memzero(pass, strlen(pass));
+ free(pass);
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ refresh();
+ return;
+ }
+ db.selected->pass = (char *)encrypted;
+ }
+ }
+
+ /* Cleanup sensitive data */
+ if (pass) {
+ sodium_memzero(pass, strlen(pass));
+ free(pass);
+ }
+
+ /* Update modification time and save */
+ db.selected->modified = time(NULL);
+ if (savedb() != 0) {
+ ui_status("Failed to save database");
+ } else {
+ ui_status("Entry updated successfully");
+ }
+
+ /* Clean up window */
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ ui_draw();
+}
+
+static char *
+readpass(const char *prompt)
+{
+ char *pass = NULL;
+ WINDOW *win;
+
+ /* Create a new window for password input */
+ win = create_dialog(5, COLS-8, "Password Input");
+
+ /* Show prompt and info */
+ mvwprintw(win, 1, 2, "%s", prompt);
+ wattron(win, A_DIM); /* Dimmed text for info message */
+ mvwprintw(win, 2, 2, "(Password will not be shown while typing)");
+ wattroff(win, A_DIM);
+ wrefresh(win);
+
+ /* Read password */
+ pass = malloc(MAX_PASS);
+ if (pass) {
+ noecho(); /* Ensure password isn't shown */
+ curs_set(1); /* Show cursor while typing */
+ wmove(win, 1, 2 + strlen(prompt));
+ wgetnstr(win, pass, MAX_PASS-1);
+ curs_set(0); /* Hide cursor again */
+ }
+
+ /* Clean up */
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ wrefresh(mainwin);
+ wrefresh(statuswin);
+
+ return pass;
+}
+
+static void
+ui_status(const char *fmt, ...)
+{
+ va_list ap;
+
+ werase(statuswin);
+ wmove(statuswin, 0, 0);
+ wattron(statuswin, COLOR_PAIR(COLOR_UI_STATUS));
+ va_start(ap, fmt);
+ vw_printw(statuswin, fmt, ap);
+ va_end(ap);
+ wattroff(statuswin, COLOR_PAIR(COLOR_UI_STATUS));
+ wrefresh(statuswin);
+}
+
+static int
+encrypt(const char *plain, char **cipher)
+{
+ unsigned char nonce[crypto_secretbox_NONCEBYTES];
+ unsigned char *c;
+ unsigned char *message = (unsigned char *)plain;
+ char *b64;
+ size_t clen = crypto_secretbox_MACBYTES + strlen(plain);
+ size_t mlen = strlen(plain);
+ size_t b64len;
+
+ /* Allocate buffer for encrypted data + nonce */
+ c = malloc(crypto_secretbox_NONCEBYTES + clen);
+ if (!c) return -1;
+
+ /* Generate random nonce */
+ randombytes_buf(nonce, sizeof(nonce));
+ memcpy(c, nonce, sizeof(nonce));
+
+ /* Encrypt */
+ if (crypto_secretbox_easy(c + crypto_secretbox_NONCEBYTES,
+ message, mlen,
+ nonce, db.key) != 0) {
+ free(c);
+ return -1;
+ }
+
+ /* Convert to base64 */
+ b64len = sodium_base64_encoded_len(crypto_secretbox_NONCEBYTES + clen,
+ sodium_base64_VARIANT_ORIGINAL);
+ b64 = malloc(b64len);
+ if (!b64) {
+ free(c);
+ return -1;
+ }
+
+ sodium_bin2base64(b64, b64len,
+ c, crypto_secretbox_NONCEBYTES + clen,
+ sodium_base64_VARIANT_ORIGINAL);
+
+ *cipher = b64;
+ free(c);
+ return 0;
+}
+
+static char *
+genpass(int len)
+{
+
+ char *pass;
+ int i;
+
+ if (len < 1)
+ return NULL;
+
+ pass = malloc(len + 1);
+ if (!pass)
+ return NULL;
+
+ for (i = 0; i < len; i++)
+ pass[i] = charset[randombytes_uniform(sizeof(charset) - 1)];
+ pass[len] = '\0';
+
+ return pass;
+}
+
+static void
+handle_resize(void)
+{
+ endwin();
+ refresh();
+ clear();
+
+ /* Recreate windows with new size */
+ if (mainwin)
+ delwin(mainwin);
+ if (statuswin)
+ delwin(statuswin);
+
+ mainwin = newwin(LINES - 3, COLS, 0, 0);
+ statuswin = newwin(3, COLS, LINES - 3, 0);
+
+ /* Reset window properties */
+ keypad(mainwin, TRUE);
+ scrollok(mainwin, TRUE);
+ wbkgd(statuswin, COLOR_PAIR(COLOR_STATUS_BAR));
+ box(statuswin, 0, 0);
+
+ /* Redraw everything */
+ ui_draw();
+ refresh();
+ wrefresh(mainwin);
+ wrefresh(statuswin);
+}
+
+static WINDOW *
+create_dialog(int height, int width, const char *title)
+{
+ WINDOW *win;
+ int starty, startx;
+
+ /* Ensure minimum size and maximum size */
+ height = height < 3 ? 3 : height;
+ width = width < 20 ? 20 : width;
+
+ /* Don't exceed screen size */
+ if (height > LINES - 2)
+ height = LINES - 2;
+ if (width > COLS - 2)
+ width = COLS - 2;
+
+ /* Center the window */
+ starty = (LINES - height) / 2;
+ startx = (COLS - width) / 2;
+
+ /* Create window with border */
+ win = newwin(height, width, starty, startx);
+ box(win, 0, 0);
+
+ /* Add title if provided */
+ if (title) {
+ int title_len = strlen(title);
+ if (title_len > width - 4)
+ title_len = width - 4;
+ wattron(win, COLOR_PAIR(COLOR_DIALOG_HEADER));
+
+ mvwprintw(win, 0, (width - title_len - 2) / 2, " %.*s ", title_len, title);
+ wattroff(win, COLOR_PAIR(COLOR_DIALOG_HEADER));
+ }
+
+ /* Ensure the window is visible */
+ wrefresh(win);
+ return win;
+}
+
+static int
+copyto_clipboard(const char *text)
+{
+ FILE *pipe;
+ size_t text_len;
+ const char *cmd = NULL;
+ pid_t pid;
+
+ if (!text)
+ return -1;
+
+ text_len = strlen(text);
+
+ if (getenv("WAYLAND_DISPLAY")) {
+ cmd = "wl-copy";
+ } else if (getenv("DISPLAY")) {
+ cmd = "xclip -selection clipboard";
+ } else {
+ ui_status("No clipboard available (neither Wayland nor X11)");
+ return -1;
+ }
+
+ /* Copy to clipboard */
+ pipe = popen(cmd, "w");
+ if (!pipe) {
+ return -1;
+ }
+ if (fwrite(text, 1, text_len, pipe) != text_len) {
+ pclose(pipe);
+ return -1;
+ }
+ if (pclose(pipe) != 0) {
+ return -1;
+ }
+
+ /* Fork process for clipboard clearing */
+ if ((pid = fork()) == 0) {
+ /* Child process */
+ sleep(clr_clipboard);
+ pipe = popen(cmd, "w");
+ if (pipe) {
+ fwrite("", 1, 0, pipe);
+ pclose(pipe);
+ }
+ _exit(0);
+ }
+
+ return 0;
+}
+
+static void
+ui_init(void)
+{
+
+ /* Initialize colors */
+ start_color();
+ for (size_t i = 0; i < LENGTH(colors); i++) {
+ init_pair(i + 1, colors[i][0], colors[i][1]);
+ }
+
+ /* Create main windows with borders */
+ mainwin = newwin(LINES - 3, COLS, 0, 0);
+ statuswin = newwin(3, COLS, LINES - 3, 0);
+
+ /* Enable keypad and scrolling */
+ keypad(mainwin, TRUE);
+ scrollok(mainwin, TRUE);
+
+ /* Set up status window */
+ wbkgd(statuswin, COLOR_PAIR(COLOR_STATUS_BAR));
+ box(statuswin, 0, 0);
+}
+
+static void
+ui_cleanup(void)
+{
+ if (mainwin) delwin(mainwin);
+ if (statuswin) delwin(statuswin);
+ endwin();
+}
+
+static void
+ui_draw(void)
+{
+ Entry *e;
+ int y = 0;
+ char timestr[32];
+ struct tm *tm;
+ int available_width = COLS;
+ /* Declare all column widths at function start */
+ int time_width = 19; /* Fixed width for timestamp */
+ int path_width; /* Will be set in each view */
+ int title_width;
+ int user_width;
+
+ werase(mainwin);
+
+ switch (db.view) {
+
+ case VIEW_LIST:
+ /* Calculate column widths */
+ time_width = 19; /* Fixed width for timestamp */
+ path_width = (available_width - time_width - 6) / 3;
+ title_width = path_width;
+ user_width = available_width - time_width - path_width - title_width - 6;
+
+ /* Draw header without separators */
+ wattron(mainwin, COLOR_PAIR(COLOR_TOP_BAR) | A_BOLD);
+ mvwhline(mainwin, 0, 0, ' ', available_width); // Clear the line first with spaces
+
+ /* Draw headers with proper spacing */
+ mvwprintw(mainwin, 0, 0, "%-*s", time_width, "Modified");
+ mvwprintw(mainwin, 0, time_width + 2, "%-*s", path_width, "Category");
+ mvwprintw(mainwin, 0, time_width + path_width + 4, "%-*s", title_width, "Title");
+ mvwprintw(mainwin, 0, time_width + path_width + title_width + 6, "%-*s", user_width, "Username");
+
+ wattroff(mainwin, COLOR_PAIR(COLOR_TOP_BAR) | A_BOLD);
+
+ /* Draw header background */
+// wattron(mainwin, A_REVERSE);
+// mvwhline(mainwin, 0, 0, ' ', available_width);
+
+ /* Draw centered headers */
+// wattron(mainwin, COLOR_PAIR(COLOR_TOP_BAR) | A_BOLD); /* Use your defined color pair */
+// mvwprintw(mainwin, 0, 0, "%*s%*s",
+// mod_len, "Modified", mod_pad, "");
+// mvwprintw(mainwin, 0, time_width + 1, "%*s%*s",
+// path_len, "Path", path_pad, "");
+// mvwprintw(mainwin, 0, time_width + path_width + 2, "%*s%*s",
+// title_len, "Title", title_pad, "");
+// mvwprintw(mainwin, 0, time_width + path_width + title_width + 3, "%*s%*s",
+// user_len, "Username", user_pad, "");
+// wattroff(mainwin, COLOR_PAIR(COLOR_TOP_BAR) | A_BOLD);
+// wattroff(mainwin, A_REVERSE);
+
+ /* Separator line */
+ // wattron(mainwin, A_DIM);
+ // mvwaddch(mainwin, 1, time_width, ACS_VLINE);
+ // mvwaddch(mainwin, 1, time_width + path_width + 1, ACS_VLINE);
+ // mvwaddch(mainwin, 1, time_width + path_width + title_width + 2, ACS_VLINE);
+ // mvwhline(mainwin, 1, 0, ACS_HLINE, available_width);
+ // wattroff(mainwin, A_DIM);
+
+ /* Entries (left-aligned) */
+ y = 2;
+ for (e = db.entries; e && y < LINES-2; e = e->next) {
+ if (e == db.selected)
+
+ wattron(mainwin, COLOR_PAIR(COLOR_SELECTED));
+
+ tm = localtime(&e->modified);
+ strftime(timestr, sizeof(timestr), "%Y-%m-%d %H:%M", tm);
+
+ /* Left align entries */
+ mvwprintw(mainwin, y, 0, "%-*s", time_width, timestr);
+ waddch(mainwin, ' ');
+ mvwprintw(mainwin, y, time_width + 1, "%-*s", path_width, e->path ? e->path : "");
+ waddch(mainwin, ' ');
+ mvwprintw(mainwin, y, time_width + path_width + 2, "%-*s", title_width, e->title ? e->title : "");
+ waddch(mainwin, ' ');
+ mvwprintw(mainwin, y, time_width + path_width + title_width + 3, "%-*s", user_width, e->user ? e->user : "");
+
+ if (e == db.selected)
+ wattroff(mainwin, COLOR_PAIR(COLOR_SELECTED));
+
+ y++;
+ }
+ break;
+
+ case VIEW_ENTRY:
+ /* Detail view implementation remains the same */
+ if (db.selected) {
+ tm = localtime(&db.selected->modified);
+ strftime(timestr, sizeof(timestr), "%Y-%m-%d %H:%M", tm);
+
+ wattron(mainwin, A_BOLD);
+ mvwprintw(mainwin, y++, 0, "Details");
+ wattroff(mainwin, A_BOLD);
+ mvwhline(mainwin, y++, 0, ACS_HLINE, available_width);
+
+ mvwprintw(mainwin, y++, 0, "Modified: %s", timestr);
+ mvwprintw(mainwin, y++, 0, "Path: %s", db.selected->path ? db.selected->path : "");
+ mvwprintw(mainwin, y++, 0, "Title: %s", db.selected->title ? db.selected->title : "");
+ mvwprintw(mainwin, y++, 0, "Username: %s", db.selected->user ? db.selected->user : "");
+ mvwprintw(mainwin, y++, 0, "Password: [Hidden - Press 'y' to copy]");
+
+ if (db.selected->notes) {
+ mvwprintw(mainwin, y++, 0, "Notes: %s", db.selected->notes);
+ }
+ }
+ break;
+
+case VIEW_SEARCH: {
+ int matches = 0;
+ int i = 0;
+ int idx = 0;
+ Entry *first_match = NULL;
+ Entry **matched_entries = NULL;
+
+ /* Calculate column widths */
+ path_width = (available_width - time_width - 6) / 3;
+ title_width = path_width;
+ user_width = available_width - time_width - path_width - title_width - 6;
+
+ /* Ensure the columns fit within available space */
+ if (path_width + title_width + user_width + 6 > available_width) {
+ ui_status("Column widths exceed available space!");
+ break;
+ }
+
+ /* First collect all matches across all fields */
+ for (e = db.entries; e; e = e->next) {
+ if (strstr(e->path, db.search) ||
+ strstr(e->title, db.search) ||
+ (e->user && strstr(e->user, db.search))) {
+ matches++;
+ }
+ }
+
+ /* Create array of matches */
+ if (matches > 0) {
+ matched_entries = malloc(matches * sizeof(Entry*));
+ if (!matched_entries) {
+ ui_status("Memory allocation failed");
+ break;
+ }
+
+ /* Fill match array */
+ for (e = db.entries; e; e = e->next) {
+ if (strstr(e->path, db.search) ||
+ strstr(e->title, db.search) ||
+ (e->user && strstr(e->user, db.search))) {
+ matched_entries[idx++] = e;
+ if (!first_match) first_match = e;
+ }
+ }
+
+ /* Handle selection */
+ if (matches == 1) {
+ db.selected = first_match;
+ } else if (!db.selected ||
+ !(strstr(db.selected->path, db.search) ||
+ strstr(db.selected->title, db.search) ||
+ (db.selected->user && strstr(db.selected->user, db.search)))) {
+ db.selected = first_match;
+ }
+ }
+
+ /* Draw header - center title */
+ int title_length = snprintf(NULL, 0, "Search Results for \"%s\" (%d matches)", db.search, matches);
+ int title_start = (available_width - title_length) / 2;
+ wattron(mainwin, A_BOLD | A_REVERSE);
+ mvwhline(mainwin, 0, 0, ' ', available_width);
+ mvwprintw(mainwin, 0, title_start, "Search Results for \"%s\" (%d matches)", db.search, matches);
+ wattroff(mainwin, A_BOLD | A_REVERSE);
+
+ /* Draw separator with field headers */
+ wattron(mainwin, A_DIM);
+ mvwhline(mainwin, 1, 0, ACS_HLINE, available_width);
+ wattroff(mainwin, A_DIM);
+
+ /* Draw matched entries */
+ y = 2;
+ for (i = 0; i < matches && y < LINES-2; i++) {
+ e = matched_entries[i];
+
+ if (e == db.selected)
+ wattron(mainwin, COLOR_PAIR(COLOR_SELECTED));
+
+ /* Draw entry with all fields */
+ mvwprintw(mainwin, y, 0, "%-*s", path_width, e->path ? e->path : "");
+ mvwprintw(mainwin, y, path_width, "%-*s", title_width, e->title ? e->title : "");
+ mvwprintw(mainwin, y, path_width + title_width, "%-*s", user_width, e->user ? e->user : "");
+
+ if (e == db.selected)
+ wattroff(mainwin, COLOR_PAIR(COLOR_SELECTED));
+
+ y++;
+ }
+
+ /* Clean up */
+ if (matched_entries)
+ free(matched_entries);
+
+ break;
+ }
+}
+ /* Status bar */
+ wmove(statuswin, 1, 1);
+ wprintw(statuswin, " [a]dd [d]elete [e]dit [y]ank [/]search [tab]view [q]uit");
+ wrefresh(mainwin);
+ wrefresh(statuswin);
+}
+
+static void
+cmd_add(void)
+{
+ WINDOW *win;
+ char path[MAX_PATH] = {0};
+ char title[MAX_PATH] = {0};
+ char user[MAX_PATH] = {0};
+ char notes[MAX_PATH] = {0};
+ char *pass = NULL;
+ Entry *e;
+ int input_y = 3;
+ int label_x = 2; /* Position for labels */
+ int input_x = 15; /* Position for input fields */
+// int max_field_width; /* Maximum width for input fields */
+
+ /* Create centered dialog window */
+ win = create_dialog(16, COLS - 20, "Add New Entry");
+ if (!win) {
+ ui_status("Failed to create dialog window");
+ return;
+ }
+
+ /* Calculate maximum field width */
+ // max_field_width = COLS - input_x - 23; /* Leave some margin */
+
+ /* Header */
+ wattron(win, A_BOLD);
+ mvwprintw(win, 1, label_x, "New Entry");
+ wattroff(win, A_BOLD);
+
+ /* Print labels */
+ mvwprintw(win, input_y, label_x, "Path:");
+ wattron(win, A_DIM);
+ mvwprintw(win, input_y + 1, input_x, "(category/name, e.g., 'email/gmail')");
+ wattroff(win, A_DIM);
+
+ mvwprintw(win, input_y + 3, label_x, "Title:");
+ wattron(win, A_DIM);
+ mvwprintw(win, input_y + 4, input_x, "(descriptive name)");
+ wattroff(win, A_DIM);
+
+ mvwprintw(win, input_y + 6, label_x, "Username:");
+ wattron(win, A_DIM);
+ mvwprintw(win, input_y + 7, input_x, "(optional)");
+ wattroff(win, A_DIM);
+
+ mvwprintw(win, input_y + 9, label_x, "Notes:");
+ wattron(win, A_DIM);
+ mvwprintw(win, input_y + 10, input_x, "(optional)");
+ wattroff(win, A_DIM);
+
+ /* Get input with proper positioning */
+ echo();
+
+ /* Path input */
+ wmove(win, input_y, input_x);
+ wclrtoeol(win);
+ wrefresh(win);
+ wgetnstr(win, path, sizeof(path)-1);
+
+ /* Title input */
+ wmove(win, input_y + 3, input_x);
+ wclrtoeol(win);
+ wrefresh(win);
+ wgetnstr(win, title, sizeof(title)-1);
+
+ /* Username input */
+ wmove(win, input_y + 6, input_x);
+ wclrtoeol(win);
+ wrefresh(win);
+ wgetnstr(win, user, sizeof(user)-1);
+
+ /* Notes input */
+ wmove(win, input_y + 9, input_x);
+ wclrtoeol(win);
+ wrefresh(win);
+ wgetnstr(win, notes, sizeof(notes)-1);
+
+ noecho();
+
+ /* Password section */
+ mvwprintw(win, input_y + 12, label_x, "Generate random password? (y/n): ");
+ wrefresh(win);
+
+ int c = wgetch(win);
+
+ if (c == 'y' || c == 'Y') {
+ pass = genpass(PW_LEN);
+ if (pass) {
+ wclear(win); /* Clear the window first */
+ box(win, 0, 0); /* Redraw the box */
+ mvwprintw(win, 1, label_x, "Password Generated");
+ wattron(win, A_BOLD);
+ mvwprintw(win, 3, label_x, "Password generated and copied to clipboard");
+ wattroff(win, A_BOLD);
+ wrefresh(win);
+ if (copyto_clipboard(pass) == 0) {
+ ui_status("Generated password copied to clipboard");
+ }
+ }
+ } else {
+ wclear(win); /* Clear window */
+ box(win, 0, 0); /* Redraw box */
+ mvwprintw(win, 1, label_x, "Password Entry");
+ wattron(win, A_DIM);
+ mvwprintw(win, 3, label_x, "Enter password (will not be shown)");
+ wattroff(win, A_DIM);
+ wrefresh(win);
+ pass = readpass("");
+ }
+
+ /* Check input and create entry */
+ if (strlen(path) == 0 || strlen(title) == 0) {
+ ui_status("Cancelled: empty path or title");
+ if (pass) {
+ sodium_memzero(pass, strlen(pass));
+ free(pass);
+ }
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ refresh();
+ return;
+ }
+
+ e = addentry(path, title);
+ if (!e) {
+ ui_status("Failed to create entry");
+ if (pass) {
+ sodium_memzero(pass, strlen(pass));
+ free(pass);
+ }
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ refresh();
+ return;
+ }
+
+ /* Set optional fields */
+ if (strlen(user) > 0) {
+ e->user = strdup(user);
+ }
+ if (strlen(notes) > 0) {
+ e->notes = strdup(notes);
+ }
+
+ /* Handle password encryption */
+ if (pass && strlen(pass) > 0) {
+ unsigned char *encrypted;
+ size_t enclen;
+
+ if (encrypt_data((const unsigned char *)pass, strlen(pass),
+ &encrypted, &enclen) != 0) {
+ ui_status("Failed to encrypt password");
+ delentry(e);
+ sodium_memzero(pass, strlen(pass));
+ free(pass);
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ refresh();
+ return;
+ }
+ e->pass = (char *)encrypted;
+ }
+
+ /* Cleanup */
+ if (pass) {
+ sodium_memzero(pass, strlen(pass));
+ free(pass);
+ }
+
+ e->modified = time(NULL);
+ if (savedb() != 0) {
+ ui_status("Failed to save database");
+ } else {
+ ui_status("Entry added successfully");
+ }
+
+ /* Clean up window */
+ delwin(win);
+ touchwin(mainwin);
+ touchwin(statuswin);
+ ui_draw();
+}
+
+static void
+cmd_generate(void)
+{
+ char *pass;
+
+ if (!db.selected)
+ return;
+
+ pass = genpass(PW_LEN); /* default length */
+ if (pass) {
+ if (db.selected->pass)
+ free(db.selected->pass);
+ encrypt(pass, &db.selected->pass);
+ copyto_clipboard(pass);
+ sodium_memzero(pass, strlen(pass));
+ free(pass);
+ db.selected->modified = time(NULL);
+ savedb();
+ ui_status("Generated and copied to clipboard");
+ }
+}
+
+static void
+cmd_copy(void)
+{
+ char *plain = NULL;
+ unsigned char *decrypted = NULL;
+ size_t declen;
+
+ if (!db.selected || !db.selected->pass) {
+ ui_status("No password to copy");
+ return;
+ }
+
+ /* Decrypt the password */
+ if (decrypt_data((unsigned char *)db.selected->pass, strlen(db.selected->pass),
+ &decrypted, &declen) != 0) {
+ ui_status("Failed to decrypt password");
+ return;
+ }
+
+ /* Convert to string and copy */
+ plain = (char *)decrypted;
+
+ if (copyto_clipboard(plain) == 0) {
+ ui_status("Password copied to clipboard");
+ } else {
+ ui_status("Failed to copy to clipboard");
+ }
+
+ /* Clean up */
+ sodium_memzero(plain, strlen(plain));
+ free(plain);
+}
+
+static void
+cmd_search(void)
+{
+ int c;
+ int pos = 0;
+ char searchbuf[256] = {0};
+
+ werase(statuswin);
+ box(statuswin, 0, 0);
+ mvwprintw(statuswin, 1, 1, "Search (ESC to cancel): ");
+ wrefresh(statuswin);
+
+ noecho();
+ cbreak();
+
+ wmove(statuswin, 1, 23);
+
+ while ((c = wgetch(statuswin)) != '\n' && c != 27) {
+ if (c == KEY_BACKSPACE || c == 127) {
+ if (pos > 0) {
+ pos--;
+ searchbuf[pos] = '\0';
+ mvwaddch(statuswin, 1, 23 + pos, ' ');
+ wmove(statuswin, 1, 23 + pos);
+ }
+ } else if (pos < sizeof(searchbuf) - 1) {
+ searchbuf[pos] = c;
+ searchbuf[pos + 1] = '\0';
+ mvwaddch(statuswin, 1, 23 + pos, c);
+ pos++;
+ }
+ wrefresh(statuswin);
+ }
+
+ if (c == 27) { /* ESC pressed */
+ db.search[0] = '\0';
+ db.view = VIEW_LIST;
+ } else {
+ strncpy(db.search, searchbuf, sizeof(db.search)-1);
+ db.search[sizeof(db.search)-1] = '\0';
+ db.view = VIEW_SEARCH;
+ }
+
+ werase(statuswin);
+ box(statuswin, 0, 0);
+ wrefresh(statuswin);
+}
+
+static int
+loaddb(const char *path, const char *masterpass)
+{
+ FILE *fp;
+ char magic[sizeof(DB_MAGIC)];
+ uint32_t version;
+ uint32_t count;
+ Entry *e;
+ size_t enclen;
+ unsigned char *encrypted;
+ unsigned char *decrypted;
+ size_t declen;
+
+
+ if (!(fp = fopen(path, "rb"))) {
+ return -1;
+ }
+
+ /* Read and verify magic */
+ if (fread(magic, 1, strlen(DB_MAGIC), fp) != strlen(DB_MAGIC) ||
+ memcmp(magic, DB_MAGIC, strlen(DB_MAGIC)) != 0) {
+ fclose(fp);
+ return -1;
+ }
+
+ /* Read version */
+ if (fread(&version, sizeof(version), 1, fp) != 1) {
+ fclose(fp);
+ return -1;
+ }
+
+ if (version != 2) {
+ fclose(fp);
+ return -1;
+ }
+
+ /* Read salt and derive key */
+ if (fread(db.salt, 1, sizeof(db.salt), fp) != sizeof(db.salt)) {
+ fclose(fp);
+ return -1;
+ }
+
+ if (crypto_pwhash(db.key, sizeof(db.key),
+ masterpass, strlen(masterpass),
+ db.salt,
+ crypto_pwhash_OPSLIMIT_INTERACTIVE,
+ crypto_pwhash_MEMLIMIT_INTERACTIVE,
+ crypto_pwhash_ALG_DEFAULT) != 0) {
+ fclose(fp);
+ return -1;
+ }
+
+ /* Read and verify encrypted block */
+ if (fread(&enclen, sizeof(enclen), 1, fp) != 1) {
+ fclose(fp);
+ return -1;
+ }
+
+ encrypted = malloc(enclen);
+ if (!encrypted) {
+ fclose(fp);
+ return -1;
+ }
+
+ if (fread(encrypted, 1, enclen, fp) != enclen) {
+ free(encrypted);
+ fclose(fp);
+ return -1;
+ }
+
+ if (decrypt_data(encrypted, enclen, &decrypted, &declen) != 0) {
+ free(encrypted);
+ fclose(fp);
+ return -2; /* Wrong password */
+ }
+
+ free(encrypted);
+
+ /* Verify decrypted content */
+ if (declen != strlen(VERIFY_STRING) ||
+ memcmp(decrypted, VERIFY_STRING, strlen(VERIFY_STRING)) != 0) {
+ free(decrypted);
+ fclose(fp);
+ return -2;
+ }
+
+ free(decrypted);
+
+ /* Read entry count */
+ if (fread(&count, sizeof(count), 1, fp) != 1) {
+ fclose(fp);
+ return -1;
+ }
+
+ /* Read entries */
+ while (count-- > 0) {
+ char *path = NULL, *title = NULL;
+ int ret;
+
+ /* Read path */
+ ret = read_encrypted_string(fp, &path);
+ if (ret != 0) {
+ if (path) free(path);
+ fclose(fp);
+ return -1;
+ }
+
+ /* Read title */
+ ret = read_encrypted_string(fp, &title);
+ if (ret != 0) {
+ if (path) free(path);
+ if (title) free(title);
+ fclose(fp);
+ return -1;
+ }
+
+ e = addentry(path, title);
+ if (!e) {
+ free(path);
+ free(title);
+ fclose(fp);
+ return -1;
+ }
+
+ free(path);
+ free(title);
+
+ /* Read other fields */
+ if (read_encrypted_string(fp, &e->user) != 0) {
+ fclose(fp);
+ return -1;
+ }
+
+ if (read_encrypted_string(fp, &e->pass) != 0) {
+ fclose(fp);
+ return -1;
+ }
+
+ if (read_encrypted_string(fp, &e->notes) != 0) {
+ fclose(fp);
+ return -1;
+ }
+
+ if (fread(&e->modified, sizeof(e->modified), 1, fp) != 1) {
+ fclose(fp);
+ return -1;
+ }
+
+ }
+
+ fclose(fp);
+ return 0;
+}
+
+int
+main(int argc, char *argv[])
+{
+ Entry *e;
+ char *pass;
+ int ch, result;
+
+ /* Check arguments */
+ if (argc != 2)
+ die("usage: %s dbfile", argv[0]);
+
+ /* Initialize sodium */
+ if (sodium_init() < 0)
+ die("sodium_init failed");
+
+ /* initialize ncurses */
+ initscr();
+ raw();
+ noecho();
+ keypad(stdscr, TRUE);
+
+ /* Initialize UI */
+ ui_init();
+
+ /* Show initial status */
+ ui_status("Press '?' for help, 'q' to quit");
+ ui_draw();
+
+ /* Handle database */
+ db.dbpath = argv[1];
+ if (access(db.dbpath, F_OK) == 0) {
+ /* Existing database: authenticate */
+ for (int attempts = 3; attempts > 0; attempts--) {
+ pass = readpass("Master Password: ");
+ if (!pass) {
+ ui_cleanup();
+ die("failed to read password");
+ }
+
+ result = loaddb(db.dbpath, pass);
+ sodium_memzero(pass, strlen(pass));
+ free(pass);
+
+ if (result == 0) {
+ break;
+ } else if (result == -2) {
+ if (attempts > 1) {
+ ui_status("Wrong password. %d attempts remaining", attempts - 1);
+ sleep(1);
+ } else {
+ ui_cleanup();
+ die("too many wrong password attempts");
+ }
+ } else {
+ ui_cleanup();
+ die("failed to load database");
+ }
+ }
+ } else {
+ /* New database: create */
+ char *pass2;
+
+ pass = readpass("New Master Password: ");
+ if (!pass) {
+ ui_cleanup();
+ die("failed to read password");
+ }
+
+ pass2 = readpass("Confirm Master Password: ");
+ if (!pass2) {
+ sodium_memzero(pass, strlen(pass));
+ free(pass);
+ ui_cleanup();
+ die("failed to read password confirmation");
+ }
+
+ if (strcmp(pass, pass2) != 0) {
+ sodium_memzero(pass, strlen(pass));
+ sodium_memzero(pass2, strlen(pass2));
+ free(pass);
+ free(pass2);
+ ui_cleanup();
+ die("passwords do not match");
+ }
+
+ sodium_memzero(pass2, strlen(pass2));
+ free(pass2);
+
+ if (createdb(db.dbpath, pass) != 0) {
+ sodium_memzero(pass, strlen(pass));
+ free(pass);
+ ui_cleanup();
+ die("failed to create database");
+ }
+
+ sodium_memzero(pass, strlen(pass));
+ free(pass);
+ }
+
+ /* Show initial status */
+ clear();
+ refresh();
+ ui_status("Press '?' for help, 'q' to quit");
+ ui_draw();
+ wrefresh(mainwin);
+ wrefresh(statuswin);
+ doupdate();
+
+ /* Main loop */
+ while ((ch = getch()) != 'q') {
+ if (ch == ERR) {
+ if (errno == EINTR)
+ continue;
+ /* Only die on serious errors, not just ERR from getch */
+ if (errno != 0) {
+ ui_cleanup();
+ die("input error: %s", strerror(errno));
+ }
+ continue;
+ }
+
+ switch (ch) {
+
+ case KEY_RESIZE:
+ handle_resize();
+ break;
+
+ case '?':
+ werase(mainwin);
+ for (int i = 0; i < sizeof(help_text) / sizeof(help_text[0]); i++) {
+ mvwprintw(mainwin, i + 2, 2, "%s", help_text[i]);
+ }
+
+ wrefresh(mainwin);
+ ui_status("Press any key to continue");
+ getch();
+ break;
+
+ case 27: /* ESC */
+ if (db.view == VIEW_SEARCH) {
+ db.search[0] = '\0';
+ db.view = VIEW_LIST;
+
+ werase(statuswin);
+ box(statuswin, 0, 0);
+ wrefresh(statuswin);
+ nodelay(stdscr, TRUE); /* Don't wait for second ESC byte */
+ getch(); /* Clear any pending ESC sequence */
+ nodelay(stdscr, FALSE);
+ }
+ break;
+
+case 'j':
+ if (db.view == VIEW_SEARCH) {
+ Entry **matches = NULL;
+ int match_count = 0;
+
+ /* Count and collect matches */
+ for (e = db.entries; e; e = e->next) {
+ if (strstr(e->path, db.search) ||
+ strstr(e->title, db.search) ||
+ (e->user && strstr(e->user, db.search))) {
+ match_count++;
+ }
+ }
+
+ if (match_count > 0) {
+ matches = malloc(match_count * sizeof(Entry*));
+ if (matches) {
+ int idx = 0;
+ for (e = db.entries; e; e = e->next) {
+ if (strstr(e->path, db.search) ||
+ strstr(e->title, db.search) ||
+ (e->user && strstr(e->user, db.search))) {
+ matches[idx++] = e;
+ }
+ }
+
+ /* Find current selection and move to next */
+ for (int i = 0; i < match_count; i++) {
+ if (matches[i] == db.selected && i < match_count - 1) {
+ db.selected = matches[i + 1];
+ break;
+ }
+ }
+ free(matches);
+ }
+ }
+ } else if (db.selected && db.selected->next) {
+ db.selected = db.selected->next;
+ } else if (!db.selected && db.entries) {
+ db.selected = db.entries;
+ }
+ break;
+
+case 'k':
+ if (db.view == VIEW_SEARCH) {
+ Entry **matches = NULL;
+ int match_count = 0;
+
+ /* Count and collect matches */
+ for (e = db.entries; e; e = e->next) {
+ if (strstr(e->path, db.search) ||
+ strstr(e->title, db.search) ||
+ (e->user && strstr(e->user, db.search))) {
+ match_count++;
+ }
+ }
+
+ if (match_count > 0) {
+ matches = malloc(match_count * sizeof(Entry*));
+ if (matches) {
+ int idx = 0;
+ for (e = db.entries; e; e = e->next) {
+ if (strstr(e->path, db.search) ||
+ strstr(e->title, db.search) ||
+ (e->user && strstr(e->user, db.search))) {
+ matches[idx++] = e;
+ }
+ }
+
+ /* Find current selection and move to previous */
+ for (int i = 0; i < match_count; i++) {
+ if (matches[i] == db.selected && i > 0) {
+ db.selected = matches[i - 1];
+ break;
+ }
+ }
+ free(matches);
+ }
+ }
+ } else if (db.selected) {
+ Entry *prev;
+ for (prev = db.entries; prev && prev->next != db.selected; prev = prev->next)
+ ;
+ db.selected = prev;
+ }
+ break;
+
+ case 'a': /* add entry */
+ cmd_add();
+ break;
+
+ case 'd': /* delete entry */
+ if (db.selected) {
+ char c;
+ ui_status("Delete entry? (y/n)");
+ c = getch();
+ if (c == 'y' || c == 'Y') {
+ Entry *next = db.selected->next;
+ delentry(db.selected);
+ db.selected = next;
+ savedb();
+ ui_status("Entry deleted");
+ } else {
+ ui_status("Deletion cancelled");
+ }
+ }
+ break;
+
+ case 'e': /* edit entry */
+ if (db.selected) {
+ cmd_edit();
+ savedb();
+ }
+ break;
+
+ case 'y': /* copy password */
+ cmd_copy();
+ break;
+
+ case 'g': /* generate password */
+ if (db.selected) {
+ cmd_generate();
+ savedb();
+ }
+ break;
+
+ case '/': /* search */
+ cmd_search();
+ break;
+
+ case '\t': /* toggle view */
+ if (db.view == VIEW_LIST && db.selected)
+ db.view = VIEW_ENTRY;
+ else
+ db.view = VIEW_LIST;
+ break;
+
+ case ERR:
+ /* Handle input error */
+ if (errno == EINTR)
+ continue;
+ ui_cleanup();
+ die("input error: %s", strerror(errno));
+ break;
+ }
+
+ /* Update display */
+ ui_draw();
+ }
+
+ /* Cleanup and exit */
+ ui_cleanup();
+ cleanup();
+
+ /* Zero out sensitive memory */
+ sodium_memzero(&db, sizeof(db));
+
+ return 0;
+}
+\ No newline at end of file