Welcome to little lamb

Code » ssp » commit a275c8c

Add main program code & commands add/show/list

author Olivier Brunel
2023-04-17 17:59:49 UTC
committer Olivier Brunel
2023-04-17 21:20:20 UTC
parent 8ff1ef0f791e14cb765ff962639322ee0480bc86

Add main program code & commands add/show/list

These three first commands all work fine, however none of them support
comments yet. Other than that they should work as expected.

.gitignore +3 -0
skalibs +0 -1
src/include/ssp.h +84 -0
src/ssp/add.c +141 -0
src/ssp/commands.c +17 -0
src/ssp/database.c +187 -0
src/ssp/list.c +114 -0
src/ssp/show.c +134 -0
src/ssp/ssp.c +97 -43

diff --git a/.gitignore b/.gitignore
index 07124e2..7094b31 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,7 @@
 /src/include/config.h
 /build
 /limb
+/limb.built
+/skalibs
+/skalibs.built
 /ssp
diff --git a/skalibs b/skalibs
deleted file mode 120000
index e7d8e9f..0000000
--- a/skalibs
+++ /dev/null
@@ -1 +0,0 @@
-../skalibs
\ No newline at end of file
diff --git a/src/include/ssp.h b/src/include/ssp.h
new file mode 100644
index 0000000..0086654
--- /dev/null
+++ b/src/include/ssp.h
@@ -0,0 +1,84 @@
+/* This file is part of ssp                             https://lila.oss/ssp
+ * Copyright (C) 2023 Olivier Brunel                          jjk@jjacky.com */
+/* SPDX-License-Identifier: GPL-2.0-only */
+#ifndef SSP_SSP_H
+#define SSP_SSP_H
+
+#include <skalibs/stralloc.h>
+#include <limb/cdb.h>
+
+enum {
+    OPT_HELP = 1 << 0,
+};
+
+#define SSP_MAGIC       0x53535000
+#define SSP_MAGIC_INT   0x73737042
+
+#define KEY_LEN         32
+#define SALT_LEN        32
+#define NONCE_LEN       12
+#define PWD_MAX         136
+#define ITER            1000
+
+struct ssp {
+    stralloc sa;
+    cdb cdb;
+    cdb_data key;
+    cdb_data val;
+    const char *db;
+    unsigned int options;
+    u32 pos;
+    char pwd[PWD_MAX + 1];
+};
+
+enum {
+    TYPE_COUNTER = 0,
+    TYPE_TIME
+};
+
+enum {
+    ALGO_SHA1 = 0,
+    ALGO_SHA256,
+    ALGO_SHA512,
+    ALGO_SHA3_224,
+    ALGO_SHA3_256,
+    ALGO_SHA3_512,
+    ALGO_BLAKE3,
+};
+
+extern const char *algos[];
+
+struct otp
+{
+    size_t slen; /* length of secret within data; or offset to comments */
+    u8 type     : 2;
+    u8 algo     : 4;
+    u8 _unused  : 2;
+    union {
+        u8 precision;
+        u8 digits;
+    };
+    char data[];
+};
+
+/* ssp.c */
+int exitcode_from_errno(int e);
+const char *get_site_name(const char *s, struct ssp *ctx);
+
+/* database.c */
+void rebuild_db(const char *oldkey, const char *newkey, struct otp *otp, struct ssp *ctx);
+int open_db(struct ssp *ctx);
+
+#define db_file(ctx)        (((ctx)->db) ? (ctx)->db : (ctx)->sa.s)
+#define db_reset(ctx)       (ctx)->pos = CDB_TRAVERSE_INIT()
+#define db_next(ctx)        cdb_traverse_next(&(ctx)->cdb, &(ctx)->key, &(ctx)->val, &(ctx)->pos)
+#define db_find(site,ctx)   ((ctx)->key.s = site, (ctx)->key.len = strlen(site) + 1, \
+                            cdb_find(&(ctx)->cdb, &(ctx)->val, (ctx)->key.s, (ctx)->key.len))
+#define db_site(ctx)        (ctx)->key.s
+#define db_otp(ctx)         (const struct otp *) (ctx)->val.s
+
+/* show.c */
+void show_site(unsigned options, struct ssp *ctx);
+void show_site_ini(struct ssp *ctx);
+
+#endif /* SSP_SSP_H */
diff --git a/src/ssp/add.c b/src/ssp/add.c
new file mode 100644
index 0000000..a9104d6
--- /dev/null
+++ b/src/ssp/add.c
@@ -0,0 +1,141 @@
+/* This file is part of ssp                             https://lila.oss/ssp
+ * Copyright (C) 2023 Olivier Brunel                          jjk@jjacky.com */
+/* SPDX-License-Identifier: GPL-2.0-only */
+#include <errno.h>
+#include <limb/base32.h>
+#include <limb/bytestr.h>
+#include <limb/command.h>
+#include <limb/exitcode.h>
+#include <limb/output.h>
+#include <limb/parseopt.h>
+#include <limb/u16.h>
+#include "ssp.h"
+
+COMMAND(add, "Add a new site to the database",
+        "<site> [OPTION..] <secret>",
+" -a, --algo ALGO                       Set ALGO as hashing algorithm [sha256]\n"
+"                                       Use 'list' to list available algorithms\n"
+" -c, --comments COMMENTS               Set COMMENTS as site's comments\n"
+" -d, --digits NUM                      Set to counter-based (HOTP) using NUM digits [6]\n"
+" -t, --time [SECS]                     Set to time-based (TOTP) with precision of SECS [30]\n"
+);
+
+
+static int
+parse_cmdline(int argc, const char *argv[], const char usage[], struct otp *otp)
+{
+    const struct option options[] = {
+        OPTION_ARG_REQ ('a', "algo",                    0, OPTID_SHORTOPT),
+        OPTION_ARG_REQ ('c', "comments",                0, OPTID_SHORTOPT),
+        OPTION_ARG_REQ ('d', "digits",                  0, OPTID_SHORTOPT),
+        OPTION_ARG_OPT ('t', "time",                    0, OPTID_SHORTOPT),
+        OPTION_DONE
+    };
+    struct parseopt po = { 0 };
+
+    int c;
+    while ((c = parseopt(argc, argv, options, 0, &po))) switch (c) {
+        case 'a':
+            {
+                int i, first = -1;
+                i = byte_get_match(&first, po.arg, strlen(po.arg), algos);
+                if (i < 0) {
+                    if (!strcmp(po.arg, "list")) {
+                        out("Available algorithms:");
+                        for (i = 0; algos[i]; ++i)
+                            out("- ", algos[i]);
+                        diecmdusage(0, usage, &command_add);
+                    }
+                    warn("invalid algorithm: ", po.arg);
+                    if (first > -1)
+                        list_matches(out_putmsg, OLVL_NORMAL, "did you mean ",
+                                     NULL, " or ", " ?", po.arg, strlen(po.arg),
+                                     first, algos);
+                    diecmdusage(EX_USAGE, usage, &command_add);
+                }
+                otp->algo = i;
+            }
+            break;
+        case 'd':
+            otp->type = TYPE_COUNTER;
+            otp->digits = *po.arg - '0';
+            if (po.arg[1] || otp->digits < 5 || otp->digits > 9) {
+                warn("invalid number of digits, must be between 5 and 9: ", po.arg);
+                diecmdusage(EX_USAGE, usage, &command_add);
+            }
+            break;
+        case 't':
+            if (!po.arg) {
+                otp->precision = 30;
+            } else {
+                u16 u;
+                if (!u160_scan(&u, po.arg) || u < 10 || u > 120) {
+                    warn("invalid precision argument, must be between 10 and 120: ", po.arg);
+                    diecmdusage(EX_USAGE, usage, &command_add);
+                }
+                otp->precision = u;
+            }
+            otp->type = TYPE_TIME;
+            break;
+        case -1:
+            diecmdusage(EX_USAGE, usage, &command_add);
+        default:
+            die(EX_SOFTWARE, "unexpected return value ", PUTMSG_INT(c), " from parseopt");
+    };
+
+    return po.cur;
+}
+
+int
+add_main(int argc, const char *argv[], const char *env[], const char usage[], void *ctx_)
+{
+    struct ssp *ctx = ctx_;
+    struct otp otp = { .type = TYPE_COUNTER, .algo = ALGO_SHA256, .digits = 6 };
+    const char *site;
+    int i;
+
+    if (argc == 1) {
+        warn("argument \"site\" missing");
+        diecmdusage(EX_USAGE, usage, &command_add);
+    }
+    site = get_site_name(argv[1], ctx);
+    if (!site)
+        diefusys(exitcode_from_errno(errno), "parse site's name");
+    if (*site == '-') {
+        warn("argument \"site\" cannot start with a '-'");
+        diecmdusage(EX_DATA_ERR, usage, &command_add);
+    }
+
+    --argc;
+    ++argv;
+    i = parse_cmdline(argc, argv, usage, &otp);
+
+    if (i == argc) {
+        warn("argument \"secret\" missing");
+        diecmdusage(EX_USAGE, usage, &command_add);
+    } else if (i + 1 < argc) {
+        warn("too many arguments");
+        diecmdusage(EX_USAGE, usage, &command_add);
+    }
+
+    const char *secret = argv[i];
+    ssize_t l = base32_scan(NULL, secret, strlen(secret));
+    if (l < 0)
+        dief(EX_DATA_ERR, "invalid secret: Not in base32");
+    if (!l)
+        dief(EX_DATA_ERR, "secret is empty");
+
+    char buf[sizeof(otp) + l + 1];
+    struct otp *p = (struct otp *) buf;
+    memcpy(p, &otp, sizeof(otp));
+    p->slen = l;
+    base32_scan(p->data, secret, strlen(secret));
+    p->data[l] = 0;
+
+    if (open_db(ctx) < 0)
+        diefu(exitcode_from_errno(errno), "open database");
+    out("Adding site ", ESC, site, ESC, "... ");
+    rebuild_db(NULL, site, p, ctx);
+
+    return 0;
+}
diff --git a/src/ssp/commands.c b/src/ssp/commands.c
new file mode 100644
index 0000000..e6a2ac8
--- /dev/null
+++ b/src/ssp/commands.c
@@ -0,0 +1,17 @@
+/* This file is part of ssp                             https://lila.oss/ssp
+ * Copyright (C) 2023 Olivier Brunel                          jjk@jjacky.com */
+/* SPDX-License-Identifier: GPL-2.0-only */
+#include <limb/command.h>
+
+extern struct command command_add;
+extern struct command command_list;
+extern struct command command_show;
+
+struct command *commands[] = {
+    &command_add,
+    &command_list,
+    &command_show,
+    0
+};
+
+N_COMMANDS;
diff --git a/src/ssp/database.c b/src/ssp/database.c
new file mode 100644
index 0000000..f440687
--- /dev/null
+++ b/src/ssp/database.c
@@ -0,0 +1,187 @@
+/* This file is part of ssp                             https://lila.oss/ssp
+ * Copyright (C) 2023 Olivier Brunel                          jjk@jjacky.com */
+/* SPDX-License-Identifier: GPL-2.0-only */
+#include <errno.h>
+#include <string.h>
+#include <skalibs/random.h>
+#include <limb/djbunix.h>
+#include <limb/bytestr.h>
+#include <limb/cdb.h>
+#include <limb/cdbmake.h>
+#include <limb/chacha20.h>
+#include <limb/exitcode.h>
+#include <limb/hasher_sha3_256.h>
+#include <limb/output.h>
+#include <limb/pbkdf2.h>
+#include <limb/term.h>
+#include <limb/u32.h>
+#include "ssp.h"
+
+int
+get_key(char key[KEY_LEN], const char salt[SALT_LEN], const char *prompt, struct ssp *ctx)
+{
+    ssize_t plen;
+    if (!*ctx->pwd) {
+        plen = ask_password(ctx->pwd, sizeof(ctx->pwd), prompt);
+        if (plen < 0) return 0;
+    } else {
+        plen = strlen(ctx->pwd);
+    }
+
+    /* get encryption key */
+    pbkdf2(key, KEY_LEN, sha3_256, ctx->pwd, plen, salt, SALT_LEN, ITER);
+
+    return 1;
+}
+
+int
+open_db(struct ssp *ctx)
+{
+    int fd = open_read(db_file(ctx));
+    if (fd < 0) {
+        /* no file is not an error at this point */
+        if (errno == ENOENT) {
+            ctx->cdb = cdb_zero;
+            return 0;
+        }
+        warnusys("open ", ESC, db_file(ctx), ESC);
+        return -1;
+    }
+
+    size_t salen = ctx->sa.len;
+    if (!slurp(&ctx->sa, fd)) {
+        warnusys("read ", ESC, db_file(ctx), ESC);
+        fd_close(fd);
+        return -1;
+    }
+    fd_close(fd);
+
+    /* check our magic */
+    u32p_be((u32 *) (ctx->sa.s + salen));
+    if (* (u32 *) (ctx->sa.s + salen) != SSP_MAGIC) {
+        warnu("read ", ESC, db_file(ctx), ESC, ": not an SSP database");
+        return (errno = ENOMSG, -1);
+    }
+    /* set off past the magic, to the salt */
+    size_t off = salen + sizeof(u32);
+    char salt[SALT_LEN];
+    memcpy(salt, ctx->sa.s + off, sizeof(salt));
+    /* move off past salt, to encrypted data */
+    off += sizeof(salt);
+
+    /* check the fileis big enough to contain up to encrypted data */
+    if (ctx->sa.len <= off) {
+        warnu("read ", ESC, db_file(ctx), ESC, ": file corrupted");
+        return (errno = EBADE, -1);
+    }
+
+    char key[KEY_LEN];
+    if (!get_key(key, salt, "Enter database password: ", ctx)) {
+        warnusys("read ", ESC, db_file(ctx), ESC, ": cannot read password");
+        return -1;
+    }
+
+    /* decrypt data in-place */
+    char nonce[NONCE_LEN] = { 0 };
+    chacha20(ctx->sa.s + off, key, nonce, ctx->sa.s + off, ctx->sa.len - off);
+
+    /* check internal magic, to "validate" the password */
+    u32p_be((u32 *) (ctx->sa.s + off));
+    if (* (u32 *) (ctx->sa.s + off) != SSP_MAGIC_INT) {
+        warnu("read ", ESC, db_file(ctx), ESC, ": wrong password");
+        return (errno = EINVAL, -1);
+    }
+    /* move off past internal magic, to actual cdb content */
+    off += sizeof(u32);
+
+    cdb_init_frommem(&ctx->cdb, ctx->sa.s + off, ctx->sa.len - off);
+    return 1;
+}
+
+void
+rebuild_db(const char *oldkey, const char *newkey, struct otp *otp, struct ssp *ctx)
+{
+    size_t olen = 0, nlen;
+    size_t vlen = sizeof(*otp) + otp->slen + strlen(otp->data + otp->slen) + 1;
+    cdbmaker_sa mkr;
+    int r = 0;
+
+    nlen = strlen(newkey) + 1;
+    if (oldkey) olen = strlen(oldkey) + 1;
+
+    if (!cdbmaker_sa_start(&mkr))
+        diefusys(EX_TEMPFAIL, "save database");
+    /* new file? */
+    if (!ctx->cdb.map) {
+        if (!cdbmaker_sa_add(&mkr, newkey, nlen, (char *) otp, vlen))
+            diefusys(EX_TEMPFAIL, "save database");
+    } else {
+        int a = 0;
+        db_reset(ctx);
+        while ((r = db_next(ctx)) == 1) {
+            if (!a && olen && olen == ctx->key.len && !strcmp(oldkey, ctx->key.s)) {
+                /* replace otp */
+                if (!cdbmaker_sa_add(&mkr, ctx->key.s, ctx->key.len, (char *) otp, vlen))
+                    diefusys(EX_TEMPFAIL, "save database");
+                a = 1;
+            } else if (!a && !olen && nlen == ctx->key.len && !strcmp(newkey, ctx->key.s)) {
+                diefu(EX_DATA_ERR, "add site ", ESC, newkey, ESC, ": Site already exists");
+            } else {
+                if (!a && !olen && strcoll(ctx->key.s, newkey) > 0) {
+                    /* insert otp at the right place */
+                    if (!cdbmaker_sa_add(&mkr, newkey, nlen, (char *) otp, vlen))
+                        diefusys(EX_TEMPFAIL, "save database");
+                    a = 1;
+                }
+                if (!cdbmaker_sa_add(&mkr, ctx->key.s, ctx->key.len, ctx->val.s, ctx->val.len))
+                    diefusys(EX_TEMPFAIL, "save database");
+            }
+        }
+        if (r < 0) diefu(EX_DATA_ERR, "read database: file corrupted");
+        if (!a) {
+            /* insert last */
+            if (!cdbmaker_sa_add(&mkr, newkey, nlen, (char *) otp, vlen))
+                diefusys(EX_TEMPFAIL, "save database");
+        }
+    }
+    if (!cdbmaker_sa_finish(&mkr))
+        diefusys(EX_TEMPFAIL, "save database");
+
+    char salt[SALT_LEN];
+    char enckey[KEY_LEN];
+    char nonce[NONCE_LEN] = { 0 };
+
+    /* each time we save the db, we generate a new key to encrypt it */
+    random_buf(salt, sizeof(salt));
+    if (!get_key(enckey, salt, "Enter new database password: ", ctx))
+        diefusys(EX_DATA_ERR, "save database: cannot read password");
+
+    u32 magic_int = SSP_MAGIC_INT;
+    u32p_be(&magic_int);
+
+    chacha20_ctx chacha;
+    chacha20_init(enckey, nonce, &chacha);
+    chacha20_crypt(&magic_int, &magic_int, sizeof(magic_int), &chacha);
+    chacha20_crypt(mkr.sa.s, mkr.sa.s, mkr.sa.len, &chacha);
+    chacha20_clear(&chacha);
+
+
+    u32 magic = SSP_MAGIC;
+    u32p_be(&magic);
+
+    struct iovec v[4];
+    v[0].iov_base = &magic;
+    v[0].iov_len = sizeof(magic);
+    v[1].iov_base = salt;
+    v[1].iov_len = sizeof(salt);
+    v[2].iov_base = &magic_int;
+    v[2].iov_len = sizeof(magic_int);
+    v[3].iov_base = mkr.sa.s;
+    v[3].iov_len = mkr.sa.len;
+
+    if (!openwritevnclose(db_file(ctx), v, 4))
+        diefusys(EX_CANTCREAT, "save database to ", ESC, db_file(ctx), ESC);
+
+    cdbmaker_sa_free(&mkr);
+    out("Database saved to ", ESC, db_file(ctx), ESC);
+}
diff --git a/src/ssp/list.c b/src/ssp/list.c
new file mode 100644
index 0000000..c18f9ed
--- /dev/null
+++ b/src/ssp/list.c
@@ -0,0 +1,114 @@
+/* This file is part of ssp                             https://lila.oss/ssp
+ * Copyright (C) 2023 Olivier Brunel                          jjk@jjacky.com */
+/* SPDX-License-Identifier: GPL-2.0-only */
+#include <errno.h>
+#include <fnmatch.h>
+#include <limb/command.h>
+#include <limb/exitcode.h>
+#include <limb/output.h>
+#include <limb/parseopt.h>
+#include "ssp.h"
+
+enum {
+    OPT_DETAILS     = 1 << 0,
+    OPT_SECRET      = 1 << 1, /* must be the same as in show */
+    OPT_FMT_INI     = 1 << 2,
+};
+
+struct list {
+    unsigned options;
+    const char *sep;
+    const char *ptrn;
+};
+
+COMMAND(list, "List all sites in the database",
+        "[OPTION..] [<pattern>]",
+" -d, --details                         Show sites' details\n"
+" -f, --format                          Output in INI-like format\n"
+" -S, --secret                          Show sites' secrets (implies --details)\n"
+" -s, --sep SEP                         Use SEP as separator\n"
+);
+
+static int
+parse_cmdline(int argc, const char *argv[], const char usage[], struct list *ctx)
+{
+    const struct option options[] = {
+        OPTION_ARG_NONE('d', "details",                 0, OPTID_SHORTOPT),
+        OPTION_ARG_NONE('f', "format",                  0, OPTID_SHORTOPT),
+        OPTION_ARG_NONE('S', "secret",                  0, OPTID_SHORTOPT),
+        OPTION_ARG_REQ ('s', "sep",                     0, OPTID_SHORTOPT),
+        OPTION_DONE
+    };
+    struct parseopt po = { 0 };
+
+    int c;
+    while ((c = parseopt(argc, argv, options, 0, &po))) switch (c) {
+        case 'd':
+            ctx->options |= OPT_DETAILS;
+            break;
+        case 'f':
+            ctx->options |= OPT_FMT_INI;
+            break;
+        case 'S':
+            ctx->options |= OPT_DETAILS | OPT_SECRET;
+            break;
+        case 's':
+            ctx->sep = po.arg;
+            break;
+        case -1:
+            diecmdusage(EX_USAGE, usage, &command_list);
+        default:
+            die(EX_SOFTWARE, "unexpected return value ", PUTMSG_INT(c), " from parseopt");
+    };
+
+    return po.cur;
+}
+
+int
+list_main(int argc, const char *argv[], const char *env[], const char usage[], void *ctx_)
+{
+    struct ssp *ctx = ctx_;
+    struct list list = { 0, "\n", NULL };
+
+    int i = parse_cmdline(argc, argv, usage, &list);
+
+    if (i + 1 == argc)
+        list.ptrn = argv[i];
+
+    if (i + 1 < argc) {
+        warn("too many arguments");
+        diecmdusage(EX_USAGE, usage, &command_list);
+    }
+
+    int r = open_db(ctx);
+    if (r < 0)
+        diefu(exitcode_from_errno(errno), "open database");
+    if (!r)
+        dief(0, "no database");
+
+    i = 0;
+    db_reset(ctx);
+    while ((r = db_next(ctx)) == 1) {
+        if (list.ptrn && fnmatch(list.ptrn, db_site(ctx), 0))
+            continue;
+        if (list.options & OPT_FMT_INI) {
+            show_site_ini(ctx);
+        } else if (list.options & OPT_DETAILS) {
+            show_site(list.options & OPT_SECRET, ctx);
+        } else {
+            if (i) add(list.sep);
+            add(db_site(ctx));
+        }
+        i = 1;
+    }
+    if (r < 0)
+        diefu(EX_DATA_ERR, "read database: file corrupted");
+    if (i) {
+        /* add new line + flush buffers */
+        if (!(list.options & (OPT_DETAILS | OPT_FMT_INI))) out(NULL);
+    } else {
+        out("no site matching ", ESC, list.ptrn, ESC);
+    }
+
+    return 0;
+}
diff --git a/src/ssp/show.c b/src/ssp/show.c
new file mode 100644
index 0000000..3a87b8c
--- /dev/null
+++ b/src/ssp/show.c
@@ -0,0 +1,134 @@
+/* This file is part of ssp                             https://lila.oss/ssp
+ * Copyright (C) 2023 Olivier Brunel                          jjk@jjacky.com */
+/* SPDX-License-Identifier: GPL-2.0-only */
+#include <errno.h>
+#include <limb/base32.h>
+#include <limb/command.h>
+#include <limb/exitcode.h>
+#include <limb/output.h>
+#include <limb/parseopt.h>
+#include "ssp.h"
+
+enum {
+    OPT_SECRET      = 1 << 1, /* must be the same as in list */
+    OPT_FMT_INI     = 1 << 2,
+};
+
+struct show {
+    unsigned options;
+};
+
+COMMAND(show, "Show details of a site",
+        "[OPTION..] <site>",
+" -f, --format                          Output in INI-like format\n"
+" -S, --secret                          Show site's secret\n"
+);
+
+static int
+parse_cmdline(int argc, const char *argv[], const char usage[], struct show *ctx)
+{
+    const struct option options[] = {
+        OPTION_ARG_NONE('f', "format",                  0, OPTID_SHORTOPT),
+        OPTION_ARG_NONE('S', "secret",                  0, OPTID_SHORTOPT),
+        OPTION_DONE
+    };
+    struct parseopt po = { 0 };
+
+    int c;
+    while ((c = parseopt(argc, argv, options, 0, &po))) switch (c) {
+        case 'f':
+            ctx->options |= OPT_FMT_INI;
+            break;
+        case 'S':
+            ctx->options |= OPT_SECRET;
+            break;
+        case -1:
+            diecmdusage(EX_USAGE, usage, &command_show);
+        default:
+            die(EX_SOFTWARE, "unexpected return value ", PUTMSG_INT(c), " from parseopt");
+    };
+
+    return po.cur;
+}
+
+void
+show_site(unsigned options, struct ssp *ctx)
+{
+    const char *types[] = { "Counter", "Time" };
+    const struct otp *otp = db_otp(ctx);
+    out("- Site ", ESC, db_site(ctx), ESC);
+    out("   Algo: ", algos[otp->algo]);
+    add("   ", types[otp->type], "-based; ");
+    if (otp->type == TYPE_COUNTER)
+        out("Digits: ", PUTMSG_UINT(otp->digits));
+    else /* TYPE_TIME */
+        out("Precision: ", PUTMSG_UINT(otp->precision), "s");
+    if (options & OPT_SECRET) {
+        size_t l = base32_fmt(NULL, otp->data, otp->slen, 0);
+        char buf[l+1];
+        base32_fmt(buf, otp->data, otp->slen, 0);
+        buf[l] = 0;
+        out("   Secret: ", buf);
+    }
+}
+
+void
+show_site_ini(struct ssp *ctx)
+{
+    const struct otp *otp = db_otp(ctx);
+
+    out("[", PUTMSG_TOGGLE_ESC, db_site(ctx), PUTMSG_TOGGLE_ESC, "]");
+    out("algo=", algos[otp->algo]);
+    if (otp->type == TYPE_TIME) {
+        out("time=1");
+        out("precision=", PUTMSG_UINT(otp->precision));
+    } else {
+        out("digits=", PUTMSG_UINT(otp->digits));
+    }
+    size_t l = base32_fmt(NULL, otp->data, otp->slen, 0);
+    char buf[l + 1];
+    base32_fmt(buf, otp->data, otp->slen, 0);
+    buf[l] = 0;
+    out("secret=", buf);
+}
+
+int
+show_main(int argc, const char *argv[], const char *env[], const char usage[], void *ctx_)
+{
+    struct ssp *ctx = ctx_;
+    struct show show = { 0 };
+
+    int i = parse_cmdline(argc, argv, usage, &show);
+
+    if (i == argc) {
+        warn("argument \"site\" missing");
+        diecmdusage(EX_USAGE, usage, &command_show);
+    }
+
+    if (i + 1 < argc) {
+        warn("too many arguments");
+        diecmdusage(EX_USAGE, usage, &command_show);
+    }
+
+    int r = open_db(ctx);
+    if (r < 0)
+        diefu(exitcode_from_errno(errno), "open database");
+    if (!r)
+        dief(0, "no database");
+
+    const char *site = get_site_name(argv[i], ctx);
+    if (!site)
+        diefusys(exitcode_from_errno(errno), "parse site's name");
+
+    r = db_find(site, ctx);
+    if (r < 0)
+        diefu(EX_DATA_ERR, "find site ", ESC, site, ESC);
+    if (!r)
+        out("no site ", ESC, site, ESC, " in database");
+    else if (show.options & OPT_FMT_INI)
+        show_site_ini(ctx);
+    else
+        show_site(show.options, ctx);
+
+    return 0;
+}
diff --git a/src/ssp/ssp.c b/src/ssp/ssp.c
index 39be0dd..be77bbd 100644
--- a/src/ssp/ssp.c
+++ b/src/ssp/ssp.c
@@ -1,76 +1,130 @@
 /* This file is part of ssp                             https://lila.oss/ssp
  * Copyright (C) 2023 Olivier Brunel                          jjk@jjacky.com */
 /* SPDX-License-Identifier: GPL-2.0-only */
-#include <limb/autoopt.h>
+#include <errno.h>
+#include <locale.h>
+#include <skalibs/env.h>
+#include <limb/command.h>
+#include <limb/esc.h>
 #include <limb/exitcode.h>
-#include <limb/loadopt.h>
+#include <limb/parseopt.h>
 #include <limb/output.h>
+#include "ssp.h"
 #include "config.h"
 
 const char *PROG = "ssp";
 
+const char *algos[] = { "sha1", "sha256", "sha512", "sha3-224", "sha3-256",
+    "sha3-512", "blake3", 0 };
+
 enum {
-    OPTID_DEBUG = OPTID_FIRST,
-    OPTID_VERSION,
+    OPTID_VERSION = OPTID_FIRST,
 };
 
-static void
-parse_cmdline(int argc, const char *argv[], const char *file, const char *section)
+int
+exitcode_from_errno(int e)
 {
-    const char usage[] = "[-h] [OPTION..]";
+    switch (e) {
+        case ENOMEM: return EX_TEMPFAIL;
+        case EINVAL:
+        case ENOMSG:
+        case EBADE:
+                     return EX_DATA_ERR;
+        default:
+                     return EX_IOERR;
+    }
+}
+
+const char *
+get_site_name(const char *s, struct ssp *ctx)
+{
+    ssize_t l = strlen(s);
+
+    if (*s != '"' || s[l - 1] != '"')
+        /* not quoted, return as-is */
+        return s;
+
+    size_t salen = ctx->sa.len;
+    l -= 2;
+    if (!stralloc_readyplus(&ctx->sa, l + 1))
+        return NULL;
+    /* unescape it */
+    l = esc_scan(ctx->sa.s + ctx->sa.len, l, s + 1, l);
+    if (l < 0)
+        return NULL;
+    ctx->sa.s[l++] = 0;
+    ctx->sa.len += l;
+    return ctx->sa.s + salen;
+}
+
+static struct command *
+parse_cmdline(int *argc, const char **argv[], struct ssp *ctx)
+{
+    const char usage[] = "[-h] [-D database] command [...]";
     const struct option options[] = {
-        OPTION_ARG_OPT(  0 , "debug",                   0, OPTID_DEBUG),
-        OPTION_ARG_NONE('h', "help",                    OPT_SKIP, OPTID_SHORTOPT),
-        OPTION_ARG_REQ( 'O', "log-file",                0, OPTID_SHORTOPT),
-        OPTION_ARG_NONE('q', "quiet",                   0, OPTID_SHORTOPT),
-        OPTION_ARG_NONE('v', "verbose",                 0, OPTID_SHORTOPT),
+        OPTION_ARG_REQ ('D', "database",                0, OPTID_SHORTOPT),
+        OPTION_ARG_NONE('h', "help",                    0, OPTID_SHORTOPT),
         OPTION_ARG_NONE( 0 , "version",                 0, OPTID_VERSION),
-        LOADOPT_DONE
+        OPTION_DONE
     };
-    struct loadopt lo = { 0 };
+    struct parseopt po = { 0 };
 
     int c;
-    while ((c = loadopt(argc, argv, options, file, section, 0, &lo))) switch (c) {
-        case OPTID_DEBUG:
-            if (!autoopt_debug(&options[lo.idx], lo.arg))
-                dieusage(EX_USAGE, usage);
+    while ((c = parseopt(*argc, *argv, options, 0, &po))) switch (c) {
+        case 'D':
+            ctx->db = po.arg;
             break;
         case 'h':
-            diehelp(0, usage,
-"     --debug [[@[level]:]+FD|FILE]     Enable debug output (to FD|FILE)\n"
-" -O, --output [@[level]:]+FD|FILE      Set output log to FD|FILE\n"
-"\n"
-" -q, --quiet                           Enable quiet mode\n"
-" -v, --verbose                         Enable verbose mode\n"
-"\n"
-" -h, --help                            Show this help screen and exit\n"
-"     --version                         Show version information and exit\n"
-);
-        case 'O':
-            if (!autoopt_log(&options[lo.idx], lo.arg))
-                dieusage(EX_USAGE, usage);
-            break;
-        case 'q':
-            autoopt_quiet(&options[lo.idx], lo.arg);
-            break;
-        case 'v':
-            autoopt_verbose(&options[lo.idx], lo.arg);
+            ctx->options |= OPT_HELP;
             break;
         case OPTID_VERSION:
             dieversion(SSP_VERSION, "2023", SSP_CURYEAR, SSP_AUTHOR, SSP_URL, NULL);
         case -1:
             dieusage(EX_USAGE, usage);
         default:
-            die(EX_SOFTWARE, "unexpected return value ", PUTMSG_INT(c), " from loadopt");
+            die(EX_SOFTWARE, "unexpected return value ", PUTMSG_INT(c), " from parseopt");
     };
+
+    /* no command specified */
+    if (po.cur == *argc)
+        dienocommand(EX_USAGE, usage, (ctx->options & OPT_HELP) ?
+" -D, --database FILE                   Use FILE as database\n"
+"\n"
+" -h, --help                            Show (command's) help screen and exit\n"
+"     --version                         Show version information and exit\n"
+: NULL);
+
+    *argc -= po.cur;
+    *argv += po.cur;
+
+    return getcommandordie(EX_USAGE, usage, **argv);
 }
 
 int
-main(int argc, const char *argv[])
+main(int argc, const char *argv[], const char *env[])
 {
-    const char config[] = "/etc/ssp.conf";
-    parse_cmdline(argc, argv, config, NULL);
+    const char usage[] = "[-h] [-D database] ";
+    struct ssp ctx = { 0 };
+
+    setlocale(LC_ALL, "");
+
+    struct command *command = parse_cmdline(&argc, &argv, &ctx);
+
+    if (!ctx.db) {
+        const char *home = env_get2(env, "HOME");
+        if (!home || !*home) {
+            warn("$HOME not defined, using current directory instead");
+            home = ".";
+        }
+        if (!stralloc_cats(&ctx.sa, home)
+                || !stralloc_cats(&ctx.sa, "/ssp.db") || !stralloc_0(&ctx.sa))
+            diefusys(EX_TEMPFAIL, "set database path");
+    }
+
+    if (ctx.options & OPT_HELP)
+        diecmdhelp(0, usage, command);
 
-    out("done.");
-    return 0;
+    int r = command->main(argc, argv, env, usage, &ctx);
+    stralloc_free(&ctx.sa);
+    return r;
 }