From 591f432b04d3b10abea0a50a445d19fd1b8d1e7c Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Thu, 8 Oct 2026 13:44:41 +0200 Subject: [PATCH] NexoPass 1.1: quick unlock, keyring cache, export/import, settings Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- modules/scripts.nix | 2 +- modules/scripts/nexopass.sh | 598 ++++++++++++++++++++++++++++++------ 3 files changed, 506 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 6bc892a..d3a6882 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,9 @@ My personal NixOS system configuration. ## NexoPass -`nexopass` (in `modules/scripts/`) derives site passwords from one master with Argon2id, so no password is ever stored. An encrypted vault in `~/.local/share/nexopass/` keeps only the site list, current versions and an archive of old versions. Case never matters, typos get a "did you mean" prompt. Run `nexopass help` for usage. +`nexopass` (in `modules/scripts/`) derives site passwords from one master with Argon2id, so no password is ever stored. An encrypted vault in `~/.local/share/nexopass/` keeps only the site list, current versions and an archive of old versions. Case never matters, typos get a "did you mean" prompt. + +Quick unlock with a password or PIN, "stay unlocked" for a few minutes (kernel keyring), encrypted export/import shared with the NexoPass Android app, and a settings menu: `nexopass settings`. Run `nexopass help` for everything else. Apache License 2.0. --- diff --git a/modules/scripts.nix b/modules/scripts.nix index 1c85233..292706f 100644 --- a/modules/scripts.nix +++ b/modules/scripts.nix @@ -5,7 +5,7 @@ let stripMetadata = pkgs.writeShellScriptBin "strip-metadata" (builtins.readFile ./scripts/strip-metadata.sh); nexopass = pkgs.writeShellApplication { name = "nexopass"; - runtimeInputs = with pkgs; [ libargon2 gnupg wl-clipboard coreutils ]; + runtimeInputs = with pkgs; [ libargon2 gnupg wl-clipboard keyutils coreutils gnused ]; runtimeEnv.NEXOPASS_WORDLIST = "${./scripts/eff_large_wordlist.txt}"; text = builtins.readFile ./scripts/nexopass.sh; }; diff --git a/modules/scripts/nexopass.sh b/modules/scripts/nexopass.sh index 4681ca9..34b7277 100755 --- a/modules/scripts/nexopass.sh +++ b/modules/scripts/nexopass.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Nexoniarz +# # NexoPass: passwords derived from one master, nothing stored in plain text. # # password = Argon2id(master, site + version + mode + user). The same input @@ -15,9 +18,14 @@ set -euo pipefail export LC_ALL=C +NEXOPASS_VERSION="1.1.0" WORDLIST="${NEXOPASS_WORDLIST:-$(dirname "$(readlink -f "$0")")/eff_large_wordlist.txt}" WORDLIST_SHA256="addd35536511597a02fa0a9ff1e5284677b8883b83e986e43f15a3db996b903e" VAULT="${NEXOPASS_VAULT:-${XDG_DATA_HOME:-$HOME/.local/share}/nexopass/vault.gpg}" +DATA_DIR=$(dirname "$VAULT") +QUICK="$DATA_DIR/quick.gpg" +QUICK_META="$DATA_DIR/quick.meta" +CONFIG="${NEXOPASS_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/nexopass/config}" # Changing any of these changes every password. SALT_PREFIX="nexopass:v1" @@ -32,54 +40,105 @@ 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" -CLIP_SECONDS=30 +# Quick unlock keys are 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" -if [[ -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 - -die() { echo "${RED}[!]${RST} $*" >&2; exit 1; } +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 < Show the password for a site (adds the site on first use) - ${CYAN}rotate${RST} 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} Show all versions of a site, old ones included - ${CYAN}remove${RST} Remove a site from the list (passwords themselves don't change) - ${CYAN}new-master${RST} [N] Generate a random master of N words (default 6) - ${CYAN}help${RST} Show this help +${BOLD}Passwords:${RST} + ${CYAN}get${RST} Show the password for a site (adds the site on first use) + ${CYAN}rotate${RST} 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} Show all versions of a site, old ones included + ${CYAN}remove${RST} Remove a site from the list (passwords themselves don't change) - nexopass Same as: nexopass get - nexopass Interactive session: master asked once, then type commands +${BOLD}Vault and security:${RST} + ${CYAN}settings${RST} Quick unlock (PIN/password), auto-lock, clipboard, defaults... + ${CYAN}export${RST} [file] Save an encrypted copy of your site list (opens on the phone too) + ${CYAN}import${RST} Merge an exported site list into this one + ${CYAN}lock${RST} Forget the remembered master right now + ${CYAN}show-master${RST} Show your master (needs your PIN/password) + ${CYAN}new-master${RST} [N] Generate a random master of N words (default 6) + ${CYAN}about${RST} Version, author, license + + nexopass Same as: nexopass get + nexopass Interactive session: unlock once, then type commands ${BOLD}Options:${RST} -u, --user NAME Account name, for several accounts on one site -n, --length N Random characters (12-64) instead of words, for sites with a length limit; set when adding or rotating a site -v, --version N Start a new site at version N (e.g. already rotated on phone) - -c, --copy Copy to clipboard (cleared after $CLIP_SECONDS s) instead of printing + -c, --copy Copy to clipboard instead of printing -s, --show With list/history: also show the passwords -h, --help Show this help -Upper and lower case never matter. The master is one or more words separated by -spaces; 5-6 random words are recommended. +Case never matters. The master is ${MIN_MASTER_WORDS}+ words separated by spaces: +3 is the minimum, 4 recommended, 6 the safest. EOF } +about() { + cat <"$CONFIG.tmp" + mv "$CONFIG.tmp" "$CONFIG" +} + # --- normalization ---------------------------------------------------------- trim() { @@ -135,19 +194,21 @@ normalize_master() { MASTER="${w[*]}" } -# Warns about masters that someone with one leaked password could guess. +# Prints the strength label; fails when the master is below the minimum. master_strength() { local -a w read -ra w <<<"$MASTER" - local word all_eff=1 - for word in "${w[@]}"; do - [[ -n ${WORD_SET[$word]:-} ]] || all_eff=0 - done - if ((all_eff && ${#w[@]} < 5)) || ((${#MASTER} < 16)); then - warn "Weak master: ${#w[@]} word(s), ${#MASTER} characters." - warn "If one site leaks your password, a master this short can be guessed. 5-6 random words are recommended." + 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 --------------------------------------------------------- @@ -189,11 +250,9 @@ load_words() { sum=$(sha256sum "$WORDLIST") [[ ${sum%% *} == "$WORDLIST_SHA256" ]] || die "Word list differs from the EFF original, passwords would not match." WORDS=() - declare -gA WORD_SET=() local _num word while IFS=$'\t' read -r _num word; do WORDS+=("$word") - WORD_SET[$word]=1 done <"$WORDLIST" } @@ -244,40 +303,58 @@ check_words() { printf '%s %s' "$a" "${WORDS[REPLY]}" } +# --- gpg helpers ------------------------------------------------------------ + +# gpg_pass : 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). -gpg_with_key() { - gpg --batch --quiet --no-symkey-cache --pinentry-mode loopback \ - --passphrase-fd 3 "$@" 3< <(printf '%s\n' "$VAULT_KEY") +# parse_records : fills _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 line - plain=$(gpg_with_key --decrypt "$VAULT" 2>/dev/null) || die "Cannot open the vault: wrong master?" - while IFS= read -r line; do - [[ -z $line || $line == \#* ]] && continue - local -a f - IFS=$SEP read -ra f <<<"$line$SEP" - 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 <<<"$plain" + local plain + plain=$(gpg_pass "$VAULT_KEY" --decrypt "$VAULT" 2>/dev/null) || return 1 + parse_records "$plain" R } vault_save() { - local dir tmp i - dir=$(dirname "$VAULT") - (umask 077 && mkdir -p "$dir") - tmp=$(mktemp "$dir/.vault.XXXXXX") - { - 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 - } | gpg_with_key --yes --symmetric --cipher-algo AES256 --output "$tmp" || { + 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." } @@ -301,6 +378,234 @@ 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 +} + +# --- unlocking -------------------------------------------------------------- +# Order: master remembered in the kernel keyring (if "stay unlocked" is on), +# then quick unlock (PIN/password), then the master itself. + +cache_name() { + local h + h=$(printf '%s' "$VAULT" | sha256sum) + printf 'nexopass:%s' "${h:0:16}" +} + +cache_get() { + ((CFG_CACHE > 0)) || return 1 + local id + id=$(keyctl search @u user "$(cache_name)" 2>/dev/null) || return 1 + MASTER=$(keyctl pipe "$id" 2>/dev/null) || return 1 + keyctl timeout "$id" $((CFG_CACHE * 60)) &>/dev/null || true +} + +cache_put() { + ((CFG_CACHE > 0)) || return 0 + local id + id=$(printf '%s' "$MASTER" | 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 +} + +quick_method() { + [[ -f $QUICK && -f $QUICK_META ]] || return 1 + sed -n 's/^method=//p' "$QUICK_META" +} + +quick_meta_get() { sed -n "s/^$1=//p" "$QUICK_META"; } + +quick_meta_set_failed() { + local m s + m=$(quick_meta_get method) + s=$(quick_meta_get salt) + printf 'method=%s\nsalt=%s\nfailed=%s\n' "$m" "$s" "$1" >"$QUICK_META" +} + +quick_key() { + printf '%s' "$1" | argon2 "$2" -id -t "$QUICK_T" -m "$QUICK_M" -p 1 -l 32 -r +} + +quick_remove() { + rm -f "$QUICK" "$QUICK_META" +} + +# Asks for the PIN/password until it works or the attempts run out. +# Fails (so the caller asks for the master) on empty input. +quick_unlock() { + local method name secret key salt failed left + method=$(quick_method) || return 1 + name=PIN + [[ $method == password ]] && name=Password + salt=$(quick_meta_get salt) + while true; do + read -rsp "${BOLD}$name${RST} ${DIM}(Enter = use master)${RST}: " secret &2 + [[ -n $secret ]] || return 1 + info "Unlocking..." + key=$(quick_key "$secret" "$salt") || die "Argon2 failed." + if MASTER=$(gpg_pass "$key" --decrypt "$QUICK" 2>/dev/null); then + quick_meta_set_failed 0 + return 0 + fi + failed=$(($(quick_meta_get failed) + 1)) + left=$((CFG_ATTEMPTS - failed)) + if ((left <= 0)); then + quick_remove + warn "Too many wrong attempts: quick unlock removed. Use your master, then set it up again in settings." + return 1 + fi + quick_meta_set_failed "$failed" + warn "Wrong ${name,,}. $left attempt(s) left." + done +} + +# Asks for the PIN/password once without counting it as unlock; for show-master. +quick_verify() { + local method name secret key + method=$(quick_method) || return 1 + name=PIN + [[ $method == password ]] && name=Password + read -rsp "${BOLD}$name${RST}: " secret &2 + key=$(quick_key "$secret" "$(quick_meta_get salt)") || return 1 + QUICK_MASTER=$(gpg_pass "$key" --decrypt "$QUICK" 2>/dev/null) +} + +quick_setup() { + local method=$1 name secret again salt key + name=PIN + [[ $method == password ]] && name=password + if [[ $method == pin ]]; then + warn "This PC has no TPM chip, so anyone who copies your quick-unlock file can try" + warn "PINs offline. A 6-digit PIN holds for days, not years. A password is safer." + fi + read -rsp "New $name: " secret &2 + if [[ $method == pin ]]; then + [[ $secret =~ ^[0-9]{6,32}$ ]] || die "A PIN is 6 to 32 digits." + else + ((${#secret} >= 8)) || die "A password needs at least 8 characters." + fi + read -rsp "Repeat $name: " again &2 + [[ $secret == "$again" ]] || die "They don't match." + info "Saving..." + salt=$(od -An -tx1 -N16 /dev/urandom | tr -d ' \n') + key=$(quick_key "$secret" "$salt") || die "Argon2 failed." + (umask 077 && mkdir -p "$DATA_DIR") + printf '%s' "$MASTER" | gpg_pass "$key" --yes --symmetric --cipher-algo AES256 --output "$QUICK.tmp" || + die "Could not save quick unlock." + mv "$QUICK.tmp" "$QUICK" + printf 'method=%s\nsalt=%s\nfailed=0\n' "$method" "$salt" >"$QUICK_META" + chmod 600 "$QUICK" "$QUICK_META" + ok "Quick unlock with a $name is on. Your master still works too." +} + +read_master() { + local raw + read -rsp "${BOLD}Master:${RST} " raw &2 + normalize_master "$raw" + [[ -n $MASTER ]] || die "Empty master." + ((${#MASTER} <= 127)) || die "Master is too long (max 127 bytes)." +} + +# First run: the master is typed twice, must meet the minimum, and the vault +# is created right away. +create_vault() { + local first strength raw choice + echo "${BOLD}Welcome to NexoPass.${RST} No vault yet, so let's create one." + echo "Your master is ${MIN_MASTER_WORDS}+ words: 3 is 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 &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)" + 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 + ok "Vault created." + read -rp "Set up quick unlock so you don't type the master every time? [p]assword / P[I]N / [n]o: " choice : edit distance into REPLY, a swap of two letters counts as one. @@ -373,11 +678,11 @@ output_password() { printf '%s' "$PASSWORD" | wl-copy local pw=$PASSWORD ( - sleep "$CLIP_SECONDS" + sleep "$CFG_CLIP" [[ "$(wl-paste -n 2>/dev/null)" == "$pw" ]] && wl-copy --clear ) &>/dev/null & disown - ok "${BOLD}$1${RST}: copied, clipboard clears in $CLIP_SECONDS s." + ok "${BOLD}$1${RST}: copied, clipboard clears in $CFG_CLIP s." else ok "${BOLD}$1${RST}:" printf ' %s\n' "${BOLD}${MAGENTA}$PASSWORD${RST}" @@ -390,6 +695,10 @@ ask_yes() { [[ $answer == [yY]* ]] } +mode_label() { + if [[ $1 == words ]]; then echo "words"; else echo "${1#chars} characters"; fi +} + # --- commands --------------------------------------------------------------- need_site() { @@ -399,7 +708,6 @@ need_site() { cmd_get() { need_site - vault_load local i mode if resolve_typo; then i=$REPLY @@ -409,6 +717,7 @@ cmd_get() { die "$(label "$SITE" "$USER_NAME" "${R_VER[i]}") is already saved; use rotate to move to a newer version." else mode=words + ((CFG_LENGTH)) && mode="chars$CFG_LENGTH" [[ -n $LENGTH ]] && mode="chars$LENGTH" ask_yes "Add '$(label "$SITE" "$USER_NAME")' to your list?" || die "Cancelled." add_record "$SITE" "$USER_NAME" "${VERSION_ARG:-1}" "$mode" @@ -422,7 +731,6 @@ cmd_get() { cmd_rotate() { need_site - vault_load resolve_typo || die "'$SITE' is not on your list. Use: nexopass get $SITE_ARG" local i=$REPLY mode version mode=${R_MODE[i]} @@ -453,7 +761,6 @@ print_rows() { } cmd_list() { - vault_load local -a rows=() local i while IFS= read -r i; do rows+=("$i"); done < <( @@ -470,7 +777,6 @@ cmd_list() { cmd_history() { need_site - vault_load resolve_typo || die "'$SITE' is not on your list." local -a rows=() local i @@ -484,7 +790,6 @@ cmd_history() { cmd_remove() { need_site - vault_load resolve_typo || die "'$SITE' is not on your list." ask_yes "Remove $(label "$SITE" "$USER_NAME") and its history from the list?" || die "Cancelled." local i @@ -498,13 +803,118 @@ cmd_remove() { ok "Removed." } +cmd_export() { + local file=${SITE_ARG:-$HOME/nexopass-$(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() { + quick_method >/dev/null || die "Quick unlock is off, so nothing is stored: your master is only in your head." + warn "Make sure nobody is looking at your screen." + quick_verify || die "Wrong PIN/password." + echo " ${BOLD}${MAGENTA}$QUICK_MASTER${RST}" +} + +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 1-99." + [[ $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}" - ((count >= 5)) || warn "Fewer than 5 words is weak, see 'nexopass help'." +} + +# --- settings menu ---------------------------------------------------------- + +choose() { # choose : REPLY gets the picked option + local prompt=$1 ans + shift + read -rp "$prompt [$*]: " ans &2 - normalize_master "$raw" - [[ -n $MASTER ]] || die "Empty master." - ((${#MASTER} <= 127)) || die "Master is too long (max 127 bytes)." - if [[ ! -f $VAULT ]]; then - local first=$MASTER - read -rsp "No vault yet, so this is your first run. Repeat master: " raw &2 - normalize_master "$raw" - [[ $MASTER == "$first" ]] || die "Masters don't match." - master_strength || ask_yes "Use it anyway?" || die "Cancelled." - info "Check words: ${BOLD}$(check_words)${RST}" - info "Remember them: the phone app shows the same words for the same master." - fi - info "Unlocking..." - VAULT_KEY=$(derive_hex "$SALT_PREFIX:vault" 32) || die "Argon2 failed." - vault_load -} - 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} get rotate list history remove help quit${RST}" + ok "Unlocked. Commands: ${CYAN} get rotate list history remove settings export import help quit${RST}" local line local -a words while IFS= read -rep "${MAGENTA}nexopass›${RST} " line; do @@ -605,19 +1009,21 @@ session() { parse_args "${words[@]}" [[ -n $CMD ]] && run_command ) || true + load_config + setup_colors + # A command may have changed the vault (or settings); reload it. + vault_load || die "Cannot open the vault anymore." done } main() { load_words + load_config + setup_colors parse_args "$@" - case $CMD in - help) usage && return ;; - new-master) cmd_new_master && return ;; - "") banner ;; - esac - read_master if [[ -z $CMD ]]; then + banner + ensure_unlocked session else run_command