nexoniarz-config-nixos/modules/scripts/nexopass.sh
Nexoniarz 0dc4664899 NexoPass 1.2: accounts, required app password, 5-15 word passwords
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:29:20 +02:00

1390 lines
48 KiB
Shell
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Nexoniarz
#
# NexoPass: passwords derived from a master, nothing stored in plain text.
#
# password = Argon2id(master, site + version + mode + user). The same input
# always gives the same password, so no password is ever saved anywhere.
#
# You can have several accounts, each with its own master and its own vault:
# the list of sites, their current version and an archive of old versions,
# encrypted (GnuPG, AES-256) with a key derived from that account's master.
# One app password (or PIN) unlocks NexoPass: it encrypts the keyring that
# holds every account's master, so a master is typed only to create or
# recover an account.
#
# Case never matters: masters, site names and account names are lowercased.
# A master is a list of words: any whitespace between them counts as one space.
#
# Must stay byte-for-byte compatible with the NexoPass Android app.
set -euo pipefail
export LC_ALL=C
NEXOPASS_VERSION="1.2.0"
WORDLIST="${NEXOPASS_WORDLIST:-$(dirname "$(readlink -f "$0")")/eff_large_wordlist.txt}"
WORDLIST_SHA256="addd35536511597a02fa0a9ff1e5284677b8883b83e986e43f15a3db996b903e"
BASE_DIR="${NEXOPASS_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/nexopass}"
ACCOUNTS_DIR="$BASE_DIR/accounts"
KEYRING="$BASE_DIR/keyring.gpg"
KEYRING_META="$BASE_DIR/keyring.meta"
CONFIG="${NEXOPASS_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/nexopass/config}"
# Changing any of these changes every password.
SALT_PREFIX="nexopass:v1"
ARGON_T=3
ARGON_M=16 # 2^16 KiB = 64 MiB
ARGON_P=1
SITE_BYTES=1024
WORD_COUNT=5 # "words" means 5; other counts are "words<N>" (5-15)
UPPER="ABCDEFGHJKLMNPQRSTUVWXYZ"
LOWER="abcdefghijkmnopqrstuvwxyz"
DIGITS="23456789"
SYMBOLS='!@#$%&*?-_+='
TWO_PART_TLDS="com.pl net.pl org.pl edu.pl gov.pl info.pl biz.pl co.uk org.uk ac.uk com.au co.jp com.br"
# The app password key is slower on purpose: a PIN has few combinations.
QUICK_T=4
QUICK_M=18 # 2^18 KiB = 256 MiB
MIN_MASTER_WORDS=3
MIN_MASTER_BYTES=12
SEP=$'\x1f'
VAULT_HEADER="# nexopass vault v1"
declare -A KR=() # account name -> master, while unlocked
QKEY="" # key that encrypts the keyring, while unlocked
UNLOCKED=0
die() { echo "${RED:-}[!]${RST:-} $*" >&2; exit 1; }
info() { echo "${CYAN}[*]${RST} $*" >&2; }
warn() { echo "${YELLOW}[!]${RST} $*" >&2; }
ok() { echo "${GREEN}[+]${RST} $*"; }
setup_colors() {
if [[ $CFG_COLOR != never && -t 1 && -t 2 && -z ${NO_COLOR:-} ]]; then
RST=$'\e[0m' BOLD=$'\e[1m' DIM=$'\e[2m' RED=$'\e[31m' GREEN=$'\e[32m'
YELLOW=$'\e[33m' MAGENTA=$'\e[35m' CYAN=$'\e[36m'
else
RST="" BOLD="" DIM="" RED="" GREEN="" YELLOW="" MAGENTA="" CYAN=""
fi
}
usage() {
cat <<EOF
${BOLD}${MAGENTA}NexoPass${RST} $NEXOPASS_VERSION - passwords derived from a master, nothing stored in plain text.
${BOLD}Usage:${RST} nexopass [command] [site] [options]
${BOLD}Passwords:${RST}
${CYAN}get${RST} <site> Show the password for a site (adds the site on first use)
${CYAN}rotate${RST} <site> After a breach: switch to the next version, archive the old one
${CYAN}list${RST} List your sites with their current version
${CYAN}history${RST} <site> Show all versions of a site, old ones included
${CYAN}remove${RST} <site> Remove a site and its history from the list
(with -v N: remove only archived version N)
${BOLD}Accounts${RST} (each has its own master and site list; one app password for all):
${CYAN}accounts${RST} List your accounts
${CYAN}account new${RST} <name> Add an account: a new master, or one you use on the phone
${CYAN}account use${RST} <name> Switch the default account
${CYAN}account rename${RST} <old> <new> Rename an account
${CYAN}account detach${RST} [name] Remove an account from this computer
(app password, its master and "yes"; offers a backup)
${BOLD}Security and data:${RST}
${CYAN}settings${RST} App password, stay unlocked, clipboard, defaults...
${CYAN}passwd${RST} Change the app password/PIN
${CYAN}show-master${RST} Show the account's master (asks for the app password)
${CYAN}export${RST} [file] Encrypted copy of the account's site list (opens on the phone too)
${CYAN}import${RST} <file> Merge an export into the account's site list
${CYAN}lock${RST} Lock now
${CYAN}new-master${RST} [N] Generate a random master of N words (default 6)
${CYAN}about${RST} Version, author, license
nexopass <site> Same as: nexopass get <site>
nexopass Interactive session: unlock once, then type commands
${BOLD}Options:${RST}
-A, --account NAME Use this account for one command
-u, --user NAME Login name, for several logins on one site
-w, --words N Password of N words (5-15); when adding or rotating
-n, --length N Random characters (12-64) instead of words, for sites
with a length limit; when adding or rotating
-v, --version N New site: start at version N. remove: archived version N
-c, --copy Copy to clipboard instead of printing
-s, --show With list/history: also show the passwords
-h, --help Show this help
Case never matters. A master is ${MIN_MASTER_WORDS}+ words: 3 the minimum, 4 recommended, 6 the safest.
EOF
}
about() {
cat <<EOF
${BOLD}${MAGENTA}NexoPass${RST} $NEXOPASS_VERSION
Passwords derived from a master, nothing stored in plain text.
Compatible with the NexoPass Android app.
Made by ${BOLD}Nexoniarz${RST}
License: Apache License 2.0
EOF
}
# --- settings ---------------------------------------------------------------
# Plain key=value file, nothing secret in it. Unknown or invalid lines are ignored.
load_config() {
CFG_CACHE=5 CFG_CLIP=30 CFG_MODE=words CFG_COLOR=auto CFG_ATTEMPTS=5 CFG_ACCOUNT=main
[[ -f $CONFIG ]] || return 0
local k v
while IFS='=' read -r k v; do
case $k in
cache_minutes) [[ $v =~ ^(0|1|5|15|30|60)$ ]] && CFG_CACHE=$v ;;
clip_seconds) [[ $v =~ ^(10|15|30|60|120)$ ]] && CFG_CLIP=$v ;;
default_mode) valid_mode "$v" && CFG_MODE=$v ;;
color) [[ $v =~ ^(auto|never)$ ]] && CFG_COLOR=$v ;;
max_attempts) [[ $v =~ ^(3|5|10)$ ]] && CFG_ATTEMPTS=$v ;;
current_account) account_name_ok "$v" && CFG_ACCOUNT=$v ;;
esac
done <"$CONFIG"
return 0
}
save_config() {
mkdir -p "$(dirname "$CONFIG")"
printf '%s\n' "# NexoPass settings, change with: nexopass settings" \
"cache_minutes=$CFG_CACHE" "clip_seconds=$CFG_CLIP" "default_mode=$CFG_MODE" \
"color=$CFG_COLOR" "max_attempts=$CFG_ATTEMPTS" "current_account=$CFG_ACCOUNT" >"$CONFIG.tmp"
mv "$CONFIG.tmp" "$CONFIG"
}
# --- accounts ---------------------------------------------------------------
# Every account has its own folder with vault.gpg (its site list).
account_name_ok() { [[ $1 =~ ^[a-z0-9][a-z0-9_-]{0,31}$ ]]; }
select_account() {
ACCOUNT=$1
DATA_DIR="$ACCOUNTS_DIR/$1"
VAULT="$DATA_DIR/vault.gpg"
UNLOCKED=0
}
account_exists() { [[ -f $ACCOUNTS_DIR/$1/vault.gpg ]]; }
account_names() {
local d
for d in "$ACCOUNTS_DIR"/*/; do
[[ -f $d/vault.gpg ]] && basename "$d"
done
return 0
}
# Older versions kept one vault directly in the data folder: it becomes the
# account "main". Its master is asked once, then the app password is set.
migrate_layout() {
[[ -f $BASE_DIR/vault.gpg && ! -e $ACCOUNTS_DIR/main ]] || return 0
(umask 077 && mkdir -p "$ACCOUNTS_DIR/main")
mv "$BASE_DIR/vault.gpg" "$ACCOUNTS_DIR/main/vault.gpg"
[[ -f $BASE_DIR/vault.gpg.bak ]] && mv "$BASE_DIR/vault.gpg.bak" "$ACCOUNTS_DIR/main/vault.gpg.bak"
rm -f "$BASE_DIR/quick.gpg" "$BASE_DIR/quick.meta"
MIGRATED=1
return 0
}
# --- normalization ----------------------------------------------------------
trim() {
local s=$1
s="${s#"${s%%[![:space:]]*}"}"
printf '%s' "${s%"${s##*[![:space:]]}"}"
}
# Unicode-aware lowercase. Runs in a subshell with builtins only, so the value
# never reaches the process list.
lower() {
(
LC_ALL=C.UTF-8
printf '%s' "${1,,}"
)
}
# 'https://www.Allegro.pl/konto' -> 'allegro', 'Discord' -> 'discord'
normalize_site() {
local s n
s=$(trim "${1,,}")
[[ $s == *://* ]] && s="${s#*://}"
s="${s%%[/?#]*}"
s="${s##*@}"
s="${s%%:*}"
s="${s#www.}"
s="${s%.}"
[[ -n $s && $s != .* && $s != *. && $s != *..* ]] || return 1
local -a l
IFS=. read -ra l <<<"$s"
n=${#l[@]}
if ((n >= 3)) && [[ " $TWO_PART_TLDS " == *" ${l[n - 2]}.${l[n - 1]} "* ]]; then
s=${l[n - 3]}
elif ((n >= 2)); then
s=${l[n - 2]}
fi
[[ $s =~ ^[a-z0-9-]+$ ]] || return 1
printf '%s' "$s"
}
normalize_user() {
local u
u=$(trim "$(lower "$1")")
[[ $u != *[[:cntrl:]]* ]] || return 1
printf '%s' "$u"
}
# Lowercase, and any run of spaces/tabs/newlines becomes a single space.
normalize_master() {
local -a w
read -ra w <<<"$(lower "$1")"
local IFS=' '
MASTER="${w[*]}"
}
# Prints the strength label; fails when the master is below the minimum.
master_strength() {
local -a w
read -ra w <<<"$MASTER"
local n=${#w[@]}
if ((n < MIN_MASTER_WORDS || ${#MASTER} < MIN_MASTER_BYTES)); then
echo "too short: at least $MIN_MASTER_WORDS words and $MIN_MASTER_BYTES characters"
return 1
fi
case $n in
3) echo "3 words: minimum" ;;
4) echo "4 words: recommended" ;;
5) echo "5 words: strong" ;;
*) echo "$n words: safest" ;;
esac
}
# --- key derivation ---------------------------------------------------------
# derive_hex <salt> <bytes>: Argon2id(master, salt) as hex. The master goes
# through a pipe from a builtin, so it never shows up in the process list.
derive_hex() {
printf '%s' "$MASTER" | argon2 "$1" -id -t "$ARGON_T" -m "$ARGON_M" -p "$ARGON_P" -l "$2" -r
}
# Turns hex into a list of 32-bit numbers that pick() consumes in order.
load_ints() {
local hex=$1 i
INTS=()
POS=0
for ((i = 0; i < ${#hex}; i += 8)); do
INTS+=($((16#${hex:i:8})))
done
}
# pick <n>: unbiased number 0..n-1 into REPLY.
pick() {
local n=$1 limit x
limit=$((4294967296 - 4294967296 % n))
while ((POS < ${#INTS[@]})); do
x=${INTS[POS]}
POS=$((POS + 1))
if ((x < limit)); then
REPLY=$((x % n))
return
fi
done
die "Ran out of random data (should never happen)."
}
load_words() {
[[ -f $WORDLIST ]] || die "Word list not found: $WORDLIST"
local sum
sum=$(sha256sum "$WORDLIST")
[[ ${sum%% *} == "$WORDLIST_SHA256" ]] || die "Word list differs from the EFF original, passwords would not match."
WORDS=()
local _num word
while IFS=$'\t' read -r _num word; do
WORDS+=("$word")
done <"$WORDLIST"
}
valid_mode() {
[[ $1 == words || $1 =~ ^words([6-9]|1[0-5])$ || $1 =~ ^chars(1[2-9]|[2-5][0-9]|6[0-4])$ ]]
}
# words_mode <n>: 5 stays "words" so older entries keep their passwords.
words_mode() {
if (($1 == WORD_COUNT)); then echo words; else echo "words$1"; fi
}
# Mode for a new site or version: -w / -n win, else <fallback>.
pick_mode() {
if [[ -n $WORDS_ARG ]]; then
words_mode "$WORDS_ARG"
elif [[ -n $LENGTH ]]; then
echo "chars$LENGTH"
else
echo "$1"
fi
}
# make_password <site> <user> <version> <mode>: result in PASSWORD.
make_password() {
local site=$1 user=$2 version=$3 mode=$4 hex i j t
hex=$(derive_hex "$SALT_PREFIX:site:$site:$version:$mode:$user" "$SITE_BYTES") || die "Argon2 failed."
load_ints "$hex"
PASSWORD=""
if [[ $mode == words* ]]; then
local count=${mode#words}
count=${count:-$WORD_COUNT}
for ((i = 0; i < count; i++)); do
pick "${#WORDS[@]}"
PASSWORD+="${PASSWORD:+-}${WORDS[REPLY]^}"
done
pick "${#DIGITS}"
PASSWORD+="${DIGITS:REPLY:1}"
else
# One character from each group, the rest from all of them, then shuffle.
local len=${mode#chars} all="$UPPER$LOWER$DIGITS$SYMBOLS" group
local -a c=()
for group in "$UPPER" "$LOWER" "$DIGITS" "$SYMBOLS"; do
pick "${#group}"
c+=("${group:REPLY:1}")
done
for ((i = 4; i < len; i++)); do
pick "${#all}"
c+=("${all:REPLY:1}")
done
for ((i = len - 1; i > 0; i--)); do
pick $((i + 1))
j=$REPLY
t=${c[i]}
c[i]=${c[j]}
c[j]=$t
done
PASSWORD=$(printf '%s' "${c[@]}")
fi
}
# Two words that are always the same for the same master. Same on the phone.
check_words() {
local hex
hex=$(derive_hex "$SALT_PREFIX:check" 32) || die "Argon2 failed."
load_ints "$hex"
pick "${#WORDS[@]}"
local a=${WORDS[REPLY]}
pick "${#WORDS[@]}"
printf '%s %s' "$a" "${WORDS[REPLY]}"
}
# --- gpg helpers ------------------------------------------------------------
# gpg_pass <passphrase> <gpg args...>: the passphrase goes in through fd 3.
gpg_pass() {
local pass=$1
shift
gpg --batch --quiet --no-symkey-cache --pinentry-mode loopback \
--passphrase-fd 3 "$@" 3< <(printf '%s\n' "$pass")
}
# --- vault ------------------------------------------------------------------
# Plain text inside the encryption: one line per version, fields separated by
# 0x1F: site, user, version, mode, since, until (empty until = current).
# parse_records <text> <prefix>: fills <prefix>_SITE, _USER... arrays.
parse_records() {
local -n ps=${2}_SITE pu=${2}_USER pv=${2}_VER pm=${2}_MODE pa=${2}_SINCE pb=${2}_UNTIL
ps=() pu=() pv=() pm=() pa=() pb=()
local line site
while IFS= read -r line; do
[[ -z $line || $line == \#* ]] && continue
local -a f
IFS=$SEP read -ra f <<<"$line$SEP"
((${#f[@]} >= 5)) || continue
[[ ${f[2]} =~ ^[1-9][0-9]{0,3}$ ]] || continue
site=$(normalize_site "${f[0]}") || continue
[[ $site == "${f[0]}" ]] || continue
ps+=("${f[0]}") pu+=("${f[1]:-}") pv+=("${f[2]}") pm+=("${f[3]}") pa+=("${f[4]}") pb+=("${f[5]:-}")
done <<<"$1"
}
vault_text() {
local i
echo "$VAULT_HEADER"
for i in "${!R_SITE[@]}"; do
printf '%s\n' "${R_SITE[i]}$SEP${R_USER[i]}$SEP${R_VER[i]}$SEP${R_MODE[i]}$SEP${R_SINCE[i]}$SEP${R_UNTIL[i]}"
done
}
vault_load() {
R_SITE=() R_USER=() R_VER=() R_MODE=() R_SINCE=() R_UNTIL=()
[[ -f $VAULT ]] || return 0
local plain
plain=$(gpg_pass "$VAULT_KEY" --decrypt "$VAULT" 2>/dev/null) || return 1
parse_records "$plain" R
}
vault_save() {
local tmp
(umask 077 && mkdir -p "$DATA_DIR")
tmp=$(mktemp "$DATA_DIR/.vault.XXXXXX")
vault_text | gpg_pass "$VAULT_KEY" --yes --symmetric --cipher-algo AES256 --output "$tmp" || {
rm -f "$tmp"
die "Could not save the vault."
}
[[ -f $VAULT ]] && cp -p "$VAULT" "$VAULT.bak"
mv "$tmp" "$VAULT"
}
# find_current <site> <user>: index of the current version into REPLY, or fail.
find_current() {
local i
for i in "${!R_SITE[@]}"; do
if [[ ${R_SITE[i]} == "$1" && ${R_USER[i]} == "$2" && -z ${R_UNTIL[i]} ]]; then
REPLY=$i
return 0
fi
done
return 1
}
add_record() {
R_SITE+=("$1") R_USER+=("$2") R_VER+=("$3") R_MODE+=("$4") R_SINCE+=("$(date +%F)") R_UNTIL+=("")
}
# Merges the I_* arrays into R_*. Per site and account the highest version is
# the current one, every lower version is archived. Same rule as the app.
merge_records() {
local -a lines=()
local i
mapfile -t lines < <(
{
for i in "${!R_SITE[@]}"; do
printf '%s\n' "${R_SITE[i]}$SEP${R_USER[i]}$SEP${R_VER[i]}$SEP${R_MODE[i]}$SEP${R_SINCE[i]}$SEP${R_UNTIL[i]}"
done
for i in "${!I_SITE[@]}"; do
printf '%s\n' "${I_SITE[i]}$SEP${I_USER[i]}$SEP${I_VER[i]}$SEP${I_MODE[i]}$SEP${I_SINCE[i]}$SEP${I_UNTIL[i]}"
done
} | sort -t "$SEP" -k1,1 -k2,2 -k3,3n
)
R_SITE=() R_USER=() R_VER=() R_MODE=() R_SINCE=() R_UNTIL=()
local line n
for line in "${lines[@]}"; do
local -a f
IFS=$SEP read -ra f <<<"$line$SEP"
n=$((${#R_SITE[@]} - 1))
if ((n >= 0)) && [[ ${R_SITE[n]} == "${f[0]}" && ${R_USER[n]} == "${f[1]:-}" && ${R_VER[n]} == "${f[2]}" ]]; then
# Same version twice: keep the archived copy, it knows its end date.
[[ -z ${R_UNTIL[n]} ]] && R_UNTIL[n]=${f[5]:-}
continue
fi
R_SITE+=("${f[0]}") R_USER+=("${f[1]:-}") R_VER+=("${f[2]}") R_MODE+=("${f[3]}") R_SINCE+=("${f[4]}") R_UNTIL+=("${f[5]:-}")
done
for i in "${!R_SITE[@]}"; do
n=$((i + 1))
if ((n < ${#R_SITE[@]})) && [[ ${R_SITE[n]} == "${R_SITE[i]}" && ${R_USER[n]} == "${R_USER[i]}" ]]; then
[[ -z ${R_UNTIL[i]} ]] && R_UNTIL[i]=${R_SINCE[n]}
else
R_UNTIL[i]=""
fi
done
}
# --- keyring and app password -----------------------------------------------
# keyring.gpg: one line per account, "name<0x1F>master", encrypted with QKEY =
# Argon2id(app password, random salt). keyring.meta: method, salt, failed tries.
keyring_method() {
[[ -f $KEYRING && -f $KEYRING_META ]] || return 1
sed -n 's/^method=//p' "$KEYRING_META"
}
keyring_meta_get() { sed -n "s/^$1=//p" "$KEYRING_META"; }
keyring_set_failed() {
printf 'method=%s\nsalt=%s\nfailed=%s\n' "$(keyring_meta_get method)" "$(keyring_meta_get salt)" "$1" >"$KEYRING_META"
}
app_name() {
if [[ $(keyring_method) == pin ]]; then echo PIN; else echo "app password"; fi
}
keyring_parse() {
KR=()
local line
while IFS= read -r line; do
[[ $line == *"$SEP"* ]] || continue
KR[${line%%"$SEP"*}]=${line#*"$SEP"}
done <<<"$1"
}
keyring_text() {
local n
for n in "${!KR[@]}"; do
printf '%s\n' "$n$SEP${KR[$n]}"
done
}
keyring_save() {
[[ -n $QKEY ]] || die "Internal error: keyring key missing."
(umask 077 && mkdir -p "$BASE_DIR")
keyring_text | gpg_pass "$QKEY" --yes --symmetric --cipher-algo AES256 --output "$KEYRING.tmp" ||
die "Could not save the keyring."
mv "$KEYRING.tmp" "$KEYRING"
chmod 600 "$KEYRING"
}
# Re-reads the keyring with the key we already have (after another command changed it).
keyring_reload() {
[[ -n $QKEY && -f $KEYRING ]] || return 1
local text
text=$(gpg_pass "$QKEY" --decrypt "$KEYRING" 2>/dev/null) || return 1
keyring_parse "$text"
}
app_key() {
printf '%s' "$1" | argon2 "$2" -id -t "$QUICK_T" -m "$QUICK_M" -p 1 -l 32 -r
}
# app_try <secret>: 0 = unlocked, 1 = wrong, 2 = too many wrong (keyring removed).
app_try() {
local key text failed left
key=$(app_key "$1" "$(keyring_meta_get salt)") || die "Argon2 failed."
if text=$(gpg_pass "$key" --decrypt "$KEYRING" 2>/dev/null); then
QKEY=$key
keyring_parse "$text"
keyring_set_failed 0
return 0
fi
failed=$(($(keyring_meta_get failed) + 1))
left=$((CFG_ATTEMPTS - failed))
if ((left <= 0)); then
rm -f "$KEYRING" "$KEYRING_META"
warn "Too many wrong attempts: the app password was removed. Recover with your master."
return 2
fi
keyring_set_failed "$failed"
warn "Wrong $(app_name). $left attempt(s) left."
return 1
}
# Asks for the app password until it works. Fails on empty input (recover
# with the master) or when the attempts run out.
app_unlock() {
local secret rc name
name=$(app_name)
while true; do
read -rsp "${BOLD}${name^}${RST} ${DIM}(forgot it? press Enter to recover with your master)${RST}: " secret </dev/tty
echo >&2
[[ -n $secret ]] || return 1
info "Unlocking..."
rc=0
app_try "$secret" || rc=$?
case $rc in
0) return 0 ;;
2) return 1 ;;
esac
done
}
# Asks once for the current app password: for passwd, show-master, detach.
app_confirm() {
local secret rc=0
keyring_method >/dev/null || die "No app password is set yet."
read -rsp "${BOLD}Current $(app_name)${RST}: " secret </dev/tty
echo >&2
app_try "$secret" || rc=$?
((rc == 0)) || die "Not confirmed."
}
# app_password_setup <password|pin>: new secret twice, new salt, keyring re-saved.
app_password_setup() {
local method=$1 name secret again salt
name=PIN
[[ $method == password ]] && name=password
if [[ $method == pin ]]; then
warn "This PC has no TPM chip, so anyone who copies your keyring file can try"
warn "PINs offline. A 6-digit PIN holds for days, not years. A password is safer."
fi
while true; do
read -rsp "New $name: " secret </dev/tty
echo >&2
if [[ $method == pin && ! $secret =~ ^[0-9]{6,32}$ ]]; then
warn "A PIN is 6 to 32 digits."
continue
elif [[ $method == password ]] && ((${#secret} < 8)); then
warn "Use at least 8 characters."
continue
fi
read -rsp "Repeat $name: " again </dev/tty
echo >&2
[[ $secret == "$again" ]] && break
warn "They don't match, try again."
done
info "Saving..."
salt=$(od -An -tx1 -N16 /dev/urandom | tr -d ' \n')
QKEY=$(app_key "$secret" "$salt") || die "Argon2 failed."
(umask 077 && mkdir -p "$BASE_DIR")
printf 'method=%s\nsalt=%s\nfailed=0\n' "$method" "$salt" >"$KEYRING_META"
chmod 600 "$KEYRING_META"
keyring_save
ok "From now on you unlock NexoPass with this $name."
}
choose_app_password() {
local choice
read -rp "Unlock NexoPass with a [p]assword (recommended) or a P[I]N? [p] " choice </dev/tty
case ${choice,,} in
i*) app_password_setup pin ;;
*) app_password_setup password ;;
esac
}
# --- "stay unlocked" --------------------------------------------------------
# The keyring key and the keyring stay in the kernel keyring for a few minutes.
cache_name() {
local h
h=$(printf '%s' "$BASE_DIR" | sha256sum)
printf 'nexopass:%s' "${h:0:16}"
}
cache_get() {
((CFG_CACHE > 0)) || return 1
local id data
id=$(keyctl search @u user "$(cache_name)" 2>/dev/null) || return 1
data=$(keyctl pipe "$id" 2>/dev/null) || return 1
QKEY=${data%%$'\n'*}
keyring_parse "${data#*$'\n'}"
keyctl timeout "$id" $((CFG_CACHE * 60)) &>/dev/null || true
}
cache_put() {
((CFG_CACHE > 0)) && [[ -n $QKEY ]] || return 0
local id
id=$({
printf '%s\n' "$QKEY"
keyring_text
} | keyctl padd user "$(cache_name)" @u) || return 0
keyctl timeout "$id" $((CFG_CACHE * 60)) &>/dev/null || true
}
cache_clear() {
local id
id=$(keyctl search @u user "$(cache_name)" 2>/dev/null) || return 0
keyctl unlink "$id" @u &>/dev/null || true
}
# --- unlocking --------------------------------------------------------------
read_master() {
local raw
read -rsp "${BOLD}${1:-Master}:${RST} " raw </dev/tty
echo >&2
normalize_master "$raw"
[[ -n $MASTER ]] || die "Empty master."
((${#MASTER} <= 127)) || die "Master is too long (max 127 bytes)."
}
# Unlocks the app (keyring). Leaves KR empty when recovering with a master.
unlock_app() {
[[ -n $QKEY ]] && return 0
cache_get && return 0
if keyring_method >/dev/null; then
app_unlock && return 0
RECOVERING=1
fi
return 0
}
# create_account <name>: master twice, vault created, master added to the keyring.
create_account() {
local name=$1 first strength raw
echo "Type the master for account ${BOLD}$name${RST}: a new one, or the one you already use"
echo "on your phone. ${MIN_MASTER_WORDS}+ words: 3 the minimum, 4 recommended, 6 the safest."
echo "Need one? Run: ${CYAN}nexopass new-master${RST}"
read_master
first=$MASTER
strength=$(master_strength) || die "Master is $strength."
read -rsp "Repeat master: " raw </dev/tty
echo >&2
normalize_master "$raw"
[[ $MASTER == "$first" ]] || die "Masters don't match."
info "Master strength: ${BOLD}$strength${RST}"
info "Check words: ${BOLD}$(check_words)${RST} (the phone app shows the same for the same master)"
select_account "$name"
VAULT_KEY=$(derive_hex "$SALT_PREFIX:vault" 32) || die "Argon2 failed."
R_SITE=() R_USER=() R_VER=() R_MODE=() R_SINCE=() R_UNTIL=()
vault_save
KR[$name]=$MASTER
ok "Account '$name' created. Keep its master in your head: it's the way to recover."
if [[ -n $QKEY ]]; then
keyring_save
else
choose_app_password
fi
UNLOCKED=1
}
first_run() {
local name
echo "${BOLD}Welcome to NexoPass.${RST} Let's create your first account."
read -rp "Account name [main]: " name </dev/tty
name=${name:-main}
name=${name,,}
account_name_ok "$name" || die "Use lowercase letters, digits, - or _."
create_account "$name"
CFG_ACCOUNT=$name
save_config
}
# Opens the selected account: its master comes from the keyring, or is typed
# once (new since an update, skipped during recovery, or recovering).
open_account() {
if ! account_exists "$ACCOUNT"; then
if [[ -z $(account_names) && -z ${ACCOUNT_OPT:-} ]]; then
first_run
cache_put
return
fi
die "No account '$ACCOUNT'. See: nexopass accounts"
fi
local typed=0
MASTER=${KR[$ACCOUNT]:-}
if [[ -z $MASTER ]]; then
typed=1
if ((${MIGRATED:-0})); then
info "NexoPass now has accounts and an app password. Your vault is account 'main':"
info "enter its master once, then choose the app password."
elif ((${RECOVERING:-0})); then
info "Recovering: enter the master of account '$ACCOUNT', then set a new app password."
info "Your other accounts will ask for their master once, the next time you use them."
elif [[ -n $QKEY ]]; then
info "Account '$ACCOUNT' isn't in the keyring yet: enter its master once."
else
info "Enter the master of account '$ACCOUNT' once, then choose an app password."
fi
read_master "Master of '$ACCOUNT'"
info "Unlocking..."
fi
VAULT_KEY=$(derive_hex "$SALT_PREFIX:vault" 32) || die "Argon2 failed."
if ! vault_load; then
((typed)) && die "Cannot open account '$ACCOUNT': wrong master?"
die "The saved master no longer opens '$ACCOUNT'. Detach and re-add it."
fi
if ((typed)); then
KR[$ACCOUNT]=$MASTER
if [[ -n $QKEY ]]; then keyring_save; else choose_app_password; fi
RECOVERING=0
fi
cache_put
UNLOCKED=1
}
ensure_unlocked() {
((UNLOCKED)) && return 0
unlock_app
open_account
}
# --- typo suggestions -------------------------------------------------------
# distance <a> <b>: edit distance into REPLY, a swap of two letters counts as one.
distance() {
local a=$1 b=$2 la=${#1} lb=${#2} w i j cost v
w=$((lb + 1))
local -a d=()
for ((i = 0; i <= la; i++)); do d[i * w]=$i; done
for ((j = 0; j <= lb; j++)); do d[j]=$j; done
for ((i = 1; i <= la; i++)); do
for ((j = 1; j <= lb; j++)); do
cost=1
[[ ${a:i-1:1} == "${b:j-1:1}" ]] && cost=0
v=$((d[(i - 1) * w + j] + 1))
((d[i * w + j - 1] + 1 < v)) && v=$((d[i * w + j - 1] + 1))
((d[(i - 1) * w + j - 1] + cost < v)) && v=$((d[(i - 1) * w + j - 1] + cost))
if ((i > 1 && j > 1)) && [[ ${a:i-1:1} == "${b:j-2:1}" && ${a:i-2:1} == "${b:j-1:1}" ]]; then
((d[(i - 2) * w + j - 2] + 1 < v)) && v=$((d[(i - 2) * w + j - 2] + 1))
fi
d[i * w + j]=$v
done
done
REPLY=${d[la * w + lb]}
}
# If SITE/USER_NAME is not on the list but something close is, asks whether
# that was meant and switches to it. An exact match never asks anything.
resolve_typo() {
find_current "$SITE" "$USER_NAME" && return 0
local i limit=2 n=0 ans
((${#SITE} <= 4)) && limit=1
local -a cand=()
while IFS=' ' read -r _ i; do
[[ -n $i ]] && cand+=("$i")
done < <(
for i in "${!R_SITE[@]}"; do
[[ -z ${R_UNTIL[i]} ]] || continue
if ((${#SITE} - ${#R_SITE[i]} > limit || ${#R_SITE[i]} - ${#SITE} > limit)); then continue; fi
distance "$SITE" "${R_SITE[i]}"
((REPLY <= limit)) && echo "$REPLY $i"
done | sort -n | head -5
)
((${#cand[@]})) || return 1
echo "${YELLOW}?${RST} '$(label "$SITE" "$USER_NAME")' is not on your list. Did you mean:" >&2
for i in "${cand[@]}"; do
n=$((n + 1))
echo " ${BOLD}$n)${RST} $(label "${R_SITE[i]}" "${R_USER[i]}")" >&2
done
echo " ${BOLD}0)${RST} no, '$(label "$SITE" "$USER_NAME")' is a different site" >&2
read -rp "Choice [1]: " ans </dev/tty
ans=${ans:-1}
[[ $ans =~ ^[0-9]{1,2}$ ]] || return 1
((ans >= 1 && ans <= ${#cand[@]})) || return 1
i=${cand[ans - 1]}
SITE=${R_SITE[i]} USER_NAME=${R_USER[i]}
find_current "$SITE" "$USER_NAME"
}
# --- output -----------------------------------------------------------------
label() {
local s=$1
[[ -n $2 ]] && s="$1 ($2)"
[[ -n ${3:-} ]] && s+=" v$3"
printf '%s' "$s"
}
output_password() {
if ((COPY)); then
printf '%s' "$PASSWORD" | wl-copy
local pw=$PASSWORD
(
sleep "$CFG_CLIP"
[[ "$(wl-paste -n 2>/dev/null)" == "$pw" ]] && wl-copy --clear
) &>/dev/null &
disown
ok "${BOLD}$1${RST}: copied, clipboard clears in $CFG_CLIP s."
else
ok "${BOLD}$1${RST}:"
printf ' %s\n' "${BOLD}${MAGENTA}$PASSWORD${RST}"
fi
}
ask_yes() {
local answer
read -rp "${YELLOW}?${RST} $1 [y/N] " answer </dev/tty
[[ $answer == [yY]* ]]
}
mode_label() {
case $1 in
words) echo "5 words" ;;
words*) echo "${1#words} words" ;;
*) echo "${1#chars} characters" ;;
esac
}
# --- commands ---------------------------------------------------------------
need_site() {
[[ -n $SITE_ARG ]] || die "Which site? e.g. nexopass $CMD discord"
SITE=$(normalize_site "$SITE_ARG") || die "Not a valid site name: $SITE_ARG"
}
cmd_get() {
need_site
local i mode
if resolve_typo; then
i=$REPLY
[[ -z $LENGTH$WORDS_ARG || ${R_MODE[i]} == "$(pick_mode "${R_MODE[i]}")" ]] ||
info "Length is fixed per version; use rotate with -w/-n to change it."
[[ -z $VERSION_ARG || $VERSION_ARG == "${R_VER[i]}" ]] ||
die "$(label "$SITE" "$USER_NAME" "${R_VER[i]}") is already saved; use rotate to move to a newer version."
else
mode=$(pick_mode "$CFG_MODE")
ask_yes "Add '$(label "$SITE" "$USER_NAME")' to your list?" || die "Cancelled."
add_record "$SITE" "$USER_NAME" "${VERSION_ARG:-1}" "$mode"
vault_save
i=$((${#R_SITE[@]} - 1))
ok "Added $(label "$SITE" "$USER_NAME" "${R_VER[i]}")."
fi
make_password "$SITE" "$USER_NAME" "${R_VER[i]}" "${R_MODE[i]}"
output_password "$(label "$SITE" "$USER_NAME" "${R_VER[i]}")"
}
cmd_rotate() {
need_site
resolve_typo || die "'$SITE' is not on your list. Use: nexopass get $SITE_ARG"
local i=$REPLY mode version
mode=$(pick_mode "${R_MODE[i]}")
version=$((R_VER[i] + 1))
ask_yes "Archive version ${R_VER[i]} of $(label "$SITE" "$USER_NAME") and switch to version $version?" || die "Cancelled."
R_UNTIL[i]=$(date +%F)
add_record "$SITE" "$USER_NAME" "$version" "$mode"
vault_save
ok "Version ${R_VER[i]} archived. Now change the password on the site to:"
make_password "$SITE" "$USER_NAME" "$version" "$mode"
output_password "$(label "$SITE" "$USER_NAME" "$version")"
}
print_rows() {
local i state
printf '%s%-20s %-18s %-5s %-8s %-10s %s%s\n' "$DIM" SITE USER VER MODE SINCE UNTIL "$RST"
for i in "$@"; do
state="${GREEN}current${RST}"
[[ -n ${R_UNTIL[i]} ]] && state="${DIM}${R_UNTIL[i]}${RST}"
printf '%s%-20s%s %-18s %s%-5s%s %-8s %-10s %s\n' "$BOLD" "${R_SITE[i]}" "$RST" "${R_USER[i]:--}" \
"$CYAN" "v${R_VER[i]}" "$RST" "${R_MODE[i]}" "${R_SINCE[i]}" "$state"
if ((SHOW)); then
make_password "${R_SITE[i]}" "${R_USER[i]}" "${R_VER[i]}" "${R_MODE[i]}"
printf ' %s\n' "${MAGENTA}$PASSWORD${RST}"
fi
done
}
cmd_list() {
local -a rows=()
local i
while IFS= read -r i; do rows+=("$i"); done < <(
for i in "${!R_SITE[@]}"; do
[[ -z ${R_UNTIL[i]} ]] && printf '%s\t%s\t%s\n' "${R_SITE[i]}" "${R_USER[i]}" "$i"
done | sort | cut -f3
)
((${#rows[@]})) || {
info "No sites yet. Add one with: nexopass get <site>"
return
}
print_rows "${rows[@]}"
}
cmd_history() {
need_site
resolve_typo || die "'$SITE' is not on your list."
local -a rows=()
local i
while IFS= read -r i; do rows+=("$i"); done < <(
for i in "${!R_SITE[@]}"; do
[[ ${R_SITE[i]} == "$SITE" && ${R_USER[i]} == "$USER_NAME" ]] && printf '%s\t%s\n' "${R_VER[i]}" "$i"
done | sort -n | cut -f2
)
print_rows "${rows[@]}"
}
cmd_remove() {
need_site
resolve_typo || die "'$SITE' is not on your list."
local cur=$REPLY only=""
if [[ -n $WORDS_ARG ]]; then
[[ $WORDS_ARG =~ ^([5-9]|1[0-5])$ ]] || die "Words must be 5-15."
[[ -z $LENGTH ]] || die "Pick either -w (words) or -n (characters)."
fi
if [[ -n $VERSION_ARG ]]; then
[[ $VERSION_ARG != "${R_VER[cur]}" ]] || die "v$VERSION_ARG is the current version; to drop it, rotate first or remove the whole site."
local i found=0
for i in "${!R_SITE[@]}"; do
[[ ${R_SITE[i]} == "$SITE" && ${R_USER[i]} == "$USER_NAME" && ${R_VER[i]} == "$VERSION_ARG" ]] && found=1
done
((found)) || die "$(label "$SITE" "$USER_NAME") has no archived v$VERSION_ARG."
only=$VERSION_ARG
ask_yes "Remove archived $(label "$SITE" "$USER_NAME" "$only") from the list?" || die "Cancelled."
else
ask_yes "Remove $(label "$SITE" "$USER_NAME") and its whole history from the list?" || die "Cancelled."
fi
local i
local -a s=() u=() v=() m=() a=() b=()
for i in "${!R_SITE[@]}"; do
[[ ${R_SITE[i]} == "$SITE" && ${R_USER[i]} == "$USER_NAME" && (-z $only || ${R_VER[i]} == "$only") ]] && continue
s+=("${R_SITE[i]}") u+=("${R_USER[i]}") v+=("${R_VER[i]}") m+=("${R_MODE[i]}") a+=("${R_SINCE[i]}") b+=("${R_UNTIL[i]}")
done
R_SITE=("${s[@]}") R_USER=("${u[@]}") R_VER=("${v[@]}") R_MODE=("${m[@]}") R_SINCE=("${a[@]}") R_UNTIL=("${b[@]}")
vault_save
ok "Removed."
}
cmd_export() {
local file=${SITE_ARG:-$HOME/nexopass-$ACCOUNT-$(date +%F).pgp} pass
[[ -e $file ]] && { ask_yes "$file exists. Overwrite?" || die "Cancelled."; }
pass=$(derive_hex "$SALT_PREFIX:export" 32) || die "Argon2 failed."
vault_text | gpg_pass "$pass" --yes --symmetric --cipher-algo AES256 --output "$file" || die "Export failed."
ok "Exported ${#R_SITE[@]} entries to ${BOLD}$file${RST}"
info "Encrypted: it opens only with the same master, here or in the phone app."
}
cmd_import() {
[[ -n $SITE_ARG ]] || die "Which file? e.g. nexopass import ~/nexopass-2026-10-08.pgp"
[[ -f $SITE_ARG ]] || die "No such file: $SITE_ARG"
local pass plain before
pass=$(derive_hex "$SALT_PREFIX:export" 32) || die "Argon2 failed."
plain=$(gpg_pass "$pass" --decrypt "$SITE_ARG" 2>/dev/null) || die "Cannot open it: exported with a different master, or not a NexoPass export."
I_SITE=() I_USER=() I_VER=() I_MODE=() I_SINCE=() I_UNTIL=()
parse_records "$plain" I
((${#I_SITE[@]})) || die "The file has no sites in it."
before=${#R_SITE[@]}
merge_records
vault_save
ok "Imported. ${#I_SITE[@]} entries read, list went from $before to ${#R_SITE[@]} entries."
}
cmd_show_master() {
account_exists "$ACCOUNT" || die "No account '$ACCOUNT'."
keyring_method >/dev/null || die "No app password yet: unlock once to set it up."
warn "Make sure nobody is looking at your screen."
app_confirm
[[ -n ${KR[$ACCOUNT]:-} ]] || die "The master of '$ACCOUNT' isn't in the keyring yet: open the account once."
echo " ${BOLD}${MAGENTA}${KR[$ACCOUNT]}${RST}"
}
cmd_passwd() {
if keyring_method >/dev/null; then
app_confirm
else
ensure_unlocked
return
fi
choose_app_password
cache_clear
cache_put
}
cmd_lock() {
cache_clear
ok "Locked. Next time you'll be asked again."
}
cmd_new_master() {
local count=${SITE_ARG:-6}
[[ $count =~ ^[1-9][0-9]?$ ]] || die "Number of words must be 3-99."
((count >= MIN_MASTER_WORDS)) || die "At least $MIN_MASTER_WORDS words (4 recommended, 6 safest)."
local -a picked
mapfile -t picked < <(printf '%s\n' "${WORDS[@]}" | shuf -n "$count" --random-source=/dev/urandom)
echo "${BOLD}${MAGENTA}${picked[*]}${RST}"
}
# --- accounts commands ------------------------------------------------------
cmd_accounts() {
local n mark
local -a names
mapfile -t names < <(account_names)
((${#names[@]})) || {
info "No accounts yet. Run nexopass to create one."
return
}
printf '%s %-24s %s%s\n' "$DIM" ACCOUNT "CHANGED" "$RST"
for n in "${names[@]}"; do
mark=" "
[[ $n == "$CFG_ACCOUNT" ]] && mark="${GREEN}*${RST}"
printf '%s %s%-24s%s %s\n' "$mark" "$BOLD" "$n" "$RST" "$(date -r "$ACCOUNTS_DIR/$n/vault.gpg" +%F)"
done
}
cmd_account() {
local sub=${SITE_ARG,,} name=${ARG2,,} new=${ARG3,,}
case $sub in
"" | list) cmd_accounts ;;
new)
account_name_ok "$name" || die "Give it a name: lowercase letters, digits, - or _ (e.g. nexopass account new work)."
account_exists "$name" && die "There is already an account called '$name'."
unlock_app
((${RECOVERING:-0})) && die "Unlock with your app password first (or recover an existing account)."
create_account "$name"
cache_clear
cache_put
if [[ $CFG_ACCOUNT != "$name" ]] && ask_yes "Switch to '$name' now?"; then
CFG_ACCOUNT=$name
save_config
fi
;;
use)
account_exists "$name" || die "No account '$name'. See: nexopass accounts"
CFG_ACCOUNT=$name
save_config
ok "Now using account ${BOLD}$name${RST}."
;;
rename)
account_exists "$name" || die "No account '$name'."
account_name_ok "$new" || die "New name: lowercase letters, digits, - or _."
[[ -e $ACCOUNTS_DIR/$new ]] && die "There is already an account called '$new'."
unlock_app
mv "$ACCOUNTS_DIR/$name" "$ACCOUNTS_DIR/$new"
if [[ -n $QKEY ]]; then
if [[ -n ${KR[$name]:-} ]]; then
KR[$new]=${KR[$name]}
unset 'KR[$name]'
fi
keyring_save
cache_clear
cache_put
fi
if [[ $CFG_ACCOUNT == "$name" ]]; then
CFG_ACCOUNT=$new
save_config
fi
ok "Renamed '$name' to '$new'."
;;
detach) cmd_detach "${name:-$ACCOUNT}" ;;
*) die "Unknown: account $sub. Use: accounts, account new|use|rename|detach" ;;
esac
}
# Removes an account from this computer: app password, its master and "yes".
cmd_detach() {
local name=$1 ans rest=""
account_exists "$name" || die "No account '$name'."
select_account "$name"
echo "${RED}${BOLD}Detach account '$name'${RST}"
echo "This removes the account from this computer: its site list with the archive and its"
echo "saved master. Your logins keep their passwords and the master can recreate them,"
echo "but the site list is gone unless you have an export (or it's still on your phone)."
if ask_yes "Export a backup of '$name' first?"; then
ensure_unlocked
SITE_ARG=""
cmd_export
fi
keyring_method >/dev/null && app_confirm
read_master "Master of '$name'"
info "Checking the master..."
VAULT_KEY=$(derive_hex "$SALT_PREFIX:vault" 32) || die "Argon2 failed."
vault_load || die "Wrong master. Nothing was removed."
read -rp "Type ${BOLD}yes${RST} to detach '$name': " ans </dev/tty
[[ $ans == yes ]] || die "Cancelled. Nothing was removed."
rm -rf "${ACCOUNTS_DIR:?}/$name"
if [[ -n $QKEY ]]; then
unset 'KR[$name]'
keyring_save
fi
cache_clear
if [[ $CFG_ACCOUNT == "$name" ]]; then
rest=$(account_names | head -1)
CFG_ACCOUNT=${rest:-main}
save_config
fi
ok "Account '$name' detached."
[[ -n $rest ]] && info "Now using account '$rest'."
return 0
}
# --- settings menu ----------------------------------------------------------
choose() { # choose <prompt> <options...>: REPLY gets the picked option
local prompt=$1 ans opt
shift
read -rp "$prompt [$*]: " ans </dev/tty
for opt in "$@"; do
if [[ $ans == "$opt" ]]; then
REPLY=$opt
return 0
fi
done
warn "Not changed."
return 1
}
cmd_settings() {
local ans method
while true; do
method=$(keyring_method || echo "not set")
echo
echo "${BOLD}${MAGENTA}NexoPass settings${RST} ${DIM}account: $ACCOUNT${RST}"
printf ' %s1)%s Change app password ...... %s\n' "$BOLD" "$RST" "$method"
printf ' %s2)%s Stay unlocked for ........ %s\n' "$BOLD" "$RST" "$( ((CFG_CACHE)) && echo "$CFG_CACHE min" || echo "off (always ask)")"
printf ' %s3)%s Clipboard clears after ... %s s\n' "$BOLD" "$RST" "$CFG_CLIP"
printf ' %s4)%s New sites use ............ %s\n' "$BOLD" "$RST" "$(mode_label "$CFG_MODE")"
printf ' %s5)%s Wrong attempts allowed ... %s\n' "$BOLD" "$RST" "$CFG_ATTEMPTS"
printf ' %s6)%s Colors ................... %s\n' "$BOLD" "$RST" "$CFG_COLOR"
printf ' %s7)%s Show master\n' "$BOLD" "$RST"
printf ' %s8)%s Export site list\n' "$BOLD" "$RST"
printf ' %s9)%s Import site list\n' "$BOLD" "$RST"
printf ' %sa)%s Accounts\n' "$BOLD" "$RST"
printf ' %sd)%s %sDetach this account%s\n' "$BOLD" "$RST" "$RED" "$RST"
printf ' %sl)%s Lock now\n' "$BOLD" "$RST"
printf ' %si)%s About\n' "$BOLD" "$RST"
printf ' %sq)%s Done\n' "$BOLD" "$RST"
read -rp "Choice: " ans </dev/tty || return 0
(
case ${ans,,} in
1) cmd_passwd ;;
2) choose "Stay unlocked for (minutes, 0 = always ask)" 0 1 5 15 30 60 && CFG_CACHE=$REPLY && save_config && { ((CFG_CACHE)) || cache_clear; } ;;
3) choose "Clear clipboard after (seconds)" 10 15 30 60 120 && CFG_CLIP=$REPLY && save_config ;;
4)
read -rp "New sites use: words (5-15) or c + characters (c12-c64), e.g. 6 or c20: " ans </dev/tty
if [[ $ans =~ ^([5-9]|1[0-5])$ ]]; then
CFG_MODE=$(words_mode "$ans")
elif [[ $ans =~ ^c(1[2-9]|[2-5][0-9]|6[0-4])$ ]]; then
CFG_MODE="chars${ans#c}"
else
warn "Not changed."
exit 0
fi
save_config
;;
5) choose "Wrong app password attempts before it is removed" 3 5 10 && CFG_ATTEMPTS=$REPLY && save_config ;;
6) choose "Colors" auto never && CFG_COLOR=$REPLY && save_config ;;
7) cmd_show_master ;;
8) ensure_unlocked && SITE_ARG="" && cmd_export ;;
9)
read -rp "File to import: " SITE_ARG </dev/tty
SITE_ARG=${SITE_ARG/#\~/$HOME}
ensure_unlocked && cmd_import
;;
a)
cmd_accounts
echo "Manage them with: nexopass account new|use|rename|detach <name>"
;;
d) cmd_detach "$ACCOUNT" && exit 3 ;;
l) cmd_lock ;;
i) about ;;
q | "") exit 3 ;;
*) warn "Unknown choice." ;;
esac
) || {
[[ $? == 3 ]] && return 0
true
}
load_config
done
}
# --- argument handling ------------------------------------------------------
parse_args() {
CMD="" SITE_ARG="" ARG2="" ARG3="" USER_NAME="" LENGTH="" WORDS_ARG="" VERSION_ARG="" ACCOUNT_OPT="" COPY=0 SHOW=0
while (($#)); do
case $1 in
-u | --user | -n | --length | -w | --words | -v | --version | -A | --account)
(($# >= 2)) || die "$1 needs a value."
case $1 in
-u | --user) USER_NAME=$(normalize_user "$2") || die "Invalid login name." ;;
-n | --length) LENGTH=$2 ;;
-w | --words) WORDS_ARG=$2 ;;
-v | --version) VERSION_ARG=$2 ;;
-A | --account)
ACCOUNT_OPT=${2,,}
account_name_ok "$ACCOUNT_OPT" || die "Invalid account name: $2"
;;
esac
shift 2
;;
-c | --copy) COPY=1 && shift ;;
-s | --show) SHOW=1 && shift ;;
-h | --help) CMD=help && shift ;;
-*) die "Unknown option: $1 (see nexopass help)" ;;
*)
if [[ -z $CMD ]]; then
CMD=${1,,}
elif [[ -z $SITE_ARG ]]; then
SITE_ARG=$1
elif [[ $CMD == account && -z $ARG2 ]]; then
ARG2=$1
elif [[ $CMD == account && -z $ARG3 ]]; then
ARG3=$1
else
die "Unexpected argument: $1"
fi
shift
;;
esac
done
case $CMD in
get | rotate | list | history | remove | export | import | settings | passwd | lock | show-master | \
account | accounts | new-master | about | help | "") ;;
*) SITE_ARG=$CMD CMD=get ;;
esac
if [[ -n $LENGTH ]]; then
[[ $LENGTH =~ ^[1-6][0-9]$ ]] || die "Length must be 12-64."
((LENGTH >= 12 && LENGTH <= 64)) || die "Length must be 12-64."
fi
if [[ -n $WORDS_ARG ]]; then
[[ $WORDS_ARG =~ ^([5-9]|1[0-5])$ ]] || die "Words must be 5-15."
[[ -z $LENGTH ]] || die "Pick either -w (words) or -n (characters)."
fi
if [[ -n $VERSION_ARG ]]; then
[[ $VERSION_ARG =~ ^[1-9][0-9]{0,3}$ ]] || die "Version must be a number from 1."
fi
select_account "${ACCOUNT_OPT:-$CFG_ACCOUNT}"
}
# Commands that do their own unlocking (or need none).
run_plain() {
case $CMD in
help) usage ;;
about) about ;;
new-master) cmd_new_master ;;
lock) cmd_lock ;;
settings) cmd_settings ;;
passwd) cmd_passwd ;;
account) cmd_account ;;
accounts) cmd_accounts ;;
show-master) cmd_show_master ;;
*) return 1 ;;
esac
}
run_command() {
run_plain && return
ensure_unlocked
case $CMD in
get) cmd_get ;;
rotate) cmd_rotate ;;
list) cmd_list ;;
history) cmd_history ;;
remove) cmd_remove ;;
export) cmd_export ;;
import) cmd_import ;;
esac
}
banner() {
echo "${MAGENTA}${BOLD} ╭──────────────────╮${RST}"
echo "${MAGENTA}${BOLD} │${RST} ${BOLD}N E X O P A S S${RST} ${MAGENTA}${BOLD}│${RST}"
echo "${MAGENTA}${BOLD} ╰──────────────────╯${RST}"
echo " ${DIM}made by Nexoniarz${RST}"
}
session() {
ok "Unlocked. Commands: ${CYAN}<site> get rotate list history remove accounts account settings help quit${RST}"
local line
local -a words
while IFS= read -rep "${MAGENTA}nexopass${RST}${DIM}[$ACCOUNT]${RST}${MAGENTA}›${RST} " line; do
read -ra words <<<"$line"
((${#words[@]})) || continue
case ${words[0],,} in
quit | exit | q) break ;;
esac
(
parse_args "${words[@]}"
[[ -n $CMD ]] && run_command
) || true
# A command may have changed settings, accounts or the keyring.
load_config
setup_colors
keyring_reload || QKEY=""
if [[ $CFG_ACCOUNT != "$ACCOUNT" ]] || ! account_exists "$ACCOUNT"; then
select_account "$CFG_ACCOUNT"
info "Account: ${BOLD}$ACCOUNT${RST}"
fi
UNLOCKED=0
ensure_unlocked
done
}
main() {
load_words
load_config
setup_colors
migrate_layout
parse_args "$@"
if [[ -z $CMD ]]; then
banner
ensure_unlocked
session
else
run_command
fi
}
main "$@"