diff options
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | _AUR/PKGBUILD | 2 | ||||
| -rw-r--r-- | _sys/usr/share/bash-completion/completions/gotp | 13 | ||||
| -rw-r--r-- | cli.go | 43 | ||||
| -rw-r--r-- | cli_test.go | 54 | ||||
| -rw-r--r-- | cmd/gotp/main.go | 44 | ||||
| -rw-r--r-- | gotp.go | 42 | ||||
| -rw-r--r-- | provider_aegis.go | 7 | ||||
| -rw-r--r-- | testdata/cli_Export_test.txt | 3 | ||||
| -rw-r--r-- | testdata/cli_with_passphrase_test.txt | 5 |
10 files changed, 216 insertions, 4 deletions
@@ -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" @@ -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] @@ -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: |
