From ae7f55bf2fb99ad9851e82f397ec359090c8373f Mon Sep 17 00:00:00 2001 From: tjblackheart Date: Wed, 29 Jul 2026 08:09:28 +0200 Subject: [PATCH] feat: reject plaintext vaults --- cmd/andcli/main.go | 4 +-- internal/vaults/aegis/aegis.go | 16 +++++++++ internal/vaults/aegis/aegis_test.go | 22 +++++++----- .../testdata/aegis-export-test-plain.json | 13 +++++++ internal/vaults/andotp/andotp.go | 15 ++++++-- internal/vaults/andotp/andotp_test.go | 20 +++++++---- .../andotp/testdata/andotp_test_plain.json | 15 ++++++++ internal/vaults/keepass/keepass.go | 4 +++ internal/vaults/protonpass/protonpass.go | 19 ++++++++--- internal/vaults/protonpass/protonpass_test.go | 26 +++++++++----- .../testdata/protonpass-test-plain.csv | 1 + .../testdata/protonpass-test-plain.zip | Bin 0 -> 296 bytes internal/vaults/stratum/stratum.go | 7 ++++ internal/vaults/stratum/stratum_test.go | 22 +++++++----- .../backup-andcli-test-plain.stratum.txt | 1 + .../twofas/testdata/twofas-export-plain.2fas | 32 ++++++++++++++++++ internal/vaults/twofas/twofas.go | 12 +++++++ internal/vaults/twofas/twofas_test.go | 22 +++++++----- internal/vaults/vault.go | 12 +++++-- 19 files changed, 210 insertions(+), 53 deletions(-) create mode 100644 internal/vaults/aegis/testdata/aegis-export-test-plain.json create mode 100644 internal/vaults/andotp/testdata/andotp_test_plain.json create mode 100644 internal/vaults/protonpass/testdata/protonpass-test-plain.csv create mode 100644 internal/vaults/protonpass/testdata/protonpass-test-plain.zip create mode 100644 internal/vaults/stratum/testdata/backup-andcli-test-plain.stratum.txt create mode 100644 internal/vaults/twofas/testdata/twofas-export-plain.2fas diff --git a/cmd/andcli/main.go b/cmd/andcli/main.go index 16df024..b70e0cc 100644 --- a/cmd/andcli/main.go +++ b/cmd/andcli/main.go @@ -9,7 +9,6 @@ import ( tea "charm.land/bubbletea/v2" - "github.com/tjblackheart/andcli/v2/internal/buildinfo" "github.com/tjblackheart/andcli/v2/internal/config" "github.com/tjblackheart/andcli/v2/internal/input" "github.com/tjblackheart/andcli/v2/internal/model" @@ -24,7 +23,6 @@ import ( func main() { log.SetFlags(0) - log.SetPrefix(fmt.Sprintf("%s: ", buildinfo.AppName)) cfg, err := config.Create() if err != nil { @@ -33,7 +31,7 @@ func main() { vault, err := open(cfg) if err != nil { - log.Fatalln(err) + log.Fatalf("Error reading file: %s\n", err) } entries := vault.Entries() diff --git a/internal/vaults/aegis/aegis.go b/internal/vaults/aegis/aegis.go index 27c39b9..8f48266 100644 --- a/internal/vaults/aegis/aegis.go +++ b/internal/vaults/aegis/aegis.go @@ -66,6 +66,10 @@ func Open(filename string, pass []byte) (vaults.Vault, error) { 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) } @@ -109,6 +113,18 @@ func (v aegis) Entries() []vaults.Entry { return entries } +func (v aegis) IsPlain(b []byte) bool { + var db struct { + DB json.RawMessage `json:"db"` + } + + json.Unmarshal(b, &db) // intentional skip err check + if len(db.DB) > 0 && db.DB[0] != '"' { + return true + } + return false +} + func (v aegis) masterKeyFromPass(password []byte) ([]byte, error) { var salt, keyNonce, keyTag, key, derivedKey []byte var err error diff --git a/internal/vaults/aegis/aegis_test.go b/internal/vaults/aegis/aegis_test.go index c30a793..33047f5 100644 --- a/internal/vaults/aegis/aegis_test.go +++ b/internal/vaults/aegis/aegis_test.go @@ -1,6 +1,7 @@ package aegis import ( + "errors" "reflect" "testing" @@ -13,21 +14,26 @@ func TestOpen(t *testing.T) { filename string password string fails bool + wantErr error }{ - {"decrypts", "testdata/aegis-export-test.json", "andcli-test", false}, - {"fails: wrong password", "testdata/aegis-export-test.json", "invalid", true}, - {"fails: invalid file", "testdata/aegis-invalid-file.json", "invalid", true}, + {"decrypts", "testdata/aegis-export-test.json", "andcli-test", false, nil}, + {"fails: wrong password", "testdata/aegis-export-test.json", "invalid", true, nil}, + {"fails: invalid file", "testdata/aegis-invalid-file.json", "invalid", true, nil}, + {"fails: plaintext vault", "testdata/aegis-export-test-plain.json", "", true, vaults.ErrIsPlain}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { v, err := Open(tt.filename, []byte(tt.password)) - if tt.fails { - if err == nil { - t.Fatal("Open() expected error, got none") - } - return + if tt.fails { + if err == nil { + t.Fatal("Open() expected error, got none") } + if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { + t.Fatalf("Open() error = %v, want %v", err, tt.wantErr) + } + return + } entries := v.Entries() if len(entries) != 1 { diff --git a/internal/vaults/aegis/testdata/aegis-export-test-plain.json b/internal/vaults/aegis/testdata/aegis-export-test-plain.json new file mode 100644 index 0000000..83a63a8 --- /dev/null +++ b/internal/vaults/aegis/testdata/aegis-export-test-plain.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "header": { + "slots": null, + "params": null + }, + "db": { + "version": 3, + "entries": [], + "groups": [], + "icons_optimized": true + } +} diff --git a/internal/vaults/andotp/andotp.go b/internal/vaults/andotp/andotp.go index 49161fc..e27b852 100644 --- a/internal/vaults/andotp/andotp.go +++ b/internal/vaults/andotp/andotp.go @@ -38,17 +38,22 @@ func Open(filename string, pass []byte) (vaults.Vault, error) { return nil, fmt.Errorf("%s: %w", vaultType, err) } + v := &andotp{entries: make([]entry, 0)} + + if v.IsPlain(b) { + return nil, vaults.ErrIsPlain + } + b, err = gao.Decrypt(b, string(pass)) if err != nil { return nil, fmt.Errorf("%s: %w", vaultType, err) } - entries := make([]entry, 0) - if err := json.Unmarshal(b, &entries); err != nil { + if err := json.Unmarshal(b, &v.entries); err != nil { return nil, fmt.Errorf("%s: %w", vaultType, err) } - return &andotp{entries}, nil + return v, nil } func (v andotp) Entries() []vaults.Entry { @@ -73,3 +78,7 @@ func (v andotp) Entries() []vaults.Entry { return entries } + +func (v andotp) IsPlain(b []byte) bool { + return b[0] == '[' +} diff --git a/internal/vaults/andotp/andotp_test.go b/internal/vaults/andotp/andotp_test.go index bfb7c19..dc840ac 100644 --- a/internal/vaults/andotp/andotp_test.go +++ b/internal/vaults/andotp/andotp_test.go @@ -1,6 +1,7 @@ package andotp import ( + "errors" "reflect" "testing" @@ -13,20 +14,25 @@ func TestOpen(t *testing.T) { filename string password string fails bool + wantErr error }{ - {"decrypts", "testdata/andotp_test.json.aes", "andcli-test", false}, - {"fails: wrong password", "testdata/andotp_test.json.aes", "invalid", true}, + {"decrypts", "testdata/andotp_test.json.aes", "andcli-test", false, nil}, + {"fails: wrong password", "testdata/andotp_test.json.aes", "invalid", true, nil}, + {"fails: plaintext vault", "testdata/andotp_test_plain.json", "", true, vaults.ErrIsPlain}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { v, err := Open(tt.filename, []byte(tt.password)) - if tt.fails { - if err == nil { - t.Fatal("Open() expected error, got none") - } - return + if tt.fails { + if err == nil { + t.Fatal("Open() expected error, got none") } + if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { + t.Fatalf("Open() error = %v, want %v", err, tt.wantErr) + } + return + } entries := v.Entries() if len(entries) != 1 { diff --git a/internal/vaults/andotp/testdata/andotp_test_plain.json b/internal/vaults/andotp/testdata/andotp_test_plain.json new file mode 100644 index 0000000..6f4b2dd --- /dev/null +++ b/internal/vaults/andotp/testdata/andotp_test_plain.json @@ -0,0 +1,15 @@ +[ + { + "secret": "ZLM4YMN5NTRNG7KHM45PYKSVUROQZQ5CFA5OH6AQ5TMWOBENLSTA====", + "issuer": "otp.nwo.dev", + "label": "andcli-test", + "digits": 6, + "type": "TOTP", + "algorithm": "SHA256", + "thumbnail": "Default", + "last_used": 1667481235360, + "used_frequency": 0, + "period": 30, + "tags": [] + } +] diff --git a/internal/vaults/keepass/keepass.go b/internal/vaults/keepass/keepass.go index 12f6aed..98b8e03 100644 --- a/internal/vaults/keepass/keepass.go +++ b/internal/vaults/keepass/keepass.go @@ -83,6 +83,10 @@ func (v keepass) Entries() []vaults.Entry { return entries } +func (v keepass) IsPlain(b []byte) bool { + return false // not sure if you even can export an unecrypted kdbx. +} + func parseGroups(groups []gokeepasslib.Group) []gokeepasslib.Entry { entries := make([]gokeepasslib.Entry, 0) for _, group := range groups { diff --git a/internal/vaults/protonpass/protonpass.go b/internal/vaults/protonpass/protonpass.go index c323273..0f53e11 100644 --- a/internal/vaults/protonpass/protonpass.go +++ b/internal/vaults/protonpass/protonpass.go @@ -19,7 +19,10 @@ import ( const vaultType = vaults.PROTON -var _ vaults.Vault = &envelope{} +var ( + _ vaults.Vault = &envelope{} + zipHeader = []byte{0x50, 0x4b, 0x03, 0x04} +) type ( envelope struct{ Vaults map[string]proton } @@ -46,6 +49,11 @@ func Open(filename string, pass []byte) (vaults.Vault, error) { return nil, fmt.Errorf("%s: %s", vaultType, err) } + var e envelope + if e.IsPlain(b) { + return nil, vaults.ErrIsPlain + } + hnd, err := crypto.PGP().Decryption().Password(pass).New() if err != nil { return nil, fmt.Errorf("%s: %s", vaultType, err) @@ -56,7 +64,6 @@ func Open(filename string, pass []byte) (vaults.Vault, error) { return nil, fmt.Errorf("%s: %s", vaultType, err) } - var e envelope if err := json.Unmarshal(result.Bytes(), &e); err != nil { return nil, fmt.Errorf("%s: %s", vaultType, err) } @@ -103,10 +110,12 @@ func (e envelope) Entries() []vaults.Entry { return entries } +func (e envelope) IsPlain(b []byte) bool { + return b[0] == '{' || string(b[:4]) == "type" +} + // opens, reads and returns file content, handles zip if necessary. func read(filename string) ([]byte, error) { - sig := []byte{0x50, 0x4b, 0x03, 0x04} - f, err := os.Open(filename) if err != nil { return nil, err @@ -119,7 +128,7 @@ func read(filename string) ([]byte, error) { } // not a zip file - if !bytes.Equal(head, sig) { + if !bytes.Equal(head, zipHeader) { return os.ReadFile(filename) } diff --git a/internal/vaults/protonpass/protonpass_test.go b/internal/vaults/protonpass/protonpass_test.go index 5e018e0..022fb3c 100644 --- a/internal/vaults/protonpass/protonpass_test.go +++ b/internal/vaults/protonpass/protonpass_test.go @@ -1,8 +1,11 @@ package protonpass import ( + "errors" "fmt" "testing" + + "github.com/tjblackheart/andcli/v2/internal/vaults" ) func TestMain(m *testing.M) { @@ -15,22 +18,27 @@ func TestOpen(t *testing.T) { filename string password string fails bool + wantErr error }{ - {"decrypts text", "testdata/protonpass-test.pgp", "andcli-test", false}, - {"decrypts zip", "testdata/protonpass-test.pgp.zip", "andcli-test", false}, - {"decrypts hidden zip", "testdata/protonpass-test.pgp.data", "andcli-test", false}, - {"fails: wrong password", "testdata/protonpass-test.pgp", "", true}, + {"decrypts text", "testdata/protonpass-test.pgp", "andcli-test", false, nil}, + {"decrypts zip", "testdata/protonpass-test.pgp.zip", "andcli-test", false, nil}, + {"decrypts hidden zip", "testdata/protonpass-test.pgp.data", "andcli-test", false, nil}, + {"fails: wrong password", "testdata/protonpass-test.pgp", "", true, nil}, + {"fails: plaintext vault", "testdata/protonpass-test-plain.zip", "", true, vaults.ErrIsPlain}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { v, err := Open(tt.filename, []byte(tt.password)) - if tt.fails { - if err == nil { - t.Fatal("Open() expected error, got nil") - } - return + if tt.fails { + if err == nil { + t.Fatal("Open() expected error, got none") + } + if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { + t.Fatalf("Open() error = %v, want %v", err, tt.wantErr) } + return + } entries := v.Entries() if len(entries) != 3 { diff --git a/internal/vaults/protonpass/testdata/protonpass-test-plain.csv b/internal/vaults/protonpass/testdata/protonpass-test-plain.csv new file mode 100644 index 0000000..9f656ac --- /dev/null +++ b/internal/vaults/protonpass/testdata/protonpass-test-plain.csv @@ -0,0 +1 @@ +type,name,url,email,username,password,note,totp,createTime,modifyTime,vault diff --git a/internal/vaults/protonpass/testdata/protonpass-test-plain.zip b/internal/vaults/protonpass/testdata/protonpass-test-plain.zip new file mode 100644 index 0000000000000000000000000000000000000000..062b5457a5d84111e477cef26b09811a2c14cd0f GIT binary patch literal 296 zcmWIWW@gc4U|`^2c&GI@hAXvb+kYtFWDsFUNi0d!%PP*#V`LCuI0sY)0zD9l!MD$v z>#%``m| ziA$C(*?liYXjXv+g90bF{o6Bkb0r<(^kZh3_X+JO+ZuiHOwyv*$-MhG>JOZH^=0>( z2A=}OOD*ddBZIH<{Vd6Sw6tc2ebyw=eG|(1K1IK~{qYjtl1;xD0=yZSbeVB`1nOjl aEsY=+UT+0>v$BB{F#@4CkUkFLFaQ8T@o5VH literal 0 HcmV?d00001 diff --git a/internal/vaults/stratum/stratum.go b/internal/vaults/stratum/stratum.go index 9c57031..d6bac80 100644 --- a/internal/vaults/stratum/stratum.go +++ b/internal/vaults/stratum/stratum.go @@ -65,6 +65,9 @@ func Open(filename string, pass []byte) (vaults.Vault, error) { } v := &stratum{Authenticators: make([]entry, 0)} + if v.IsPlain(b) { + return nil, vaults.ErrIsPlain + } switch string(b[:len(HEADER)]) { case HEADER: @@ -125,6 +128,10 @@ func (v stratum) Entries() []vaults.Entry { return list } +func (v stratum) IsPlain(b []byte) bool { + return len(b) >= 7 && string(b[:7]) == "otpauth" +} + func (v stratum) decrypt(b, pass []byte) ([]byte, error) { salt := b[len(HEADER) : len(HEADER)+SALT_LENGTH] nonce := b[len(HEADER)+SALT_LENGTH : len(HEADER)+SALT_LENGTH+IV_LENGTH] diff --git a/internal/vaults/stratum/stratum_test.go b/internal/vaults/stratum/stratum_test.go index 008bbe3..000974f 100644 --- a/internal/vaults/stratum/stratum_test.go +++ b/internal/vaults/stratum/stratum_test.go @@ -1,6 +1,7 @@ package stratum import ( + "errors" "fmt" "reflect" "testing" @@ -18,21 +19,26 @@ func TestOpen(t *testing.T) { filename string password string fails bool + wantErr error }{ - {"decrypts", "testdata/backup-andcli-test.stratum", "andcli-test", false}, - {"fails: wrong password", "testdata/backup-andcli-test.stratum", "", true}, - {"fails: legacy", "testdata/backup-legacy-andcli-test.stratum", "", true}, + {"decrypts", "testdata/backup-andcli-test.stratum", "andcli-test", false, nil}, + {"fails: wrong password", "testdata/backup-andcli-test.stratum", "", true, nil}, + {"fails: legacy", "testdata/backup-legacy-andcli-test.stratum", "", true, nil}, + {"fails: plaintext vault", "testdata/backup-andcli-test-plain.stratum.txt", "", true, vaults.ErrIsPlain}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { v, err := Open(tt.filename, []byte(tt.password)) - if tt.fails { - if err == nil { - t.Fatal("Open() expected error, got none") - } - return + if tt.fails { + if err == nil { + t.Fatal("Open() expected error, got none") + } + if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { + t.Fatalf("Open() error = %v, want %v", err, tt.wantErr) } + return + } entries := v.Entries() if len(entries) != 3 { diff --git a/internal/vaults/stratum/testdata/backup-andcli-test-plain.stratum.txt b/internal/vaults/stratum/testdata/backup-andcli-test-plain.stratum.txt new file mode 100644 index 0000000..83aafe9 --- /dev/null +++ b/internal/vaults/stratum/testdata/backup-andcli-test-plain.stratum.txt @@ -0,0 +1 @@ +otpauth://totp/otp.provider.dev%3Atester?secret=abc123&issuer=otp.provider.dev diff --git a/internal/vaults/twofas/testdata/twofas-export-plain.2fas b/internal/vaults/twofas/testdata/twofas-export-plain.2fas new file mode 100644 index 0000000..22828a4 --- /dev/null +++ b/internal/vaults/twofas/testdata/twofas-export-plain.2fas @@ -0,0 +1,32 @@ +{ + "services": [ + { + "name": "otp.provider.dev", + "secret": "abc123", + "updatedAt": 1785299771447, + "otp": { + "link": "otpauth://totp/otp.provider.dev:tester?algorithm=SHA1&digits=6&issuer=otp.provider.dev&period=30&secret=abc123", + "label": "tester", + "account": "tester", + "issuer": "otp.provider.dev", + "digits": 6, + "period": 30, + "algorithm": "SHA1", + "tokenType": "TOTP", + "source": "Link" + }, + "order": { "position": 0 }, + "icon": { + "selected": "Label", + "label": { "text": "OT", "backgroundColor": "Orange" }, + "iconCollection": { "id": "a5b3fb65-4ec5-43e6-8ec1-49e24ca9e7ad" } + } + } + ], + "groups": [], + "updatedAt": 1785304029062, + "schemaVersion": 4, + "appVersionCode": 5000034, + "appVersionName": "5.5.3", + "appOrigin": "android" +} diff --git a/internal/vaults/twofas/twofas.go b/internal/vaults/twofas/twofas.go index 0ca831f..c96bf12 100644 --- a/internal/vaults/twofas/twofas.go +++ b/internal/vaults/twofas/twofas.go @@ -72,6 +72,10 @@ func Open(filename string, pass []byte) (vaults.Vault, error) { 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) } @@ -120,6 +124,14 @@ func (v twofas) Entries() []vaults.Entry { return entries } +func (v twofas) IsPlain(b []byte) bool { + var s struct { + ServicesEncrypted json.RawMessage `json:"servicesEncrypted"` + } + json.Unmarshal(b, &s) + return len(s.ServicesEncrypted) == 0 +} + func (v twofas) masterKeyFromPass(password []byte) ([]byte, error) { servicesEncrypted := strings.SplitN(v.ServicesEncrypted, ":", numFields+1) if len(servicesEncrypted) != numFields { diff --git a/internal/vaults/twofas/twofas_test.go b/internal/vaults/twofas/twofas_test.go index e56caf6..539c1bc 100644 --- a/internal/vaults/twofas/twofas_test.go +++ b/internal/vaults/twofas/twofas_test.go @@ -1,6 +1,7 @@ package twofas import ( + "errors" "reflect" "testing" @@ -13,21 +14,26 @@ func TestOpen(t *testing.T) { filename string password string fails bool + wantErr error }{ - {"decrypts", "testdata/twofas-export-test.2fas", "andcli-test", false}, - {"fails: wrong password", "testdata/twofas-export-test.2fas", "invalid", true}, - {"fails: invalid file", "testdata/twofas-invalid-file.2fas", "invalid", true}, + {"decrypts", "testdata/twofas-export-test.2fas", "andcli-test", false, nil}, + {"fails: wrong password", "testdata/twofas-export-test.2fas", "invalid", true, nil}, + {"fails: invalid file", "testdata/twofas-invalid-file.2fas", "invalid", true, nil}, + {"fails: plaintext vault", "testdata/twofas-export-plain.2fas", "", true, vaults.ErrIsPlain}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { v, err := Open(tt.filename, []byte(tt.password)) - if tt.fails { - if err == nil { - t.Fatal("Open() expected error, got none") - } - return + if tt.fails { + if err == nil { + t.Fatal("Open() expected error, got none") } + if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { + t.Fatalf("Open() error = %v, want %v", err, tt.wantErr) + } + return + } entries := v.Entries() if len(entries) != 1 { diff --git a/internal/vaults/vault.go b/internal/vaults/vault.go index 65cb329..d352a56 100644 --- a/internal/vaults/vault.go +++ b/internal/vaults/vault.go @@ -1,15 +1,23 @@ package vaults -import "strings" +import ( + "errors" + "strings" +) // Vault is the basic skeleton of a vault implementation. -type Vault interface{ Entries() []Entry } +type Vault interface { + Entries() []Entry + IsPlain([]byte) bool +} // Type is an implemented vault type name. type Type string func (t Type) String() string { return string(t) } +var ErrIsPlain error = errors.New("unencrypted vaults are not supported") + const ( ANDOTP Type = "andotp" AEGIS Type = "aegis"