stash

Simple password manager shell script
Log | Files | Refs | README

commit a4fd86deffa0c687ba88c15c0366c0dafff13449
Author: Luke Willis <lukejw@loquat.dev>
Date:   Tue,  9 Dec 2025 12:27:40 -0500

Initial commit

Diffstat:
Aguix.scm | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apassphrase-from-stdin.patch | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asta.sh | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 429 insertions(+), 0 deletions(-)

diff --git a/guix.scm b/guix.scm @@ -0,0 +1,85 @@ +(use-modules (guix packages) + (guix licenses) + (guix git) + (guix build-system trivial) + (guix gexp) + (gnu packages) + (gnu packages authentication) + (gnu packages base) + (gnu packages golang-crypto) + (gnu packages shells) + (gnu packages version-control) + (gnu packages xdisorg)) + +;; dash is a good POSIX shell for scripts in my experience +(define dash-w-sh-symlink + (package + (inherit dash) + (arguments + `(#:phases + (modify-phases %standard-phases + (add-after 'install 'install-sh-symlink + (lambda* (#:key outputs #:allow-other-keys) + ;; Add a `sh' -> `dash' link. + (let ((out (assoc-ref outputs "out"))) + (with-directory-excursion (string-append out "/bin") + (symlink "dash" "sh") + #t))))))))) + +;; TODO: Rewrite sta.sh with libage somehow? This is slightly jank. +;; I will probably do a C or Rust rewrite in the future. +(define-public age-with-stdin-passphrase + (package + (inherit age) + (source (origin + (inherit (package-source age)) + (patches (list + (local-file "passphrase-from-stdin.patch"))))))) + +;; Package +(define stash + (package + (name "stash") + (version "0.1.0") + ;; Use local directory as source + (source (local-file (dirname (current-filename)) #:recursive? #t)) + (build-system trivial-build-system) + (arguments + `(#:modules ((guix build utils)) + #:builder + (begin + (use-modules (guix build utils)) + (let* ((bin-dir (string-append %output "/bin")) + (bin (string-append bin-dir "/sta.sh")) + (get-input-bin (lambda (input-name bin-name) + (string-append + (assoc-ref %build-inputs input-name) + (string-append "/bin/" bin-name)))) + (wrapper-helper (lambda (var value) + `(,var ":" = (,(string-append + "${" var ":=" value "}"))))) + (copy-script (lambda (file dest) + (copy-file file dest) + (patch-shebang dest + (list (string-append + (assoc-ref %build-inputs "dash") + "/bin"))) + (chmod dest #o555)))) + (mkdir-p bin-dir) + (copy-script (string-append (assoc-ref %build-inputs "source") "/sta.sh") + bin) + (wrap-program bin + (wrapper-helper "TOFI" (get-input-bin "tofi" "tofi")) + (wrapper-helper "AGE" (get-input-bin "age" "age")) + (wrapper-helper "AGEKEYGEN" (get-input-bin "age" "age-keygen")) + (wrapper-helper "OATHTOOL" (get-input-bin "oath-toolkit" "oathtool"))))))) + (inputs (list dash-w-sh-symlink + tofi + age-with-stdin-passphrase + oath-toolkit)) + (home-page "https://git.loquat.dev/stash") + (synopsis "Simple password manager shell script") + (description #f) + (license gpl3+))) + +stash diff --git a/passphrase-from-stdin.patch b/passphrase-from-stdin.patch @@ -0,0 +1,110 @@ +From b4f09bfa98e46fa66414cb3d37fab46eb13cb127 Mon Sep 17 00:00:00 2001 +From: Alexander Yastrebov <yastrebov.alex@gmail.com> +Date: Fri, 20 Jun 2025 23:12:08 +0200 +Subject: [PATCH] Decrypt with passphrase from stdin + +Read passphrase from stdin when it is not used for input data. + +Fixes #603 +--- + cmd/age/age.go | 12 +++++++++++- + cmd/age/testdata/output_file.txt | 2 +- + cmd/age/testdata/scrypt.txt | 6 +++--- + cmd/age/testdata/terminal.txt | 9 ++++++++- + 4 files changed, 23 insertions(+), 6 deletions(-) + +diff --git a/cmd/age/age.go b/cmd/age/age.go +index e5d17e2b..275e17b2 100644 +--- a/cmd/age/age.go ++++ b/cmd/age/age.go +@@ -455,10 +455,20 @@ func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) { + } + + func decryptPass(in io.Reader, out io.Writer) { ++ passphrase := passphrasePromptForDecryption ++ ++ if in != os.Stdin && !term.IsTerminal(int(os.Stdin.Fd())) { ++ passphrase = func() (string, error) { ++ b, err := io.ReadAll(os.Stdin) ++ b = bytes.TrimRight(b, "\n") ++ return string(b), err ++ } ++ } ++ + identities := []age.Identity{ + // If there is an scrypt recipient (it will have to be the only one and) + // this identity will be invoked. +- &LazyScryptIdentity{passphrasePromptForDecryption}, ++ &LazyScryptIdentity{passphrase}, + } + + decrypt(identities, in, out) +diff --git a/cmd/age/testdata/output_file.txt b/cmd/age/testdata/output_file.txt +index 5b16d654..24b5219f 100644 +--- a/cmd/age/testdata/output_file.txt ++++ b/cmd/age/testdata/output_file.txt +@@ -54,7 +54,7 @@ cmp inputcopy input + # https://github.com/FiloSottile/age/issues/159 + ttyin terminal + age -p -a -o test.age input +-ttyin terminalwrong ++ttyin -stdin terminalwrong + ! age -o test.out -d test.age + ttyout 'Enter passphrase' + stderr 'incorrect passphrase' +diff --git a/cmd/age/testdata/scrypt.txt b/cmd/age/testdata/scrypt.txt +index 93298855..abc26895 100644 +--- a/cmd/age/testdata/scrypt.txt ++++ b/cmd/age/testdata/scrypt.txt +@@ -10,14 +10,14 @@ ttyout 'Enter passphrase' + ! stdout . + + # decrypt with a provided passphrase +-ttyin terminal ++ttyin -stdin terminal + age -d test.age + ttyout 'Enter passphrase' + ! stderr . + cmp stdout input + + # decrypt with the wrong passphrase +-ttyin wrong ++ttyin -stdin wrong + ! age -d test.age + stderr 'incorrect passphrase' + +@@ -27,7 +27,7 @@ ttyin empty + age -p -o test.age + ! stderr . + ! stdout . +-ttyin autogenerated ++ttyin -stdin autogenerated + age -d test.age + cmp stdout input + +diff --git a/cmd/age/testdata/terminal.txt b/cmd/age/testdata/terminal.txt +index b2cf0078..8923b7d5 100644 +--- a/cmd/age/testdata/terminal.txt ++++ b/cmd/age/testdata/terminal.txt +@@ -34,7 +34,12 @@ age -p -a -o test.age + ttyout 'Enter passphrase' + ! stderr . + # check the file was encrypted correctly +-ttyin terminal ++ttyin -stdin terminal ++age -d test.age ++cmp stdout input ++ ++# read passphrase from stdin ++stdin password + age -d test.age + cmp stdout input + +@@ -53,5 +58,7 @@ test + -- terminal -- + password + password ++-- password -- ++password + -- empty -- + diff --git a/sta.sh b/sta.sh @@ -0,0 +1,234 @@ +#!/bin/sh + +# Sta.sh --- Simple account management +# Copyright © 2025 Luke Willis <lukejw@loquat.dev> +# +# This file is part of Sta.sh. +# +# Sta.sh is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Sta.sh is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# Sta.sh. If not, see <https://www.gnu.org/licenses/>. + +DATADIR="$HOME/.stash" +CONFIGDIR="$XDG_CONFIG_HOME/stash" + +TOFICONFIG="$CONFIGDIR/tofi/config" + +RAWKEY="$DATADIR/key" +PUBKEY="$DATADIR/key.pub" +AGEKEY="$DATADIR/key.age" + +# This could allow package maintainers (read: me) to set custom paths for each +# program used by Sta.sh. For now, I'm just assuming inputs are propogated. +: "${TOFI:=tofi}" +: "${AGE:=age}" +: "${AGEKEYGEN:=age-keygen}" +: "${OATHTOOL:=$OATHTOOL}" + +# Accept a password from the user and output it +# No arguments +take_password() { + echo | $TOFI -c "$TOFICONFIG" \ + --prompt-text="Master password:" \ + --hide-input=true \ + --hidden-character="*" \ + --require-match=false +} + +# Accept text from the user and output it +# $1 is prompt text +take_text() { + echo | $TOFI -c "$TOFICONFIG" \ + --require-match=false \ + --prompt-text="$1" +} + +# Shortcut function to handle taking and encrypting a field +# $1 is login name +# $2 is field name +encrypt() { + # Create login if it doesn't exist + # + # You would think that this should be done in the create_login function but this + # actually prevents an empty login folder from being created + DIR="$DATADIR/logins/$1" + mkdir -p "$DIR" + OUT="$DIR/$2" + + # Take input + INPUT=$(take_text "Enter $2: ") + + # Ignore if blank + if [ -z "$INPUT" ]; then + echo "Blank field, ignoring..." + return 0 + fi + + # Encrypt it with the public key and store it in the login folder + # TODO: Require master password to override existing value + echo "$INPUT" | $AGE -e -R "$PUBKEY" -o "$OUT" +} + +# Decrypt a field according to its special method. Trusts that the field exists +# $1 is key +# $2 is directory +# $3 is field +decrypt() { + # Decrypt field + VALUE=$(echo "$KEY" | $AGE -d -i - "$2/$3") + + # Process value + case $3 in + "otp") + echo "$VALUE" | $OATHTOOL -b --totp - + ;; + # No special case, return raw value + *) + echo "$VALUE" + ;; + esac +} + +# Copy a information from a specific login in the specified manner +# $1 is login name +# $2 is copy style (blank for default) +copy() { + DIR="$DATADIR/logins/$1" + + # Decrypt master key + while true; do + PASS=$(take_password) + + if [ -z "$PASS" ]; then + echo "Key decryption cancelled" + return 0 + fi + + # Attempt decryption + KEY=$(echo "$PASS" | $AGE -d "$AGEKEY") + status=$? + if [ "$status" -eq 0 ]; then + echo "Key decryption successful" + break + fi + + # Incorrect password, loop again + echo "Key decryption failed" + done + + # Handle copy style + case "$2" in + "") + # Default. Select one field to decrypt and copy. + FIELD=$(ls -1 $DIR | $TOFI -c "$TOFICONFIG" --prompt-text "Copy" --placeholder-text="field") + + if [ -z "$FIELD" ]; then + echo "None selected, returning" + return 0 + fi + + # Decrypt and copy field + decrypt "$KEY" "$DIR" "$FIELD" | wl-copy -n + echo "Copied $FIELD" + ;; + "quick") + # Copy username, password and otp in that order (if they exist). + for FIELD in username password otp; do + if [ -e "$DIR/$FIELD" ]; then + # Decrypt and copy field, hanging until pasted or overwritten. + decrypt "$KEY" "$DIR" "$FIELD" | wl-copy -nof + echo "Pasted $FIELD" + fi + done + ;; + *) + echo "Unknown login style $2?" + ;; + esac +} + +# Listing available logins, take a login name and modify its values. +# This will create a login and/or values if they do not already exist. +modify_logins() { + NAME=$(ls -1 "$DATADIR/logins" | $TOFI -c "$TOFICONFIG" --require-match=false --prompt-text="Modify" --placeholder-text="login") + + # Ignore if no name is provided + if [ -z "$NAME" ]; then + echo "No login selected" + return 0 + fi + + while true; do + CHOICE=$(printf "username\npassword\notp" | $TOFI -c "$TOFICONFIG" --require-match=false --prompt-text="Set" --placeholder-text="field") + case "$CHOICE" in + "") + break + ;; + *) + encrypt "$NAME" "$CHOICE" + ;; + esac + done + + echo 'Done!!!' +} + +# Select from the available logins and perform the requested action +# $1 is passed to `copy` as $2 +fetch_login() { + echo "Fetching login" + + CHOICE=$(ls -1 "$DATADIR/logins" | $TOFI -c "$TOFICONFIG" --prompt-text "Select" --placeholder-text "login") + + case $CHOICE in + "") echo "No login selected" ;; + *) + echo "$CHOICE selected" + copy $CHOICE $1 + ;; + esac +} + +# Initialize stash if data directory does not exist +if [ ! -d "$DATADIR" ]; then + # Don't attempt to initialize outside of a terminal + # TODO: Add ability to initialize through tofi + if [ ! -t 1 ]; then + echo | $TOFI -c "$TOFICONFIG" \ + --prompt-text "Please run \"sta.sh\" in the terminal to initialize it!" \ + --hide-input=true \ + --require-match=false + return 0 + fi + + echo "Initializing stash..." + + mkdir "$DATADIR" + mkdir "$DATADIR/logins" + + # Generate private and public keys + $AGEKEYGEN -o "$RAWKEY" + $AGEKEYGEN -y "$RAWKEY" > "$PUBKEY" + + # Encrypt the private key with a master password chosen by the user + $AGE -e -p -o "$AGEKEY" "$RAWKEY" + rm "$RAWKEY" + + echo "Initialized" +fi + +# Parse argument and run respective function +case "$1" in + ""|fetch) fetch_login ;; + quick) fetch_login quick ;; + modify) modify_logins ;; + *) echo "Unknown command" ;; +esac