diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72e8ffc --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +* diff --git a/README.md b/README.md new file mode 100644 index 0000000..28dc6f3 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# MUTN Password Manager +Pronounced as: "mutton", "muh·tn" + +MUTN is a very simple self-hosted, SSH-synchronized password manager based on libmutton. + +MUTN is an expanded re-implementation of [sshyp](https://github.com/rwinkhart/sshyp) written in Go. + +Though MUTN will feel very familiar to users of sshyp, it is intended to differ and eventually break compatibility. + +# WARNING +It is your responsibility to assess the security and stability of MUTN before using it and ensure it meets your needs. +I am not responsible for any data loss or breaches of your information resulting from the use of MUTN. +MUTN is a new project that is constantly being updated, and though safety and security are priorities, they cannot be guaranteed. + +# Mission Statement +MUTN aims to make it as simple as possible to manage passwords and notes via CLI across multiple devices in a secure, self-hosted fashion. + +# Building +MUTN is currently in early development and does not yet serve its intended purposes. + +To build a test version of MUTN, simply clone this repository and run `go build main` in the "src" directory. + +# Roadmap +Short-term Goals: + +- Re-implement all of sshyp's functionality, including the sshyp-mfa extension from [sshyp-labs](https://github.com/rwinkhart/sshyp-labs) + +Long-term Goals: + +- Migrate away from GPG +- Consider using a Go-based SSH package, rather than relying on OpenSSH directly +- Break the entry format to allow for storing password aging data (how old a password is) +- Add markdown support for notes diff --git a/commit.sh b/commit.sh new file mode 100755 index 0000000..9f50fa9 --- /dev/null +++ b/commit.sh @@ -0,0 +1,4 @@ +#!/bin/sh +git add -f extra src/*.go src/*.mod .gitignore commit.sh LICENSE README.md +git commit -m "$1" +git push diff --git a/extra/completion.bash b/extra/completion.bash new file mode 100644 index 0000000..49d8688 --- /dev/null +++ b/extra/completion.bash @@ -0,0 +1,41 @@ +# mutn(1) completion + +_path_gen() { + local mutn_path="$HOME/.local/share/libmutton" + local glob_status=$(shopt -p globstar) + [ -z "${glob_status##*u*}" ] && shopt -s globstar # if recursive globbing is disabled, enable it + local full_paths=("$mutn_path"/**/*.gpg) + $glob_status # set recursive globbing to user default + for scan_path in "${full_paths[@]}"; do + trimmed_paths+=("${scan_path:${#mutn_path}:-4}") + done + if [ "${trimmed_paths[0]}" == '/**/*' ]; then trimmed_paths[0]=help; fi +} && + +_mutn_completions() { + local cur=${COMP_WORDS[COMP_CWORD]} + local prev=${COMP_WORDS[COMP_CWORD-1]} + + case $prev in + mutn ) + while read -r; do ITEM=${REPLY// /\\ }; COMPREPLY+=( "$ITEM" ); done < <( compgen -W "$(printf "'%s' " "${trimmed_paths[@]}")" -- "$cur" ) + ;; + /* ) + while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "add gen edit copy shear" -- "$cur" ) + ;; + add ) + while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "password note folder" -- "$cur" ) + ;; + copy ) + while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "password username url note" -- "$cur" ) + ;; + edit ) + while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "password username url note relocate" -- "$cur" ) + ;; + gen ) + while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "update" -- "$cur" ) + ;; + esac + +} && +_path_gen && complete -F _mutn_completions mutn diff --git a/extra/completion.zsh b/extra/completion.zsh new file mode 100644 index 0000000..a3a2b48 --- /dev/null +++ b/extra/completion.zsh @@ -0,0 +1,30 @@ +#compdef mutn + +mutn_path="$HOME/.local/share/libmutton" +full_paths=("$mutn_path"/**/*.gpg) +trimmed_paths=() +for scan_path in "${full_paths[@]}"; do + trimmed_paths+=("${${scan_path#$mutn_path}%????}") +done +[[ -z $trimmed_paths ]] && trimmed_paths=(help) + +case ${words[-2]} in + mutn ) + compadd $trimmed_paths + ;; + /* ) + compadd {add,gen,edit,copy,shear} + ;; + add ) + compadd {password,note,folder} + ;; + copy ) + compadd {password,username,url,note} + ;; + edit ) + compadd {password,username,url,note,relocate} + ;; + gen ) + compadd update + ;; +esac diff --git a/src/ALLglobals.go b/src/ALLglobals.go new file mode 100644 index 0000000..7091b65 --- /dev/null +++ b/src/ALLglobals.go @@ -0,0 +1,15 @@ +package main + +import ( + termy "golang.org/x/crypto/ssh/terminal" + "os" +) + +// invisible variables +var home, _ = os.UserHomeDir() + +// exported variables +var ( + RootLength = len(EntryRoot) + Width, _, _ = termy.GetSize(int(os.Stdout.Fd())) +) diff --git a/src/UNIXglobals.go b/src/UNIXglobals.go new file mode 100644 index 0000000..fcb1d5e --- /dev/null +++ b/src/UNIXglobals.go @@ -0,0 +1,12 @@ +//go:build !windows + +package main + +// EntryRoot path to libmutton entry directory +var EntryRoot = home + "/.local/share/sshyp" + +// exported constants +const ( + PathSeparator = "/" + Windows = false +) diff --git a/src/WINglobals.go b/src/WINglobals.go new file mode 100644 index 0000000..de18bb9 --- /dev/null +++ b/src/WINglobals.go @@ -0,0 +1,12 @@ +//go:build windows + +package main + +// exported variables +var EntryRoot = home + "\\AppData\\Local\\libmutton\\entries" + +// exported constants +const ( + PathSeparator = "\\" + Windows = true +) diff --git a/src/entryList.go b/src/entryList.go new file mode 100644 index 0000000..f0d192a --- /dev/null +++ b/src/entryList.go @@ -0,0 +1,153 @@ +package main + +import ( + "fmt" + "io/fs" + "path/filepath" + "strings" +) + +var ( + fileListRoot []string + fileListMain []string + dirListMain []string +) + +// processing for printing file entries (determines color, line wrapping, and prints) +func printFileEntry(entry string, lastSlash int, charCounter int, colorAlternator int8) (int, int8) { + // determine color to print fileEntryName (alternate each time function is run) + var colorCode string + if colorAlternator > 0 { + colorCode = "" + } else { + colorCode = "\033[38;5;8m" + } + colorAlternator = -colorAlternator + + // trim the containing directory and file extension from the entry to determine fileEntryName + fileEntryName := entry[lastSlash:] + fileEntryName = fileEntryName[:len(fileEntryName)-4] + + // determine whether to wrap to a new line (+1 is to account for trailing spaces) + charCounter += len(fileEntryName) + 1 + if charCounter >= Width { + charCounter = len(fileEntryName) + 1 + fmt.Println() + } + + // print fileEntryName to screen + fmt.Printf("%s%s\033[0m ", colorCode, fileEntryName) + + return charCounter, colorAlternator +} + +// EntryListGen generates and displays full entry list +func EntryListGen() { + fmt.Print("\nfor a list of usable commands, run \"mutn help\"\n\n\033[38;5;0;48;5;15mlibmutton entries:\033[0m") + + // walk entry directory + _ = filepath.WalkDir(EntryRoot, + func(fullPath string, entry fs.DirEntry, err error) error { + + // trim root path from each path before storing + trimmedPath := fullPath[RootLength:] + + // create three separate slices for root-level entries, all other entries, and all subdirectories + // root-level entries get their own slice so that they can be alphabetically sorted without the chance of directories being placed in from of them + if !entry.IsDir() && strings.Count(trimmedPath, PathSeparator) == 1 { + fileListRoot = append(fileListRoot, trimmedPath) + } else if !entry.IsDir() { + fileListMain = append(fileListMain, trimmedPath) + } else { + dirListMain = append(dirListMain, trimmedPath) + } + + return nil + }) + + // fileListRoot iteration + charCounter := 0 // set to track whether to line-wrap based on character count in line + var colorAlternator int8 = 1 // set to allow alternating colors for each printed entry name + ran := false // set to track whether the loop has been run yet + for _, file := range fileListRoot { + + // print root ("/") header if this is the first run of the loop + // must be done within loop in order to allow not printing the header if there are no root-level entries + if !ran { + ran = true + fmt.Print("\n\n\033[38;5;7;48;5;8m/\033[0m\n") + } + + // get index of last occurrence of pathSeparator in trimmed entry path (used to split entry's containing directory and entry's name) and pass to printFileEntry + charCounter, colorAlternator = printFileEntry(file, strings.LastIndex(file, PathSeparator)+1, charCounter, colorAlternator) + + } + + // DirListMain iteration + nextStartIndex := 0 // set to track where to resume when iterating through fileListMain + var containsSubdirectory bool // set to track whether the current directory contains a subdirectory - if it does, empty directory warnings will not be printed + for i, directory := range dirListMain { + + // reset formatting variables for new directory + charCounter = 0 + colorAlternator = 1 + + // check if next directory is within the current one + if len(dirListMain) > i+1 { + nextDir := dirListMain[i+1] + if directory == nextDir[:strings.LastIndex(nextDir, PathSeparator)] { + containsSubdirectory = true + } else { + containsSubdirectory = false + } + } else { + containsSubdirectory = false + } + + // fileListMain iteration + ran = false // set to track whether the loop has been run yet + for _, file := range fileListMain[nextStartIndex:] { + + // get index of last occurrence of pathSeparator in trimmed entry path (used to split entry's containing directory and entry's name) + lastSlash := strings.LastIndex(file, PathSeparator) + 1 + + // print the current file if it belongs in the current directory - otherwise, break the loop and move on to the next directory + if file[:lastSlash-1] == directory { + + // print directory header if this is the first run of the loop + // must be done within loop in order to allow not printing the header if its directory contains no entries + if !ran { + ran = true + // for consistency, format directories with UNIX-style path separators on all platforms + if !Windows { + fmt.Printf("\n\n\033[38;5;7;48;5;8m%s/\033[0m\n", directory) + } else { + fmt.Printf("\n\n\033[38;5;7;48;5;8m%s/\033[0m\n", strings.ReplaceAll(directory, PathSeparator, "/")) + } + } + + nextStartIndex++ // increment to prevent having to iterate through ALL of fileListMain each time + charCounter, colorAlternator = printFileEntry(file, lastSlash, charCounter, colorAlternator) + + } else { + break + } + } + + // display empty directory warning if the last printed directory contained no entries or subdirectories + if !ran && !containsSubdirectory { + // for consistency, format directories with UNIX-style path separators on all platforms + if !Windows { + fmt.Printf("\n\n\033[38;5;7;48;5;8m%s/\033[0m\n", directory) + } else { + fmt.Printf("\n\n\033[38;5;7;48;5;8m%s/\033[0m\n", strings.ReplaceAll(directory, PathSeparator, "/")) + } + fmt.Print("\033[38;5;9m-empty directory-\033[0m") + } + + } + + // print trailing new lines for proper spacing after entry list is complete + fmt.Print("\n\n") + +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..a7448c6 --- /dev/null +++ b/src/go.mod @@ -0,0 +1,10 @@ +module main + +go 1.21.6 + +require golang.org/x/crypto v0.18.0 + +require ( + golang.org/x/sys v0.16.0 // indirect + golang.org/x/term v0.16.0 // indirect +) diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..32cb017 --- /dev/null +++ b/src/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "os" +) + +func main() { + + args := os.Args[1:] + argsCount := len(args) + + if argsCount == 0 { + + EntryListGen() + + } else if argsCount == 1 { + + switch args[0] { + + case "help", "--help", "-h": + HelpMain() + case "version", "-v": + Version() + default: + HelpMain() + + } + } +} diff --git a/src/printInfo.go b/src/printInfo.go new file mode 100644 index 0000000..4b273c6 --- /dev/null +++ b/src/printInfo.go @@ -0,0 +1,91 @@ +package main + +import "fmt" + +func HelpMain() { + fmt.Print("\n\033[1mMUTN | Copyright (c) 2024 Randall Winkhart\033[0m\n" + ` +This software exists under the MIT license; you may redistribute it under certain conditions. +This program comes with absolutely no warranty; type "mutn version" for details. + +` + "\033[1mUsage:\033[0m" + ` mutn [/ [argument] [option]] | [argument] + +` + "\033[1mArguments:\033[0m" + ` + help/--help/-h Bring up this menu + version/-v Display version and license information + init Set up MUTN + tweak Change configuration options + add Add an entry + gen Generate a new password + edit Edit an existing entry + copy Copy details of an entry to your clipboard + shear Delete an existing entry + sync Manually sync the entry directory + +` + "\033[1mOptions:\033[0m" + ` + add: + password/-p Add a password entry + note/-n Add a note entry + folder/-f Add a new folder for entries +edit: + rename/relocate/-r Rename or relocate an entry + username/-u Change the username of an entry + password/-p Change the password of an entry + url/-l Change the url attached to an entry + note/-n Change the note attached to an entry + copy: + username/-u Copy the username of an entry to your clipboard + password/-p Copy the password of an entry to your clipboard + url/-l Copy the url of an entry to your clipboard + note/-n Copy the note of an entry to your clipboard + gen: + update/-u Generate a password for an existing entry + +` + "\033[1mTip 1:\033[0m" + ` You can quickly read an entry with "mutn /" +` + "\033[1mTip 2:\033[0m" + ` Type "mutn" to view a list of saved entries` + "\n\n") +} + +func Version() { + fmt.Print("\n\033[1mMUTN | Copyright (c) 2024 Randall Winkhart\033[0m" + ` + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE.` + + "\n\n---------------------------------------------------------" + + "\n\n MUTN is a simple, self-hosted,\n SSH-synchronized password manager based on libmutton\n\n" + + " .. \033[38;2;206;48;98m♥♥ ♥♥\033[0m ..\n" + + " /()\\''.''. \033[38;2;206;48;98m♥♥♥♥♥♥♥\033[0m .''.''/()\\ _)\n" + + " _. : * \033[38;2;206;48;98m♥♥♥♥♥\033[0m * : <[◎]|_|=\n" + + " }-}-*] `..'..' \033[38;2;206;48;98m♥♥♥\033[0m `..'..' |\n" + + " ◎-◎ // \\\\ \033[38;2;206;48;98m♥\033[0m // \\\\ /|\\\n" + + "\033[38;2;1;173;216m<><><><><><><><><><><><><><>-<><><><><><><><><><><><><><>\n" + + "\\\033[38;5;15;48;5;15m \033[0m\033[38;2;1;173;216m/\n" + + "\\\033[38;5;15;48;5;15m \033[0mMUTN Version 0.0.1\033[38;5;15;48;5;15m \033[0m\033[38;2;1;173;216m/\n" + + "\\\033[38;5;15;48;5;15m \033[0mThe Butchered Update\033[38;5;15;48;5;15m \033[0m\033[38;2;1;173;216m/\n" + + "\\\033[38;5;15;48;5;15m \033[0m\033[38;2;1;173;216m/\n" + + "\\\033[38;5;15;48;5;15m \033[0mCopyright (C) 2024 Randall Winkhart\033[38;5;15;48;5;15m \033[0m\033[38;2;1;173;216m/\n" + + "\\\033[38;5;15;48;5;15m \033[0m\033[38;2;1;173;216m/\n" + + "<><><><><><><><><><><><><><>-<><><><><><><><><><><><><><>\033[0m\n" + + "\nFor more information, see:\n" + + "https://github.com/rwinkhart/MUTN\n" + + "https://github.com/rwinkhart/libmutton\n\n") +}