Skip to content

Commit 36b13ef

Browse files
authored
feat: Added displaying of TOTP values (#157)
1 parent 7890c9c commit 36b13ef

6 files changed

Lines changed: 294 additions & 16 deletions

File tree

.github/golangci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
version: "2"

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ $ enp list twitter
1919
$ # show passwords of 'enpass.com'
2020
$ enp show enpass.com
2121

22+
$ # show every field of every entry matching 'github' (incl. TOTP code)
23+
$ enp -detailed show github
24+
2225
$ # copy password of 'reddit.com' entry to clipboard
2326
$ enp copy reddit.com
2427

@@ -72,6 +75,7 @@ Flags
7275
| `-and` | Combines filters with AND instead of default OR |
7376
| `-sort` | Sort the output by title and username of the `list` and `show` command |
7477
| `-trashed` | Show trashed items in the `list` and `show` command |
78+
| `-detailed` | Show every field of each entry in `list` and `show` instead of only the summary fields (title, login, category, label, type) |
7579
| `-clipboardPrimary` | Use primary X selection instead of clipboard for the `copy` command |
7680
| `-title=TITLE` | Title for `create`/`edit` commands |
7781
| `-login=LOGIN` | Login/username for `create`/`edit` commands |
@@ -81,6 +85,18 @@ Flags
8185
| `-category=CATEGORY` | Category for `create`/`edit` commands (default: Login) |
8286
| `-force` | Skip confirmation prompts for `trash`/`delete` commands |
8387

88+
TOTP fields
89+
-----
90+
With `-detailed`, fields of type `totp` are treated as sensitive: their
91+
secret key is never displayed by the `list` command and the field is masked
92+
the same way passwords are. The `show` command computes the current
93+
[RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238) code for the
94+
field and prints it alongside the secret key. Both bare base32 secrets and
95+
`otpauth://totp/...` URIs (honoring the `period`, `digits` and `algorithm`
96+
parameters) are supported. If the stored value can't be parsed, `show`
97+
prints `<dynamic TOTP value>` instead of a code so the user knows the field
98+
holds a generated value rather than a static one.
99+
84100
Environment Variables
85101
-----
86102
| Name | Description |

cmd/enpasscli/main.go

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"sort"
1212
"strconv"
1313
"strings"
14+
"time"
1415

1516
"github.com/gdamore/tcell/v2"
1617
"github.com/hazcod/enpass-cli/pkg/clipboard"
@@ -126,8 +127,8 @@ func printHelp() {
126127
fmt.Println("Usage: enpass-cli [flags] <command> [filters...]")
127128
fmt.Println()
128129
fmt.Println("Commands:")
129-
fmt.Println(" list [filter] List entries (without passwords)")
130-
fmt.Println(" show [filter] Show entries (with passwords)")
130+
fmt.Println(" list [filter] List entries (without passwords; TOTP fields masked)")
131+
fmt.Println(" show [filter] Show entries (with passwords; computes RFC 6238 TOTP code)")
131132
fmt.Println(" copy <filter> Copy password to clipboard")
132133
fmt.Println(" pass <filter> Print password to stdout")
133134
fmt.Println(" ui Interactive terminal UI")
@@ -140,6 +141,11 @@ func printHelp() {
140141
fmt.Println(" version Print version")
141142
fmt.Println(" help Print this help")
142143
fmt.Println()
144+
fmt.Println("Pass -detailed to list/show to see every field of each entry instead of")
145+
fmt.Println("only the summary fields (title, login, category, label, type). TOTP fields")
146+
fmt.Println("are treated as sensitive: their secret is hidden in list, and show prints")
147+
fmt.Println("the current RFC 6238 code alongside the secret.")
148+
fmt.Println()
143149
fmt.Println("Flags:")
144150
flag.Usage()
145151
}
@@ -183,12 +189,17 @@ type entryView struct {
183189

184190
// fieldView is a single field of an entry (username, email, password, ...).
185191
// Value is empty when the field is sensitive and the caller didn't ask for
186-
// decrypted output (list mode).
192+
// decrypted output (list mode). For TOTP fields the stored Value is the
193+
// secret key, so it's treated as sensitive: hidden in list mode, included in
194+
// show mode. TOTPCode carries the current RFC 6238 code; TOTPError is set
195+
// when computing it failed.
187196
type fieldView struct {
188197
Type string `json:"type"`
189198
Label string `json:"label,omitempty"`
190199
Sensitive bool `json:"sensitive,omitempty"`
191200
Value string `json:"value,omitempty"`
201+
TOTPCode string `json:"totp_code,omitempty"`
202+
TOTPError string `json:"totp_error,omitempty"`
192203
}
193204

194205
// 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
219230
if c.IsTrashed() && !*args.trashed {
220231
continue
221232
}
233+
// Non-password field values are stored in cleartext; Decrypt() returns
234+
// them as-is. For password fields, Decrypt() actually decrypts.
235+
value, derr := c.Decrypt()
236+
if derr != nil {
237+
return nil, fmt.Errorf("could not decrypt %s/%s: %w", c.Title, c.Label, derr)
238+
}
239+
// Match the Enpass native apps' view mode: hide empty-value template
240+
// placeholders that a user never filled in (e.g. "Date Mod", "Field 6").
241+
// Sections are visual dividers and stay even when empty.
242+
if value == "" && c.Type != "section" {
243+
continue
244+
}
222245
g, ok := groups[c.UUID]
223246
if !ok {
224247
g = &entryView{
@@ -236,13 +259,22 @@ func collectEntries(vault *enpass.Vault, args *Args, includeSensitive bool) ([]e
236259
Label: c.Label,
237260
Sensitive: c.Sensitive,
238261
}
239-
// Non-password field values are stored in cleartext; Decrypt() returns
240-
// them as-is. For password fields, Decrypt() actually decrypts.
241-
value, derr := c.Decrypt()
242-
if derr != nil {
243-
return nil, fmt.Errorf("could not decrypt %s/%s: %w", c.Title, c.Label, derr)
262+
isTOTP := c.Type == "totp"
263+
hasValue := value != ""
264+
// TOTP fields are classified as sensitive: in list mode neither the
265+
// secret nor the live code is exposed. Only compute the code when the
266+
// caller is going to display it.
267+
if isTOTP && hasValue && includeSensitive {
268+
if code, terr := enpass.ComputeTOTP(value, time.Now()); terr == nil {
269+
f.TOTPCode = code
270+
} else {
271+
f.TOTPError = terr.Error()
272+
}
273+
}
274+
if isTOTP && hasValue {
275+
f.Sensitive = true
244276
}
245-
if includeSensitive || !c.Sensitive {
277+
if includeSensitive || !f.Sensitive {
246278
f.Value = value
247279
}
248280
g.Fields = append(g.Fields, f)
@@ -346,22 +378,65 @@ func outputDetailed(logger *logrus.Logger, entries []entryView, args *Args) {
346378
if name == "" {
347379
name = f.Type
348380
}
381+
// Three-level hierarchy: record header (no indent), section header
382+
// (4 spaces), regular field (8 spaces). Regular fields are at the
383+
// same depth whether the record has sections or not, so columns
384+
// stay aligned across records.
385+
indent := fieldIndent
386+
if f.Type == "section" {
387+
indent = sectionIndent
388+
}
389+
if f.Type == "totp" && (f.TOTPCode != "" || f.TOTPError != "") {
390+
renderTOTPField(logger, indent, name, f)
391+
continue
392+
}
349393
switch {
350394
case f.Sensitive && f.Value == "":
351-
logger.Printf(" %s (%s): ********", name, f.Type)
395+
logger.Printf("%s%s (%s): ********", indent, name, f.Type)
352396
case f.Value != "":
353-
logger.Printf(" %s (%s): %s", name, f.Type, f.Value)
397+
logger.Printf("%s%s (%s): %s", indent, name, f.Type, f.Value)
354398
default:
355-
logger.Printf(" %s (%s)", name, f.Type)
399+
logger.Printf("%s%s (%s)", indent, name, f.Type)
356400
}
357401
}
358402
}
359403
}
360404

405+
const (
406+
sectionIndent = " "
407+
fieldIndent = " "
408+
)
409+
410+
// renderTOTPField prints a TOTP field. When the code could be computed we
411+
// show it; otherwise we tell the user the value is dynamic. The secret is
412+
// only included when collectEntries chose to expose it (i.e. show mode).
413+
func renderTOTPField(logger *logrus.Logger, indent, name string, f fieldView) {
414+
parts := []string{}
415+
switch {
416+
case f.TOTPCode != "":
417+
parts = append(parts, "code "+f.TOTPCode)
418+
case f.TOTPError != "":
419+
parts = append(parts, "<dynamic TOTP value>")
420+
default:
421+
logger.Printf("%s%s (%s)", indent, name, f.Type)
422+
return
423+
}
424+
if f.Value != "" {
425+
parts = append(parts, "secret: "+f.Value)
426+
}
427+
logger.Printf("%s%s (%s): %s", indent, name, f.Type, strings.Join(parts, " "))
428+
}
429+
361430
// anchorField picks the field that represents the entry in compact mode.
362-
// Mirrors the original GetEntries dedup: prefer the sensitive (password)
363-
// field, fall back to the first field.
431+
// Prefer the password field so the compact summary stays password-focused
432+
// even when other sensitive field types (e.g. TOTP) are present. Fall back
433+
// to any sensitive field, then to the first field.
364434
func anchorField(fields []fieldView) *fieldView {
435+
for i := range fields {
436+
if fields[i].Type == "password" {
437+
return &fields[i]
438+
}
439+
}
365440
for i := range fields {
366441
if fields[i].Sensitive {
367442
return &fields[i]

pkg/enpass/totp.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package enpass
2+
3+
import (
4+
"crypto/hmac"
5+
"crypto/sha1"
6+
"crypto/sha256"
7+
"crypto/sha512"
8+
"encoding/base32"
9+
"encoding/binary"
10+
"fmt"
11+
"hash"
12+
"net/url"
13+
"strconv"
14+
"strings"
15+
"time"
16+
)
17+
18+
// ComputeTOTP returns the current RFC 6238 code for the given field value.
19+
// The value may be a bare base32 secret (with optional whitespace, dashes,
20+
// and missing padding) or an otpauth://totp/... URI carrying the secret and
21+
// optional period/digits/algorithm parameters. Returns an error if the value
22+
// can't be parsed as a TOTP secret.
23+
func ComputeTOTP(value string, now time.Time) (string, error) {
24+
secret, period, digits, algo, err := parseTOTPValue(value)
25+
if err != nil {
26+
return "", err
27+
}
28+
29+
key, err := base32.StdEncoding.DecodeString(normalizeBase32(secret))
30+
if err != nil {
31+
return "", fmt.Errorf("invalid base32 secret: %w", err)
32+
}
33+
if len(key) == 0 {
34+
return "", fmt.Errorf("empty TOTP secret")
35+
}
36+
37+
var newHash func() hash.Hash
38+
switch strings.ToUpper(algo) {
39+
case "SHA1":
40+
newHash = sha1.New
41+
case "SHA256":
42+
newHash = sha256.New
43+
case "SHA512":
44+
newHash = sha512.New
45+
default:
46+
return "", fmt.Errorf("unsupported TOTP algorithm: %s", algo)
47+
}
48+
49+
counter := uint64(now.Unix()) / uint64(period)
50+
buf := make([]byte, 8)
51+
binary.BigEndian.PutUint64(buf, counter)
52+
53+
mac := hmac.New(newHash, key)
54+
mac.Write(buf)
55+
sum := mac.Sum(nil)
56+
57+
offset := sum[len(sum)-1] & 0x0f
58+
code := (uint32(sum[offset]&0x7f) << 24) |
59+
(uint32(sum[offset+1]) << 16) |
60+
(uint32(sum[offset+2]) << 8) |
61+
uint32(sum[offset+3])
62+
63+
mod := uint32(1)
64+
for i := 0; i < digits; i++ {
65+
mod *= 10
66+
}
67+
return fmt.Sprintf("%0*d", digits, code%mod), nil
68+
}
69+
70+
func parseTOTPValue(value string) (secret string, period, digits int, algo string, err error) {
71+
value = strings.TrimSpace(value)
72+
if value == "" {
73+
return "", 0, 0, "", fmt.Errorf("empty TOTP value")
74+
}
75+
76+
period, digits, algo = 30, 6, "SHA1"
77+
78+
if !strings.HasPrefix(strings.ToLower(value), "otpauth://") {
79+
return value, period, digits, algo, nil
80+
}
81+
82+
u, perr := url.Parse(value)
83+
if perr != nil {
84+
return "", 0, 0, "", fmt.Errorf("invalid otpauth URI: %w", perr)
85+
}
86+
q := u.Query()
87+
secret = q.Get("secret")
88+
if secret == "" {
89+
return "", 0, 0, "", fmt.Errorf("otpauth URI has no secret")
90+
}
91+
if p := q.Get("period"); p != "" {
92+
if n, perr := strconv.Atoi(p); perr == nil && n > 0 {
93+
period = n
94+
}
95+
}
96+
if d := q.Get("digits"); d != "" {
97+
if n, perr := strconv.Atoi(d); perr == nil && n > 0 {
98+
digits = n
99+
}
100+
}
101+
if a := q.Get("algorithm"); a != "" {
102+
algo = a
103+
}
104+
return secret, period, digits, algo, nil
105+
}
106+
107+
func normalizeBase32(secret string) string {
108+
secret = strings.ToUpper(secret)
109+
secret = strings.ReplaceAll(secret, " ", "")
110+
secret = strings.ReplaceAll(secret, "-", "")
111+
if rem := len(secret) % 8; rem != 0 {
112+
secret += strings.Repeat("=", 8-rem)
113+
}
114+
return secret
115+
}

pkg/enpass/totp_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package enpass
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestComputeTOTP_RFC6238_SHA1_6Digits(t *testing.T) {
9+
// RFC 6238 Appendix B uses the ASCII secret "12345678901234567890"
10+
// which is base32 "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ". The published
11+
// 8-digit codes truncated to 6 digits (mod 10^6) are the expected
12+
// 6-digit values for the same timestamps.
13+
const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
14+
cases := []struct {
15+
unix int64
16+
want string
17+
}{
18+
{59, "287082"},
19+
{1111111109, "081804"},
20+
{1111111111, "050471"},
21+
{1234567890, "005924"},
22+
{2000000000, "279037"},
23+
}
24+
for _, tc := range cases {
25+
got, err := ComputeTOTP(secret, time.Unix(tc.unix, 0))
26+
if err != nil {
27+
t.Fatalf("unix=%d: unexpected error: %v", tc.unix, err)
28+
}
29+
if got != tc.want {
30+
t.Errorf("unix=%d: got %q, want %q", tc.unix, got, tc.want)
31+
}
32+
}
33+
}
34+
35+
func TestComputeTOTP_OtpAuthURI(t *testing.T) {
36+
uri := "otpauth://totp/Example:alice@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&period=30&digits=6&algorithm=SHA1"
37+
got, err := ComputeTOTP(uri, time.Unix(1234567890, 0))
38+
if err != nil {
39+
t.Fatalf("unexpected error: %v", err)
40+
}
41+
if got != "005924" {
42+
t.Errorf("got %q, want %q", got, "005924")
43+
}
44+
}
45+
46+
func TestComputeTOTP_NormalizesSecret(t *testing.T) {
47+
// Spaces, dashes and missing padding should all parse.
48+
got, err := ComputeTOTP("gezd gnbv-gy3tqojq gezd gnbv-gy3tqojq", time.Unix(1234567890, 0))
49+
if err != nil {
50+
t.Fatalf("unexpected error: %v", err)
51+
}
52+
if got != "005924" {
53+
t.Errorf("got %q, want %q", got, "005924")
54+
}
55+
}
56+
57+
func TestComputeTOTP_RejectsBadInput(t *testing.T) {
58+
for _, in := range []string{"", "!!!not-base32!!!", "otpauth://totp/foo"} {
59+
if _, err := ComputeTOTP(in, time.Unix(0, 0)); err == nil {
60+
t.Errorf("input %q: expected error, got nil", in)
61+
}
62+
}
63+
}

0 commit comments

Comments
 (0)