feat(store): accounts CRUD with encrypted password column
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||
)
|
||||
|
||||
var ErrAccountNotFound = errors.New("account not found")
|
||||
|
||||
// Account is the decrypted, in-memory view of a configured account.
|
||||
type Account struct {
|
||||
ID int64
|
||||
Name string
|
||||
Mode string // RO | RW
|
||||
IMAPHost string
|
||||
IMAPPort int
|
||||
IMAPSecurity string // tls | starttls
|
||||
AuthType string // password | oauth2
|
||||
Username string
|
||||
Password string // decrypted; empty in ListAccounts
|
||||
WhitelistInEnabled bool
|
||||
WhitelistOutEnabled bool
|
||||
SubjectRegex string
|
||||
ProcessBacklog bool
|
||||
}
|
||||
|
||||
func (s *Store) AddAccount(a Account) (int64, error) {
|
||||
var encPw []byte
|
||||
if a.Password != "" {
|
||||
b, err := crypto.Seal(s.key, []byte(a.Password))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
encPw = b
|
||||
}
|
||||
res, err := s.db.Exec(`
|
||||
INSERT INTO accounts
|
||||
(name,mode,imap_host,imap_port,imap_security,auth_type,username,
|
||||
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity, a.AuthType, a.Username,
|
||||
encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
|
||||
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert account: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (s *Store) GetAccount(name string) (Account, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id,name,mode,imap_host,imap_port,imap_security,auth_type,username,
|
||||
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
|
||||
FROM accounts WHERE name = ?`, name)
|
||||
a, encPw, err := scanAccount(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Account{}, ErrAccountNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if len(encPw) > 0 {
|
||||
pw, err := crypto.Open(s.key, encPw)
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("decrypt password (wrong EMCLI_KEY?): %w", err)
|
||||
}
|
||||
a.Password = string(pw)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAccounts() ([]Account, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id,name,mode,imap_host,imap_port,imap_security,auth_type,username,
|
||||
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
|
||||
FROM accounts ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Account
|
||||
for rows.Next() {
|
||||
a, _, err := scanAccount(rows) // secrets discarded
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAccount(name string) error {
|
||||
res, err := s.db.Exec("DELETE FROM accounts WHERE name = ?", name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrAccountNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanner is satisfied by *sql.Row and *sql.Rows.
|
||||
type scanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanAccount(sc scanner) (Account, []byte, error) {
|
||||
var (
|
||||
a Account
|
||||
encPw []byte
|
||||
subj sql.NullString
|
||||
wlIn, wlOut int
|
||||
backlog int
|
||||
)
|
||||
err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity,
|
||||
&a.AuthType, &a.Username, &encPw, &wlIn, &wlOut, &subj, &backlog)
|
||||
if err != nil {
|
||||
return Account{}, nil, err
|
||||
}
|
||||
a.WhitelistInEnabled = wlIn != 0
|
||||
a.WhitelistOutEnabled = wlOut != 0
|
||||
a.ProcessBacklog = backlog != 0
|
||||
a.SubjectRegex = subj.String
|
||||
return a, encPw, nil
|
||||
}
|
||||
|
||||
func b2i(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func nullStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func sampleAccount() Account {
|
||||
return Account{
|
||||
Name: "work", Mode: "RO",
|
||||
IMAPHost: "imap.example.com", IMAPPort: 993, IMAPSecurity: "tls",
|
||||
AuthType: "password", Username: "me@example.com",
|
||||
Password: "s3cr3t", SubjectRegex: "",
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddGetAccountDecryptsSecret(t *testing.T) {
|
||||
s := openTemp(t)
|
||||
id, err := s.AddAccount(sampleAccount())
|
||||
if err != nil {
|
||||
t.Fatalf("AddAccount: %v", err)
|
||||
}
|
||||
if id == 0 {
|
||||
t.Fatal("want non-zero id")
|
||||
}
|
||||
got, err := s.GetAccount("work")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount: %v", err)
|
||||
}
|
||||
if got.Password != "s3cr3t" {
|
||||
t.Fatalf("password not decrypted: %q", got.Password)
|
||||
}
|
||||
if got.Mode != "RO" || got.IMAPPort != 993 {
|
||||
t.Fatalf("fields wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordStoredEncrypted(t *testing.T) {
|
||||
s := openTemp(t)
|
||||
_, _ = s.AddAccount(sampleAccount())
|
||||
var blob []byte
|
||||
if err := s.db.QueryRow("SELECT enc_password FROM accounts WHERE name='work'").Scan(&blob); err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if string(blob) == "s3cr3t" || len(blob) == 0 {
|
||||
t.Fatalf("password not encrypted at rest: %q", blob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAccountNotFound(t *testing.T) {
|
||||
s := openTemp(t)
|
||||
if _, err := s.GetAccount("nope"); !errors.Is(err, ErrAccountNotFound) {
|
||||
t.Fatalf("want ErrAccountNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAccountsOmitsSecrets(t *testing.T) {
|
||||
s := openTemp(t)
|
||||
_, _ = s.AddAccount(sampleAccount())
|
||||
list, err := s.ListAccounts()
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list: %v len=%d", err, len(list))
|
||||
}
|
||||
if list[0].Password != "" {
|
||||
t.Fatal("ListAccounts must not return secrets")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user