From 704345f8228ce9729bfadf79685ad4891350349f Mon Sep 17 00:00:00 2001 From: Marc Alvarez Date: Tue, 20 Oct 2026 20:21:36 -0600 Subject: [PATCH 1/4] The encrypted export holds each entry's earlier secrets, and importing it brings them back --- docs/SECURITY_MODEL.md | 2 +- docs/USAGE_AND_CONFIGURATION.md | 12 ++-- internal/compat/compat_test.go | 3 + internal/database/history.go | 25 +++++++ internal/importer/seshexport/entries.go | 12 +++- internal/importer/seshexport/entries_test.go | 21 ++++++ internal/password/export.go | 73 +++++++++++++++++--- internal/password/export_encrypted.go | 1 + sesh/cmd/sesh/import_cmd_test.go | 59 ++++++++++++++++ 9 files changed, 189 insertions(+), 19 deletions(-) diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md index 758d071d..24bf445c 100644 --- a/docs/SECURITY_MODEL.md +++ b/docs/SECURITY_MODEL.md @@ -188,7 +188,7 @@ Encrypted exports (`sesh export`, its default) are wrapped in a portable envelop - **Random 32-byte salt** per export — the same password produces different ciphertext each time - **Envelope format** (JSON): `{version, algorithm, salt, params, ciphertext}` — salt and params are public, as in the vault's key record -Plain exports (`sesh export --format json` or `--format csv`) write secrets in plaintext and are intended for local scripting. Encrypted exports are the right choice for backups, transferring between machines, or storing in any medium the user doesn't fully trust. The same brute-force threat model applies as with the master password, so the export password has its rules: at least 8 characters, and an easy one is warned about and asked about. An export to a file is written to a private temporary file beside it, made once the password has been asked and renamed into place once the export is whole (removed if it's interrupted by Ctrl-C or an interrupt, quit, hang-up or termination signal, though not one that can't be caught), and an existing one is replaced only with `--force`. A device or pipe named instead (`/dev/stdout`, a shell's `>(…)`) is written to as it is. An encrypted export's JSON is built in memory before it's encrypted; the buffer it ends in is wiped however the export ends, though copies left behind as it grew aren't, nor the strings each secret passes through on its way into JSON or CSV. +An encrypted export holds each entry's earlier secrets too (its history), so moving a vault with one keeps them. Plain exports (`sesh export --format json` or `--format csv`) write secrets in plaintext and are intended for local scripting; they leave earlier secrets out, to expose no more than the current ones. Encrypted exports are the right choice for backups, transferring between machines, or storing in any medium the user doesn't fully trust. The same brute-force threat model applies as with the master password, so the export password has its rules: at least 8 characters, and an easy one is warned about and asked about. An export to a file is written to a private temporary file beside it, made once the password has been asked and renamed into place once the export is whole (removed if it's interrupted by Ctrl-C or an interrupt, quit, hang-up or termination signal, though not one that can't be caught), and an existing one is replaced only with `--force`. A device or pipe named instead (`/dev/stdout`, a shell's `>(…)`) is written to as it is. An encrypted export's JSON is built in memory before it's encrypted; the buffer it ends in is wiped however the export ends, though copies left behind as it grew aren't, nor the strings each secret passes through on its way into JSON or CSV. ### Changing the master password (`sesh passwd`) diff --git a/docs/USAGE_AND_CONFIGURATION.md b/docs/USAGE_AND_CONFIGURATION.md index 68cecab4..cb7b85f4 100644 --- a/docs/USAGE_AND_CONFIGURATION.md +++ b/docs/USAGE_AND_CONFIGURATION.md @@ -435,7 +435,7 @@ sesh can't reset it for you: the master password derives the vault's key, sesh d sesh recover ``` -- **Restore from an encrypted export**, if you made one. Start a new vault (below), then import the export (entries only: the trash and earlier secrets aren't in it). It asks for the export's own password, which you chose when exporting: +- **Restore from an encrypted export**, if you made one. Start a new vault (below), then import the export (the trash isn't in it). It asks for the export's own password, which you chose when exporting: ```bash sesh import --from sesh backup.enc @@ -745,7 +745,7 @@ What to do FAIL: 2 problems, 2 warnings ``` -It names every entry it can't read, and never shows a secret. It exits 1 when the config doesn't load, there's no vault, the vault file can't be read, the vault can't be unlocked (a wrong master password, say), or the file, an entry, the trash, an earlier secret, or the recovery key has a problem; warnings exit 0. Whatever stops the vault part, the setup's steps and the tally still show. If the file is damaged but every entry still reads, it says to export them now and start a new vault from that (the trash and earlier secrets stay in the old file). If nothing can unlock the vault without asking (no `SESH_MASTER_PASSWORD`, no unlocked agent) and there's no terminal to ask at, as in a script, the setup is still checked, the last line says the vault wasn't, and it exits 0 unless the setup has a problem. If the agent locks or another command changes the master password while it checks, it says to run it again rather than blaming any entry. Run it when something seems off, after moving or restoring the vault, after a crash, or now and then. It writes nothing while it checks, then one `doctor` event to the audit log (not when the file itself is damaged). +It names every entry it can't read, and never shows a secret. It exits 1 when the config doesn't load, there's no vault, the vault file can't be read, the vault can't be unlocked (a wrong master password, say), or the file, an entry, the trash, an earlier secret, or the recovery key has a problem; warnings exit 0. Whatever stops the vault part, the setup's steps and the tally still show. If the file is damaged but every entry still reads, it says to export them now and start a new vault from that (the trash stays in the old file). If nothing can unlock the vault without asking (no `SESH_MASTER_PASSWORD`, no unlocked agent) and there's no terminal to ask at, as in a script, the setup is still checked, the last line says the vault wasn't, and it exits 0 unless the setup has a problem. If the agent locks or another command changes the master password while it checks, it says to run it again rather than blaming any entry. Run it when something seems off, after moving or restoring the vault, after a crash, or now and then. It writes nothing while it checks, then one `doctor` event to the audit log (not when the file itself is damaged). ### Backups @@ -825,7 +825,7 @@ An automatic sync with nothing to do isn't written to the [audit log](#the-audit - **What a sync tool does is handled:** its copies of a file (Dropbox's "conflicted copy", Syncthing's, iCloud's "name 2") are merged and removed; a file it loses is written again, never taken for a deletion; an older copy it puts back is ignored, so an old password can't quietly return. - **iCloud Drive can keep files in the cloud** to save space ("Optimise Mac Storage"): sesh waits for those, says how many in the summary, and writes nothing over them. Keep the sync folder downloaded: in Finder, Control-click it and choose Keep Downloaded (macOS 15 or later). - **Each computer is named** in summaries and in history by its own name; `sync.name` changes it. -- **Joining needs a new or empty vault,** or one joining again the folder it left: a vault's own entries, never synced with the folder, would bring back, as edits, any deleted elsewhere. To bring a vault's entries along: `sesh export --file F`, move the vault's files aside (the vault, any `-wal` and `-shm` files beside it, and `touchid.key`), `sesh sync join `, then `sesh import --from sesh F`; the trash and earlier secrets stay with the vault moved aside. +- **Joining needs a new or empty vault,** or one joining again the folder it left: a vault's own entries, never synced with the folder, would bring back, as edits, any deleted elsewhere. To bring a vault's entries along: `sesh export --file F`, move the vault's files aside (the vault, any `-wal` and `-shm` files beside it, and `touchid.key`), `sesh sync join `, then `sesh import --from sesh F`; the trash stays with the vault moved aside. - **A master password changed on one computer** reaches the others: they ask for it at their next `sesh sync`, at a terminal, and switch to it (see [Changing your master password](#changing-your-master-password-sesh-passwd)). - **Joining again** works for a vault that left the folder, even one whose entries were all deleted since. One whose master password changed while it was away is switched to the folder's as it joins (it asks for the folder's, then its own): its own change isn't kept, so to change every computer's password, change it while syncing. - **`sync.dir` set by hand** (`sesh config set sync.dir`, or `SESH_SYNC_DIR`) to a folder the vault hasn't synced with is held to the same rule as joining: a vault with entries of its own that synced with another folder, or never synced, doesn't sync with one holding entry files (even ones it can't read), as that would bring back what was deleted in either; a new, empty folder starts afresh. A folder inside the vault's own, or holding the vault, is refused. @@ -864,7 +864,7 @@ An export to a file, in any format, doesn't replace an existing one without `--f Encrypted exports use Argon2id and AES-256-GCM, as the vault does. The file holds its own salt and Argon2id settings, so it opens on any computer, whatever its master password, given the export password. -Every export holds everything about each entry (CSV leaves out file entries, and says how many; JSON and the encrypted export include them): its kind, service name, username, secret, times, settings, folder, tags, URL, notes, and custom fields (secret ones included). Its earlier secrets (its [history](#password-history)) aren't included. A TOTP entry's settings (algorithm, digits, period, issuer) decide which codes are right, so they come back with it on import. In JSON they're the `settings` field; in CSV, a `settings` column holding the same JSON. +Every export holds everything about each entry (CSV leaves out file entries, and says how many; JSON and the encrypted export include them): its kind, service name, username, secret, times, settings, folder, tags, URL, notes, and custom fields (secret ones included). The encrypted export also holds each entry's earlier secrets (its [history](#password-history)), with when each was replaced, and importing it brings them back; JSON and CSV leave them out. One made by sesh 0.1.1 or earlier has none, and imports as before. A TOTP entry's settings (algorithm, digits, period, issuer) decide which codes are right, so they come back with it on import. In JSON they're the `settings` field; in CSV, a `settings` column holding the same JSON. ### Changing your master password (`sesh passwd`) @@ -1320,7 +1320,7 @@ $ sesh trash restore 7 ### Password history -Each time an entry's secret changes (stored over, generated, edited, replaced by an import, or put back from its history), the one it replaces is kept, with when it was replaced: the newest 10 for each entry (`history.keep`). Importing from Bitwarden or KeePass brings in an entry's old passwords too: below sesh's own when the import replaces an entry you have, and never one the entry has already, so importing again adds nothing twice. `history.keep` limits them as it does the rest, keeping the newest, and the import says how many it kept and how many it had no room for. One whose time the export doesn't give is listed as `replaced ?`. Storing the same secret again keeps nothing, and neither does storing over a secret that's damaged. Lowering `history.keep` drops what's past it the next time a command unlocks the vault, which the audit log records; `0` keeps no new earlier secrets, and drops none of those kept: forget them with `--forget`. Only the secret is versioned, not the URL, notes or fields. +Each time an entry's secret changes (stored over, generated, edited, replaced by an import, or put back from its history), the one it replaces is kept, with when it was replaced: the newest 10 for each entry (`history.keep`). Importing from Bitwarden, KeePass, or sesh's own encrypted export brings in an entry's old passwords too: below sesh's own when the import replaces an entry you have, and never one the entry has already, so importing again adds nothing twice. `history.keep` limits them as it does the rest, keeping the newest, and the import says how many it kept and how many it had no room for. One whose time the export doesn't give is listed as `replaced ?`. Storing the same secret again keeps nothing, and neither does storing over a secret that's damaged. Lowering `history.keep` drops what's past it the next time a command unlocks the vault, which the audit log records; `0` keeps no new earlier secrets, and drops none of those kept: forget them with `--forget`. Only the secret is versioned, not the URL, notes or fields. ``` $ sesh history password/github/alice @@ -1940,7 +1940,7 @@ sesh add --help | Message | What it means | What to do | |---|---|---| | "this vault doesn't sync: …" | `sync.dir` isn't set | `sesh sync init` makes a sync folder from this vault; `sesh sync join ` joins one | -| "this vault already has entries of its own, never synced with this folder. Joining takes a new or empty vault, …" (from `sesh sync join`) | Joining would bring back, as edits, entries deleted on the other computers | Follow the steps it gives: export, move the vault's files aside, join, import (the trash and earlier secrets stay with the vault moved aside) | +| "this vault already has entries of its own, never synced with this folder. Joining takes a new or empty vault, …" (from `sesh sync join`) | Joining would bring back, as edits, entries deleted on the other computers | Follow the steps it gives: export, move the vault's files aside, join, import (the trash stays with the vault moved aside) | | "the master password was changed on another computer: …", or "⚠️ Sync: the master password was changed on another computer; nothing syncs until it's taken: sesh sync, or the Sync row in sesh ui's settings." | Another computer changed it; this vault still opens with its own, and doesn't sync | Run `sesh sync` at a terminal, or in `sesh ui` take it from the Sync row in settings and safety: each asks for this vault's password, then the new one. Forgotten your own? `sesh recover` takes the new one with the recovery key | | "⚠️ Sync: the master password was changed here and on another computer at once, and the other's is kept; nothing syncs until it's taken: sesh sync, or the Sync row in sesh ui's settings." | Two changes were made from the same master password, each computer's sync tool not yet showing the other's; the other one is kept on every computer | Run `sesh sync` at a terminal, or take it from the Sync row in `sesh ui`, and type the other change's password. If no computer of yours made it, `sesh passwd` puts yours back | | "this computer's master password change hasn't reached the sync folder yet: …" | The password was changed here with the folder out of reach | `sesh sync` sends it, as does any command that syncs on its own (`sync.auto`) once the folder is back | diff --git a/internal/compat/compat_test.go b/internal/compat/compat_test.go index c8886bb6..b5d9b6fc 100644 --- a/internal/compat/compat_test.go +++ b/internal/compat/compat_test.go @@ -257,6 +257,9 @@ func TestExportV011(t *testing.T) { if string(e.Secret) != secrets[e.Key] || e.Settings != settings[e.Key] { t.Errorf("%v: secret %q, settings %+v", e.Key, e.Secret, e.Settings) } + if len(e.Earlier) != 0 { + t.Errorf("%v: %d earlier secrets, from an export that has none", e.Key, len(e.Earlier)) + } if e.Key == github { if e.Folder != "work" || !slices.Equal(e.Tags, []string{"dev"}) { t.Errorf("GitHub filed in %q, tagged %q", e.Folder, e.Tags) diff --git a/internal/database/history.go b/internal/database/history.go index 6336a9ce..8afd7147 100644 --- a/internal/database/history.go +++ b/internal/database/history.go @@ -404,6 +404,31 @@ func (s *Store) History(k vault.Key) (_ []Version, err error) { return out, rows.Err() } +// Earlier is the entry k's earlier secrets, the newest first, each with +// when it was replaced, for reading, which the audit log records for each. +// One forgotten meanwhile is left out. +func (s *Store) Earlier(k vault.Key, reading string) ([]vault.Earlier, error) { + versions, err := s.History(k) + if err != nil { + return nil, err + } + out := make([]vault.Earlier, 0, len(versions)) + for i, v := range versions { + secret, err := s.readVersion(k, v, i+2, reading) + if errors.Is(err, ErrVersionGone) { + continue + } + if err != nil { + for _, e := range out { + secure.SecureZeroBytes(e.Secret) + } + return nil, err + } + out = append(out, vault.Earlier{ReplacedAt: v.ReplacedAt, Secret: secret}) + } + return out, nil +} + // version is the row of k's version n (2 and up). func (s *Store) version(k vault.Key, n int) (Version, error) { versions, err := s.History(k) diff --git a/internal/importer/seshexport/entries.go b/internal/importer/seshexport/entries.go index 96f787cd..8740e053 100644 --- a/internal/importer/seshexport/entries.go +++ b/internal/importer/seshexport/entries.go @@ -7,11 +7,12 @@ import ( "github.com/bashhack/sesh/internal/importer" "github.com/bashhack/sesh/internal/password" + "github.com/bashhack/sesh/internal/secure" ) // Entries are the export's entries as sesh import brings them in: each -// with its secret, details, folder, tags, settings and times. One sesh -// can't hold is skipped, saying why. +// with its secret, details, earlier secrets, folder, tags, settings and +// times. One sesh can't hold is skipped, saying why. func Entries(list []password.ExportEntry) []*importer.Entry { out := make([]*importer.Entry, 0, len(list)) for i := range list { @@ -37,7 +38,14 @@ func Entries(list []password.ExportEntry) []*importer.Entry { e.Skip = err.Error() break } + earlier, err := ee.Earlier() + if err != nil { + secure.SecureZeroBytes(secret) + e.Skip = err.Error() + break + } e.Secret = secret + e.Earlier = earlier e.Details = ee.Details() } out = append(out, importer.Checked(e)) diff --git a/internal/importer/seshexport/entries_test.go b/internal/importer/seshexport/entries_test.go index 1e28d10a..9f2260ae 100644 --- a/internal/importer/seshexport/entries_test.go +++ b/internal/importer/seshexport/entries_test.go @@ -37,3 +37,24 @@ func TestEntries(t *testing.T) { } } } + +// An entry's earlier secrets come too, the newest first with when each was replaced, a file's decoded as its secret +// is; one that won't decode skips the entry. +func TestEntriesEarlier(t *testing.T) { + at := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) + got := Entries([]password.ExportEntry{ + {Service: "github", Type: vault.KindPassword, Secret: "now", History: []password.ExportEarlier{{ReplacedAt: at, Secret: "before"}, {Secret: "first"}}}, + {Service: "kubeconfig", Type: vault.KindFile, Secret: "bm93", Encoding: "base64", History: []password.ExportEarlier{{ReplacedAt: at, Secret: "b2xk"}}}, + {Service: "bad", Type: vault.KindFile, Secret: "bm93", Encoding: "base64", History: []password.ExportEarlier{{Secret: "not base64!"}}}, + }) + if e := got[0]; e.Skip != "" || len(e.Earlier) != 2 || string(e.Earlier[0].Secret) != "before" || !e.Earlier[0].ReplacedAt.Equal(at) || + string(e.Earlier[1].Secret) != "first" || !e.Earlier[1].ReplacedAt.IsZero() { + t.Errorf("github: %+v", e) + } + if e := got[1]; e.Skip != "" || len(e.Earlier) != 1 || string(e.Earlier[0].Secret) != "old" || string(e.Secret) != "now" { + t.Errorf("kubeconfig: %+v", e) + } + if e := got[2]; !strings.Contains(e.Skip, "an earlier secret") || len(e.Secret) != 0 { + t.Errorf("bad: skipped %q, secret %q", e.Skip, e.Secret) + } +} diff --git a/internal/password/export.go b/internal/password/export.go index f4ff83da..b70e94d4 100644 --- a/internal/password/export.go +++ b/internal/password/export.go @@ -29,13 +29,22 @@ type ExportEntry struct { Secret string `json:"secret"` // Encoding is "base64" for a file entry's content, which can be any // bytes; "" means the secret is as it is. - Encoding string `json:"encoding,omitempty"` - Folder string `json:"folder,omitempty"` - URL string `json:"url,omitempty"` - Notes string `json:"notes,omitempty"` - Tags []string `json:"tags,omitempty"` - Fields []ExportField `json:"fields,omitempty"` - Settings vault.Settings `json:"settings,omitzero"` + Encoding string `json:"encoding,omitempty"` + Folder string `json:"folder,omitempty"` + URL string `json:"url,omitempty"` + Notes string `json:"notes,omitempty"` + Tags []string `json:"tags,omitempty"` + Fields []ExportField `json:"fields,omitempty"` + // History is the entry's earlier secrets, the newest first, encoded as + // Secret is: only the encrypted export holds them. + History []ExportEarlier `json:"history,omitempty"` + Settings vault.Settings `json:"settings,omitzero"` +} + +// ExportEarlier is an earlier secret as an export holds it. +type ExportEarlier struct { + ReplacedAt time.Time `json:"replaced_at,omitzero"` + Secret string `json:"secret"` } // Key is the entry's key. @@ -57,6 +66,23 @@ func (e *ExportEntry) SecretBytes() ([]byte, error) { return nil, fmt.Errorf("its secret's encoding, %q, isn't one sesh writes", e.Encoding) } +// Earlier is the entry's earlier secrets, the newest first, decoded as its +// Encoding says, for the caller to zero. +func (e *ExportEntry) Earlier() ([]vault.Earlier, error) { + out := make([]vault.Earlier, 0, len(e.History)) + for _, h := range e.History { + secret, err := (&ExportEntry{Secret: h.Secret, Encoding: e.Encoding}).SecretBytes() + if err != nil { + for _, o := range out { + secure.SecureZeroBytes(o.Secret) + } + return nil, fmt.Errorf("an earlier secret: %w", err) + } + out = append(out, vault.Earlier{ReplacedAt: h.ReplacedAt, Secret: secret}) + } + return out, nil +} + // Details is the entry's details, which the caller zeroes. func (e *ExportEntry) Details() vault.Details { return importDetails(e) } @@ -116,6 +142,9 @@ type ExportOptions struct { Folder string Tags []string FolderSet bool + // History puts each entry's earlier secrets in a JSON export too, from + // a store that keeps them: the encrypted export sets it. + History bool // LeftOut is set by Export: the file entries a CSV export left out, // since CSV holds text. LeftOut int @@ -124,6 +153,11 @@ type ExportOptions struct { KDF kdf.Params } +// earlierReader is a store that keeps entries' earlier secrets. +type earlierReader interface { + Earlier(k vault.Key, reading string) ([]vault.Earlier, error) +} + // Export decrypts entries and streams them to w, one at a time, so only // one plaintext record is live in memory at a time. Returns the number of // entries successfully written; a partial count + error is possible if a @@ -136,7 +170,7 @@ func (m *Manager) Export(w io.Writer, opts *ExportOptions) (int, error) { switch opts.Format { case "", FormatJSON: - return m.exportJSON(w, entries) + return m.exportJSON(w, entries, opts.History) case FormatCSV: kept := slices.DeleteFunc(entries, func(e vault.Entry) bool { return e.Kind == vault.KindFile }) opts.LeftOut = len(entries) - len(kept) @@ -149,8 +183,13 @@ func (m *Manager) Export(w io.Writer, opts *ExportOptions) (int, error) { // exportJSON writes entries as a JSON array, decrypting and marshaling one // record at a time. The output matches what json.Encoder.Encode on a full // slice would produce, but without holding every plaintext secret in -// memory simultaneously. -func (m *Manager) exportJSON(w io.Writer, entries []vault.Entry) (int, error) { +// memory simultaneously. With history, each entry's earlier secrets go in +// too. +func (m *Manager) exportJSON(w io.Writer, entries []vault.Entry, history bool) (int, error) { + earlier, _ := m.store.(earlierReader) + if !history { + earlier = nil + } if _, err := io.WriteString(w, "["); err != nil { return 0, err } @@ -194,6 +233,20 @@ func (m *Manager) exportJSON(w io.Writer, entries []vault.Entry) (int, error) { if err := m.exportDetails(e, &ee); err != nil { return count, err } + if earlier != nil { + versions, err := earlier.Earlier(e.Key, "to export") + if err != nil { + return count, fmt.Errorf("read %s's earlier secrets: %w", e.Key, err) + } + for _, v := range versions { + s := string(v.Secret) + if encoding == "base64" { + s = base64.StdEncoding.EncodeToString(v.Secret) + } + ee.History = append(ee.History, ExportEarlier{ReplacedAt: v.ReplacedAt, Secret: s}) + secure.SecureZeroBytes(v.Secret) + } + } b, err := json.MarshalIndent(ee, " ", " ") //nolint:gosec // plaintext export writes secrets by design; --format encrypted is the protected alternative if err != nil { diff --git a/internal/password/export_encrypted.go b/internal/password/export_encrypted.go index 56660d30..822490f5 100644 --- a/internal/password/export_encrypted.go +++ b/internal/password/export_encrypted.go @@ -48,6 +48,7 @@ func (m *Manager) ExportEncrypted(w io.Writer, opts *ExportOptions, password []b Folder: opts.Folder, FolderSet: opts.FolderSet, Tags: opts.Tags, + History: true, }) if err != nil { return 0, err diff --git a/sesh/cmd/sesh/import_cmd_test.go b/sesh/cmd/sesh/import_cmd_test.go index dac1d607..fd0b4907 100644 --- a/sesh/cmd/sesh/import_cmd_test.go +++ b/sesh/cmd/sesh/import_cmd_test.go @@ -744,6 +744,65 @@ func TestImport_Sesh(t *testing.T) { } } +// An encrypted export carries each entry's earlier secrets, with when each was replaced, and importing it brings +// them back; a plain export leaves them out. +func TestImport_SeshKeepsHistory(t *testing.T) { + src := openDoctorVault(t, importVault(t)) + src.SetHistoryKeep(10) + gh := vault.Key{Kind: vault.KindPassword, Service: "github", Username: "alice"} + for _, secret := range []string{"pass-1", "pass-2", "pass-3"} { + if err := src.Put(gh, []byte(secret)); err != nil { + t.Fatal(err) + } + } + // A file's earlier contents are any bytes, as its content is. + kube := vault.Key{Kind: vault.KindFile, Service: "kubeconfig"} + for _, content := range [][]byte{{0xff, 0x00, 0x01}, []byte("now")} { + if err := src.Put(kube, content); err != nil { + t.Fatal(err) + } + } + before, err := src.History(gh) + if err != nil || len(before) != 2 { + t.Fatalf("history %v, %v", before, err) + } + m := password.NewManager(src) + var plain bytes.Buffer + if _, err := m.Export(&plain, &password.ExportOptions{Format: password.FormatJSON}); err != nil || strings.Contains(plain.String(), "pass-1") { + t.Errorf("a plain export holds an earlier secret, or failed (%v):\n%s", err, plain.String()) + } + var buf bytes.Buffer + if _, err := m.ExportEncrypted(&buf, &password.ExportOptions{KDF: kdf.Minimum()}, []byte("export-pass-1234")); err != nil { + t.Fatal(err) + } + file := filepath.Join(t.TempDir(), "backup.enc") + if err := os.WriteFile(file, buf.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + + dst := importVault(t) // a new vault, to import into + old := readSecret + t.Cleanup(func() { readSecret = old }) + readSecret = func() ([]byte, error) { return []byte("export-pass-1234"), nil } + if out, stderr, err := runImportOut(t, "y\n", true, "--from", "sesh", "--on-conflict", "skip", file); err != nil || !strings.Contains(out, "✅ Imported 2 entries from a sesh export.") { + t.Fatalf("%q\n%s\n%v", out, stderr, err) + } + store := openDoctorVault(t, dst) + if got, err := store.ReadVersion(kube, 2, "test"); err != nil || !bytes.Equal(got, []byte{0xff, 0x00, 0x01}) { + t.Errorf("kubeconfig's earlier content: %q, %v", got, err) + } + after, err := store.History(gh) + if err != nil || len(after) != 2 { + t.Fatalf("imported history %v, %v", after, err) + } + for i, want := range []string{"pass-2", "pass-1"} { + got, err := store.ReadVersion(gh, i+2, "test") + if err != nil || string(got) != want || !after[i].ReplacedAt.Equal(before[i].ReplacedAt) { + t.Errorf("version %d: %q (replaced %v), %v; want %q (replaced %v)", i+2, got, after[i].ReplacedAt, err, want, before[i].ReplacedAt) + } + } +} + // Replacing an AWS entry from sesh's export takes the export's device with its key; a new entry keeps its code // settings, tags, notes, fields and times. A file whose settings sesh refuses isn't asked a password for, and a plain // export is sent to --action import, with or without --from. From 134f0c3c644b3bedbd71f86a679e2aaa773211a4 Mon Sep 17 00:00:00 2001 From: Marc Alvarez Date: Tue, 20 Oct 2026 20:41:48 -0600 Subject: [PATCH 2/4] An earlier secret that can't be read is left out of the export, which says whose; earlier files keep their names; imports count earlier secrets --- docs/SECURITY_MODEL.md | 2 +- docs/USAGE_AND_CONFIGURATION.md | 4 +- internal/database/history.go | 83 ++++++++++++++++++----- internal/password/export.go | 42 +++++++----- internal/password/export_encrypted.go | 6 +- internal/vault/details.go | 6 +- sesh/cmd/sesh/export_cmd.go | 19 ++++++ sesh/cmd/sesh/import_cmd.go | 10 +-- sesh/cmd/sesh/import_cmd_test.go | 95 ++++++++++++++++++++++++--- sesh/cmd/sesh/ui_export.go | 2 +- 10 files changed, 216 insertions(+), 53 deletions(-) diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md index 24bf445c..f6240a80 100644 --- a/docs/SECURITY_MODEL.md +++ b/docs/SECURITY_MODEL.md @@ -50,7 +50,7 @@ The vault provides application-level encryption on top of file-system storage: - **Audit logging**: The `audit_log` table records access, modification, and deletion events with timestamps (never the secrets). `sesh audit` shows it. Events older than `audit.retention_days` (default 90; `0` keeps everything) are removed when the vault is opened, and `sesh audit prune` removes them on demand. It's a record for the user, not tamper-proof: anyone who can write the vault file can change it - **The trash**: a deleted entry stays in the vault file, encrypted as before, for `trash.keep_days` (default 30), so it can be restored; then it's deleted for good, as `sesh trash remove`, `sesh trash empty` and `--purge` do at once. Until then, someone with the vault file and the master password can read it, as they could before it was deleted. `0` sends no new deletion to the trash (each is for good), but leaves what's already there until it's removed or emptied - **Password health** (`sesh health`): reads every password and API key once, in memory, recorded as one audit event. It holds no list of them: a secret used twice is found by its SHA-256, and each secret is wiped after it's checked, though rating a password's strength makes copies of it that can't be wiped (as rating a new password does). Nothing is sent over the network. -- **Password history**: an entry's earlier secrets (the newest `history.keep`, default 10) stay in the vault file, encrypted like its secret and bound to the entry by AES-GCM associated data of their own, so an old secret can't be read as the current one. Someone with the vault file and the master password can read them, as they could when each was current. `sesh history --forget N` deletes one for good. `history.keep = 0` keeps no new ones, but leaves those already kept until they're forgotten +- **Password history**: an entry's earlier secrets (the newest `history.keep`, default 10) stay in the vault file, encrypted like its secret and bound to the entry by AES-GCM associated data of their own, so an old secret can't be read as the current one. Someone with the vault file and the master password can read them, as they could when each was current. `sesh history --forget N` deletes one for good from the vault; an encrypted export made before then still holds it (importing that export brings it back), and so do backups made before then. `history.keep = 0` keeps no new ones, but leaves those already kept until they're forgotten - **WAL mode**: Write-ahead logging for safe concurrent reads ##### The master password diff --git a/docs/USAGE_AND_CONFIGURATION.md b/docs/USAGE_AND_CONFIGURATION.md index cb7b85f4..6c3cadf9 100644 --- a/docs/USAGE_AND_CONFIGURATION.md +++ b/docs/USAGE_AND_CONFIGURATION.md @@ -864,7 +864,7 @@ An export to a file, in any format, doesn't replace an existing one without `--f Encrypted exports use Argon2id and AES-256-GCM, as the vault does. The file holds its own salt and Argon2id settings, so it opens on any computer, whatever its master password, given the export password. -Every export holds everything about each entry (CSV leaves out file entries, and says how many; JSON and the encrypted export include them): its kind, service name, username, secret, times, settings, folder, tags, URL, notes, and custom fields (secret ones included). The encrypted export also holds each entry's earlier secrets (its [history](#password-history)), with when each was replaced, and importing it brings them back; JSON and CSV leave them out. One made by sesh 0.1.1 or earlier has none, and imports as before. A TOTP entry's settings (algorithm, digits, period, issuer) decide which codes are right, so they come back with it on import. In JSON they're the `settings` field; in CSV, a `settings` column holding the same JSON. +Every export holds everything about each entry (CSV leaves out file entries, and says how many; JSON and the encrypted export include them): its kind, service name, username, secret, times, settings, folder, tags, URL, notes, and custom fields (secret ones included). The encrypted export also holds each entry's earlier secrets (its [history](#password-history)), with when each was replaced and, for a file, its name then, and importing it brings them back; JSON and CSV leave them out. An earlier secret that can't be read (damage) is left out, and the export names whose; the rest is exported. One made by sesh 0.1.1 or earlier has none, and imports as before. A TOTP entry's settings (algorithm, digits, period, issuer) decide which codes are right, so they come back with it on import. In JSON they're the `settings` field; in CSV, a `settings` column holding the same JSON. ### Changing your master password (`sesh passwd`) @@ -1282,7 +1282,7 @@ For a very long list, `xargs` runs sesh more than once, and each run is all or n - **folders and tags…** (`f`): the sidebar's rows, in any window: Enter shows a row's entries, and `r` renames the folder (with those inside it) or tag; - **the trash…** (`t`): each entry with its kind, when it was deleted, and its trash id; Enter puts one back; Space marks entries, and `x` then deletes those marked for good; `a` puts it back under another name, typed as its ID (as `sesh trash restore --as` takes it), for one whose name is taken now; `x` deletes it for good, and `e` empties the trash of what it lists (an entry trashed meanwhile stays), each after a yes; - **backups…** (`b`): the vault's [backups](#backups) newest first, with when each was made and its size; another vault's with the same file name is marked. Enter restores the vault from the selected one, asking first with what `sesh restore` says: the vault as it is is backed up first, and the app then locks, since the backup opens with the master password it had then. A vault that's missing or damaged is restored from the command line. `n` backs up now (pruned to `backup.keep`), `f` to a file or folder you type, as a full path or one starting with `~/` (asking before replacing a file, and refusing the vault's own files), `x` deletes the selected backup, and `a` deletes this vault's backups and makes a fresh one, for when an old master password or recovery key may have leaked; each delete asks first. - - **import and export…** (`i`): **export, encrypted** (`e`) asks for a file (a full path, or one starting with `~/`), which entries (every one, or those of a kind, in a folder, with tags, as `sesh export` narrows them; one that matches nothing is said before the file is touched), and a password for the export, twice, and writes [sesh's encrypted export](#encrypted-exports) there, as `sesh export --file` does: the password has the master password's rules (an easy one is asked about once), a file there already is replaced only after Enter again, and the file is written whole, readable only by you. Each entry read is recorded in the audit log. A device or pipe isn't written to, since the app has the terminal, nor the vault's own files. **Import from** sesh's own encrypted export (`s`), Bitwarden (`b`), KeePass (`p`), Chrome (`c`), Firefox (`f`), Apple Passwords (`a`) or Google Authenticator (`g`, its transfer codes' screenshots, separated by commas) or LastPass Authenticator (`l`, its JSON export) does what [`sesh import`](#importing-from-other-apps-sesh-import) does: a file (a full path, or one starting with `~/`) is read, and the password of an export protected by one is asked for when it is; then the app shows sesh import's summary. When some entries are in the vault already, `s` leaves them as they are and `o` replaces them, with their details merged; `y` imports. Esc lets the import go, except once it's being written, which carries on to the end: Ctrl-C then quits once it's written, saying what it did. + - **import and export…** (`i`): **export, encrypted** (`e`) asks for a file (a full path, or one starting with `~/`), which entries (every one, or those of a kind, in a folder, with tags, as `sesh export` narrows them; one that matches nothing is said before the file is touched), and a password for the export, twice, and writes [sesh's encrypted export](#encrypted-exports) there, as `sesh export --file` does: the password has the master password's rules (an easy one is asked about once), a file there already is replaced only after Enter again, and the file is written whole, readable only by you. Each entry read is recorded in the audit log, and each earlier secret read with it. A device or pipe isn't written to, since the app has the terminal, nor the vault's own files. **Import from** sesh's own encrypted export (`s`), Bitwarden (`b`), KeePass (`p`), Chrome (`c`), Firefox (`f`), Apple Passwords (`a`) or Google Authenticator (`g`, its transfer codes' screenshots, separated by commas) or LastPass Authenticator (`l`, its JSON export) does what [`sesh import`](#importing-from-other-apps-sesh-import) does: a file (a full path, or one starting with `~/`) is read, and the password of an export protected by one is asked for when it is; then the app shows sesh import's summary. When some entries are in the vault already, `s` leaves them as they are and `o` replaces them, with their details merged; `y` imports. Esc lets the import go, except once it's being written, which carries on to the end: Ctrl-C then quits once it's written, saying what it did. - **set up AWS MFA…** (`m`): what [`sesh aws setup`](#adding-totp-entries-and-setting-up-aws) does, step by step. First the profile (the AWS CLI's are offered), checked as setup checks it: the AWS CLI is there, how the profile signs in, and, for one with access keys, who it signs in as, AWS asked; one sesh has a key for already is replaced only after a yes. Then the key, with what to do in AWS first above it: pasted (AWS's page shows it under Show secret key), or its `otpauth://` link, read from a QR code on screen (macOS) or in an image file, or taken from a TOTP entry sesh has for a device AWS has already; and the entry's folder and tags. The key is saved before AWS is told of it. Then the two codes AWS asks for (`n` makes new ones, should AWS say they're wrong); Enter once AWS says the device was added. A profile with access keys then has its device chosen from those AWS lists (`r` looks again, `m` types its ARN), which `sesh aws` asks AWS for a session with. Leaving at any step once the key is saved says what's kept, and what to do; - **sign in to AWS…** (`w`): what `sesh aws --profile ` does, for a profile you type (the AWS CLI's are offered, and it starts with `AWS_PROFILE`'s, or `AWS_DEFAULT_PROFILE`'s, as `sesh aws` does; empty is the default profile), as **sign in to AWS and open a shell** does from the profile's MFA entry, below Ctrl-K. A profile that signs in with `aws login` or SSO needn't have its MFA key in sesh (without it, the sign-in says so and copies nothing); one with an IAM user's access keys does, and is told to set it up; - **health report…** (`r`): what [`sesh health`](#password-health-sesh-health) finds, one row per problem: weak, reused, old, or unreadable. Enter on a row goes to its entry and opens its form; for a password, set to generate a new one. An unreadable one points to `sesh doctor`. Reading the secrets is one audit event; diff --git a/internal/database/history.go b/internal/database/history.go index 8afd7147..b75777b5 100644 --- a/internal/database/history.go +++ b/internal/database/history.go @@ -249,7 +249,7 @@ func (s *Store) sealEarlier(k vault.Key, secret, data, salt []byte, old *oldSecr if err != nil { return nil, fmt.Errorf("encrypt an earlier secret of %s: %w", k, err) } - h.sealed = append(h.sealed, oldSecret{data: data, salt: salt, at: e.ReplacedAt}) + h.sealed = append(h.sealed, oldSecret{data: data, salt: salt, at: e.ReplacedAt, set: e.SetAt, fileName: e.FileName}) } return h, nil } @@ -269,11 +269,16 @@ func (s *Store) keepImported(tx *sql.Tx, k vault.Key, h *importedHistory) error if !slices.EqualFunc(rows, h.had, func(r resealedHistory, id int64) bool { return r.id == id }) { return ErrEntryChanged } - // When an imported one was set isn't known: no later than replaced. + // When an imported one was set, when the import doesn't say: no later + // than replaced. for i := range slices.Backward(h.sealed) { o := &h.sealed[i] - if _, err := tx.Exec(`INSERT INTO history (kind, service, username, encrypted_data, salt, replaced_at, set_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - string(k.Kind), k.Service, k.Username, o.data, o.salt, o.at.UTC(), o.at.UTC()); err != nil { + set := o.set + if set.IsZero() { + set = o.at + } + if _, err := tx.Exec(`INSERT INTO history (kind, service, username, encrypted_data, salt, replaced_at, set_at, file_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + string(k.Kind), k.Service, k.Username, o.data, o.salt, o.at.UTC(), set.UTC(), o.fileName); err != nil { return fmt.Errorf("keep %s's imported earlier secrets: %w", k, err) } } @@ -405,28 +410,72 @@ func (s *Store) History(k vault.Key) (_ []Version, err error) { } // Earlier is the entry k's earlier secrets, the newest first, each with -// when it was replaced, for reading, which the audit log records for each. -// One forgotten meanwhile is left out. -func (s *Store) Earlier(k vault.Key, reading string) ([]vault.Earlier, error) { - versions, err := s.History(k) +// when it was replaced, when its use began and a file's name, for reading, +// which the audit log records for each. One that can't be read (damage) is +// left out, and partial says so; so is the whole history when it can't be +// listed. An error is only one that should stop what's reading it: the +// vault's key changed, or the key can't be had. +func (s *Store) Earlier(k vault.Key, reading string) (_ []vault.Earlier, partial bool, err error) { + read, err := s.earlierRows(k) if err != nil { - return nil, err + return nil, true, nil } - out := make([]vault.Earlier, 0, len(versions)) - for i, v := range versions { - secret, err := s.readVersion(k, v, i+2, reading) - if errors.Is(err, ErrVersionGone) { - continue - } + out := make([]vault.Earlier, 0, len(read)) + defer func() { if err != nil { for _, e := range out { secure.SecureZeroBytes(e.Secret) } + } + }() + for i, r := range read { + secret, err := s.oracle.DecryptEntry(r.data, r.salt, historyAAD(k)) + if errors.Is(err, ErrDecrypt) { + if kerr := s.keyUnchanged(s.db); errors.Is(kerr, ErrVaultKeyChanged) { + return nil, false, kerr + } + partial = true + continue + } + if err != nil { + return nil, false, fmt.Errorf("decrypt %s's version %d: %w", k, i+2, err) + } + s.audit("access", k.String(), fmt.Sprintf("History: version %d, %s", i+2, reading)) + replaced, _ := r.replaced.(time.Time) + set, _ := r.set.(time.Time) + out = append(out, vault.Earlier{ReplacedAt: replaced, SetAt: set, FileName: r.fileName, Secret: secret}) + } + return out, partial, nil +} + +// earlierRow is a history row as Earlier reads it. +type earlierRow struct { + replaced, set any + fileName string + data, salt []byte +} + +// earlierRows is k's history rows, the newest first. +func (s *Store) earlierRows(k vault.Key) (_ []earlierRow, err error) { + rows, err := s.db.Query(`SELECT replaced_at, set_at, file_name, encrypted_data, salt FROM history WHERE kind = ? AND service = ? AND username = ? AND trash_id IS NULL ORDER BY id DESC`, + string(k.Kind), k.Service, k.Username) + if err != nil { + return nil, err + } + defer func() { + if cerr := rows.Close(); err == nil && cerr != nil { + err = cerr + } + }() + var out []earlierRow + for rows.Next() { + var r earlierRow + if err := rows.Scan(&r.replaced, &r.set, &r.fileName, &r.data, &r.salt); err != nil { return nil, err } - out = append(out, vault.Earlier{ReplacedAt: v.ReplacedAt, Secret: secret}) + out = append(out, r) } - return out, nil + return out, rows.Err() } // version is the row of k's version n (2 and up). diff --git a/internal/password/export.go b/internal/password/export.go index b70e94d4..c1ec4dd3 100644 --- a/internal/password/export.go +++ b/internal/password/export.go @@ -41,9 +41,12 @@ type ExportEntry struct { Settings vault.Settings `json:"settings,omitzero"` } -// ExportEarlier is an earlier secret as an export holds it. +// ExportEarlier is an earlier secret as an export holds it: when it was +// replaced, when its use began, and a file's name with it. type ExportEarlier struct { ReplacedAt time.Time `json:"replaced_at,omitzero"` + SetAt time.Time `json:"set_at,omitzero"` + FileName string `json:"file_name,omitempty"` Secret string `json:"secret"` } @@ -78,7 +81,7 @@ func (e *ExportEntry) Earlier() ([]vault.Earlier, error) { } return nil, fmt.Errorf("an earlier secret: %w", err) } - out = append(out, vault.Earlier{ReplacedAt: h.ReplacedAt, Secret: secret}) + out = append(out, vault.Earlier{ReplacedAt: h.ReplacedAt, SetAt: h.SetAt, FileName: h.FileName, Secret: secret}) } return out, nil } @@ -139,9 +142,12 @@ type ExportOptions struct { EntryType EntryType // empty means all types // Folder, when FolderSet, keeps the entries in it or a folder under // it ("" keeps those in none); Tags keeps those with all of them. - Folder string - Tags []string - FolderSet bool + Folder string + Tags []string + // HistoryLeftOut is set by Export, with History: the entries some of + // whose earlier secrets couldn't be read (damage), left out. + HistoryLeftOut []string + FolderSet bool // History puts each entry's earlier secrets in a JSON export too, from // a store that keeps them: the encrypted export sets it. History bool @@ -153,9 +159,10 @@ type ExportOptions struct { KDF kdf.Params } -// earlierReader is a store that keeps entries' earlier secrets. -type earlierReader interface { - Earlier(k vault.Key, reading string) ([]vault.Earlier, error) +// EarlierReader is a store that keeps entries' earlier secrets: those +// that can be read, and whether any couldn't. +type EarlierReader interface { + Earlier(k vault.Key, reading string) (earlier []vault.Earlier, partial bool, err error) } // Export decrypts entries and streams them to w, one at a time, so only @@ -170,7 +177,7 @@ func (m *Manager) Export(w io.Writer, opts *ExportOptions) (int, error) { switch opts.Format { case "", FormatJSON: - return m.exportJSON(w, entries, opts.History) + return m.exportJSON(w, entries, opts) case FormatCSV: kept := slices.DeleteFunc(entries, func(e vault.Entry) bool { return e.Kind == vault.KindFile }) opts.LeftOut = len(entries) - len(kept) @@ -183,11 +190,11 @@ func (m *Manager) Export(w io.Writer, opts *ExportOptions) (int, error) { // exportJSON writes entries as a JSON array, decrypting and marshaling one // record at a time. The output matches what json.Encoder.Encode on a full // slice would produce, but without holding every plaintext secret in -// memory simultaneously. With history, each entry's earlier secrets go in -// too. -func (m *Manager) exportJSON(w io.Writer, entries []vault.Entry, history bool) (int, error) { - earlier, _ := m.store.(earlierReader) - if !history { +// memory simultaneously. With opts.History, each entry's earlier secrets +// go in too. +func (m *Manager) exportJSON(w io.Writer, entries []vault.Entry, opts *ExportOptions) (int, error) { + earlier, _ := m.store.(EarlierReader) + if !opts.History { earlier = nil } if _, err := io.WriteString(w, "["); err != nil { @@ -234,16 +241,19 @@ func (m *Manager) exportJSON(w io.Writer, entries []vault.Entry, history bool) ( return count, err } if earlier != nil { - versions, err := earlier.Earlier(e.Key, "to export") + versions, partial, err := earlier.Earlier(e.Key, "to export") if err != nil { return count, fmt.Errorf("read %s's earlier secrets: %w", e.Key, err) } + if partial { + opts.HistoryLeftOut = append(opts.HistoryLeftOut, e.Key.String()) + } for _, v := range versions { s := string(v.Secret) if encoding == "base64" { s = base64.StdEncoding.EncodeToString(v.Secret) } - ee.History = append(ee.History, ExportEarlier{ReplacedAt: v.ReplacedAt, Secret: s}) + ee.History = append(ee.History, ExportEarlier{ReplacedAt: v.ReplacedAt, SetAt: v.SetAt, FileName: v.FileName, Secret: s}) secure.SecureZeroBytes(v.Secret) } } diff --git a/internal/password/export_encrypted.go b/internal/password/export_encrypted.go index 822490f5..45c98f8c 100644 --- a/internal/password/export_encrypted.go +++ b/internal/password/export_encrypted.go @@ -42,17 +42,19 @@ func (m *Manager) ExportEncrypted(w io.Writer, opts *ExportOptions, password []b var buf wipingBuffer // The plain JSON is wiped however this ends. defer func() { secure.SecureZeroBytes(buf.b) }() - count, err := m.Export(&buf, &ExportOptions{ + inner := &ExportOptions{ Format: FormatJSON, EntryType: opts.EntryType, Folder: opts.Folder, FolderSet: opts.FolderSet, Tags: opts.Tags, History: true, - }) + } + count, err := m.Export(&buf, inner) if err != nil { return 0, err } + opts.HistoryLeftOut = inner.HistoryLeftOut salt := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, salt); err != nil { diff --git a/internal/vault/details.go b/internal/vault/details.go index bd50f00f..c88c3093 100644 --- a/internal/vault/details.go +++ b/internal/vault/details.go @@ -556,5 +556,9 @@ func changeWord(part string, had, has bool) string { // and when it stopped being the secret (zero when the source doesn't say). type Earlier struct { ReplacedAt time.Time - Secret []byte + // SetAt is when its use began, zero when not known; FileName, a file + // entry's file's name with it. + SetAt time.Time + FileName string + Secret []byte } diff --git a/sesh/cmd/sesh/export_cmd.go b/sesh/cmd/sesh/export_cmd.go index e9dca622..6573b857 100644 --- a/sesh/cmd/sesh/export_cmd.go +++ b/sesh/cmd/sesh/export_cmd.go @@ -8,6 +8,7 @@ import ( "os" "os/signal" "path/filepath" + "slices" "strings" "syscall" @@ -165,10 +166,28 @@ func runExport(app *App, args []string) error { if *format != "encrypted" { msg += ". It holds every secret unencrypted: delete it when you're done" } + msg += historyLeftOut(opts.HistoryLeftOut) _, err = fmt.Fprintln(app.Stderr, msg) return err } +// The vault's store gives an encrypted export each entry's earlier +// secrets: without it, they'd be left out without a word. +var _ password.EarlierReader = (*database.Store)(nil) + +// historyLeftOut says which entries' earlier secrets an export left out, +// since they couldn't be read: "" for none. +func historyLeftOut(keys []string) string { + if len(keys) == 0 { + return "" + } + named := keys + if len(named) > 3 { + named = append(slices.Clone(named[:3]), fmt.Sprintf("and %d more", len(keys)-3)) + } + return fmt.Sprintf(". Some earlier secrets of %s couldn't be read (damaged), so aren't in it: %s; sesh doctor checks them", entryCount(len(keys)), strings.Join(named, ", ")) +} + var errExportNeedsTerminal = errors.New("an encrypted export asks for its password at a terminal; --format json or csv writes one without") // checkExportMatches refuses an export of a kind, folder or tag (f) that diff --git a/sesh/cmd/sesh/import_cmd.go b/sesh/cmd/sesh/import_cmd.go index 705c4655..3dd07cbd 100644 --- a/sesh/cmd/sesh/import_cmd.go +++ b/sesh/cmd/sesh/import_cmd.go @@ -334,13 +334,13 @@ func (p *importPlan) apply(store *database.Store) (string, error) { var b strings.Builder fmt.Fprintf(&b, "✅ Imported %s from %s.\n", what(done), p.f.source) if history.Kept > 0 { - fmt.Fprintf(&b, "Kept %s in their history (sesh history lists them).\n", nouns(history.Kept, "old password", "old passwords")) + fmt.Fprintf(&b, "Kept %s in their history (sesh history lists them).\n", nouns(history.Kept, "earlier secret", "earlier secrets")) } if history.NoRoom > 0 { - fmt.Fprintf(&b, "Not kept: %s past history.keep (%d), the oldest of those imported.\n", nouns(history.NoRoom, "old password", "old passwords"), store.HistoryKeep()) + fmt.Fprintf(&b, "Not kept: %s past history.keep (%d), the oldest of those imported.\n", nouns(history.NoRoom, "earlier secret", "earlier secrets"), store.HistoryKeep()) } if history.TooLarge > 0 { - fmt.Fprintf(&b, "Not kept: %s larger than sesh holds (%d bytes).\n", nouns(history.TooLarge, "old password", "old passwords"), database.MaxSecretSize) + fmt.Fprintf(&b, "Not kept: %s larger than sesh holds (%d bytes).\n", nouns(history.TooLarge, "earlier secret", "earlier secrets"), database.MaxSecretSize) } if i := slices.IndexFunc(write, func(e *importer.Entry) bool { return e.Key.Kind == vault.KindTOTP }); i >= 0 && (p.f.source == "Google Authenticator" || p.f.source == "LastPass Authenticator") { k := write[i].Key @@ -408,7 +408,7 @@ func fitHistory(e *importer.Entry, keep int) { if e.Lost == nil { e.Lost = map[string]int{} } - e.Lost["old passwords (history.keep is 0)"] += len(e.Earlier) + e.Lost["earlier secrets (history.keep is 0)"] += len(e.Earlier) for _, o := range e.Earlier { secure.SecureZeroBytes(o.Secret) } @@ -579,7 +579,7 @@ func importSummary(f *found, fresh, clashes, skipped []*importer.Entry, onConfli old += len(e.Earlier) } if old > 0 { - fmt.Fprintf(&b, "\nOld passwords, for the entries' history: up to %d (any an entry has already aren't added again)\n", old) + fmt.Fprintf(&b, "\nEarlier secrets, for the entries' history: up to %d (any an entry has already aren't added again)\n", old) } if len(lost) > 0 { b.WriteString("\nNot kept:\n") diff --git a/sesh/cmd/sesh/import_cmd_test.go b/sesh/cmd/sesh/import_cmd_test.go index fd0b4907..c70a8b79 100644 --- a/sesh/cmd/sesh/import_cmd_test.go +++ b/sesh/cmd/sesh/import_cmd_test.go @@ -303,7 +303,7 @@ func TestImport_Bitwarden(t *testing.T) { " Passwords, in Work-Accounts (1):\n password/GitHub/alice\n", ` field "linked user" not kept`, " Secure notes, in no folder (1):\n secure_note/Me\n", - "Old passwords, for the entries' history: up to 1 (any an entry has already aren't added again)\n", + "Earlier secrets, for the entries' history: up to 1 (any an entry has already aren't added again)\n", "Not kept:\n items set to ask for the master password again (sesh doesn't ask): 1\n", "Already in the vault (1):\n totp/GitHub/alice\n", `named "GitHub (2)" in sesh: an entry before it has the name`, @@ -317,7 +317,7 @@ func TestImport_Bitwarden(t *testing.T) { t.Cleanup(func() { readSecret = old }) readSecret = func() ([]byte, error) { return []byte("export-pass-1"), nil } out, stderr, err := runImportOut(t, "y\n", true, "--on-conflict", "skip", bitwardenFixture("password.json")) - if err != nil || !strings.Contains(out, "✅ Imported 11 entries from Bitwarden.\nKept 1 old password in their history") || !strings.Contains(stderr, "Password for the Bitwarden export: ") { + if err != nil || !strings.Contains(out, "✅ Imported 11 entries from Bitwarden.\nKept 1 earlier secret in their history") || !strings.Contains(stderr, "Password for the Bitwarden export: ") { t.Fatalf("%q\n%s\n%v", out, stderr, err) } if strings.Contains(out, "delete") { @@ -505,7 +505,7 @@ func TestImport_KeePass(t *testing.T) { `"Home/Lab" is "Home-Lab" in sesh`, `"Old account": in KeePass's recycle bin`, `"Steam": its TOTP key: a Steam code, which sesh doesn't make`, - "Old passwords, for the entries' history: up to 1 (any an entry has already aren't added again)\n", + "Earlier secrets, for the entries' history: up to 1 (any an entry has already aren't added again)\n", "earlier versions with no other password to keep (sesh's history keeps old passwords only): 11\n", "attachments (add each with sesh add file): 2\n", } { @@ -514,7 +514,7 @@ func TestImport_KeePass(t *testing.T) { } } out, _, err := runImportOut(t, "", false, "--yes", "--on-conflict", "skip", keepassFixture("app-kdbx4.xml")) - if err != nil || !strings.Contains(out, "✅ Imported 11 entries from KeePass.\nKept 1 old password in their history") || !strings.Contains(out, "holds your passwords unencrypted") { + if err != nil || !strings.Contains(out, "✅ Imported 11 entries from KeePass.\nKept 1 earlier secret in their history") || !strings.Contains(out, "holds your passwords unencrypted") { t.Fatalf("%q, %v", out, err) } store := openDoctorVault(t, env) @@ -641,9 +641,9 @@ func TestImport_ApplePasswordsRefusedCode(t *testing.T) { func TestImport_HistoryAgainAndKeepNone(t *testing.T) { env := importVault(t) gh := vault.Key{Kind: vault.KindPassword, Service: "GitHub", Username: "alice"} - for i, want := range []string{"Kept 1 old password in their history", ""} { + for i, want := range []string{"Kept 1 earlier secret in their history", ""} { out, _, err := runImportOut(t, "", false, "--yes", "--on-conflict", "overwrite", bitwardenFixture("plain.json")) - if err != nil || (want != "" && !strings.Contains(out, want)) || (want == "" && strings.Contains(out, "old password")) { + if err != nil || (want != "" && !strings.Contains(out, want)) || (want == "" && strings.Contains(out, "earlier secret")) { t.Fatalf("import %d: %v\n%s", i+1, err, out) } } @@ -661,13 +661,13 @@ func TestImport_HistoryAgainAndKeepNone(t *testing.T) { } putSecrets(t, gh, "mine-2") out, _, err := runImportOut(t, "", false, "--yes", "--on-conflict", "overwrite", bitwardenFixture("plain.json")) - if err != nil || !strings.Contains(out, "Not kept: 1 old password past history.keep (1), the oldest of those imported.") || strings.Contains(out, "Kept ") { + if err != nil || !strings.Contains(out, "Not kept: 1 earlier secret past history.keep (1), the oldest of those imported.") || strings.Contains(out, "Kept ") { t.Errorf("no room: %v\n%s", err, out) } t.Setenv(config.EnvHistoryKeep, "0") _, stderr, err := runImportOut(t, "", false, "--dry-run", "--on-conflict", "overwrite", bitwardenFixture("plain.json")) - if err != nil || !strings.Contains(stderr, "old passwords (history.keep is 0): 1\n") || strings.Contains(stderr, "for the entries' history") { + if err != nil || !strings.Contains(stderr, "earlier secrets (history.keep is 0): 1\n") || strings.Contains(stderr, "for the entries' history") { t.Errorf("history.keep 0: %v\n%s", err, stderr) } } @@ -803,6 +803,85 @@ func TestImport_SeshKeepsHistory(t *testing.T) { } } +// A file's earlier versions keep their file names through an export: restored after the import, one comes back with +// its own. +func TestImport_SeshKeepsEarlierFileNames(t *testing.T) { + src := openDoctorVault(t, importVault(t)) + src.SetHistoryKeep(10) + kube := vault.Key{Kind: vault.KindFile, Service: "kubeconfig"} + for _, v := range []struct{ name, content string }{{"old.yaml", "v1"}, {"new.yaml", "v2"}} { + if err := src.Save(&vault.Entry{Key: kube, Settings: vault.Settings{FileName: v.name}}, []byte(v.content)); err != nil { + t.Fatal(err) + } + } + var buf bytes.Buffer + if _, err := password.NewManager(src).ExportEncrypted(&buf, &password.ExportOptions{KDF: kdf.Minimum()}, []byte("export-pass-1234")); err != nil { + t.Fatal(err) + } + file := filepath.Join(t.TempDir(), "backup.enc") + if err := os.WriteFile(file, buf.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + dst := importVault(t) + old := readSecret + t.Cleanup(func() { readSecret = old }) + readSecret = func() ([]byte, error) { return []byte("export-pass-1234"), nil } + if _, stderr, err := runImportOut(t, "y\n", true, "--from", "sesh", "--on-conflict", "skip", file); err != nil { + t.Fatalf("%s\n%v", stderr, err) + } + store := openDoctorVault(t, dst) + if _, err := store.RestoreVersion(kube, 2); err != nil { + t.Fatal(err) + } + e, err := store.Lookup(kube) + if err != nil { + t.Fatal(err) + } + if got, err := store.Get(kube); err != nil || string(got) != "v1" || e.Settings.FileName != "old.yaml" { + t.Errorf("restored: %q named %q (%v), want v1 named old.yaml", got, e.Settings.FileName, err) + } +} + +// An earlier secret that can't be read (damage) is left out of an encrypted export, which says whose; the export +// still holds everything else. +func TestExport_LeavesOutDamagedHistory(t *testing.T) { + env := importVault(t) + src := openDoctorVault(t, env) + src.SetHistoryKeep(10) + gh := vault.Key{Kind: vault.KindPassword, Service: "github", Username: "alice"} + for _, secret := range []string{"pass-1", "pass-2", "pass-3"} { + if err := src.Put(gh, []byte(secret)); err != nil { + t.Fatal(err) + } + } + sqlExec(t, env.dbPath, `UPDATE history SET encrypted_data = x'00112233445566778899aabbccddeeff00112233445566778899' WHERE id = (SELECT MIN(id) FROM history WHERE service = 'github')`) + opts := &password.ExportOptions{KDF: kdf.Minimum()} + var buf bytes.Buffer + n, err := password.NewManager(src).ExportEncrypted(&buf, opts, []byte("export-pass-1234")) + if err != nil || n != 2 { + t.Fatalf("exported %d, %v", n, err) + } + if !slices.Equal(opts.HistoryLeftOut, []string{gh.String()}) { + t.Errorf("left out %v, want github's", opts.HistoryLeftOut) + } + sealed, err := password.ReadSealed(&buf) + if err != nil { + t.Fatal(err) + } + list, err := sealed.Entries([]byte("export-pass-1234")) + if err != nil { + t.Fatal(err) + } + for _, e := range list { + if e.Key() == gh && (len(e.History) != 1 || e.History[0].Secret != "pass-2") { + t.Errorf("github's history in the export: %+v", e.History) + } + } + if got := historyLeftOut(opts.HistoryLeftOut); !strings.Contains(got, "Some earlier secrets of 1 entry couldn't be read (damaged), so aren't in it: password/github/alice") { + t.Errorf("the note: %q", got) + } +} + // Replacing an AWS entry from sesh's export takes the export's device with its key; a new entry keeps its code // settings, tags, notes, fields and times. A file whose settings sesh refuses isn't asked a password for, and a plain // export is sent to --action import, with or without --from. diff --git a/sesh/cmd/sesh/ui_export.go b/sesh/cmd/sesh/ui_export.go index 15c97fcb..c1cc4424 100644 --- a/sesh/cmd/sesh/ui_export.go +++ b/sesh/cmd/sesh/ui_export.go @@ -58,5 +58,5 @@ func (u *uiVault) Export(to string, pw []byte, replace bool, only tui.ExportOnly if err := out.Finish(); err != nil { return "", fmt.Errorf("write the export to %s: %w", tildePath(dest), err) } - return fmt.Sprintf("Exported %s to %s.", entryCount(n), tildePath(dest)), nil + return fmt.Sprintf("Exported %s to %s%s.", entryCount(n), tildePath(dest), historyLeftOut(opts.HistoryLeftOut)), nil } From d1750d9eec7d1a2fe4c9432535dc442add5d8d4d Mon Sep 17 00:00:00 2001 From: Marc Alvarez Date: Tue, 20 Oct 2026 20:44:16 -0600 Subject: [PATCH 3/4] An earlier version's file name is checked on import, and one sesh refuses left out --- docs/USAGE_AND_CONFIGURATION.md | 2 +- internal/importer/seshexport/entries.go | 9 +++++++++ internal/importer/seshexport/entries_test.go | 12 ++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/USAGE_AND_CONFIGURATION.md b/docs/USAGE_AND_CONFIGURATION.md index 6c3cadf9..5b461444 100644 --- a/docs/USAGE_AND_CONFIGURATION.md +++ b/docs/USAGE_AND_CONFIGURATION.md @@ -1320,7 +1320,7 @@ $ sesh trash restore 7 ### Password history -Each time an entry's secret changes (stored over, generated, edited, replaced by an import, or put back from its history), the one it replaces is kept, with when it was replaced: the newest 10 for each entry (`history.keep`). Importing from Bitwarden, KeePass, or sesh's own encrypted export brings in an entry's old passwords too: below sesh's own when the import replaces an entry you have, and never one the entry has already, so importing again adds nothing twice. `history.keep` limits them as it does the rest, keeping the newest, and the import says how many it kept and how many it had no room for. One whose time the export doesn't give is listed as `replaced ?`. Storing the same secret again keeps nothing, and neither does storing over a secret that's damaged. Lowering `history.keep` drops what's past it the next time a command unlocks the vault, which the audit log records; `0` keeps no new earlier secrets, and drops none of those kept: forget them with `--forget`. Only the secret is versioned, not the URL, notes or fields. +Each time an entry's secret changes (stored over, generated, edited, replaced by an import, or put back from its history), the one it replaces is kept, with when it was replaced: the newest 10 for each entry (`history.keep`). Importing from Bitwarden or KeePass brings in an entry's old passwords too, and sesh's own encrypted export its earlier secrets of every kind: below sesh's own when the import replaces an entry you have, and never one the entry has already, so importing again adds nothing twice. `history.keep` limits them as it does the rest, keeping the newest, and the import says how many it kept and how many it had no room for. One whose time the export doesn't give is listed as `replaced ?`. Storing the same secret again keeps nothing, and neither does storing over a secret that's damaged. Lowering `history.keep` drops what's past it the next time a command unlocks the vault, which the audit log records; `0` keeps no new earlier secrets, and drops none of those kept: forget them with `--forget`. Only the secret is versioned, not the URL, notes or fields. ``` $ sesh history password/github/alice diff --git a/internal/importer/seshexport/entries.go b/internal/importer/seshexport/entries.go index 8740e053..e17ec8d2 100644 --- a/internal/importer/seshexport/entries.go +++ b/internal/importer/seshexport/entries.go @@ -8,6 +8,7 @@ import ( "github.com/bashhack/sesh/internal/importer" "github.com/bashhack/sesh/internal/password" "github.com/bashhack/sesh/internal/secure" + "github.com/bashhack/sesh/internal/vault" ) // Entries are the export's entries as sesh import brings them in: each @@ -44,6 +45,14 @@ func Entries(list []password.ExportEntry) []*importer.Entry { e.Skip = err.Error() break } + // An earlier version's file name is held to the rule a file's + // name is: one sesh refuses is left out, its content kept. + for i := range earlier { + if earlier[i].FileName != "" && vault.CheckFileName(earlier[i].FileName) != nil { + earlier[i].FileName = "" + e.Changes = append(e.Changes, "an earlier version's file name, which isn't one sesh takes, left out") + } + } e.Secret = secret e.Earlier = earlier e.Details = ee.Details() diff --git a/internal/importer/seshexport/entries_test.go b/internal/importer/seshexport/entries_test.go index 9f2260ae..3c0ed637 100644 --- a/internal/importer/seshexport/entries_test.go +++ b/internal/importer/seshexport/entries_test.go @@ -1,6 +1,7 @@ package seshexport import ( + "slices" "strings" "testing" "time" @@ -58,3 +59,14 @@ func TestEntriesEarlier(t *testing.T) { t.Errorf("bad: skipped %q, secret %q", e.Skip, e.Secret) } } + +// An earlier version's file name sesh refuses is left out, saying so; its content still comes. +func TestEntriesEarlierFileName(t *testing.T) { + got := Entries([]password.ExportEntry{{Service: "kubeconfig", Type: vault.KindFile, Secret: "bm93", Encoding: "base64", + History: []password.ExportEarlier{{FileName: "../evil\a", Secret: "b2xk"}, {FileName: "old.yaml", Secret: "b2xkZXI="}}}}) + e := got[0] + if e.Skip != "" || len(e.Earlier) != 2 || e.Earlier[0].FileName != "" || string(e.Earlier[0].Secret) != "old" || + e.Earlier[1].FileName != "old.yaml" || !slices.ContainsFunc(e.Changes, func(c string) bool { return strings.Contains(c, "file name") }) { + t.Errorf("%+v", e) + } +} From 83ff20f5321649bf3f7be34f9e471bcfbfedbc11 Mon Sep 17 00:00:00 2001 From: Marc Alvarez Date: Tue, 20 Oct 2026 20:44:31 -0600 Subject: [PATCH 4/4] Release notes for v0.1.2 --- .github/release-notes/v0.1.2.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .github/release-notes/v0.1.2.md diff --git a/.github/release-notes/v0.1.2.md b/.github/release-notes/v0.1.2.md new file mode 100644 index 00000000..556de2cf --- /dev/null +++ b/.github/release-notes/v0.1.2.md @@ -0,0 +1,10 @@ +## Changes + +- **Encrypted exports keep each entry's earlier secrets.** `sesh export` (and Export in `sesh ui`) now includes every entry's history, with when each earlier secret was replaced and, for a file, its name then; `sesh import --from sesh` brings them back, within `history.keep`. Moving a vault to another computer this way no longer loses them. Plain exports (`--format json` or `csv`) still leave them out. An export made by 0.1.1 or earlier imports as before, and 0.1.1 reading a new export takes its entries and ignores the history. +- **A damaged earlier secret no longer stops an export.** One that can't be read is left out, and the export names the entries it affects; everything else is exported. +- Import summaries say "earlier secrets" rather than "old passwords", since a sesh export's history can be files, keys or notes as well. + +## For contributors + +- `scripts/sesh-dev` builds the checkout and runs it on a vault, config and agent of its own, apart from an installed sesh; `AGENTS.md` says how to work on sesh. +- `internal/compat` holds a vault, an encrypted export and a sync folder made by v0.1.1, and tests that sesh still reads them.