commit a4fd86deffa0c687ba88c15c0366c0dafff13449
Author: Luke Willis <lukejw@loquat.dev>
Date: Tue, 9 Dec 2025 12:27:40 -0500
Initial commit
Diffstat:
| A | guix.scm | | | 85 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | passphrase-from-stdin.patch | | | 110 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | sta.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