diff --git a/.github/golangci.yml b/.github/golangci.yml index e69de29..26b4afd 100644 --- a/.github/golangci.yml +++ b/.github/golangci.yml @@ -0,0 +1 @@ +version: "2" diff --git a/README.md b/README.md index ace290c..2913576 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ $ enp list twitter $ # show passwords of 'enpass.com' $ enp show enpass.com +$ # show every field of every entry matching 'github' (incl. TOTP code) +$ enp -detailed show github + $ # copy password of 'reddit.com' entry to clipboard $ enp copy reddit.com @@ -72,6 +75,7 @@ Flags | `-and` | Combines filters with AND instead of default OR | | `-sort` | Sort the output by title and username of the `list` and `show` command | | `-trashed` | Show trashed items in the `list` and `show` command | +| `-detailed` | Show every field of each entry in `list` and `show` instead of only the summary fields (title, login, category, label, type) | | `-clipboardPrimary` | Use primary X selection instead of clipboard for the `copy` command | | `-title=TITLE` | Title for `create`/`edit` commands | | `-login=LOGIN` | Login/username for `create`/`edit` commands | @@ -81,6 +85,18 @@ Flags | `-category=CATEGORY` | Category for `create`/`edit` commands (default: Login) | | `-force` | Skip confirmation prompts for `trash`/`delete` commands | +TOTP fields +----- +With `-detailed`, fields of type `totp` are treated as sensitive: their +secret key is never displayed by the `list` command and the field is masked +the same way passwords are. The `show` command computes the current +[RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238) code for the +field and prints it alongside the secret key. Both bare base32 secrets and +`otpauth://totp/...` URIs (honoring the `period`, `digits` and `algorithm` +parameters) are supported. If the stored value can't be parsed, `show` +prints `` instead of a code so the user knows the field +holds a generated value rather than a static one. + Environment Variables ----- | Name | Description | diff --git a/cmd/enpasscli/main.go b/cmd/enpasscli/main.go index 0bbd7df..5c7d55c 100644 --- a/cmd/enpasscli/main.go +++ b/cmd/enpasscli/main.go @@ -11,6 +11,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/gdamore/tcell/v2" "github.com/hazcod/enpass-cli/pkg/clipboard" @@ -126,8 +127,8 @@ func printHelp() { fmt.Println("Usage: enpass-cli [flags] [filters...]") fmt.Println() fmt.Println("Commands:") - fmt.Println(" list [filter] List entries (without passwords)") - fmt.Println(" show [filter] Show entries (with passwords)") + fmt.Println(" list [filter] List entries (without passwords; TOTP fields masked)") + fmt.Println(" show [filter] Show entries (with passwords; computes RFC 6238 TOTP code)") fmt.Println(" copy Copy password to clipboard") fmt.Println(" pass Print password to stdout") fmt.Println(" ui Interactive terminal UI") @@ -140,6 +141,11 @@ func printHelp() { fmt.Println(" version Print version") fmt.Println(" help Print this help") fmt.Println() + fmt.Println("Pass -detailed to list/show to see every field of each entry instead of") + fmt.Println("only the summary fields (title, login, category, label, type). TOTP fields") + fmt.Println("are treated as sensitive: their secret is hidden in list, and show prints") + fmt.Println("the current RFC 6238 code alongside the secret.") + fmt.Println() fmt.Println("Flags:") flag.Usage() } @@ -183,12 +189,17 @@ type entryView struct { // fieldView is a single field of an entry (username, email, password, ...). // Value is empty when the field is sensitive and the caller didn't ask for -// decrypted output (list mode). +// decrypted output (list mode). For TOTP fields the stored Value is the +// secret key, so it's treated as sensitive: hidden in list mode, included in +// show mode. TOTPCode carries the current RFC 6238 code; TOTPError is set +// when computing it failed. type fieldView struct { Type string `json:"type"` Label string `json:"label,omitempty"` Sensitive bool `json:"sensitive,omitempty"` Value string `json:"value,omitempty"` + TOTPCode string `json:"totp_code,omitempty"` + TOTPError string `json:"totp_error,omitempty"` } // collectEntries fetches every field for matching entries and groups them by @@ -219,6 +230,18 @@ func collectEntries(vault *enpass.Vault, args *Args, includeSensitive bool) ([]e if c.IsTrashed() && !*args.trashed { continue } + // Non-password field values are stored in cleartext; Decrypt() returns + // them as-is. For password fields, Decrypt() actually decrypts. + value, derr := c.Decrypt() + if derr != nil { + return nil, fmt.Errorf("could not decrypt %s/%s: %w", c.Title, c.Label, derr) + } + // Match the Enpass native apps' view mode: hide empty-value template + // placeholders that a user never filled in (e.g. "Date Mod", "Field 6"). + // Sections are visual dividers and stay even when empty. + if value == "" && c.Type != "section" { + continue + } g, ok := groups[c.UUID] if !ok { g = &entryView{ @@ -236,13 +259,22 @@ func collectEntries(vault *enpass.Vault, args *Args, includeSensitive bool) ([]e Label: c.Label, Sensitive: c.Sensitive, } - // Non-password field values are stored in cleartext; Decrypt() returns - // them as-is. For password fields, Decrypt() actually decrypts. - value, derr := c.Decrypt() - if derr != nil { - return nil, fmt.Errorf("could not decrypt %s/%s: %w", c.Title, c.Label, derr) + isTOTP := c.Type == "totp" + hasValue := value != "" + // TOTP fields are classified as sensitive: in list mode neither the + // secret nor the live code is exposed. Only compute the code when the + // caller is going to display it. + if isTOTP && hasValue && includeSensitive { + if code, terr := enpass.ComputeTOTP(value, time.Now()); terr == nil { + f.TOTPCode = code + } else { + f.TOTPError = terr.Error() + } + } + if isTOTP && hasValue { + f.Sensitive = true } - if includeSensitive || !c.Sensitive { + if includeSensitive || !f.Sensitive { f.Value = value } g.Fields = append(g.Fields, f) @@ -346,22 +378,65 @@ func outputDetailed(logger *logrus.Logger, entries []entryView, args *Args) { if name == "" { name = f.Type } + // Three-level hierarchy: record header (no indent), section header + // (4 spaces), regular field (8 spaces). Regular fields are at the + // same depth whether the record has sections or not, so columns + // stay aligned across records. + indent := fieldIndent + if f.Type == "section" { + indent = sectionIndent + } + if f.Type == "totp" && (f.TOTPCode != "" || f.TOTPError != "") { + renderTOTPField(logger, indent, name, f) + continue + } switch { case f.Sensitive && f.Value == "": - logger.Printf(" %s (%s): ********", name, f.Type) + logger.Printf("%s%s (%s): ********", indent, name, f.Type) case f.Value != "": - logger.Printf(" %s (%s): %s", name, f.Type, f.Value) + logger.Printf("%s%s (%s): %s", indent, name, f.Type, f.Value) default: - logger.Printf(" %s (%s)", name, f.Type) + logger.Printf("%s%s (%s)", indent, name, f.Type) } } } } +const ( + sectionIndent = " " + fieldIndent = " " +) + +// renderTOTPField prints a TOTP field. When the code could be computed we +// show it; otherwise we tell the user the value is dynamic. The secret is +// only included when collectEntries chose to expose it (i.e. show mode). +func renderTOTPField(logger *logrus.Logger, indent, name string, f fieldView) { + parts := []string{} + switch { + case f.TOTPCode != "": + parts = append(parts, "code "+f.TOTPCode) + case f.TOTPError != "": + parts = append(parts, "") + default: + logger.Printf("%s%s (%s)", indent, name, f.Type) + return + } + if f.Value != "" { + parts = append(parts, "secret: "+f.Value) + } + logger.Printf("%s%s (%s): %s", indent, name, f.Type, strings.Join(parts, " ")) +} + // anchorField picks the field that represents the entry in compact mode. -// Mirrors the original GetEntries dedup: prefer the sensitive (password) -// field, fall back to the first field. +// Prefer the password field so the compact summary stays password-focused +// even when other sensitive field types (e.g. TOTP) are present. Fall back +// to any sensitive field, then to the first field. func anchorField(fields []fieldView) *fieldView { + for i := range fields { + if fields[i].Type == "password" { + return &fields[i] + } + } for i := range fields { if fields[i].Sensitive { return &fields[i] diff --git a/pkg/enpass/totp.go b/pkg/enpass/totp.go new file mode 100644 index 0000000..9b2a7bb --- /dev/null +++ b/pkg/enpass/totp.go @@ -0,0 +1,115 @@ +package enpass + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/base32" + "encoding/binary" + "fmt" + "hash" + "net/url" + "strconv" + "strings" + "time" +) + +// ComputeTOTP returns the current RFC 6238 code for the given field value. +// The value may be a bare base32 secret (with optional whitespace, dashes, +// and missing padding) or an otpauth://totp/... URI carrying the secret and +// optional period/digits/algorithm parameters. Returns an error if the value +// can't be parsed as a TOTP secret. +func ComputeTOTP(value string, now time.Time) (string, error) { + secret, period, digits, algo, err := parseTOTPValue(value) + if err != nil { + return "", err + } + + key, err := base32.StdEncoding.DecodeString(normalizeBase32(secret)) + if err != nil { + return "", fmt.Errorf("invalid base32 secret: %w", err) + } + if len(key) == 0 { + return "", fmt.Errorf("empty TOTP secret") + } + + var newHash func() hash.Hash + switch strings.ToUpper(algo) { + case "SHA1": + newHash = sha1.New + case "SHA256": + newHash = sha256.New + case "SHA512": + newHash = sha512.New + default: + return "", fmt.Errorf("unsupported TOTP algorithm: %s", algo) + } + + counter := uint64(now.Unix()) / uint64(period) + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, counter) + + mac := hmac.New(newHash, key) + mac.Write(buf) + sum := mac.Sum(nil) + + offset := sum[len(sum)-1] & 0x0f + code := (uint32(sum[offset]&0x7f) << 24) | + (uint32(sum[offset+1]) << 16) | + (uint32(sum[offset+2]) << 8) | + uint32(sum[offset+3]) + + mod := uint32(1) + for i := 0; i < digits; i++ { + mod *= 10 + } + return fmt.Sprintf("%0*d", digits, code%mod), nil +} + +func parseTOTPValue(value string) (secret string, period, digits int, algo string, err error) { + value = strings.TrimSpace(value) + if value == "" { + return "", 0, 0, "", fmt.Errorf("empty TOTP value") + } + + period, digits, algo = 30, 6, "SHA1" + + if !strings.HasPrefix(strings.ToLower(value), "otpauth://") { + return value, period, digits, algo, nil + } + + u, perr := url.Parse(value) + if perr != nil { + return "", 0, 0, "", fmt.Errorf("invalid otpauth URI: %w", perr) + } + q := u.Query() + secret = q.Get("secret") + if secret == "" { + return "", 0, 0, "", fmt.Errorf("otpauth URI has no secret") + } + if p := q.Get("period"); p != "" { + if n, perr := strconv.Atoi(p); perr == nil && n > 0 { + period = n + } + } + if d := q.Get("digits"); d != "" { + if n, perr := strconv.Atoi(d); perr == nil && n > 0 { + digits = n + } + } + if a := q.Get("algorithm"); a != "" { + algo = a + } + return secret, period, digits, algo, nil +} + +func normalizeBase32(secret string) string { + secret = strings.ToUpper(secret) + secret = strings.ReplaceAll(secret, " ", "") + secret = strings.ReplaceAll(secret, "-", "") + if rem := len(secret) % 8; rem != 0 { + secret += strings.Repeat("=", 8-rem) + } + return secret +} diff --git a/pkg/enpass/totp_test.go b/pkg/enpass/totp_test.go new file mode 100644 index 0000000..fa8aef4 --- /dev/null +++ b/pkg/enpass/totp_test.go @@ -0,0 +1,63 @@ +package enpass + +import ( + "testing" + "time" +) + +func TestComputeTOTP_RFC6238_SHA1_6Digits(t *testing.T) { + // RFC 6238 Appendix B uses the ASCII secret "12345678901234567890" + // which is base32 "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ". The published + // 8-digit codes truncated to 6 digits (mod 10^6) are the expected + // 6-digit values for the same timestamps. + const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + cases := []struct { + unix int64 + want string + }{ + {59, "287082"}, + {1111111109, "081804"}, + {1111111111, "050471"}, + {1234567890, "005924"}, + {2000000000, "279037"}, + } + for _, tc := range cases { + got, err := ComputeTOTP(secret, time.Unix(tc.unix, 0)) + if err != nil { + t.Fatalf("unix=%d: unexpected error: %v", tc.unix, err) + } + if got != tc.want { + t.Errorf("unix=%d: got %q, want %q", tc.unix, got, tc.want) + } + } +} + +func TestComputeTOTP_OtpAuthURI(t *testing.T) { + uri := "otpauth://totp/Example:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&period=30&digits=6&algorithm=SHA1" + got, err := ComputeTOTP(uri, time.Unix(1234567890, 0)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "005924" { + t.Errorf("got %q, want %q", got, "005924") + } +} + +func TestComputeTOTP_NormalizesSecret(t *testing.T) { + // Spaces, dashes and missing padding should all parse. + got, err := ComputeTOTP("gezd gnbv-gy3tqojq gezd gnbv-gy3tqojq", time.Unix(1234567890, 0)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "005924" { + t.Errorf("got %q, want %q", got, "005924") + } +} + +func TestComputeTOTP_RejectsBadInput(t *testing.T) { + for _, in := range []string{"", "!!!not-base32!!!", "otpauth://totp/foo"} { + if _, err := ComputeTOTP(in, time.Unix(0, 0)); err == nil { + t.Errorf("input %q: expected error, got nil", in) + } + } +} diff --git a/pkg/enpass/vault.go b/pkg/enpass/vault.go index 2651cb1..95bf488 100644 --- a/pkg/enpass/vault.go +++ b/pkg/enpass/vault.go @@ -340,8 +340,8 @@ func (v *Vault) executeEntryQuery(cardType string, filters []string) (*sql.Rows, INNER JOIN itemfield ON uuid = item_uuid ` - where := []string{"item.deleted = ?"} - values := []interface{}{0} + where := []string{"item.deleted = ?", "itemfield.deleted = ?"} + values := []interface{}{0, 0} if cardType != "" { where = append(where, "type = ?") @@ -366,6 +366,14 @@ func (v *Vault) executeEntryQuery(cardType string, filters []string) (*sql.Rows, } query += " WHERE " + strings.Join(where, " AND ") + // itemfield.orde is Enpass's per-entry display order (column name truncated + // from "order" to dodge the SQL keyword). Ordering by it keeps section + // headers and other fields in the order the user arranged them in the + // Enpass UI — without this, SQLite returns rows in arbitrary insertion + // order and sections (added later by edits) often drift to the end. + // We group by item_uuid first so each entry's fields stay contiguous when + // the grouping pass in the CLI builds entry views. + query += " ORDER BY item.uuid, itemfield.orde" v.logger.Trace("query: ", query) return v.db.Query(query, values...) }