aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--LICENSE27
-rw-r--r--Makefile7
-rw-r--r--README.adoc100
-rw-r--r--cli.go305
-rw-r--r--cli_test.go112
-rw-r--r--cmd/gotp/main.go178
-rw-r--r--config.go126
-rw-r--r--config_test.go43
-rw-r--r--go.mod15
-rw-r--r--go.sum17
-rw-r--r--gotp.go46
-rw-r--r--issuer.go146
-rw-r--r--provider_aegis.go58
-rw-r--r--testdata/ed255197
-rw-r--r--testdata/ed25519.conf6
-rw-r--r--testdata/ed25519.pub1
-rw-r--r--testdata/rsa40
-rw-r--r--testdata/rsa.conf6
-rw-r--r--testdata/rsa.pub1
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
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..beeb500
--- /dev/null
+++ b/LICENSE
@@ -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
diff --git a/cli.go b/cli.go
new file mode 100644
index 0000000..a7b83cc
--- /dev/null
+++ b/cli.go
@@ -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)
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..adc287a
--- /dev/null
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..8f1b5ba
--- /dev/null
+++ b/go.sum
@@ -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=
diff --git a/gotp.go b/gotp.go
new file mode 100644
index 0000000..76676a2
--- /dev/null
+++ b/gotp.go
@@ -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-----
+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
+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