Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ andcli can handle input from the following providers (if they provide more than
- [Stratum / Authenticator Pro](https://stratumauth.com)
- [Keepass](https://www.keepassdx.com/) or anything else that exports \*.kdbx v2
- [ProtonPass](https://proton.me/pass) in \*.pgp and \*.zip format
- [Proton Authenticator](https://proton.me/authenticator)

At the moment only TOTP entries are supported.

Expand Down Expand Up @@ -75,7 +76,7 @@ Options:
-q, --query string Query the vault directly and skip TUI functionality
--session-timeout int Auto-close after N seconds of inactivity (0=disabled) (default 300)
--timeout int Timeout for decrypting the vault file, in seconds (default 5)
-t, --type string Vault type (andotp, aegis, twofas, stratum, keepass, proton)
-t, --type string Vault type (aegis, andotp, keepass, proton, protonauth, stratum, twofas)
-v, --version Prints version info and exits
```

Expand Down
1 change: 1 addition & 0 deletions cmd/andcli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
_ "github.com/tjblackheart/andcli/v2/internal/vaults/aegis"
_ "github.com/tjblackheart/andcli/v2/internal/vaults/andotp"
_ "github.com/tjblackheart/andcli/v2/internal/vaults/keepass"
_ "github.com/tjblackheart/andcli/v2/internal/vaults/protonauth"
_ "github.com/tjblackheart/andcli/v2/internal/vaults/protonpass"
_ "github.com/tjblackheart/andcli/v2/internal/vaults/stratum"
_ "github.com/tjblackheart/andcli/v2/internal/vaults/twofas"
Expand Down
166 changes: 166 additions & 0 deletions internal/vaults/protonauth/protonauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package protonauth

import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"net/url"
"os"
"strconv"
"strings"

"github.com/tjblackheart/andcli/v2/internal/vaults"
"golang.org/x/crypto/argon2"
)

const (
// Argon2 / AES constants taken from Proton Authenticator.
// See github.com/protonpass/proton-pass-common
// and github.com/protonpass/android-authenticator
m_cost = 19 * 1024
t_cost = 2
p_cost = 1
keySize = 32
saltSize = 16
ivSize = 12
tagSize = 16
additional = "proton.authenticator.export.v1"
vaultType = vaults.PROTON_AUTH
)

var _ vaults.Vault = &protonAuthenticator{}

type (
protonAuthenticator struct {
Version int
Salt string
Content string
//
db db
}

db struct{ Entries []entry }

entry struct {
ID string
Content content
Note string
}

content struct {
URI string
Type string `json:"entry_type"`
Name string
}
)

func init() { vaults.Register(vaultType, Open) }

func Open(filename string, pass []byte) (vaults.Vault, error) {
var v protonAuthenticator

b, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("%s: %w", vaultType, err)
}

if v.IsPlain(b) {
return nil, vaults.ErrIsPlain
}

if err := json.Unmarshal(b, &v); err != nil {
return nil, fmt.Errorf("%s: %w", vaultType, err)
}

if v.Version != 1 {
return nil, fmt.Errorf("%s: unsupported version", vaultType)
}

db, err := v.decrypt(pass)
if err != nil {
return nil, fmt.Errorf("%s: %w", vaultType, err)
}

if err := json.Unmarshal(db, &v.db); err != nil {
return nil, fmt.Errorf("%s: %w", vaultType, err)
}

return &v, nil
}

func (v *protonAuthenticator) IsPlain(b []byte) bool {
return !strings.Contains(string(b), "salt")
}

func (v *protonAuthenticator) Entries() []vaults.Entry {
entries := make([]vaults.Entry, 0)

for _, e := range v.db.Entries {
tokenType := strings.ToUpper(e.Content.Type)
if tokenType != "TOTP" {
continue
}

u, err := url.Parse(e.Content.URI)
if err != nil {
log.Printf("%q: %s", e.Content.Name, err)
continue
}

values := u.Query()
digits, _ := strconv.Atoi(values.Get("digits"))
period, _ := strconv.Atoi(values.Get("period"))

entry := vaults.Entry{
Secret: values.Get("secret"),
Issuer: values.Get("issuer"),
Label: e.Content.Name,
Digits: digits,
Type: tokenType,
Algorithm: values.Get("algorithm"),
Period: period,
}

if err := entry.SanitizeAndValidate(); err == nil {
entries = append(entries, entry)
}
}

return entries
}

func (v *protonAuthenticator) decrypt(pass []byte) ([]byte, error) {
salt, err := base64.StdEncoding.DecodeString(v.Salt)
if err != nil {
return nil, err
}

key := argon2.IDKey(pass, salt, t_cost, m_cost, p_cost, keySize)

content, err := base64.StdEncoding.DecodeString(v.Content)
if err != nil {
return nil, err
}

if len(content) < ivSize+tagSize {
return nil, errors.New("unexpected content length")
}

block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}

gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}

iv, ct := content[:ivSize], content[ivSize:]

return gcm.Open(nil, iv, ct, []byte(additional))
}
46 changes: 46 additions & 0 deletions internal/vaults/protonauth/protonauth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package protonauth_test

import (
"fmt"
"testing"

"github.com/tjblackheart/andcli/v2/internal/vaults/protonauth"
)

func TestOpen(t *testing.T) {
tests := []struct {
name string
filename string
password string
fails bool
}{
{"decrypts", "testdata/protonauth-test.json", "andcli-test", false},
{"rejects wrong password", "testdata/protonauth-test.json", "invalid", true},
{"rejects plain data", "testdata/protonauth-test-plain.json", "", true},
{"rejects invalid version", "testdata/protonauth-test-noversion.json", "", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v, err := protonauth.Open(tt.filename, []byte(tt.password))
if tt.fails {
if err == nil {
t.Fatal("Open() expected error, got none")
}
return
}

entries := v.Entries()
if len(entries) != 3 {
t.Fatalf("Open() expected len to be 3, have %v", len(entries))
}

for i := range 3 {
want := fmt.Sprintf("demo%d", i+1)
if entries[i].Label != want {
t.Fatalf("Open() have %v, %s", entries[i].Label, want)
}
}
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"entries": [],
"salt": "abcdef",
"content": ""
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"version": 1,
"entries": []
}
5 changes: 5 additions & 0 deletions internal/vaults/protonauth/testdata/protonauth-test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"version": 1,
"salt": "CFJqJoofEK8G51uoldUJUg==",
"content": "ObvHbJshsPA04X+OO5Ch1Qf/rRdvqqzH4iUsImMX+30F+JzSOdYzZBVOASRTRRFmQ1KX3i2kWX0ygn+ViNCF5AiNjxywslQdZwArdluoKP2sOOVFBbvYBpbASHV43uBl3uvM0umwRmmyTT6ER91yC9o6iiDf6OizgFy3RDX/nuF4X/bqWFIiPgUPL8YiQQ+ElGp7E+cI7776c6zauTEZV1Xi9GF/gRZIeNg2jtwdvlWQ6UmrDYe/1PZrlnBxCmKudyPwwE//Kz2/KYHhMNZR2hC5wniV7sMqqsVWssgwQK8Jvjc+AxStArrLvhWPHpDKrCgLFagQua0uIBG2t6bu1ct05ZbtGUmFh0x7XwpXIe88IrkNjn6GbF49tq4Ln4axrpNiFj95IJXooZyJTMiFS2vmiza5gwSoUtJfjCOlXEuA/eziKYRLqPCa1CzG8zHogKDwj7oxadnHnpajVPsQfmwKBNseRtVzucpKl5bsJDvwz/LQsCh6PpgTlarfDxrC6ddvhkwcppdoFB3fHfT/6Og7JIDISnPhHT0ze7c+qY9XndH14jqBk7Ci6050wgrJf0zZfDepmqDpMMkGhIBr3QhKfSymHpIWOhhuLxAxgvhzOOF8ssqKFmijjx9kXDYqDZr1xfRKSY+LHRxDnA7EQ45yjfqZ6gsi7dAslnqUFvWLjkC9zYNSzv1MTCb7ROkbc84isSQrPgN/SJ/eRYjvKVjdBIRPUtzglpbAq1C0DBnDk1CzUUQYvW9iiXPSDTA8EU+BqPIi4TGlDXhy8Tlqgg/wT5REJReI9FQzkCwEjtjZsuGLCwawAKPJnPNCUrqGGJF//PdV4Lq1yIJrw8SOjXfSfBIf3bK3sHQOBYmsE9rlJy8gRssHWs3b6xx982rhHBAhGbqZqUjV9p8HtMI6VfOixuFJyQoQ83LjRYbvp3p60W3jDPHEtPWx6Y2ydjx9bbOac8limVxknMhrQaZk6kWsAYXorlkUrr5xI78EYuwHtLrfgJiO37GUXGk/j9s63mZ451i2LDV+dSzx4vhriak2Kzp611cuxKWYbylWAPQbfVaHsVFXUnk="
}
13 changes: 7 additions & 6 deletions internal/vaults/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import (
)

const (
ANDOTP VaultType = "andotp"
AEGIS VaultType = "aegis"
TWOFAS VaultType = "twofas"
STRATUM VaultType = "stratum"
KEEPASS VaultType = "keepass"
PROTON VaultType = "proton"
ANDOTP VaultType = "andotp"
AEGIS VaultType = "aegis"
TWOFAS VaultType = "twofas"
STRATUM VaultType = "stratum"
KEEPASS VaultType = "keepass"
PROTON VaultType = "proton"
PROTON_AUTH VaultType = "protonauth"
)

type (
Expand Down
Loading