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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,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 (aegis, andotp, keepass, proton, protonauth, stratum, twofas)
-t, --type string Vault type (aegis, andotp, ente, 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 @@ -17,6 +17,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/ente"
_ "github.com/tjblackheart/andcli/v2/internal/vaults/keepass"
_ "github.com/tjblackheart/andcli/v2/internal/vaults/protonauth"
_ "github.com/tjblackheart/andcli/v2/internal/vaults/protonpass"
Expand Down
142 changes: 142 additions & 0 deletions internal/vaults/ente/ente.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package ente

import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/url"
"os"
"strconv"
"strings"

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

const vaultType = vaults.ENTE

var _ vaults.Vault = &ente{}

type (
ente struct {
Version int
KdfParams params
EncryptedData string
EncryptionNonce string
//
plain []byte
}

params struct {
MemLimit uint32
OpsLimit uint32
Salt string
}
)

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

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

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)
}

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

return &v, nil
}

func (v *ente) IsPlain(b []byte) bool {
return len(b) == 0 || b[0] != '{' || !strings.Contains(string(b), "kdfParams")
}

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

for _, line := range strings.Split(string(v.plain), "\n") {
if line == "" {
continue
}

u, err := url.Parse(line)
if err != nil {
log.Printf("parse entry: %s", err)
continue
}

label := ""
parts := strings.SplitN(u.Path, ":", 2)
if len(parts) == 2 {
label = parts[1]
}

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: label,
Digits: digits,
Type: strings.ToUpper(u.Host),
Algorithm: values.Get("algorithm"),
Period: period,
}

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

return entries
}

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

time, mem := v.KdfParams.OpsLimit, v.KdfParams.MemLimit/1024
if mem < 1024 || time < 1 {
return nil, fmt.Errorf("invalid kdf params")
}
key := argon2.IDKey(pass, salt, time, mem, 1, 32)

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

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

dec, err := newStreamDecryptor(key, nonce)
if err != nil {
return nil, err
}

return dec.pull(data)
}
47 changes: 47 additions & 0 deletions internal/vaults/ente/ente_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package ente_test

import (
"fmt"
"testing"

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

// This test will take quite some time because of the ente crypto.
func TestOpen(t *testing.T) {
tests := []struct {
name string
filename string
password string
fails bool
}{
{"decrypts", "testdata/ente-auth-codes.json", "andcli-test", false},
{"rejects wrong password", "testdata/ente-auth-codes.json", "invalid", true},
{"rejects plain data", "testdata/ente-auth-codes-plain.txt", "", true},
{"rejects invalid version", "testdata/ente-noversion.json", "", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v, err := ente.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)
}
}
})
}
}
96 changes: 96 additions & 0 deletions internal/vaults/ente/stream.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package ente

import (
"crypto/subtle"
"encoding/binary"
"errors"

"golang.org/x/crypto/chacha20"
"golang.org/x/crypto/poly1305"
)

// This implementation is mostly taken from the ente CLI.
// See https://github.com/ente/ente/tree/main/cli/internal/crypto

const (
tagMessage = 0x00
tagFinal = 0x03
keySize = 32
headerSize = 24
overhead = 17 // 1 tag byte + 16 MAC bytes
)

var pad0 [16]byte

type decryptor struct {
k [keySize]byte
nonce [12]byte
}

func newStreamDecryptor(key, header []byte) (*decryptor, error) {
if len(key) != keySize || len(header) != headerSize {
return nil, errors.New("invalid key or header size")
}

k, err := chacha20.HChaCha20(key, header[:16])
if err != nil {
return nil, err
}

d := &decryptor{}
copy(d.k[:], k)

d.nonce[0] = 1 // counter starts at 1
copy(d.nonce[4:], header[16:])

return d, nil
}

func (d *decryptor) pull(cipher []byte) ([]byte, error) {
if len(cipher) < overhead {
return nil, errors.New("ciphertext too short")
}
mlen := len(cipher) - overhead

ch, err := chacha20.NewUnauthenticatedCipher(d.k[:], d.nonce[:])
if err != nil {
return nil, err
}

var block [64]byte
ch.XORKeyStream(block[:], block[:]) // keystream block #0

var polyKey [32]byte
copy(polyKey[:], block[:32])
mac := poly1305.New(&polyKey)

block = [64]byte{cipher[0]}
ch.XORKeyStream(block[:], block[:]) // keystream block #1

tag := block[0]
block[0] = cipher[0] // encrypted tag byte goes into the MAC

mac.Write(block[:]) // 64 bytes
mac.Write(cipher[1 : 1+mlen])
mac.Write(pad0[:mlen&0xf])

var slen [8]byte
binary.LittleEndian.PutUint64(slen[:], 0)
mac.Write(slen[:])

binary.LittleEndian.PutUint64(slen[:], 64+uint64(mlen))
mac.Write(slen[:])

if subtle.ConstantTimeCompare(mac.Sum(nil), cipher[1+mlen:]) != 1 {
return nil, errors.New("authentication failed")
}

if tag != tagFinal && tag != tagMessage {
return nil, errors.New("invalid tag")
}

plain := make([]byte, mlen)
ch.XORKeyStream(plain, cipher[1:1+mlen])

return plain, nil
}
2 changes: 2 additions & 0 deletions internal/vaults/ente/testdata/ente-auth-codes-plain.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
otpauth://totp/otp.provider.dev:demo1?secret=ABCDEF
otpauth://totp/otp.provider.dev:demo1?secret=CFEDAB
6 changes: 6 additions & 0 deletions internal/vaults/ente/testdata/ente-auth-codes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"version": 1,
"kdfParams": { "memLimit": 1073741824, "opsLimit": 4, "salt": "gqSpZsAWM00wLoD1xJaaSQ==" },
"encryptedData": "NhcGX74dlGN5oIHh4/NXBqDDXQ3BJYIkuWjgW6G8SM8Wrn4hukPd6cPvNt8UXKyauPceNA2nadwM5t8QTjfMa6bgQstrXNWKhJQAvMh1O7CC6ziQE2oxlZrgEE9zyGR7O+bj41Nv3F19m/ycATvi5SmTWN+W5bclC50S65ABXh4CLYMzzHtDT/+5fnTF866TuddB/MVheowGL+/jG1bwXadPi+OQut+2Re1gqucy2unxmVrqxx1ZWwNWoDczEx9DLCvu+BVtGER+Y4KvkVWz09sboAzIi+ouGDWqrpNcfJLQb6B7cLmqpcRfToodM5A+0vhdPoUUmb5uTG5myTRD7DBCCWplSTgZxXv4Y9crJwH1tJzqDMFQXA2sMbv69q9V/hTyzQvt1bZJs/qB4q0ivoTuXytOKff+TdvanDpjSRRAQl4Z/HodthirXLAJSSuCY+6BgCxOAvS4r7PPQMbq269rCgymTFMDc/tmN59HeYDJtNa5LJ5BaYSFMb06YnncuGCHKEUAU0YCIgGicaTr6MMCmQRO7pNn/mTz8YG/ta5mSglYaZb9+V5tMkFEX/fxuYagSKMAz4GFOXzghZKADh3x5byqx7NCF2qVJat6shLbBH/Cnj1AzGl/EMKRhLDtA42ffBb+9fEHwb6EDiT2bxLQWxVpKrJ7c8i24FOCXpUEbKNKlDiplkGXqtK2fxaKC/3SuviSvFc+houCQjhl+7XCprdRXdFYgt/QsXFKZ3vQB3BMvhWyZwK170JQxRHFSVCmxeAytu68P8gQigSL1d4BQRN2Swg8RTjPMb9u9xL+vMNdF+Yr5PXS08EbLmPIJ+TiyClJGKvQdd4T8Y8vgwsHRgBz/ytLDIzGTVelbxIzfKu83ZI0RJEKCYViZWUfvQ8pBS/YD1Vf3uhcjbVON2S+2ji1pf8tHFrVE8x8KxLlnbcy0jfK7ImVKanT16GbelE07QV7fMgpji+2HpeRJLIK5mLSuwWHlx+ASD3Up/BqbAElJ/gkUZYHhoNeoInmyD3LzUDtLBFfqmki4tQfm8OnpVygVQwMqVUTa7uvGWUL8Ma8YM0+qERLYmBv4BuTzSDtbvzz0iho0mvKbJvoZShJ1TskLWnSwUhWcvDkItNFg7FlxefO8OT+xT8rowOkVaFA2GN57l4fVcGjD1jjDC4DfrXAkiKVwQ7JDr0C7yKq3Y5+KJ2iHkx3zEBIO+5RwY+17W1po1ij/fS6x8uIcxhcJFtayrKRJ+HLbdjJMHUxJcRx5RgHeZPmktVt76yrznmTC1epfkBtM3bgSCbkOKz0A6w5lIoym5VrUWbokHx+SecxyBXXlUWDeJ9Ig1p8seIA+qbEqeJq9ET6b1MvlMoSJ61dB/U8mCDkAXHfgE9MvuBIRNEvG8o51+hn2KmWqEBD2R2ncPvLlP/23V4gAFNd3SC7licXc1JRHFcZke/NMMawAdm020gcyKEXkMt9+Lng7W+yc/7bIDHsqnUH0D7X/8ZmOOSbKJX/G6LBbUH/3BSPiv5SuBaaSwI=",
"encryptionNonce": "N67/wj82v5vTJnDihdnmhiJ+npFDlx2H"
}
1 change: 1 addition & 0 deletions internal/vaults/ente/testdata/ente-noversion.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "kdfParams": {}, "encryptedData": "", "encryptionNonce": "" }
1 change: 1 addition & 0 deletions internal/vaults/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const (
KEEPASS VaultType = "keepass"
PROTON VaultType = "proton"
PROTON_AUTH VaultType = "protonauth"
ENTE VaultType = "ente"
)

type (
Expand Down
4 changes: 4 additions & 0 deletions internal/vaults/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (

_ "github.com/tjblackheart/andcli/v2/internal/vaults/aegis"
_ "github.com/tjblackheart/andcli/v2/internal/vaults/andotp"
_ "github.com/tjblackheart/andcli/v2/internal/vaults/ente"
_ "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 Expand Up @@ -39,8 +41,10 @@ func TestTypes(t *testing.T) {
[]vaults.VaultType{
vaults.AEGIS,
vaults.ANDOTP,
vaults.ENTE,
vaults.KEEPASS,
vaults.PROTON,
vaults.PROTON_AUTH,
vaults.STRATUM,
vaults.TWOFAS,
},
Expand Down
Loading