aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md7
-rw-r--r--_AUR/PKGBUILD2
-rw-r--r--_sys/usr/share/bash-completion/completions/gotp13
-rw-r--r--cli.go43
-rw-r--r--cli_test.go54
-rw-r--r--cmd/gotp/main.go44
-rw-r--r--gotp.go42
-rw-r--r--provider_aegis.go7
-rw-r--r--testdata/cli_Export_test.txt3
-rw-r--r--testdata/cli_with_passphrase_test.txt5
10 files changed, 216 insertions, 4 deletions
diff --git a/README.md b/README.md
index 13e972f..13f6e80 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,13 @@ seconds, default to 30 seconds.
The ISSUER field is also optional, its define the name of
provider that generate the secret.
+ export <FORMAT> [FILE]
+
+Export all the issuers to file format that can be imported by provider.
+Currently, the only supported FORMAT is "uri".
+If FILE is not provided, it will print to the standard output.
+The list of exported issuers are printed in order by its label.
+
gen <LABEL> [N]
Generate N number passwords using the secret identified by LABEL.
diff --git a/_AUR/PKGBUILD b/_AUR/PKGBUILD
index 86afab5..4cc671b 100644
--- a/_AUR/PKGBUILD
+++ b/_AUR/PKGBUILD
@@ -3,7 +3,7 @@
## SPDX-License-Identifier: GPL-3.0-or-later
pkgname=gotp-git
-pkgver=0.4.0.r7.gf5788bd
+pkgver=0.5.0.r5.g58c64c0
pkgrel=1
pkgdesc="A command line interface to manage and generate Time-based One Time Password (TOTP)"
diff --git a/_sys/usr/share/bash-completion/completions/gotp b/_sys/usr/share/bash-completion/completions/gotp
index 6262172..444acc7 100644
--- a/_sys/usr/share/bash-completion/completions/gotp
+++ b/_sys/usr/share/bash-completion/completions/gotp
@@ -15,8 +15,9 @@ suggest_key() {
_gotp_completions()
{
- commands=("add" "gen" "get" "import" "list" "remove"
+ local commands=("add" "export" "gen" "get" "import" "list" "remove"
"remove-private-key" "rename" "set-private-key")
+ local formats=("uri")
local len=${#COMP_WORDS[@]}
local cmd=${COMP_WORDS[1]}
@@ -25,6 +26,16 @@ _gotp_completions()
case "$cmd" in
add)
;;
+ export)
+ if [[ $len == 3 ]]; then
+ if [[ -z $key ]]; then
+ COMPREPLY=("${formats[@]}")
+ else
+ list="${formats[@]}"
+ COMPREPLY=($(compgen -W "$list" -- "$key"))
+ fi
+ fi
+ ;;
gen)
if [[ $len == 3 ]]; then
suggest_key "$key"
diff --git a/cli.go b/cli.go
index a28f40a..a2bcbf5 100644
--- a/cli.go
+++ b/cli.go
@@ -7,6 +7,7 @@ import (
_ "embed"
"encoding/base32"
"fmt"
+ "io"
"os"
"path/filepath"
"sort"
@@ -72,6 +73,48 @@ func (cli *Cli) Add(issuer *Issuer) (err error) {
return nil
}
+// Export all the issuers and its secret to the file or standard output.
+// List of supported format: "uri".
+func (cli *Cli) Export(w io.Writer, formatName string) (err error) {
+ var logp = `Export`
+
+ formatName = strings.ToLower(formatName)
+ switch formatName {
+ case formatNameURI:
+ default:
+ return fmt.Errorf(`%s: unknown format name %q`, logp, formatName)
+ }
+
+ err = cli.cfg.loadPrivateKey()
+ if err != nil {
+ return fmt.Errorf(`%s: %w`, logp, err)
+ }
+
+ var (
+ labels = cli.List()
+ issuers = make([]*Issuer, 0, len(labels))
+
+ label string
+ issuer *Issuer
+ )
+ for _, label = range labels {
+ issuer, err = cli.cfg.get(label)
+ if err != nil {
+ return fmt.Errorf(`%s: %w`, logp, err)
+ }
+ issuers = append(issuers, issuer)
+ }
+
+ if formatName == formatNameURI {
+ err = exportAsURI(w, issuers)
+ if err != nil {
+ return fmt.Errorf(`%s: %w`, logp, err)
+ }
+ }
+
+ return nil
+}
+
// Generate n number of OTP from given issuer name.
func (cli *Cli) Generate(label string, n int) (listOtp []string, err error) {
var (
diff --git a/cli_test.go b/cli_test.go
index ef6a07c..485cad5 100644
--- a/cli_test.go
+++ b/cli_test.go
@@ -10,6 +10,7 @@ import (
"io/fs"
"os"
"path/filepath"
+ "strings"
"testing"
"git.sr.ht/~shulhan/pakakeh.go/lib/test"
@@ -90,6 +91,37 @@ test = SHA1:x:6:30:
}
}
+func TestCli_Export(t *testing.T) {
+ var (
+ tdata *test.Data
+ err error
+ )
+
+ tdata, err = test.LoadData(`testdata/cli_Export_test.txt`)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var cli = &Cli{
+ cfg: &config{
+ Issuers: map[string]string{
+ `test l@bel`: `SHA1:s3cr3t:6:30:IssuerName`,
+ },
+ },
+ }
+
+ var sb = strings.Builder{}
+
+ err = cli.Export(&sb, formatNameURI)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var exp = string(tdata.Output[`uri`])
+
+ test.Assert(t, `Export: uri`, exp, sb.String())
+}
+
func TestCli_SetPrivateKey(t *testing.T) {
var (
tdata *test.Data
@@ -265,6 +297,10 @@ func TestCli_withPassphrase(t *testing.T) {
testAddWithPassphrase(t, tdata, cli)
})
+ t.Run(`Export`, func(t *testing.T) {
+ testExportWithPassphrase(t, tdata, cli)
+ })
+
t.Run(`Generate`, func(t *testing.T) {
testGenerateWithPassphrase(t, tdata, cli)
})
@@ -330,6 +366,24 @@ func testAddWithPassphrase(t *testing.T, tdata *test.Data, cli *Cli) {
mockTermrw.BufRead.Reset()
}
+func testExportWithPassphrase(t *testing.T, tdata *test.Data, cli *Cli) {
+ var pass = string(tdata.Input[`gotp.pass`]) + "\r\n"
+ mockTermrw.BufRead.WriteString(pass)
+
+ t.Cleanup(mockTermrw.BufRead.Reset)
+
+ var got = strings.Builder{}
+
+ var err = cli.Export(&got, formatNameURI)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var exp = string(tdata.Output[`gotp.conf:export`])
+
+ test.Assert(t, `testExportWithPassphrase`, exp, got.String())
+}
+
func testGenerateWithPassphrase(t *testing.T, tdata *test.Data, cli *Cli) {
type testCase struct {
label string
diff --git a/cmd/gotp/main.go b/cmd/gotp/main.go
index 24e94e3..1ae7587 100644
--- a/cmd/gotp/main.go
+++ b/cmd/gotp/main.go
@@ -20,6 +20,7 @@ import (
const (
cmdName = `gotp`
cmdAdd = `add`
+ cmdExport = `export`
cmdGenerate = `gen`
cmdGet = `get`
cmdImport = `import`
@@ -64,6 +65,11 @@ func main() {
log.Printf(`%s %s: missing parameters`, cmdName, cmd)
os.Exit(1)
}
+ case cmdExport:
+ if len(args) < 2 {
+ log.Fatalf(`%s %s: missing parameter: format`, cmdName, cmd)
+ }
+
case cmdGenerate:
if len(args) < 2 {
log.Printf(`%s %s: missing parameters`, cmdName, cmd)
@@ -106,8 +112,7 @@ func main() {
return
default:
- log.Printf(`%s: unknown command %q`, cmdName, cmd)
- flag.Usage()
+ log.Fatalf(`%s: unknown command %q`, cmdName, cmd)
}
var userConfigDir string
@@ -128,6 +133,8 @@ func main() {
switch cmd {
case cmdAdd:
doAdd(cli, args)
+ case cmdExport:
+ doExport(cli, flag.Arg(1), flag.Arg(2))
case cmdGenerate:
doGenerate(cli, args)
case cmdGet:
@@ -169,6 +176,39 @@ func doAdd(cli *gotp.Cli, args []string) {
fmt.Println(`OK`)
}
+func doExport(cli *gotp.Cli, providerName string, exportFile string) {
+ var (
+ out *os.File
+ err error
+ )
+
+ if len(exportFile) == 0 {
+ out = os.Stdout
+ } else {
+ out, err = os.OpenFile(exportFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
+ if err != nil {
+ log.Fatalf(`export: %s`, err)
+ }
+ }
+
+ err = cli.Export(out, providerName)
+ if err != nil {
+ goto out
+ }
+
+ return
+out:
+ log.Printf(`export: %s`, err)
+
+ if len(exportFile) == 0 {
+ err = out.Close()
+ if err != nil {
+ log.Printf(`export: %s`, err)
+ }
+ }
+ os.Exit(1)
+}
+
func doGenerate(cli *gotp.Cli, args []string) {
var (
label = args[1]
diff --git a/gotp.go b/gotp.go
index 3c597d0..e913b8d 100644
--- a/gotp.go
+++ b/gotp.go
@@ -5,7 +5,9 @@
package gotp
import (
+ "fmt"
"io"
+ "net/url"
"strings"
"time"
"unicode"
@@ -29,6 +31,11 @@ const (
providerNameAegis = `aegis`
)
+// List of known format for export.
+const (
+ formatNameURI = `uri`
+)
+
// Version define the latest version of this module and gotp CLI.
var Version = `0.5.0`
@@ -60,3 +67,38 @@ func normalizeLabel(in string) (out string) {
}
return strings.ToLower(buf.String())
}
+
+// exportAsURI export the list of issuers using [URI] format.
+//
+// [URI]: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
+func exportAsURI(w io.Writer, issuers []*Issuer) (err error) {
+ var (
+ logp = `exportAsURI`
+ issuer *Issuer
+ )
+
+ for _, issuer = range issuers {
+ var q = url.Values{}
+
+ q.Set(`algorithm`, issuer.Hash)
+ q.Set(`secret`, issuer.Secret)
+ if len(issuer.Name) == 0 {
+ q.Set(`issuer`, issuer.Label)
+ } else {
+ q.Set(`issuer`, issuer.Name)
+ }
+
+ var otpauth = url.URL{
+ Scheme: `otpauth`,
+ Host: `totp`,
+ Path: issuer.Label,
+ RawQuery: q.Encode(),
+ }
+
+ _, err = w.Write([]byte(otpauth.String() + "\n"))
+ if err != nil {
+ return fmt.Errorf(`%s: %w`, logp, err)
+ }
+ }
+ return nil
+}
diff --git a/provider_aegis.go b/provider_aegis.go
index bdc71ec..2429b86 100644
--- a/provider_aegis.go
+++ b/provider_aegis.go
@@ -11,6 +11,13 @@ import (
"strconv"
)
+// parseProviderAegis parse the [Aegis Authenticator] exported file in the
+// URI format.
+// The URI scheme has the following format,
+//
+// otpauth://totp/<LABEL>?period=&digits=&algorithm=&secret=&issuer=
+//
+// [Aegis Authenticator]: https://getaegis.app/
func parseProviderAegis(file string) (issuers []*Issuer, err error) {
var (
logp = `parseProviderAegis`
diff --git a/testdata/cli_Export_test.txt b/testdata/cli_Export_test.txt
new file mode 100644
index 0000000..51ae320
--- /dev/null
+++ b/testdata/cli_Export_test.txt
@@ -0,0 +1,3 @@
+
+<<< uri
+otpauth://totp/test%20l@bel?algorithm=SHA1&issuer=IssuerName&secret=s3cr3t
diff --git a/testdata/cli_with_passphrase_test.txt b/testdata/cli_with_passphrase_test.txt
index 6eae769..6970956 100644
--- a/testdata/cli_with_passphrase_test.txt
+++ b/testdata/cli_with_passphrase_test.txt
@@ -57,6 +57,11 @@ test-sha1 = GKhXh+9sSkfRjpVfu8o1dIAPTyahjHpGZxfSTqFJIUnAJj3HpQGa0UO3QNQ4qN66CYRa
test-sha256 = e6BCkqDD0Su15P1PPyZz6PXiYhLQ+imlhpy99+r5xM+hbL6MfZ71JaXiXw5+1JJ5oM+yqV0ejzg6pW2U9yYu2M2QfDv7BxY8rV5+8fchKgWoZ7t3cfX9VX2OXKbjnZLMrO5vYFvk8jUfeemFzy9UvoCUfYdPq3V9/2IUStBgyWNRwOmHq6ImVc5/4YoMCqXvQ/rxUl/NCujF3qQvQPLCL2Abm/lQRdWiQDzEB8+tn40iax1XoGK4dYTeuJJX7tYwv2cvQctbjYJcb+9cA+AroHW0TuyBWt37iII1rCvIA9pBb45U17Aj74Xj1vH9/WamLWLAX9bfLZwgzl2/Qa7c86jgw5jlfPcVUvxtFOJSIS/2cudxD9j8EOg4cAzySry8WP+ZPxnVqI+I4ZqSVFOtV5uSXuTiVPXCv1gtVl44ChwJw38LVBztADffM6Iqp1WjeSbASFwzDvEZmR/7qqeSgPeem/k9oPKfAJwi251oXdj2gJxG1R+JaxVjNVs1qf75
test-sha512 = XDHGL4xQp3VCE4oIxKlBvKs9xwStDPaAe52RTXL3uiXU+RKH+w2Pgh9hl6O1mjyL4oSRJ872po9jLKxAk2OkOMINRbb601GaiuLY4Xhb/lOMQek0jCK0NDweMtodt5EAoMhSJ0styclEucHQWFLIFGwotfYTTjJYjombRFfG5CqsMB6XFQBvL5uvXpe2axJ+vyP3t0RuW/Rroovyn2lckZhyJGsHObyScC1sgdVoZhAeFDiihD6Cn1oLeiHrN9RviA8vPBZ1PV5+To2TLafJu+3InheeyIQWtBLVY8+dfVurYzpAHPi3rXc86FXIYaH9bI7muWWRIpN7lIb3RAZcYoXFdiXlvq07cd90FhsuCh9UuUzSEs1RtlsF2NhNobpP2xjMhdO+4LAaTqfew6snUUN/+G6lUDOBUeNp9HPzzMAlZG/eZ7y0u1dsd9vYwEr24ivDO+i66R4d9vmbCIohzHqc5HbSqZxi5k4H4mVnJTadC2fMrsp/nQaFtKjjX/VM
+<<< gotp.conf:export
+otpauth://totp/test-sha1?algorithm=SHA1&issuer=test-sha1&secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
+otpauth://totp/test-sha256?algorithm=SHA256&issuer=test-sha256&secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
+otpauth://totp/test-sha512?algorithm=SHA512&issuer=test-sha512&secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
+
<<< get:test-sha1
SHA1:GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ:6:30: