From 1a9de90f890f133e6ab8f298241fe3a6237ba4c8 Mon Sep 17 00:00:00 2001 From: Randall Winkhart Date: Sun, 23 Nov 2025 03:34:31 -0500 Subject: [PATCH] Enable entry password aging (sync with latest libmutton development version) --- README.md | 20 ++++++------- docs/man | 2 +- go.mod | 2 +- go.sum | 4 +-- mutn.go | 64 ++++++++++++++++++++++------------------ src/cli/1globals.go | 2 +- src/cli/add.go | 17 +++++++---- src/cli/copyMenu.go | 4 +-- src/cli/edit.go | 26 +++++++++------- src/cli/entryList.go | 23 +++++++++++++-- src/cli/entryReader.go | 6 ++-- src/cli/utilitiesMisc.go | 12 ++++---- 12 files changed, 106 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 84dc2a5..5a9a246 100644 --- a/README.md +++ b/README.md @@ -23,20 +23,18 @@ Additionally, MUTN is available as a source package ("[mutn](https://aur.archlin After installing, please review the [usage guide](https://github.com/rwinkhart/MUTN/blob/main/wiki/usage.md). # Roadmap -### Release v0.3.1 -- [x] Add interactive copy menu - - [ ] Send user to this menu after add/gen -- [ ] libmutton v0.5.0 ### Release v0.4.0 -- [ ] libmutton v0.6.0 - - [ ] Password aging support - - [ ] Add yellow/red dot indicators to entry list readout for when passwords should be changed +- Add interactive copy menu + - Send user to this menu after add/gen +- libmutton v0.5.0 + - Password aging support + - Add green/yellow/red dot indicators to entry list readout for when passwords should be changed ### Release v0.5.0 -- [ ] libmutton v0.7.0 - - [ ] Implement "netpin" (quick-unlock) +- libmutton v0.6.0 + - Implement "netpin" (quick-unlock) ### Release v0.6.0 -- [ ] Switch to fully compliant Markdown (do not preserve new lines) -- [ ] Switch to alternative Markdown renderer (minimark or BEAN) +- Switch to fully compliant Markdown (do not preserve new lines) +- Switch to alternative Markdown renderer (minimark or BEAN) ### Release v1.0.0 - Distribution packages - [ ] Create packaging scripts - [x] Stable source PKGBUILD diff --git a/docs/man b/docs/man index 1125cfd..ba11acb 100644 --- a/docs/man +++ b/docs/man @@ -1,4 +1,4 @@ -.TH MUTN 1 "09 November 2025" "v0.3.A" "MUTN man page" +.TH MUTN 1 "23 November 2025" "v0.D.0" "MUTN man page" .SH NAME \fBmutn\fR - Simple, self-hosted, SSH-synchronized password and note management based on libmutton. It is the successor to sshyp. diff --git a/go.mod b/go.mod index f3738af..b7166ca 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.4 require ( github.com/charmbracelet/glamour v0.7.0 github.com/rwinkhart/go-boilerplate v0.1.1-0.20251110055016-10ee4f91fcb6 - github.com/rwinkhart/libmutton v0.4.3-0.20251114034746-04e1b0dd37d3 + github.com/rwinkhart/libmutton v0.4.3-0.20251123082520-3ddce4bdf5a7 golang.org/x/term v0.36.0 ) diff --git a/go.sum b/go.sum index a2e1314..37aacb3 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/rwinkhart/go-highlite v0.1.1 h1:9TxbRhYVfD/3YaEgNk1BtEiZ0t8/5mxDUZqNM github.com/rwinkhart/go-highlite v0.1.1/go.mod h1:mWLMtCWcyV0BG4NeyPyAUGMjPvI0ds6vXmUq89QwBFA= github.com/rwinkhart/go-winio v0.1.0 h1:b72agLW+dETGmhR3VbcbwnStfgKfc5AfgJOXBJDkaHg= github.com/rwinkhart/go-winio v0.1.0/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= -github.com/rwinkhart/libmutton v0.4.3-0.20251114034746-04e1b0dd37d3 h1:dkPUIG9s8YR2y+Xw3tcJ2ee2FTNemRizfSFuASn2Pjw= -github.com/rwinkhart/libmutton v0.4.3-0.20251114034746-04e1b0dd37d3/go.mod h1:7sMUNpRyYES4Z694TAuzt1MR7Iy4nnVd48uTxcvZeis= +github.com/rwinkhart/libmutton v0.4.3-0.20251123082520-3ddce4bdf5a7 h1:Yr8RSWi59FN1gBAS0LZ2gasZZ21GNFDMuUiThi/nQug= +github.com/rwinkhart/libmutton v0.4.3-0.20251123082520-3ddce4bdf5a7/go.mod h1:7sMUNpRyYES4Z694TAuzt1MR7Iy4nnVd48uTxcvZeis= github.com/rwinkhart/peercred-mini v0.1.2 h1:4cGWDbv0whvLeVvbUdx84V/9p+2fS+DEXgrA1KxlRFo= github.com/rwinkhart/peercred-mini v0.1.2/go.mod h1:LLHG7YshHEpbpJJP+Il9nx2dnGj5O3VGE32rWmflj0c= github.com/rwinkhart/rcw v0.2.3 h1:g1rVaspZnZM8nWupeSdVO827di4ORMZnczw6iXyo01Y= diff --git a/mutn.go b/mutn.go index de5c8d3..41c5963 100644 --- a/mutn.go +++ b/mutn.go @@ -11,6 +11,7 @@ import ( "github.com/rwinkhart/go-boilerplate/back" "github.com/rwinkhart/go-boilerplate/front" "github.com/rwinkhart/go-boilerplate/other" + "github.com/rwinkhart/libmutton/age" "github.com/rwinkhart/libmutton/cfg" "github.com/rwinkhart/libmutton/clip" "github.com/rwinkhart/libmutton/core" @@ -34,32 +35,32 @@ func main() { // if the first argument is an entry... if strings.HasPrefix(args[1], "/") { - // store location of target entry - targetLocation := global.TargetLocationFormat(args[1]) + // store realPath of target entry + realPath := global.GetRealPath(args[1]) // entry reader shortcut (if no other arguments are supplied) if argsCount == 2 { - cli.EntryReaderDecrypt(targetLocation, true) + cli.EntryReaderDecrypt(realPath, true) // perform other operations on the entry (if other arguments are supplied) } else if argsCount == 3 || (argsCount == 4 && (args[3] == "show" || args[3] == "-s")) { if argsCount == 3 { // default to "password" if no field is specified (for copy, edit, and add) switch args[2] { case "show", "-s": - cli.EntryReaderDecrypt(targetLocation, false) + cli.EntryReaderDecrypt(realPath, false) case "copy": - err := clip.CopyShortcut(targetLocation, 0) + err := clip.CopyShortcut(realPath, 0) if err != nil { other.PrintError("Failed to copy password to clipboard: "+err.Error(), global.ErrorClipboard) } case "edit": - cli.EditEntryField(targetLocation, true, 0) + cli.EditEntryField(realPath, true, 0) case "gen": - cli.AddEntry(targetLocation, true, 1) + cli.AddEntry(realPath, true, 1) case "add": - cli.AddEntry(targetLocation, true, 0) + cli.AddEntry(realPath, true, 0) case "shear": - err := syncclient.ShearRemoteFromClient(args[1]) // pass the incomplete path as the server and all clients (reading from the deletions directory) will have a different home directory + err := syncclient.ShearRemoteFromClient(args[1], false) // pass the incomplete path as the server and all clients (reading from the deletions directory) will have a different home directory if err != nil { other.PrintError("Failed to shear target: "+err.Error(), back.ErrorWrite) } @@ -69,11 +70,11 @@ func main() { } else { // handle "show" or "-s" argument for gen, edit, and add switch args[2] { case "edit": - cli.EditEntryField(targetLocation, false, 0) + cli.EditEntryField(realPath, false, 0) case "gen": - cli.AddEntry(targetLocation, false, 1) + cli.AddEntry(realPath, false, 1) case "add": - cli.AddEntry(targetLocation, false, 0) + cli.AddEntry(realPath, false, 0) default: cli.HelpMain() } @@ -96,11 +97,11 @@ func main() { case "note", "-n": field = 4 case "menu", "-m": - cli.CopyMenu(targetLocation) + cli.CopyMenu(realPath) default: cli.HelpCopy() } - err := clip.CopyShortcut(targetLocation, field) + err := clip.CopyShortcut(realPath, field) if err != nil { other.PrintError("Failed to copy field to clipboard: "+err.Error(), global.ErrorClipboard) } @@ -118,38 +119,38 @@ func main() { case "note", "-n": field = 4 case "rename", "-r": - isAccessible, _ := back.TargetIsFile(targetLocation, true) // error is ignored because dir/file status is irrelevant + isAccessible, _ := back.TargetIsFile(realPath, true) // error is ignored because dir/file status is irrelevant if !isAccessible { - other.PrintError("Failed to access location ("+targetLocation+")", back.ErrorTargetNotFound) + other.PrintError("Failed to access location ("+realPath+")", back.ErrorTargetNotFound) } cli.RenameCli(args[1]) // pass the incomplete path as the server and all clients (reading from the deletions directory) will have a different home directory default: cli.HelpEdit() } if argsCount == 4 { - cli.EditEntryField(targetLocation, true, field) + cli.EditEntryField(realPath, true, field) } else { switch args[4] { case "show", "-s": - cli.EditEntryField(targetLocation, false, field) + cli.EditEntryField(realPath, false, field) default: - cli.EditEntryField(targetLocation, true, field) + cli.EditEntryField(realPath, true, field) } } case "gen": if argsCount == 4 { switch args[3] { case "update", "-u": - cli.GenUpdate(targetLocation, true) + cli.GenUpdate(realPath, true) default: cli.HelpGen() } } else if args[3] == "update" || args[3] == "-u" { switch args[4] { case "show", "-s": - cli.GenUpdate(targetLocation, false) + cli.GenUpdate(realPath, false) default: - cli.GenUpdate(targetLocation, true) + cli.GenUpdate(realPath, true) } } cli.HelpGen() @@ -157,17 +158,17 @@ func main() { switch args[3] { case "password", "-pw": if argsCount == 4 { - cli.AddEntry(targetLocation, true, 0) + cli.AddEntry(realPath, true, 0) } else { switch args[4] { case "show", "-s": - cli.AddEntry(targetLocation, false, 0) + cli.AddEntry(realPath, false, 0) default: - cli.AddEntry(targetLocation, true, 0) + cli.AddEntry(realPath, true, 0) } } case "note", "-n": - cli.AddEntry(targetLocation, true, 2) + cli.AddEntry(realPath, true, 2) case "folder", "-f": err := syncclient.AddFolderRemoteFromClient(args[1]) // pass the incomplete path as the server will have a different home directory if err != nil { @@ -204,14 +205,14 @@ func main() { other.PrintError("Initialization failed: "+err.Error(), 0) } case "tweak": - choice := front.InputMenuGen("Action:", []string{"Change device ID", "Change master password/Optimize entries", "Set text editor"}) + choice := front.InputMenuGen("Action:", []string{"Change device ID", "Change master password/Optimize entries", "Set text editor", "Age all entries"}) switch choice { case 1: oldDeviceID, err := global.GetCurrentDeviceID() if err != nil { other.PrintError("Failed to get current device ID: "+err.Error(), back.ErrorRead) } - _, _, err = synccycles.DeviceIDGen(oldDeviceID, "") + _, _, _, err = synccycles.DeviceIDGen(oldDeviceID, "") if err != nil { other.PrintError("Failed to change device ID: "+err.Error(), global.ErrorSyncProcess) } @@ -230,6 +231,13 @@ func main() { if err != nil { other.PrintError("Failed to set text editor: "+err.Error(), back.ErrorWrite) } + case 4: + forceReage := front.InputBinary("Re-age aged entries?") + fmt.Println("Aging entries; this may take awhile - do not terminate this process") + err := age.AgeAllPasswordEntries(forceReage) + if err != nil { + other.PrintError("Failed to age entries: "+err.Error(), 1) + } } case "copy": cli.HelpCopy() diff --git a/src/cli/1globals.go b/src/cli/1globals.go index f7f4cbb..c0f25aa 100644 --- a/src/cli/1globals.go +++ b/src/cli/1globals.go @@ -13,7 +13,7 @@ var ( ) const ( - MUTNVersion = "0.3.A" // untagged releases feature a letter suffix corresponding to the eventual release version, e.g "0.B.0" -> "0.2.0", "0.2.A" -> "0.2.1" + MUTNVersion = "0.D.0" // untagged releases feature a letter suffix corresponding to the eventual release version, e.g "0.B.0" -> "0.2.0", "0.2.A" -> "0.2.1" ansiBlackOnWhite = "\033[38;5;0;48;5;15m" ) diff --git a/src/cli/add.go b/src/cli/add.go index edb500c..c686658 100644 --- a/src/cli/add.go +++ b/src/cli/add.go @@ -7,22 +7,22 @@ import ( "github.com/rwinkhart/libmutton/core" ) -// AddEntry creates a new entry at targetLocation by taking user input via CLI prompts. +// AddEntry creates a new entry at realPath by taking user input via CLI prompts. // Requires: entryType (0 = standard password entry, 1 = auto-generated password entry, 2 = note-only entry). -func AddEntry(targetLocation string, hideSecrets bool, entryType uint8) { - // ensure targetLocation is valid - _, err := core.EntryAddPrecheck(targetLocation) +func AddEntry(realPath string, hideSecrets bool, entryType uint8) { + // ensure realPath is valid + _, err := core.EntryAddPrecheck(realPath) if err != nil { other.PrintError("Failed to add entry: "+err.Error(), back.ErrorWrite) } var decryptedEntry []string + var password string if entryType < 2 { username := front.Input("Username:") // determine whether to generate the password - var password string if entryType == 0 { password = string(front.InputHidden("Password:")) } else { @@ -43,5 +43,10 @@ func AddEntry(targetLocation string, hideSecrets bool, entryType uint8) { } // write and preview the new entry - writeEntryCLI(targetLocation, decryptedEntry, hideSecrets) + if password != "" { + writeEntryCLI(realPath, decryptedEntry, hideSecrets, true) + } else { + writeEntryCLI(realPath, decryptedEntry, hideSecrets, false) + } + } diff --git a/src/cli/copyMenu.go b/src/cli/copyMenu.go index 38993a9..45d5f2b 100644 --- a/src/cli/copyMenu.go +++ b/src/cli/copyMenu.go @@ -16,9 +16,9 @@ import ( // CopyMenu decrypts an entry and allows the user to // interactively copy fields without having to re-decrypt each time. -func CopyMenu(targetLocation string) { +func CopyMenu(realPath string) { // decrypt entry - decSlice, err := crypt.DecryptFileToSlice(targetLocation) + decSlice, err := crypt.DecryptFileToSlice(realPath) if err != nil { other.PrintError("Failed to decrypt entry: "+err.Error(), global.ErrorDecryption) } diff --git a/src/cli/edit.go b/src/cli/edit.go index 4d8b40b..fd0f369 100644 --- a/src/cli/edit.go +++ b/src/cli/edit.go @@ -15,10 +15,10 @@ import ( ) // RenameCli renames an entry at oldLocationIncomplete to a new location (user input) on both the client and the server. -func RenameCli(oldLocationIncomplete string) { +func RenameCli(oldVanityPath string) { // prompt user for new location and rename - newLocationIncomplete := front.Input("New location:") - err := syncclient.RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete) + newVanityPath := front.Input("New location:") + err := syncclient.RenameRemoteFromClient(oldVanityPath, newVanityPath) if err != nil { other.PrintError("Failed to rename entry: "+err.Error(), back.ErrorWrite) } @@ -26,10 +26,10 @@ func RenameCli(oldLocationIncomplete string) { // exit is done from sync.RenameRemoteFromClient } -// EditEntryField edits a field of an entry at targetLocation (user input). -func EditEntryField(targetLocation string, hideSecrets bool, field int) { +// EditEntryField edits a field of an entry at realPath (user input). +func EditEntryField(realPath string, hideSecrets bool, field int) { // fetch old entry data (with all required lines present) - decryptedEntry, err := core.GetOldEntryData(targetLocation, field) + decryptedEntry, err := core.GetOldEntryData(realPath, field) if err != nil { other.PrintError("Failed to fetch entry data: "+err.Error(), back.ErrorRead) } @@ -58,13 +58,17 @@ func EditEntryField(targetLocation string, hideSecrets bool, field int) { } // write and preview the modified entry - writeEntryCLI(targetLocation, decryptedEntry, hideSecrets) + if field == 0 { + writeEntryCLI(realPath, decryptedEntry, hideSecrets, true) + } else { + writeEntryCLI(realPath, decryptedEntry, hideSecrets, false) + } } -// GenUpdate generates a new password for an entry at targetLocation (user input). -func GenUpdate(targetLocation string, hideSecrets bool) { +// GenUpdate generates a new password for an entry at realPath (user input). +func GenUpdate(realPath string, hideSecrets bool) { // fetch old entry data - decryptedEntry, err := core.GetOldEntryData(targetLocation, 0) + decryptedEntry, err := core.GetOldEntryData(realPath, 0) if err != nil { other.PrintError("Failed to fetch entry data: "+err.Error(), back.ErrorRead) } @@ -73,7 +77,7 @@ func GenUpdate(targetLocation string, hideSecrets bool) { decryptedEntry[0] = inputPasswordGen() // write and preview the modified entry - writeEntryCLI(targetLocation, decryptedEntry, hideSecrets) + writeEntryCLI(realPath, decryptedEntry, hideSecrets, true) } // editNote uses the user-specified text editor to edit an existing note (or create a new one if baseNote is empty). diff --git a/src/cli/entryList.go b/src/cli/entryList.go index 464a2b2..9a46186 100644 --- a/src/cli/entryList.go +++ b/src/cli/entryList.go @@ -7,6 +7,7 @@ import ( "github.com/rwinkhart/go-boilerplate/back" "github.com/rwinkhart/go-boilerplate/other" + "github.com/rwinkhart/libmutton/age" "github.com/rwinkhart/libmutton/synccommon" ) @@ -47,7 +48,7 @@ func determineIndentation(skippedDirList []bool, dirList []string, currentDirInd } // printFileEntry handles processing for printing file entries (determines color, wraps lines, and prints). -func printFileEntry(entry string, lastSlash, charCounter, indent int, colorAlternator int8) (int, int8) { +func printFileEntry(entry string, lastSlash, charCounter, indent int, colorAlternator int8, agingTimestamp int64) (int, int8) { // determine color to print fileEntryName (alternate each time function is run) var colorCode string if colorAlternator > 0 { @@ -57,6 +58,17 @@ func printFileEntry(entry string, lastSlash, charCounter, indent int, colorAlter } colorAlternator = -colorAlternator + // determine password aging dot + var agingDot string + switch age.TranslateAgeTimestamp(agingTimestamp) { + case 1: + agingDot = back.AnsiGreen + "⁍" + back.AnsiReset + case 2: + agingDot = back.AnsiWarning + "⁍" + back.AnsiReset + case 3: + agingDot = back.AnsiError + "⁍" + back.AnsiReset + } + // trim the containing directory from the entry to determine fileEntryName fileEntryName := entry[lastSlash:] @@ -72,7 +84,7 @@ func printFileEntry(entry string, lastSlash, charCounter, indent int, colorAlter } // print fileEntryName to screen - fmt.Printf("%s%s%s ", colorCode, fileEntryName, back.AnsiReset) + fmt.Printf("%s%s%s%s ", agingDot, colorCode, fileEntryName, back.AnsiReset) return charCounter, colorAlternator } @@ -83,6 +95,11 @@ func EntryListGen() { if err != nil { other.PrintError("Failed to generate entry list: "+err.Error(), back.ErrorRead) } + var vanityPathsToTimestamps map[string]int64 + vanityPathsToTimestamps, err = synccommon.GetEntryAges() + if err != nil { + other.PrintError("Failed to retrieve entry aging data: "+err.Error(), back.ErrorRead) + } // print header bar w/total entry count fmt.Print("\n"+ansiBlackOnWhite, len(fileList), " libmutton entries:"+back.AnsiReset) @@ -134,7 +151,7 @@ func EntryListGen() { fmt.Printf("\n\n"+strings.Repeat(" ", indent*2)+ansiDirectoryHeader+"%s/"+back.AnsiReset+"\n", vanityDirectory) } - charCounter, colorAlternator = printFileEntry(file, lastSlash, charCounter, indent, colorAlternator) + charCounter, colorAlternator = printFileEntry(file, lastSlash, charCounter, indent, colorAlternator, vanityPathsToTimestamps[file]) } } diff --git a/src/cli/entryReader.go b/src/cli/entryReader.go index a090581..51c3f01 100644 --- a/src/cli/entryReader.go +++ b/src/cli/entryReader.go @@ -83,12 +83,12 @@ fieldLoop: } // EntryReaderDecrypt is a wrapper for EntryReader that first decrypts an RCW-wrapped file before sending it to EntryReader. -func EntryReaderDecrypt(targetLocation string, hideSecrets bool) { - _, err := back.TargetIsFile(targetLocation, true) +func EntryReaderDecrypt(realPath string, hideSecrets bool) { + _, err := back.TargetIsFile(realPath, true) if err != nil { // if the location does not exist or is a directory... other.PrintError("Failed to verify target location: "+err.Error(), back.ErrorTargetNotFound) } - decSlice, err := crypt.DecryptFileToSlice(targetLocation) + decSlice, err := crypt.DecryptFileToSlice(realPath) if err != nil { other.PrintError("Failed to decrypt entry: "+err.Error(), global.ErrorDecryption) } diff --git a/src/cli/utilitiesMisc.go b/src/cli/utilitiesMisc.go index a95f950..c9d5bfc 100644 --- a/src/cli/utilitiesMisc.go +++ b/src/cli/utilitiesMisc.go @@ -2,7 +2,6 @@ package cli import ( "fmt" - "strings" "github.com/rwinkhart/go-boilerplate/back" "github.com/rwinkhart/go-boilerplate/front" @@ -28,17 +27,16 @@ func inputPasswordGen() string { return synccycles.StringGen(passLength, complexity, passCharset) } -// writeEntryCLI writes an entry to targetLocation and previews it (errors if no data is supplied). -func writeEntryCLI(targetLocation string, decryptedEntry []string, hideSecrets bool) { - if core.EntryIsNotEmpty(decryptedEntry) { - // write the entry to the target location - err := core.WriteEntry(targetLocation, []byte(strings.Join(decryptedEntry, "\n"))) +// writeEntryCLI writes an entry to realPath and previews it (errors if no data is supplied). +func writeEntryCLI(realPath string, decSlice []string, hideSecrets bool, passwordIsNew bool) { + if core.EntryIsNotEmpty(decSlice) { + err := core.WriteEntry(realPath, decSlice, passwordIsNew) if err != nil { other.PrintError("Failed to write entry: "+err.Error(), back.ErrorWrite) } // preview the entry fmt.Println(back.AnsiBold + "\nEntry Preview:" + back.AnsiReset) - EntryReader(decryptedEntry, hideSecrets, true) + EntryReader(decSlice, hideSecrets, true) } else { other.PrintError("No data supplied for entry", back.ErrorTargetNotFound) }