diff options
| author | Shulhan <ms@kilabit.info> | 2021-10-23 01:55:51 +0700 |
|---|---|---|
| committer | Shulhan <ms@kilabit.info> | 2021-10-27 22:21:17 +0700 |
| commit | c814416265b73823c215c04f5656471b84116e19 (patch) | |
| tree | 8f89e8abc7d84e5621cfd882a6c11659767096f5 | |
| download | gotp-c814416265b73823c215c04f5656471b84116e19.tar.xz | |
gotp: command line interface for Time-based One Time Password (TOTP)
The gotp currently has the following features,
* add: add new TOTP issuer with their label and secret
* gen: generate password
* import: import TOTP from other provider, currently support Aegis
* list: print all registered TOTP configuration by labels
* remove: delete a TOTP configuration from file by label
* rename: changes the TOTP configuration by its label
| -rw-r--r-- | .gitignore | 5 | ||||
| -rw-r--r-- | LICENSE | 27 | ||||
| -rw-r--r-- | Makefile | 7 | ||||
| -rw-r--r-- | README.adoc | 100 | ||||
| -rw-r--r-- | cli.go | 305 | ||||
| -rw-r--r-- | cli_test.go | 112 | ||||
| -rw-r--r-- | cmd/gotp/main.go | 178 | ||||
| -rw-r--r-- | config.go | 126 | ||||
| -rw-r--r-- | config_test.go | 43 | ||||
| -rw-r--r-- | go.mod | 15 | ||||
| -rw-r--r-- | go.sum | 17 | ||||
| -rw-r--r-- | gotp.go | 46 | ||||
| -rw-r--r-- | issuer.go | 146 | ||||
| -rw-r--r-- | provider_aegis.go | 58 | ||||
| -rw-r--r-- | testdata/ed25519 | 7 | ||||
| -rw-r--r-- | testdata/ed25519.conf | 6 | ||||
| -rw-r--r-- | testdata/ed25519.pub | 1 | ||||
| -rw-r--r-- | testdata/rsa | 40 | ||||
| -rw-r--r-- | testdata/rsa.conf | 6 | ||||
| -rw-r--r-- | testdata/rsa.pub | 1 |
20 files changed, 1246 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b93693 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +README.html +cover.html +cover.out +testdata/add.conf +testdata/save.conf @@ -0,0 +1,27 @@ +Copyright (c) 2021 M. Shulhan (ms@kilabit.info). All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of M. Shulhan, nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..074ea4b --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: all install + +all: + go test -race -failfast ./... + +install: + go install ./cmd/gotp diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..6f95f0e --- /dev/null +++ b/README.adoc @@ -0,0 +1,100 @@ += gotp +Shulhan <ms@kilabit.info> + +A command line interface to manage and generate Time-based One Time Password +(TOTP). + +== SYNOPSIS + + gotp <command> <parameters...> + +== DESCRIPTION + +add <LABEL> <HASH>:<BASE32-SECRET>[:DIGITS][:TIME-STEP][:ISSUER] + + Add a TOTP secret identified by unique LABEL. + HASH is one of the valid hash function: SHA1, SHA256, or + SHA512. + BASE32-SECRET is the secret to generate one-time password + encoded in base32. + The DIGITS field is optional, define the number digits + generated for password, default to 6. + The TIME-STEP field is optional, its define the interval in + seconds, default to 30 seconds. + The ISSUER field is also optional, its define the name of + provider that generate the secret. + +gen <LABEL> [N] + + Generate N number passwords using the secret identified by LABEL. + +import <PROVIDER> <FILE> + + Import the TOTP configuration from other provider. + Currently, the only supported PROVIDER is Aegis and the supported file + is .txt. + +list + + List all labels stored in the configuration. + +remove <LABEL> + + Remove LABEL from configuration. + +rename <LABEL> <NEW-LABEL> + + Rename a LABEL into NEW-LABEL. + +== ENCRYPTION + +On the first run, the gotp command will ask for path of private key. +If the key exist, all the OTP values (excluding the label) will be encrypted. +The private key must be RSA based. + +One can skip inputting the private key by pressing enter, and the OTP +configuration will be stored as plain text. + +== FILES + +$USER_CONFIG_DIR/gotp/gotp.conf:: Path to file where the configuration and +secret are stored. + +== EXAMPLES + +Add "my-totp" to configuration using SHA1 as hash function, "GEZDGNBVGY3TQOJQ" +as the secret, with 6 digits passwords, and 30 seconds as time step. + + $ gotp add my-totp SHA1:GEZDGNBVGY3TQOJQ:6:30 + +Generate 3 recents passwords from "my-totp", + + $ gotp gen my-totp 3 + gotp: reading configuration from /home/$USER/.config/gotp/gotp.conf + 847945 + 326823 + 767317 + +Import the exported Aegis TOTP from file, + + $ gotp import aegis aegis-export-uri.txt + gotp: reading configuration from /home/$USER/.config/gotp/gotp.conf + OK + +List all labels stored in the configuration, + + $ gotp list + gotp: reading configuration from /home/$USER/.config/gotp/gotp.conf + my-totp + +Remove a label "my-totp", + + $ gotp remove my-totp + gotp: reading configuration from /home/$USER/.config/gotp/gotp.conf + OK + +Rename a label "my-totp" to "my-otp", + + $ gotp rename my-totp my-otp + gotp: reading configuration from /home/$USER/.config/gotp/gotp.conf + OK @@ -0,0 +1,305 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package gotp + +import ( + "crypto/rsa" + _ "embed" + "encoding/base32" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/shuLhan/share/lib/totp" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/terminal" +) + +//go:embed README.adoc +var Readme string + +type Cli struct { + cfg *config +} + +func NewCli() (cli *Cli, err error) { + var ( + logp = "NewCli" + ) + + cli = &Cli{} + + userConfigDir, err := os.UserConfigDir() + if err != nil { + return nil, fmt.Errorf("%s: UserConfigDir: %w", logp, err) + } + + cfgFile := filepath.Join(userConfigDir, configDir, configFile) + + cli.cfg, err = newConfig(cfgFile) + if err != nil { + return nil, fmt.Errorf("%s: UserConfigDir: %w", logp, err) + } + + if cli.cfg.isNotExist { + cli.cfg.PrivateKey, err = cli.inputPrivateKey(os.Stdin) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + } + if len(cli.cfg.PrivateKey) > 0 { + cli.cfg.privateKey, err = cli.loadPrivateKey(cli.cfg.PrivateKey, nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + } + if cli.cfg.isNotExist { + err = cli.cfg.save() + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + } + + return cli, nil +} + +func (cli *Cli) inputPrivateKey(stdin *os.File) (privateKeyFile string, err error) { + fmt.Printf("Seems like this is your first time using this gotp.\n") + fmt.Printf("If you would like to encrypt the secret, please\n") + fmt.Printf("enter the path to private key or enter to skip it: ") + fmt.Fscanln(stdin, &privateKeyFile) + + return privateKeyFile, nil +} + +// +// loadPrivateKey parse the RSA private key with optional passphrase. +// +func (cli *Cli) loadPrivateKey(privateKeyFile string, pass []byte) ( + rsaPrivateKey *rsa.PrivateKey, err error, +) { + if len(privateKeyFile) == 0 { + return nil, nil + } + + var ( + logp = "loadPrivateKey" + privateKey interface{} + ok bool + ) + + rawPem, err := os.ReadFile(privateKeyFile) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + if len(pass) == 0 { + privateKey, err = ssh.ParseRawPrivateKey(rawPem) + } else { + privateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(rawPem, pass) + } + if err != nil { + errPassMissing := &ssh.PassphraseMissingError{} + if !errors.As(err, &errPassMissing) { + return nil, fmt.Errorf("%s %q: %w", logp, privateKeyFile, err) + } + + fmt.Printf("Enter passphrase for %s: ", privateKeyFile) + + stdin := os.Stdin.Fd() + pass, err := terminal.ReadPassword(int(stdin)) + fmt.Printf("\n") + if err != nil { + return nil, fmt.Errorf("%s %q: %w", logp, privateKeyFile, err) + } + + return cli.loadPrivateKey(privateKeyFile, pass) + } + rsaPrivateKey, ok = privateKey.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("%s: invalid or unsupported private key", logp) + } + + return rsaPrivateKey, nil +} + +// +// Add new issuer to the config. +// +func (cli *Cli) Add(issuer *Issuer) (err error) { + if issuer == nil { + return nil + } + + logp := "Add" + + err = cli.add(issuer) + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + err = cli.cfg.save() + 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 ( + logp = "Generate" + cryptoHash totp.CryptoHash + ) + + issuer, err := cli.cfg.get(label) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + secret, err := base32.StdEncoding.DecodeString(issuer.Secret) + if err != nil { + return nil, fmt.Errorf("%s: secret is not a valid base32 encoding: %w", logp, err) + } + + switch issuer.Hash { + case HashSHA256: + cryptoHash = totp.CryptoHashSHA256 + case HashSHA512: + cryptoHash = totp.CryptoHashSHA512 + default: + cryptoHash = totp.CryptoHashSHA1 + } + + proto := totp.New(cryptoHash, issuer.Digits, issuer.TimeStep) + + listOtp, err = proto.GenerateN(secret, n) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + return listOtp, nil +} + +// +// Import the TOTP configuration from file format based on provider. +// +func (cli *Cli) Import(providerName, file string) (n int, err error) { + logp := "Import" + + providerName = strings.ToLower(providerName) + switch providerName { + case providerNameAegis: + default: + return 0, fmt.Errorf("%s: unknown provider %q", logp, providerName) + } + + issuers, err := parseProviderAegis(file) + if err != nil { + return 0, fmt.Errorf("%s: %w", logp, err) + } + + for _, issuer := range issuers { + err = issuer.validate() + if err != nil { + return 0, fmt.Errorf("%s: %w", logp, err) + } + + err = cli.cfg.add(issuer) + if err != nil { + return 0, fmt.Errorf("%s: %w", logp, err) + } + } + + err = cli.cfg.save() + if err != nil { + return 0, fmt.Errorf("%s: %w", logp, err) + } + + return len(issuers), nil +} + +// +// List all labels sorted in ascending order. +// +func (cli *Cli) List() (labels []string) { + for label := range cli.cfg.Issuers { + labels = append(labels, label) + } + sort.Strings(labels) + return labels +} + +// +// Remove a TOTP configuration by its label. +// +func (cli *Cli) Remove(label string) (err error) { + logp := "Remove" + + label = strings.ToLower(label) + _, ok := cli.cfg.Issuers[label] + if !ok { + return fmt.Errorf("%s: %q not exist", logp, label) + } + + delete(cli.cfg.Issuers, label) + + err = cli.cfg.save() + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + return nil +} + +// +// Rename a label to newLabel. +// It will return an error if the label parameter is not exist or newLabel +// already exist. +// +func (cli *Cli) Rename(label, newLabel string) (err error) { + logp := "Rename" + + label = strings.ToLower(label) + rawValue, ok := cli.cfg.Issuers[label] + if !ok { + return fmt.Errorf("%s: %q not exist", logp, label) + } + + newLabel = strings.ToLower(newLabel) + _, ok = cli.cfg.Issuers[newLabel] + if ok { + return fmt.Errorf("%s: new label %q already exist", logp, newLabel) + } + + delete(cli.cfg.Issuers, label) + + cli.cfg.Issuers[newLabel] = rawValue + + err = cli.cfg.save() + if err != nil { + return fmt.Errorf("%s: %w", logp, err) + } + + return nil +} + +func (cli *Cli) add(issuer *Issuer) (err error) { + err = issuer.validate() + if err != nil { + return err + } + err = cli.cfg.add(issuer) + if err != nil { + return err + } + return nil +} diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..77eb865 --- /dev/null +++ b/cli_test.go @@ -0,0 +1,112 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package gotp + +import ( + "fmt" + "os" + "testing" + + "github.com/shuLhan/share/lib/test" +) + +func TestCli_inputPrivateKey(t *testing.T) { + cli := &Cli{ + cfg: &config{ + file: "testdata/save.conf", + isNotExist: true, + }, + } + + cases := []struct { + desc string + privateKey string + exp string + }{{ + desc: "Without private key", + exp: "[gotp]\nprivate_key =\n", + }, { + desc: "With private key", + privateKey: "testdata/rsa", + }} + + for _, c := range cases { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + fmt.Fprintf(w, "%s\n", c.privateKey) + + gotPrivateKeyFile, err := cli.inputPrivateKey(r) + if err != nil { + t.Fatal(err) + } + + test.Assert(t, cli.cfg.file, c.privateKey, gotPrivateKeyFile) + } +} + +func TestCli_Add(t *testing.T) { + cli := &Cli{ + cfg: &config{ + Issuers: make(map[string]string), + file: "testdata/add.conf", + }, + } + + err := cli.cfg.save() + if err != nil { + t.Fatal(err) + } + + cases := []struct { + desc string + issuer *Issuer + expError string + expConfig string + }{{ + desc: "With nil issuer", + expConfig: "[gotp]\nprivate_key =\n", + }, { + desc: "With invalid label", + issuer: &Issuer{ + Label: "Not@valid", + }, + expError: `Add: validate: invalid label "Not@valid"`, + }, { + desc: "With invalid hash", + issuer: &Issuer{ + Label: "Test", + Hash: "SHA255", + }, + expError: `Add: validate: invalid algorithm "SHA255"`, + }, { + desc: "With valid label", + issuer: &Issuer{ + Label: "Test", + Hash: HashSHA1, + Secret: "x", + }, + expConfig: "[gotp]\nprivate_key =\n\n[gotp \"issuer\"]\ntest = SHA1:x:6:30:\n", + }} + + for _, c := range cases { + t.Log(c.desc) + + err = cli.Add(c.issuer) + if err != nil { + test.Assert(t, "error", c.expError, err.Error()) + continue + } + + got, err := os.ReadFile(cli.cfg.file) + if err != nil { + t.Fatal(err) + } + + test.Assert(t, cli.cfg.file, c.expConfig, string(got)) + } +} diff --git a/cmd/gotp/main.go b/cmd/gotp/main.go new file mode 100644 index 0000000..254a188 --- /dev/null +++ b/cmd/gotp/main.go @@ -0,0 +1,178 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "flag" + "fmt" + "log" + "os" + "strconv" + "strings" + + "git.sr.ht/~shulhan/gotp" +) + +const ( + cmdName = "gotp" + cmdAdd = "add" + cmdGenerate = "gen" + cmdImport = "import" + cmdList = "list" + cmdRemove = "remove" + cmdRename = "rename" +) + +func main() { + log.SetFlags(0) + + flag.Usage = func() { + fmt.Println(gotp.Readme) + os.Exit(2) + } + flag.Parse() + + args := flag.Args() + if len(args) == 0 { + flag.Usage() + } + + cmd := strings.ToLower(args[0]) + + switch cmd { + case cmdAdd: + if len(args) < 3 { + log.Printf("%s %s: missing parameters", cmdName, cmd) + os.Exit(1) + } + case cmdGenerate: + if len(args) < 2 { + log.Printf("%s %s: missing parameters", cmdName, cmd) + os.Exit(1) + } + case cmdImport: + if len(args) <= 2 { + log.Printf("%s %s: missing parameters", cmdName, cmd) + os.Exit(1) + } + case cmdList: + // NOOP. + case cmdRemove: + if len(args) <= 1 { + log.Printf("%s %s: missing parameters", cmdName, cmd) + os.Exit(1) + } + case cmdRename: + if len(args) <= 2 { + log.Printf("%s %s: missing parameters", cmdName, cmd) + os.Exit(1) + } + default: + log.Printf("%s: unknown command %q", cmdName, cmd) + flag.Usage() + } + + cli, err := gotp.NewCli() + if err != nil { + log.Printf("%s: %s", cmdName, err) + os.Exit(1) + } + + switch cmd { + case cmdAdd: + doAdd(cli, args) + case cmdGenerate: + doGenerate(cli, args) + case cmdImport: + doImport(cli, args) + case cmdList: + doList(cli) + case cmdRemove: + doRemove(cli, args) + case cmdRename: + doRename(cli, args) + } +} + +func doAdd(cli *gotp.Cli, args []string) { + label := args[1] + rawConfig := args[2] + issuer, err := gotp.NewIssuer(label, rawConfig, nil) + if err != nil { + log.Printf("%s: %s", cmdName, err) + os.Exit(1) + } + err = cli.Add(issuer) + if err != nil { + log.Printf("%s: %s", cmdName, err) + os.Exit(1) + } + fmt.Println("OK") +} + +func doGenerate(cli *gotp.Cli, args []string) { + var ( + label = args[1] + n int = 1 + err error + ) + if len(args) >= 3 { + n, err = strconv.Atoi(args[2]) + if err != nil { + log.Printf("%s: %s", cmdName, err) + os.Exit(1) + } + } + listOtp, err := cli.Generate(label, n) + if err != nil { + log.Printf("%s: %s", cmdName, err) + os.Exit(1) + } + for _, otp := range listOtp { + fmt.Println(otp) + } +} + +func doImport(cli *gotp.Cli, args []string) { + var ( + providerName = args[1] + file = args[2] + ) + n, err := cli.Import(providerName, file) + if err != nil { + log.Printf("%s: %s", cmdName, err) + os.Exit(1) + } + fmt.Printf("OK - %d imported", n) +} + +func doList(cli *gotp.Cli) { + labels := cli.List() + for _, label := range labels { + fmt.Println(label) + } +} + +func doRemove(cli *gotp.Cli, args []string) { + label := args[1] + err := cli.Remove(label) + if err != nil { + log.Printf("%s: %s", cmdName, err) + os.Exit(1) + } + fmt.Println("OK") +} + +func doRename(cli *gotp.Cli, args []string) { + label := args[1] + newLabel := args[2] + + err := cli.Rename(label, newLabel) + if err != nil { + log.Printf("%s: %s", cmdName, err) + os.Exit(1) + } + fmt.Println("OK") +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..d472cee --- /dev/null +++ b/config.go @@ -0,0 +1,126 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package gotp + +import ( + "crypto/rsa" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/shuLhan/share/lib/ini" +) + +const ( + valueSeparator = ":" +) + +type config struct { + PrivateKey string `ini:"gotp::private_key"` + Issuers map[string]string `ini:"gotp:issuer"` + + file string + isNotExist bool + privateKey *rsa.PrivateKey // Only RSA private key can do encryption. +} + +func newConfig(file string) (cfg *config, err error) { + logp := "newConfig" + + cfg = &config{ + Issuers: make(map[string]string), + file: file, + } + + in, err := ini.Open(file) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("%s: Open %q: %w", logp, file, err) + } + cfg.isNotExist = true + } + + if cfg.isNotExist { + dir := filepath.Dir(file) + err = os.MkdirAll(dir, 0700) + if err != nil { + return nil, fmt.Errorf("%s: MkdirAll %q: %w", logp, dir, err) + } + return cfg, nil + } + + err = in.Unmarshal(cfg) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + return cfg, nil +} + +func (cfg *config) add(issuer *Issuer) (err error) { + var ( + value string + exist bool + ) + + if issuer == nil { + return nil + } + + _, exist = cfg.Issuers[issuer.Label] + if exist { + return fmt.Errorf("duplicate issuer name %q", issuer.Label) + } + + value, err = issuer.pack(cfg.privateKey) + if err != nil { + return err + } + + cfg.Issuers[issuer.Label] = value + + return nil +} + +// +// get the issuer by its name. +// +func (cfg *config) get(name string) (issuer *Issuer, err error) { + logp := "get" + + name = strings.ToLower(name) + + v, ok := cfg.Issuers[name] + if !ok { + return nil, fmt.Errorf("%s: issuer %q not found", logp, name) + } + + issuer, err = NewIssuer(name, v, cfg.privateKey) + if err != nil { + return nil, fmt.Errorf("%s %q: %w", logp, name, err) + } + + return issuer, nil +} + +// save the config to file. +func (cfg *config) save() (err error) { + logp := "save" + + b, err := ini.Marshal(cfg) + if err != nil { + return fmt.Errorf("%s %s: %w", logp, cfg.file, err) + } + + err = os.WriteFile(cfg.file, b, 0600) + if err != nil { + return fmt.Errorf("%s %s: %w", logp, cfg.file, err) + } + + return nil +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..883882f --- /dev/null +++ b/config_test.go @@ -0,0 +1,43 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package gotp + +import ( + "testing" + + "github.com/shuLhan/share/lib/test" +) + +func TestNewConfig(t *testing.T) { + cases := []struct { + desc string + configFile string + expConfig *config + expError string + }{{ + desc: "With openssh rsa", + configFile: "testdata/rsa.conf", + expConfig: &config{ + PrivateKey: "testdata/rsa", + Issuers: map[string]string{ + "email-domain": "XYZ", + "test": "ABCD", + }, + file: "testdata/rsa.conf", + }, + }} + + for _, c := range cases { + t.Log(c.desc) + + gotConfig, err := newConfig(c.configFile) + if err != nil { + test.Assert(t, "error", c.expError, err.Error()) + continue + } + + test.Assert(t, "Issuer", c.expConfig, gotConfig) + } +} @@ -0,0 +1,15 @@ +module git.sr.ht/~shulhan/gotp + +go 1.17 + +require ( + github.com/shuLhan/share v0.30.1-0.20211026162333-a7fd63a0836f + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 +) + +require ( + golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect +) + +//replace github.com/shuLhan/share => ../share @@ -0,0 +1,17 @@ +github.com/shuLhan/share v0.30.1-0.20211026162333-a7fd63a0836f h1:6NhrAx3jJXTsNNnn97RTk2HFjUbR9i2nGBDStAIRwCg= +github.com/shuLhan/share v0.30.1-0.20211026162333-a7fd63a0836f/go.mod h1:1E7VQSKC7cbCmAi6izvm2S8jH5Z98a9SSS2IlvmNs/Y= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211007125505-59d4e928ea9d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac h1:oN6lz7iLW/YC7un8pq+9bOLyXrprv2+DKfkJY+2LJJw= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -0,0 +1,46 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package gotp + +import ( + "strings" + "unicode" +) + +// List of available algorithm for Provider. +const ( + HashSHA1 = "SHA1" // Default algorithm. + HashSHA256 = "SHA256" + HashSHA512 = "SHA512" +) + +const ( + configDir = "gotp" + configFile = "gotp.conf" + defaultHash = HashSHA1 + + // List of known providers + providerNameAegis = "aegis" +) + +// +// normalizeLabel convert non alpha number, hyphen, underscore, or period +// characters into "-". +// +func normalizeLabel(in string) (out string) { + var ( + buf strings.Builder + replacement rune = '-' + ) + for _, r := range in { + if unicode.IsLetter(r) || unicode.IsDigit(r) || + r == '-' || r == '_' || r == '.' { + buf.WriteRune(r) + } else { + buf.WriteRune(replacement) + } + } + return strings.ToLower(buf.String()) +} diff --git a/issuer.go b/issuer.go new file mode 100644 index 0000000..494d51d --- /dev/null +++ b/issuer.go @@ -0,0 +1,146 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package gotp + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "fmt" + "strconv" + "strings" + + "github.com/shuLhan/share/lib/ini" + "github.com/shuLhan/share/lib/totp" +) + +// +// Issuer contains the configuration for single TOTP issuer, including +// their unique label, algorithm, secret key, and number of digits. +// +type Issuer struct { + Label string + Hash string + Secret string // The secret value in base32. + Digits int + TimeStep int + Name string + raw []byte +} + +// +// NewIssuer create and initialize new issuer from raw value. +// If the rsaPrivateKey is not nil, that means the rawConfig is encrypted. +// +func NewIssuer(label, rawConfig string, rsaPrivateKey *rsa.PrivateKey) (issuer *Issuer, err error) { + logp := "NewIssuer" + + if rsaPrivateKey != nil { + cipherText, err := base64.StdEncoding.DecodeString(rawConfig) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + raw, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, rsaPrivateKey, cipherText, nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + rawConfig = string(raw) + } + + vals := strings.Split(rawConfig, valueSeparator) + if len(vals) < 2 { + return nil, fmt.Errorf("%s: invalid value %q", logp, rawConfig) + } + issuer = &Issuer{ + Label: label, + Hash: vals[0], + Secret: vals[1], + } + if len(vals) >= 3 { + issuer.Digits, err = strconv.Atoi(vals[2]) + if err != nil { + return nil, fmt.Errorf("%s: invalid digits %s: %w", logp, vals[2], err) + } + } else { + issuer.Digits = totp.DefCodeDigits + } + if len(vals) >= 4 { + issuer.TimeStep, err = strconv.Atoi(vals[3]) + if err != nil { + return nil, fmt.Errorf("%s: invalid time step %s: %w", logp, vals[3], err) + } + } else { + issuer.TimeStep = totp.DefTimeStep + } + if len(vals) >= 5 { + issuer.Name = vals[4] + } + + return issuer, nil +} + +func (issuer *Issuer) String() string { + return fmt.Sprintf("%s:%s:%d:%d:%s", issuer.Hash, issuer.Secret, + issuer.Digits, issuer.TimeStep, issuer.Name) +} + +// +// pack the Issuer into string separated by ":". +// If the privateKey is not nil, the string will be encrypted and encoded to +// base64. +// +func (issuer *Issuer) pack(privateKey *rsa.PrivateKey) (value string, err error) { + var ( + logp = "pack" + plainText = issuer.String() + ) + + issuer.raw = []byte(plainText) + if privateKey == nil { + return string(issuer.raw), nil + } + + rng := rand.Reader + issuer.raw, err = rsa.EncryptOAEP(sha256.New(), rng, &privateKey.PublicKey, issuer.raw, nil) + if err != nil { + return "", fmt.Errorf("%s: %w", logp, err) + } + + value = base64.StdEncoding.EncodeToString(issuer.raw) + + return value, nil +} + +func (issuer *Issuer) validate() (err error) { + logp := "validate" + + if !ini.IsValidVarName(issuer.Label) { + return fmt.Errorf("%s: invalid label %q", logp, issuer.Label) + } + issuer.Hash = strings.ToUpper(issuer.Hash) + switch issuer.Hash { + case "": + issuer.Hash = defaultHash + case HashSHA1, HashSHA256, HashSHA512: + // NOOP + default: + return fmt.Errorf("%s: invalid algorithm %q", logp, issuer.Hash) + } + + if len(issuer.Secret) == 0 { + return fmt.Errorf("%s: empty key", logp) + } + if issuer.Digits <= 0 { + issuer.Digits = totp.DefCodeDigits + } + if issuer.TimeStep <= 0 { + issuer.TimeStep = totp.DefTimeStep + } + + return nil +} diff --git a/provider_aegis.go b/provider_aegis.go new file mode 100644 index 0000000..040da0a --- /dev/null +++ b/provider_aegis.go @@ -0,0 +1,58 @@ +// Copyright 2021, Shulhan <ms@kilabit.info>. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package gotp + +import ( + "bytes" + "fmt" + "net/url" + "os" + "strconv" +) + +func parseProviderAegis(file string) (issuers []*Issuer, err error) { + logp := "parseProviderAegis" + + b, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("%s: %w", logp, err) + } + + lines := bytes.Split(b, []byte("\n")) + for x, line := range lines { + u, err := url.Parse(string(line)) + if err != nil { + return nil, fmt.Errorf("%s: line %d: invalid format %q", logp, x, line) + } + if u.Host != "totp" { + continue + } + + q := u.Query() + issuer := &Issuer{ + Label: normalizeLabel(u.Path[1:]), + Hash: q.Get("algorithm"), + Secret: q.Get("secret"), + Name: q.Get("issuer"), + } + + val := q.Get("digits") + issuer.Digits, err = strconv.Atoi(val) + if err != nil { + return nil, fmt.Errorf("%s: line %d: invalid digits %q", + logp, x, val) + } + + val = q.Get("period") + issuer.TimeStep, err = strconv.Atoi(val) + if err != nil { + return nil, fmt.Errorf("%s: line %d: invalid period %q", + logp, x, val) + } + + issuers = append(issuers, issuer) + } + return issuers, nil +} diff --git a/testdata/ed25519 b/testdata/ed25519 new file mode 100644 index 0000000..469e336 --- /dev/null +++ b/testdata/ed25519 @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAwxCC/tMzx9RQoAbUFrpC6t5U3gd3rkKOhOK1tf0gb/AAAAJAt659XLeuf +VwAAAAtzc2gtZWQyNTUxOQAAACAwxCC/tMzx9RQoAbUFrpC6t5U3gd3rkKOhOK1tf0gb/A +AAAEAlYl1GVMm0TAqs1Db2/pT7UbZ+ZAH8AQERU7Uo85bjZzDEIL+0zPH1FCgBtQWukLq3 +lTeB3euQo6E4rW1/SBv8AAAACm1zQGluc3Bpcm8BAgM= +-----END OPENSSH PRIVATE KEY----- diff --git a/testdata/ed25519.conf b/testdata/ed25519.conf new file mode 100644 index 0000000..a186b81 --- /dev/null +++ b/testdata/ed25519.conf @@ -0,0 +1,6 @@ +[totp] +private_key = testdata/ed25519 + +[totp "issuer"] +test = ABCD +email-domain = XYZ diff --git a/testdata/ed25519.pub b/testdata/ed25519.pub new file mode 100644 index 0000000..0bad3e9 --- /dev/null +++ b/testdata/ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDDEIL+0zPH1FCgBtQWukLq3lTeB3euQo6E4rW1/SBv8 ms@inspiro diff --git a/testdata/rsa b/testdata/rsa new file mode 100644 index 0000000..930e049 --- /dev/null +++ b/testdata/rsa @@ -0,0 +1,40 @@ +-----BEGIN PRIVATE KEY----- +MIIG/wIBADANBgkqhkiG9w0BAQEFAASCBukwggblAgEAAoIBgQCuU+71RwY0BVtB +gcePPz4d/cvig0tOgMzM4FyQ03cu2pe7CiewjUKGF5PCRp7buN07H1UnVp/SSAY0 +PlEmFrZ9qTCjw0Jy3FQxkcew/ia6KUMZ/qNsS3+fgedpuAXEWuddFC2fri3zRw/W +Io5kWcfpv93ILP3lvZKdezOPvowYMCT6T9Wkd0n6+5gPFCgUlz/Qb9K5+jui5YyW +O/r+8+3tBMkpKSWqklUwIRD7lFsyksf6DLWjEtpSkqBQJLl/RyQ6fvroQAziKR/7 +mp2Z8z3VWcm1iTGzEibSXFSqUOAeTrwkK4MzOwQbcZ1AX5kPQGdhwZnhB4NG3Ryu +SEj5slbS3AiKa7u7jPDmAotove16wt25jcfIacmlTr5f61w7YgJ//7+av4yOX1DW +4FhnUwwBQfhmLAmacymRCOEdNjRP28xAVE9SKFO5mEExmTUt+1GTP6lAesRcnVu6 +961OI1poTRkG/Hife1hWaD5uLm19v/gORlwlnmLAVMgcSeL4ZQ8CAwEAAQKCAYEA +rKhKurmOhkVr3ZR5HwJHNpMgtQbOtkDRFnV8mKAooco0jzZ+mtk9sut1F+yz9/C/ +hIgC4cRk5HMbWfEClFPYiNriZr/Ed1iLNtEo077UgnrNj9ho6aBZFZUmqsltRM23 +6rNKgKWVsyaFo9Nz7iYR3wx9z33oNfutU7YrGkpiHK7KYPRjJ9JR/nfjYcPX8pTe +ykWGk1YobK0nscBUVuEnnCGqTs8HFEp8fv+w/0svjAZec+TUnV/VxgSdLONCqbG9 +qGN9b71qggws1Om5RLtzqzQUZ//ZQp9PaCB6SXXxuYvs14iEMaJbuLsd3mrV+KEr +/KD7vS7Qcuandyk/t1r9D1C4Jd3/K1g28MLIyUaH9ArmGZVDIm4law+UqXNAzYyB +cG9YDP+H9Osu9bk2HpzpFIdAN+pAprqLy2sOjZYk3OqyJert9uKZhih0YV4barQG +mXwZh4PIlJFbL5bYvsjPeyC5YLCIYkPpM7bRRFeeKXzFUgWmdjSO7dZK+r1vhl9x +AoHBANlf/k+cC7C/SvkaGeQL5wJwuYqL6CAnegz9owiTEJ/ZyfbXCsTxBnrwvWsr +ihqGhDN1mvCMU4U7wkw1y0lLKhlEG7GkFuMzXlLuVrN7hIdN5bzMTVWpfAEkiKlE +ZhrqswVfjvqo9shZRjeLsWPBcTU5/GCKeyoO/stV9VOxW37W+hsCuNwUQ81yWr6C +OIdqgGA3lS+3ikawwlme433N0S3u1fz7LkiwYzIsAKvpJSStY3tdSh10sv35hQ3Q +ylibFwKBwQDNTc1u5epgEGgbsKZsd8IjFrBOHxQTatSfiiep44izlGfXDc2fy7Dv +lLbTO4bb7gM6/h7WcNrR4xD2byW/aTpWocR9WZE06ONM0WWHxrG3GUlLacPm9qbn +lVVcIVaZ84vFWm5Iwx9mp1G/oJUj9+23rtDhzleQAWZYpPimYRXHRGC+9tJFwSRs +momU0Bg8USX1HLmeoF6eDA6srkPUjE0YJj9XiUVPrj5Y6DoB7EvlqPoe08lrduCu +yRtGAK9yYMkCgcEAwLHHzuqVsmjhHVF2AiJK9m7XC2paq6ZTG5D8JW25HvsBfj/C +3DKNfA5M2+QM2NbF+pgWcYbzwvfmlYhaXO60sxEdO6wqXd37j6iYUyL7qLX1Ihg5 +teY4dwrb2rE3kkTbzbeYF7wQiCobhMHgzn18zaJJh4s7A12noLjicP6YDSilLayM +EwwjzsvAhYEKe4B9rM2ZAmPBwEiRAMFJCQyakg1WxJwlu2ohhShcsAhNVOYfegyI +1vPMeDfpKwYbWdChAoHALCUqo0n016svd6TIZFJsADeEuYedPX+fMJ74YGN4pYSn +v2pMaKvX4+xKU6ldGjVXpHu4Dcw+gRseLp8/sqTh0nb+VSXJP2NEXOQ5vLRQylo5 +lGmtoiAvS2Sk8iaJBJmF2G3VgPfT7LLVtSrGZvGMwA5rA+LmmSRz6WOvw7bkg/CE +DQvtsuoQ9vlT1Bfa3j1kuAvxLda2Aa3+Cct+8lkoyqSOwjfWG4gQB/YHYxp4R23K +Oo63pM/vCCZeIvNKxoFhAoHBAIefDR+JYZGOfMGHzz7mvmPRixd5D1sWESropi5h +vhM2M0hCtrZP+bbbWn2LvsYm4yDclaQF+iKfDp8yX8sYzrgYW3w3TlR/ONeBqYnD +FbRiMKsJbPeOp4h+RaBnsHv7RxLthcEC+vAU5fZjlDjgp2ee834p36tVsNxJGdzv +lPch5Vft3aQ9KokkI+NfHesZf3R2fN5w+2dJ0dt1UbECrPq4HKa7AGRfz67MXQUw +A5UpEG5MnONGwhGvqh9sHmvR4w== +-----END PRIVATE KEY----- diff --git a/testdata/rsa.conf b/testdata/rsa.conf new file mode 100644 index 0000000..be9d289 --- /dev/null +++ b/testdata/rsa.conf @@ -0,0 +1,6 @@ +[gotp] +private_key = testdata/rsa + +[gotp "issuer"] +test = ABCD +email-domain = XYZ diff --git a/testdata/rsa.pub b/testdata/rsa.pub new file mode 100644 index 0000000..bf1e99d --- /dev/null +++ b/testdata/rsa.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCuU+71RwY0BVtBgcePPz4d/cvig0tOgMzM4FyQ03cu2pe7CiewjUKGF5PCRp7buN07H1UnVp/SSAY0PlEmFrZ9qTCjw0Jy3FQxkcew/ia6KUMZ/qNsS3+fgedpuAXEWuddFC2fri3zRw/WIo5kWcfpv93ILP3lvZKdezOPvowYMCT6T9Wkd0n6+5gPFCgUlz/Qb9K5+jui5YyWO/r+8+3tBMkpKSWqklUwIRD7lFsyksf6DLWjEtpSkqBQJLl/RyQ6fvroQAziKR/7mp2Z8z3VWcm1iTGzEibSXFSqUOAeTrwkK4MzOwQbcZ1AX5kPQGdhwZnhB4NG3RyuSEj5slbS3AiKa7u7jPDmAotove16wt25jcfIacmlTr5f61w7YgJ//7+av4yOX1DW4FhnUwwBQfhmLAmacymRCOEdNjRP28xAVE9SKFO5mEExmTUt+1GTP6lAesRcnVu6961OI1poTRkG/Hife1hWaD5uLm19v/gORlwlnmLAVMgcSeL4ZQ8= ms@inspiro |
