Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
version: "2"
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand All @@ -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 `<dynamic TOTP value>` instead of a code so the user knows the field
holds a generated value rather than a static one.

Environment Variables
-----
| Name | Description |
Expand Down
103 changes: 89 additions & 14 deletions cmd/enpasscli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"sort"
"strconv"
"strings"
"time"

"github.com/gdamore/tcell/v2"
"github.com/hazcod/enpass-cli/pkg/clipboard"
Expand Down Expand Up @@ -126,8 +127,8 @@ func printHelp() {
fmt.Println("Usage: enpass-cli [flags] <command> [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 <filter> Copy password to clipboard")
fmt.Println(" pass <filter> Print password to stdout")
fmt.Println(" ui Interactive terminal UI")
Expand All @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand All @@ -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)
Expand Down Expand Up @@ -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, "<dynamic TOTP value>")
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]
Expand Down
115 changes: 115 additions & 0 deletions pkg/enpass/totp.go
Original file line number Diff line number Diff line change
@@ -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
}
63 changes: 63 additions & 0 deletions pkg/enpass/totp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading