Skip to content

Commit

Permalink
feat: support gen TOTP
Browse files Browse the repository at this point in the history
  • Loading branch information
jimyag committed May 31, 2024
0 parents commit c4563e9
Show file tree
Hide file tree
Showing 14 changed files with 547 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: 2
updates:
- package-ecosystem: docker
directory: "/"
schedule:
interval: daily
time: "21:00"
open-pull-requests-limit: 10
42 changes: 42 additions & 0 deletions .github/workflows/realease.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
on:
push:
tags:
- v*

jobs:
releases-matrix:
name: Release Go Binary
runs-on: ubuntu-latest
permissions:
contents: write
strategy:
max-parallel: 1
matrix:
include:
- goarch: amd64
goos: darwin
- goarch: arm64
goos: darwin
- goarch: amd64
goos: linux
- goarch: arm64
goos: linux
- goarch: amd64
goos: windows
steps:
- name: Show environment
run: export
- uses: actions/checkout@v3
- uses: ncipollo/release-action@v1
with:
allowUpdates: true
token: ${{ secrets.GITHUB_TOKEN }}
- uses: wangyoucao577/go-release-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
build_command: "make"
binary_name: "2fa"
extra_files: 2fa

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2fa
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
build:
go build -o 2fa main.go
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@

# 2fa

2fa is a two-factor authentication command line tool.

## Install

```bash
go get github.com/jimyag/2fa@latest
```

or download the binary from <https://github.com/jimyag/2fa/releases>

## Usage

### Add

Add a 2fa key

`2fa add <name> <key>`

```bash
2fa add jimyag 4LDRN6EUDSF3RNV7
```

### Get

Get a 2fa key and copy to clipboard

`2fa get <name> [--copy/-c]`

```bash
2fa get jimyag
2fa get jimyag --copy
```

### List

List all 2fa keys and display in a table

`2fa list`

```bash
2fa list
+--------+--------+------------+-----------+
| NAME | TOTP | LIFETIME/S | NEXT TOTP |
+--------+--------+------------+-----------+
| jimyag | 056907 | 2 | 134552 |
+--------+--------+------------+-----------+

```

### Delete

Delete a 2fa key

`2fa del <name>`

```bash
2fa del jimyag
```
166 changes: 166 additions & 0 deletions cmd/2fa.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package cmd

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"os"
"strings"
)

const secretKey = "018fcf3d2bf3745ca38d0360d6816e68"

type Key struct {
Key string `json:"key"`
}
type TwoFactor struct {
Keys map[string]Key `json:"keys"`
cfgFile string `json:"-"`
}

func New() (*TwoFactor, error) {
tf := &TwoFactor{
Keys: map[string]Key{
"": {
Key: secretKey,
},
},
}
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("could not get home directory: %w", err)
}

configDir := fmt.Sprintf("%s/.config/2fa", homeDir)
if err = os.MkdirAll(configDir, 0755); err != nil {
return nil, fmt.Errorf("could not create config directory %s : %w", configDir, err)
}

tf.cfgFile = fmt.Sprintf("%s/.2fa.json", configDir)
_, err = os.Stat(tf.cfgFile)
if err != nil {
if os.IsNotExist(err) {
if err = tf.Write(); err != nil {
return nil, fmt.Errorf("could not write config file %s : %w", tf.cfgFile, err)
}
} else {
return nil, fmt.Errorf("could not stat config file %s : %w", tf.cfgFile, err)
}
}
err = tf.Load()
return tf, err

}

func (tf *TwoFactor) Add(name, key string) error {
key = strings.ToUpper(key)
tf.Keys[name] = Key{key}
return tf.Write()
}

func (tf *TwoFactor) List() map[string]Key {
list := make(map[string]Key)
for k, v := range tf.Keys {
list[k] = v
}
return list
}

func (tf *TwoFactor) Get(name string) string {
if key, ok := tf.Keys[name]; ok {
return key.Key
}
return ""
}

func (tf *TwoFactor) Remove(name string) error {
delete(tf.Keys, name)
return tf.Write()
}

func (tf *TwoFactor) Write() error {
file, err := os.OpenFile(tf.cfgFile, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
log.Fatalln("could not open config file", err)
return err
}
defer file.Close()
plaintext, err := json.Marshal(tf.Keys)
if err != nil {
return err
}

cipherText, err := tf.EncryptMessage(plaintext)
if err != nil {
return err
}
_, err = file.WriteString(cipherText)
return err
}

func (tf *TwoFactor) Load() error {
file, err := os.Open(tf.cfgFile)
if err != nil {
return err
}
defer file.Close()

cipherText, err := io.ReadAll(file)
if err != nil {
return err
}

data, err := tf.DecryptMessage(cipherText)
if err != nil {
fmt.Println(err)
return json.Unmarshal(cipherText, &tf.Keys)
}
return json.Unmarshal([]byte(data), &tf.Keys)
}

func (tf *TwoFactor) EncryptMessage(message []byte) (string, error) {
block, err := aes.NewCipher([]byte(secretKey))
if err != nil {
return "", fmt.Errorf("could not create new cipher: %v", err)
}

cipherText := make([]byte, aes.BlockSize+len(message))
iv := cipherText[:aes.BlockSize]
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
return "", fmt.Errorf("could not encrypt: %v", err)
}

stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(cipherText[aes.BlockSize:], message)

return base64.StdEncoding.EncodeToString(cipherText), nil
}

func (tf *TwoFactor) DecryptMessage(message []byte) (string, error) {
cipherText, err := base64.StdEncoding.DecodeString(string(message))
if err != nil {
return "", fmt.Errorf("could not base64 decode: %v", err)
}

block, err := aes.NewCipher([]byte(secretKey))
if err != nil {
return "", fmt.Errorf("could not create new cipher: %v", err)
}

if len(cipherText) < aes.BlockSize {
return "", fmt.Errorf("invalid ciphertext block size")
}

iv := cipherText[:aes.BlockSize]
cipherText = cipherText[aes.BlockSize:]

stream := cipher.NewCFBDecrypter(block, iv)
stream.XORKeyStream(cipherText, cipherText)

return string(cipherText), nil
}
23 changes: 23 additions & 0 deletions cmd/add.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package cmd

import (
"log"

"github.com/spf13/cobra"
)

var addCmd = &cobra.Command{
Use: "add",
Short: "2fa add <name> <key>",
Run: addRun,
}

func addRun(cmd *cobra.Command, args []string) {
if len(args) != 2 {
_ = cmd.Help()
return
}
if err := tfa.Add(args[0], args[1]); err != nil {
log.Fatalln("could not add key", err)
}
}
23 changes: 23 additions & 0 deletions cmd/del.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package cmd

import (
"log"

"github.com/spf13/cobra"
)

var delCmd = &cobra.Command{
Use: "del",
Short: "2fa del <name>",
Run: delRun,
}

func delRun(cmd *cobra.Command, args []string) {
if len(args) != 1 {
_ = cmd.Help()
return
}
if err := tfa.Remove(args[0]); err != nil {
log.Fatalln("could not remove key", err)
}
}
47 changes: 47 additions & 0 deletions cmd/get.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package cmd

import (
"fmt"
"log"
"time"

"github.com/atotto/clipboard"
"github.com/spf13/cobra"
)

var getCmd = &cobra.Command{
Use: "get",
Short: "2fa get <name>",
Run: getRun,
}

var copyToClipboard bool

func init() {
getCmd.Flags().BoolVarP(&copyToClipboard, "copy", "c", false, "copy to clipboard")
}

func getRun(cmd *cobra.Command, args []string) {
if len(args) != 1 {
_ = cmd.Help()
return
}
key := tfa.Get(args[0])
if key == "" {
log.Fatalln("could not find key")
}

code, err := GenTOTP(key, time.Now(), 6, 30)
if err != nil {
log.Fatalln("could not generate code", err)
}
if copyToClipboard {
if err = clipboard.WriteAll(code); err != nil {
log.Fatalln("could not copy to clipboard", err)
}
} else {
fmt.Println(code)

}

}
Loading

0 comments on commit c4563e9

Please sign in to comment.